From ab5f0638021c00863579fd48b796ed17c3ea115a Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Tue, 10 Oct 2023 04:45:30 +0200 Subject: [PATCH 01/31] added table properties for station, AND cal. statistics, color by statistics, but with error --- hat/visualisation.py | 541 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 541 insertions(+) create mode 100644 hat/visualisation.py diff --git a/hat/visualisation.py b/hat/visualisation.py new file mode 100644 index 0000000..0949967 --- /dev/null +++ b/hat/visualisation.py @@ -0,0 +1,541 @@ +""" +Python module for visualising geospatial content using jupyter notebook, +for both spatial and temporal, e.g. netcdf, vector, raster, with time series etc +""" + +import os +import json +import pandas as pd +import geopandas as gpd +from shapely.geometry import Point +import numpy as np +from ipywidgets import HBox, VBox, Layout, Label, Output, HTML +from ipyleaflet import Map, Marker, Rectangle, GeoJSON, Popup, AwesomeIcon, CircleMarker, LegendControl +import plotly.graph_objs as go +from hat.observations import read_station_metadata_file +from hat.hydrostats import run_analysis +from hat.filters import filter_timeseries +from IPython.core.display import display +from IPython.display import clear_output +import time +import xarray as xr +from typing import Dict, Union +from functools import partial +import matplotlib +import matplotlib.pyplot as plt +from ipyleaflet_legend import Legend + + +class IPyLeaflet: + """Visualization class for interactive map with IPyLeaflet and Plotly.""" + + def __init__(self, center_lat: float, center_lon: float): + # Initialize the map widget + self.map = Map(center=[center_lat, center_lon], zoom=5, layout=Layout(width='500px', height='500px')) + + pd.set_option('display.max_colwidth', None) + + # Initialize the output widget for time series plots and dataframe display + self.output_widget = Output() + self.df_output = Output() + + # Create the title label for the properties table + self.feature_properties_title = Label("", layout=Layout(font_weight='bold', margin='10px 0')) + + # Main layout: map and output widget on top, properties table at the bottom + self.layout = VBox([HBox([self.map, self.output_widget, self.df_output])]) + + def add_marker(self, lat: float, lon: float, on_click_callback): + """Add a marker to the map.""" + marker = Marker(location=[lat, lon], draggable=False, opacity = 0.7) + marker.on_click(on_click_callback) + self.map.add_layer(marker) + + def display(self): + display(self.vbox) + + @staticmethod + def initialize_plot(): + """Initialize a plotly figure widget.""" + f = go.FigureWidget( + layout=go.Layout( + width=600, + legend=dict( + orientation="h", + yanchor="bottom", + y=1.02, + xanchor="right", + x=1 + ) + ) + ) + return f + + +class ThrottledClick: + """Class to throttle click events.""" + def __init__(self, delay=1.0): + self.delay = delay + self.last_call = 0 + + def should_process(self): + current_time = time.time() + if current_time - self.last_call > self.delay: + self.last_call = current_time + return True + return False + + +class NotebookMap: + """Main class for visualization in Jupyter Notebook.""" + + def __init__(self, config: Dict, stations_metadata: str, observations: str, simulations: Union[Dict, str], stats=None): + self.config = config + self.stations_metadata = read_station_metadata_file( + fpath=stations_metadata, + coord_names=config['station_coordinates'], + epsg=config['station_epsg'], + filters=config['station_filters'] + ) + self.station_file_name = os.path.basename(stations_metadata) # Derive the file name here + self.stations_metadata['ObsID'] = self.stations_metadata['ObsID'].astype(str) + self.observations = observations + # Ensure simulations is always a dictionary + if isinstance(simulations, str): + simulations = {"default": simulations} + elif not isinstance(simulations, dict): + raise ValueError("Simulations input should be either a string or a dictionary.") + self.simulations = simulations + # Prepare sim_ds and obs_ds for statistics + self.sim_ds = self.prepare_simulations_data() + self.obs_ds = self.prepare_observations_data() + + self.stats = stats + self.threshold = 70 + self.statistics = None + if self.stats: + self.calculate_statistics() + self.statistics_output = Output() + + def prepare_simulations_data(self): + # If simulations is a string, assume it's a file path + if isinstance(self.simulations, str): + return xr.open_dataset(self.simulations) + + # If simulations is a dictionary, load data for each experiment + elif isinstance(self.simulations, dict): + datasets = {} + for exp, path in self.simulations.items(): + # Expanding the tilde + expanded_path = os.path.expanduser(path) + + if os.path.isfile(expanded_path): # Check if it's a file + ds = xr.open_dataset(expanded_path) + elif os.path.isdir(expanded_path): # Check if it's a directory + # Handle the case when it's a directory; + # assume all .nc files in the directory need to be combined + files = [f for f in os.listdir(expanded_path) if f.endswith('.nc')] + ds = xr.open_mfdataset([os.path.join(expanded_path, f) for f in files], combine='by_coords') + else: + raise ValueError(f"Invalid path: {expanded_path}") + datasets[exp] = ds + + return datasets + + else: + raise TypeError("Invalid type for simulations. Expected str or dict.") + + + def prepare_observations_data(self): + file_extension = os.path.splitext(self.observations)[-1].lower() + + if file_extension == '.csv': + obs_df = pd.read_csv(self.observations, parse_dates=["Timestamp"]) + obs_melted = obs_df.melt(id_vars="Timestamp", var_name="station", value_name="obsdis") + + # Convert the melted DataFrame to xarray Dataset + obs_ds = obs_melted.set_index(["Timestamp", "station"]).to_xarray() + obs_ds = obs_ds.rename({"Timestamp": "time"}) + + elif file_extension == '.nc': + obs_ds = xr.open_dataset(self.observations) + + # Check if the necessary attributes are present + if 'obsdis' not in obs_ds or 'time' not in obs_ds.coords: + raise ValueError("The NetCDF file does not have the expected variables or coordinates.") + + # Convert the 'station' coordinate to a multi-index of 'latitude' and 'longitude' + lats = obs_ds['lat'].values + lons = obs_ds['lon'].values + multi_index = pd.MultiIndex.from_tuples(list(zip(lats, lons)), names=['lat', 'lon']) + obs_ds['station'] = ('station', multi_index) + + else: + raise ValueError("Unsupported file format for observations.") + + # Subset obs_ds based on sim_ds time values + if isinstance(self.sim_ds, xr.Dataset): + time_values = self.sim_ds['time'].values + elif isinstance(self.sim_ds, dict): + # Use the first dataset in the dictionary to determine time values + first_dataset = next(iter(self.sim_ds.values())) + time_values = first_dataset['time'].values + else: + raise ValueError("Unexpected type for self.sim_ds") + + obs_ds = obs_ds.sel(time=time_values) + return obs_ds + + + + + def calculate_statistics(self): + statistics = {} + if isinstance(self.sim_ds, xr.Dataset): + # Single simulation dataset + sim_ds_f, obs_ds_f = filter_timeseries(self.sim_ds, self.obs_ds, self.threshold) + statistics["single"] = run_analysis(self.stats, sim_ds_f, obs_ds_f) + elif isinstance(self.sim_ds, dict): + # Dictionary of simulation datasets + for exp, ds in self.sim_ds.items(): + sim_ds_f, obs_ds_f = filter_timeseries(ds, self.obs_ds, self.threshold) + statistics[exp] = run_analysis(self.stats, sim_ds_f, obs_ds_f) + self.statistics = statistics + + + + def display_dataframe_with_scroll(self, df, title=""): + with self.geo_map.df_output: + clear_output(wait=True) + + # Define styles directly within the HTML content + table_style = """ + + """ + table_html = df.to_html(classes='custom-table') + content = f"{table_style}

{title}

{table_html}
" + + display(HTML(content)) + + + # Define a callback to handle marker clicks + def handle_click(self, station_id): + # Use the throttler to determine if we should process the click + if not self.throttler.should_process(): + return + + self.loading_label.value = "Loading..." # Indicate that data is being loaded + try: + # Convert station ID to string for consistency + station_id = str(station_id) + + # Clear existing data from the plot + self.f.data = [] + + # Convert ds_time to a list of string formatted dates for reindexing + ds_time_str = [dt.isoformat() for dt in pd.to_datetime(self.ds_time)] + + # Loop over all datasets to plot them + for ds, exp_name in zip(self.ds_list, self.simulations.keys()): + if station_id in ds['station'].values: + ds_time_series_data = ds['simulation_timeseries'].sel(station=station_id).values + + # Filter out NaN values and their associated dates using the utility function + valid_dates_ds, valid_data_ds = filter_nan_values(ds_time_str, ds_time_series_data) + + self.f.add_trace(go.Scatter(x=valid_dates_ds, y=valid_data_ds, mode='lines', name=exp_name)) + else: + print(f"Station ID: {station_id} not found in dataset {exp_name}.") + + # Time series from the obs + if station_id in self.obs_ds['station'].values: + obs_time_series = self.obs_ds['obsdis'].sel(station=station_id).values + + # Filter out NaN values and their associated dates using the utility function + valid_dates_obs, valid_data_obs = filter_nan_values(ds_time_str, obs_time_series) + + self.f.add_trace(go.Scatter(x=valid_dates_obs, y=valid_data_obs, mode='lines', name='Obs. Data')) + else: + print(f"Station ID: {station_id} not found in obs_df. Columns are: {self.obs_ds.columns}") + + + # Update the x-axis and y-axis properties + self.f.layout.xaxis.title = 'Date' + self.f.layout.xaxis.tickformat = '%d-%m-%Y' + self.f.layout.yaxis.title = 'Discharge [m3/s]' + + # Generate and display the statistics table for the clicked station + with self.statistics_output: + df_stats = self.generate_statistics_table(station_id) + self.display_dataframe_with_scroll(df_stats, title="Statistics Overview") + + self.loading_label.value = "" # Clear the loading message + + except Exception as e: + print(f"Error encountered: {e}") + self.loading_label.value = "Error encountered. Check the printed message." + + + def mapplot(self, colorby='kge', sim='exp1'): + # Utilize the already prepared datasets + if isinstance(self.sim_ds, dict): # If there are multiple experiments + self.ds_list = list(self.sim_ds.values()) + else: # If there's only one dataset + self.ds_list = [self.sim_ds] + + # Utilize the obs_ds to convert it to a dataframe (if needed elsewhere in the method) + self.obs_df = self.obs_ds.to_dataframe().reset_index() + + self.statistics_output.clear_output() + + # Assume all datasets have the same coordinates for simplicity + # Determine center coordinates using the first dataset in the list + center_lat, center_lon = self.ds_list[0]['latitude'].mean().item(), self.ds_list[0]['longitude'].mean().item() + + # Create a GeoMap centered on the mean coordinates + self.geo_map = IPyLeaflet(center_lat, center_lon) + + self.f = IPyLeaflet.initialize_plot() # Initialize a plotly figure widget for the time series + + # Convert ds 'time' to datetime format for alignment with external_df + self.ds_time = self.ds_list[0]['time'].values.astype('datetime64[D]') + + # Create a label to indicate loading + self.loading_label = Label(value="") + + self.throttler = ThrottledClick() + + # Check if statistics were computed + if self.statistics is None: + raise ValueError("Statistics have not been computed. Run `calculate_statistics` first.") + + # Check if the chosen simulation exists in the statistics + if sim not in self.statistics: + raise ValueError(f"Simulation '{sim}' not found in computed statistics.") + + # Check if the chosen statistic (colorby) exists for the simulation + if colorby not in self.statistics[sim].data_vars: + raise ValueError(f"Statistic '{colorby}' not found in computed statistics for simulation '{sim}'.") + + # Retrieve the desired data + stat_data = self.statistics[sim][colorby].values + + # Define a colormap (you can choose any other colormap that you like) + colormap = plt.cm.viridis + + # Normalize the data for coloring + norm = plt.Normalize(stat_data.min(), stat_data.max()) + + # Create a GeoJSON structure from the stations_metadata DataFrame + + for _, row in self.stations_metadata.iterrows(): + lat, lon = row['StationLat'], row['StationLon'] + station_id = row['ObsID'] + + # Get the index of the station in the statistics data + station_indices = np.where(self.ds_list[0]['station'].values.astype(str) == str(station_id))[0] + + if len(station_indices) == 0: + color = 'gray' + else: + if station_indices[0] >= len(stat_data) or np.isnan(stat_data[station_indices[0]]): + color = 'gray' + else: + color = matplotlib.colors.rgb2hex(colormap(norm(stat_data[station_indices[0]]))) + print(f"Station {station_id} has color {color} based on statistic value {stat_data[station_indices[0]]}") + + circle_marker = CircleMarker(location=(lat, lon), radius=5, color=color, fill_opacity=0.8) + circle_marker.on_click(partial(self.handle_marker_click, row=row)) + self.geo_map.map.add_layer(circle_marker) + + # Add legend to your map + + legend_dict = self.colormap_to_legend(stat_data, colormap) + # print("Legend Dict:", legend_dict) + my_legend = Legend(legend_dict, name=colorby) + self.geo_map.map.add_control(my_legend) + self.statistics_output = Output() + + # Initialize the layout only once + # Create a new VBox for the plotly figure and the statistics output + plot_with_stats = VBox([self.f, self.statistics_output]) + + # Modify the main layout to use the new VBox + self.layout = VBox([HBox([self.geo_map.map, plot_with_stats, self.geo_map.df_output]), self.loading_label]) + # self.layout = VBox([HBox([self.geo_map.map, self.f, self.statistics_output]), self.geo_map.df_output, self.loading_label]) + display(self.layout) + + + def colormap_to_legend(self, stat_data, colormap, n_labels=5): + """ + Convert a matplotlib colormap to a dictionary suitable for ipyleaflet-legend. + + Parameters: + - colormap: A matplotlib colormap instance + - n_labels: Number of labels/colors in the legend + """ + values = np.linspace(0, 1, n_labels) + colors = [matplotlib.colors.rgb2hex(colormap(value)) for value in values] + labels = [f"{value:.2f}" for value in np.linspace(stat_data.min(), stat_data.max(), n_labels)] + return dict(zip(labels, colors)) + + + def handle_marker_click(self, row, **kwargs): + station_id = row['ObsID'] + station_name = row['StationName'] + + self.handle_click(row['ObsID']) + + # Convert the station metadata to a DataFrame for display + df = pd.DataFrame([row]) + + title_plot = f"Time Series for Station ID: {station_id}, {station_name}" + title_table = f"Station property from metadata: {self.station_file_name}" + self.f.layout.title.text = title_plot # Set title for the time series plot + + # Display the DataFrame in the df_output widget + self.display_dataframe_with_scroll(df, title=title_table) + + + def handle_geojson_click(self, feature, **kwargs): + # Extract properties directly from the feature + station_id = feature['properties']['station_id'] + self.handle_click(station_id) + + # Display metadata of the station + df_station = self.stations_metadata[self.stations_metadata['ObsID'] == station_id] + title_table = f"Station property from metadata: {self.station_file_name}" + self.display_dataframe_with_scroll(df_station, title=title_table) + + + def generate_statistics_table(self, station_id): + # print(f"Generating statistics table for station: {station_id}") + + data = [] + # Loop through each simulation and get the statistics for the given station_id + + for exp_name, stats in self.statistics.items(): + print("Loop initiated for experiment:", exp_name) + if str(station_id) in stats['station'].values: + print("Available station IDs in stats:", stats['station'].values) + row = [exp_name] + [stats[var].sel(station=station_id).values for var in stats.data_vars if var not in ['longitude', 'latitude']] + data.append(row) + print("Data after processing:", data) + + # Convert the data to a DataFrame for display + columns = ['Exp. name'] + list(stats.data_vars.keys()) + columns.remove('longitude') + columns.remove('latitude') + statistics_df = pd.DataFrame(data, columns=columns) + + # Check if the dataframe has been generated correctly + if statistics_df.empty: + print(f"No statistics data found for station ID: {station_id}.") + return pd.DataFrame() # Return an empty dataframe + + return statistics_df + + + def overlay_external_vector(self, vector_data: str, style=None, fill_color=None, line_color=None): + """Add vector data to the map.""" + if style is None: + style = { + "stroke": True, + "color": line_color if line_color else "#FF0000", + "weight": 2, + "opacity": 1, + "fill": True, + "fillColor": fill_color if fill_color else "#03f", + "fillOpacity": 0.3, + } + + # Load the GeoJSON data from the file + with open(vector_data, 'r') as f: + vector = json.load(f) + + # Create the GeoJSON layer + geojson_layer = GeoJSON(data=vector, style=style) + + # Update the title label + vector_name = os.path.basename(vector_data) + + # Define the callback to handle feature clicks + def on_feature_click(event, feature, **kwargs): + title = f"Feature property of the external vector: {vector_name}" + + properties = feature['properties'] + df = properties_to_dataframe(properties) + + # Display the DataFrame in the df_output widget using the modified method + self.display_dataframe_with_scroll(df, title=title) + + # Bind the callback to the layer + geojson_layer.on_click(on_feature_click) + + self.geo_map.map.add_layer(geojson_layer) + + @staticmethod + def plot_station(self, station_data, station_id, lat, lon): + existing_traces = [trace.name for trace in self.f.data] + for var, y_data in station_data.items(): + data_exists = len(y_data) > 0 + + trace_name = f"Station {station_id} - {var}" + if data_exists and trace_name not in existing_traces: + # Use the time data from the dataset + x_data = station_data.get('time') + + if x_data is not None: + # Convert datetime64 array to native Python datetime objects + x_data = pd.to_datetime(x_data) + + self.f.add_scatter(x=x_data, y=y_data, mode='lines+markers', name=trace_name) + + + +def properties_to_dataframe(properties: Dict) -> pd.DataFrame: + """Convert feature properties to a DataFrame for display.""" + return pd.DataFrame([properties]) + + +def filter_nan_values(dates, data_values): + """ + Filters out NaN values and their associated dates. + + Parameters: + - dates: List of dates. + - data_values: List of data values corresponding to the dates. + + Returns: + - valid_dates: List of dates without NaN values. + - valid_data: List of non-NaN data values. + """ + valid_dates = [date for date, val in zip(dates, data_values) if not np.isnan(val)] + valid_data = [val for val in data_values if not np.isnan(val)] + + return valid_dates, valid_data \ No newline at end of file From fb4098e25b939e6ea392fa8fad15a82e5c18a07d Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Tue, 10 Oct 2023 04:45:48 +0200 Subject: [PATCH 02/31] added another version of visualization module to cater for destinE data --- hat/visualisation_v2.py | 199 ++++++++++++ .../4_visualisation_interactive.ipynb | 301 ++++++++++++++++++ 2 files changed, 500 insertions(+) create mode 100644 hat/visualisation_v2.py create mode 100644 notebooks/examples/4_visualisation_interactive.ipynb diff --git a/hat/visualisation_v2.py b/hat/visualisation_v2.py new file mode 100644 index 0000000..9cf723d --- /dev/null +++ b/hat/visualisation_v2.py @@ -0,0 +1,199 @@ +""" +Python module for visualising geospatial content using jupyter notebook, +for both spatial and temporal, e.g. netcdf, vector, raster, with time series etc +""" +import os +import pandas as pd +from typing import Dict, Union, List + +import geopandas as gpd +from shapely.geometry import Point +import xarray as xr + +from hat.hydrostats import run_analysis +from hat.filters import filter_timeseries + +class NotebookMap: + def __init__(self, config: Dict, stations_metadata: str, observations: str, simulations: Union[Dict, str], stats=None): + self.config = config + + # Prepare Station Metadata + self.stations_metadata = self.prepare_station_metadata( + fpath=stations_metadata, + station_id_column_name=config["station_id_column_name"], + coord_names=config['station_coordinates'], + epsg=config['station_epsg'], + filters=config['station_filters'] + ) + + # Prepare Observations Data + self.observation = self.prepare_observations_data(observations) + + # Prepare Simulations Data + self.simulations = self.prepare_simulations_data(simulations) + + # Ensure stations in obs and sims are present in metadata + valid_stations = set(self.stations_metadata[self.config["station_id_column_name"]].values) + + # Filter data based on valid stations + self.observation = self.filter_stations_by_metadata(self.observation, valid_stations) + self.simulations = {exp: self.filter_stations_by_metadata(ds, valid_stations) for exp, ds in self.simulations.items()} + + self.stats_input = stats + self.stat_threshold = 70 # default for now, may need to be added as option + self.stats_output = {} + if self.stats_input: + self.stats_output = self.calculate_statistics() + + def filter_stations_by_metadata(self, ds, valid_stations): + """Filter the stations in the dataset to only include those in valid_stations.""" + return ds.sel(station=[s for s in ds.station.values if s in valid_stations]) + + + def prepare_station_metadata(self, fpath: str, station_id_column_name: str, coord_names: List[str], epsg: int, filters=None) -> xr.Dataset: + # Read the station metadata file + df = pd.read_csv(fpath) + + # Convert to a GeoDataFrame + geometry = [Point(xy) for xy in zip(df[coord_names[0]], df[coord_names[1]])] + gdf = gpd.GeoDataFrame(df, crs=f"EPSG:{epsg}", geometry=geometry) + + # Apply filters if provided + if filters: + for column, value in filters.items(): + gdf = gdf[gdf[column] == value] + + return gdf + + def prepare_observations_data(self, observations: str) -> xr.Dataset: + """ + Load and preprocess observations data. + + Parameters: + - observations: Path to the observations data file. + + Returns: + - obs_ds: An xarray Dataset containing the observations data. + """ + file_extension = os.path.splitext(observations)[-1].lower() + station_id_column_name = self.config.get('station_id_column_name', 'station_id_num') + + if file_extension == '.csv': + obs_df = pd.read_csv(observations, parse_dates=["Timestamp"]) + obs_melted = obs_df.melt(id_vars="Timestamp", var_name="station", value_name="obsdis") + + # Convert melted DataFrame to xarray Dataset + obs_ds = obs_melted.set_index(["Timestamp", "station"]).to_xarray() + obs_ds = obs_ds.rename({"Timestamp": "time"}) + + elif file_extension == '.nc': + obs_ds = xr.open_dataset(observations) + + # Check for necessary attributes + if 'obsdis' not in obs_ds or 'time' not in obs_ds.coords: + raise ValueError("The NetCDF file lacks the expected variables or coordinates.") + + # Rename the station_id to station and set it as an index + obs_ds = obs_ds.rename({station_id_column_name: "station"}) + obs_ds = obs_ds.set_index(station="station") + else: + raise ValueError("Unsupported file format for observations.") + + return obs_ds + + + def prepare_simulations_data(self, simulations: Union[Dict, str]) -> Dict[str, xr.Dataset]: + """ + Load and preprocess simulations data. + + Parameters: + - simulations: Either a string path to the simulations data file or a dictionary mapping + experiment names to file paths. + + Returns: + - datasets: A dictionary mapping experiment names to their respective xarray Datasets. + """ + sim_ds = {} + + # Handle the case where simulations is a single string path + if isinstance(simulations, str): + sim_ds["default"] = xr.open_dataset(simulations) + + # Handle the case where simulations is a dictionary of experiment names to paths + elif isinstance(simulations, dict): + for exp, path in simulations.items(): + expanded_path = os.path.expanduser(path) + + if os.path.isfile(expanded_path): # If it's a file + ds = xr.open_dataset(expanded_path) + + elif os.path.isdir(expanded_path): # If it's a directory + # Assume all .nc files in the directory need to be combined + files = [f for f in os.listdir(expanded_path) if f.endswith('.nc')] + ds = xr.open_mfdataset([os.path.join(expanded_path, f) for f in files], combine='by_coords') + + else: + raise ValueError(f"Invalid path: {expanded_path}") + + sim_ds[exp] = ds + else: + raise TypeError("Expected simulations to be either str or dict.") + + return sim_ds + + + + def calculate_statistics(self) -> Dict[str, xr.Dataset]: + """ + Calculate statistics for the simulations against the observations. + + Returns: + - statistics: A dictionary mapping experiment names to their respective statistics xarray Datasets. + """ + stats_output = {} + + if isinstance(self.simulations, xr.Dataset): + # For a single simulation dataset + sim_filtered, obs_filtered = filter_timeseries(self.simulations, self.observation, self.stat_threshold) + stats_output["default"] = run_analysis(self.stats_input, sim_filtered, obs_filtered) + elif isinstance(self.simulations, dict): + # For multiple simulation datasets + for exp, ds in self.simulations.items(): + # print(f"Processing experiment: {exp}") + # print("Simulation dataset stations:", ds.station.values) + # print("Observation dataset stations:", self.observation.station.values) + + sim_filtered, obs_filtered = filter_timeseries(ds, self.observation, self.stat_threshold) + # stats_output[exp] = run_analysis(self.stats_input, sim_filtered, obs_filtered) + + stations_series = pd.Series(ds.station.values) + duplicates = stations_series.value_counts().loc[lambda x: x > 1] + print(exp, ":", duplicates) + + else: + raise ValueError("Unexpected type for self.simulations") + + return stats_output + +# Utility Functions + +def properties_to_dataframe(properties: Dict) -> pd.DataFrame: + """Convert feature properties to a DataFrame for display.""" + return pd.DataFrame([properties]) + +def filter_nan_values(dates, data_values): + """ + Filters out NaN values and their associated dates. + + Parameters: + - dates: List of dates. + - data_values: List of data values corresponding to the dates. + + Returns: + - valid_dates: List of dates without NaN values. + - valid_data: List of non-NaN data values. + """ + valid_dates = [date for date, val in zip(dates, data_values) if not np.isnan(val)] + valid_data = [val for val in data_values if not np.isnan(val)] + + return valid_dates, valid_data \ No newline at end of file diff --git a/notebooks/examples/4_visualisation_interactive.ipynb b/notebooks/examples/4_visualisation_interactive.ipynb new file mode 100644 index 0000000..5c693b8 --- /dev/null +++ b/notebooks/examples/4_visualisation_interactive.ipynb @@ -0,0 +1,301 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Import necessart visualisation library and define inputs simulation and additional vector to add" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from hat.visualisation import NotebookMap\n", + "stations = \"/home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/20230222_outlets_elbe_de.csv\"\n", + "observations = \"/home/dadiyorto/freelance/02_ort_ecmwf/test/observed/Qts_calib_9021.csv\"\n", + "simulations = {\n", + " \"exp1\": \"/home/dadiyorto/freelance/02_ort_ecmwf/test/simulation_datadir/simulation_timeseries.nc\",\n", + " \"exp2\": \"/home/dadiyorto/freelance/02_ort_ecmwf/test/simulation_datadir/simulation_timeseries.nc\",\n", + "}\n", + "config = {\n", + " \"station_epsg\": 4326,\n", + " \"station_id_column_name\": \"station_id\",\n", + " \"station_filters\":\"\",\n", + " \"station_coordinates\": [\"StationLon\", \"StationLat\"]\n", + "}\n", + "\n", + "statistics = ['kge', 'rmse', 'mae', 'correlation']" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Display map and use the tool to show plot of the simulation time series.\n", + "Additionally add external vector file to overlay on the existsing map" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "ename": "TypeError", + "evalue": "read_station_metadata_file() got an unexpected keyword argument 'filters'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)", + "\u001b[1;32m/home/dadiyorto/freelance/02_ort_ecmwf/dev/hat/notebooks/examples/4_visualisation_interactive.ipynb Cell 4\u001b[0m line \u001b[0;36m1\n\u001b[0;32m----> 1\u001b[0m \u001b[39mmap\u001b[39m \u001b[39m=\u001b[39m NotebookMap(config, stations, observations, simulations, stats\u001b[39m=\u001b[39;49mstatistics)\n", + "File \u001b[0;32m~/freelance/02_ort_ecmwf/dev/hat/hat/visualisation.py:94\u001b[0m, in \u001b[0;36mNotebookMap.__init__\u001b[0;34m(self, config, stations_metadata, observations, simulations, stats)\u001b[0m\n\u001b[1;32m 92\u001b[0m \u001b[39mdef\u001b[39;00m \u001b[39m__init__\u001b[39m(\u001b[39mself\u001b[39m, config: Dict, stations_metadata: \u001b[39mstr\u001b[39m, observations: \u001b[39mstr\u001b[39m, simulations: Union[Dict, \u001b[39mstr\u001b[39m], stats\u001b[39m=\u001b[39m\u001b[39mNone\u001b[39;00m):\n\u001b[1;32m 93\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mconfig \u001b[39m=\u001b[39m config\n\u001b[0;32m---> 94\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata \u001b[39m=\u001b[39m read_station_metadata_file(\n\u001b[1;32m 95\u001b[0m fpath\u001b[39m=\u001b[39;49mstations_metadata,\n\u001b[1;32m 96\u001b[0m coord_names\u001b[39m=\u001b[39;49mconfig[\u001b[39m'\u001b[39;49m\u001b[39mstation_coordinates\u001b[39;49m\u001b[39m'\u001b[39;49m],\n\u001b[1;32m 97\u001b[0m epsg\u001b[39m=\u001b[39;49mconfig[\u001b[39m'\u001b[39;49m\u001b[39mstation_epsg\u001b[39;49m\u001b[39m'\u001b[39;49m],\n\u001b[1;32m 98\u001b[0m filters\u001b[39m=\u001b[39;49mconfig[\u001b[39m'\u001b[39;49m\u001b[39mstation_filters\u001b[39;49m\u001b[39m'\u001b[39;49m]\n\u001b[1;32m 99\u001b[0m )\n\u001b[1;32m 100\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstation_file_name \u001b[39m=\u001b[39m os\u001b[39m.\u001b[39mpath\u001b[39m.\u001b[39mbasename(stations_metadata) \u001b[39m# Derive the file name here\u001b[39;00m\n\u001b[1;32m 101\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata[\u001b[39m'\u001b[39m\u001b[39mObsID\u001b[39m\u001b[39m'\u001b[39m] \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata[\u001b[39m'\u001b[39m\u001b[39mObsID\u001b[39m\u001b[39m'\u001b[39m]\u001b[39m.\u001b[39mastype(\u001b[39mstr\u001b[39m)\n", + "\u001b[0;31mTypeError\u001b[0m: read_station_metadata_file() got an unexpected keyword argument 'filters'" + ] + } + ], + "source": [ + "map = NotebookMap(config, stations, observations, simulations, stats=statistics)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Station 4 has color #c0df25 based on statistic value 0.5471751158959437\n", + "Station 5 has color #dfe318 based on statistic value 0.7376279988803034\n", + "Station 6 has color #d8e219 based on statistic value 0.6792520362572181\n", + "Station 8 has color #dde318 based on statistic value 0.7224318531801283\n", + "Station 9 has color #e5e419 based on statistic value 0.7650829122593195\n", + "Station 10 has color #e5e419 based on statistic value 0.7623623211979086\n", + "Station 11 has color #e5e419 based on statistic value 0.7626829463154589\n", + "Station 12 has color #b5de2b based on statistic value 0.4935717934686382\n", + "Station 13 has color #addc30 based on statistic value 0.45060948119659516\n", + "Station 14 has color #93d741 based on statistic value 0.3035724795386384\n", + "Station 15 has color #d8e219 based on statistic value 0.6843228165059871\n", + "Station 16 has color #c5e021 based on statistic value 0.5872259346281932\n", + "Station 17 has color #77d153 based on statistic value 0.13784125932391056\n", + "Station 18 has color #81d34d based on statistic value 0.20402810842358654\n", + "Station 19 has color #c2df23 based on statistic value 0.5655851330506583\n", + "Station 69 has color #8bd646 based on statistic value 0.2623725873210412\n", + "Station 71 has color #e5e419 based on statistic value 0.7651472440380137\n", + "Station 73 has color #d0e11c based on statistic value 0.6366702886046419\n", + "Station 74 has color #a0da39 based on statistic value 0.38069355270376415\n", + "Station 75 has color #e7e419 based on statistic value 0.771766702233077\n", + "Station 76 has color #e5e419 based on statistic value 0.7649466409284118\n", + "Station 77 has color #dde318 based on statistic value 0.7148989973551911\n", + "Station 78 has color #e5e419 based on statistic value 0.7578605067002151\n", + "Station 79 has color #c2df23 based on statistic value 0.570235775091059\n", + "Station 80 has color #440154 based on statistic value -2.8771271379605388\n", + "Station 81 has color #efe51c based on statistic value 0.8122966744760616\n", + "Station 82 has color #cae11f based on statistic value 0.6059442625222966\n", + "Station 83 has color #b0dd2f based on statistic value 0.46392177301101967\n", + "Station 84 has color #d2e21b based on statistic value 0.6492094621376316\n", + "Station 85 has color #9bd93c based on statistic value 0.3436411308610797\n", + "Station 86 has color #2cb17e based on statistic value -0.44917530504479264\n", + "Station 87 has color #1f968b based on statistic value -0.8785537121775633\n", + "Station 88 has color #dde318 based on statistic value 0.7084648770114568\n", + "Station 89 has color #7ad151 based on statistic value 0.15948589309326644\n", + "Station 90 has color #d8e219 based on statistic value 0.681858293809658\n", + "Station 92 has color #c8e020 based on statistic value 0.5933677848703405\n", + "Station 93 has color #98d83e based on statistic value 0.3332936321416301\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "b44ef784b6a142d2b6038fe28fdb0c26", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(HBox(children=(Map(center=[48.67101241090147, 9.645834728960766], controls=(ZoomControl(options…" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Error encountered: 'Dataset' object has no attribute 'columns'\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Error encountered: 'Dataset' object has no attribute 'columns'\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Station ID: 121 not found in dataset exp1.\n", + "Error encountered: 'Dataset' object has no attribute 'columns'\n" + ] + } + ], + "source": [ + "map.mapplot(colorby='kge', sim='exp1')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "vector_file_path = '/home/dadiyorto/freelance/02_ort_ecmwf/test/map/map.geojson' # can be .shp, .geojson, or .kml\n", + "map.overlay_external_vector(vector_file_path)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "map.stations_metadata\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "ename": "", + "evalue": "", + "output_type": "error", + "traceback": [ + "\u001b[1;31mThe Kernel crashed while executing code in the the current cell or a previous cell. Please review the code in the cell(s) to identify a possible cause of the failure. Click here for more info. View Jupyter log for further details." + ] + } + ], + "source": [ + "from hat.visualisation_v2 import NotebookMap as nbm\n", + "stations= '~/destinE/outlets_v4.0_20230726_withEFAS.csv'\n", + "observations= '~/destinE/station_attributes_with_obsdis06h_197001-202312_20230726_withEFAS.nc'\n", + "\n", + "simulations = {\n", + " \"i05j\": \"~/destinE/cama_i05j_stations.nc\",\n", + " \"i05h\": \"~/destinE/cama_i05h_stations.nc\"\n", + "\n", + "}\n", + "config = {\n", + " \"station_epsg\": 4326,\n", + " \"station_id_column_name\": \"station_id\",\n", + " \"station_filters\":\"\",\n", + " \"station_coordinates\": [\"StationLon\", \"StationLat\"]\n", + "}\n", + "\n", + "statistics = ['kge', 'rmse', 'mae', 'correlation']\n", + "\n", + "map = nbm(config, stations, observations, simulations, stats=statistics)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import xarray as xr\n", + "import pandas as pd\n", + "\n", + "# Load the observations dataset\n", + "obs_ds = xr.open_dataset('~/destinE/station_attributes_with_obsdis06h_197001-202312_20230726_withEFAS.nc')\n", + "\n", + "# Load the simulations datasets\n", + "sim_i05j = xr.open_dataset('~/destinE/cama_i05j_stations.nc')\n", + "sim_i05h = xr.open_dataset('~/destinE/cama_i05h_stations.nc')\n", + "\n", + "print(sim_i05j)\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "obs_ds\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import xarray as xr\n", + "import os\n", + "\n", + "simulations = {\n", + " \"i05j\": \"~/destinE/cama_i05j_stations.nc\",\n", + " \"i05h\": \"~/destinE/cama_i05h_stations.nc\"\n", + "}\n", + "datasets = {}\n", + "datasets = {}\n", + "for exp, path in simulations.items():\n", + " # Expanding the tilde\n", + " expanded_path = os.path.expanduser(path)\n", + " \n", + " if os.path.isfile(expanded_path): # Check if it's a file\n", + " ds = xr.open_dataset(expanded_path)\n", + " elif os.path.isdir(expanded_path): # Check if it's a directory\n", + " # Handle the case when it's a directory; \n", + " # assume all .nc files in the directory need to be combined\n", + " files = [f for f in os.listdir(expanded_path) if f.endswith('.nc')]\n", + " ds = xr.open_mfdataset([os.path.join(expanded_path, f) for f in files], combine='by_coords')\n", + " else:\n", + " raise ValueError(f\"Invalid path: {expanded_path}\")\n", + " datasets[exp] = ds\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "hat-dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} From bb06dd1617a5457a8db39ea113e33ff4c542d016 Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Tue, 10 Oct 2023 09:33:16 +0200 Subject: [PATCH 03/31] resolved problem when tempdir doesnt exists --- hat/data.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hat/data.py b/hat/data.py index 4f25eee..643b0d9 100644 --- a/hat/data.py +++ b/hat/data.py @@ -37,6 +37,9 @@ def get_tmpdir(): else: tmpdir = TemporaryDirectory().name + # Ensure the directory exists + os.makedirs(tmpdir, exist_ok=True) + return tmpdir From 9c9d14c8a38a0cd17cab3a8d9cecfa5fa941f7f9 Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Tue, 10 Oct 2023 10:17:22 +0200 Subject: [PATCH 04/31] reverted back to working station click with less buggy layout --- hat/visualisation.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/hat/visualisation.py b/hat/visualisation.py index 0949967..e90c8df 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -185,9 +185,7 @@ def prepare_observations_data(self): obs_ds = obs_ds.sel(time=time_values) return obs_ds - - - + def calculate_statistics(self): statistics = {} @@ -300,6 +298,10 @@ def handle_click(self, station_id): print(f"Error encountered: {e}") self.loading_label.value = "Error encountered. Check the printed message." + with self.geo_map.output_widget: + clear_output(wait=True) # Clear any previous plots or messages + display(self.f) + def mapplot(self, colorby='kge', sim='exp1'): # Utilize the already prepared datasets @@ -320,7 +322,7 @@ def mapplot(self, colorby='kge', sim='exp1'): # Create a GeoMap centered on the mean coordinates self.geo_map = IPyLeaflet(center_lat, center_lon) - self.f = IPyLeaflet.initialize_plot() # Initialize a plotly figure widget for the time series + self.f = self.geo_map.initialize_plot() # Initialize a plotly figure widget for the time series # Convert ds 'time' to datetime format for alignment with external_df self.ds_time = self.ds_list[0]['time'].values.astype('datetime64[D]') @@ -375,18 +377,20 @@ def mapplot(self, colorby='kge', sim='exp1'): # Add legend to your map - legend_dict = self.colormap_to_legend(stat_data, colormap) + # legend_dict = self.colormap_to_legend(stat_data, colormap) # print("Legend Dict:", legend_dict) - my_legend = Legend(legend_dict, name=colorby) - self.geo_map.map.add_control(my_legend) + # my_legend = Legend(legend_dict, name=colorby) + # self.geo_map.map.add_control(my_legend) self.statistics_output = Output() # Initialize the layout only once # Create a new VBox for the plotly figure and the statistics output - plot_with_stats = VBox([self.f, self.statistics_output]) + # plot_with_stats = VBox([self.f, self.statistics_output]) # Modify the main layout to use the new VBox - self.layout = VBox([HBox([self.geo_map.map, plot_with_stats, self.geo_map.df_output]), self.loading_label]) + + # self.layout = VBox([HBox([self.geo_map.map, self.geo_map.df_output]), self.loading_label]) + self.layout = VBox([HBox([self.geo_map.map, self.f]), self.geo_map.df_output]) # self.layout = VBox([HBox([self.geo_map.map, self.f, self.statistics_output]), self.geo_map.df_output, self.loading_label]) display(self.layout) From fd2d9a429c4d5d020a9a1c87311c6b75ed3b634d Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Tue, 10 Oct 2023 11:31:12 +0200 Subject: [PATCH 05/31] added common id finder between input files --- hat/visualisation.py | 38 +- .../4_visualisation_interactive.ipynb | 520 +++++++++++++++++- 2 files changed, 524 insertions(+), 34 deletions(-) diff --git a/hat/visualisation.py b/hat/visualisation.py index e90c8df..a524609 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -89,7 +89,7 @@ def should_process(self): class NotebookMap: """Main class for visualization in Jupyter Notebook.""" - def __init__(self, config: Dict, stations_metadata: str, observations: str, simulations: Union[Dict, str], stats=None): + def __init__(self, config: Dict, stations_metadata: str, observations: str, simulations: Dict, stats=None): self.config = config self.stations_metadata = read_station_metadata_file( fpath=stations_metadata, @@ -97,21 +97,24 @@ def __init__(self, config: Dict, stations_metadata: str, observations: str, simu epsg=config['station_epsg'], filters=config['station_filters'] ) + self.station_index = config["station_id_column_name"] self.station_file_name = os.path.basename(stations_metadata) # Derive the file name here - self.stations_metadata['ObsID'] = self.stations_metadata['ObsID'].astype(str) + self.stations_metadata[self.station_index] = self.stations_metadata[self.station_index].astype(str) self.observations = observations # Ensure simulations is always a dictionary - if isinstance(simulations, str): - simulations = {"default": simulations} - elif not isinstance(simulations, dict): - raise ValueError("Simulations input should be either a string or a dictionary.") + self.simulations = simulations # Prepare sim_ds and obs_ds for statistics self.sim_ds = self.prepare_simulations_data() self.obs_ds = self.prepare_observations_data() - + self.common_id = self.find_common_station() + self.stations_metadata = self.stations_metadata.sel(station_id=self.common_id) + self.obs_ds = self.obs_ds.sel(station = self.common_id) + for sim, ds in self.sim_ds.items(): + self.sim_ds[sim] = ds.sel(station=self.common_id) + self.obs_ds = self.obs_ds.sel(station = self.common_id) self.stats = stats - self.threshold = 70 + self.threshold = 70 #to be opt self.statistics = None if self.stats: self.calculate_statistics() @@ -185,7 +188,22 @@ def prepare_observations_data(self): obs_ds = obs_ds.sel(time=time_values) return obs_ds - + + + def find_common_station(self): + ids = [] + ids += [list(self.obs_ds['station'].values)] + ids += [list(ds['station'].values) for ds in self.sim_ds.values()] + ids += [self.station_index] + + common_ids = None + for id in ids: + if common_ids is None: + common_ids = set(id) + else: + common_ids = set(id) & common_ids + + return list(common_ids) def calculate_statistics(self): statistics = {} @@ -199,6 +217,8 @@ def calculate_statistics(self): sim_ds_f, obs_ds_f = filter_timeseries(ds, self.obs_ds, self.threshold) statistics[exp] = run_analysis(self.stats, sim_ds_f, obs_ds_f) self.statistics = statistics + + diff --git a/notebooks/examples/4_visualisation_interactive.ipynb b/notebooks/examples/4_visualisation_interactive.ipynb index 5c693b8..1eb9a6f 100644 --- a/notebooks/examples/4_visualisation_interactive.ipynb +++ b/notebooks/examples/4_visualisation_interactive.ipynb @@ -14,7 +14,7 @@ "outputs": [], "source": [ "from hat.visualisation import NotebookMap\n", - "stations = \"/home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/20230222_outlets_elbe_de.csv\"\n", + "stations = \"/home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/20230714_hres_calib_stations.csv\"\n", "observations = \"/home/dadiyorto/freelance/02_ort_ecmwf/test/observed/Qts_calib_9021.csv\"\n", "simulations = {\n", " \"exp1\": \"/home/dadiyorto/freelance/02_ort_ecmwf/test/simulation_datadir/simulation_timeseries.nc\",\n", @@ -44,15 +44,40 @@ "metadata": {}, "outputs": [ { - "ename": "TypeError", - "evalue": "read_station_metadata_file() got an unexpected keyword argument 'filters'", + "name": "stdout", + "output_type": "stream", + "text": [ + "station file\n", + "/home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/\n" + ] + }, + { + "ename": "Exception", + "evalue": "Could not open file /home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/", "output_type": "error", "traceback": [ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)", + "\u001b[0;31mCPLE_OpenFailedError\u001b[0m Traceback (most recent call last)", + "File \u001b[0;32mfiona/ogrext.pyx:136\u001b[0m, in \u001b[0;36mfiona.ogrext.gdal_open_vector\u001b[0;34m()\u001b[0m\n", + "File \u001b[0;32mfiona/_err.pyx:291\u001b[0m, in \u001b[0;36mfiona._err.exc_wrap_pointer\u001b[0;34m()\u001b[0m\n", + "\u001b[0;31mCPLE_OpenFailedError\u001b[0m: '/home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/' not recognized as a supported file format.", + "\nDuring handling of the above exception, another exception occurred:\n", + "\u001b[0;31mDriverError\u001b[0m Traceback (most recent call last)", + "File \u001b[0;32m~/freelance/02_ort_ecmwf/dev/hat/hat/observations.py:49\u001b[0m, in \u001b[0;36mread_station_metadata_file\u001b[0;34m(fpath, coord_names, epsg, filters)\u001b[0m\n\u001b[1;32m 48\u001b[0m \u001b[39melse\u001b[39;00m:\n\u001b[0;32m---> 49\u001b[0m gdf \u001b[39m=\u001b[39m gpd\u001b[39m.\u001b[39;49mread_file(fpath)\n\u001b[1;32m 50\u001b[0m \u001b[39mexcept\u001b[39;00m \u001b[39mException\u001b[39;00m:\n", + "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/geopandas/io/file.py:297\u001b[0m, in \u001b[0;36m_read_file\u001b[0;34m(filename, bbox, mask, rows, engine, **kwargs)\u001b[0m\n\u001b[1;32m 295\u001b[0m path_or_bytes \u001b[39m=\u001b[39m filename\n\u001b[0;32m--> 297\u001b[0m \u001b[39mreturn\u001b[39;00m _read_file_fiona(\n\u001b[1;32m 298\u001b[0m path_or_bytes, from_bytes, bbox\u001b[39m=\u001b[39;49mbbox, mask\u001b[39m=\u001b[39;49mmask, rows\u001b[39m=\u001b[39;49mrows, \u001b[39m*\u001b[39;49m\u001b[39m*\u001b[39;49mkwargs\n\u001b[1;32m 299\u001b[0m )\n\u001b[1;32m 301\u001b[0m \u001b[39melse\u001b[39;00m:\n", + "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/geopandas/io/file.py:338\u001b[0m, in \u001b[0;36m_read_file_fiona\u001b[0;34m(path_or_bytes, from_bytes, bbox, mask, rows, where, **kwargs)\u001b[0m\n\u001b[1;32m 337\u001b[0m \u001b[39mwith\u001b[39;00m fiona_env():\n\u001b[0;32m--> 338\u001b[0m \u001b[39mwith\u001b[39;00m reader(path_or_bytes, \u001b[39m*\u001b[39;49m\u001b[39m*\u001b[39;49mkwargs) \u001b[39mas\u001b[39;00m features:\n\u001b[1;32m 339\u001b[0m crs \u001b[39m=\u001b[39m features\u001b[39m.\u001b[39mcrs_wkt\n", + "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/fiona/env.py:457\u001b[0m, in \u001b[0;36mensure_env_with_credentials..wrapper\u001b[0;34m(*args, **kwds)\u001b[0m\n\u001b[1;32m 456\u001b[0m \u001b[39mwith\u001b[39;00m env_ctor(session\u001b[39m=\u001b[39msession):\n\u001b[0;32m--> 457\u001b[0m \u001b[39mreturn\u001b[39;00m f(\u001b[39m*\u001b[39;49margs, \u001b[39m*\u001b[39;49m\u001b[39m*\u001b[39;49mkwds)\n", + "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/fiona/__init__.py:292\u001b[0m, in \u001b[0;36mopen\u001b[0;34m(fp, mode, driver, schema, crs, encoding, layer, vfs, enabled_drivers, crs_wkt, allow_unsupported_drivers, **kwargs)\u001b[0m\n\u001b[1;32m 291\u001b[0m \u001b[39mif\u001b[39;00m mode \u001b[39min\u001b[39;00m (\u001b[39m\"\u001b[39m\u001b[39ma\u001b[39m\u001b[39m\"\u001b[39m, \u001b[39m\"\u001b[39m\u001b[39mr\u001b[39m\u001b[39m\"\u001b[39m):\n\u001b[0;32m--> 292\u001b[0m colxn \u001b[39m=\u001b[39m Collection(\n\u001b[1;32m 293\u001b[0m path,\n\u001b[1;32m 294\u001b[0m mode,\n\u001b[1;32m 295\u001b[0m driver\u001b[39m=\u001b[39;49mdriver,\n\u001b[1;32m 296\u001b[0m encoding\u001b[39m=\u001b[39;49mencoding,\n\u001b[1;32m 297\u001b[0m layer\u001b[39m=\u001b[39;49mlayer,\n\u001b[1;32m 298\u001b[0m enabled_drivers\u001b[39m=\u001b[39;49menabled_drivers,\n\u001b[1;32m 299\u001b[0m allow_unsupported_drivers\u001b[39m=\u001b[39;49mallow_unsupported_drivers,\n\u001b[1;32m 300\u001b[0m \u001b[39m*\u001b[39;49m\u001b[39m*\u001b[39;49mkwargs\n\u001b[1;32m 301\u001b[0m )\n\u001b[1;32m 302\u001b[0m \u001b[39melif\u001b[39;00m mode \u001b[39m==\u001b[39m \u001b[39m\"\u001b[39m\u001b[39mw\u001b[39m\u001b[39m\"\u001b[39m:\n", + "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/fiona/collection.py:243\u001b[0m, in \u001b[0;36mCollection.__init__\u001b[0;34m(self, path, mode, driver, schema, crs, encoding, layer, vsi, archive, enabled_drivers, crs_wkt, ignore_fields, ignore_geometry, include_fields, wkt_version, allow_unsupported_drivers, **kwargs)\u001b[0m\n\u001b[1;32m 242\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39msession \u001b[39m=\u001b[39m Session()\n\u001b[0;32m--> 243\u001b[0m \u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49msession\u001b[39m.\u001b[39;49mstart(\u001b[39mself\u001b[39;49m, \u001b[39m*\u001b[39;49m\u001b[39m*\u001b[39;49mkwargs)\n\u001b[1;32m 244\u001b[0m \u001b[39melif\u001b[39;00m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mmode \u001b[39min\u001b[39;00m (\u001b[39m\"\u001b[39m\u001b[39ma\u001b[39m\u001b[39m\"\u001b[39m, \u001b[39m\"\u001b[39m\u001b[39mw\u001b[39m\u001b[39m\"\u001b[39m):\n", + "File \u001b[0;32mfiona/ogrext.pyx:588\u001b[0m, in \u001b[0;36mfiona.ogrext.Session.start\u001b[0;34m()\u001b[0m\n", + "File \u001b[0;32mfiona/ogrext.pyx:143\u001b[0m, in \u001b[0;36mfiona.ogrext.gdal_open_vector\u001b[0;34m()\u001b[0m\n", + "\u001b[0;31mDriverError\u001b[0m: '/home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/' not recognized as a supported file format.", + "\nDuring handling of the above exception, another exception occurred:\n", + "\u001b[0;31mException\u001b[0m Traceback (most recent call last)", "\u001b[1;32m/home/dadiyorto/freelance/02_ort_ecmwf/dev/hat/notebooks/examples/4_visualisation_interactive.ipynb Cell 4\u001b[0m line \u001b[0;36m1\n\u001b[0;32m----> 1\u001b[0m \u001b[39mmap\u001b[39m \u001b[39m=\u001b[39m NotebookMap(config, stations, observations, simulations, stats\u001b[39m=\u001b[39;49mstatistics)\n", "File \u001b[0;32m~/freelance/02_ort_ecmwf/dev/hat/hat/visualisation.py:94\u001b[0m, in \u001b[0;36mNotebookMap.__init__\u001b[0;34m(self, config, stations_metadata, observations, simulations, stats)\u001b[0m\n\u001b[1;32m 92\u001b[0m \u001b[39mdef\u001b[39;00m \u001b[39m__init__\u001b[39m(\u001b[39mself\u001b[39m, config: Dict, stations_metadata: \u001b[39mstr\u001b[39m, observations: \u001b[39mstr\u001b[39m, simulations: Union[Dict, \u001b[39mstr\u001b[39m], stats\u001b[39m=\u001b[39m\u001b[39mNone\u001b[39;00m):\n\u001b[1;32m 93\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mconfig \u001b[39m=\u001b[39m config\n\u001b[0;32m---> 94\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata \u001b[39m=\u001b[39m read_station_metadata_file(\n\u001b[1;32m 95\u001b[0m fpath\u001b[39m=\u001b[39;49mstations_metadata,\n\u001b[1;32m 96\u001b[0m coord_names\u001b[39m=\u001b[39;49mconfig[\u001b[39m'\u001b[39;49m\u001b[39mstation_coordinates\u001b[39;49m\u001b[39m'\u001b[39;49m],\n\u001b[1;32m 97\u001b[0m epsg\u001b[39m=\u001b[39;49mconfig[\u001b[39m'\u001b[39;49m\u001b[39mstation_epsg\u001b[39;49m\u001b[39m'\u001b[39;49m],\n\u001b[1;32m 98\u001b[0m filters\u001b[39m=\u001b[39;49mconfig[\u001b[39m'\u001b[39;49m\u001b[39mstation_filters\u001b[39;49m\u001b[39m'\u001b[39;49m]\n\u001b[1;32m 99\u001b[0m )\n\u001b[1;32m 100\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstation_file_name \u001b[39m=\u001b[39m os\u001b[39m.\u001b[39mpath\u001b[39m.\u001b[39mbasename(stations_metadata) \u001b[39m# Derive the file name here\u001b[39;00m\n\u001b[1;32m 101\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata[\u001b[39m'\u001b[39m\u001b[39mObsID\u001b[39m\u001b[39m'\u001b[39m] \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata[\u001b[39m'\u001b[39m\u001b[39mObsID\u001b[39m\u001b[39m'\u001b[39m]\u001b[39m.\u001b[39mastype(\u001b[39mstr\u001b[39m)\n", - "\u001b[0;31mTypeError\u001b[0m: read_station_metadata_file() got an unexpected keyword argument 'filters'" + "File \u001b[0;32m~/freelance/02_ort_ecmwf/dev/hat/hat/observations.py:51\u001b[0m, in \u001b[0;36mread_station_metadata_file\u001b[0;34m(fpath, coord_names, epsg, filters)\u001b[0m\n\u001b[1;32m 49\u001b[0m gdf \u001b[39m=\u001b[39m gpd\u001b[39m.\u001b[39mread_file(fpath)\n\u001b[1;32m 50\u001b[0m \u001b[39mexcept\u001b[39;00m \u001b[39mException\u001b[39;00m:\n\u001b[0;32m---> 51\u001b[0m \u001b[39mraise\u001b[39;00m \u001b[39mException\u001b[39;00m(\u001b[39mf\u001b[39m\u001b[39m\"\u001b[39m\u001b[39mCould not open file \u001b[39m\u001b[39m{\u001b[39;00mfpath\u001b[39m}\u001b[39;00m\u001b[39m\"\u001b[39m)\n\u001b[1;32m 53\u001b[0m \u001b[39m# (optionally) filter the stations, e.g. 'Contintent == Europe'\u001b[39;00m\n\u001b[1;32m 54\u001b[0m \u001b[39mif\u001b[39;00m filters \u001b[39mis\u001b[39;00m \u001b[39mnot\u001b[39;00m \u001b[39mNone\u001b[39;00m:\n", + "\u001b[0;31mException\u001b[0m: Could not open file /home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/" ] } ], @@ -62,7 +87,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "metadata": {}, "outputs": [ { @@ -111,7 +136,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "b44ef784b6a142d2b6038fe28fdb0c26", + "model_id": "85d6291cc7954b0ca8fd37c9a139c73a", "version_major": 2, "version_minor": 0 }, @@ -126,22 +151,63 @@ "name": "stdout", "output_type": "stream", "text": [ - "Error encountered: 'Dataset' object has no attribute 'columns'\n" + "Error encountered: \"no index found for coordinate 'latitude'\"\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Error encountered: \"no index found for coordinate 'latitude'\"\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Error encountered: \"no index found for coordinate 'latitude'\"\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Error encountered: \"no index found for coordinate 'latitude'\"\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Error encountered: \"no index found for coordinate 'latitude'\"\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Error encountered: \"no index found for coordinate 'latitude'\"\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Error encountered: \"no index found for coordinate 'latitude'\"\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - "Error encountered: 'Dataset' object has no attribute 'columns'\n" + "Error encountered: \"no index found for coordinate 'latitude'\"\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - "Station ID: 121 not found in dataset exp1.\n", - "Error encountered: 'Dataset' object has no attribute 'columns'\n" + "Error encountered: \"no index found for coordinate 'latitude'\"\n" ] } ], @@ -161,27 +227,431 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
<xarray.Dataset>\n",
+       "Dimensions:  (time: 1464, station: 1913)\n",
+       "Coordinates:\n",
+       "  * time     (time) datetime64[ns] 2000-01-01T12:00:00 ... 2001-01-01T06:00:00\n",
+       "  * station  (station) object '1' '10' '1002' '1003' ... '990' '992' '993' '998'\n",
+       "Data variables:\n",
+       "    obsdis   (time, station) float64 nan nan nan nan nan ... nan nan nan nan
" + ], + "text/plain": [ + "\n", + "Dimensions: (time: 1464, station: 1913)\n", + "Coordinates:\n", + " * time (time) datetime64[ns] 2000-01-01T12:00:00 ... 2001-01-01T06:00:00\n", + " * station (station) object '1' '10' '1002' '1003' ... '990' '992' '993' '998'\n", + "Data variables:\n", + " obsdis (time, station) float64 nan nan nan nan nan ... nan nan nan nan" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "map.stations_metadata\n" + "map.obs_ds\n" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "ename": "", - "evalue": "", - "output_type": "error", - "traceback": [ - "\u001b[1;31mThe Kernel crashed while executing code in the the current cell or a previous cell. Please review the code in the cell(s) to identify a possible cause of the failure. Click here for more info. View Jupyter log for further details." - ] - } - ], + "outputs": [], "source": [ "from hat.visualisation_v2 import NotebookMap as nbm\n", "stations= '~/destinE/outlets_v4.0_20230726_withEFAS.csv'\n", From e6941f33887d3e4b387dcb316470ab4910070b64 Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Tue, 10 Oct 2023 12:20:13 +0200 Subject: [PATCH 06/31] debugging common station index --- hat/visualisation.py | 14 +- .../4_visualisation_interactive.ipynb | 610 ++---------------- 2 files changed, 57 insertions(+), 567 deletions(-) diff --git a/hat/visualisation.py b/hat/visualisation.py index a524609..555b47c 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -108,10 +108,13 @@ def __init__(self, config: Dict, stations_metadata: str, observations: str, simu self.sim_ds = self.prepare_simulations_data() self.obs_ds = self.prepare_observations_data() self.common_id = self.find_common_station() - self.stations_metadata = self.stations_metadata.sel(station_id=self.common_id) + print(self.common_id) + self.stations_metadata = self.stations_metadata.loc[self.stations_metadata[self.station_index] == self.common_id] self.obs_ds = self.obs_ds.sel(station = self.common_id) for sim, ds in self.sim_ds.items(): self.sim_ds[sim] = ds.sel(station=self.common_id) + + self.obs_ds = self.obs_ds.sel(station = self.common_id) self.stats = stats self.threshold = 70 #to be opt @@ -193,15 +196,22 @@ def prepare_observations_data(self): def find_common_station(self): ids = [] ids += [list(self.obs_ds['station'].values)] + print(self.obs_ds.station_id) ids += [list(ds['station'].values) for ds in self.sim_ds.values()] - ids += [self.station_index] + print(self.sim_ds) + ids += [self.stations_metadata[self.station_index]] + common_ids = None for id in ids: + print(id) + if common_ids is None: common_ids = set(id) else: common_ids = set(id) & common_ids + + print(common_ids) return list(common_ids) diff --git a/notebooks/examples/4_visualisation_interactive.ipynb b/notebooks/examples/4_visualisation_interactive.ipynb index 1eb9a6f..fd125c4 100644 --- a/notebooks/examples/4_visualisation_interactive.ipynb +++ b/notebooks/examples/4_visualisation_interactive.ipynb @@ -48,36 +48,41 @@ "output_type": "stream", "text": [ "station file\n", - "/home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/\n" + "/home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/20230714_hres_calib_stations.csv\n", + "['1', '10', '1002', '1003', '1004', '1005', '1006', '1007', '1008', '1009', '101', '1010', '1011', '1012', '1013', '1014', '1017', '1018', '102', '1020', '1021', '1022', '1023', '1024', '1026', '1027', '103', '1030', '1031', '1032', '1033', '1036', '1037', '1038', '1040', '1041', '1044', '1048', '1050', '1052', '1054', '1056', '1057', '1060', '1061', '1062', '1067', '1068', '1069', '107', '1070', '1075', '1076', '1077', '1078', '108', '1082', '1083', '1086', '1087', '1088', '109', '1091', '1092', '1093', '1094', '1095', '1099', '11', '110', '1101', '1102', '1103', '1104', '1107', '1108', '1109', '111', '1110', '1114', '1115', '1117', '1118', '1119', '1120', '1122', '1123', '1128', '113', '1130', '1131', '1132', '1135', '1136', '1139', '114', '1140', '1141', '1142', '1145', '1148', '1154', '1155', '1157', '1159', '1161', '1163', '1164', '1165', '1167', '1169', '117', '1172', '1173', '1174', '1175', '1176', '1177', '1178', '1179', '118', '1180', '1181', '1182', '1183', '1184', '1185', '1186', '1187', '1188', '1189', '119', '1190', '1191', '1192', '1193', '1194', '1195', '1196', '1197', '1198', '1199', '12', '120', '1200', '1201', '1202', '1203', '1205', '1206', '1207', '1209', '1210', '1212', '1214', '122', '1220', '1222', '1223', '1228', '123', '1230', '1231', '1235', '1236', '1237', '1238', '1239', '1240', '1241', '1245', '1247', '1249', '125', '1250', '1253', '1255', '126', '1260', '1262', '1263', '1265', '1266', '1267', '1269', '127', '1271', '1273', '1275', '1277', '1279', '128', '1281', '1283', '1285', '1290', '1291', '1292', '1298', '1299', '130', '1300', '1301', '1308', '131', '1311', '132', '1321', '1322', '1324', '1327', '1328', '133', '1330', '1331', '1332', '1334', '1335', '1337', '1339', '134', '1340', '1341', '1342', '1343', '1344', '1345', '1346', '1349', '135', '1350', '1351', '1353', '1354', '1356', '1357', '1358', '1359', '136', '1360', '1361', '1362', '1363', '1364', '1366', '1367', '1368', '1369', '137', '1370', '1372', '1373', '1374', '1375', '1376', '1377', '1378', '138', '139', '14', '140', '1400', '1401', '1402', '1403', '1405', '1407', '1408', '1409', '141', '1410', '1412', '1414', '1415', '1416', '1417', '1419', '142', '1420', '1423', '1424', '1426', '1428', '1429', '143', '1430', '1431', '1432', '1433', '1434', '1435', '1436', '1437', '1438', '1439', '144', '1440', '1441', '1444', '1445', '1446', '1447', '1448', '145', '1450', '1451', '1452', '1453', '1454', '1455', '1458', '1459', '146', '1460', '1461', '1462', '1463', '1464', '1465', '1467', '1468', '147', '1470', '1471', '1472', '1473', '1474', '1475', '1477', '1478', '1479', '148', '1480', '1481', '1482', '1483', '1484', '1485', '1486', '1487', '1488', '1489', '149', '1490', '1492', '1493', '1494', '1495', '1496', '1497', '1499', '15', '150', '1500', '1501', '1502', '1503', '1505', '1507', '151', '1514', '1519', '152', '1522', '1526', '1529', '153', '1532', '1534', '1536', '1537', '1546', '155', '1550', '1552', '1558', '156', '1563', '1565', '1567', '1568', '1573', '1575', '158', '1580', '1589', '159', '1592', '160', '1605', '1606', '1607', '1608', '1609', '161', '1610', '1611', '1612', '1613', '1614', '1615', '1616', '1617', '1618', '1619', '1620', '1621', '1622', '1623', '1624', '1625', '1626', '1627', '1628', '163', '1630', '1631', '1632', '1633', '1634', '1635', '1636', '1637', '1638', '1639', '164', '1640', '1642', '1643', '1644', '1645', '1647', '166', '1664', '168', '1680', '1681', '1683', '1685', '1688', '169', '1690', '1696', '1697', '1698', '17', '170', '1700', '1702', '1703', '1704', '1705', '1706', '1708', '1710', '1711', '1712', '1713', '1714', '1715', '1716', '1717', '1718', '1719', '1720', '1722', '1723', '1724', '1726', '1727', '173', '1734', '1736', '1740', '175', '176', '179', '18', '180', '181', '182', '183', '184', '185', '187', '188', '1889', '189', '1890', '1891', '1892', '1893', '1894', '1896', '1897', '1898', '19', '190', '1900', '1901', '1903', '1905', '1908', '191', '1919', '192', '1920', '1923', '1925', '1926', '1928', '1930', '1931', '1935', '1937', '1938', '1939', '1940', '1941', '1942', '1943', '1944', '1945', '1947', '1948', '1949', '195', '1950', '1951', '1952', '1953', '1954', '1956', '1957', '1958', '1959', '1960', '1961', '1962', '1963', '1964', '1965', '1966', '1967', '1968', '1969', '197', '1970', '1971', '1972', '1973', '1975', '1976', '1977', '1978', '198', '1980', '1981', '1982', '1984', '1985', '1986', '1987', '1988', '1989', '1990', '1991', '1992', '1993', '1994', '1995', '1996', '1997', '1998', '1999', '2', '200', '2000', '2001', '2002', '2003', '2004', '2005', '2006', '2008', '201', '2010', '2011', '2012', '2013', '2014', '2015', '2016', '2017', '2018', '2019', '202', '2021', '2022', '2024', '2025', '2026', '2027', '2028', '2029', '203', '2030', '2031', '2032', '2034', '2036', '2069', '207', '2074', '208', '2088', '2090', '2091', '2094', '2095', '2096', '21', '2102', '2103', '211', '212', '2128', '2129', '213', '2130', '2133', '2134', '2135', '2137', '2138', '2139', '214', '2141', '2143', '2144', '2147', '2150', '2151', '2154', '2156', '2158', '2159', '216', '2160', '2161', '2162', '2163', '2164', '2165', '2166', '2167', '2169', '2170', '2171', '2172', '2174', '2177', '2179', '2180', '2182', '2190', '2191', '2192', '2193', '2194', '2195', '2196', '2198', '2201', '2208', '2209', '221', '2210', '223', '2247', '225', '2251', '2252', '2253', '2254', '2255', '2256', '2258', '2259', '2260', '2261', '2262', '2263', '2264', '2265', '2266', '227', '2270', '2276', '2278', '2279', '228', '2280', '2282', '2283', '2284', '2286', '2287', '2288', '2290', '2295', '2296', '2297', '2298', '2299', '2300', '2301', '2304', '2305', '2306', '2307', '2308', '231', '2311', '2312', '2315', '2317', '2319', '232', '2320', '2322', '2323', '2326', '2327', '2328', '2329', '233', '2330', '2331', '2333', '2335', '2336', '2337', '2338', '234', '2341', '2343', '2345', '2346', '2348', '235', '2351', '2352', '2354', '2356', '2358', '2359', '236', '2360', '2362', '2363', '2365', '2367', '237', '2370', '2372', '2373', '2376', '2377', '2378', '2379', '238', '2382', '2385', '2386', '2388', '2389', '239', '2391', '2393', '2395', '2396', '2398', '240', '2401', '2402', '2403', '2405', '2406', '2407', '2408', '2409', '241', '2410', '2414', '2418', '242', '2420', '2421', '2422', '2425', '2428', '243', '2432', '2433', '2434', '2435', '2438', '244', '2440', '2445', '2446', '2447', '2449', '245', '2450', '2452', '2455', '2456', '2457', '2459', '246', '2460', '2462', '2465', '2466', '2469', '247', '2472', '2473', '2474', '2475', '2476', '2477', '248', '249', '2513', '2514', '2521', '2524', '2525', '2532', '2534', '2535', '2538', '2546', '2547', '2548', '2551', '2553', '2555', '2556', '2557', '2558', '2561', '2562', '2563', '2567', '2568', '257', '2570', '2576', '258', '2580', '2596', '2597', '26', '2600', '2608', '261', '2613', '2623', '263', '2630', '2631', '2632', '2633', '2634', '2635', '264', '265', '267', '268', '2681', '269', '27', '270', '2709', '2711', '2712', '2713', '2714', '2716', '272', '2721', '2722', '2723', '2724', '2725', '2726', '2727', '2728', '2729', '273', '2730', '2731', '2732', '2733', '2735', '2736', '2737', '2738', '2739', '274', '2740', '2741', '2743', '2744', '2745', '2746', '2747', '2748', '2749', '2750', '2751', '2753', '2754', '2756', '2758', '2764', '2769', '277', '2770', '2771', '2773', '2774', '2775', '2776', '2778', '278', '2780', '2781', '2782', '2783', '2784', '2785', '2786', '2787', '2788', '2789', '279', '2790', '2791', '2792', '2794', '2795', '2796', '2797', '2798', '28', '280', '2804', '2805', '2806', '2807', '2808', '2809', '281', '2810', '2811', '2812', '2813', '2814', '2815', '2816', '2818', '282', '2820', '2821', '2822', '2823', '2824', '2825', '2826', '2827', '2828', '2829', '283', '2830', '2832', '2833', '2834', '2835', '2838', '284', '2840', '2841', '2842', '2843', '2844', '2846', '2847', '2848', '2849', '285', '2850', '2851', '2852', '2853', '2854', '2855', '2856', '2858', '2859', '286', '2860', '2861', '2863', '2864', '2865', '2866', '2867', '2869', '287', '2870', '2873', '2874', '2875', '2876', '2877', '2878', '2879', '288', '2880', '2881', '2882', '2883', '2884', '2885', '2886', '2887', '2888', '2889', '289', '2890', '2891', '2892', '2893', '2894', '2895', '2896', '2897', '2898', '2899', '29', '290', '2900', '2901', '2902', '2903', '2904', '2905', '2908', '291', '2910', '2911', '2912', '2913', '2914', '2916', '2917', '2918', '2919', '292', '2920', '2921', '2922', '2923', '2924', '2925', '2926', '2928', '2929', '293', '2932', '2933', '2935', '2936', '2937', '294', '2941', '2943', '2944', '2945', '2946', '2947', '2948', '295', '2951', '2952', '2954', '2957', '296', '2961', '2962', '2963', '2964', '2965', '2966', '2969', '297', '2970', '2971', '2974', '2975', '2976', '2977', '2978', '2979', '298', '2980', '2981', '2982', '2983', '2984', '2985', '2986', '2987', '2988', '2989', '299', '2993', '2994', '2996', '2997', '2998', '2999', '300', '3000', '3001', '3002', '3003', '3004', '3005', '3006', '3008', '3009', '301', '3010', '3011', '3012', '3013', '3014', '3015', '3016', '3017', '3018', '3019', '302', '3020', '3021', '3022', '3023', '3024', '3026', '3028', '303', '3030', '3031', '3032', '3034', '304', '3041', '3042', '3043', '3045', '3046', '307', '308', '3086', '3087', '309', '3092', '3093', '3095', '3096', '3097', '3098', '3099', '31', '310', '3100', '3101', '3102', '3103', '3105', '3106', '3108', '3109', '311', '3110', '3111', '3112', '3113', '3114', '3115', '3116', '3117', '3118', '3119', '312', '3120', '3121', '3123', '3124', '3125', '3126', '3127', '3128', '3130', '3131', '3132', '3133', '3134', '3135', '3136', '3137', '3138', '3139', '314', '3140', '3141', '3142', '3143', '3144', '3145', '3146', '3147', '3149', '315', '3150', '3151', '3152', '3153', '3154', '3155', '3156', '3157', '3158', '3159', '316', '3160', '3161', '3162', '3163', '3164', '3165', '3166', '3167', '3168', '3169', '317', '3171', '3172', '3173', '3174', '3175', '3177', '3178', '3179', '318', '3180', '3181', '3182', '3183', '3184', '3185', '3186', '3187', '3188', '319', '3191', '3192', '3193', '3194', '3195', '3196', '3197', '3198', '3199', '32', '320', '3200', '3201', '3202', '3203', '3204', '3205', '3206', '3207', '3208', '3209', '3210', '3211', '3212', '3213', '3214', '3216', '3217', '3219', '3220', '3221', '3222', '3223', '3224', '3225', '3226', '3227', '3228', '3229', '323', '3230', '3231', '3233', '3234', '3235', '3237', '3238', '3239', '3240', '3241', '3242', '3243', '3244', '3245', '3246', '3247', '3248', '3249', '325', '3250', '3251', '3252', '3253', '3254', '3255', '3256', '3257', '3258', '3259', '3265', '3266', '3269', '327', '3271', '3273', '3279', '328', '3281', '329', '33', '330', '3301', '3302', '3304', '3307', '3308', '3309', '331', '3310', '3311', '3314', '3315', '3317', '3320', '3321', '3324', '3325', '3328', '333', '3332', '3334', '3338', '334', '3340', '335', '3355', '336', '337', '338', '339', '34', '343', '345', '347', '348', '349', '350', '351', '352', '353', '355', '356', '357', '358', '359', '36', '361', '362', '366', '369', '370', '371', '372', '38', '380', '381', '39', '390', '393', '394', '395', '396', '397', '398', '399', '4', '40', '4001', '4004', '4006', '4007', '4008', '4009', '4011', '4013', '4014', '4015', '4016', '4018', '4020', '4021', '410', '4111', '4113', '4114', '4116', '4119', '4122', '4131', '4133', '4140', '4143', '4144', '4148', '4149', '4163', '4170', '4174', '4192', '4194', '4198', '4199', '42', '4202', '4205', '421', '4216', '4218', '4224', '4229', '4232', '4235', '4236', '4241', '4248', '4252', '4254', '4255', '4257', '4258', '4267', '4273', '428', '4282', '4289', '4290', '4294', '4296', '4297', '4298', '430', '4300', '4306', '4309', '4313', '4316', '4317', '4318', '4319', '4320', '4321', '4322', '4323', '4324', '4328', '4335', '4339', '4341', '4342', '4343', '4346', '4347', '4351', '4352', '4354', '4355', '4356', '4357', '4359', '4361', '4363', '4368', '4385', '4386', '4388', '4415', '4416', '4417', '4418', '4419', '443', '45', '450', '4519', '4520', '4521', '4522', '4523', '4525', '4526', '4527', '4528', '4529', '4530', '4531', '4532', '4533', '4534', '4535', '4536', '4538', '454', '4540', '4541', '4543', '4546', '457', '458', '459', '460', '461', '462', '463', '464', '465', '466', '467', '468', '469', '47', '470', '471', '472', '473', '474', '475', '476', '477', '478', '479', '480', '483', '485', '486', '487', '488', '489', '49', '490', '491', '492', '493', '494', '495', '496', '497', '498', '5', '50', '500', '501', '502', '503', '504', '505', '506', '5066', '507', '508', '509', '51', '510', '511', '512', '513', '514', '515', '516', '517', '518', '519', '520', '521', '5214', '5216', '5217', '5218', '5219', '522', '5220', '526', '529', '530', '531', '532', '533', '534', '535', '536', '537', '538', '539', '542', '543', '544', '545', '546', '547', '548', '549', '550', '551', '552', '553', '554', '555', '556', '557', '558', '559', '560', '561', '562', '563', '564', '565', '566', '567', '568', '569', '570', '571', '572', '574', '575', '576', '577', '578', '579', '58', '580', '581', '582', '583', '584', '585', '586', '587', '588', '589', '59', '590', '591', '592', '593', '594', '595', '596', '597', '598', '6', '60', '600', '602', '603', '604', '605', '606', '607', '608', '609', '610', '611', '613', '614', '615', '616', '617', '618', '619', '62', '620', '621', '622', '623', '624', '625', '626', '628', '629', '63', '630', '631', '632', '633', '634', '635', '636', '637', '638', '639', '640', '641', '642', '643', '645', '646', '647', '648', '649', '653', '657', '660', '663', '664', '665', '667', '67', '670', '671', '674', '675', '676', '677', '678', '682', '684', '686', '690', '693', '696', '697', '699', '700', '701', '703', '706', '707', '71', '713', '715', '73', '74', '75', '76', '772', '773', '774', '775', '777', '778', '779', '78', '780', '781', '782', '784', '785', '786', '787', '788', '789', '79', '790', '791', '792', '793', '794', '795', '796', '797', '798', '799', '80', '800', '801', '802', '803', '804', '805', '807', '808', '809', '81', '811', '817', '818', '82', '822', '823', '829', '83', '830', '831', '832', '833', '834', '836', '839', '840', '841', '842', '845', '85', '850', '851', '852', '853', '854', '856', '857', '858', '859', '86', '860', '864', '868', '869', '87', '870', '886', '89', '892', '895', '9', '900', '903', '904', '906', '91', '928', '93', '931', '933', '934', '935', '936', '938', '939', '94', '941', '942', '943', '944', '945', '95', '951', '952', '953', '956', '96', '960', '965', '967', '968', '969', '970', '971', '972', '973', '975', '977', '978', '979', '98', '983', '986', '99', '990', '992', '993', '998']\n", + "{'2923', '1409', '1279', '1984', '3000', '594', '99', '1464', '1103', '4416', '3317', '538', '842', '3210', '868', '1185', '1119', '4531', '1451', '3204', '1428', '1536', '2440', '559', '934', '589', '355', '1624', '2553', '3208', '1267', '1685', '1241', '2141', '109', '517', '214', '600', '75', '307', '1402', '3182', '2893', '1363', '2794', '2989', '935', '3219', '483', '1376', '137', '616', '1349', '1618', '3202', '1364', '231', '2937', '637', '772', '555', '1222', '131', '246', '1145', '4294', '1023', '2363', '2726', '1609', '2295', '2897', '2813', '2750', '1187', '83', '3143', '664', '2143', '1095', '1971', '466', '2128', '2391', '3334', '533', '4317', '2853', '293', '1993', '832', '81', '9', '1501', '3108', '1099', '315', '1230', '2029', '1573', '278', '241', '1', '2787', '931', '345', '784', '1711', '1961', '2558', '295', '294', '2623', '286', '1197', '284', '1589', '2172', '1607', '108', '202', '32', '2547', '1368', '2333', '2835', '1141', '362', '2209', '380', '2377', '3109', '3199', '2890', '4298', '1175', '4257', '2948', '1031', '1605', '535', '1636', '604', '2945', '2795', '491', '261', '671', '2019', '339', '2006', '2908', '2151', '1980', '1265', '1717', '800', '515', '2450', '1972', '1172', '1497', '2709', '1263', '1716', '2283', '18', '3309', '2434', '395', '822', '1102', '576', '1238', '4359', '556', '2833', '1262', '146', '19', '2774', '1190', '2869', '836', '308', '1240', '2809', '613', '713', '3158', '2198', '1631', '1093', '583', '2769', '2452', '333', '1067', '625', '1901', '113', '3181', '3146', '2477', '3222', '478', '1645', '3092', '3311', '296', '986', '2170', '2858', '3002', '791', '1378', '3279', '285', '675', '2280', '1283', '4352', '1614', '2474', '2899', '51', '170', '2290', '1627', '633', '1123', '2343', '2781', '1939', '2957', '2351', '1992', '2935', '1468', '3229', '639', '1203', '4335', '696', '592', '1301', '1465', '2917', '3155', '3246', '267', '33', '1373', '2724', '3273', '1647', '2789', '3175', '2336', '3137', '2008', '2737', '2914', '1164', '2778', '2775', '2928', '2832', '2165', '892', '850', '1638', '3103', '839', '3178', '1889', '232', '197', '2786', '327', '2096', '212', '257', '1009', '4116', '2825', '831', '2359', '1006', '281', '240', '1681', '3100', '4346', '2174', '3111', '50', '103', '1415', '15', '2810', '706', '623', '225', '2996', '512', '4323', '520', '470', '228', '1481', '1255', '572', '1077', '603', '1350', '4324', '4232', '467', '2812', '2613', '1563', '4355', '3244', '130', '351', '2977', '2428', '1030', '595', '1432', '2807', '239', '3266', '263', '1970', '2904', '311', '799', '3162', '227', '2393', '2435', '4148', '550', '4255', '1941', '4543', '2286', '642', '1441', '590', '1529', '562', '1131', '3251', '1925', '1896', '3118', '4357', '280', '977', '3086', '14', '1161', '1341', '3242', '1892', '610', '2090', '2970', '2330', '1291', '2852', '2074', '1044', '1637', '2287', '552', '1905', '2964', '86', '2024', '128', '4131', '1919', '4297', '817', '1032', '36', '1372', '1176', '2378', '2885', '1087', '2406', '465', '1040', '2460', '4267', '1005', '2513', '2134', '1400', '2032', '2952', '1642', '4122', '577', '2999', '292', '93', '2864', '4020', '4351', '536', '3207', '588', '1484', '3180', '1200', '2984', '1062', '2373', '300', '2966', '1720', '1472', '473', '2270', '1423', '358', '1332', '1361', '1964', '792', '335', '1938', '2301', '4248', '2811', '21', '369', '2894', '1037', '1697', '2873', '3191', '2944', '834', '611', '1401', '1078', '965', '4361', '2445', '2567', '1612', '1975', '2358', '1713', '3223', '2103', '2317', '3269', '4016', '5218', '667', '1410', '2144', '2462', '184', '1275', '328', '2608', '2322', '4532', '1091', '2962', '811', '3144', '2003', '3021', '1639', '78', '273', '2576', '1477', '582', '684', '665', '686', '886', '3012', '1008', '4202', '1167', '2179', '3135', '4523', '803', '134', '390', '648', '3125', '138', '2816', '2337', '2784', '549', '697', '1963', '2867', '777', '575', '153', '163', '148', '3138', '571', '3004', '1700', '628', '2266', '1356', '516', '1104', '359', '366', '1374', '2150', '34', '291', '1331', '3220', '4235', '450', '787', '2851', '302', '2880', '1290', '2568', '2901', '1945', '463', '2030', '840', '2756', '45', '545', '2284', '2754', '89', '1943', '2456', '503', '118', '2004', '4521', '1202', '593', '808', '1485', '3310', '493', '1478', '2278', '641', '274', '2783', '1988', '1623', '457', '653', '1298', '1931', '2820', '4252', '4538', '979', '1944', '1017', '4520', '1004', '73', '1696', '1930', '643', '1335', '2745', '3128', '2829', '1962', '1108', '1500', '3024', '1632', '264', '4015', '2681', '4216', '785', '1223', '2402', '320', '2258', '2282', '2385', '1249', '3119', '904', '164', '4143', '1558', '2328', '1024', '2331', '3234', '2847', '2308', '4386', '2632', '3017', '485', '1300', '497', '903', '2988', '2027', '4199', '983', '3320', '47', '272', '614', '3101', '1644', '1266', '2447', '1438', '2855', '2834', '1928', '2747', '2190', '396', '486', '4354', '1897', '591', '301', '2195', '4119', '1179', '1068', '1050', '265', '120', '2929', '2790', '1999', '309', '3197', '150', '2171', '2409', '968', '2256', '864', '4536', '3098', '4541', '2738', '1360', '647', '1990', '3231', '330', '4229', '2600', '2319', '1403', '152', '1088', '1991', '1033', '1003', '1327', '182', '775', '102', '2721', '690', '4316', '2524', '547', '2875', '62', '2866', '829', '574', '237', '1714', '502', '620', '567', '4321', '161', '2922', '3153', '1198', '597', '554', '2210', '1455', '3315', '2016', '329', '4318', '1740', '270', '2951', '805', '2354', '2748', '564', '2746', '1680', '4194', '1625', '1351', '1966', '634', '4170', '2446', '1201', '1550', '3113', '2138', '2159', '585', '4008', '598', '2396', '830', '3214', '495', '2139', '2407', '3265', '459', '1207', '1173', '3321', '149', '569', '287', '331', '2360', '132', '3161', '1154', '1010', '421', '1342', '657', '1615', '2327', '269', '2326', '1060', '58', '2954', '823', '2860', '1181', '551', '1640', '1567', '2538', '3228', '4528', '973', '3046', '114', '3157', '180', '4385', '4339', '471', '2201', '221', '2180', '4540', '677', '2887', '1702', '2388', '3250', '2095', '3114', '4144', '602', '1169', '1994', '3150', '969', '780', '1444', '91', '2156', '3224', '1920', '2916', '492', '2561', '500', '638', '3328', '786', '1321', '142', '1357', '581', '809', '1165', '635', '2335', '1235', '2731', '3001', '1967', '2352', '584', '1568', '2137', '578', '4241', '678', '2389', '469', '10', '201', '1532', '1247', '4007', '107', '2861', '443', '4534', '352', '1688', '779', '2069', '1250', '2736', '3355', '356', '2563', '2993', '3243', '2425', '312', '807', '1285', '2299', '1891', '2838', '119', '361', '2264', '4525', '1948', '3239', '1322', '1346', '558', '123', '2859', '1719', '1122', '2741', '4368', '1061', '2925', '343', '2311', '853', '510', '2372', '1132', '504', '615', '2821', '5217', '951', '1620', '2932', '1191', '2', '1027', '2879', '166', '4320', '1277', '778', '2422', '494', '2129', '2883', '1565', '1499', '2455', '3131', '1117', '1957', '952', '1192', '1174', '1505', '1260', '1978', '4535', '1502', '288', '3301', '490', '3249', '4519', '147', '2017', '975', '1367', '2730', '2892', '2713', '3255', '2814', '2433', '4419', '464', '631', '4289', '1537', '2192', '410', '2727', '609', '117', '4001', '3019', '507', '2788', '3141', '619', '2943', '4306', '282', '1471', '474', '4018', '2002', '2300', '2556', '2947', '3032', '3209', '1718', '5219', '3006', '477', '570', '1269', '1228', '85', '2729', '1086', '1893', '5066', '29', '1487', '682', '854', '1135', '299', '2758', '1188', '74', '2367', '3167', '1194', '2465', '3121', '49', '632', '1987', '856', '4388', '1459', '245', '2532', '289', '626', '234', '3096', '630', '3116', '155', '2163', '543', '1734', '2764', '1580', '2028', '476', '605', '372', '2421', '2166', '298', '1358', '59', '518', '3245', '145', '2936', '2535', '1002', '122', '900', '3338', '158', '1083', '4', '2982', '522', '3020', '1493', '2630', '660', '1026', '1617', '1205', '2408', '3156', '4111', '579', '3252', '3045', '101', '2261', '1139', '857', '243', '208', '506', '852', '1592', '2900', '509', '3095', '1703', '1480', '235', '4546', '2844', '2036', '1452', '462', '1157', '1052', '967', '3022', '190', '2018', '236', '1634', '2320', '970', '95', '3225', '3200', '4254', '2088', '2979', '1054', '1345', '350', '4218', '110', '397', '3216', '60', '3009', '1997', '223', '2941', '338', '537', '2312', '4356', '1440', '2459', '1486', '211', '629', '869', '3332', '1635', '1370', '2167', '314', '2976', '3136', '2965', '1507', '2933', '3010', '1434', '895', '1479', '1076', '1407', '2711', '4347', '1206', '1412', '1114', '428', '1340', '2732', '1935', '2796', '2135', '1977', '1622', '4522', '1926', '3151', '1109', '2792', '318', '3281', '1958', '1189', '1613', '4013', '2376', '1995', '3097', '5214', '2323', '2798', '508', '2840', '2182', '978', '2034', '1474', '461', '2841', '2414', '4113', '2365', '3241', '3256', '2000', '1986', '2902', '316', '1450', '2961', '2913', '3308', '2912', '3042', '1183', '1177', '1292', '3238', '2164', '2160', '203', '1417', '3147', '1245', '4342', '2896', '2254', '39', '393', '498', '4236', '2570', '2889', '1184', '3133', '4300', '3003', '1982', '1453', '1343', '1128', '2341', '189', '1140', '2830', '845', '3248', '2782', '1616', '544', '1894', '1606', '2903', '2263', '2011', '17', '1706', '2827', '519', '1446', '4174', '151', '1898', '3014', '2449', '136', '1461', '3043', '2356', '1041', '1710', '2022', '6', '1424', '2971', '2102', '268', '3165', '2469', '200', '2918', '2994', '4198', '841', '2881', '3154', '993', '1519', '279', '998', '1430', '479', '4149', '1308', '1110', '370', '939', '2005', '398', '1522', '1020', '4533', '2001', '496', '3139', '1985', '1220', '1447', '1101', '530', '4296', '277', '4328', '3227', '3018', '1726', '801', '2739', '1690', '2822', '3127', '1712', '1954', '4322', '1155', '1610', '565', '2911', '3324', '2751', '3171', '607', '715', '1013', '2130', '2983', '2924', '833', '622', '2338', '1057', '1526', '1180', '1070', '468', '2386', '213', '82', '2298', '1408', '1419', '3166', '181', '2557', '2997', '802', '936', '1120', '325', '646', '566', '3126', '156', '2805', '3102', '3173', '3226', '1426', '1036', '1552', '2878', '5220', '3193', '2255', '3132', '1534', '3211', '2133', '1273', '1311', '3163', '1281', '649', '1369', '2401', '2850', '818', '3112', '3212', '1115', '1022', '4014', '2898', '2876', '1445', '1069', '2253', '539', '2633', '169', '1082', '2546', '1969', '125', '1621', '1900', '3159', '676', '192', '76', '3257', '3011', '489', '1334', '1048', '3034', '596', '2978', '1989', '1708', '2296', '4258', '1377', '1012', '176', '135', '160', '703', '140', '3188', '3105', '1937', '2432', '2797', '1953', '2804', '563', '624', '3005', '505', '534', '942', '334', '216', '1405', '3271', '2208', '2749', '1339', '2305', '4133', '2162', '371', '2926', '3194', '28', '1253', '1075', '2848', '2315', '870', '283', '2345', '2307', '357', '2370', '1344', '2895', '3013', '1094', '2969', '2329', '2743', '580', '943', '11', '1414', '1435', '1362', '3123', '2975', '303', '3160', '2733', '3164', '2262', '3030', '249', '1546', '521', '1142', '2946', '1608', '207', '3124', '1239', '944', '513', '323', '2998', '4527', '2826', '511', '159', '2846', '98', '621', '851', '1973', '3145', '2919', '617', '1490', '774', '1467', '3213', '179', '1705', '1959', '2279', '1460', '3186', '5216', '546', '3174', '941', '487', '297', '1214', '27', '4309', '2735', '1231', '475', '3340', '2806', '1923', '3087', '2288', '2791', '526', '2247', '1458', '1514', '4192', '640', '2466', '244', '3205', '548', '793', '80', '3110', '1724', '2828', '2306', '1186', '1965', '859', '2013', '1359', '2728', '2014', '191', '2259', '2026', '3120', '3142', '31', '2177', '247', '2094', '2525', '1940', '1433', '606', '185', '670', '2877', '1210', '2634', '3254', '2740', '1107', '304', '3140', '3168', '945', '1330', '1463', '1736', '1212', '290', '1633', '1947', '3015', '1038', '175', '3008', '3195', '2596', '4021', '707', '2346', '3184', '2863', '1148', '2843', '4341', '1237', '2348', '2818', '501', '127', '1951', '906', '2905', '2986', '2420', '2091', '4114', '4140', '3023', '472', '1448', '2260', '187', '3325', '1956', '2514', '3259', '2555', '1437', '1018', '480', '4224', '700', '242', '488', '87', '2405', '1683', '96', '3152', '3237', '3314', '1664', '561', '2169', '139', '26', '2884', '2888', '319', '1375', '1236', '2891', '1475', '1470', '430', '1196', '2362', '12', '3169', '1462', '2631', '2770', '1949', '2580', '317', '1118', '1014', '773', '42', '2551', '557', '188', '1324', '381', '1722', '1195', '860', '1271', '2886', '2865', '1337', '1628', '1416', '3185', '1626', '1328', '2824', '144', '63', '4006', '3233', '1727', '2910', '3130', '3198', '2251', '337', '2438', '2398', '1021', '2635', '1495', '3258', '394', '618', '1952', '1942', '2882', '2548', '3026', '133', '636', '1998', '2252', '789', '67', '587', '2723', '2403', '1960', '2472', '1007', '2771', '2808', '568', '2920', '797', '198', '3235', '1182', '4418', '38', '4004', '4282', '2985', '4011', '2475', '2297', '2194', '1619', '1159', '1715', '2874', '1439', '2191', '2473', '2154', '796', '3192', '248', '3134', '458', '3230', '781', '4417', '2712', '3302', '310', '2418', '4273', '2870', '3201', '3196', '798', '79', '2785', '1494', '2025', '788', '701', '1723', '3149', '560', '2842', '2457', '992', '3031', '645', '794', '1996', '454', '195', '2476', '2725', '2980', '1473', '2410', '2921', '143', '1483', '233', '1199', '71', '2974', '1366', '674', '3179', '1193', '1454', '2015', '1492', '2562', '4009', '933', '3099', '3177', '3307', '938', '1981', '1908', '238', '1489', '2379', '2521', '1890', '608', '2021', '2395', '3253', '173', '2744', '3203', '4313', '348', '347', '1420', '4530', '3106', '1092', '1698', '532', '1950', '111', '2987', '3093', '3117', '4205', '858', '4163', '2304', '336', '353', '4526', '1488', '990', '4529', '953', '1056', '258', '3217', '2963', '928', '183', '1163', '3304', '1704', '2714', '2534', '586', '2276', '399', '2849', '663', '3206', '529', '2161', '3041', '94', '2854', '1178', '2776', '1299', '2722', '514', '2382', '3028', '40', '960', '553', '2010', '2265', '5', '1482', '2981', '804', '2597', '2031', '4319', '1643', '2158', '2147', '1136', '2773', '4290', '531', '782', '3187', '956', '1436', '1630', '790', '3247', '795', '1968', '3240', '1354', '2012', '2780', '1130', '1209', '168', '349', '542', '4415', '1353', '2823', '460', '971', '1611', '2193', '2753', '1429', '3183', '699', '3221', '693', '1503', '1976', '1431', '2716', '3016', '3115', '1011', '4343', '2196', '126', '1496', '972', '141', '1575', '2815', '3172', '4363', '2856', '1903'}\n", + "['1', '2', '3', '4', '5', '6', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '21', '26', '27', '28', '29', '30', '31', '32', '33', '34', '35', '36', '38', '39', '40', '42', '43', '45', '46', '47', '48', '49', '50', '51', '52', '53', '54', '57', '58', '59', '60', '62', '63', '64', '67', '69', '71', '73', '74', '75', '76', '77', '78', '79', '80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '90', '91', '92', '93', '94', '95', '96', '97', '98', '99', '100', '101', '102', '103', '104', '105', '106', '107', '108', '109', '110', '111', '112', '113', '114', '117', '118', '119', '120', '122', '123', '124', '125', '126', '127', '128', '129', '130', '131', '132', '133', '134', '135', '136', '137', '138', '139', '140', '141', '142', '143', '144', '145', '146', '147', '148', '149', '150', '151', '152', '153', '154', '155', '156', '158', '159', '160', '161', '162', '163', '164', '165', '166', '167', '168', '169', '170', '173', '175', '176', '177', '178', '179', '180', '181', '182', '183', '184', '185', '186', '187', '188', '189', '190', '191', '192', '195', '196', '197', '198', '199', '200', '201', '202', '203', '207', '208', '209', '210', '211', '212', '213', '214', '215', '216', '221', '223', '225', '226', '227', '228', '229', '230', '231', '232', '233', '234', '235', '236', '237', '238', '239', '240', '241', '242', '243', '244', '245', '246', '247', '248', '249', '250', '251', '252', '253', '254', '255', '256', '257', '258', '259', '260', '261', '262', '263', '264', '265', '266', '267', '268', '269', '270', '271', '272', '273', '274', '275', '276', '277', '278', '279', '280', '281', '282', '283', '284', '285', '286', '287', '288', '289', '290', '291', '292', '293', '294', '295', '296', '297', '298', '299', '300', '301', '302', '303', '304', '305', '307', '308', '309', '310', '311', '312', '313', '314', '315', '316', '317', '318', '319', '320', '322', '323', '324', '325', '326', '327', '328', '329', '330', '331', '332', '333', '334', '335', '336', '337', '338', '339', '340', '343', '345', '347', '348', '349', '350', '351', '352', '353', '354', '355', '356', '357', '358', '359', '361', '362', '366', '367', '369', '370', '371', '372', '379', '380', '381', '382', '384', '388', '389', '390', '391', '393', '394', '395', '396', '397', '398', '399', '402', '405', '407', '408', '410', '412', '413', '416', '418', '420', '421', '422', '423', '424', '425', '426', '428', '429', '430', '431', '432', '434', '436', '438', '439', '442', '443', '445', '449', '450', '451', '454', '456', '457', '458', '459', '460', '461', '462', '463', '464', '465', '466', '467', '468', '469', '470', '471', '472', '473', '474', '475', '476', '477', '478', '479', '480', '481', '482', '483', '484', '485', '486', '487', '488', '489', '490', '491', '492', '493', '494', '495', '496', '497', '498', '500', '501', '502', '503', '504', '505', '506', '507', '508', '509', '510', '511', '512', '513', '514', '515', '516', '517', '518', '519', '520', '521', '522', '523', '524', '526', '527', '528', '529', '530', '531', '532', '533', '534', '535', '536', '537', '538', '539', '540', '541', '542', '543', '544', '545', '546', '547', '548', '549', '550', '551', '552', '553', '554', '555', '556', '557', '558', '559', '560', '561', '562', '563', '564', '565', '566', '567', '568', '569', '570', '571', '572', '573', '574', '575', '576', '577', '578', '579', '580', '581', '582', '583', '584', '585', '586', '587', '588', '589', '590', '591', '592', '593', '594', '595', '596', '597', '598', '599', '600', '601', '602', '603', '604', '605', '606', '607', '608', '609', '610', '611', '612', '613', '614', '615', '616', '617', '618', '619', '620', '621', '622', '623', '624', '625', '626', '627', '628', '629', '630', '631', '632', '633', '634', '635', '636', '637', '638', '639', '640', '641', '642', '643', '644', '645', '646', '647', '648', '649', '650', '651', '652', '653', '654', '655', '657', '658', '659', '660', '661', '662', '663', '664', '665', '666', '667', '668', '670', '671', '672', '673', '674', '675', '676', '677', '678', '679', '682', '683', '684', '685', '686', '688', '689', '690', '691', '692', '693', '694', '695', '696', '697', '699', '700', '701', '702', '703', '704', '705', '706', '707', '713', '715', '772', '773', '774', '775', '776', '777', '778', '779', '780', '781', '782', '783', '784', '785', '786', '787', '788', '789', '790', '791', '792', '793', '794', '795', '796', '797', '798', '799', '800', '801', '802', '803', '804', '805', '807', '808', '809', '811', '813', '814', '815', '816', '817', '818', '822', '823', '824', '825', '829', '830', '831', '832', '833', '834', '836', '837', '838', '839', '840', '841', '842', '843', '844', '845', '846', '848', '850', '851', '852', '853', '854', '856', '857', '858', '859', '860', '863', '864', '866', '867', '868', '869', '870', '876', '879', '882', '883', '884', '886', '888', '891', '892', '893', '894', '895', '900', '903', '904', '905', '906', '912', '914', '919', '920', '921', '922', '924', '928', '930', '931', '932', '933', '934', '935', '936', '937', '938', '939', '940', '941', '942', '943', '944', '945', '951', '952', '953', '954', '955', '956', '960', '965', '966', '967', '968', '969', '970', '971', '972', '973', '974', '975', '976', '977', '978', '979', '980', '981', '982', '983', '984', '985', '986', '987', '988', '990', '992', '993', '994', '997', '998', '1000', '1002', '1003', '1004', '1005', '1006', '1007', '1008', '1009', '1010', '1011', '1012', '1013', '1014', '1015', '1016', '1017', '1018', '1019', '1020', '1021', '1022', '1023', '1024', '1025', '1026', '1027', '1030', '1031', '1032', '1033', '1036', '1037', '1038', '1040', '1041', '1042', '1044', '1048', '1050', '1051', '1052', '1053', '1054', '1055', '1056', '1057', '1060', '1061', '1062', '1063', '1065', '1067', '1068', '1069', '1070', '1073', '1074', '1075', '1076', '1077', '1078', '1080', '1082', '1083', '1085', '1086', '1087', '1088', '1089', '1091', '1092', '1093', '1094', '1095', '1096', '1098', '1099', '1100', '1101', '1102', '1103', '1104', '1107', '1108', '1109', '1110', '1113', '1114', '1115', '1116', '1117', '1118', '1119', '1120', '1121', '1122', '1123', '1125', '1126', '1128', '1129', '1130', '1131', '1132', '1133', '1134', '1135', '1136', '1137', '1138', '1139', '1140', '1141', '1142', '1145', '1148', '1152', '1153', '1154', '1155', '1157', '1158', '1159', '1160', '1161', '1162', '1163', '1164', '1165', '1166', '1167', '1168', '1169', '1170', '1172', '1173', '1174', '1175', '1176', '1177', '1178', '1179', '1180', '1181', '1182', '1183', '1184', '1185', '1186', '1187', '1188', '1189', '1190', '1191', '1192', '1193', '1194', '1195', '1196', '1197', '1198', '1199', '1200', '1201', '1202', '1203', '1204', '1205', '1206', '1207', '1208', '1209', '1210', '1211', '1212', '1214', '1216', '1217', '1218', '1220', '1222', '1223', '1225', '1226', '1227', '1228', '1230', '1231', '1232', '1234', '1235', '1236', '1237', '1238', '1239', '1240', '1241', '1242', '1244', '1245', '1246', '1247', '1249', '1250', '1251', '1252', '1253', '1254', '1255', '1256', '1257', '1260', '1261', '1262', '1263', '1264', '1265', '1266', '1267', '1268', '1269', '1270', '1271', '1272', '1273', '1274', '1275', '1277', '1278', '1279', '1280', '1281', '1282', '1283', '1284', '1285', '1286', '1287', '1288', '1289', '1290', '1291', '1292', '1293', '1294', '1297', '1298', '1299', '1300', '1301', '1302', '1303', '1304', '1305', '1306', '1308', '1311', '1316', '1321', '1322', '1324', '1325', '1327', '1328', '1330', '1331', '1332', '1334', '1335', '1337', '1339', '1340', '1341', '1342', '1343', '1344', '1345', '1346', '1348', '1349', '1350', '1351', '1353', '1354', '1355', '1356', '1357', '1358', '1359', '1360', '1361', '1362', '1363', '1364', '1365', '1366', '1367', '1368', '1369', '1370', '1371', '1372', '1373', '1374', '1375', '1376', '1377', '1378', '1399', '1400', '1401', '1402', '1403', '1404', '1405', '1406', '1407', '1408', '1409', '1410', '1411', '1412', '1413', '1414', '1415', '1416', '1417', '1418', '1419', '1420', '1421', '1422', '1423', '1424', '1426', '1427', '1428', '1429', '1430', '1431', '1432', '1433', '1434', '1435', '1436', '1437', '1438', '1439', '1440', '1441', '1442', '1443', '1444', '1445', '1446', '1447', '1448', '1449', '1450', '1451', '1452', '1453', '1454', '1455', '1458', '1459', '1460', '1461', '1462', '1463', '1464', '1465', '1466', '1467', '1468', '1469', '1470', '1471', '1472', '1473', '1474', '1475', '1476', '1477', '1478', '1479', '1480', '1481', '1482', '1483', '1484', '1485', '1486', '1487', '1488', '1489', '1490', '1491', '1492', '1493', '1494', '1495', '1496', '1497', '1498', '1499', '1500', '1501', '1502', '1503', '1504', '1505', '1506', '1507', '1508', '1509', '1510', '1511', '1513', '1514', '1517', '1518', '1519', '1521', '1522', '1526', '1527', '1528', '1529', '1530', '1531', '1532', '1533', '1534', '1535', '1536', '1537', '1538', '1540', '1541', '1542', '1543', '1544', '1545', '1546', '1548', '1550', '1551', '1552', '1553', '1554', '1555', '1556', '1557', '1558', '1559', '1560', '1562', '1563', '1564', '1565', '1567', '1568', '1569', '1572', '1573', '1574', '1575', '1577', '1578', '1579', '1580', '1582', '1583', '1584', '1585', '1589', '1592', '1595', '1598', '1604', '1605', '1606', '1607', '1608', '1609', '1610', '1611', '1612', '1613', '1614', '1615', '1616', '1617', '1618', '1619', '1620', '1621', '1622', '1623', '1624', '1625', '1626', '1627', '1628', '1630', '1631', '1632', '1633', '1634', '1635', '1636', '1637', '1638', '1639', '1640', '1642', '1643', '1644', '1645', '1647', '1652', '1653', '1656', '1664', '1672', '1675', '1676', '1680', '1681', '1683', '1685', '1688', '1690', '1694', '1696', '1697', '1698', '1700', '1702', '1703', '1704', '1705', '1706', '1707', '1708', '1710', '1711', '1712', '1713', '1714', '1715', '1716', '1717', '1718', '1719', '1720', '1722', '1723', '1724', '1725', '1726', '1727', '1728', '1729', '1730', '1731', '1732', '1733', '1734', '1735', '1736', '1737', '1738', '1739', '1740', '1741', '1742', '1743', '1744', '1746', '1748', '1795', '1797', '1798', '1799', '1800', '1801', '1802', '1804', '1805', '1806', '1807', '1808', '1809', '1810', '1811', '1812', '1814', '1815', '1816', '1817', '1818', '1819', '1820', '1821', '1822', '1823', '1824', '1825', '1826', '1828', '1829', '1830', '1831', '1832', '1833', '1834', '1835', '1836', '1837', '1839', '1840', '1842', '1843', '1844', '1845', '1847', '1848', '1849', '1850', '1851', '1852', '1853', '1854', '1855', '1856', '1857', '1858', '1859', '1860', '1861', '1866', '1867', '1869', '1876', '1877', '1878', '1879', '1880', '1881', '1882', '1883', '1888', '1889', '1890', '1891', '1892', '1893', '1894', '1895', '1896', '1897', '1898', '1899', '1900', '1901', '1902', '1903', '1904', '1905', '1906', '1907', '1908', '1910', '1911', '1912', '1913', '1915', '1916', '1918', '1919', '1920', '1921', '1922', '1923', '1924', '1925', '1926', '1927', '1928', '1929', '1930', '1931', '1932', '1933', '1934', '1935', '1937', '1938', '1939', '1940', '1941', '1942', '1943', '1944', '1945', '1946', '1947', '1948', '1949', '1950', '1951', '1952', '1953', '1954', '1955', '1956', '1957', '1958', '1959', '1960', '1961', '1962', '1963', '1964', '1965', '1966', '1967', '1968', '1969', '1970', '1971', '1972', '1973', '1975', '1976', '1977', '1978', '1979', '1980', '1981', '1982', '1983', '1984', '1985', '1986', '1987', '1988', '1989', '1990', '1991', '1992', '1993', '1994', '1995', '1996', '1997', '1998', '1999', '2000', '2001', '2002', '2003', '2004', '2005', '2006', '2008', '2009', '2010', '2011', '2012', '2013', '2014', '2015', '2016', '2017', '2018', '2019', '2020', '2021', '2022', '2023', '2024', '2025', '2026', '2027', '2028', '2029', '2030', '2031', '2032', '2033', '2034', '2035', '2036', '2038', '2039', '2040', '2041', '2042', '2043', '2046', '2047', '2048', '2049', '2050', '2051', '2052', '2053', '2055', '2056', '2057', '2058', '2059', '2060', '2061', '2062', '2064', '2065', '2066', '2067', '2069', '2070', '2071', '2072', '2073', '2074', '2075', '2076', '2078', '2079', '2080', '2081', '2082', '2083', '2087', '2088', '2089', '2090', '2091', '2092', '2093', '2094', '2095', '2096', '2097', '2098', '2099', '2100', '2101', '2102', '2103', '2107', '2108', '2109', '2110', '2111', '2112', '2113', '2114', '2115', '2116', '2117', '2118', '2119', '2121', '2123', '2124', '2125', '2126', '2127', '2128', '2129', '2130', '2131', '2133', '2134', '2135', '2136', '2137', '2138', '2139', '2140', '2141', '2142', '2143', '2144', '2145', '2146', '2147', '2148', '2149', '2150', '2151', '2152', '2153', '2154', '2155', '2156', '2157', '2158', '2159', '2160', '2161', '2162', '2163', '2164', '2165', '2166', '2167', '2168', '2169', '2170', '2171', '2172', '2173', '2174', '2175', '2176', '2177', '2178', '2179', '2180', '2181', '2182', '2183', '2184', '2185', '2186', '2187', '2188', '2189', '2190', '2191', '2192', '2193', '2194', '2195', '2196', '2198', '2199', '2200', '2201', '2203', '2205', '2206', '2208', '2209', '2210', '2211', '2212', '2213', '2214', '2215', '2216', '2217', '2218', '2219', '2220', '2221', '2222', '2223', '2224', '2225', '2226', '2227', '2228', '2229', '2230', '2231', '2232', '2233', '2234', '2235', '2236', '2237', '2238', '2239', '2240', '2241', '2242', '2243', '2244', '2245', '2246', '2247', '2248', '2249', '2250', '2251', '2252', '2253', '2254', '2255', '2256', '2257', '2258', '2259', '2260', '2261', '2262', '2263', '2264', '2265', '2266', '2267', '2268', '2269', '2270', '2271', '2272', '2273', '2274', '2275', '2276', '2277', '2278', '2279', '2280', '2281', '2282', '2283', '2284', '2285', '2286', '2287', '2288', '2289', '2290', '2291', '2292', '2293', '2295', '2296', '2297', '2298', '2299', '2300', '2301', '2302', '2303', '2304', '2305', '2306', '2307', '2308', '2309', '2310', '2311', '2312', '2313', '2315', '2316', '2317', '2318', '2319', '2320', '2321', '2322', '2323', '2324', '2325', '2326', '2327', '2328', '2329', '2330', '2331', '2332', '2333', '2334', '2335', '2336', '2337', '2338', '2340', '2341', '2342', '2343', '2344', '2345', '2346', '2347', '2348', '2351', '2352', '2353', '2354', '2356', '2357', '2358', '2359', '2360', '2361', '2362', '2363', '2364', '2365', '2366', '2367', '2368', '2369', '2370', '2371', '2372', '2373', '2374', '2375', '2376', '2377', '2378', '2379', '2380', '2381', '2382', '2383', '2384', '2385', '2386', '2387', '2388', '2389', '2390', '2391', '2392', '2393', '2394', '2395', '2396', '2398', '2400', '2401', '2402', '2403', '2404', '2405', '2406', '2407', '2408', '2409', '2410', '2412', '2414', '2415', '2416', '2417', '2418', '2420', '2421', '2422', '2423', '2425', '2426', '2427', '2428', '2429', '2431', '2432', '2433', '2434', '2435', '2436', '2437', '2438', '2440', '2443', '2445', '2446', '2447', '2448', '2449', '2450', '2451', '2452', '2454', '2455', '2456', '2457', '2458', '2459', '2460', '2461', '2462', '2464', '2465', '2466', '2468', '2469', '2470', '2472', '2473', '2474', '2475', '2476', '2477', '2478', '2479', '2480', '2481', '2482', '2483', '2484', '2486', '2487', '2488', '2489', '2490', '2491', '2492', '2493', '2494', '2495', '2496', '2497', '2498', '2499', '2500', '2501', '2502', '2503', '2504', '2505', '2506', '2508', '2509', '2510', '2512', '2513', '2514', '2516', '2517', '2519', '2520', '2521', '2524', '2525', '2526', '2528', '2529', '2532', '2533', '2534', '2535', '2536', '2538', '2539', '2542', '2543', '2544', '2545', '2546', '2547', '2548', '2549', '2550', '2551', '2552', '2553', '2554', '2555', '2556', '2557', '2558', '2559', '2560', '2561', '2562', '2563', '2564', '2565', '2566', '2567', '2568', '2569', '2570', '2571', '2573', '2574', '2575', '2576', '2578', '2580', '2584', '2586', '2587', '2588', '2589', '2590', '2591', '2592', '2593', '2594', '2595', '2596', '2597', '2598', '2599', '2600', '2601', '2602', '2603', '2604', '2605', '2606', '2607', '2608', '2609', '2610', '2611', '2612', '2613', '2614', '2616', '2617', '2618', '2619', '2620', '2621', '2622', '2623', '2624', '2625', '2626', '2627', '2628', '2629', '2630', '2631', '2632', '2633', '2634', '2635', '2636', '2637', '2638', '2639', '2640', '2641', '2642', '2643', '2644', '2645', '2646', '2647', '2657', '2658', '2659', '2660', '2661', '2662', '2663', '2664', '2665', '2666', '2667', '2668', '2669', '2670', '2671', '2672', '2673', '2674', '2675', '2676', '2678', '2679', '2680', '2681', '2682', '2683', '2684', '2685', '2686', '2687', '2688', '2689', '2690', '2691', '2692', '2693', '2694', '2695', '2696', '2697', '2698', '2699', '2700', '2701', '2702', '2703', '2704', '2705', '2706', '2707', '2708', '2709', '2710', '2711', '2712', '2713', '2714', '2715', '2716', '2721', '2722', '2723', '2724', '2725', '2726', '2727', '2728', '2729', '2730', '2731', '2732', '2733', '2734', '2735', '2736', '2737', '2738', '2739', '2740', '2741', '2742', '2743', '2744', '2745', '2746', '2747', '2748', '2749', '2750', '2751', '2752', '2753', '2754', '2755', '2756', '2757', '2758', '2762', '2763', '2764', '2765', '2766', '2768', '2769', '2770', '2771', '2772', '2773', '2774', '2775', '2776', '2777', '2778', '2779', '2780', '2782', '2784', '2785', '2786', '2787', '2788', '2789', '2790', '2791', '2792', '2793', '2794', '2795', '2796', '2797', '2798', '2799', '2800', '2801', '2802', '2803', '2804', '2805', '2806', '2807', '2808', '2809', '2810', '2811', '2812', '2813', '2814', '2815', '2816', '2818', '2819', '2820', '2821', '2822', '2823', '2824', '2825', '2826', '2827', '2828', '2829', '2830', '2831', '2832', '2833', '2834', '2835', '2836', '2838', '2839', '2840', '2841', '2842', '2843', '2844', '2845', '2846', '2847', '2848', '2849', '2850', '2851', '2852', '2853', '2854', '2855', '2856', '2857', '2858', '2859', '2860', '2861', '2862', '2863', '2864', '2865', '2866', '2867', '2869', '2870', '2871', '2872', '2873', '2874', '2875', '2876', '2877', '2878', '2879', '2880', '2881', '2882', '2883', '2884', '2885', '2886', '2887', '2888', '2889', '2890', '2891', '2892', '2893', '2894', '2895', '2896', '2897', '2898', '2899', '2900', '2901', '2902', '2903', '2904', '2905', '2906', '2907', '2908', '2909', '2910', '2911', '2912', '2913', '2914', '2915', '2916', '2917', '2918', '2919', '2920', '2921', '2922', '2923', '2924', '2925', '2926', '2927', '2928', '2929', '2930', '2932', '2933', '2934', '2935', '2936', '2937', '2938', '2939', '2940', '2941', '2942', '2943', '2944', '2945', '2946', '2947', '2948', '2949', '2950', '2951', '2952', '2953', '2954', '2955', '2956', '2957', '2958', '2959', '2960', '2961', '2962', '2963', '2964', '2965', '2966', '2967', '2968', '2969', '2970', '2971', '2972', '2973', '2974', '2975', '2976', '2977', '2978', '2979', '2980', '2981', '2982', '2983', '2984', '2985', '2986', '2987', '2988', '2989', '2990', '2991', '2992', '2993', '2994', '2996', '2997', '2998', '2999', '3000', '3001', '3002', '3003', '3004', '3005', '3006', '3007', '3008', '3009', '3010', '3011', '3012', '3013', '3014', '3015', '3016', '3017', '3018', '3019', '3020', '3021', '3022', '3023', '3024', '3025', '3026', '3027', '3028', '3029', '3030', '3031', '3032', '3033', '3034', '3035', '3036', '3037', '3039', '3041', '3042', '3043', '3044', '3045', '3046', '3047', '3049', '3078', '3079', '3080', '3081', '3086', '3087', '3092', '3093', '3094', '3095', '3096', '3097', '3098', '3099', '3100', '3101', '3102', '3103', '3104', '3105', '3106', '3107', '3108', '3109', '3110', '3111', '3112', '3113', '3114', '3115', '3116', '3117', '3118', '3119', '3120', '3121', '3122', '3123', '3124', '3125', '3126', '3127', '3128', '3129', '3130', '3131', '3132', '3133', '3134', '3135', '3136', '3137', '3138', '3139', '3140', '3141', '3142', '3143', '3144', '3145', '3146', '3147', '3148', '3149', '3150', '3151', '3152', '3153', '3154', '3155', '3156', '3157', '3158', '3159', '3160', '3161', '3162', '3163', '3164', '3165', '3166', '3167', '3168', '3169', '3170', '3171', '3172', '3173', '3174', '3175', '3176', '3177', '3178', '3179', '3180', '3181', '3182', '3183', '3184', '3185', '3186', '3187', '3188', '3189', '3190', '3191', '3192', '3193', '3194', '3195', '3196', '3197', '3198', '3199', '3200', '3201', '3202', '3203', '3204', '3205', '3206', '3207', '3208', '3209', '3210', '3211', '3212', '3213', '3214', '3215', '3216', '3217', '3218', '3219', '3220', '3221', '3222', '3223', '3224', '3225', '3226', '3227', '3228', '3229', '3230', '3231', '3232', '3233', '3234', '3235', '3236', '3237', '3238', '3239', '3240', '3241', '3242', '3243', '3244', '3245', '3246', '3247', '3248', '3249', '3250', '3251', '3252', '3253', '3254', '3255', '3256', '3257', '3258', '3259', '3265', '3266', '3268', '3269', '3270', '3271', '3272', '3273', '3275', '3278', '3279', '3280', '3281', '3282', '3283', '3284', '3285', '3286', '3287', '3288', '3289', '3290', '3291', '3293', '3298', '3300', '3301', '3302', '3304', '3306', '3307', '3308', '3309', '3310', '3311', '3313', '3314', '3315', '3317', '3318', '3320', '3321', '3322', '3323', '3324', '3325', '3328', '3332', '3333', '3334', '3337', '3338', '3340', '3341', '3343', '3344', '3345', '3346', '3347', '3348', '3350', '3351', '3352', '3353', '3354', '3355', '4001', '4003', '4004', '4006', '4007', '4008', '4009', '4011', '4013', '4014', '4015', '4016', '4017', '4020', '4110', '4111', '4113', '4114', '4115', '4116', '4118', '4119', '4122', '4125', '4126', '4128', '4129', '4131', '4133', '4140', '4141', '4142', '4143', '4144', '4146', '4148', '4149', '4153', '4158', '4159', '4160', '4161', '4163', '4167', '4170', '4172', '4173', '4174', '4176', '4178', '4182', '4186', '4187', '4188', '4189', '4190', '4191', '4192', '4193', '4194', '4195', '4196', '4197', '4198', '4199', '4200', '4201', '4202', '4203', '4204', '4205', '4206', '4207', '4208', '4209', '4210', '4211', '4212', '4214', '4215', '4216', '4217', '4218', '4219', '4220', '4221', '4222', '4223', '4224', '4225', '4226', '4228', '4229', '4231', '4232', '4233', '4234', '4235', '4236', '4237', '4239', '4241', '4242', '4244', '4246', '4247', '4248', '4249', '4250', '4251', '4252', '4253', '4254', '4255', '4256', '4257', '4258', '4259', '4260', '4261', '4262', '4263', '4264', '4265', '4266', '4267', '4269', '4270', '4271', '4272', '4273', '4275', '4276', '4277', '4279', '4280', '4281', '4282', '4287', '4288', '4289', '4290', '4291', '4292', '4293', '4294', '4295', '4296', '4297', '4298', '4299', '4300', '4301', '4303', '4304', '4305', '4306', '4307', '4308', '4309', '4310', '4312', '4313', '4314', '4316', '4317', '4318', '4319', '4320', '4321', '4322', '4323', '4324', '4325', '4326', '4327', '4328', '4329', '4330', '4331', '4333', '4334', '4335', '4336', '4337', '4338', '4339', '4340', '4341', '4342', '4343', '4344', '4345', '4346', '4347', '4349', '4350', '4351', '4352', '4353', '4354', '4355', '4356', '4357', '4358', '4359', '4360', '4361', '4362', '4363', '4364', '4365', '4366', '4367', '4368', '4385', '4386', '4387', '4388', '4390', '4391', '4392', '4409', '4411', '4413', '4415', '4416', '4417', '4418', '4419', '4420', '4421', '4422', '4423', '4424', '4425', '4426', '4427', '4428', '4429', '4430', '4431', '4432', '4433', '4435', '4436', '4437', '4438', '4439', '4440', '4441', '4442', '4443', '4444', '4445', '4446', '4447', '4448', '4449', '4450', '4451', '4452', '4453', '4454', '4455', '4456', '4457', '4458', '4459', '4460', '4462', '4463', '4464', '4465', '4466', '4468', '4469', '4470', '4471', '4472', '4473', '4482', '4483', '4484', '4485', '4486', '4487', '4488', '4489', '4490', '4492', '4493', '4494', '4495', '4496', '4497', '4498', '4499', '4500', '4501', '4502', '4503', '4504', '4505', '4506', '4507', '4508', '4509', '4510', '4511', '4512', '4513', '4514', '4515', '4516', '4517', '4518', '4519', '4520', '4521', '4522', '4523', '4524', '4525', '4526', '4527', '4528', '4529', '4530', '4531', '4532', '4533', '4534', '4535', '4536', '4537', '4538', '4540', '4541', '4542', '4543', '4545', '4546', '4547', '4548', '4549', '4550', '4551', '4552', '4553', '4554', '4555', '4556', '4557', '4558', '4559', '4560', '4561', '4562', '4563', '4564', '4565', '4566', '4567', '4568', '4569', '4570', '4571', '4572', '4573', '4574', '4575', '4576', '4577', '4578', '4579', '4580', '4581', '4582', '4583', '4584', '4585', '4586', '4587', '4588', '4589', '4590', '4591', '4592', '4593', '4594', '4595', '4596', '4597', '4598', '4599', '4600', '4601', '4602', '4603', '4604', '4605', '4606', '4607', '4608', '4609', '4610', '4611', '4612', '4614', '4615', '4616', '4617', '4618', '4619', '4620', '4622', '4625', '4626', '4627', '4628', '4629', '5066', '5067', '5068', '5069', '5070', '5071', '5072', '5073', '5074', '5075', '5076', '5077', '5078', '5101', '5135', '5136', '5137', '5139', '5140', '5141', '5142', '5143', '5144', '5145', '5146', '5147', '5148', '5149', '5150', '5151', '5152', '5153', '5154', '5155', '5156', '5157', '5158', '5159', '5160', '5161', '5162', '5163', '5164', '5165', '5166', '5167', '5169', '5170', '5171', '5172', '5173', '5174', '5175', '5176', '5177', '5178', '5179', '5181', '5182', '5183', '5184', '5185', '5186', '5187', '5188', '5189', '5190', '5191', '5192', '5193', '5194', '5195', '5196', '5197', '5198', '5199', '5200', '5201', '5202', '5203', '5204', '5205', '5206', '5207', '5208', '5209', '5211', '5212', '5213', '5214', '5215', '5216', '5217', '5218', '5219', '5220', '5221', '5223', '5224', '5225', '5226', '5227', '5228', '5229', '5230', '5231', '5232', '5233', '5234', '5235', '5240', '5241', '5242', '5243', '5245', '5246', '5247', '5248', '5249', '5250', '5251', '5252', '5253', '5254', '5255', '5256', '5257', '5258', '5259', '5260', '5261', '5262', '5265', '5267', '5268', '5269', '5271', '5272', '5273', '5274', '5275', '5276', '5277', '5278', '5279', '5280', '5281', '5282', '5283', '5284', '5285', '5286', '5287', '5288', '5289', '5290', '5292', '5297', '5301', '5302', '5303', '5304', '5305', '5306', '5307', '5308', '5309', '5381', '5388', '5389', '5390', '5392', '5393', '5394', '5395', '5396']\n", + "{'2923', '1409', '1279', '1984', '3000', '594', '99', '1464', '1103', '4416', '3317', '538', '842', '3210', '868', '1185', '1119', '4531', '1451', '3204', '1428', '1536', '2440', '559', '934', '589', '355', '1624', '2553', '3208', '1267', '1685', '1241', '2141', '109', '517', '214', '600', '75', '307', '1402', '3182', '2893', '1363', '2794', '2989', '935', '3219', '483', '1376', '137', '616', '1349', '1618', '3202', '1364', '231', '2937', '637', '772', '555', '1222', '131', '246', '1145', '4294', '1023', '2363', '2726', '1609', '2295', '2897', '2813', '2750', '1187', '83', '3143', '664', '2143', '1095', '1971', '466', '2128', '2391', '3334', '533', '4317', '2853', '293', '1993', '832', '81', '9', '1501', '3108', '1099', '315', '1230', '2029', '1573', '278', '241', '1', '2787', '931', '345', '784', '1711', '1961', '2558', '295', '294', '2623', '286', '1197', '284', '1589', '2172', '1607', '108', '202', '32', '2547', '1368', '2333', '2835', '1141', '362', '2209', '380', '2377', '3109', '3199', '2890', '4298', '1175', '4257', '2948', '1031', '1605', '535', '1636', '604', '2945', '2795', '491', '261', '671', '2019', '339', '2006', '2908', '2151', '1980', '1265', '1717', '800', '515', '2450', '1972', '1172', '1497', '2709', '1263', '1716', '2283', '18', '3309', '2434', '395', '822', '1102', '576', '1238', '4359', '556', '2833', '1262', '146', '19', '2774', '1190', '2869', '836', '308', '1240', '2809', '613', '713', '3158', '2198', '1631', '1093', '583', '2769', '2452', '333', '1067', '625', '1901', '113', '3181', '3146', '2477', '3222', '478', '1645', '3092', '3311', '296', '986', '2170', '2858', '3002', '791', '1378', '3279', '285', '675', '2280', '1283', '4352', '1614', '2474', '2899', '51', '170', '2290', '1627', '633', '1123', '2343', '1939', '2957', '2351', '1992', '2935', '1468', '3229', '639', '1203', '4335', '696', '592', '1301', '1465', '2917', '3155', '3246', '267', '33', '1373', '2724', '3273', '1647', '2789', '3175', '2336', '3137', '2008', '2737', '2914', '1164', '2778', '2775', '2928', '2832', '2165', '892', '850', '1638', '3103', '839', '3178', '1889', '232', '197', '2786', '327', '2096', '212', '257', '1009', '4116', '2825', '831', '2359', '1006', '281', '240', '1681', '3100', '4346', '2174', '3111', '50', '103', '1415', '15', '2810', '706', '623', '225', '2996', '512', '4323', '520', '470', '228', '1481', '1255', '572', '1077', '603', '1350', '4324', '4232', '467', '2812', '2613', '1563', '4355', '3244', '130', '351', '2977', '2428', '1030', '595', '1432', '2807', '239', '3266', '263', '1970', '2904', '311', '799', '3162', '227', '2393', '2435', '4148', '550', '4255', '1941', '4543', '2286', '642', '1441', '590', '1529', '562', '1131', '3251', '1925', '1896', '3118', '4357', '280', '977', '3086', '14', '1161', '1341', '3242', '1892', '610', '2090', '2970', '2330', '1291', '2852', '2074', '1044', '1637', '2287', '552', '1905', '2964', '86', '2024', '128', '4131', '1919', '4297', '817', '1032', '36', '1372', '1176', '2378', '2885', '1087', '2406', '465', '1040', '2460', '4267', '1005', '2513', '2134', '1400', '2032', '2952', '1642', '4122', '577', '2999', '292', '93', '2864', '4020', '4351', '536', '3207', '588', '1484', '3180', '1200', '2984', '1062', '2373', '300', '2966', '1720', '1472', '473', '2270', '1423', '358', '1332', '1361', '1964', '792', '335', '1938', '2301', '4248', '2811', '21', '369', '2894', '1037', '1697', '2873', '3191', '2944', '834', '611', '1401', '1078', '965', '4361', '2445', '2567', '1612', '1975', '2358', '1713', '3223', '2103', '2317', '3269', '4016', '5218', '667', '1410', '2144', '2462', '184', '1275', '328', '2608', '2322', '4532', '1091', '2962', '811', '3144', '2003', '3021', '1639', '78', '273', '2576', '1477', '582', '684', '665', '686', '886', '3012', '1008', '4202', '1167', '2179', '3135', '4523', '803', '134', '390', '648', '3125', '138', '2816', '2337', '2784', '549', '697', '1963', '2867', '777', '575', '153', '163', '148', '3138', '571', '3004', '1700', '628', '2266', '1356', '516', '1104', '359', '366', '1374', '2150', '34', '291', '1331', '3220', '4235', '450', '787', '2851', '302', '2880', '1290', '2568', '2901', '1945', '463', '2030', '840', '2756', '45', '545', '2284', '2754', '89', '1943', '2456', '503', '118', '2004', '4521', '1202', '593', '808', '1485', '3310', '493', '1478', '2278', '641', '274', '1988', '1623', '457', '653', '1298', '1931', '2820', '4252', '4538', '979', '1944', '1017', '4520', '1004', '73', '1696', '1930', '643', '1335', '2745', '3128', '2829', '1962', '1108', '1500', '3024', '1632', '264', '4015', '2681', '4216', '785', '1223', '2402', '320', '2258', '2282', '2385', '1249', '3119', '904', '164', '4143', '1558', '2328', '1024', '2331', '3234', '2847', '2308', '4386', '2632', '3017', '485', '1300', '497', '903', '2988', '2027', '4199', '983', '3320', '47', '272', '614', '3101', '1644', '1266', '2447', '1438', '2855', '2834', '1928', '2747', '2190', '396', '486', '4354', '1897', '591', '301', '2195', '4119', '1179', '1068', '1050', '265', '120', '2929', '2790', '1999', '309', '3197', '150', '2171', '2409', '968', '2256', '864', '4536', '3098', '4541', '2738', '1360', '647', '1990', '3231', '330', '4229', '2600', '2319', '1403', '152', '1088', '1991', '1033', '1003', '1327', '182', '775', '102', '2721', '690', '4316', '2524', '547', '2875', '62', '2866', '829', '574', '237', '1714', '502', '620', '567', '4321', '161', '2922', '3153', '1198', '597', '554', '2210', '1455', '3315', '2016', '329', '4318', '1740', '270', '2951', '805', '2354', '2748', '564', '2746', '1680', '4194', '1625', '1351', '1966', '634', '4170', '2446', '1201', '1550', '3113', '2138', '2159', '585', '4008', '598', '2396', '830', '3214', '495', '2139', '2407', '3265', '459', '1207', '1173', '3321', '149', '569', '287', '331', '2360', '132', '3161', '1154', '1010', '421', '1342', '657', '1615', '2327', '269', '2326', '1060', '58', '2954', '823', '2860', '1181', '551', '1640', '1567', '2538', '3228', '4528', '973', '3046', '114', '3157', '180', '4385', '4339', '471', '2201', '221', '2180', '4540', '677', '2887', '1702', '2388', '3250', '2095', '3114', '4144', '602', '1169', '1994', '3150', '969', '780', '1444', '91', '2156', '3224', '1920', '2916', '492', '2561', '500', '638', '3328', '786', '1321', '142', '1357', '581', '809', '1165', '635', '2335', '1235', '2731', '3001', '1967', '2352', '584', '1568', '2137', '578', '4241', '678', '2389', '469', '10', '201', '1532', '1247', '4007', '107', '2861', '443', '4534', '352', '1688', '779', '2069', '1250', '2736', '3355', '356', '2563', '2993', '3243', '2425', '312', '807', '1285', '2299', '1891', '2838', '119', '361', '2264', '4525', '1948', '3239', '1322', '1346', '558', '123', '2859', '1719', '1122', '2741', '4368', '1061', '2925', '343', '2311', '853', '510', '2372', '1132', '504', '615', '2821', '5217', '951', '1620', '2932', '1191', '2', '1027', '2879', '166', '4320', '1277', '778', '2422', '494', '2129', '2883', '1565', '1499', '2455', '3131', '1117', '1957', '952', '1192', '1174', '1505', '1260', '1978', '4535', '1502', '288', '3301', '490', '3249', '4519', '147', '2017', '975', '1367', '2730', '2892', '2713', '3255', '2814', '2433', '4419', '464', '631', '4289', '1537', '2192', '410', '2727', '609', '117', '4001', '3019', '507', '2788', '3141', '619', '2943', '4306', '282', '1471', '474', '2002', '2300', '2556', '5219', '3032', '3209', '1718', '2947', '3006', '477', '570', '1269', '1228', '85', '2729', '1086', '1893', '5066', '29', '1487', '682', '854', '1135', '299', '2758', '1188', '74', '2367', '3167', '1194', '2465', '3121', '49', '632', '1987', '856', '4388', '1459', '245', '2532', '289', '626', '234', '3096', '630', '3116', '155', '2163', '543', '1734', '2764', '1580', '2028', '476', '605', '372', '2421', '2166', '298', '1358', '59', '518', '3245', '145', '2936', '2535', '1002', '122', '900', '3338', '158', '1083', '4', '2982', '522', '3020', '1493', '2630', '660', '1026', '1617', '1205', '2408', '3156', '4111', '579', '3252', '3045', '101', '2261', '1139', '857', '243', '208', '506', '852', '1592', '2900', '509', '3095', '1703', '1480', '235', '4546', '2844', '2036', '1452', '462', '1157', '1052', '967', '3022', '190', '2018', '236', '1634', '2320', '970', '95', '3225', '3200', '4254', '2088', '2979', '1054', '1345', '350', '4218', '110', '397', '3216', '60', '3009', '1997', '223', '2941', '338', '537', '2312', '4356', '1440', '2459', '1486', '211', '629', '869', '3332', '1635', '1370', '2167', '314', '2976', '3136', '2965', '1507', '2933', '3010', '1434', '895', '1479', '1076', '1407', '2711', '4347', '1206', '1412', '1114', '428', '1340', '2732', '1935', '2796', '2135', '1977', '1622', '4522', '1926', '3151', '1109', '2792', '318', '3281', '1958', '1189', '1613', '4013', '2376', '1995', '3097', '5214', '2323', '2798', '508', '2840', '2182', '978', '2034', '1474', '461', '2841', '2414', '4113', '2365', '3241', '3256', '2000', '1986', '2902', '316', '1450', '2961', '2913', '3308', '2912', '3042', '1183', '1177', '1292', '3238', '2164', '2160', '203', '1417', '3147', '1245', '4342', '2896', '2254', '39', '393', '498', '4236', '2570', '2889', '1184', '3133', '4300', '3003', '1982', '1453', '1343', '1128', '2341', '189', '1140', '2830', '845', '3248', '2782', '1616', '544', '1894', '1606', '2903', '2263', '2011', '17', '1706', '2827', '519', '1446', '4174', '151', '1898', '3014', '2449', '136', '1461', '3043', '2356', '1041', '1710', '2022', '6', '1424', '2971', '2102', '268', '3165', '2469', '200', '2918', '2994', '4198', '841', '2881', '3154', '993', '1519', '279', '998', '1430', '479', '4149', '1308', '1110', '370', '939', '2005', '398', '1522', '1020', '4533', '2001', '496', '3139', '1985', '1220', '1447', '1101', '530', '4296', '277', '4328', '3227', '3018', '1726', '801', '2739', '1690', '2822', '3127', '1712', '1954', '4322', '1155', '1610', '565', '2911', '3324', '2751', '3171', '607', '715', '1013', '2130', '2983', '2924', '833', '622', '2338', '1057', '1526', '1180', '1070', '468', '2386', '213', '82', '2298', '1408', '1419', '3166', '181', '2557', '2997', '802', '936', '1120', '325', '646', '566', '3126', '156', '2805', '3102', '3173', '3226', '1426', '1036', '1552', '2878', '5220', '3193', '2255', '3132', '1534', '3211', '2133', '1273', '1311', '3163', '1281', '649', '1369', '2401', '2850', '818', '3112', '3212', '1115', '1022', '4014', '2898', '2876', '1445', '1069', '2253', '539', '2633', '169', '1082', '2546', '1969', '125', '1621', '1900', '3159', '676', '192', '76', '3257', '3011', '489', '1334', '1048', '3034', '596', '2978', '1989', '1708', '2296', '4258', '1377', '1012', '176', '135', '160', '703', '140', '3188', '3105', '1937', '2432', '2797', '1953', '2804', '563', '624', '3005', '505', '534', '942', '334', '216', '1405', '3271', '2208', '2749', '1339', '2305', '4133', '2162', '371', '2926', '3194', '28', '1253', '1075', '2848', '2315', '870', '283', '2345', '2307', '357', '2370', '1344', '2895', '3013', '1094', '2969', '2329', '2743', '580', '943', '11', '1414', '1435', '1362', '3123', '2975', '303', '3160', '2733', '3164', '2262', '3030', '249', '1546', '521', '1142', '2946', '1608', '207', '3124', '1239', '944', '513', '323', '2998', '4527', '2826', '511', '159', '2846', '98', '621', '851', '1973', '3145', '2919', '617', '1490', '774', '1467', '3213', '179', '1705', '1959', '2279', '1460', '3186', '5216', '546', '3174', '941', '487', '297', '1214', '27', '4309', '2735', '1231', '475', '3340', '2806', '1923', '3087', '2288', '2791', '526', '2247', '1458', '1514', '4192', '640', '2466', '244', '3205', '548', '793', '80', '3110', '1724', '2828', '2306', '1186', '1965', '859', '2013', '1359', '2728', '2014', '191', '2259', '2026', '3120', '3142', '31', '2177', '247', '2094', '2525', '1940', '1433', '606', '185', '670', '2877', '1210', '2634', '3254', '2740', '1107', '304', '3140', '3168', '945', '1330', '1463', '1736', '1212', '290', '1633', '1947', '3015', '1038', '175', '3008', '3195', '2596', '707', '2346', '3184', '2863', '1148', '2843', '4341', '1237', '2348', '2818', '501', '127', '1951', '906', '2905', '2986', '2420', '2091', '4114', '4140', '3023', '472', '1448', '2260', '187', '3325', '1956', '2514', '3259', '2555', '1437', '1018', '480', '4224', '700', '242', '488', '87', '2405', '1683', '96', '3152', '3237', '3314', '1664', '561', '2169', '139', '26', '2884', '2888', '319', '1375', '1236', '2891', '1475', '1470', '430', '1196', '2362', '12', '3169', '1462', '2631', '2770', '1949', '2580', '317', '1118', '1014', '773', '42', '2551', '557', '188', '1324', '381', '1722', '1195', '860', '1271', '2886', '2865', '1337', '1628', '1416', '3185', '1626', '1328', '2824', '144', '63', '4006', '3233', '1727', '2910', '3130', '3198', '2251', '337', '2438', '2398', '1021', '2635', '1495', '3258', '394', '618', '1952', '1942', '2882', '2548', '3026', '133', '636', '1998', '2252', '789', '67', '587', '2723', '2403', '1960', '2472', '1007', '2771', '2808', '568', '2920', '797', '198', '3235', '1182', '4418', '38', '4004', '4282', '2985', '4011', '2475', '2297', '2194', '1619', '1159', '1715', '2874', '1439', '2191', '2473', '2154', '796', '3192', '248', '3134', '458', '3230', '781', '4417', '2712', '3302', '310', '2418', '4273', '2870', '3201', '3196', '798', '79', '2785', '1494', '2025', '788', '701', '1723', '3149', '560', '2842', '2457', '992', '3031', '645', '794', '1996', '454', '195', '2476', '2725', '2980', '1473', '2410', '2921', '143', '1483', '233', '1199', '71', '2974', '1366', '674', '3179', '1193', '1454', '2015', '1492', '2562', '4009', '933', '3099', '3177', '3307', '938', '1981', '1908', '238', '1489', '2379', '2521', '1890', '608', '2021', '2395', '3253', '173', '2744', '3203', '4313', '348', '347', '1420', '4530', '3106', '1092', '1698', '532', '1950', '111', '2987', '3093', '3117', '4205', '858', '4163', '2304', '336', '353', '4526', '1488', '990', '4529', '953', '1056', '258', '3217', '2963', '928', '183', '1163', '3304', '1704', '2714', '2534', '586', '2276', '399', '2849', '663', '3206', '529', '2161', '3041', '94', '2854', '1178', '2776', '1299', '2722', '514', '2382', '3028', '40', '960', '553', '2010', '2265', '5', '1482', '2981', '804', '2597', '2031', '4319', '1643', '2158', '2147', '1136', '2773', '4290', '531', '782', '3187', '956', '1436', '1630', '790', '3247', '795', '1968', '3240', '1354', '2012', '2780', '1130', '1209', '168', '349', '542', '4415', '1353', '2823', '460', '971', '1611', '2193', '2753', '1429', '3183', '699', '3221', '693', '1503', '1976', '1431', '2716', '3016', '3115', '1011', '4343', '2196', '126', '1496', '972', '141', '1575', '2815', '3172', '4363', '2856', '1903'}\n", + "0 1\n", + "1 2\n", + "2 3\n", + "3 4\n", + "4 5\n", + " ... \n", + "3951 5393\n", + "3952 5394\n", + "3953 5395\n", + "3954 5396\n", + "3955 5397\n", + "Name: station_id, Length: 3956, dtype: object\n", + "{'2923', '1409', '1279', '1984', '3000', '594', '99', '1464', '1103', '4416', '3317', '538', '842', '3210', '868', '1185', '1119', '4531', '1451', '3204', '1428', '1536', '2440', '559', '934', '589', '355', '1624', '2553', '3208', '1267', '1685', '1241', '2141', '109', '517', '214', '600', '75', '307', '1402', '3182', '2893', '1363', '2794', '2989', '935', '3219', '483', '1376', '137', '616', '1349', '1618', '3202', '1364', '231', '2937', '637', '772', '555', '1222', '131', '246', '1145', '4294', '1023', '2363', '2726', '1609', '2295', '2897', '2813', '2750', '1187', '83', '3143', '664', '2143', '1095', '1971', '466', '2128', '2391', '3334', '533', '4317', '2853', '293', '1993', '832', '81', '9', '1501', '3108', '1099', '315', '1230', '2029', '1573', '278', '241', '1', '2787', '931', '345', '784', '1711', '1961', '2558', '295', '294', '2623', '286', '1197', '284', '1589', '2172', '1607', '108', '202', '32', '2547', '1368', '2333', '2835', '1141', '362', '2209', '380', '2377', '3109', '3199', '2890', '4298', '1175', '4257', '2948', '1031', '1605', '535', '1636', '604', '2945', '2795', '491', '261', '671', '2019', '339', '2006', '2908', '2151', '1980', '1265', '1717', '800', '515', '2450', '1972', '1172', '1497', '2709', '1263', '1716', '2283', '18', '3309', '2434', '395', '822', '1102', '576', '1238', '4359', '556', '2833', '1262', '146', '19', '2774', '1190', '2869', '836', '308', '1240', '2809', '613', '713', '3158', '2198', '1631', '1093', '583', '2769', '2452', '333', '1067', '625', '1901', '113', '3181', '3146', '2477', '3222', '478', '1645', '3092', '3311', '296', '986', '2170', '2858', '3002', '791', '1378', '3279', '285', '675', '2280', '1283', '4352', '1614', '2474', '2899', '51', '170', '2290', '1627', '633', '1123', '2343', '1939', '2957', '2351', '1992', '2935', '1468', '3229', '639', '1203', '4335', '696', '592', '1301', '1465', '2917', '3155', '3246', '267', '33', '1373', '2724', '3273', '1647', '2789', '3175', '2336', '3137', '2008', '2737', '2914', '1164', '2778', '2775', '2928', '2832', '2165', '892', '850', '1638', '3103', '839', '3178', '1889', '232', '197', '2786', '327', '2096', '212', '257', '1009', '4116', '2825', '831', '2359', '1006', '281', '240', '1681', '3100', '4346', '2174', '3111', '50', '103', '1415', '15', '2810', '706', '623', '225', '2996', '512', '4323', '520', '470', '228', '1481', '1255', '572', '1077', '603', '1350', '4324', '4232', '467', '2812', '2613', '1563', '4355', '3244', '130', '351', '2977', '2428', '1030', '595', '1432', '2807', '239', '3266', '263', '1970', '2904', '311', '799', '3162', '227', '2393', '2435', '4148', '550', '4255', '1941', '4543', '2286', '642', '1441', '590', '1529', '562', '1131', '3251', '1925', '1896', '3118', '4357', '280', '977', '3086', '14', '1161', '1341', '3242', '1892', '610', '2090', '2970', '2330', '1291', '2852', '2074', '1044', '1637', '2287', '552', '1905', '2964', '86', '2024', '128', '4131', '1919', '4297', '817', '1032', '36', '1372', '1176', '2378', '2885', '1087', '2406', '465', '1040', '2460', '4267', '1005', '2513', '2134', '1400', '2032', '2952', '1642', '4122', '577', '2999', '292', '93', '2864', '4020', '4351', '536', '3207', '588', '1484', '3180', '1200', '2984', '1062', '2373', '300', '2966', '1720', '1472', '473', '2270', '1423', '358', '1332', '1361', '1964', '792', '335', '1938', '2301', '4248', '2811', '21', '369', '2894', '1037', '1697', '2873', '3191', '2944', '834', '611', '1401', '1078', '965', '4361', '2445', '2567', '1612', '1975', '2358', '1713', '3223', '2103', '2317', '3269', '4016', '5218', '667', '1410', '2144', '2462', '184', '1275', '328', '2608', '2322', '4532', '1091', '2962', '811', '3144', '2003', '3021', '1639', '78', '273', '2576', '1477', '582', '684', '665', '686', '886', '3012', '1008', '4202', '1167', '2179', '3135', '4523', '803', '134', '390', '648', '3125', '138', '2816', '2337', '2784', '549', '697', '1963', '2867', '777', '575', '153', '163', '148', '3138', '571', '3004', '1700', '628', '2266', '1356', '516', '1104', '359', '366', '1374', '2150', '34', '291', '1331', '3220', '4235', '450', '787', '2851', '302', '2880', '1290', '2568', '2901', '1945', '463', '2030', '840', '2756', '45', '545', '2284', '2754', '89', '1943', '2456', '503', '118', '2004', '4521', '1202', '593', '808', '1485', '3310', '493', '1478', '2278', '641', '274', '1988', '1623', '457', '653', '1298', '1931', '2820', '4252', '4538', '979', '1944', '1017', '4520', '1004', '73', '1696', '1930', '643', '1335', '2745', '3128', '2829', '1962', '1108', '1500', '3024', '1632', '264', '4015', '2681', '4216', '785', '1223', '2402', '320', '2258', '2282', '2385', '1249', '3119', '904', '164', '4143', '1558', '2328', '1024', '2331', '3234', '2847', '2308', '4386', '2632', '3017', '485', '1300', '497', '903', '2988', '2027', '4199', '983', '3320', '47', '272', '614', '3101', '1644', '1266', '2447', '1438', '2855', '2834', '1928', '2747', '2190', '396', '486', '4354', '1897', '591', '301', '2195', '4119', '1179', '1068', '1050', '265', '120', '2929', '2790', '1999', '309', '3197', '150', '2171', '2409', '968', '2256', '864', '4536', '3098', '4541', '2738', '1360', '647', '1990', '3231', '330', '4229', '2600', '2319', '1403', '152', '1088', '1991', '1033', '1003', '1327', '182', '775', '102', '2721', '690', '4316', '2524', '547', '2875', '62', '2866', '829', '574', '237', '1714', '502', '620', '567', '4321', '161', '2922', '3153', '1198', '597', '554', '2210', '1455', '3315', '2016', '329', '4318', '1740', '270', '2951', '805', '2354', '2748', '564', '2746', '1680', '4194', '1625', '1351', '1966', '634', '4170', '2446', '1201', '1550', '3113', '2138', '2159', '585', '4008', '598', '2396', '830', '3214', '495', '2139', '2407', '3265', '459', '1207', '1173', '3321', '149', '569', '287', '331', '2360', '132', '3161', '1154', '1010', '421', '1342', '657', '1615', '2327', '269', '2326', '1060', '58', '2954', '823', '2860', '1181', '551', '1640', '1567', '2538', '3228', '4528', '973', '3046', '114', '3157', '180', '4385', '4339', '471', '2201', '221', '2180', '4540', '677', '2887', '1702', '2388', '3250', '2095', '3114', '4144', '602', '1169', '1994', '3150', '969', '780', '1444', '91', '2156', '3224', '1920', '2916', '492', '2561', '500', '638', '3328', '786', '1321', '142', '1357', '581', '809', '1165', '635', '2335', '1235', '2731', '3001', '1967', '2352', '584', '1568', '2137', '578', '4241', '678', '2389', '469', '10', '201', '1532', '1247', '4007', '107', '2861', '443', '4534', '352', '1688', '779', '2069', '1250', '2736', '3355', '356', '2563', '2993', '3243', '2425', '312', '807', '1285', '2299', '1891', '2838', '119', '361', '2264', '4525', '1948', '3239', '1322', '1346', '558', '123', '2859', '1719', '1122', '2741', '4368', '1061', '2925', '343', '2311', '853', '510', '2372', '1132', '504', '615', '2821', '5217', '951', '1620', '2932', '1191', '2', '1027', '2879', '166', '4320', '1277', '778', '2422', '494', '2129', '2883', '1565', '1499', '2455', '3131', '1117', '1957', '952', '1192', '1174', '1505', '1260', '1978', '4535', '1502', '288', '3301', '490', '3249', '4519', '147', '2017', '975', '1367', '2730', '2892', '2713', '3255', '2814', '2433', '4419', '464', '631', '4289', '1537', '2192', '410', '2727', '609', '117', '4001', '3019', '507', '2788', '3141', '619', '2943', '4306', '282', '1471', '474', '2002', '2300', '2556', '5219', '3032', '3209', '1718', '2947', '3006', '477', '570', '1269', '1228', '85', '2729', '1086', '1893', '5066', '29', '1487', '682', '854', '1135', '299', '2758', '1188', '74', '2367', '3167', '1194', '2465', '3121', '49', '632', '1987', '856', '4388', '1459', '245', '2532', '289', '626', '234', '3096', '630', '3116', '155', '2163', '543', '1734', '2764', '1580', '2028', '476', '605', '372', '2421', '2166', '298', '1358', '59', '518', '3245', '145', '2936', '2535', '1002', '122', '900', '3338', '158', '1083', '4', '2982', '522', '3020', '1493', '2630', '660', '1026', '1617', '1205', '2408', '3156', '4111', '579', '3252', '3045', '101', '2261', '1139', '857', '243', '208', '506', '852', '1592', '2900', '509', '3095', '1703', '1480', '235', '4546', '2844', '2036', '1452', '462', '1157', '1052', '967', '3022', '190', '2018', '236', '1634', '2320', '970', '95', '3225', '3200', '4254', '2088', '2979', '1054', '1345', '350', '4218', '110', '397', '3216', '60', '3009', '1997', '223', '2941', '338', '537', '2312', '4356', '1440', '2459', '1486', '211', '629', '869', '3332', '1635', '1370', '2167', '314', '2976', '3136', '2965', '1507', '2933', '3010', '1434', '895', '1479', '1076', '1407', '2711', '4347', '1206', '1412', '1114', '428', '1340', '2732', '1935', '2796', '2135', '1977', '1622', '4522', '1926', '3151', '1109', '2792', '318', '3281', '1958', '1189', '1613', '4013', '2376', '1995', '3097', '5214', '2323', '2798', '508', '2840', '2182', '978', '2034', '1474', '461', '2841', '2414', '4113', '2365', '3241', '3256', '2000', '1986', '2902', '316', '1450', '2961', '2913', '3308', '2912', '3042', '1183', '1177', '1292', '3238', '2164', '2160', '203', '1417', '3147', '1245', '4342', '2896', '2254', '39', '393', '498', '4236', '2570', '2889', '1184', '3133', '4300', '3003', '1982', '1453', '1343', '1128', '2341', '189', '1140', '2830', '845', '3248', '2782', '1616', '544', '1894', '1606', '2903', '2263', '2011', '17', '1706', '2827', '519', '1446', '4174', '151', '1898', '3014', '2449', '136', '1461', '3043', '2356', '1041', '1710', '2022', '6', '1424', '2971', '2102', '268', '3165', '2469', '200', '2918', '2994', '4198', '841', '2881', '3154', '993', '1519', '279', '998', '1430', '479', '4149', '1308', '1110', '370', '939', '2005', '398', '1522', '1020', '4533', '2001', '496', '3139', '1985', '1220', '1447', '1101', '530', '4296', '277', '4328', '3227', '3018', '1726', '801', '2739', '1690', '2822', '3127', '1712', '1954', '4322', '1155', '1610', '565', '2911', '3324', '2751', '3171', '607', '715', '1013', '2130', '2983', '2924', '833', '622', '2338', '1057', '1526', '1180', '1070', '468', '2386', '213', '82', '2298', '1408', '1419', '3166', '181', '2557', '2997', '802', '936', '1120', '325', '646', '566', '3126', '156', '2805', '3102', '3173', '3226', '1426', '1036', '1552', '2878', '5220', '3193', '2255', '3132', '1534', '3211', '2133', '1273', '1311', '3163', '1281', '649', '1369', '2401', '2850', '818', '3112', '3212', '1115', '1022', '4014', '2898', '2876', '1445', '1069', '2253', '539', '2633', '169', '1082', '2546', '1969', '125', '1621', '1900', '3159', '676', '192', '76', '3257', '3011', '489', '1334', '1048', '3034', '596', '2978', '1989', '1708', '2296', '4258', '1377', '1012', '176', '135', '160', '703', '140', '3188', '3105', '1937', '2432', '2797', '1953', '2804', '563', '624', '3005', '505', '534', '942', '334', '216', '1405', '3271', '2208', '2749', '1339', '2305', '4133', '2162', '371', '2926', '3194', '28', '1253', '1075', '2848', '2315', '870', '283', '2345', '2307', '357', '2370', '1344', '2895', '3013', '1094', '2969', '2329', '2743', '580', '943', '11', '1414', '1435', '1362', '3123', '2975', '303', '3160', '2733', '3164', '2262', '3030', '249', '1546', '521', '1142', '2946', '1608', '207', '3124', '1239', '944', '513', '323', '2998', '4527', '2826', '511', '159', '2846', '98', '621', '851', '1973', '3145', '2919', '617', '1490', '774', '1467', '3213', '179', '1705', '1959', '2279', '1460', '3186', '5216', '546', '3174', '941', '487', '297', '1214', '27', '4309', '2735', '1231', '475', '3340', '2806', '1923', '3087', '2288', '2791', '526', '2247', '1458', '1514', '4192', '640', '2466', '244', '3205', '548', '793', '80', '3110', '1724', '2828', '2306', '1186', '1965', '859', '2013', '1359', '2728', '2014', '191', '2259', '2026', '3120', '3142', '31', '2177', '247', '2094', '2525', '1940', '1433', '606', '185', '670', '2877', '1210', '2634', '3254', '2740', '1107', '304', '3140', '3168', '945', '1330', '1463', '1736', '1212', '290', '1633', '1947', '3015', '1038', '175', '3008', '3195', '2596', '707', '2346', '3184', '2863', '1148', '2843', '4341', '1237', '2348', '2818', '501', '127', '1951', '906', '2905', '2986', '2420', '2091', '4114', '4140', '3023', '472', '1448', '2260', '187', '3325', '1956', '2514', '3259', '2555', '1437', '1018', '480', '4224', '700', '242', '488', '87', '2405', '1683', '96', '3152', '3237', '3314', '1664', '561', '2169', '139', '26', '2884', '2888', '319', '1375', '1236', '2891', '1475', '1470', '430', '1196', '2362', '12', '3169', '1462', '2631', '2770', '1949', '2580', '317', '1118', '1014', '773', '42', '2551', '557', '188', '1324', '381', '1722', '1195', '860', '1271', '2886', '2865', '1337', '1628', '1416', '3185', '1626', '1328', '2824', '144', '63', '4006', '3233', '1727', '2910', '3130', '3198', '2251', '337', '2438', '2398', '1021', '2635', '1495', '3258', '394', '618', '1952', '1942', '2882', '2548', '3026', '133', '636', '1998', '2252', '789', '67', '587', '2723', '2403', '1960', '2472', '1007', '2771', '2808', '568', '2920', '797', '198', '3235', '1182', '4418', '38', '4004', '4282', '2985', '4011', '2475', '2297', '2194', '1619', '1159', '1715', '2874', '1439', '2191', '2473', '2154', '796', '3192', '248', '3134', '458', '3230', '781', '4417', '2712', '3302', '310', '2418', '4273', '2870', '3201', '3196', '798', '79', '2785', '1494', '2025', '788', '701', '1723', '3149', '560', '2842', '2457', '992', '3031', '645', '794', '1996', '454', '195', '2476', '2725', '2980', '1473', '2410', '2921', '143', '1483', '233', '1199', '71', '2974', '1366', '674', '3179', '1193', '1454', '2015', '1492', '2562', '4009', '933', '3099', '3177', '3307', '938', '1981', '1908', '238', '1489', '2379', '2521', '1890', '608', '2021', '2395', '3253', '173', '2744', '3203', '4313', '348', '347', '1420', '4530', '3106', '1092', '1698', '532', '1950', '111', '2987', '3093', '3117', '4205', '858', '4163', '2304', '336', '353', '4526', '1488', '990', '4529', '953', '1056', '258', '3217', '2963', '928', '183', '1163', '3304', '1704', '2714', '2534', '586', '2276', '399', '2849', '663', '3206', '529', '2161', '3041', '94', '2854', '1178', '2776', '1299', '2722', '514', '2382', '3028', '40', '960', '553', '2010', '2265', '5', '1482', '2981', '804', '2597', '2031', '4319', '1643', '2158', '2147', '1136', '2773', '4290', '531', '782', '3187', '956', '1436', '1630', '790', '3247', '795', '1968', '3240', '1354', '2012', '2780', '1130', '1209', '168', '349', '542', '4415', '1353', '2823', '460', '971', '1611', '2193', '2753', '1429', '3183', '699', '3221', '693', '1503', '1976', '1431', '2716', '3016', '3115', '1011', '4343', '2196', '126', '1496', '972', '141', '1575', '2815', '3172', '4363', '2856', '1903'}\n", + "['2923', '1409', '1279', '1984', '3000', '594', '99', '1464', '1103', '4416', '3317', '538', '842', '3210', '868', '1185', '1119', '4531', '1451', '3204', '1428', '1536', '2440', '559', '934', '589', '355', '1624', '2553', '3208', '1267', '1685', '1241', '2141', '109', '517', '214', '600', '75', '307', '1402', '3182', '2893', '1363', '2794', '2989', '935', '3219', '483', '1376', '137', '616', '1349', '1618', '3202', '1364', '231', '2937', '637', '772', '555', '1222', '131', '246', '1145', '4294', '1023', '2363', '2726', '1609', '2295', '2897', '2813', '2750', '1187', '83', '3143', '664', '2143', '1095', '1971', '466', '2128', '2391', '3334', '533', '4317', '2853', '293', '1993', '832', '81', '9', '1501', '3108', '1099', '315', '1230', '2029', '1573', '278', '241', '1', '2787', '931', '345', '784', '1711', '1961', '2558', '295', '294', '2623', '286', '1197', '284', '1589', '2172', '1607', '108', '202', '32', '2547', '1368', '2333', '2835', '1141', '362', '2209', '380', '2377', '3109', '3199', '2890', '4298', '1175', '4257', '2948', '1031', '1605', '535', '1636', '604', '2945', '2795', '491', '261', '671', '2019', '339', '2006', '2908', '2151', '1980', '1265', '1717', '800', '515', '2450', '1972', '1172', '1497', '2709', '1263', '1716', '2283', '18', '3309', '2434', '395', '822', '1102', '576', '1238', '4359', '556', '2833', '1262', '146', '19', '2774', '1190', '2869', '836', '308', '1240', '2809', '613', '713', '3158', '2198', '1631', '1093', '583', '2769', '2452', '333', '1067', '625', '1901', '113', '3181', '3146', '2477', '3222', '478', '1645', '3092', '3311', '296', '986', '2170', '2858', '3002', '791', '1378', '3279', '285', '675', '2280', '1283', '4352', '1614', '2474', '2899', '51', '170', '2290', '1627', '633', '1123', '2343', '1939', '2957', '2351', '1992', '2935', '1468', '3229', '639', '1203', '4335', '696', '592', '1301', '1465', '2917', '3155', '3246', '267', '33', '1373', '2724', '3273', '1647', '2789', '3175', '2336', '3137', '2008', '2737', '2914', '1164', '2778', '2775', '2928', '2832', '2165', '892', '850', '1638', '3103', '839', '3178', '1889', '232', '197', '2786', '327', '2096', '212', '257', '1009', '4116', '2825', '831', '2359', '1006', '281', '240', '1681', '3100', '4346', '2174', '3111', '50', '103', '1415', '15', '2810', '706', '623', '225', '2996', '512', '4323', '520', '470', '228', '1481', '1255', '572', '1077', '603', '1350', '4324', '4232', '467', '2812', '2613', '1563', '4355', '3244', '130', '351', '2977', '2428', '1030', '595', '1432', '2807', '239', '3266', '263', '1970', '2904', '311', '799', '3162', '227', '2393', '2435', '4148', '550', '4255', '1941', '4543', '2286', '642', '1441', '590', '1529', '562', '1131', '3251', '1925', '1896', '3118', '4357', '280', '977', '3086', '14', '1161', '1341', '3242', '1892', '610', '2090', '2970', '2330', '1291', '2852', '2074', '1044', '1637', '2287', '552', '1905', '2964', '86', '2024', '128', '4131', '1919', '4297', '817', '1032', '36', '1372', '1176', '2378', '2885', '1087', '2406', '465', '1040', '2460', '4267', '1005', '2513', '2134', '1400', '2032', '2952', '1642', '4122', '577', '2999', '292', '93', '2864', '4020', '4351', '536', '3207', '588', '1484', '3180', '1200', '2984', '1062', '2373', '300', '2966', '1720', '1472', '473', '2270', '1423', '358', '1332', '1361', '1964', '792', '335', '1938', '2301', '4248', '2811', '21', '369', '2894', '1037', '1697', '2873', '3191', '2944', '834', '611', '1401', '1078', '965', '4361', '2445', '2567', '1612', '1975', '2358', '1713', '3223', '2103', '2317', '3269', '4016', '5218', '667', '1410', '2144', '2462', '184', '1275', '328', '2608', '2322', '4532', '1091', '2962', '811', '3144', '2003', '3021', '1639', '78', '273', '2576', '1477', '582', '684', '665', '686', '886', '3012', '1008', '4202', '1167', '2179', '3135', '4523', '803', '134', '390', '648', '3125', '138', '2816', '2337', '2784', '549', '697', '1963', '2867', '777', '575', '153', '163', '148', '3138', '571', '3004', '1700', '628', '2266', '1356', '516', '1104', '359', '366', '1374', '2150', '34', '291', '1331', '3220', '4235', '450', '787', '2851', '302', '2880', '1290', '2568', '2901', '1945', '463', '2030', '840', '2756', '45', '545', '2284', '2754', '89', '1943', '2456', '503', '118', '2004', '4521', '1202', '593', '808', '1485', '3310', '493', '1478', '2278', '641', '274', '1988', '1623', '457', '653', '1298', '1931', '2820', '4252', '4538', '979', '1944', '1017', '4520', '1004', '73', '1696', '1930', '643', '1335', '2745', '3128', '2829', '1962', '1108', '1500', '3024', '1632', '264', '4015', '2681', '4216', '785', '1223', '2402', '320', '2258', '2282', '2385', '1249', '3119', '904', '164', '4143', '1558', '2328', '1024', '2331', '3234', '2847', '2308', '4386', '2632', '3017', '485', '1300', '497', '903', '2988', '2027', '4199', '983', '3320', '47', '272', '614', '3101', '1644', '1266', '2447', '1438', '2855', '2834', '1928', '2747', '2190', '396', '486', '4354', '1897', '591', '301', '2195', '4119', '1179', '1068', '1050', '265', '120', '2929', '2790', '1999', '309', '3197', '150', '2171', '2409', '968', '2256', '864', '4536', '3098', '4541', '2738', '1360', '647', '1990', '3231', '330', '4229', '2600', '2319', '1403', '152', '1088', '1991', '1033', '1003', '1327', '182', '775', '102', '2721', '690', '4316', '2524', '547', '2875', '62', '2866', '829', '574', '237', '1714', '502', '620', '567', '4321', '161', '2922', '3153', '1198', '597', '554', '2210', '1455', '3315', '2016', '329', '4318', '1740', '270', '2951', '805', '2354', '2748', '564', '2746', '1680', '4194', '1625', '1351', '1966', '634', '4170', '2446', '1201', '1550', '3113', '2138', '2159', '585', '4008', '598', '2396', '830', '3214', '495', '2139', '2407', '3265', '459', '1207', '1173', '3321', '149', '569', '287', '331', '2360', '132', '3161', '1154', '1010', '421', '1342', '657', '1615', '2327', '269', '2326', '1060', '58', '2954', '823', '2860', '1181', '551', '1640', '1567', '2538', '3228', '4528', '973', '3046', '114', '3157', '180', '4385', '4339', '471', '2201', '221', '2180', '4540', '677', '2887', '1702', '2388', '3250', '2095', '3114', '4144', '602', '1169', '1994', '3150', '969', '780', '1444', '91', '2156', '3224', '1920', '2916', '492', '2561', '500', '638', '3328', '786', '1321', '142', '1357', '581', '809', '1165', '635', '2335', '1235', '2731', '3001', '1967', '2352', '584', '1568', '2137', '578', '4241', '678', '2389', '469', '10', '201', '1532', '1247', '4007', '107', '2861', '443', '4534', '352', '1688', '779', '2069', '1250', '2736', '3355', '356', '2563', '2993', '3243', '2425', '312', '807', '1285', '2299', '1891', '2838', '119', '361', '2264', '4525', '1948', '3239', '1322', '1346', '558', '123', '2859', '1719', '1122', '2741', '4368', '1061', '2925', '343', '2311', '853', '510', '2372', '1132', '504', '615', '2821', '5217', '951', '1620', '2932', '1191', '2', '1027', '2879', '166', '4320', '1277', '778', '2422', '494', '2129', '2883', '1565', '1499', '2455', '3131', '1117', '1957', '952', '1192', '1174', '1505', '1260', '1978', '4535', '1502', '288', '3301', '490', '3249', '4519', '147', '2017', '975', '1367', '2730', '2892', '2713', '3255', '2814', '2433', '4419', '464', '631', '4289', '1537', '2192', '410', '2727', '609', '117', '4001', '3019', '507', '2788', '3141', '619', '2943', '4306', '282', '1471', '474', '2002', '2300', '2556', '5219', '3032', '3209', '1718', '2947', '3006', '477', '570', '1269', '1228', '85', '2729', '1086', '1893', '5066', '29', '1487', '682', '854', '1135', '299', '2758', '1188', '74', '2367', '3167', '1194', '2465', '3121', '49', '632', '1987', '856', '4388', '1459', '245', '2532', '289', '626', '234', '3096', '630', '3116', '155', '2163', '543', '1734', '2764', '1580', '2028', '476', '605', '372', '2421', '2166', '298', '1358', '59', '518', '3245', '145', '2936', '2535', '1002', '122', '900', '3338', '158', '1083', '4', '2982', '522', '3020', '1493', '2630', '660', '1026', '1617', '1205', '2408', '3156', '4111', '579', '3252', '3045', '101', '2261', '1139', '857', '243', '208', '506', '852', '1592', '2900', '509', '3095', '1703', '1480', '235', '4546', '2844', '2036', '1452', '462', '1157', '1052', '967', '3022', '190', '2018', '236', '1634', '2320', '970', '95', '3225', '3200', '4254', '2088', '2979', '1054', '1345', '350', '4218', '110', '397', '3216', '60', '3009', '1997', '223', '2941', '338', '537', '2312', '4356', '1440', '2459', '1486', '211', '629', '869', '3332', '1635', '1370', '2167', '314', '2976', '3136', '2965', '1507', '2933', '3010', '1434', '895', '1479', '1076', '1407', '2711', '4347', '1206', '1412', '1114', '428', '1340', '2732', '1935', '2796', '2135', '1977', '1622', '4522', '1926', '3151', '1109', '2792', '318', '3281', '1958', '1189', '1613', '4013', '2376', '1995', '3097', '5214', '2323', '2798', '508', '2840', '2182', '978', '2034', '1474', '461', '2841', '2414', '4113', '2365', '3241', '3256', '2000', '1986', '2902', '316', '1450', '2961', '2913', '3308', '2912', '3042', '1183', '1177', '1292', '3238', '2164', '2160', '203', '1417', '3147', '1245', '4342', '2896', '2254', '39', '393', '498', '4236', '2570', '2889', '1184', '3133', '4300', '3003', '1982', '1453', '1343', '1128', '2341', '189', '1140', '2830', '845', '3248', '2782', '1616', '544', '1894', '1606', '2903', '2263', '2011', '17', '1706', '2827', '519', '1446', '4174', '151', '1898', '3014', '2449', '136', '1461', '3043', '2356', '1041', '1710', '2022', '6', '1424', '2971', '2102', '268', '3165', '2469', '200', '2918', '2994', '4198', '841', '2881', '3154', '993', '1519', '279', '998', '1430', '479', '4149', '1308', '1110', '370', '939', '2005', '398', '1522', '1020', '4533', '2001', '496', '3139', '1985', '1220', '1447', '1101', '530', '4296', '277', '4328', '3227', '3018', '1726', '801', '2739', '1690', '2822', '3127', '1712', '1954', '4322', '1155', '1610', '565', '2911', '3324', '2751', '3171', '607', '715', '1013', '2130', '2983', '2924', '833', '622', '2338', '1057', '1526', '1180', '1070', '468', '2386', '213', '82', '2298', '1408', '1419', '3166', '181', '2557', '2997', '802', '936', '1120', '325', '646', '566', '3126', '156', '2805', '3102', '3173', '3226', '1426', '1036', '1552', '2878', '5220', '3193', '2255', '3132', '1534', '3211', '2133', '1273', '1311', '3163', '1281', '649', '1369', '2401', '2850', '818', '3112', '3212', '1115', '1022', '4014', '2898', '2876', '1445', '1069', '2253', '539', '2633', '169', '1082', '2546', '1969', '125', '1621', '1900', '3159', '676', '192', '76', '3257', '3011', '489', '1334', '1048', '3034', '596', '2978', '1989', '1708', '2296', '4258', '1377', '1012', '176', '135', '160', '703', '140', '3188', '3105', '1937', '2432', '2797', '1953', '2804', '563', '624', '3005', '505', '534', '942', '334', '216', '1405', '3271', '2208', '2749', '1339', '2305', '4133', '2162', '371', '2926', '3194', '28', '1253', '1075', '2848', '2315', '870', '283', '2345', '2307', '357', '2370', '1344', '2895', '3013', '1094', '2969', '2329', '2743', '580', '943', '11', '1414', '1435', '1362', '3123', '2975', '303', '3160', '2733', '3164', '2262', '3030', '249', '1546', '521', '1142', '2946', '1608', '207', '3124', '1239', '944', '513', '323', '2998', '4527', '2826', '511', '159', '2846', '98', '621', '851', '1973', '3145', '2919', '617', '1490', '774', '1467', '3213', '179', '1705', '1959', '2279', '1460', '3186', '5216', '546', '3174', '941', '487', '297', '1214', '27', '4309', '2735', '1231', '475', '3340', '2806', '1923', '3087', '2288', '2791', '526', '2247', '1458', '1514', '4192', '640', '2466', '244', '3205', '548', '793', '80', '3110', '1724', '2828', '2306', '1186', '1965', '859', '2013', '1359', '2728', '2014', '191', '2259', '2026', '3120', '3142', '31', '2177', '247', '2094', '2525', '1940', '1433', '606', '185', '670', '2877', '1210', '2634', '3254', '2740', '1107', '304', '3140', '3168', '945', '1330', '1463', '1736', '1212', '290', '1633', '1947', '3015', '1038', '175', '3008', '3195', '2596', '707', '2346', '3184', '2863', '1148', '2843', '4341', '1237', '2348', '2818', '501', '127', '1951', '906', '2905', '2986', '2420', '2091', '4114', '4140', '3023', '472', '1448', '2260', '187', '3325', '1956', '2514', '3259', '2555', '1437', '1018', '480', '4224', '700', '242', '488', '87', '2405', '1683', '96', '3152', '3237', '3314', '1664', '561', '2169', '139', '26', '2884', '2888', '319', '1375', '1236', '2891', '1475', '1470', '430', '1196', '2362', '12', '3169', '1462', '2631', '2770', '1949', '2580', '317', '1118', '1014', '773', '42', '2551', '557', '188', '1324', '381', '1722', '1195', '860', '1271', '2886', '2865', '1337', '1628', '1416', '3185', '1626', '1328', '2824', '144', '63', '4006', '3233', '1727', '2910', '3130', '3198', '2251', '337', '2438', '2398', '1021', '2635', '1495', '3258', '394', '618', '1952', '1942', '2882', '2548', '3026', '133', '636', '1998', '2252', '789', '67', '587', '2723', '2403', '1960', '2472', '1007', '2771', '2808', '568', '2920', '797', '198', '3235', '1182', '4418', '38', '4004', '4282', '2985', '4011', '2475', '2297', '2194', '1619', '1159', '1715', '2874', '1439', '2191', '2473', '2154', '796', '3192', '248', '3134', '458', '3230', '781', '4417', '2712', '3302', '310', '2418', '4273', '2870', '3201', '3196', '798', '79', '2785', '1494', '2025', '788', '701', '1723', '3149', '560', '2842', '2457', '992', '3031', '645', '794', '1996', '454', '195', '2476', '2725', '2980', '1473', '2410', '2921', '143', '1483', '233', '1199', '71', '2974', '1366', '674', '3179', '1193', '1454', '2015', '1492', '2562', '4009', '933', '3099', '3177', '3307', '938', '1981', '1908', '238', '1489', '2379', '2521', '1890', '608', '2021', '2395', '3253', '173', '2744', '3203', '4313', '348', '347', '1420', '4530', '3106', '1092', '1698', '532', '1950', '111', '2987', '3093', '3117', '4205', '858', '4163', '2304', '336', '353', '4526', '1488', '990', '4529', '953', '1056', '258', '3217', '2963', '928', '183', '1163', '3304', '1704', '2714', '2534', '586', '2276', '399', '2849', '663', '3206', '529', '2161', '3041', '94', '2854', '1178', '2776', '1299', '2722', '514', '2382', '3028', '40', '960', '553', '2010', '2265', '5', '1482', '2981', '804', '2597', '2031', '4319', '1643', '2158', '2147', '1136', '2773', '4290', '531', '782', '3187', '956', '1436', '1630', '790', '3247', '795', '1968', '3240', '1354', '2012', '2780', '1130', '1209', '168', '349', '542', '4415', '1353', '2823', '460', '971', '1611', '2193', '2753', '1429', '3183', '699', '3221', '693', '1503', '1976', '1431', '2716', '3016', '3115', '1011', '4343', '2196', '126', '1496', '972', '141', '1575', '2815', '3172', '4363', '2856', '1903']\n" ] }, { - "ename": "Exception", - "evalue": "Could not open file /home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/", + "ename": "ValueError", + "evalue": "('Lengths must match to compare', (3956,), (1909,))", "output_type": "error", "traceback": [ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mCPLE_OpenFailedError\u001b[0m Traceback (most recent call last)", - "File \u001b[0;32mfiona/ogrext.pyx:136\u001b[0m, in \u001b[0;36mfiona.ogrext.gdal_open_vector\u001b[0;34m()\u001b[0m\n", - "File \u001b[0;32mfiona/_err.pyx:291\u001b[0m, in \u001b[0;36mfiona._err.exc_wrap_pointer\u001b[0;34m()\u001b[0m\n", - "\u001b[0;31mCPLE_OpenFailedError\u001b[0m: '/home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/' not recognized as a supported file format.", - "\nDuring handling of the above exception, another exception occurred:\n", - "\u001b[0;31mDriverError\u001b[0m Traceback (most recent call last)", - "File \u001b[0;32m~/freelance/02_ort_ecmwf/dev/hat/hat/observations.py:49\u001b[0m, in \u001b[0;36mread_station_metadata_file\u001b[0;34m(fpath, coord_names, epsg, filters)\u001b[0m\n\u001b[1;32m 48\u001b[0m \u001b[39melse\u001b[39;00m:\n\u001b[0;32m---> 49\u001b[0m gdf \u001b[39m=\u001b[39m gpd\u001b[39m.\u001b[39;49mread_file(fpath)\n\u001b[1;32m 50\u001b[0m \u001b[39mexcept\u001b[39;00m \u001b[39mException\u001b[39;00m:\n", - "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/geopandas/io/file.py:297\u001b[0m, in \u001b[0;36m_read_file\u001b[0;34m(filename, bbox, mask, rows, engine, **kwargs)\u001b[0m\n\u001b[1;32m 295\u001b[0m path_or_bytes \u001b[39m=\u001b[39m filename\n\u001b[0;32m--> 297\u001b[0m \u001b[39mreturn\u001b[39;00m _read_file_fiona(\n\u001b[1;32m 298\u001b[0m path_or_bytes, from_bytes, bbox\u001b[39m=\u001b[39;49mbbox, mask\u001b[39m=\u001b[39;49mmask, rows\u001b[39m=\u001b[39;49mrows, \u001b[39m*\u001b[39;49m\u001b[39m*\u001b[39;49mkwargs\n\u001b[1;32m 299\u001b[0m )\n\u001b[1;32m 301\u001b[0m \u001b[39melse\u001b[39;00m:\n", - "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/geopandas/io/file.py:338\u001b[0m, in \u001b[0;36m_read_file_fiona\u001b[0;34m(path_or_bytes, from_bytes, bbox, mask, rows, where, **kwargs)\u001b[0m\n\u001b[1;32m 337\u001b[0m \u001b[39mwith\u001b[39;00m fiona_env():\n\u001b[0;32m--> 338\u001b[0m \u001b[39mwith\u001b[39;00m reader(path_or_bytes, \u001b[39m*\u001b[39;49m\u001b[39m*\u001b[39;49mkwargs) \u001b[39mas\u001b[39;00m features:\n\u001b[1;32m 339\u001b[0m crs \u001b[39m=\u001b[39m features\u001b[39m.\u001b[39mcrs_wkt\n", - "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/fiona/env.py:457\u001b[0m, in \u001b[0;36mensure_env_with_credentials..wrapper\u001b[0;34m(*args, **kwds)\u001b[0m\n\u001b[1;32m 456\u001b[0m \u001b[39mwith\u001b[39;00m env_ctor(session\u001b[39m=\u001b[39msession):\n\u001b[0;32m--> 457\u001b[0m \u001b[39mreturn\u001b[39;00m f(\u001b[39m*\u001b[39;49margs, \u001b[39m*\u001b[39;49m\u001b[39m*\u001b[39;49mkwds)\n", - "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/fiona/__init__.py:292\u001b[0m, in \u001b[0;36mopen\u001b[0;34m(fp, mode, driver, schema, crs, encoding, layer, vfs, enabled_drivers, crs_wkt, allow_unsupported_drivers, **kwargs)\u001b[0m\n\u001b[1;32m 291\u001b[0m \u001b[39mif\u001b[39;00m mode \u001b[39min\u001b[39;00m (\u001b[39m\"\u001b[39m\u001b[39ma\u001b[39m\u001b[39m\"\u001b[39m, \u001b[39m\"\u001b[39m\u001b[39mr\u001b[39m\u001b[39m\"\u001b[39m):\n\u001b[0;32m--> 292\u001b[0m colxn \u001b[39m=\u001b[39m Collection(\n\u001b[1;32m 293\u001b[0m path,\n\u001b[1;32m 294\u001b[0m mode,\n\u001b[1;32m 295\u001b[0m driver\u001b[39m=\u001b[39;49mdriver,\n\u001b[1;32m 296\u001b[0m encoding\u001b[39m=\u001b[39;49mencoding,\n\u001b[1;32m 297\u001b[0m layer\u001b[39m=\u001b[39;49mlayer,\n\u001b[1;32m 298\u001b[0m enabled_drivers\u001b[39m=\u001b[39;49menabled_drivers,\n\u001b[1;32m 299\u001b[0m allow_unsupported_drivers\u001b[39m=\u001b[39;49mallow_unsupported_drivers,\n\u001b[1;32m 300\u001b[0m \u001b[39m*\u001b[39;49m\u001b[39m*\u001b[39;49mkwargs\n\u001b[1;32m 301\u001b[0m )\n\u001b[1;32m 302\u001b[0m \u001b[39melif\u001b[39;00m mode \u001b[39m==\u001b[39m \u001b[39m\"\u001b[39m\u001b[39mw\u001b[39m\u001b[39m\"\u001b[39m:\n", - "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/fiona/collection.py:243\u001b[0m, in \u001b[0;36mCollection.__init__\u001b[0;34m(self, path, mode, driver, schema, crs, encoding, layer, vsi, archive, enabled_drivers, crs_wkt, ignore_fields, ignore_geometry, include_fields, wkt_version, allow_unsupported_drivers, **kwargs)\u001b[0m\n\u001b[1;32m 242\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39msession \u001b[39m=\u001b[39m Session()\n\u001b[0;32m--> 243\u001b[0m \u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49msession\u001b[39m.\u001b[39;49mstart(\u001b[39mself\u001b[39;49m, \u001b[39m*\u001b[39;49m\u001b[39m*\u001b[39;49mkwargs)\n\u001b[1;32m 244\u001b[0m \u001b[39melif\u001b[39;00m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mmode \u001b[39min\u001b[39;00m (\u001b[39m\"\u001b[39m\u001b[39ma\u001b[39m\u001b[39m\"\u001b[39m, \u001b[39m\"\u001b[39m\u001b[39mw\u001b[39m\u001b[39m\"\u001b[39m):\n", - "File \u001b[0;32mfiona/ogrext.pyx:588\u001b[0m, in \u001b[0;36mfiona.ogrext.Session.start\u001b[0;34m()\u001b[0m\n", - "File \u001b[0;32mfiona/ogrext.pyx:143\u001b[0m, in \u001b[0;36mfiona.ogrext.gdal_open_vector\u001b[0;34m()\u001b[0m\n", - "\u001b[0;31mDriverError\u001b[0m: '/home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/' not recognized as a supported file format.", - "\nDuring handling of the above exception, another exception occurred:\n", - "\u001b[0;31mException\u001b[0m Traceback (most recent call last)", + "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", "\u001b[1;32m/home/dadiyorto/freelance/02_ort_ecmwf/dev/hat/notebooks/examples/4_visualisation_interactive.ipynb Cell 4\u001b[0m line \u001b[0;36m1\n\u001b[0;32m----> 1\u001b[0m \u001b[39mmap\u001b[39m \u001b[39m=\u001b[39m NotebookMap(config, stations, observations, simulations, stats\u001b[39m=\u001b[39;49mstatistics)\n", - "File \u001b[0;32m~/freelance/02_ort_ecmwf/dev/hat/hat/visualisation.py:94\u001b[0m, in \u001b[0;36mNotebookMap.__init__\u001b[0;34m(self, config, stations_metadata, observations, simulations, stats)\u001b[0m\n\u001b[1;32m 92\u001b[0m \u001b[39mdef\u001b[39;00m \u001b[39m__init__\u001b[39m(\u001b[39mself\u001b[39m, config: Dict, stations_metadata: \u001b[39mstr\u001b[39m, observations: \u001b[39mstr\u001b[39m, simulations: Union[Dict, \u001b[39mstr\u001b[39m], stats\u001b[39m=\u001b[39m\u001b[39mNone\u001b[39;00m):\n\u001b[1;32m 93\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mconfig \u001b[39m=\u001b[39m config\n\u001b[0;32m---> 94\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata \u001b[39m=\u001b[39m read_station_metadata_file(\n\u001b[1;32m 95\u001b[0m fpath\u001b[39m=\u001b[39;49mstations_metadata,\n\u001b[1;32m 96\u001b[0m coord_names\u001b[39m=\u001b[39;49mconfig[\u001b[39m'\u001b[39;49m\u001b[39mstation_coordinates\u001b[39;49m\u001b[39m'\u001b[39;49m],\n\u001b[1;32m 97\u001b[0m epsg\u001b[39m=\u001b[39;49mconfig[\u001b[39m'\u001b[39;49m\u001b[39mstation_epsg\u001b[39;49m\u001b[39m'\u001b[39;49m],\n\u001b[1;32m 98\u001b[0m filters\u001b[39m=\u001b[39;49mconfig[\u001b[39m'\u001b[39;49m\u001b[39mstation_filters\u001b[39;49m\u001b[39m'\u001b[39;49m]\n\u001b[1;32m 99\u001b[0m )\n\u001b[1;32m 100\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstation_file_name \u001b[39m=\u001b[39m os\u001b[39m.\u001b[39mpath\u001b[39m.\u001b[39mbasename(stations_metadata) \u001b[39m# Derive the file name here\u001b[39;00m\n\u001b[1;32m 101\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata[\u001b[39m'\u001b[39m\u001b[39mObsID\u001b[39m\u001b[39m'\u001b[39m] \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata[\u001b[39m'\u001b[39m\u001b[39mObsID\u001b[39m\u001b[39m'\u001b[39m]\u001b[39m.\u001b[39mastype(\u001b[39mstr\u001b[39m)\n", - "File \u001b[0;32m~/freelance/02_ort_ecmwf/dev/hat/hat/observations.py:51\u001b[0m, in \u001b[0;36mread_station_metadata_file\u001b[0;34m(fpath, coord_names, epsg, filters)\u001b[0m\n\u001b[1;32m 49\u001b[0m gdf \u001b[39m=\u001b[39m gpd\u001b[39m.\u001b[39mread_file(fpath)\n\u001b[1;32m 50\u001b[0m \u001b[39mexcept\u001b[39;00m \u001b[39mException\u001b[39;00m:\n\u001b[0;32m---> 51\u001b[0m \u001b[39mraise\u001b[39;00m \u001b[39mException\u001b[39;00m(\u001b[39mf\u001b[39m\u001b[39m\"\u001b[39m\u001b[39mCould not open file \u001b[39m\u001b[39m{\u001b[39;00mfpath\u001b[39m}\u001b[39;00m\u001b[39m\"\u001b[39m)\n\u001b[1;32m 53\u001b[0m \u001b[39m# (optionally) filter the stations, e.g. 'Contintent == Europe'\u001b[39;00m\n\u001b[1;32m 54\u001b[0m \u001b[39mif\u001b[39;00m filters \u001b[39mis\u001b[39;00m \u001b[39mnot\u001b[39;00m \u001b[39mNone\u001b[39;00m:\n", - "\u001b[0;31mException\u001b[0m: Could not open file /home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/" + "File \u001b[0;32m~/freelance/02_ort_ecmwf/dev/hat/hat/visualisation.py:112\u001b[0m, in \u001b[0;36mNotebookMap.__init__\u001b[0;34m(self, config, stations_metadata, observations, simulations, stats)\u001b[0m\n\u001b[1;32m 110\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mcommon_id \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mfind_common_station()\n\u001b[1;32m 111\u001b[0m \u001b[39mprint\u001b[39m(\u001b[39mself\u001b[39m\u001b[39m.\u001b[39mcommon_id)\n\u001b[0;32m--> 112\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata\u001b[39m.\u001b[39mloc[\u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49mstations_metadata[\u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49mstation_index] \u001b[39m==\u001b[39;49m \u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49mcommon_id]\n\u001b[1;32m 113\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mobs_ds \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mobs_ds\u001b[39m.\u001b[39msel(station \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mcommon_id)\n\u001b[1;32m 114\u001b[0m \u001b[39mfor\u001b[39;00m sim, ds \u001b[39min\u001b[39;00m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39msim_ds\u001b[39m.\u001b[39mitems():\n", + "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/pandas/core/ops/common.py:76\u001b[0m, in \u001b[0;36m_unpack_zerodim_and_defer..new_method\u001b[0;34m(self, other)\u001b[0m\n\u001b[1;32m 72\u001b[0m \u001b[39mreturn\u001b[39;00m \u001b[39mNotImplemented\u001b[39m\n\u001b[1;32m 74\u001b[0m other \u001b[39m=\u001b[39m item_from_zerodim(other)\n\u001b[0;32m---> 76\u001b[0m \u001b[39mreturn\u001b[39;00m method(\u001b[39mself\u001b[39;49m, other)\n", + "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/pandas/core/arraylike.py:40\u001b[0m, in \u001b[0;36mOpsMixin.__eq__\u001b[0;34m(self, other)\u001b[0m\n\u001b[1;32m 38\u001b[0m \u001b[39m@unpack_zerodim_and_defer\u001b[39m(\u001b[39m\"\u001b[39m\u001b[39m__eq__\u001b[39m\u001b[39m\"\u001b[39m)\n\u001b[1;32m 39\u001b[0m \u001b[39mdef\u001b[39;00m \u001b[39m__eq__\u001b[39m(\u001b[39mself\u001b[39m, other):\n\u001b[0;32m---> 40\u001b[0m \u001b[39mreturn\u001b[39;00m \u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49m_cmp_method(other, operator\u001b[39m.\u001b[39;49meq)\n", + "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/pandas/core/series.py:5799\u001b[0m, in \u001b[0;36mSeries._cmp_method\u001b[0;34m(self, other, op)\u001b[0m\n\u001b[1;32m 5796\u001b[0m lvalues \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39m_values\n\u001b[1;32m 5797\u001b[0m rvalues \u001b[39m=\u001b[39m extract_array(other, extract_numpy\u001b[39m=\u001b[39m\u001b[39mTrue\u001b[39;00m, extract_range\u001b[39m=\u001b[39m\u001b[39mTrue\u001b[39;00m)\n\u001b[0;32m-> 5799\u001b[0m res_values \u001b[39m=\u001b[39m ops\u001b[39m.\u001b[39;49mcomparison_op(lvalues, rvalues, op)\n\u001b[1;32m 5801\u001b[0m \u001b[39mreturn\u001b[39;00m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39m_construct_result(res_values, name\u001b[39m=\u001b[39mres_name)\n", + "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/pandas/core/ops/array_ops.py:323\u001b[0m, in \u001b[0;36mcomparison_op\u001b[0;34m(left, right, op)\u001b[0m\n\u001b[1;32m 318\u001b[0m \u001b[39mif\u001b[39;00m \u001b[39misinstance\u001b[39m(rvalues, (np\u001b[39m.\u001b[39mndarray, ABCExtensionArray)):\n\u001b[1;32m 319\u001b[0m \u001b[39m# TODO: make this treatment consistent across ops and classes.\u001b[39;00m\n\u001b[1;32m 320\u001b[0m \u001b[39m# We are not catching all listlikes here (e.g. frozenset, tuple)\u001b[39;00m\n\u001b[1;32m 321\u001b[0m \u001b[39m# The ambiguous case is object-dtype. See GH#27803\u001b[39;00m\n\u001b[1;32m 322\u001b[0m \u001b[39mif\u001b[39;00m \u001b[39mlen\u001b[39m(lvalues) \u001b[39m!=\u001b[39m \u001b[39mlen\u001b[39m(rvalues):\n\u001b[0;32m--> 323\u001b[0m \u001b[39mraise\u001b[39;00m \u001b[39mValueError\u001b[39;00m(\n\u001b[1;32m 324\u001b[0m \u001b[39m\"\u001b[39m\u001b[39mLengths must match to compare\u001b[39m\u001b[39m\"\u001b[39m, lvalues\u001b[39m.\u001b[39mshape, rvalues\u001b[39m.\u001b[39mshape\n\u001b[1;32m 325\u001b[0m )\n\u001b[1;32m 327\u001b[0m \u001b[39mif\u001b[39;00m should_extension_dispatch(lvalues, rvalues) \u001b[39mor\u001b[39;00m (\n\u001b[1;32m 328\u001b[0m (\u001b[39misinstance\u001b[39m(rvalues, (Timedelta, BaseOffset, Timestamp)) \u001b[39mor\u001b[39;00m right \u001b[39mis\u001b[39;00m NaT)\n\u001b[1;32m 329\u001b[0m \u001b[39mand\u001b[39;00m lvalues\u001b[39m.\u001b[39mdtype \u001b[39m!=\u001b[39m \u001b[39mobject\u001b[39m\n\u001b[1;32m 330\u001b[0m ):\n\u001b[1;32m 331\u001b[0m \u001b[39m# Call the method on lvalues\u001b[39;00m\n\u001b[1;32m 332\u001b[0m res_values \u001b[39m=\u001b[39m op(lvalues, rvalues)\n", + "\u001b[0;31mValueError\u001b[0m: ('Lengths must match to compare', (3956,), (1909,))" ] } ], @@ -87,130 +92,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Station 4 has color #c0df25 based on statistic value 0.5471751158959437\n", - "Station 5 has color #dfe318 based on statistic value 0.7376279988803034\n", - "Station 6 has color #d8e219 based on statistic value 0.6792520362572181\n", - "Station 8 has color #dde318 based on statistic value 0.7224318531801283\n", - "Station 9 has color #e5e419 based on statistic value 0.7650829122593195\n", - "Station 10 has color #e5e419 based on statistic value 0.7623623211979086\n", - "Station 11 has color #e5e419 based on statistic value 0.7626829463154589\n", - "Station 12 has color #b5de2b based on statistic value 0.4935717934686382\n", - "Station 13 has color #addc30 based on statistic value 0.45060948119659516\n", - "Station 14 has color #93d741 based on statistic value 0.3035724795386384\n", - "Station 15 has color #d8e219 based on statistic value 0.6843228165059871\n", - "Station 16 has color #c5e021 based on statistic value 0.5872259346281932\n", - "Station 17 has color #77d153 based on statistic value 0.13784125932391056\n", - "Station 18 has color #81d34d based on statistic value 0.20402810842358654\n", - "Station 19 has color #c2df23 based on statistic value 0.5655851330506583\n", - "Station 69 has color #8bd646 based on statistic value 0.2623725873210412\n", - "Station 71 has color #e5e419 based on statistic value 0.7651472440380137\n", - "Station 73 has color #d0e11c based on statistic value 0.6366702886046419\n", - "Station 74 has color #a0da39 based on statistic value 0.38069355270376415\n", - "Station 75 has color #e7e419 based on statistic value 0.771766702233077\n", - "Station 76 has color #e5e419 based on statistic value 0.7649466409284118\n", - "Station 77 has color #dde318 based on statistic value 0.7148989973551911\n", - "Station 78 has color #e5e419 based on statistic value 0.7578605067002151\n", - "Station 79 has color #c2df23 based on statistic value 0.570235775091059\n", - "Station 80 has color #440154 based on statistic value -2.8771271379605388\n", - "Station 81 has color #efe51c based on statistic value 0.8122966744760616\n", - "Station 82 has color #cae11f based on statistic value 0.6059442625222966\n", - "Station 83 has color #b0dd2f based on statistic value 0.46392177301101967\n", - "Station 84 has color #d2e21b based on statistic value 0.6492094621376316\n", - "Station 85 has color #9bd93c based on statistic value 0.3436411308610797\n", - "Station 86 has color #2cb17e based on statistic value -0.44917530504479264\n", - "Station 87 has color #1f968b based on statistic value -0.8785537121775633\n", - "Station 88 has color #dde318 based on statistic value 0.7084648770114568\n", - "Station 89 has color #7ad151 based on statistic value 0.15948589309326644\n", - "Station 90 has color #d8e219 based on statistic value 0.681858293809658\n", - "Station 92 has color #c8e020 based on statistic value 0.5933677848703405\n", - "Station 93 has color #98d83e based on statistic value 0.3332936321416301\n" - ] - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "85d6291cc7954b0ca8fd37c9a139c73a", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(HBox(children=(Map(center=[48.67101241090147, 9.645834728960766], controls=(ZoomControl(options…" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Error encountered: \"no index found for coordinate 'latitude'\"\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Error encountered: \"no index found for coordinate 'latitude'\"\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Error encountered: \"no index found for coordinate 'latitude'\"\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Error encountered: \"no index found for coordinate 'latitude'\"\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Error encountered: \"no index found for coordinate 'latitude'\"\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Error encountered: \"no index found for coordinate 'latitude'\"\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Error encountered: \"no index found for coordinate 'latitude'\"\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Error encountered: \"no index found for coordinate 'latitude'\"\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Error encountered: \"no index found for coordinate 'latitude'\"\n" - ] - } - ], + "outputs": [], "source": [ "map.mapplot(colorby='kge', sim='exp1')" ] @@ -227,433 +111,29 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "
<xarray.Dataset>\n",
-       "Dimensions:  (time: 1464, station: 1913)\n",
-       "Coordinates:\n",
-       "  * time     (time) datetime64[ns] 2000-01-01T12:00:00 ... 2001-01-01T06:00:00\n",
-       "  * station  (station) object '1' '10' '1002' '1003' ... '990' '992' '993' '998'\n",
-       "Data variables:\n",
-       "    obsdis   (time, station) float64 nan nan nan nan nan ... nan nan nan nan
" - ], - "text/plain": [ - "\n", - "Dimensions: (time: 1464, station: 1913)\n", - "Coordinates:\n", - " * time (time) datetime64[ns] 2000-01-01T12:00:00 ... 2001-01-01T06:00:00\n", - " * station (station) object '1' '10' '1002' '1003' ... '990' '992' '993' '998'\n", - "Data variables:\n", - " obsdis (time, station) float64 nan nan nan nan nan ... nan nan nan nan" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "map.obs_ds\n" + "map.station_metadata\n" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, - "outputs": [], + "outputs": [ + { + "ename": "", + "evalue": "", + "output_type": "error", + "traceback": [ + "\u001b[1;31mThe Kernel crashed while executing code in the the current cell or a previous cell. Please review the code in the cell(s) to identify a possible cause of the failure. Click here for more info. View Jupyter log for further details." + ] + } + ], "source": [ - "from hat.visualisation_v2 import NotebookMap as nbm\n", + "from hat.visualisation import NotebookMap as nbm\n", "stations= '~/destinE/outlets_v4.0_20230726_withEFAS.csv'\n", "observations= '~/destinE/station_attributes_with_obsdis06h_197001-202312_20230726_withEFAS.nc'\n", "\n", From fa7d9786e8d4f75e13dd9a904cc7965e7ce7a6db Mon Sep 17 00:00:00 2001 From: Corentin Carton De Wiart Date: Tue, 10 Oct 2023 10:59:48 +0000 Subject: [PATCH 07/31] debug notebook vis --- environment.yml | 2 +- hat/filters.py | 5 + hat/visualisation.py | 79 +++----- .../4_visualisation_interactive.ipynb | 175 ++++++++++++------ 4 files changed, 156 insertions(+), 105 deletions(-) diff --git a/environment.yml b/environment.yml index 6b28254..199838b 100644 --- a/environment.yml +++ b/environment.yml @@ -11,7 +11,7 @@ dependencies: - xarray - plotly - matplotlib - - jupyterlab + - jupyter - tqdm - typer - humanize diff --git a/hat/filters.py b/hat/filters.py index 88cdb07..b38953e 100644 --- a/hat/filters.py +++ b/hat/filters.py @@ -159,6 +159,7 @@ def filter_timeseries(sims_ds: xr.Dataset, obs_ds: xr.Dataset, threshold=80): matching_stations = sorted( set(sims_ds.station.values).intersection(obs_ds.station.values) ) + print(matching_stations) sims_ds = sims_ds.sel(station=matching_stations) obs_ds = obs_ds.sel(station=matching_stations) @@ -169,6 +170,10 @@ def filter_timeseries(sims_ds: xr.Dataset, obs_ds: xr.Dataset, threshold=80): # discharge data dis = obs_ds.obsdis + print(sims_ds) + print(dis) + import numpy as np + print(dis.values[np.isfinite(dis.values)]) # Replace negative values with NaN dis = dis.where(dis >= 0) diff --git a/hat/visualisation.py b/hat/visualisation.py index 555b47c..3c53c6a 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -103,35 +103,34 @@ def __init__(self, config: Dict, stations_metadata: str, observations: str, simu self.observations = observations # Ensure simulations is always a dictionary - self.simulations = simulations # Prepare sim_ds and obs_ds for statistics - self.sim_ds = self.prepare_simulations_data() + self.sim_ds = self.prepare_simulations_data(simulations) + print(self.sim_ds) self.obs_ds = self.prepare_observations_data() - self.common_id = self.find_common_station() - print(self.common_id) - self.stations_metadata = self.stations_metadata.loc[self.stations_metadata[self.station_index] == self.common_id] - self.obs_ds = self.obs_ds.sel(station = self.common_id) + common_id = self.find_common_station() + print(common_id) + self.stations_metadata = self.stations_metadata.loc[self.stations_metadata[self.station_index].isin(common_id)] + self.obs_ds = self.obs_ds.sel(station = common_id) for sim, ds in self.sim_ds.items(): - self.sim_ds[sim] = ds.sel(station=self.common_id) + self.sim_ds[sim] = ds.sel(station=common_id) + print(self.stations_metadata) + print(self.obs_ds) + print(self.sim_ds) - self.obs_ds = self.obs_ds.sel(station = self.common_id) + self.obs_ds = self.obs_ds.sel(station=common_id) self.stats = stats - self.threshold = 70 #to be opt + self.threshold = config['stats_threshold'] #to be opt self.statistics = None if self.stats: - self.calculate_statistics() + self.statistics = self.calculate_statistics() self.statistics_output = Output() - def prepare_simulations_data(self): - # If simulations is a string, assume it's a file path - if isinstance(self.simulations, str): - return xr.open_dataset(self.simulations) + def prepare_simulations_data(self, simulations): # If simulations is a dictionary, load data for each experiment - elif isinstance(self.simulations, dict): - datasets = {} - for exp, path in self.simulations.items(): + datasets = {} + for exp, path in simulations.items(): # Expanding the tilde expanded_path = os.path.expanduser(path) @@ -145,12 +144,8 @@ def prepare_simulations_data(self): else: raise ValueError(f"Invalid path: {expanded_path}") datasets[exp] = ds - - return datasets - - else: - raise TypeError("Invalid type for simulations. Expected str or dict.") + return datasets def prepare_observations_data(self): file_extension = os.path.splitext(self.observations)[-1].lower() @@ -166,15 +161,12 @@ def prepare_observations_data(self): elif file_extension == '.nc': obs_ds = xr.open_dataset(self.observations) - # Check if the necessary attributes are present - if 'obsdis' not in obs_ds or 'time' not in obs_ds.coords: - raise ValueError("The NetCDF file does not have the expected variables or coordinates.") + # # Check if the necessary attributes are present + # if 'obsdis' not in obs_ds or 'time' not in obs_ds.coords: + # raise ValueError("The NetCDF file does not have the expected variables or coordinates.") - # Convert the 'station' coordinate to a multi-index of 'latitude' and 'longitude' - lats = obs_ds['lat'].values - lons = obs_ds['lon'].values - multi_index = pd.MultiIndex.from_tuples(list(zip(lats, lons)), names=['lat', 'lon']) - obs_ds['station'] = ('station', multi_index) + # multi_index = pd.MultiIndex.from_tuples(list(zip(lats, lons)), names=['lat', 'lon']) + # obs_ds['station'] = ('station', multi_index) else: raise ValueError("Unsupported file format for observations.") @@ -196,42 +188,25 @@ def prepare_observations_data(self): def find_common_station(self): ids = [] ids += [list(self.obs_ds['station'].values)] - print(self.obs_ds.station_id) ids += [list(ds['station'].values) for ds in self.sim_ds.values()] - print(self.sim_ds) ids += [self.stations_metadata[self.station_index]] - common_ids = None for id in ids: - print(id) - if common_ids is None: common_ids = set(id) else: common_ids = set(id) & common_ids - - print(common_ids) - return list(common_ids) def calculate_statistics(self): statistics = {} - if isinstance(self.sim_ds, xr.Dataset): - # Single simulation dataset - sim_ds_f, obs_ds_f = filter_timeseries(self.sim_ds, self.obs_ds, self.threshold) - statistics["single"] = run_analysis(self.stats, sim_ds_f, obs_ds_f) - elif isinstance(self.sim_ds, dict): - # Dictionary of simulation datasets - for exp, ds in self.sim_ds.items(): - sim_ds_f, obs_ds_f = filter_timeseries(ds, self.obs_ds, self.threshold) - statistics[exp] = run_analysis(self.stats, sim_ds_f, obs_ds_f) - self.statistics = statistics + # Dictionary of simulation datasets + for exp, ds in self.sim_ds.items(): + sim_ds_f, obs_ds_f = filter_timeseries(ds.dis, self.obs_ds, self.threshold) + statistics[exp] = run_analysis(self.stats, sim_ds_f, obs_ds_f) + return statistics - - - - def display_dataframe_with_scroll(self, df, title=""): with self.geo_map.df_output: clear_output(wait=True) diff --git a/notebooks/examples/4_visualisation_interactive.ipynb b/notebooks/examples/4_visualisation_interactive.ipynb index fd125c4..e3db842 100644 --- a/notebooks/examples/4_visualisation_interactive.ipynb +++ b/notebooks/examples/4_visualisation_interactive.ipynb @@ -9,22 +9,25 @@ }, { "cell_type": "code", - "execution_count": 1, - "metadata": {}, + "execution_count": 3, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "from hat.visualisation import NotebookMap\n", - "stations = \"/home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/20230714_hres_calib_stations.csv\"\n", - "observations = \"/home/dadiyorto/freelance/02_ort_ecmwf/test/observed/Qts_calib_9021.csv\"\n", + "stations = \"/ec/vol/destine-hydro/OBSERVATIONS/outlets_v4.0_20230726_withEFAS.csv\"\n", + "observations = \"/ec/res4/scratch/macw/hat/destine/observations/destine_observations.nc\"\n", "simulations = {\n", - " \"exp1\": \"/home/dadiyorto/freelance/02_ort_ecmwf/test/simulation_datadir/simulation_timeseries.nc\",\n", - " \"exp2\": \"/home/dadiyorto/freelance/02_ort_ecmwf/test/simulation_datadir/simulation_timeseries.nc\",\n", + " \"i05j\": \"/ec/res4/scratch/macw/hat/destine/i05j/cama_i05j_stations.nc\",\n", + " \"i05h\": \"/ec/res4/scratch/macw/hat/destine/i05h/cama_i05h_stations.nc\",\n", "}\n", "config = {\n", " \"station_epsg\": 4326,\n", " \"station_id_column_name\": \"station_id\",\n", - " \"station_filters\":\"\",\n", - " \"station_coordinates\": [\"StationLon\", \"StationLat\"]\n", + " \"station_filters\":\"station_id == G10162\",\n", + " \"station_coordinates\": [\"StationLon\", \"StationLat\"],\n", + " \"stats_threshold\": 10\n", "}\n", "\n", "statistics = ['kge', 'rmse', 'mae', 'correlation']" @@ -40,49 +43,104 @@ }, { "cell_type": "code", - "execution_count": 2, - "metadata": {}, + "execution_count": 4, + "metadata": { + "tags": [] + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "station file\n", - "/home/dadiyorto/freelance/02_ort_ecmwf/test/station_metadata/20230714_hres_calib_stations.csv\n", - "['1', '10', '1002', '1003', '1004', '1005', '1006', '1007', '1008', '1009', '101', '1010', '1011', '1012', '1013', '1014', '1017', '1018', '102', '1020', '1021', '1022', '1023', '1024', '1026', '1027', '103', '1030', '1031', '1032', '1033', '1036', '1037', '1038', '1040', '1041', '1044', '1048', '1050', '1052', '1054', '1056', '1057', '1060', '1061', '1062', '1067', '1068', '1069', '107', '1070', '1075', '1076', '1077', '1078', '108', '1082', '1083', '1086', '1087', '1088', '109', '1091', '1092', '1093', '1094', '1095', '1099', '11', '110', '1101', '1102', '1103', '1104', '1107', '1108', '1109', '111', '1110', '1114', '1115', '1117', '1118', '1119', '1120', '1122', '1123', '1128', '113', '1130', '1131', '1132', '1135', '1136', '1139', '114', '1140', '1141', '1142', '1145', '1148', '1154', '1155', '1157', '1159', '1161', '1163', '1164', '1165', '1167', '1169', '117', '1172', '1173', '1174', '1175', '1176', '1177', '1178', '1179', '118', '1180', '1181', '1182', '1183', '1184', '1185', '1186', '1187', '1188', '1189', '119', '1190', '1191', '1192', '1193', '1194', '1195', '1196', '1197', '1198', '1199', '12', '120', '1200', '1201', '1202', '1203', '1205', '1206', '1207', '1209', '1210', '1212', '1214', '122', '1220', '1222', '1223', '1228', '123', '1230', '1231', '1235', '1236', '1237', '1238', '1239', '1240', '1241', '1245', '1247', '1249', '125', '1250', '1253', '1255', '126', '1260', '1262', '1263', '1265', '1266', '1267', '1269', '127', '1271', '1273', '1275', '1277', '1279', '128', '1281', '1283', '1285', '1290', '1291', '1292', '1298', '1299', '130', '1300', '1301', '1308', '131', '1311', '132', '1321', '1322', '1324', '1327', '1328', '133', '1330', '1331', '1332', '1334', '1335', '1337', '1339', '134', '1340', '1341', '1342', '1343', '1344', '1345', '1346', '1349', '135', '1350', '1351', '1353', '1354', '1356', '1357', '1358', '1359', '136', '1360', '1361', '1362', '1363', '1364', '1366', '1367', '1368', '1369', '137', '1370', '1372', '1373', '1374', '1375', '1376', '1377', '1378', '138', '139', '14', '140', '1400', '1401', '1402', '1403', '1405', '1407', '1408', '1409', '141', '1410', '1412', '1414', '1415', '1416', '1417', '1419', '142', '1420', '1423', '1424', '1426', '1428', '1429', '143', '1430', '1431', '1432', '1433', '1434', '1435', '1436', '1437', '1438', '1439', '144', '1440', '1441', '1444', '1445', '1446', '1447', '1448', '145', '1450', '1451', '1452', '1453', '1454', '1455', '1458', '1459', '146', '1460', '1461', '1462', '1463', '1464', '1465', '1467', '1468', '147', '1470', '1471', '1472', '1473', '1474', '1475', '1477', '1478', '1479', '148', '1480', '1481', '1482', '1483', '1484', '1485', '1486', '1487', '1488', '1489', '149', '1490', '1492', '1493', '1494', '1495', '1496', '1497', '1499', '15', '150', '1500', '1501', '1502', '1503', '1505', '1507', '151', '1514', '1519', '152', '1522', '1526', '1529', '153', '1532', '1534', '1536', '1537', '1546', '155', '1550', '1552', '1558', '156', '1563', '1565', '1567', '1568', '1573', '1575', '158', '1580', '1589', '159', '1592', '160', '1605', '1606', '1607', '1608', '1609', '161', '1610', '1611', '1612', '1613', '1614', '1615', '1616', '1617', '1618', '1619', '1620', '1621', '1622', '1623', '1624', '1625', '1626', '1627', '1628', '163', '1630', '1631', '1632', '1633', '1634', '1635', '1636', '1637', '1638', '1639', '164', '1640', '1642', '1643', '1644', '1645', '1647', '166', '1664', '168', '1680', '1681', '1683', '1685', '1688', '169', '1690', '1696', '1697', '1698', '17', '170', '1700', '1702', '1703', '1704', '1705', '1706', '1708', '1710', '1711', '1712', '1713', '1714', '1715', '1716', '1717', '1718', '1719', '1720', '1722', '1723', '1724', '1726', '1727', '173', '1734', '1736', '1740', '175', '176', '179', '18', '180', '181', '182', '183', '184', '185', '187', '188', '1889', '189', '1890', '1891', '1892', '1893', '1894', '1896', '1897', '1898', '19', '190', '1900', '1901', '1903', '1905', '1908', '191', '1919', '192', '1920', '1923', '1925', '1926', '1928', '1930', '1931', '1935', '1937', '1938', '1939', '1940', '1941', '1942', '1943', '1944', '1945', '1947', '1948', '1949', '195', '1950', '1951', '1952', '1953', '1954', '1956', '1957', '1958', '1959', '1960', '1961', '1962', '1963', '1964', '1965', '1966', '1967', '1968', '1969', '197', '1970', '1971', '1972', '1973', '1975', '1976', '1977', '1978', '198', '1980', '1981', '1982', '1984', '1985', '1986', '1987', '1988', '1989', '1990', '1991', '1992', '1993', '1994', '1995', '1996', '1997', '1998', '1999', '2', '200', '2000', '2001', '2002', '2003', '2004', '2005', '2006', '2008', '201', '2010', '2011', '2012', '2013', '2014', '2015', '2016', '2017', '2018', '2019', '202', '2021', '2022', '2024', '2025', '2026', '2027', '2028', '2029', '203', '2030', '2031', '2032', '2034', '2036', '2069', '207', '2074', '208', '2088', '2090', '2091', '2094', '2095', '2096', '21', '2102', '2103', '211', '212', '2128', '2129', '213', '2130', '2133', '2134', '2135', '2137', '2138', '2139', '214', '2141', '2143', '2144', '2147', '2150', '2151', '2154', '2156', '2158', '2159', '216', '2160', '2161', '2162', '2163', '2164', '2165', '2166', '2167', '2169', '2170', '2171', '2172', '2174', '2177', '2179', '2180', '2182', '2190', '2191', '2192', '2193', '2194', '2195', '2196', '2198', '2201', '2208', '2209', '221', '2210', '223', '2247', '225', '2251', '2252', '2253', '2254', '2255', '2256', '2258', '2259', '2260', '2261', '2262', '2263', '2264', '2265', '2266', '227', '2270', '2276', '2278', '2279', '228', '2280', '2282', '2283', '2284', '2286', '2287', '2288', '2290', '2295', '2296', '2297', '2298', '2299', '2300', '2301', '2304', '2305', '2306', '2307', '2308', '231', '2311', '2312', '2315', '2317', '2319', '232', '2320', '2322', '2323', '2326', '2327', '2328', '2329', '233', '2330', '2331', '2333', '2335', '2336', '2337', '2338', '234', '2341', '2343', '2345', '2346', '2348', '235', '2351', '2352', '2354', '2356', '2358', '2359', '236', '2360', '2362', '2363', '2365', '2367', '237', '2370', '2372', '2373', '2376', '2377', '2378', '2379', '238', '2382', '2385', '2386', '2388', '2389', '239', '2391', '2393', '2395', '2396', '2398', '240', '2401', '2402', '2403', '2405', '2406', '2407', '2408', '2409', '241', '2410', '2414', '2418', '242', '2420', '2421', '2422', '2425', '2428', '243', '2432', '2433', '2434', '2435', '2438', '244', '2440', '2445', '2446', '2447', '2449', '245', '2450', '2452', '2455', '2456', '2457', '2459', '246', '2460', '2462', '2465', '2466', '2469', '247', '2472', '2473', '2474', '2475', '2476', '2477', '248', '249', '2513', '2514', '2521', '2524', '2525', '2532', '2534', '2535', '2538', '2546', '2547', '2548', '2551', '2553', '2555', '2556', '2557', '2558', '2561', '2562', '2563', '2567', '2568', '257', '2570', '2576', '258', '2580', '2596', '2597', '26', '2600', '2608', '261', '2613', '2623', '263', '2630', '2631', '2632', '2633', '2634', '2635', '264', '265', '267', '268', '2681', '269', '27', '270', '2709', '2711', '2712', '2713', '2714', '2716', '272', '2721', '2722', '2723', '2724', '2725', '2726', '2727', '2728', '2729', '273', '2730', '2731', '2732', '2733', '2735', '2736', '2737', '2738', '2739', '274', '2740', '2741', '2743', '2744', '2745', '2746', '2747', '2748', '2749', '2750', '2751', '2753', '2754', '2756', '2758', '2764', '2769', '277', '2770', '2771', '2773', '2774', '2775', '2776', '2778', '278', '2780', '2781', '2782', '2783', '2784', '2785', '2786', '2787', '2788', '2789', '279', '2790', '2791', '2792', '2794', '2795', '2796', '2797', '2798', '28', '280', '2804', '2805', '2806', '2807', '2808', '2809', '281', '2810', '2811', '2812', '2813', '2814', '2815', '2816', '2818', '282', '2820', '2821', '2822', '2823', '2824', '2825', '2826', '2827', '2828', '2829', '283', '2830', '2832', '2833', '2834', '2835', '2838', '284', '2840', '2841', '2842', '2843', '2844', '2846', '2847', '2848', '2849', '285', '2850', '2851', '2852', '2853', '2854', '2855', '2856', '2858', '2859', '286', '2860', '2861', '2863', '2864', '2865', '2866', '2867', '2869', '287', '2870', '2873', '2874', '2875', '2876', '2877', '2878', '2879', '288', '2880', '2881', '2882', '2883', '2884', '2885', '2886', '2887', '2888', '2889', '289', '2890', '2891', '2892', '2893', '2894', '2895', '2896', '2897', '2898', '2899', '29', '290', '2900', '2901', '2902', '2903', '2904', '2905', '2908', '291', '2910', '2911', '2912', '2913', '2914', '2916', '2917', '2918', '2919', '292', '2920', '2921', '2922', '2923', '2924', '2925', '2926', '2928', '2929', '293', '2932', '2933', '2935', '2936', '2937', '294', '2941', '2943', '2944', '2945', '2946', '2947', '2948', '295', '2951', '2952', '2954', '2957', '296', '2961', '2962', '2963', '2964', '2965', '2966', '2969', '297', '2970', '2971', '2974', '2975', '2976', '2977', '2978', '2979', '298', '2980', '2981', '2982', '2983', '2984', '2985', '2986', '2987', '2988', '2989', '299', '2993', '2994', '2996', '2997', '2998', '2999', '300', '3000', '3001', '3002', '3003', '3004', '3005', '3006', '3008', '3009', '301', '3010', '3011', '3012', '3013', '3014', '3015', '3016', '3017', '3018', '3019', '302', '3020', '3021', '3022', '3023', '3024', '3026', '3028', '303', '3030', '3031', '3032', '3034', '304', '3041', '3042', '3043', '3045', '3046', '307', '308', '3086', '3087', '309', '3092', '3093', '3095', '3096', '3097', '3098', '3099', '31', '310', '3100', '3101', '3102', '3103', '3105', '3106', '3108', '3109', '311', '3110', '3111', '3112', '3113', '3114', '3115', '3116', '3117', '3118', '3119', '312', '3120', '3121', '3123', '3124', '3125', '3126', '3127', '3128', '3130', '3131', '3132', '3133', '3134', '3135', '3136', '3137', '3138', '3139', '314', '3140', '3141', '3142', '3143', '3144', '3145', '3146', '3147', '3149', '315', '3150', '3151', '3152', '3153', '3154', '3155', '3156', '3157', '3158', '3159', '316', '3160', '3161', '3162', '3163', '3164', '3165', '3166', '3167', '3168', '3169', '317', '3171', '3172', '3173', '3174', '3175', '3177', '3178', '3179', '318', '3180', '3181', '3182', '3183', '3184', '3185', '3186', '3187', '3188', '319', '3191', '3192', '3193', '3194', '3195', '3196', '3197', '3198', '3199', '32', '320', '3200', '3201', '3202', '3203', '3204', '3205', '3206', '3207', '3208', '3209', '3210', '3211', '3212', '3213', '3214', '3216', '3217', '3219', '3220', '3221', '3222', '3223', '3224', '3225', '3226', '3227', '3228', '3229', '323', '3230', '3231', '3233', '3234', '3235', '3237', '3238', '3239', '3240', '3241', '3242', '3243', '3244', '3245', '3246', '3247', '3248', '3249', '325', '3250', '3251', '3252', '3253', '3254', '3255', '3256', '3257', '3258', '3259', '3265', '3266', '3269', '327', '3271', '3273', '3279', '328', '3281', '329', '33', '330', '3301', '3302', '3304', '3307', '3308', '3309', '331', '3310', '3311', '3314', '3315', '3317', '3320', '3321', '3324', '3325', '3328', '333', '3332', '3334', '3338', '334', '3340', '335', '3355', '336', '337', '338', '339', '34', '343', '345', '347', '348', '349', '350', '351', '352', '353', '355', '356', '357', '358', '359', '36', '361', '362', '366', '369', '370', '371', '372', '38', '380', '381', '39', '390', '393', '394', '395', '396', '397', '398', '399', '4', '40', '4001', '4004', '4006', '4007', '4008', '4009', '4011', '4013', '4014', '4015', '4016', '4018', '4020', '4021', '410', '4111', '4113', '4114', '4116', '4119', '4122', '4131', '4133', '4140', '4143', '4144', '4148', '4149', '4163', '4170', '4174', '4192', '4194', '4198', '4199', '42', '4202', '4205', '421', '4216', '4218', '4224', '4229', '4232', '4235', '4236', '4241', '4248', '4252', '4254', '4255', '4257', '4258', '4267', '4273', '428', '4282', '4289', '4290', '4294', '4296', '4297', '4298', '430', '4300', '4306', '4309', '4313', '4316', '4317', '4318', '4319', '4320', '4321', '4322', '4323', '4324', '4328', '4335', '4339', '4341', '4342', '4343', '4346', '4347', '4351', '4352', '4354', '4355', '4356', '4357', '4359', '4361', '4363', '4368', '4385', '4386', '4388', '4415', '4416', '4417', '4418', '4419', '443', '45', '450', '4519', '4520', '4521', '4522', '4523', '4525', '4526', '4527', '4528', '4529', '4530', '4531', '4532', '4533', '4534', '4535', '4536', '4538', '454', '4540', '4541', '4543', '4546', '457', '458', '459', '460', '461', '462', '463', '464', '465', '466', '467', '468', '469', '47', '470', '471', '472', '473', '474', '475', '476', '477', '478', '479', '480', '483', '485', '486', '487', '488', '489', '49', '490', '491', '492', '493', '494', '495', '496', '497', '498', '5', '50', '500', '501', '502', '503', '504', '505', '506', '5066', '507', '508', '509', '51', '510', '511', '512', '513', '514', '515', '516', '517', '518', '519', '520', '521', '5214', '5216', '5217', '5218', '5219', '522', '5220', '526', '529', '530', '531', '532', '533', '534', '535', '536', '537', '538', '539', '542', '543', '544', '545', '546', '547', '548', '549', '550', '551', '552', '553', '554', '555', '556', '557', '558', '559', '560', '561', '562', '563', '564', '565', '566', '567', '568', '569', '570', '571', '572', '574', '575', '576', '577', '578', '579', '58', '580', '581', '582', '583', '584', '585', '586', '587', '588', '589', '59', '590', '591', '592', '593', '594', '595', '596', '597', '598', '6', '60', '600', '602', '603', '604', '605', '606', '607', '608', '609', '610', '611', '613', '614', '615', '616', '617', '618', '619', '62', '620', '621', '622', '623', '624', '625', '626', '628', '629', '63', '630', '631', '632', '633', '634', '635', '636', '637', '638', '639', '640', '641', '642', '643', '645', '646', '647', '648', '649', '653', '657', '660', '663', '664', '665', '667', '67', '670', '671', '674', '675', '676', '677', '678', '682', '684', '686', '690', '693', '696', '697', '699', '700', '701', '703', '706', '707', '71', '713', '715', '73', '74', '75', '76', '772', '773', '774', '775', '777', '778', '779', '78', '780', '781', '782', '784', '785', '786', '787', '788', '789', '79', '790', '791', '792', '793', '794', '795', '796', '797', '798', '799', '80', '800', '801', '802', '803', '804', '805', '807', '808', '809', '81', '811', '817', '818', '82', '822', '823', '829', '83', '830', '831', '832', '833', '834', '836', '839', '840', '841', '842', '845', '85', '850', '851', '852', '853', '854', '856', '857', '858', '859', '86', '860', '864', '868', '869', '87', '870', '886', '89', '892', '895', '9', '900', '903', '904', '906', '91', '928', '93', '931', '933', '934', '935', '936', '938', '939', '94', '941', '942', '943', '944', '945', '95', '951', '952', '953', '956', '96', '960', '965', '967', '968', '969', '970', '971', '972', '973', '975', '977', '978', '979', '98', '983', '986', '99', '990', '992', '993', '998']\n", - "{'2923', '1409', '1279', '1984', '3000', '594', '99', '1464', '1103', '4416', '3317', '538', '842', '3210', '868', '1185', '1119', '4531', '1451', '3204', '1428', '1536', '2440', '559', '934', '589', '355', '1624', '2553', '3208', '1267', '1685', '1241', '2141', '109', '517', '214', '600', '75', '307', '1402', '3182', '2893', '1363', '2794', '2989', '935', '3219', '483', '1376', '137', '616', '1349', '1618', '3202', '1364', '231', '2937', '637', '772', '555', '1222', '131', '246', '1145', '4294', '1023', '2363', '2726', '1609', '2295', '2897', '2813', '2750', '1187', '83', '3143', '664', '2143', '1095', '1971', '466', '2128', '2391', '3334', '533', '4317', '2853', '293', '1993', '832', '81', '9', '1501', '3108', '1099', '315', '1230', '2029', '1573', '278', '241', '1', '2787', '931', '345', '784', '1711', '1961', '2558', '295', '294', '2623', '286', '1197', '284', '1589', '2172', '1607', '108', '202', '32', '2547', '1368', '2333', '2835', '1141', '362', '2209', '380', '2377', '3109', '3199', '2890', '4298', '1175', '4257', '2948', '1031', '1605', '535', '1636', '604', '2945', '2795', '491', '261', '671', '2019', '339', '2006', '2908', '2151', '1980', '1265', '1717', '800', '515', '2450', '1972', '1172', '1497', '2709', '1263', '1716', '2283', '18', '3309', '2434', '395', '822', '1102', '576', '1238', '4359', '556', '2833', '1262', '146', '19', '2774', '1190', '2869', '836', '308', '1240', '2809', '613', '713', '3158', '2198', '1631', '1093', '583', '2769', '2452', '333', '1067', '625', '1901', '113', '3181', '3146', '2477', '3222', '478', '1645', '3092', '3311', '296', '986', '2170', '2858', '3002', '791', '1378', '3279', '285', '675', '2280', '1283', '4352', '1614', '2474', '2899', '51', '170', '2290', '1627', '633', '1123', '2343', '2781', '1939', '2957', '2351', '1992', '2935', '1468', '3229', '639', '1203', '4335', '696', '592', '1301', '1465', '2917', '3155', '3246', '267', '33', '1373', '2724', '3273', '1647', '2789', '3175', '2336', '3137', '2008', '2737', '2914', '1164', '2778', '2775', '2928', '2832', '2165', '892', '850', '1638', '3103', '839', '3178', '1889', '232', '197', '2786', '327', '2096', '212', '257', '1009', '4116', '2825', '831', '2359', '1006', '281', '240', '1681', '3100', '4346', '2174', '3111', '50', '103', '1415', '15', '2810', '706', '623', '225', '2996', '512', '4323', '520', '470', '228', '1481', '1255', '572', '1077', '603', '1350', '4324', '4232', '467', '2812', '2613', '1563', '4355', '3244', '130', '351', '2977', '2428', '1030', '595', '1432', '2807', '239', '3266', '263', '1970', '2904', '311', '799', '3162', '227', '2393', '2435', '4148', '550', '4255', '1941', '4543', '2286', '642', '1441', '590', '1529', '562', '1131', '3251', '1925', '1896', '3118', '4357', '280', '977', '3086', '14', '1161', '1341', '3242', '1892', '610', '2090', '2970', '2330', '1291', '2852', '2074', '1044', '1637', '2287', '552', '1905', '2964', '86', '2024', '128', '4131', '1919', '4297', '817', '1032', '36', '1372', '1176', '2378', '2885', '1087', '2406', '465', '1040', '2460', '4267', '1005', '2513', '2134', '1400', '2032', '2952', '1642', '4122', '577', '2999', '292', '93', '2864', '4020', '4351', '536', '3207', '588', '1484', '3180', '1200', '2984', '1062', '2373', '300', '2966', '1720', '1472', '473', '2270', '1423', '358', '1332', '1361', '1964', '792', '335', '1938', '2301', '4248', '2811', '21', '369', '2894', '1037', '1697', '2873', '3191', '2944', '834', '611', '1401', '1078', '965', '4361', '2445', '2567', '1612', '1975', '2358', '1713', '3223', '2103', '2317', '3269', '4016', '5218', '667', '1410', '2144', '2462', '184', '1275', '328', '2608', '2322', '4532', '1091', '2962', '811', '3144', '2003', '3021', '1639', '78', '273', '2576', '1477', '582', '684', '665', '686', '886', '3012', '1008', '4202', '1167', '2179', '3135', '4523', '803', '134', '390', '648', '3125', '138', '2816', '2337', '2784', '549', '697', '1963', '2867', '777', '575', '153', '163', '148', '3138', '571', '3004', '1700', '628', '2266', '1356', '516', '1104', '359', '366', '1374', '2150', '34', '291', '1331', '3220', '4235', '450', '787', '2851', '302', '2880', '1290', '2568', '2901', '1945', '463', '2030', '840', '2756', '45', '545', '2284', '2754', '89', '1943', '2456', '503', '118', '2004', '4521', '1202', '593', '808', '1485', '3310', '493', '1478', '2278', '641', '274', '2783', '1988', '1623', '457', '653', '1298', '1931', '2820', '4252', '4538', '979', '1944', '1017', '4520', '1004', '73', '1696', '1930', '643', '1335', '2745', '3128', '2829', '1962', '1108', '1500', '3024', '1632', '264', '4015', '2681', '4216', '785', '1223', '2402', '320', '2258', '2282', '2385', '1249', '3119', '904', '164', '4143', '1558', '2328', '1024', '2331', '3234', '2847', '2308', '4386', '2632', '3017', '485', '1300', '497', '903', '2988', '2027', '4199', '983', '3320', '47', '272', '614', '3101', '1644', '1266', '2447', '1438', '2855', '2834', '1928', '2747', '2190', '396', '486', '4354', '1897', '591', '301', '2195', '4119', '1179', '1068', '1050', '265', '120', '2929', '2790', '1999', '309', '3197', '150', '2171', '2409', '968', '2256', '864', '4536', '3098', '4541', '2738', '1360', '647', '1990', '3231', '330', '4229', '2600', '2319', '1403', '152', '1088', '1991', '1033', '1003', '1327', '182', '775', '102', '2721', '690', '4316', '2524', '547', '2875', '62', '2866', '829', '574', '237', '1714', '502', '620', '567', '4321', '161', '2922', '3153', '1198', '597', '554', '2210', '1455', '3315', '2016', '329', '4318', '1740', '270', '2951', '805', '2354', '2748', '564', '2746', '1680', '4194', '1625', '1351', '1966', '634', '4170', '2446', '1201', '1550', '3113', '2138', '2159', '585', '4008', '598', '2396', '830', '3214', '495', '2139', '2407', '3265', '459', '1207', '1173', '3321', '149', '569', '287', '331', '2360', '132', '3161', '1154', '1010', '421', '1342', '657', '1615', '2327', '269', '2326', '1060', '58', '2954', '823', '2860', '1181', '551', '1640', '1567', '2538', '3228', '4528', '973', '3046', '114', '3157', '180', '4385', '4339', '471', '2201', '221', '2180', '4540', '677', '2887', '1702', '2388', '3250', '2095', '3114', '4144', '602', '1169', '1994', '3150', '969', '780', '1444', '91', '2156', '3224', '1920', '2916', '492', '2561', '500', '638', '3328', '786', '1321', '142', '1357', '581', '809', '1165', '635', '2335', '1235', '2731', '3001', '1967', '2352', '584', '1568', '2137', '578', '4241', '678', '2389', '469', '10', '201', '1532', '1247', '4007', '107', '2861', '443', '4534', '352', '1688', '779', '2069', '1250', '2736', '3355', '356', '2563', '2993', '3243', '2425', '312', '807', '1285', '2299', '1891', '2838', '119', '361', '2264', '4525', '1948', '3239', '1322', '1346', '558', '123', '2859', '1719', '1122', '2741', '4368', '1061', '2925', '343', '2311', '853', '510', '2372', '1132', '504', '615', '2821', '5217', '951', '1620', '2932', '1191', '2', '1027', '2879', '166', '4320', '1277', '778', '2422', '494', '2129', '2883', '1565', '1499', '2455', '3131', '1117', '1957', '952', '1192', '1174', '1505', '1260', '1978', '4535', '1502', '288', '3301', '490', '3249', '4519', '147', '2017', '975', '1367', '2730', '2892', '2713', '3255', '2814', '2433', '4419', '464', '631', '4289', '1537', '2192', '410', '2727', '609', '117', '4001', '3019', '507', '2788', '3141', '619', '2943', '4306', '282', '1471', '474', '4018', '2002', '2300', '2556', '2947', '3032', '3209', '1718', '5219', '3006', '477', '570', '1269', '1228', '85', '2729', '1086', '1893', '5066', '29', '1487', '682', '854', '1135', '299', '2758', '1188', '74', '2367', '3167', '1194', '2465', '3121', '49', '632', '1987', '856', '4388', '1459', '245', '2532', '289', '626', '234', '3096', '630', '3116', '155', '2163', '543', '1734', '2764', '1580', '2028', '476', '605', '372', '2421', '2166', '298', '1358', '59', '518', '3245', '145', '2936', '2535', '1002', '122', '900', '3338', '158', '1083', '4', '2982', '522', '3020', '1493', '2630', '660', '1026', '1617', '1205', '2408', '3156', '4111', '579', '3252', '3045', '101', '2261', '1139', '857', '243', '208', '506', '852', '1592', '2900', '509', '3095', '1703', '1480', '235', '4546', '2844', '2036', '1452', '462', '1157', '1052', '967', '3022', '190', '2018', '236', '1634', '2320', '970', '95', '3225', '3200', '4254', '2088', '2979', '1054', '1345', '350', '4218', '110', '397', '3216', '60', '3009', '1997', '223', '2941', '338', '537', '2312', '4356', '1440', '2459', '1486', '211', '629', '869', '3332', '1635', '1370', '2167', '314', '2976', '3136', '2965', '1507', '2933', '3010', '1434', '895', '1479', '1076', '1407', '2711', '4347', '1206', '1412', '1114', '428', '1340', '2732', '1935', '2796', '2135', '1977', '1622', '4522', '1926', '3151', '1109', '2792', '318', '3281', '1958', '1189', '1613', '4013', '2376', '1995', '3097', '5214', '2323', '2798', '508', '2840', '2182', '978', '2034', '1474', '461', '2841', '2414', '4113', '2365', '3241', '3256', '2000', '1986', '2902', '316', '1450', '2961', '2913', '3308', '2912', '3042', '1183', '1177', '1292', '3238', '2164', '2160', '203', '1417', '3147', '1245', '4342', '2896', '2254', '39', '393', '498', '4236', '2570', '2889', '1184', '3133', '4300', '3003', '1982', '1453', '1343', '1128', '2341', '189', '1140', '2830', '845', '3248', '2782', '1616', '544', '1894', '1606', '2903', '2263', '2011', '17', '1706', '2827', '519', '1446', '4174', '151', '1898', '3014', '2449', '136', '1461', '3043', '2356', '1041', '1710', '2022', '6', '1424', '2971', '2102', '268', '3165', '2469', '200', '2918', '2994', '4198', '841', '2881', '3154', '993', '1519', '279', '998', '1430', '479', '4149', '1308', '1110', '370', '939', '2005', '398', '1522', '1020', '4533', '2001', '496', '3139', '1985', '1220', '1447', '1101', '530', '4296', '277', '4328', '3227', '3018', '1726', '801', '2739', '1690', '2822', '3127', '1712', '1954', '4322', '1155', '1610', '565', '2911', '3324', '2751', '3171', '607', '715', '1013', '2130', '2983', '2924', '833', '622', '2338', '1057', '1526', '1180', '1070', '468', '2386', '213', '82', '2298', '1408', '1419', '3166', '181', '2557', '2997', '802', '936', '1120', '325', '646', '566', '3126', '156', '2805', '3102', '3173', '3226', '1426', '1036', '1552', '2878', '5220', '3193', '2255', '3132', '1534', '3211', '2133', '1273', '1311', '3163', '1281', '649', '1369', '2401', '2850', '818', '3112', '3212', '1115', '1022', '4014', '2898', '2876', '1445', '1069', '2253', '539', '2633', '169', '1082', '2546', '1969', '125', '1621', '1900', '3159', '676', '192', '76', '3257', '3011', '489', '1334', '1048', '3034', '596', '2978', '1989', '1708', '2296', '4258', '1377', '1012', '176', '135', '160', '703', '140', '3188', '3105', '1937', '2432', '2797', '1953', '2804', '563', '624', '3005', '505', '534', '942', '334', '216', '1405', '3271', '2208', '2749', '1339', '2305', '4133', '2162', '371', '2926', '3194', '28', '1253', '1075', '2848', '2315', '870', '283', '2345', '2307', '357', '2370', '1344', '2895', '3013', '1094', '2969', '2329', '2743', '580', '943', '11', '1414', '1435', '1362', '3123', '2975', '303', '3160', '2733', '3164', '2262', '3030', '249', '1546', '521', '1142', '2946', '1608', '207', '3124', '1239', '944', '513', '323', '2998', '4527', '2826', '511', '159', '2846', '98', '621', '851', '1973', '3145', '2919', '617', '1490', '774', '1467', '3213', '179', '1705', '1959', '2279', '1460', '3186', '5216', '546', '3174', '941', '487', '297', '1214', '27', '4309', '2735', '1231', '475', '3340', '2806', '1923', '3087', '2288', '2791', '526', '2247', '1458', '1514', '4192', '640', '2466', '244', '3205', '548', '793', '80', '3110', '1724', '2828', '2306', '1186', '1965', '859', '2013', '1359', '2728', '2014', '191', '2259', '2026', '3120', '3142', '31', '2177', '247', '2094', '2525', '1940', '1433', '606', '185', '670', '2877', '1210', '2634', '3254', '2740', '1107', '304', '3140', '3168', '945', '1330', '1463', '1736', '1212', '290', '1633', '1947', '3015', '1038', '175', '3008', '3195', '2596', '4021', '707', '2346', '3184', '2863', '1148', '2843', '4341', '1237', '2348', '2818', '501', '127', '1951', '906', '2905', '2986', '2420', '2091', '4114', '4140', '3023', '472', '1448', '2260', '187', '3325', '1956', '2514', '3259', '2555', '1437', '1018', '480', '4224', '700', '242', '488', '87', '2405', '1683', '96', '3152', '3237', '3314', '1664', '561', '2169', '139', '26', '2884', '2888', '319', '1375', '1236', '2891', '1475', '1470', '430', '1196', '2362', '12', '3169', '1462', '2631', '2770', '1949', '2580', '317', '1118', '1014', '773', '42', '2551', '557', '188', '1324', '381', '1722', '1195', '860', '1271', '2886', '2865', '1337', '1628', '1416', '3185', '1626', '1328', '2824', '144', '63', '4006', '3233', '1727', '2910', '3130', '3198', '2251', '337', '2438', '2398', '1021', '2635', '1495', '3258', '394', '618', '1952', '1942', '2882', '2548', '3026', '133', '636', '1998', '2252', '789', '67', '587', '2723', '2403', '1960', '2472', '1007', '2771', '2808', '568', '2920', '797', '198', '3235', '1182', '4418', '38', '4004', '4282', '2985', '4011', '2475', '2297', '2194', '1619', '1159', '1715', '2874', '1439', '2191', '2473', '2154', '796', '3192', '248', '3134', '458', '3230', '781', '4417', '2712', '3302', '310', '2418', '4273', '2870', '3201', '3196', '798', '79', '2785', '1494', '2025', '788', '701', '1723', '3149', '560', '2842', '2457', '992', '3031', '645', '794', '1996', '454', '195', '2476', '2725', '2980', '1473', '2410', '2921', '143', '1483', '233', '1199', '71', '2974', '1366', '674', '3179', '1193', '1454', '2015', '1492', '2562', '4009', '933', '3099', '3177', '3307', '938', '1981', '1908', '238', '1489', '2379', '2521', '1890', '608', '2021', '2395', '3253', '173', '2744', '3203', '4313', '348', '347', '1420', '4530', '3106', '1092', '1698', '532', '1950', '111', '2987', '3093', '3117', '4205', '858', '4163', '2304', '336', '353', '4526', '1488', '990', '4529', '953', '1056', '258', '3217', '2963', '928', '183', '1163', '3304', '1704', '2714', '2534', '586', '2276', '399', '2849', '663', '3206', '529', '2161', '3041', '94', '2854', '1178', '2776', '1299', '2722', '514', '2382', '3028', '40', '960', '553', '2010', '2265', '5', '1482', '2981', '804', '2597', '2031', '4319', '1643', '2158', '2147', '1136', '2773', '4290', '531', '782', '3187', '956', '1436', '1630', '790', '3247', '795', '1968', '3240', '1354', '2012', '2780', '1130', '1209', '168', '349', '542', '4415', '1353', '2823', '460', '971', '1611', '2193', '2753', '1429', '3183', '699', '3221', '693', '1503', '1976', '1431', '2716', '3016', '3115', '1011', '4343', '2196', '126', '1496', '972', '141', '1575', '2815', '3172', '4363', '2856', '1903'}\n", - "['1', '2', '3', '4', '5', '6', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '21', '26', '27', '28', '29', '30', '31', '32', '33', '34', '35', '36', '38', '39', '40', '42', '43', '45', '46', '47', '48', '49', '50', '51', '52', '53', '54', '57', '58', '59', '60', '62', '63', '64', '67', '69', '71', '73', '74', '75', '76', '77', '78', '79', '80', '81', '82', '83', '84', '85', '86', '87', '88', '89', '90', '91', '92', '93', '94', '95', '96', '97', '98', '99', '100', '101', '102', '103', '104', '105', '106', '107', '108', '109', '110', '111', '112', '113', '114', '117', '118', '119', '120', '122', '123', '124', '125', '126', '127', '128', '129', '130', '131', '132', '133', '134', '135', '136', '137', '138', '139', '140', '141', '142', '143', '144', '145', '146', '147', '148', '149', '150', '151', '152', '153', '154', '155', '156', '158', '159', '160', '161', '162', '163', '164', '165', '166', '167', '168', '169', '170', '173', '175', '176', '177', '178', '179', '180', '181', '182', '183', '184', '185', '186', '187', '188', '189', '190', '191', '192', '195', '196', '197', '198', '199', '200', '201', '202', '203', '207', '208', '209', '210', '211', '212', '213', '214', '215', '216', '221', '223', '225', '226', '227', '228', '229', '230', '231', '232', '233', '234', '235', '236', '237', '238', '239', '240', '241', '242', '243', '244', '245', '246', '247', '248', '249', '250', '251', '252', '253', '254', '255', '256', '257', '258', '259', '260', '261', '262', '263', '264', '265', '266', '267', '268', '269', '270', '271', '272', '273', '274', '275', '276', '277', '278', '279', '280', '281', '282', '283', '284', '285', '286', '287', '288', '289', '290', '291', '292', '293', '294', '295', '296', '297', '298', '299', '300', '301', '302', '303', '304', '305', '307', '308', '309', '310', '311', '312', '313', '314', '315', '316', '317', '318', '319', '320', '322', '323', '324', '325', '326', '327', '328', '329', '330', '331', '332', '333', '334', '335', '336', '337', '338', '339', '340', '343', '345', '347', '348', '349', '350', '351', '352', '353', '354', '355', '356', '357', '358', '359', '361', '362', '366', '367', '369', '370', '371', '372', '379', '380', '381', '382', '384', '388', '389', '390', '391', '393', '394', '395', '396', '397', '398', '399', '402', '405', '407', '408', '410', '412', '413', '416', '418', '420', '421', '422', '423', '424', '425', '426', '428', '429', '430', '431', '432', '434', '436', '438', '439', '442', '443', '445', '449', '450', '451', '454', '456', '457', '458', '459', '460', '461', '462', '463', '464', '465', '466', '467', '468', '469', '470', '471', '472', '473', '474', '475', '476', '477', '478', '479', '480', '481', '482', '483', '484', '485', '486', '487', '488', '489', '490', '491', '492', '493', '494', '495', '496', '497', '498', '500', '501', '502', '503', '504', '505', '506', '507', '508', '509', '510', '511', '512', '513', '514', '515', '516', '517', '518', '519', '520', '521', '522', '523', '524', '526', '527', '528', '529', '530', '531', '532', '533', '534', '535', '536', '537', '538', '539', '540', '541', '542', '543', '544', '545', '546', '547', '548', '549', '550', '551', '552', '553', '554', '555', '556', '557', '558', '559', '560', '561', '562', '563', '564', '565', '566', '567', '568', '569', '570', '571', '572', '573', '574', '575', '576', '577', '578', '579', '580', '581', '582', '583', '584', '585', '586', '587', '588', '589', '590', '591', '592', '593', '594', '595', '596', '597', '598', '599', '600', '601', '602', '603', '604', '605', '606', '607', '608', '609', '610', '611', '612', '613', '614', '615', '616', '617', '618', '619', '620', '621', '622', '623', '624', '625', '626', '627', '628', '629', '630', '631', '632', '633', '634', '635', '636', '637', '638', '639', '640', '641', '642', '643', '644', '645', '646', '647', '648', '649', '650', '651', '652', '653', '654', '655', '657', '658', '659', '660', '661', '662', '663', '664', '665', '666', '667', '668', '670', '671', '672', '673', '674', '675', '676', '677', '678', '679', '682', '683', '684', '685', '686', '688', '689', '690', '691', '692', '693', '694', '695', '696', '697', '699', '700', '701', '702', '703', '704', '705', '706', '707', '713', '715', '772', '773', '774', '775', '776', '777', '778', '779', '780', '781', '782', '783', '784', '785', '786', '787', '788', '789', '790', '791', '792', '793', '794', '795', '796', '797', '798', '799', '800', '801', '802', '803', '804', '805', '807', '808', '809', '811', '813', '814', '815', '816', '817', '818', '822', '823', '824', '825', '829', '830', '831', '832', '833', '834', '836', '837', '838', '839', '840', '841', '842', '843', '844', '845', '846', '848', '850', '851', '852', '853', '854', '856', '857', '858', '859', '860', '863', '864', '866', '867', '868', '869', '870', '876', '879', '882', '883', '884', '886', '888', '891', '892', '893', '894', '895', '900', '903', '904', '905', '906', '912', '914', '919', '920', '921', '922', '924', '928', '930', '931', '932', '933', '934', '935', '936', '937', '938', '939', '940', '941', '942', '943', '944', '945', '951', '952', '953', '954', '955', '956', '960', '965', '966', '967', '968', '969', '970', '971', '972', '973', '974', '975', '976', '977', '978', '979', '980', '981', '982', '983', '984', '985', '986', '987', '988', '990', '992', '993', '994', '997', '998', '1000', '1002', '1003', '1004', '1005', '1006', '1007', '1008', '1009', '1010', '1011', '1012', '1013', '1014', '1015', '1016', '1017', '1018', '1019', '1020', '1021', '1022', '1023', '1024', '1025', '1026', '1027', '1030', '1031', '1032', '1033', '1036', '1037', '1038', '1040', '1041', '1042', '1044', '1048', '1050', '1051', '1052', '1053', '1054', '1055', '1056', '1057', '1060', '1061', '1062', '1063', '1065', '1067', '1068', '1069', '1070', '1073', '1074', '1075', '1076', '1077', '1078', '1080', '1082', '1083', '1085', '1086', '1087', '1088', '1089', '1091', '1092', '1093', '1094', '1095', '1096', '1098', '1099', '1100', '1101', '1102', '1103', '1104', '1107', '1108', '1109', '1110', '1113', '1114', '1115', '1116', '1117', '1118', '1119', '1120', '1121', '1122', '1123', '1125', '1126', '1128', '1129', '1130', '1131', '1132', '1133', '1134', '1135', '1136', '1137', '1138', '1139', '1140', '1141', '1142', '1145', '1148', '1152', '1153', '1154', '1155', '1157', '1158', '1159', '1160', '1161', '1162', '1163', '1164', '1165', '1166', '1167', '1168', '1169', '1170', '1172', '1173', '1174', '1175', '1176', '1177', '1178', '1179', '1180', '1181', '1182', '1183', '1184', '1185', '1186', '1187', '1188', '1189', '1190', '1191', '1192', '1193', '1194', '1195', '1196', '1197', '1198', '1199', '1200', '1201', '1202', '1203', '1204', '1205', '1206', '1207', '1208', '1209', '1210', '1211', '1212', '1214', '1216', '1217', '1218', '1220', '1222', '1223', '1225', '1226', '1227', '1228', '1230', '1231', '1232', '1234', '1235', '1236', '1237', '1238', '1239', '1240', '1241', '1242', '1244', '1245', '1246', '1247', '1249', '1250', '1251', '1252', '1253', '1254', '1255', '1256', '1257', '1260', '1261', '1262', '1263', '1264', '1265', '1266', '1267', '1268', '1269', '1270', '1271', '1272', '1273', '1274', '1275', '1277', '1278', '1279', '1280', '1281', '1282', '1283', '1284', '1285', '1286', '1287', '1288', '1289', '1290', '1291', '1292', '1293', '1294', '1297', '1298', '1299', '1300', '1301', '1302', '1303', '1304', '1305', '1306', '1308', '1311', '1316', '1321', '1322', '1324', '1325', '1327', '1328', '1330', '1331', '1332', '1334', '1335', '1337', '1339', '1340', '1341', '1342', '1343', '1344', '1345', '1346', '1348', '1349', '1350', '1351', '1353', '1354', '1355', '1356', '1357', '1358', '1359', '1360', '1361', '1362', '1363', '1364', '1365', '1366', '1367', '1368', '1369', '1370', '1371', '1372', '1373', '1374', '1375', '1376', '1377', '1378', '1399', '1400', '1401', '1402', '1403', '1404', '1405', '1406', '1407', '1408', '1409', '1410', '1411', '1412', '1413', '1414', '1415', '1416', '1417', '1418', '1419', '1420', '1421', '1422', '1423', '1424', '1426', '1427', '1428', '1429', '1430', '1431', '1432', '1433', '1434', '1435', '1436', '1437', '1438', '1439', '1440', '1441', '1442', '1443', '1444', '1445', '1446', '1447', '1448', '1449', '1450', '1451', '1452', '1453', '1454', '1455', '1458', '1459', '1460', '1461', '1462', '1463', '1464', '1465', '1466', '1467', '1468', '1469', '1470', '1471', '1472', '1473', '1474', '1475', '1476', '1477', '1478', '1479', '1480', '1481', '1482', '1483', '1484', '1485', '1486', '1487', '1488', '1489', '1490', '1491', '1492', '1493', '1494', '1495', '1496', '1497', '1498', '1499', '1500', '1501', '1502', '1503', '1504', '1505', '1506', '1507', '1508', '1509', '1510', '1511', '1513', '1514', '1517', '1518', '1519', '1521', '1522', '1526', '1527', '1528', '1529', '1530', '1531', '1532', '1533', '1534', '1535', '1536', '1537', '1538', '1540', '1541', '1542', '1543', '1544', '1545', '1546', '1548', '1550', '1551', '1552', '1553', '1554', '1555', '1556', '1557', '1558', '1559', '1560', '1562', '1563', '1564', '1565', '1567', '1568', '1569', '1572', '1573', '1574', '1575', '1577', '1578', '1579', '1580', '1582', '1583', '1584', '1585', '1589', '1592', '1595', '1598', '1604', '1605', '1606', '1607', '1608', '1609', '1610', '1611', '1612', '1613', '1614', '1615', '1616', '1617', '1618', '1619', '1620', '1621', '1622', '1623', '1624', '1625', '1626', '1627', '1628', '1630', '1631', '1632', '1633', '1634', '1635', '1636', '1637', '1638', '1639', '1640', '1642', '1643', '1644', '1645', '1647', '1652', '1653', '1656', '1664', '1672', '1675', '1676', '1680', '1681', '1683', '1685', '1688', '1690', '1694', '1696', '1697', '1698', '1700', '1702', '1703', '1704', '1705', '1706', '1707', '1708', '1710', '1711', '1712', '1713', '1714', '1715', '1716', '1717', '1718', '1719', '1720', '1722', '1723', '1724', '1725', '1726', '1727', '1728', '1729', '1730', '1731', '1732', '1733', '1734', '1735', '1736', '1737', '1738', '1739', '1740', '1741', '1742', '1743', '1744', '1746', '1748', '1795', '1797', '1798', '1799', '1800', '1801', '1802', '1804', '1805', '1806', '1807', '1808', '1809', '1810', '1811', '1812', '1814', '1815', '1816', '1817', '1818', '1819', '1820', '1821', '1822', '1823', '1824', '1825', '1826', '1828', '1829', '1830', '1831', '1832', '1833', '1834', '1835', '1836', '1837', '1839', '1840', '1842', '1843', '1844', '1845', '1847', '1848', '1849', '1850', '1851', '1852', '1853', '1854', '1855', '1856', '1857', '1858', '1859', '1860', '1861', '1866', '1867', '1869', '1876', '1877', '1878', '1879', '1880', '1881', '1882', '1883', '1888', '1889', '1890', '1891', '1892', '1893', '1894', '1895', '1896', '1897', '1898', '1899', '1900', '1901', '1902', '1903', '1904', '1905', '1906', '1907', '1908', '1910', '1911', '1912', '1913', '1915', '1916', '1918', '1919', '1920', '1921', '1922', '1923', '1924', '1925', '1926', '1927', '1928', '1929', '1930', '1931', '1932', '1933', '1934', '1935', '1937', '1938', '1939', '1940', '1941', '1942', '1943', '1944', '1945', '1946', '1947', '1948', '1949', '1950', '1951', '1952', '1953', '1954', '1955', '1956', '1957', '1958', '1959', '1960', '1961', '1962', '1963', '1964', '1965', '1966', '1967', '1968', '1969', '1970', '1971', '1972', '1973', '1975', '1976', '1977', '1978', '1979', '1980', '1981', '1982', '1983', '1984', '1985', '1986', '1987', '1988', '1989', '1990', '1991', '1992', '1993', '1994', '1995', '1996', '1997', '1998', '1999', '2000', '2001', '2002', '2003', '2004', '2005', '2006', '2008', '2009', '2010', '2011', '2012', '2013', '2014', '2015', '2016', '2017', '2018', '2019', '2020', '2021', '2022', '2023', '2024', '2025', '2026', '2027', '2028', '2029', '2030', '2031', '2032', '2033', '2034', '2035', '2036', '2038', '2039', '2040', '2041', '2042', '2043', '2046', '2047', '2048', '2049', '2050', '2051', '2052', '2053', '2055', '2056', '2057', '2058', '2059', '2060', '2061', '2062', '2064', '2065', '2066', '2067', '2069', '2070', '2071', '2072', '2073', '2074', '2075', '2076', '2078', '2079', '2080', '2081', '2082', '2083', '2087', '2088', '2089', '2090', '2091', '2092', '2093', '2094', '2095', '2096', '2097', '2098', '2099', '2100', '2101', '2102', '2103', '2107', '2108', '2109', '2110', '2111', '2112', '2113', '2114', '2115', '2116', '2117', '2118', '2119', '2121', '2123', '2124', '2125', '2126', '2127', '2128', '2129', '2130', '2131', '2133', '2134', '2135', '2136', '2137', '2138', '2139', '2140', '2141', '2142', '2143', '2144', '2145', '2146', '2147', '2148', '2149', '2150', '2151', '2152', '2153', '2154', '2155', '2156', '2157', '2158', '2159', '2160', '2161', '2162', '2163', '2164', '2165', '2166', '2167', '2168', '2169', '2170', '2171', '2172', '2173', '2174', '2175', '2176', '2177', '2178', '2179', '2180', '2181', '2182', '2183', '2184', '2185', '2186', '2187', '2188', '2189', '2190', '2191', '2192', '2193', '2194', '2195', '2196', '2198', '2199', '2200', '2201', '2203', '2205', '2206', '2208', '2209', '2210', '2211', '2212', '2213', '2214', '2215', '2216', '2217', '2218', '2219', '2220', '2221', '2222', '2223', '2224', '2225', '2226', '2227', '2228', '2229', '2230', '2231', '2232', '2233', '2234', '2235', '2236', '2237', '2238', '2239', '2240', '2241', '2242', '2243', '2244', '2245', '2246', '2247', '2248', '2249', '2250', '2251', '2252', '2253', '2254', '2255', '2256', '2257', '2258', '2259', '2260', '2261', '2262', '2263', '2264', '2265', '2266', '2267', '2268', '2269', '2270', '2271', '2272', '2273', '2274', '2275', '2276', '2277', '2278', '2279', '2280', '2281', '2282', '2283', '2284', '2285', '2286', '2287', '2288', '2289', '2290', '2291', '2292', '2293', '2295', '2296', '2297', '2298', '2299', '2300', '2301', '2302', '2303', '2304', '2305', '2306', '2307', '2308', '2309', '2310', '2311', '2312', '2313', '2315', '2316', '2317', '2318', '2319', '2320', '2321', '2322', '2323', '2324', '2325', '2326', '2327', '2328', '2329', '2330', '2331', '2332', '2333', '2334', '2335', '2336', '2337', '2338', '2340', '2341', '2342', '2343', '2344', '2345', '2346', '2347', '2348', '2351', '2352', '2353', '2354', '2356', '2357', '2358', '2359', '2360', '2361', '2362', '2363', '2364', '2365', '2366', '2367', '2368', '2369', '2370', '2371', '2372', '2373', '2374', '2375', '2376', '2377', '2378', '2379', '2380', '2381', '2382', '2383', '2384', '2385', '2386', '2387', '2388', '2389', '2390', '2391', '2392', '2393', '2394', '2395', '2396', '2398', '2400', '2401', '2402', '2403', '2404', '2405', '2406', '2407', '2408', '2409', '2410', '2412', '2414', '2415', '2416', '2417', '2418', '2420', '2421', '2422', '2423', '2425', '2426', '2427', '2428', '2429', '2431', '2432', '2433', '2434', '2435', '2436', '2437', '2438', '2440', '2443', '2445', '2446', '2447', '2448', '2449', '2450', '2451', '2452', '2454', '2455', '2456', '2457', '2458', '2459', '2460', '2461', '2462', '2464', '2465', '2466', '2468', '2469', '2470', '2472', '2473', '2474', '2475', '2476', '2477', '2478', '2479', '2480', '2481', '2482', '2483', '2484', '2486', '2487', '2488', '2489', '2490', '2491', '2492', '2493', '2494', '2495', '2496', '2497', '2498', '2499', '2500', '2501', '2502', '2503', '2504', '2505', '2506', '2508', '2509', '2510', '2512', '2513', '2514', '2516', '2517', '2519', '2520', '2521', '2524', '2525', '2526', '2528', '2529', '2532', '2533', '2534', '2535', '2536', '2538', '2539', '2542', '2543', '2544', '2545', '2546', '2547', '2548', '2549', '2550', '2551', '2552', '2553', '2554', '2555', '2556', '2557', '2558', '2559', '2560', '2561', '2562', '2563', '2564', '2565', '2566', '2567', '2568', '2569', '2570', '2571', '2573', '2574', '2575', '2576', '2578', '2580', '2584', '2586', '2587', '2588', '2589', '2590', '2591', '2592', '2593', '2594', '2595', '2596', '2597', '2598', '2599', '2600', '2601', '2602', '2603', '2604', '2605', '2606', '2607', '2608', '2609', '2610', '2611', '2612', '2613', '2614', '2616', '2617', '2618', '2619', '2620', '2621', '2622', '2623', '2624', '2625', '2626', '2627', '2628', '2629', '2630', '2631', '2632', '2633', '2634', '2635', '2636', '2637', '2638', '2639', '2640', '2641', '2642', '2643', '2644', '2645', '2646', '2647', '2657', '2658', '2659', '2660', '2661', '2662', '2663', '2664', '2665', '2666', '2667', '2668', '2669', '2670', '2671', '2672', '2673', '2674', '2675', '2676', '2678', '2679', '2680', '2681', '2682', '2683', '2684', '2685', '2686', '2687', '2688', '2689', '2690', '2691', '2692', '2693', '2694', '2695', '2696', '2697', '2698', '2699', '2700', '2701', '2702', '2703', '2704', '2705', '2706', '2707', '2708', '2709', '2710', '2711', '2712', '2713', '2714', '2715', '2716', '2721', '2722', '2723', '2724', '2725', '2726', '2727', '2728', '2729', '2730', '2731', '2732', '2733', '2734', '2735', '2736', '2737', '2738', '2739', '2740', '2741', '2742', '2743', '2744', '2745', '2746', '2747', '2748', '2749', '2750', '2751', '2752', '2753', '2754', '2755', '2756', '2757', '2758', '2762', '2763', '2764', '2765', '2766', '2768', '2769', '2770', '2771', '2772', '2773', '2774', '2775', '2776', '2777', '2778', '2779', '2780', '2782', '2784', '2785', '2786', '2787', '2788', '2789', '2790', '2791', '2792', '2793', '2794', '2795', '2796', '2797', '2798', '2799', '2800', '2801', '2802', '2803', '2804', '2805', '2806', '2807', '2808', '2809', '2810', '2811', '2812', '2813', '2814', '2815', '2816', '2818', '2819', '2820', '2821', '2822', '2823', '2824', '2825', '2826', '2827', '2828', '2829', '2830', '2831', '2832', '2833', '2834', '2835', '2836', '2838', '2839', '2840', '2841', '2842', '2843', '2844', '2845', '2846', '2847', '2848', '2849', '2850', '2851', '2852', '2853', '2854', '2855', '2856', '2857', '2858', '2859', '2860', '2861', '2862', '2863', '2864', '2865', '2866', '2867', '2869', '2870', '2871', '2872', '2873', '2874', '2875', '2876', '2877', '2878', '2879', '2880', '2881', '2882', '2883', '2884', '2885', '2886', '2887', '2888', '2889', '2890', '2891', '2892', '2893', '2894', '2895', '2896', '2897', '2898', '2899', '2900', '2901', '2902', '2903', '2904', '2905', '2906', '2907', '2908', '2909', '2910', '2911', '2912', '2913', '2914', '2915', '2916', '2917', '2918', '2919', '2920', '2921', '2922', '2923', '2924', '2925', '2926', '2927', '2928', '2929', '2930', '2932', '2933', '2934', '2935', '2936', '2937', '2938', '2939', '2940', '2941', '2942', '2943', '2944', '2945', '2946', '2947', '2948', '2949', '2950', '2951', '2952', '2953', '2954', '2955', '2956', '2957', '2958', '2959', '2960', '2961', '2962', '2963', '2964', '2965', '2966', '2967', '2968', '2969', '2970', '2971', '2972', '2973', '2974', '2975', '2976', '2977', '2978', '2979', '2980', '2981', '2982', '2983', '2984', '2985', '2986', '2987', '2988', '2989', '2990', '2991', '2992', '2993', '2994', '2996', '2997', '2998', '2999', '3000', '3001', '3002', '3003', '3004', '3005', '3006', '3007', '3008', '3009', '3010', '3011', '3012', '3013', '3014', '3015', '3016', '3017', '3018', '3019', '3020', '3021', '3022', '3023', '3024', '3025', '3026', '3027', '3028', '3029', '3030', '3031', '3032', '3033', '3034', '3035', '3036', '3037', '3039', '3041', '3042', '3043', '3044', '3045', '3046', '3047', '3049', '3078', '3079', '3080', '3081', '3086', '3087', '3092', '3093', '3094', '3095', '3096', '3097', '3098', '3099', '3100', '3101', '3102', '3103', '3104', '3105', '3106', '3107', '3108', '3109', '3110', '3111', '3112', '3113', '3114', '3115', '3116', '3117', '3118', '3119', '3120', '3121', '3122', '3123', '3124', '3125', '3126', '3127', '3128', '3129', '3130', '3131', '3132', '3133', '3134', '3135', '3136', '3137', '3138', '3139', '3140', '3141', '3142', '3143', '3144', '3145', '3146', '3147', '3148', '3149', '3150', '3151', '3152', '3153', '3154', '3155', '3156', '3157', '3158', '3159', '3160', '3161', '3162', '3163', '3164', '3165', '3166', '3167', '3168', '3169', '3170', '3171', '3172', '3173', '3174', '3175', '3176', '3177', '3178', '3179', '3180', '3181', '3182', '3183', '3184', '3185', '3186', '3187', '3188', '3189', '3190', '3191', '3192', '3193', '3194', '3195', '3196', '3197', '3198', '3199', '3200', '3201', '3202', '3203', '3204', '3205', '3206', '3207', '3208', '3209', '3210', '3211', '3212', '3213', '3214', '3215', '3216', '3217', '3218', '3219', '3220', '3221', '3222', '3223', '3224', '3225', '3226', '3227', '3228', '3229', '3230', '3231', '3232', '3233', '3234', '3235', '3236', '3237', '3238', '3239', '3240', '3241', '3242', '3243', '3244', '3245', '3246', '3247', '3248', '3249', '3250', '3251', '3252', '3253', '3254', '3255', '3256', '3257', '3258', '3259', '3265', '3266', '3268', '3269', '3270', '3271', '3272', '3273', '3275', '3278', '3279', '3280', '3281', '3282', '3283', '3284', '3285', '3286', '3287', '3288', '3289', '3290', '3291', '3293', '3298', '3300', '3301', '3302', '3304', '3306', '3307', '3308', '3309', '3310', '3311', '3313', '3314', '3315', '3317', '3318', '3320', '3321', '3322', '3323', '3324', '3325', '3328', '3332', '3333', '3334', '3337', '3338', '3340', '3341', '3343', '3344', '3345', '3346', '3347', '3348', '3350', '3351', '3352', '3353', '3354', '3355', '4001', '4003', '4004', '4006', '4007', '4008', '4009', '4011', '4013', '4014', '4015', '4016', '4017', '4020', '4110', '4111', '4113', '4114', '4115', '4116', '4118', '4119', '4122', '4125', '4126', '4128', '4129', '4131', '4133', '4140', '4141', '4142', '4143', '4144', '4146', '4148', '4149', '4153', '4158', '4159', '4160', '4161', '4163', '4167', '4170', '4172', '4173', '4174', '4176', '4178', '4182', '4186', '4187', '4188', '4189', '4190', '4191', '4192', '4193', '4194', '4195', '4196', '4197', '4198', '4199', '4200', '4201', '4202', '4203', '4204', '4205', '4206', '4207', '4208', '4209', '4210', '4211', '4212', '4214', '4215', '4216', '4217', '4218', '4219', '4220', '4221', '4222', '4223', '4224', '4225', '4226', '4228', '4229', '4231', '4232', '4233', '4234', '4235', '4236', '4237', '4239', '4241', '4242', '4244', '4246', '4247', '4248', '4249', '4250', '4251', '4252', '4253', '4254', '4255', '4256', '4257', '4258', '4259', '4260', '4261', '4262', '4263', '4264', '4265', '4266', '4267', '4269', '4270', '4271', '4272', '4273', '4275', '4276', '4277', '4279', '4280', '4281', '4282', '4287', '4288', '4289', '4290', '4291', '4292', '4293', '4294', '4295', '4296', '4297', '4298', '4299', '4300', '4301', '4303', '4304', '4305', '4306', '4307', '4308', '4309', '4310', '4312', '4313', '4314', '4316', '4317', '4318', '4319', '4320', '4321', '4322', '4323', '4324', '4325', '4326', '4327', '4328', '4329', '4330', '4331', '4333', '4334', '4335', '4336', '4337', '4338', '4339', '4340', '4341', '4342', '4343', '4344', '4345', '4346', '4347', '4349', '4350', '4351', '4352', '4353', '4354', '4355', '4356', '4357', '4358', '4359', '4360', '4361', '4362', '4363', '4364', '4365', '4366', '4367', '4368', '4385', '4386', '4387', '4388', '4390', '4391', '4392', '4409', '4411', '4413', '4415', '4416', '4417', '4418', '4419', '4420', '4421', '4422', '4423', '4424', '4425', '4426', '4427', '4428', '4429', '4430', '4431', '4432', '4433', '4435', '4436', '4437', '4438', '4439', '4440', '4441', '4442', '4443', '4444', '4445', '4446', '4447', '4448', '4449', '4450', '4451', '4452', '4453', '4454', '4455', '4456', '4457', '4458', '4459', '4460', '4462', '4463', '4464', '4465', '4466', '4468', '4469', '4470', '4471', '4472', '4473', '4482', '4483', '4484', '4485', '4486', '4487', '4488', '4489', '4490', '4492', '4493', '4494', '4495', '4496', '4497', '4498', '4499', '4500', '4501', '4502', '4503', '4504', '4505', '4506', '4507', '4508', '4509', '4510', '4511', '4512', '4513', '4514', '4515', '4516', '4517', '4518', '4519', '4520', '4521', '4522', '4523', '4524', '4525', '4526', '4527', '4528', '4529', '4530', '4531', '4532', '4533', '4534', '4535', '4536', '4537', '4538', '4540', '4541', '4542', '4543', '4545', '4546', '4547', '4548', '4549', '4550', '4551', '4552', '4553', '4554', '4555', '4556', '4557', '4558', '4559', '4560', '4561', '4562', '4563', '4564', '4565', '4566', '4567', '4568', '4569', '4570', '4571', '4572', '4573', '4574', '4575', '4576', '4577', '4578', '4579', '4580', '4581', '4582', '4583', '4584', '4585', '4586', '4587', '4588', '4589', '4590', '4591', '4592', '4593', '4594', '4595', '4596', '4597', '4598', '4599', '4600', '4601', '4602', '4603', '4604', '4605', '4606', '4607', '4608', '4609', '4610', '4611', '4612', '4614', '4615', '4616', '4617', '4618', '4619', '4620', '4622', '4625', '4626', '4627', '4628', '4629', '5066', '5067', '5068', '5069', '5070', '5071', '5072', '5073', '5074', '5075', '5076', '5077', '5078', '5101', '5135', '5136', '5137', '5139', '5140', '5141', '5142', '5143', '5144', '5145', '5146', '5147', '5148', '5149', '5150', '5151', '5152', '5153', '5154', '5155', '5156', '5157', '5158', '5159', '5160', '5161', '5162', '5163', '5164', '5165', '5166', '5167', '5169', '5170', '5171', '5172', '5173', '5174', '5175', '5176', '5177', '5178', '5179', '5181', '5182', '5183', '5184', '5185', '5186', '5187', '5188', '5189', '5190', '5191', '5192', '5193', '5194', '5195', '5196', '5197', '5198', '5199', '5200', '5201', '5202', '5203', '5204', '5205', '5206', '5207', '5208', '5209', '5211', '5212', '5213', '5214', '5215', '5216', '5217', '5218', '5219', '5220', '5221', '5223', '5224', '5225', '5226', '5227', '5228', '5229', '5230', '5231', '5232', '5233', '5234', '5235', '5240', '5241', '5242', '5243', '5245', '5246', '5247', '5248', '5249', '5250', '5251', '5252', '5253', '5254', '5255', '5256', '5257', '5258', '5259', '5260', '5261', '5262', '5265', '5267', '5268', '5269', '5271', '5272', '5273', '5274', '5275', '5276', '5277', '5278', '5279', '5280', '5281', '5282', '5283', '5284', '5285', '5286', '5287', '5288', '5289', '5290', '5292', '5297', '5301', '5302', '5303', '5304', '5305', '5306', '5307', '5308', '5309', '5381', '5388', '5389', '5390', '5392', '5393', '5394', '5395', '5396']\n", - "{'2923', '1409', '1279', '1984', '3000', '594', '99', '1464', '1103', '4416', '3317', '538', '842', '3210', '868', '1185', '1119', '4531', '1451', '3204', '1428', '1536', '2440', '559', '934', '589', '355', '1624', '2553', '3208', '1267', '1685', '1241', '2141', '109', '517', '214', '600', '75', '307', '1402', '3182', '2893', '1363', '2794', '2989', '935', '3219', '483', '1376', '137', '616', '1349', '1618', '3202', '1364', '231', '2937', '637', '772', '555', '1222', '131', '246', '1145', '4294', '1023', '2363', '2726', '1609', '2295', '2897', '2813', '2750', '1187', '83', '3143', '664', '2143', '1095', '1971', '466', '2128', '2391', '3334', '533', '4317', '2853', '293', '1993', '832', '81', '9', '1501', '3108', '1099', '315', '1230', '2029', '1573', '278', '241', '1', '2787', '931', '345', '784', '1711', '1961', '2558', '295', '294', '2623', '286', '1197', '284', '1589', '2172', '1607', '108', '202', '32', '2547', '1368', '2333', '2835', '1141', '362', '2209', '380', '2377', '3109', '3199', '2890', '4298', '1175', '4257', '2948', '1031', '1605', '535', '1636', '604', '2945', '2795', '491', '261', '671', '2019', '339', '2006', '2908', '2151', '1980', '1265', '1717', '800', '515', '2450', '1972', '1172', '1497', '2709', '1263', '1716', '2283', '18', '3309', '2434', '395', '822', '1102', '576', '1238', '4359', '556', '2833', '1262', '146', '19', '2774', '1190', '2869', '836', '308', '1240', '2809', '613', '713', '3158', '2198', '1631', '1093', '583', '2769', '2452', '333', '1067', '625', '1901', '113', '3181', '3146', '2477', '3222', '478', '1645', '3092', '3311', '296', '986', '2170', '2858', '3002', '791', '1378', '3279', '285', '675', '2280', '1283', '4352', '1614', '2474', '2899', '51', '170', '2290', '1627', '633', '1123', '2343', '1939', '2957', '2351', '1992', '2935', '1468', '3229', '639', '1203', '4335', '696', '592', '1301', '1465', '2917', '3155', '3246', '267', '33', '1373', '2724', '3273', '1647', '2789', '3175', '2336', '3137', '2008', '2737', '2914', '1164', '2778', '2775', '2928', '2832', '2165', '892', '850', '1638', '3103', '839', '3178', '1889', '232', '197', '2786', '327', '2096', '212', '257', '1009', '4116', '2825', '831', '2359', '1006', '281', '240', '1681', '3100', '4346', '2174', '3111', '50', '103', '1415', '15', '2810', '706', '623', '225', '2996', '512', '4323', '520', '470', '228', '1481', '1255', '572', '1077', '603', '1350', '4324', '4232', '467', '2812', '2613', '1563', '4355', '3244', '130', '351', '2977', '2428', '1030', '595', '1432', '2807', '239', '3266', '263', '1970', '2904', '311', '799', '3162', '227', '2393', '2435', '4148', '550', '4255', '1941', '4543', '2286', '642', '1441', '590', '1529', '562', '1131', '3251', '1925', '1896', '3118', '4357', '280', '977', '3086', '14', '1161', '1341', '3242', '1892', '610', '2090', '2970', '2330', '1291', '2852', '2074', '1044', '1637', '2287', '552', '1905', '2964', '86', '2024', '128', '4131', '1919', '4297', '817', '1032', '36', '1372', '1176', '2378', '2885', '1087', '2406', '465', '1040', '2460', '4267', '1005', '2513', '2134', '1400', '2032', '2952', '1642', '4122', '577', '2999', '292', '93', '2864', '4020', '4351', '536', '3207', '588', '1484', '3180', '1200', '2984', '1062', '2373', '300', '2966', '1720', '1472', '473', '2270', '1423', '358', '1332', '1361', '1964', '792', '335', '1938', '2301', '4248', '2811', '21', '369', '2894', '1037', '1697', '2873', '3191', '2944', '834', '611', '1401', '1078', '965', '4361', '2445', '2567', '1612', '1975', '2358', '1713', '3223', '2103', '2317', '3269', '4016', '5218', '667', '1410', '2144', '2462', '184', '1275', '328', '2608', '2322', '4532', '1091', '2962', '811', '3144', '2003', '3021', '1639', '78', '273', '2576', '1477', '582', '684', '665', '686', '886', '3012', '1008', '4202', '1167', '2179', '3135', '4523', '803', '134', '390', '648', '3125', '138', '2816', '2337', '2784', '549', '697', '1963', '2867', '777', '575', '153', '163', '148', '3138', '571', '3004', '1700', '628', '2266', '1356', '516', '1104', '359', '366', '1374', '2150', '34', '291', '1331', '3220', '4235', '450', '787', '2851', '302', '2880', '1290', '2568', '2901', '1945', '463', '2030', '840', '2756', '45', '545', '2284', '2754', '89', '1943', '2456', '503', '118', '2004', '4521', '1202', '593', '808', '1485', '3310', '493', '1478', '2278', '641', '274', '1988', '1623', '457', '653', '1298', '1931', '2820', '4252', '4538', '979', '1944', '1017', '4520', '1004', '73', '1696', '1930', '643', '1335', '2745', '3128', '2829', '1962', '1108', '1500', '3024', '1632', '264', '4015', '2681', '4216', '785', '1223', '2402', '320', '2258', '2282', '2385', '1249', '3119', '904', '164', '4143', '1558', '2328', '1024', '2331', '3234', '2847', '2308', '4386', '2632', '3017', '485', '1300', '497', '903', '2988', '2027', '4199', '983', '3320', '47', '272', '614', '3101', '1644', '1266', '2447', '1438', '2855', '2834', '1928', '2747', '2190', '396', '486', '4354', '1897', '591', '301', '2195', '4119', '1179', '1068', '1050', '265', '120', '2929', '2790', '1999', '309', '3197', '150', '2171', '2409', '968', '2256', '864', '4536', '3098', '4541', '2738', '1360', '647', '1990', '3231', '330', '4229', '2600', '2319', '1403', '152', '1088', '1991', '1033', '1003', '1327', '182', '775', '102', '2721', '690', '4316', '2524', '547', '2875', '62', '2866', '829', '574', '237', '1714', '502', '620', '567', '4321', '161', '2922', '3153', '1198', '597', '554', '2210', '1455', '3315', '2016', '329', '4318', '1740', '270', '2951', '805', '2354', '2748', '564', '2746', '1680', '4194', '1625', '1351', '1966', '634', '4170', '2446', '1201', '1550', '3113', '2138', '2159', '585', '4008', '598', '2396', '830', '3214', '495', '2139', '2407', '3265', '459', '1207', '1173', '3321', '149', '569', '287', '331', '2360', '132', '3161', '1154', '1010', '421', '1342', '657', '1615', '2327', '269', '2326', '1060', '58', '2954', '823', '2860', '1181', '551', '1640', '1567', '2538', '3228', '4528', '973', '3046', '114', '3157', '180', '4385', '4339', '471', '2201', '221', '2180', '4540', '677', '2887', '1702', '2388', '3250', '2095', '3114', '4144', '602', '1169', '1994', '3150', '969', '780', '1444', '91', '2156', '3224', '1920', '2916', '492', '2561', '500', '638', '3328', '786', '1321', '142', '1357', '581', '809', '1165', '635', '2335', '1235', '2731', '3001', '1967', '2352', '584', '1568', '2137', '578', '4241', '678', '2389', '469', '10', '201', '1532', '1247', '4007', '107', '2861', '443', '4534', '352', '1688', '779', '2069', '1250', '2736', '3355', '356', '2563', '2993', '3243', '2425', '312', '807', '1285', '2299', '1891', '2838', '119', '361', '2264', '4525', '1948', '3239', '1322', '1346', '558', '123', '2859', '1719', '1122', '2741', '4368', '1061', '2925', '343', '2311', '853', '510', '2372', '1132', '504', '615', '2821', '5217', '951', '1620', '2932', '1191', '2', '1027', '2879', '166', '4320', '1277', '778', '2422', '494', '2129', '2883', '1565', '1499', '2455', '3131', '1117', '1957', '952', '1192', '1174', '1505', '1260', '1978', '4535', '1502', '288', '3301', '490', '3249', '4519', '147', '2017', '975', '1367', '2730', '2892', '2713', '3255', '2814', '2433', '4419', '464', '631', '4289', '1537', '2192', '410', '2727', '609', '117', '4001', '3019', '507', '2788', '3141', '619', '2943', '4306', '282', '1471', '474', '2002', '2300', '2556', '5219', '3032', '3209', '1718', '2947', '3006', '477', '570', '1269', '1228', '85', '2729', '1086', '1893', '5066', '29', '1487', '682', '854', '1135', '299', '2758', '1188', '74', '2367', '3167', '1194', '2465', '3121', '49', '632', '1987', '856', '4388', '1459', '245', '2532', '289', '626', '234', '3096', '630', '3116', '155', '2163', '543', '1734', '2764', '1580', '2028', '476', '605', '372', '2421', '2166', '298', '1358', '59', '518', '3245', '145', '2936', '2535', '1002', '122', '900', '3338', '158', '1083', '4', '2982', '522', '3020', '1493', '2630', '660', '1026', '1617', '1205', '2408', '3156', '4111', '579', '3252', '3045', '101', '2261', '1139', '857', '243', '208', '506', '852', '1592', '2900', '509', '3095', '1703', '1480', '235', '4546', '2844', '2036', '1452', '462', '1157', '1052', '967', '3022', '190', '2018', '236', '1634', '2320', '970', '95', '3225', '3200', '4254', '2088', '2979', '1054', '1345', '350', '4218', '110', '397', '3216', '60', '3009', '1997', '223', '2941', '338', '537', '2312', '4356', '1440', '2459', '1486', '211', '629', '869', '3332', '1635', '1370', '2167', '314', '2976', '3136', '2965', '1507', '2933', '3010', '1434', '895', '1479', '1076', '1407', '2711', '4347', '1206', '1412', '1114', '428', '1340', '2732', '1935', '2796', '2135', '1977', '1622', '4522', '1926', '3151', '1109', '2792', '318', '3281', '1958', '1189', '1613', '4013', '2376', '1995', '3097', '5214', '2323', '2798', '508', '2840', '2182', '978', '2034', '1474', '461', '2841', '2414', '4113', '2365', '3241', '3256', '2000', '1986', '2902', '316', '1450', '2961', '2913', '3308', '2912', '3042', '1183', '1177', '1292', '3238', '2164', '2160', '203', '1417', '3147', '1245', '4342', '2896', '2254', '39', '393', '498', '4236', '2570', '2889', '1184', '3133', '4300', '3003', '1982', '1453', '1343', '1128', '2341', '189', '1140', '2830', '845', '3248', '2782', '1616', '544', '1894', '1606', '2903', '2263', '2011', '17', '1706', '2827', '519', '1446', '4174', '151', '1898', '3014', '2449', '136', '1461', '3043', '2356', '1041', '1710', '2022', '6', '1424', '2971', '2102', '268', '3165', '2469', '200', '2918', '2994', '4198', '841', '2881', '3154', '993', '1519', '279', '998', '1430', '479', '4149', '1308', '1110', '370', '939', '2005', '398', '1522', '1020', '4533', '2001', '496', '3139', '1985', '1220', '1447', '1101', '530', '4296', '277', '4328', '3227', '3018', '1726', '801', '2739', '1690', '2822', '3127', '1712', '1954', '4322', '1155', '1610', '565', '2911', '3324', '2751', '3171', '607', '715', '1013', '2130', '2983', '2924', '833', '622', '2338', '1057', '1526', '1180', '1070', '468', '2386', '213', '82', '2298', '1408', '1419', '3166', '181', '2557', '2997', '802', '936', '1120', '325', '646', '566', '3126', '156', '2805', '3102', '3173', '3226', '1426', '1036', '1552', '2878', '5220', '3193', '2255', '3132', '1534', '3211', '2133', '1273', '1311', '3163', '1281', '649', '1369', '2401', '2850', '818', '3112', '3212', '1115', '1022', '4014', '2898', '2876', '1445', '1069', '2253', '539', '2633', '169', '1082', '2546', '1969', '125', '1621', '1900', '3159', '676', '192', '76', '3257', '3011', '489', '1334', '1048', '3034', '596', '2978', '1989', '1708', '2296', '4258', '1377', '1012', '176', '135', '160', '703', '140', '3188', '3105', '1937', '2432', '2797', '1953', '2804', '563', '624', '3005', '505', '534', '942', '334', '216', '1405', '3271', '2208', '2749', '1339', '2305', '4133', '2162', '371', '2926', '3194', '28', '1253', '1075', '2848', '2315', '870', '283', '2345', '2307', '357', '2370', '1344', '2895', '3013', '1094', '2969', '2329', '2743', '580', '943', '11', '1414', '1435', '1362', '3123', '2975', '303', '3160', '2733', '3164', '2262', '3030', '249', '1546', '521', '1142', '2946', '1608', '207', '3124', '1239', '944', '513', '323', '2998', '4527', '2826', '511', '159', '2846', '98', '621', '851', '1973', '3145', '2919', '617', '1490', '774', '1467', '3213', '179', '1705', '1959', '2279', '1460', '3186', '5216', '546', '3174', '941', '487', '297', '1214', '27', '4309', '2735', '1231', '475', '3340', '2806', '1923', '3087', '2288', '2791', '526', '2247', '1458', '1514', '4192', '640', '2466', '244', '3205', '548', '793', '80', '3110', '1724', '2828', '2306', '1186', '1965', '859', '2013', '1359', '2728', '2014', '191', '2259', '2026', '3120', '3142', '31', '2177', '247', '2094', '2525', '1940', '1433', '606', '185', '670', '2877', '1210', '2634', '3254', '2740', '1107', '304', '3140', '3168', '945', '1330', '1463', '1736', '1212', '290', '1633', '1947', '3015', '1038', '175', '3008', '3195', '2596', '707', '2346', '3184', '2863', '1148', '2843', '4341', '1237', '2348', '2818', '501', '127', '1951', '906', '2905', '2986', '2420', '2091', '4114', '4140', '3023', '472', '1448', '2260', '187', '3325', '1956', '2514', '3259', '2555', '1437', '1018', '480', '4224', '700', '242', '488', '87', '2405', '1683', '96', '3152', '3237', '3314', '1664', '561', '2169', '139', '26', '2884', '2888', '319', '1375', '1236', '2891', '1475', '1470', '430', '1196', '2362', '12', '3169', '1462', '2631', '2770', '1949', '2580', '317', '1118', '1014', '773', '42', '2551', '557', '188', '1324', '381', '1722', '1195', '860', '1271', '2886', '2865', '1337', '1628', '1416', '3185', '1626', '1328', '2824', '144', '63', '4006', '3233', '1727', '2910', '3130', '3198', '2251', '337', '2438', '2398', '1021', '2635', '1495', '3258', '394', '618', '1952', '1942', '2882', '2548', '3026', '133', '636', '1998', '2252', '789', '67', '587', '2723', '2403', '1960', '2472', '1007', '2771', '2808', '568', '2920', '797', '198', '3235', '1182', '4418', '38', '4004', '4282', '2985', '4011', '2475', '2297', '2194', '1619', '1159', '1715', '2874', '1439', '2191', '2473', '2154', '796', '3192', '248', '3134', '458', '3230', '781', '4417', '2712', '3302', '310', '2418', '4273', '2870', '3201', '3196', '798', '79', '2785', '1494', '2025', '788', '701', '1723', '3149', '560', '2842', '2457', '992', '3031', '645', '794', '1996', '454', '195', '2476', '2725', '2980', '1473', '2410', '2921', '143', '1483', '233', '1199', '71', '2974', '1366', '674', '3179', '1193', '1454', '2015', '1492', '2562', '4009', '933', '3099', '3177', '3307', '938', '1981', '1908', '238', '1489', '2379', '2521', '1890', '608', '2021', '2395', '3253', '173', '2744', '3203', '4313', '348', '347', '1420', '4530', '3106', '1092', '1698', '532', '1950', '111', '2987', '3093', '3117', '4205', '858', '4163', '2304', '336', '353', '4526', '1488', '990', '4529', '953', '1056', '258', '3217', '2963', '928', '183', '1163', '3304', '1704', '2714', '2534', '586', '2276', '399', '2849', '663', '3206', '529', '2161', '3041', '94', '2854', '1178', '2776', '1299', '2722', '514', '2382', '3028', '40', '960', '553', '2010', '2265', '5', '1482', '2981', '804', '2597', '2031', '4319', '1643', '2158', '2147', '1136', '2773', '4290', '531', '782', '3187', '956', '1436', '1630', '790', '3247', '795', '1968', '3240', '1354', '2012', '2780', '1130', '1209', '168', '349', '542', '4415', '1353', '2823', '460', '971', '1611', '2193', '2753', '1429', '3183', '699', '3221', '693', '1503', '1976', '1431', '2716', '3016', '3115', '1011', '4343', '2196', '126', '1496', '972', '141', '1575', '2815', '3172', '4363', '2856', '1903'}\n", - "0 1\n", - "1 2\n", - "2 3\n", - "3 4\n", - "4 5\n", - " ... \n", - "3951 5393\n", - "3952 5394\n", - "3953 5395\n", - "3954 5396\n", - "3955 5397\n", - "Name: station_id, Length: 3956, dtype: object\n", - "{'2923', '1409', '1279', '1984', '3000', '594', '99', '1464', '1103', '4416', '3317', '538', '842', '3210', '868', '1185', '1119', '4531', '1451', '3204', '1428', '1536', '2440', '559', '934', '589', '355', '1624', '2553', '3208', '1267', '1685', '1241', '2141', '109', '517', '214', '600', '75', '307', '1402', '3182', '2893', '1363', '2794', '2989', '935', '3219', '483', '1376', '137', '616', '1349', '1618', '3202', '1364', '231', '2937', '637', '772', '555', '1222', '131', '246', '1145', '4294', '1023', '2363', '2726', '1609', '2295', '2897', '2813', '2750', '1187', '83', '3143', '664', '2143', '1095', '1971', '466', '2128', '2391', '3334', '533', '4317', '2853', '293', '1993', '832', '81', '9', '1501', '3108', '1099', '315', '1230', '2029', '1573', '278', '241', '1', '2787', '931', '345', '784', '1711', '1961', '2558', '295', '294', '2623', '286', '1197', '284', '1589', '2172', '1607', '108', '202', '32', '2547', '1368', '2333', '2835', '1141', '362', '2209', '380', '2377', '3109', '3199', '2890', '4298', '1175', '4257', '2948', '1031', '1605', '535', '1636', '604', '2945', '2795', '491', '261', '671', '2019', '339', '2006', '2908', '2151', '1980', '1265', '1717', '800', '515', '2450', '1972', '1172', '1497', '2709', '1263', '1716', '2283', '18', '3309', '2434', '395', '822', '1102', '576', '1238', '4359', '556', '2833', '1262', '146', '19', '2774', '1190', '2869', '836', '308', '1240', '2809', '613', '713', '3158', '2198', '1631', '1093', '583', '2769', '2452', '333', '1067', '625', '1901', '113', '3181', '3146', '2477', '3222', '478', '1645', '3092', '3311', '296', '986', '2170', '2858', '3002', '791', '1378', '3279', '285', '675', '2280', '1283', '4352', '1614', '2474', '2899', '51', '170', '2290', '1627', '633', '1123', '2343', '1939', '2957', '2351', '1992', '2935', '1468', '3229', '639', '1203', '4335', '696', '592', '1301', '1465', '2917', '3155', '3246', '267', '33', '1373', '2724', '3273', '1647', '2789', '3175', '2336', '3137', '2008', '2737', '2914', '1164', '2778', '2775', '2928', '2832', '2165', '892', '850', '1638', '3103', '839', '3178', '1889', '232', '197', '2786', '327', '2096', '212', '257', '1009', '4116', '2825', '831', '2359', '1006', '281', '240', '1681', '3100', '4346', '2174', '3111', '50', '103', '1415', '15', '2810', '706', '623', '225', '2996', '512', '4323', '520', '470', '228', '1481', '1255', '572', '1077', '603', '1350', '4324', '4232', '467', '2812', '2613', '1563', '4355', '3244', '130', '351', '2977', '2428', '1030', '595', '1432', '2807', '239', '3266', '263', '1970', '2904', '311', '799', '3162', '227', '2393', '2435', '4148', '550', '4255', '1941', '4543', '2286', '642', '1441', '590', '1529', '562', '1131', '3251', '1925', '1896', '3118', '4357', '280', '977', '3086', '14', '1161', '1341', '3242', '1892', '610', '2090', '2970', '2330', '1291', '2852', '2074', '1044', '1637', '2287', '552', '1905', '2964', '86', '2024', '128', '4131', '1919', '4297', '817', '1032', '36', '1372', '1176', '2378', '2885', '1087', '2406', '465', '1040', '2460', '4267', '1005', '2513', '2134', '1400', '2032', '2952', '1642', '4122', '577', '2999', '292', '93', '2864', '4020', '4351', '536', '3207', '588', '1484', '3180', '1200', '2984', '1062', '2373', '300', '2966', '1720', '1472', '473', '2270', '1423', '358', '1332', '1361', '1964', '792', '335', '1938', '2301', '4248', '2811', '21', '369', '2894', '1037', '1697', '2873', '3191', '2944', '834', '611', '1401', '1078', '965', '4361', '2445', '2567', '1612', '1975', '2358', '1713', '3223', '2103', '2317', '3269', '4016', '5218', '667', '1410', '2144', '2462', '184', '1275', '328', '2608', '2322', '4532', '1091', '2962', '811', '3144', '2003', '3021', '1639', '78', '273', '2576', '1477', '582', '684', '665', '686', '886', '3012', '1008', '4202', '1167', '2179', '3135', '4523', '803', '134', '390', '648', '3125', '138', '2816', '2337', '2784', '549', '697', '1963', '2867', '777', '575', '153', '163', '148', '3138', '571', '3004', '1700', '628', '2266', '1356', '516', '1104', '359', '366', '1374', '2150', '34', '291', '1331', '3220', '4235', '450', '787', '2851', '302', '2880', '1290', '2568', '2901', '1945', '463', '2030', '840', '2756', '45', '545', '2284', '2754', '89', '1943', '2456', '503', '118', '2004', '4521', '1202', '593', '808', '1485', '3310', '493', '1478', '2278', '641', '274', '1988', '1623', '457', '653', '1298', '1931', '2820', '4252', '4538', '979', '1944', '1017', '4520', '1004', '73', '1696', '1930', '643', '1335', '2745', '3128', '2829', '1962', '1108', '1500', '3024', '1632', '264', '4015', '2681', '4216', '785', '1223', '2402', '320', '2258', '2282', '2385', '1249', '3119', '904', '164', '4143', '1558', '2328', '1024', '2331', '3234', '2847', '2308', '4386', '2632', '3017', '485', '1300', '497', '903', '2988', '2027', '4199', '983', '3320', '47', '272', '614', '3101', '1644', '1266', '2447', '1438', '2855', '2834', '1928', '2747', '2190', '396', '486', '4354', '1897', '591', '301', '2195', '4119', '1179', '1068', '1050', '265', '120', '2929', '2790', '1999', '309', '3197', '150', '2171', '2409', '968', '2256', '864', '4536', '3098', '4541', '2738', '1360', '647', '1990', '3231', '330', '4229', '2600', '2319', '1403', '152', '1088', '1991', '1033', '1003', '1327', '182', '775', '102', '2721', '690', '4316', '2524', '547', '2875', '62', '2866', '829', '574', '237', '1714', '502', '620', '567', '4321', '161', '2922', '3153', '1198', '597', '554', '2210', '1455', '3315', '2016', '329', '4318', '1740', '270', '2951', '805', '2354', '2748', '564', '2746', '1680', '4194', '1625', '1351', '1966', '634', '4170', '2446', '1201', '1550', '3113', '2138', '2159', '585', '4008', '598', '2396', '830', '3214', '495', '2139', '2407', '3265', '459', '1207', '1173', '3321', '149', '569', '287', '331', '2360', '132', '3161', '1154', '1010', '421', '1342', '657', '1615', '2327', '269', '2326', '1060', '58', '2954', '823', '2860', '1181', '551', '1640', '1567', '2538', '3228', '4528', '973', '3046', '114', '3157', '180', '4385', '4339', '471', '2201', '221', '2180', '4540', '677', '2887', '1702', '2388', '3250', '2095', '3114', '4144', '602', '1169', '1994', '3150', '969', '780', '1444', '91', '2156', '3224', '1920', '2916', '492', '2561', '500', '638', '3328', '786', '1321', '142', '1357', '581', '809', '1165', '635', '2335', '1235', '2731', '3001', '1967', '2352', '584', '1568', '2137', '578', '4241', '678', '2389', '469', '10', '201', '1532', '1247', '4007', '107', '2861', '443', '4534', '352', '1688', '779', '2069', '1250', '2736', '3355', '356', '2563', '2993', '3243', '2425', '312', '807', '1285', '2299', '1891', '2838', '119', '361', '2264', '4525', '1948', '3239', '1322', '1346', '558', '123', '2859', '1719', '1122', '2741', '4368', '1061', '2925', '343', '2311', '853', '510', '2372', '1132', '504', '615', '2821', '5217', '951', '1620', '2932', '1191', '2', '1027', '2879', '166', '4320', '1277', '778', '2422', '494', '2129', '2883', '1565', '1499', '2455', '3131', '1117', '1957', '952', '1192', '1174', '1505', '1260', '1978', '4535', '1502', '288', '3301', '490', '3249', '4519', '147', '2017', '975', '1367', '2730', '2892', '2713', '3255', '2814', '2433', '4419', '464', '631', '4289', '1537', '2192', '410', '2727', '609', '117', '4001', '3019', '507', '2788', '3141', '619', '2943', '4306', '282', '1471', '474', '2002', '2300', '2556', '5219', '3032', '3209', '1718', '2947', '3006', '477', '570', '1269', '1228', '85', '2729', '1086', '1893', '5066', '29', '1487', '682', '854', '1135', '299', '2758', '1188', '74', '2367', '3167', '1194', '2465', '3121', '49', '632', '1987', '856', '4388', '1459', '245', '2532', '289', '626', '234', '3096', '630', '3116', '155', '2163', '543', '1734', '2764', '1580', '2028', '476', '605', '372', '2421', '2166', '298', '1358', '59', '518', '3245', '145', '2936', '2535', '1002', '122', '900', '3338', '158', '1083', '4', '2982', '522', '3020', '1493', '2630', '660', '1026', '1617', '1205', '2408', '3156', '4111', '579', '3252', '3045', '101', '2261', '1139', '857', '243', '208', '506', '852', '1592', '2900', '509', '3095', '1703', '1480', '235', '4546', '2844', '2036', '1452', '462', '1157', '1052', '967', '3022', '190', '2018', '236', '1634', '2320', '970', '95', '3225', '3200', '4254', '2088', '2979', '1054', '1345', '350', '4218', '110', '397', '3216', '60', '3009', '1997', '223', '2941', '338', '537', '2312', '4356', '1440', '2459', '1486', '211', '629', '869', '3332', '1635', '1370', '2167', '314', '2976', '3136', '2965', '1507', '2933', '3010', '1434', '895', '1479', '1076', '1407', '2711', '4347', '1206', '1412', '1114', '428', '1340', '2732', '1935', '2796', '2135', '1977', '1622', '4522', '1926', '3151', '1109', '2792', '318', '3281', '1958', '1189', '1613', '4013', '2376', '1995', '3097', '5214', '2323', '2798', '508', '2840', '2182', '978', '2034', '1474', '461', '2841', '2414', '4113', '2365', '3241', '3256', '2000', '1986', '2902', '316', '1450', '2961', '2913', '3308', '2912', '3042', '1183', '1177', '1292', '3238', '2164', '2160', '203', '1417', '3147', '1245', '4342', '2896', '2254', '39', '393', '498', '4236', '2570', '2889', '1184', '3133', '4300', '3003', '1982', '1453', '1343', '1128', '2341', '189', '1140', '2830', '845', '3248', '2782', '1616', '544', '1894', '1606', '2903', '2263', '2011', '17', '1706', '2827', '519', '1446', '4174', '151', '1898', '3014', '2449', '136', '1461', '3043', '2356', '1041', '1710', '2022', '6', '1424', '2971', '2102', '268', '3165', '2469', '200', '2918', '2994', '4198', '841', '2881', '3154', '993', '1519', '279', '998', '1430', '479', '4149', '1308', '1110', '370', '939', '2005', '398', '1522', '1020', '4533', '2001', '496', '3139', '1985', '1220', '1447', '1101', '530', '4296', '277', '4328', '3227', '3018', '1726', '801', '2739', '1690', '2822', '3127', '1712', '1954', '4322', '1155', '1610', '565', '2911', '3324', '2751', '3171', '607', '715', '1013', '2130', '2983', '2924', '833', '622', '2338', '1057', '1526', '1180', '1070', '468', '2386', '213', '82', '2298', '1408', '1419', '3166', '181', '2557', '2997', '802', '936', '1120', '325', '646', '566', '3126', '156', '2805', '3102', '3173', '3226', '1426', '1036', '1552', '2878', '5220', '3193', '2255', '3132', '1534', '3211', '2133', '1273', '1311', '3163', '1281', '649', '1369', '2401', '2850', '818', '3112', '3212', '1115', '1022', '4014', '2898', '2876', '1445', '1069', '2253', '539', '2633', '169', '1082', '2546', '1969', '125', '1621', '1900', '3159', '676', '192', '76', '3257', '3011', '489', '1334', '1048', '3034', '596', '2978', '1989', '1708', '2296', '4258', '1377', '1012', '176', '135', '160', '703', '140', '3188', '3105', '1937', '2432', '2797', '1953', '2804', '563', '624', '3005', '505', '534', '942', '334', '216', '1405', '3271', '2208', '2749', '1339', '2305', '4133', '2162', '371', '2926', '3194', '28', '1253', '1075', '2848', '2315', '870', '283', '2345', '2307', '357', '2370', '1344', '2895', '3013', '1094', '2969', '2329', '2743', '580', '943', '11', '1414', '1435', '1362', '3123', '2975', '303', '3160', '2733', '3164', '2262', '3030', '249', '1546', '521', '1142', '2946', '1608', '207', '3124', '1239', '944', '513', '323', '2998', '4527', '2826', '511', '159', '2846', '98', '621', '851', '1973', '3145', '2919', '617', '1490', '774', '1467', '3213', '179', '1705', '1959', '2279', '1460', '3186', '5216', '546', '3174', '941', '487', '297', '1214', '27', '4309', '2735', '1231', '475', '3340', '2806', '1923', '3087', '2288', '2791', '526', '2247', '1458', '1514', '4192', '640', '2466', '244', '3205', '548', '793', '80', '3110', '1724', '2828', '2306', '1186', '1965', '859', '2013', '1359', '2728', '2014', '191', '2259', '2026', '3120', '3142', '31', '2177', '247', '2094', '2525', '1940', '1433', '606', '185', '670', '2877', '1210', '2634', '3254', '2740', '1107', '304', '3140', '3168', '945', '1330', '1463', '1736', '1212', '290', '1633', '1947', '3015', '1038', '175', '3008', '3195', '2596', '707', '2346', '3184', '2863', '1148', '2843', '4341', '1237', '2348', '2818', '501', '127', '1951', '906', '2905', '2986', '2420', '2091', '4114', '4140', '3023', '472', '1448', '2260', '187', '3325', '1956', '2514', '3259', '2555', '1437', '1018', '480', '4224', '700', '242', '488', '87', '2405', '1683', '96', '3152', '3237', '3314', '1664', '561', '2169', '139', '26', '2884', '2888', '319', '1375', '1236', '2891', '1475', '1470', '430', '1196', '2362', '12', '3169', '1462', '2631', '2770', '1949', '2580', '317', '1118', '1014', '773', '42', '2551', '557', '188', '1324', '381', '1722', '1195', '860', '1271', '2886', '2865', '1337', '1628', '1416', '3185', '1626', '1328', '2824', '144', '63', '4006', '3233', '1727', '2910', '3130', '3198', '2251', '337', '2438', '2398', '1021', '2635', '1495', '3258', '394', '618', '1952', '1942', '2882', '2548', '3026', '133', '636', '1998', '2252', '789', '67', '587', '2723', '2403', '1960', '2472', '1007', '2771', '2808', '568', '2920', '797', '198', '3235', '1182', '4418', '38', '4004', '4282', '2985', '4011', '2475', '2297', '2194', '1619', '1159', '1715', '2874', '1439', '2191', '2473', '2154', '796', '3192', '248', '3134', '458', '3230', '781', '4417', '2712', '3302', '310', '2418', '4273', '2870', '3201', '3196', '798', '79', '2785', '1494', '2025', '788', '701', '1723', '3149', '560', '2842', '2457', '992', '3031', '645', '794', '1996', '454', '195', '2476', '2725', '2980', '1473', '2410', '2921', '143', '1483', '233', '1199', '71', '2974', '1366', '674', '3179', '1193', '1454', '2015', '1492', '2562', '4009', '933', '3099', '3177', '3307', '938', '1981', '1908', '238', '1489', '2379', '2521', '1890', '608', '2021', '2395', '3253', '173', '2744', '3203', '4313', '348', '347', '1420', '4530', '3106', '1092', '1698', '532', '1950', '111', '2987', '3093', '3117', '4205', '858', '4163', '2304', '336', '353', '4526', '1488', '990', '4529', '953', '1056', '258', '3217', '2963', '928', '183', '1163', '3304', '1704', '2714', '2534', '586', '2276', '399', '2849', '663', '3206', '529', '2161', '3041', '94', '2854', '1178', '2776', '1299', '2722', '514', '2382', '3028', '40', '960', '553', '2010', '2265', '5', '1482', '2981', '804', '2597', '2031', '4319', '1643', '2158', '2147', '1136', '2773', '4290', '531', '782', '3187', '956', '1436', '1630', '790', '3247', '795', '1968', '3240', '1354', '2012', '2780', '1130', '1209', '168', '349', '542', '4415', '1353', '2823', '460', '971', '1611', '2193', '2753', '1429', '3183', '699', '3221', '693', '1503', '1976', '1431', '2716', '3016', '3115', '1011', '4343', '2196', '126', '1496', '972', '141', '1575', '2815', '3172', '4363', '2856', '1903'}\n", - "['2923', '1409', '1279', '1984', '3000', '594', '99', '1464', '1103', '4416', '3317', '538', '842', '3210', '868', '1185', '1119', '4531', '1451', '3204', '1428', '1536', '2440', '559', '934', '589', '355', '1624', '2553', '3208', '1267', '1685', '1241', '2141', '109', '517', '214', '600', '75', '307', '1402', '3182', '2893', '1363', '2794', '2989', '935', '3219', '483', '1376', '137', '616', '1349', '1618', '3202', '1364', '231', '2937', '637', '772', '555', '1222', '131', '246', '1145', '4294', '1023', '2363', '2726', '1609', '2295', '2897', '2813', '2750', '1187', '83', '3143', '664', '2143', '1095', '1971', '466', '2128', '2391', '3334', '533', '4317', '2853', '293', '1993', '832', '81', '9', '1501', '3108', '1099', '315', '1230', '2029', '1573', '278', '241', '1', '2787', '931', '345', '784', '1711', '1961', '2558', '295', '294', '2623', '286', '1197', '284', '1589', '2172', '1607', '108', '202', '32', '2547', '1368', '2333', '2835', '1141', '362', '2209', '380', '2377', '3109', '3199', '2890', '4298', '1175', '4257', '2948', '1031', '1605', '535', '1636', '604', '2945', '2795', '491', '261', '671', '2019', '339', '2006', '2908', '2151', '1980', '1265', '1717', '800', '515', '2450', '1972', '1172', '1497', '2709', '1263', '1716', '2283', '18', '3309', '2434', '395', '822', '1102', '576', '1238', '4359', '556', '2833', '1262', '146', '19', '2774', '1190', '2869', '836', '308', '1240', '2809', '613', '713', '3158', '2198', '1631', '1093', '583', '2769', '2452', '333', '1067', '625', '1901', '113', '3181', '3146', '2477', '3222', '478', '1645', '3092', '3311', '296', '986', '2170', '2858', '3002', '791', '1378', '3279', '285', '675', '2280', '1283', '4352', '1614', '2474', '2899', '51', '170', '2290', '1627', '633', '1123', '2343', '1939', '2957', '2351', '1992', '2935', '1468', '3229', '639', '1203', '4335', '696', '592', '1301', '1465', '2917', '3155', '3246', '267', '33', '1373', '2724', '3273', '1647', '2789', '3175', '2336', '3137', '2008', '2737', '2914', '1164', '2778', '2775', '2928', '2832', '2165', '892', '850', '1638', '3103', '839', '3178', '1889', '232', '197', '2786', '327', '2096', '212', '257', '1009', '4116', '2825', '831', '2359', '1006', '281', '240', '1681', '3100', '4346', '2174', '3111', '50', '103', '1415', '15', '2810', '706', '623', '225', '2996', '512', '4323', '520', '470', '228', '1481', '1255', '572', '1077', '603', '1350', '4324', '4232', '467', '2812', '2613', '1563', '4355', '3244', '130', '351', '2977', '2428', '1030', '595', '1432', '2807', '239', '3266', '263', '1970', '2904', '311', '799', '3162', '227', '2393', '2435', '4148', '550', '4255', '1941', '4543', '2286', '642', '1441', '590', '1529', '562', '1131', '3251', '1925', '1896', '3118', '4357', '280', '977', '3086', '14', '1161', '1341', '3242', '1892', '610', '2090', '2970', '2330', '1291', '2852', '2074', '1044', '1637', '2287', '552', '1905', '2964', '86', '2024', '128', '4131', '1919', '4297', '817', '1032', '36', '1372', '1176', '2378', '2885', '1087', '2406', '465', '1040', '2460', '4267', '1005', '2513', '2134', '1400', '2032', '2952', '1642', '4122', '577', '2999', '292', '93', '2864', '4020', '4351', '536', '3207', '588', '1484', '3180', '1200', '2984', '1062', '2373', '300', '2966', '1720', '1472', '473', '2270', '1423', '358', '1332', '1361', '1964', '792', '335', '1938', '2301', '4248', '2811', '21', '369', '2894', '1037', '1697', '2873', '3191', '2944', '834', '611', '1401', '1078', '965', '4361', '2445', '2567', '1612', '1975', '2358', '1713', '3223', '2103', '2317', '3269', '4016', '5218', '667', '1410', '2144', '2462', '184', '1275', '328', '2608', '2322', '4532', '1091', '2962', '811', '3144', '2003', '3021', '1639', '78', '273', '2576', '1477', '582', '684', '665', '686', '886', '3012', '1008', '4202', '1167', '2179', '3135', '4523', '803', '134', '390', '648', '3125', '138', '2816', '2337', '2784', '549', '697', '1963', '2867', '777', '575', '153', '163', '148', '3138', '571', '3004', '1700', '628', '2266', '1356', '516', '1104', '359', '366', '1374', '2150', '34', '291', '1331', '3220', '4235', '450', '787', '2851', '302', '2880', '1290', '2568', '2901', '1945', '463', '2030', '840', '2756', '45', '545', '2284', '2754', '89', '1943', '2456', '503', '118', '2004', '4521', '1202', '593', '808', '1485', '3310', '493', '1478', '2278', '641', '274', '1988', '1623', '457', '653', '1298', '1931', '2820', '4252', '4538', '979', '1944', '1017', '4520', '1004', '73', '1696', '1930', '643', '1335', '2745', '3128', '2829', '1962', '1108', '1500', '3024', '1632', '264', '4015', '2681', '4216', '785', '1223', '2402', '320', '2258', '2282', '2385', '1249', '3119', '904', '164', '4143', '1558', '2328', '1024', '2331', '3234', '2847', '2308', '4386', '2632', '3017', '485', '1300', '497', '903', '2988', '2027', '4199', '983', '3320', '47', '272', '614', '3101', '1644', '1266', '2447', '1438', '2855', '2834', '1928', '2747', '2190', '396', '486', '4354', '1897', '591', '301', '2195', '4119', '1179', '1068', '1050', '265', '120', '2929', '2790', '1999', '309', '3197', '150', '2171', '2409', '968', '2256', '864', '4536', '3098', '4541', '2738', '1360', '647', '1990', '3231', '330', '4229', '2600', '2319', '1403', '152', '1088', '1991', '1033', '1003', '1327', '182', '775', '102', '2721', '690', '4316', '2524', '547', '2875', '62', '2866', '829', '574', '237', '1714', '502', '620', '567', '4321', '161', '2922', '3153', '1198', '597', '554', '2210', '1455', '3315', '2016', '329', '4318', '1740', '270', '2951', '805', '2354', '2748', '564', '2746', '1680', '4194', '1625', '1351', '1966', '634', '4170', '2446', '1201', '1550', '3113', '2138', '2159', '585', '4008', '598', '2396', '830', '3214', '495', '2139', '2407', '3265', '459', '1207', '1173', '3321', '149', '569', '287', '331', '2360', '132', '3161', '1154', '1010', '421', '1342', '657', '1615', '2327', '269', '2326', '1060', '58', '2954', '823', '2860', '1181', '551', '1640', '1567', '2538', '3228', '4528', '973', '3046', '114', '3157', '180', '4385', '4339', '471', '2201', '221', '2180', '4540', '677', '2887', '1702', '2388', '3250', '2095', '3114', '4144', '602', '1169', '1994', '3150', '969', '780', '1444', '91', '2156', '3224', '1920', '2916', '492', '2561', '500', '638', '3328', '786', '1321', '142', '1357', '581', '809', '1165', '635', '2335', '1235', '2731', '3001', '1967', '2352', '584', '1568', '2137', '578', '4241', '678', '2389', '469', '10', '201', '1532', '1247', '4007', '107', '2861', '443', '4534', '352', '1688', '779', '2069', '1250', '2736', '3355', '356', '2563', '2993', '3243', '2425', '312', '807', '1285', '2299', '1891', '2838', '119', '361', '2264', '4525', '1948', '3239', '1322', '1346', '558', '123', '2859', '1719', '1122', '2741', '4368', '1061', '2925', '343', '2311', '853', '510', '2372', '1132', '504', '615', '2821', '5217', '951', '1620', '2932', '1191', '2', '1027', '2879', '166', '4320', '1277', '778', '2422', '494', '2129', '2883', '1565', '1499', '2455', '3131', '1117', '1957', '952', '1192', '1174', '1505', '1260', '1978', '4535', '1502', '288', '3301', '490', '3249', '4519', '147', '2017', '975', '1367', '2730', '2892', '2713', '3255', '2814', '2433', '4419', '464', '631', '4289', '1537', '2192', '410', '2727', '609', '117', '4001', '3019', '507', '2788', '3141', '619', '2943', '4306', '282', '1471', '474', '2002', '2300', '2556', '5219', '3032', '3209', '1718', '2947', '3006', '477', '570', '1269', '1228', '85', '2729', '1086', '1893', '5066', '29', '1487', '682', '854', '1135', '299', '2758', '1188', '74', '2367', '3167', '1194', '2465', '3121', '49', '632', '1987', '856', '4388', '1459', '245', '2532', '289', '626', '234', '3096', '630', '3116', '155', '2163', '543', '1734', '2764', '1580', '2028', '476', '605', '372', '2421', '2166', '298', '1358', '59', '518', '3245', '145', '2936', '2535', '1002', '122', '900', '3338', '158', '1083', '4', '2982', '522', '3020', '1493', '2630', '660', '1026', '1617', '1205', '2408', '3156', '4111', '579', '3252', '3045', '101', '2261', '1139', '857', '243', '208', '506', '852', '1592', '2900', '509', '3095', '1703', '1480', '235', '4546', '2844', '2036', '1452', '462', '1157', '1052', '967', '3022', '190', '2018', '236', '1634', '2320', '970', '95', '3225', '3200', '4254', '2088', '2979', '1054', '1345', '350', '4218', '110', '397', '3216', '60', '3009', '1997', '223', '2941', '338', '537', '2312', '4356', '1440', '2459', '1486', '211', '629', '869', '3332', '1635', '1370', '2167', '314', '2976', '3136', '2965', '1507', '2933', '3010', '1434', '895', '1479', '1076', '1407', '2711', '4347', '1206', '1412', '1114', '428', '1340', '2732', '1935', '2796', '2135', '1977', '1622', '4522', '1926', '3151', '1109', '2792', '318', '3281', '1958', '1189', '1613', '4013', '2376', '1995', '3097', '5214', '2323', '2798', '508', '2840', '2182', '978', '2034', '1474', '461', '2841', '2414', '4113', '2365', '3241', '3256', '2000', '1986', '2902', '316', '1450', '2961', '2913', '3308', '2912', '3042', '1183', '1177', '1292', '3238', '2164', '2160', '203', '1417', '3147', '1245', '4342', '2896', '2254', '39', '393', '498', '4236', '2570', '2889', '1184', '3133', '4300', '3003', '1982', '1453', '1343', '1128', '2341', '189', '1140', '2830', '845', '3248', '2782', '1616', '544', '1894', '1606', '2903', '2263', '2011', '17', '1706', '2827', '519', '1446', '4174', '151', '1898', '3014', '2449', '136', '1461', '3043', '2356', '1041', '1710', '2022', '6', '1424', '2971', '2102', '268', '3165', '2469', '200', '2918', '2994', '4198', '841', '2881', '3154', '993', '1519', '279', '998', '1430', '479', '4149', '1308', '1110', '370', '939', '2005', '398', '1522', '1020', '4533', '2001', '496', '3139', '1985', '1220', '1447', '1101', '530', '4296', '277', '4328', '3227', '3018', '1726', '801', '2739', '1690', '2822', '3127', '1712', '1954', '4322', '1155', '1610', '565', '2911', '3324', '2751', '3171', '607', '715', '1013', '2130', '2983', '2924', '833', '622', '2338', '1057', '1526', '1180', '1070', '468', '2386', '213', '82', '2298', '1408', '1419', '3166', '181', '2557', '2997', '802', '936', '1120', '325', '646', '566', '3126', '156', '2805', '3102', '3173', '3226', '1426', '1036', '1552', '2878', '5220', '3193', '2255', '3132', '1534', '3211', '2133', '1273', '1311', '3163', '1281', '649', '1369', '2401', '2850', '818', '3112', '3212', '1115', '1022', '4014', '2898', '2876', '1445', '1069', '2253', '539', '2633', '169', '1082', '2546', '1969', '125', '1621', '1900', '3159', '676', '192', '76', '3257', '3011', '489', '1334', '1048', '3034', '596', '2978', '1989', '1708', '2296', '4258', '1377', '1012', '176', '135', '160', '703', '140', '3188', '3105', '1937', '2432', '2797', '1953', '2804', '563', '624', '3005', '505', '534', '942', '334', '216', '1405', '3271', '2208', '2749', '1339', '2305', '4133', '2162', '371', '2926', '3194', '28', '1253', '1075', '2848', '2315', '870', '283', '2345', '2307', '357', '2370', '1344', '2895', '3013', '1094', '2969', '2329', '2743', '580', '943', '11', '1414', '1435', '1362', '3123', '2975', '303', '3160', '2733', '3164', '2262', '3030', '249', '1546', '521', '1142', '2946', '1608', '207', '3124', '1239', '944', '513', '323', '2998', '4527', '2826', '511', '159', '2846', '98', '621', '851', '1973', '3145', '2919', '617', '1490', '774', '1467', '3213', '179', '1705', '1959', '2279', '1460', '3186', '5216', '546', '3174', '941', '487', '297', '1214', '27', '4309', '2735', '1231', '475', '3340', '2806', '1923', '3087', '2288', '2791', '526', '2247', '1458', '1514', '4192', '640', '2466', '244', '3205', '548', '793', '80', '3110', '1724', '2828', '2306', '1186', '1965', '859', '2013', '1359', '2728', '2014', '191', '2259', '2026', '3120', '3142', '31', '2177', '247', '2094', '2525', '1940', '1433', '606', '185', '670', '2877', '1210', '2634', '3254', '2740', '1107', '304', '3140', '3168', '945', '1330', '1463', '1736', '1212', '290', '1633', '1947', '3015', '1038', '175', '3008', '3195', '2596', '707', '2346', '3184', '2863', '1148', '2843', '4341', '1237', '2348', '2818', '501', '127', '1951', '906', '2905', '2986', '2420', '2091', '4114', '4140', '3023', '472', '1448', '2260', '187', '3325', '1956', '2514', '3259', '2555', '1437', '1018', '480', '4224', '700', '242', '488', '87', '2405', '1683', '96', '3152', '3237', '3314', '1664', '561', '2169', '139', '26', '2884', '2888', '319', '1375', '1236', '2891', '1475', '1470', '430', '1196', '2362', '12', '3169', '1462', '2631', '2770', '1949', '2580', '317', '1118', '1014', '773', '42', '2551', '557', '188', '1324', '381', '1722', '1195', '860', '1271', '2886', '2865', '1337', '1628', '1416', '3185', '1626', '1328', '2824', '144', '63', '4006', '3233', '1727', '2910', '3130', '3198', '2251', '337', '2438', '2398', '1021', '2635', '1495', '3258', '394', '618', '1952', '1942', '2882', '2548', '3026', '133', '636', '1998', '2252', '789', '67', '587', '2723', '2403', '1960', '2472', '1007', '2771', '2808', '568', '2920', '797', '198', '3235', '1182', '4418', '38', '4004', '4282', '2985', '4011', '2475', '2297', '2194', '1619', '1159', '1715', '2874', '1439', '2191', '2473', '2154', '796', '3192', '248', '3134', '458', '3230', '781', '4417', '2712', '3302', '310', '2418', '4273', '2870', '3201', '3196', '798', '79', '2785', '1494', '2025', '788', '701', '1723', '3149', '560', '2842', '2457', '992', '3031', '645', '794', '1996', '454', '195', '2476', '2725', '2980', '1473', '2410', '2921', '143', '1483', '233', '1199', '71', '2974', '1366', '674', '3179', '1193', '1454', '2015', '1492', '2562', '4009', '933', '3099', '3177', '3307', '938', '1981', '1908', '238', '1489', '2379', '2521', '1890', '608', '2021', '2395', '3253', '173', '2744', '3203', '4313', '348', '347', '1420', '4530', '3106', '1092', '1698', '532', '1950', '111', '2987', '3093', '3117', '4205', '858', '4163', '2304', '336', '353', '4526', '1488', '990', '4529', '953', '1056', '258', '3217', '2963', '928', '183', '1163', '3304', '1704', '2714', '2534', '586', '2276', '399', '2849', '663', '3206', '529', '2161', '3041', '94', '2854', '1178', '2776', '1299', '2722', '514', '2382', '3028', '40', '960', '553', '2010', '2265', '5', '1482', '2981', '804', '2597', '2031', '4319', '1643', '2158', '2147', '1136', '2773', '4290', '531', '782', '3187', '956', '1436', '1630', '790', '3247', '795', '1968', '3240', '1354', '2012', '2780', '1130', '1209', '168', '349', '542', '4415', '1353', '2823', '460', '971', '1611', '2193', '2753', '1429', '3183', '699', '3221', '693', '1503', '1976', '1431', '2716', '3016', '3115', '1011', '4343', '2196', '126', '1496', '972', '141', '1575', '2815', '3172', '4363', '2856', '1903']\n" + "/ec/vol/destine-hydro/OBSERVATIONS/outlets_v4.0_20230726_withEFAS.csv\n", + "{'i05j': \n", + "Dimensions: (time: 10228, station: 6820)\n", + "Coordinates:\n", + " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", + " * station (station) object 'G0001' 'G0002' 'G0003' ... 'G10162' 'G10163'\n", + "Data variables:\n", + " dis (time, station) float32 ..., 'i05h': \n", + "Dimensions: (time: 10228, station: 6237)\n", + "Coordinates:\n", + " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", + " * station (station) object 'G0001' 'G0002' 'G0003' ... 'G10162' 'G10163'\n", + "Data variables:\n", + " dis (time, station) float32 ...}\n", + "['G10162']\n", + " version__origin__name version__version station_id station_id_num EFAS-ID \n", + "6929 GLOFAS 11 G10162 10162 5389 \\\n", + "\n", + " EC_calib5k EC_calib StationName StationLon StationLat ... \n", + "6929 -9999 0 Aforo en Formentera -0.743557 38.0835467 ... \\\n", + "\n", + " CamaLat1_15min CamaLon1_15min CamaArea_15min \n", + "6929 38.125 -0.875 14817.6 \\\n", + "\n", + " EfasCamaVerif_Eccalib_500km2_1year24H \n", + "6929 \\\n", + "\n", + " EfasCamaVerif_Eccalib_500km2_1year24H_2017+ EfasCamaVerif500km2_1year24H \n", + "6929 \\\n", + "\n", + " EFAS_Duplicate geometry x y \n", + "6929 POINT (-0.74356 38.08355) -0.743557 38.083547 \n", + "\n", + "[1 rows x 115 columns]\n", + "\n", + "Dimensions: (station: 1, time: 10228)\n", + "Coordinates:\n", + " * station (station) object 'G10162'\n", + " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", + "Data variables:\n", + " obsdis (time, station) float32 ...\n", + "{'i05j': \n", + "Dimensions: (time: 10228, station: 1)\n", + "Coordinates:\n", + " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", + " * station (station) object 'G10162'\n", + "Data variables:\n", + " dis (time, station) float32 ..., 'i05h': \n", + "Dimensions: (time: 10228, station: 1)\n", + "Coordinates:\n", + " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", + " * station (station) object 'G10162'\n", + "Data variables:\n", + " dis (time, station) float32 ...}\n", + "['G10162']\n", + "\n", + "[10228 values with dtype=float32]\n", + "Coordinates:\n", + " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", + " * station (station) object 'G10162'\n", + "\n", + "array([[nan],\n", + " [nan],\n", + " [nan],\n", + " ...,\n", + " [nan],\n", + " [nan],\n", + " [nan]], dtype=float32)\n", + "Coordinates:\n", + " * station (station) object 'G10162'\n", + " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", + "Attributes:\n", + " units: m3/s\n", + " long_name: observed discharge\n" ] }, { - "ename": "ValueError", - "evalue": "('Lengths must match to compare', (3956,), (1909,))", + "ename": "ModuleNotFoundError", + "evalue": "No module named 'np'", "output_type": "error", "traceback": [ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", - "\u001b[1;32m/home/dadiyorto/freelance/02_ort_ecmwf/dev/hat/notebooks/examples/4_visualisation_interactive.ipynb Cell 4\u001b[0m line \u001b[0;36m1\n\u001b[0;32m----> 1\u001b[0m \u001b[39mmap\u001b[39m \u001b[39m=\u001b[39m NotebookMap(config, stations, observations, simulations, stats\u001b[39m=\u001b[39;49mstatistics)\n", - "File \u001b[0;32m~/freelance/02_ort_ecmwf/dev/hat/hat/visualisation.py:112\u001b[0m, in \u001b[0;36mNotebookMap.__init__\u001b[0;34m(self, config, stations_metadata, observations, simulations, stats)\u001b[0m\n\u001b[1;32m 110\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mcommon_id \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mfind_common_station()\n\u001b[1;32m 111\u001b[0m \u001b[39mprint\u001b[39m(\u001b[39mself\u001b[39m\u001b[39m.\u001b[39mcommon_id)\n\u001b[0;32m--> 112\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mstations_metadata\u001b[39m.\u001b[39mloc[\u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49mstations_metadata[\u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49mstation_index] \u001b[39m==\u001b[39;49m \u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49mcommon_id]\n\u001b[1;32m 113\u001b[0m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mobs_ds \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mobs_ds\u001b[39m.\u001b[39msel(station \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39mcommon_id)\n\u001b[1;32m 114\u001b[0m \u001b[39mfor\u001b[39;00m sim, ds \u001b[39min\u001b[39;00m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39msim_ds\u001b[39m.\u001b[39mitems():\n", - "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/pandas/core/ops/common.py:76\u001b[0m, in \u001b[0;36m_unpack_zerodim_and_defer..new_method\u001b[0;34m(self, other)\u001b[0m\n\u001b[1;32m 72\u001b[0m \u001b[39mreturn\u001b[39;00m \u001b[39mNotImplemented\u001b[39m\n\u001b[1;32m 74\u001b[0m other \u001b[39m=\u001b[39m item_from_zerodim(other)\n\u001b[0;32m---> 76\u001b[0m \u001b[39mreturn\u001b[39;00m method(\u001b[39mself\u001b[39;49m, other)\n", - "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/pandas/core/arraylike.py:40\u001b[0m, in \u001b[0;36mOpsMixin.__eq__\u001b[0;34m(self, other)\u001b[0m\n\u001b[1;32m 38\u001b[0m \u001b[39m@unpack_zerodim_and_defer\u001b[39m(\u001b[39m\"\u001b[39m\u001b[39m__eq__\u001b[39m\u001b[39m\"\u001b[39m)\n\u001b[1;32m 39\u001b[0m \u001b[39mdef\u001b[39;00m \u001b[39m__eq__\u001b[39m(\u001b[39mself\u001b[39m, other):\n\u001b[0;32m---> 40\u001b[0m \u001b[39mreturn\u001b[39;00m \u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49m_cmp_method(other, operator\u001b[39m.\u001b[39;49meq)\n", - "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/pandas/core/series.py:5799\u001b[0m, in \u001b[0;36mSeries._cmp_method\u001b[0;34m(self, other, op)\u001b[0m\n\u001b[1;32m 5796\u001b[0m lvalues \u001b[39m=\u001b[39m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39m_values\n\u001b[1;32m 5797\u001b[0m rvalues \u001b[39m=\u001b[39m extract_array(other, extract_numpy\u001b[39m=\u001b[39m\u001b[39mTrue\u001b[39;00m, extract_range\u001b[39m=\u001b[39m\u001b[39mTrue\u001b[39;00m)\n\u001b[0;32m-> 5799\u001b[0m res_values \u001b[39m=\u001b[39m ops\u001b[39m.\u001b[39;49mcomparison_op(lvalues, rvalues, op)\n\u001b[1;32m 5801\u001b[0m \u001b[39mreturn\u001b[39;00m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39m_construct_result(res_values, name\u001b[39m=\u001b[39mres_name)\n", - "File \u001b[0;32m~/miniconda3/envs/hat-dev/lib/python3.10/site-packages/pandas/core/ops/array_ops.py:323\u001b[0m, in \u001b[0;36mcomparison_op\u001b[0;34m(left, right, op)\u001b[0m\n\u001b[1;32m 318\u001b[0m \u001b[39mif\u001b[39;00m \u001b[39misinstance\u001b[39m(rvalues, (np\u001b[39m.\u001b[39mndarray, ABCExtensionArray)):\n\u001b[1;32m 319\u001b[0m \u001b[39m# TODO: make this treatment consistent across ops and classes.\u001b[39;00m\n\u001b[1;32m 320\u001b[0m \u001b[39m# We are not catching all listlikes here (e.g. frozenset, tuple)\u001b[39;00m\n\u001b[1;32m 321\u001b[0m \u001b[39m# The ambiguous case is object-dtype. See GH#27803\u001b[39;00m\n\u001b[1;32m 322\u001b[0m \u001b[39mif\u001b[39;00m \u001b[39mlen\u001b[39m(lvalues) \u001b[39m!=\u001b[39m \u001b[39mlen\u001b[39m(rvalues):\n\u001b[0;32m--> 323\u001b[0m \u001b[39mraise\u001b[39;00m \u001b[39mValueError\u001b[39;00m(\n\u001b[1;32m 324\u001b[0m \u001b[39m\"\u001b[39m\u001b[39mLengths must match to compare\u001b[39m\u001b[39m\"\u001b[39m, lvalues\u001b[39m.\u001b[39mshape, rvalues\u001b[39m.\u001b[39mshape\n\u001b[1;32m 325\u001b[0m )\n\u001b[1;32m 327\u001b[0m \u001b[39mif\u001b[39;00m should_extension_dispatch(lvalues, rvalues) \u001b[39mor\u001b[39;00m (\n\u001b[1;32m 328\u001b[0m (\u001b[39misinstance\u001b[39m(rvalues, (Timedelta, BaseOffset, Timestamp)) \u001b[39mor\u001b[39;00m right \u001b[39mis\u001b[39;00m NaT)\n\u001b[1;32m 329\u001b[0m \u001b[39mand\u001b[39;00m lvalues\u001b[39m.\u001b[39mdtype \u001b[39m!=\u001b[39m \u001b[39mobject\u001b[39m\n\u001b[1;32m 330\u001b[0m ):\n\u001b[1;32m 331\u001b[0m \u001b[39m# Call the method on lvalues\u001b[39;00m\n\u001b[1;32m 332\u001b[0m res_values \u001b[39m=\u001b[39m op(lvalues, rvalues)\n", - "\u001b[0;31mValueError\u001b[0m: ('Lengths must match to compare', (3956,), (1909,))" + "\u001b[0;31mModuleNotFoundError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[4], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[38;5;28mmap\u001b[39m \u001b[38;5;241m=\u001b[39m \u001b[43mNotebookMap\u001b[49m\u001b[43m(\u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mstations\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mobservations\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43msimulations\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mstats\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mstatistics\u001b[49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m/etc/ecmwf/nfs/dh1_home_a/macw/git/hat/hat/visualisation.py:126\u001b[0m, in \u001b[0;36mNotebookMap.__init__\u001b[0;34m(self, config, stations_metadata, observations, simulations, stats)\u001b[0m\n\u001b[1;32m 124\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstatistics \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mNone\u001b[39;00m\n\u001b[1;32m 125\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstats:\n\u001b[0;32m--> 126\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstatistics \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mcalculate_statistics\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 127\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstatistics_output \u001b[38;5;241m=\u001b[39m Output()\n", + "File \u001b[0;32m/etc/ecmwf/nfs/dh1_home_a/macw/git/hat/hat/visualisation.py:206\u001b[0m, in \u001b[0;36mNotebookMap.calculate_statistics\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 204\u001b[0m \u001b[38;5;66;03m# Dictionary of simulation datasets\u001b[39;00m\n\u001b[1;32m 205\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m exp, ds \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39msim_ds\u001b[38;5;241m.\u001b[39mitems():\n\u001b[0;32m--> 206\u001b[0m sim_ds_f, obs_ds_f \u001b[38;5;241m=\u001b[39m \u001b[43mfilter_timeseries\u001b[49m\u001b[43m(\u001b[49m\u001b[43mds\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mdis\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mobs_ds\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mthreshold\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 207\u001b[0m statistics[exp] \u001b[38;5;241m=\u001b[39m run_analysis(\u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstats, sim_ds_f, obs_ds_f)\n\u001b[1;32m 208\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m statistics\n", + "File \u001b[0;32m/etc/ecmwf/nfs/dh1_home_a/macw/git/hat/hat/filters.py:175\u001b[0m, in \u001b[0;36mfilter_timeseries\u001b[0;34m(sims_ds, obs_ds, threshold)\u001b[0m\n\u001b[1;32m 173\u001b[0m \u001b[38;5;28mprint\u001b[39m(sims_ds)\n\u001b[1;32m 174\u001b[0m \u001b[38;5;28mprint\u001b[39m(dis)\n\u001b[0;32m--> 175\u001b[0m \u001b[38;5;28;01mimport\u001b[39;00m \u001b[38;5;21;01mnp\u001b[39;00m\n\u001b[1;32m 176\u001b[0m \u001b[38;5;28mprint\u001b[39m(dis\u001b[38;5;241m.\u001b[39mvalues[np\u001b[38;5;241m.\u001b[39misfinite(dis\u001b[38;5;241m.\u001b[39mvalues)])\n\u001b[1;32m 178\u001b[0m \u001b[38;5;66;03m# Replace negative values with NaN\u001b[39;00m\n", + "\u001b[0;31mModuleNotFoundError\u001b[0m: No module named 'np'" ] } ], @@ -92,11 +150,25 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 2, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "ename": "AttributeError", + "evalue": "type object 'map' has no attribute 'mapplot'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[2], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[38;5;28;43mmap\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mmapplot\u001b[49m(colorby\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mkge\u001b[39m\u001b[38;5;124m'\u001b[39m, sim\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mi05j\u001b[39m\u001b[38;5;124m'\u001b[39m)\n", + "\u001b[0;31mAttributeError\u001b[0m: type object 'map' has no attribute 'mapplot'" + ] + } + ], "source": [ - "map.mapplot(colorby='kge', sim='exp1')" + "map.mapplot(colorby='kge', sim='i05j')" ] }, { @@ -115,12 +187,12 @@ "metadata": {}, "outputs": [], "source": [ - "map.station_metadata\n" + "map.stations_metadata\n" ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 5, "metadata": {}, "outputs": [ { @@ -133,7 +205,7 @@ } ], "source": [ - "from hat.visualisation import NotebookMap as nbm\n", + "from hat.visualisation_v2 import NotebookMap as nbm\n", "stations= '~/destinE/outlets_v4.0_20230726_withEFAS.csv'\n", "observations= '~/destinE/station_attributes_with_obsdis06h_197001-202312_20230726_withEFAS.nc'\n", "\n", @@ -228,9 +300,9 @@ ], "metadata": { "kernelspec": { - "display_name": "hat-dev", + "display_name": "conda_hat", "language": "python", - "name": "python3" + "name": "conda_hat" }, "language_info": { "codemirror_mode": { @@ -242,10 +314,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" - }, - "orig_nbformat": 4 + "version": "3.10.11" + } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } From d302609563fee45b8de234c44bc36c8a9fc40553 Mon Sep 17 00:00:00 2001 From: Corentin Carton De Wiart Date: Tue, 10 Oct 2023 21:02:19 +0000 Subject: [PATCH 08/31] debug notebook --- hat/data.py | 4 +- hat/filters.py | 33 +- hat/hydrostats.py | 35 +- hat/hydrostats_decorators.py | 2 +- hat/tools/hydrostats_cli.py | 10 +- hat/visualisation.py | 75 +- .../4_visualisation_interactive.ipynb | 1443 ++++++++++++++++- 7 files changed, 1458 insertions(+), 144 deletions(-) diff --git a/hat/data.py b/hat/data.py index 643b0d9..d1ff6e0 100644 --- a/hat/data.py +++ b/hat/data.py @@ -260,8 +260,8 @@ def save_dataset_to_netcdf(ds: xr.Dataset, fpath: str): ds.to_netcdf(fpath) -def find_main_var(ds): - variable_names = [k for k in ds.variables if len(ds.variables[k].dims) >= 3] +def find_main_var(ds, min_dim=3): + variable_names = [k for k in ds.variables if len(ds.variables[k].dims) >= min_dim] if len(variable_names) > 1: raise Exception("More than one variable in dataset") elif len(variable_names) == 0: diff --git a/hat/filters.py b/hat/filters.py index b38953e..cae74c8 100644 --- a/hat/filters.py +++ b/hat/filters.py @@ -144,7 +144,7 @@ def filter_dataframe(df, filters: str): return df -def filter_timeseries(sims_ds: xr.Dataset, obs_ds: xr.Dataset, threshold=80): +def filter_timeseries(sims_ds: xr.DataArray, obs_ds: xr.DataArray, threshold=80): """Clean the simulation and observation timeseries Only keep.. @@ -159,35 +159,36 @@ def filter_timeseries(sims_ds: xr.Dataset, obs_ds: xr.Dataset, threshold=80): matching_stations = sorted( set(sims_ds.station.values).intersection(obs_ds.station.values) ) - print(matching_stations) + print(len(matching_stations)) sims_ds = sims_ds.sel(station=matching_stations) obs_ds = obs_ds.sel(station=matching_stations) + obs_ds = obs_ds.sel(time=sims_ds.time) + + obs_ds = obs_ds.dropna(dim='station', how='all') + sims_ds = sims_ds.sel(station=obs_ds.station) # Only keep observations in the same time period as the simulations - obs_ds = obs_ds.where(sims_ds.time == obs_ds.time, drop=True) + # obs_ds = obs_ds.where(sims_ds.time == obs_ds.time, drop=True) # Only keep obsevations with enough valid data in this timeperiod # discharge data - dis = obs_ds.obsdis print(sims_ds) - print(dis) - import numpy as np - print(dis.values[np.isfinite(dis.values)]) + print(obs_ds) # Replace negative values with NaN - dis = dis.where(dis >= 0) + # dis = dis.where(dis >= 0) - # Percentage of valid discharge data at each point in time - valid_percent = dis.notnull().mean(dim="time") * 100 + # # Percentage of valid discharge data at each point in time + # valid_percent = dis.notnull().mean(dim="time") * 100 - # Boolean index of where there is enough valid data - enough_observation_data = valid_percent > threshold + # # Boolean index of where there is enough valid data + # enough_observation_data = valid_percent > threshold - # keep where there is enough observation data - obs_ds = obs_ds.where(enough_observation_data, drop=True) + # # keep where there is enough observation data + # obs_ds = obs_ds.where(enough_observation_data, drop=True) - # keep simulation that match remaining observations - sims_ds = sims_ds.where(enough_observation_data, drop=True) + # # keep simulation that match remaining observations + # sims_ds = sims_ds.where(enough_observation_data, drop=True) return (sims_ds, obs_ds) diff --git a/hat/hydrostats.py b/hat/hydrostats.py index d03c3fe..67cc14b 100644 --- a/hat/hydrostats.py +++ b/hat/hydrostats.py @@ -4,6 +4,7 @@ import folium import geopandas as gpd import pandas as pd +import numpy as np import xarray as xr from branca.colormap import linear from folium.plugins import Fullscreen @@ -13,18 +14,15 @@ def run_analysis( functions: List, - sims_ds: xr.Dataset, - obs_ds: xr.Dataset, - sims_var_name="simulation_timeseries", - obs_var_name="obsdis", + sims_ds: xr.DataArray, + obs_ds: xr.DataArray, ) -> xr.Dataset: """Run statistical analysis on simulation and observation timeseries""" # list of stations stations = sims_ds.coords["station"].values - # Create an empty DataFrame with stations as the index - df = pd.DataFrame(index=stations) + ds = xr.Dataset() # For each statistical function for name in functions: @@ -33,23 +31,20 @@ def run_analysis( # do timeseries analysis for each station # (using a "numpy in, numpy out" function) - statistics = {} + statistics = [] for station in stations: - sims = sims_ds.sel(station=station)[sims_var_name].to_numpy() - obs = obs_ds.sel(station=station)[obs_var_name].to_numpy() - statistics[station] = func(sims, obs) - - # 1D Series of statistics (i.e. scalar value per station) - statistics_series = pd.Series(statistics, name=name) + sims = sims_ds.sel(station=station).to_numpy() + obs = obs_ds.sel(station=station).to_numpy() + + stat = func(sims, obs) + if stat is None: + print(f"Warning! All NaNs for station {station}") + stat = 0 + statistics += [stat] + statistics = np.array(statistics) # Add the Series to the DataFrame - df[name] = statistics_series - - # Convert the DataFrame to an xarray Dataset - ds = df.to_xarray() - ds = ds.rename({"index": "station"}) - ds["longitude"] = ("station", sims_ds.coords["longitude"].data) - ds["latitude"] = ("station", sims_ds.coords["latitude"].data) + ds[name] = xr.DataArray(statistics, coords={'station': stations}) return ds diff --git a/hat/hydrostats_decorators.py b/hat/hydrostats_decorators.py index cfe6817..e0a219a 100644 --- a/hat/hydrostats_decorators.py +++ b/hat/hydrostats_decorators.py @@ -49,7 +49,7 @@ def wrapper(arr1: np.ndarray, arr2: np.ndarray): nan_mask2 = np.isnan(arr2) filtered_mask = ~(nan_mask1 | nan_mask2) if not np.any(filtered_mask): - raise ValueError("All elements are NaN") + return None return func(arr1[filtered_mask], arr2[filtered_mask]) return wrapper diff --git a/hat/tools/hydrostats_cli.py b/hat/tools/hydrostats_cli.py index 5019b9c..7aae11f 100644 --- a/hat/tools/hydrostats_cli.py +++ b/hat/tools/hydrostats_cli.py @@ -7,6 +7,7 @@ from hat.exceptions import UserError from hat.filters import filter_timeseries from hat.hydrostats import run_analysis +from hat.data import find_main_var def check_inputs(functions, sims, obs): @@ -79,17 +80,22 @@ def hydrostats_cli( if not functions: return + # simulations sims_ds = xr.open_dataset(sims) + var = find_main_var(sims_ds, min_dim=2) + sims_da = sims_ds[var] # observations obs_ds = xr.open_dataset(obs) + var = find_main_var(obs_ds, min_dim=2) + obs_da = obs_ds[var] # clean timeseries - sims_ds, obs = filter_timeseries(sims_ds, obs_ds, threshold=obs_threshold) + sims_da, obs_da = filter_timeseries(sims_da, obs_da, threshold=obs_threshold) # calculate statistics - statistics_ds = run_analysis(functions, sims_ds, obs_ds) + statistics_ds = run_analysis(functions, sims_da, obs_da) # save to netcdf statistics_ds.to_netcdf(outpath) diff --git a/hat/visualisation.py b/hat/visualisation.py index 3c53c6a..de7ea78 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -29,9 +29,9 @@ class IPyLeaflet: """Visualization class for interactive map with IPyLeaflet and Plotly.""" - def __init__(self, center_lat: float, center_lon: float): + def __init__(self): # Initialize the map widget - self.map = Map(center=[center_lat, center_lon], zoom=5, layout=Layout(width='500px', height='500px')) + self.map = Map(zoom=5, layout=Layout(width='500px', height='500px')) pd.set_option('display.max_colwidth', None) @@ -107,8 +107,16 @@ def __init__(self, config: Dict, stations_metadata: str, observations: str, simu self.sim_ds = self.prepare_simulations_data(simulations) print(self.sim_ds) self.obs_ds = self.prepare_observations_data() + + self.statistics = {} + if stats: + for name, path in stats.items(): + self.statistics[name] = xr.open_dataset(path) + + assert self.statistics.keys() == self.sim_ds.keys() + common_id = self.find_common_station() - print(common_id) + print(len(common_id)) self.stations_metadata = self.stations_metadata.loc[self.stations_metadata[self.station_index].isin(common_id)] self.obs_ds = self.obs_ds.sel(station = common_id) for sim, ds in self.sim_ds.items(): @@ -118,12 +126,6 @@ def __init__(self, config: Dict, stations_metadata: str, observations: str, simu print(self.obs_ds) print(self.sim_ds) - self.obs_ds = self.obs_ds.sel(station=common_id) - self.stats = stats - self.threshold = config['stats_threshold'] #to be opt - self.statistics = None - if self.stats: - self.statistics = self.calculate_statistics() self.statistics_output = Output() def prepare_simulations_data(self, simulations): @@ -184,12 +186,13 @@ def prepare_observations_data(self): obs_ds = obs_ds.sel(time=time_values) return obs_ds - def find_common_station(self): ids = [] ids += [list(self.obs_ds['station'].values)] ids += [list(ds['station'].values) for ds in self.sim_ds.values()] ids += [self.stations_metadata[self.station_index]] + if self.statistics: + ids += [list(ds['station'].values) for ds in self.statistics.values()] common_ids = None for id in ids: @@ -203,7 +206,9 @@ def calculate_statistics(self): statistics = {} # Dictionary of simulation datasets for exp, ds in self.sim_ds.items(): - sim_ds_f, obs_ds_f = filter_timeseries(ds.dis, self.obs_ds, self.threshold) + sim_ds_f, obs_ds_f = filter_timeseries(ds.dis, self.obs_ds.obsdis, self.threshold) + print(sim_ds_f) + print(obs_ds_f) statistics[exp] = run_analysis(self.stats, sim_ds_f, obs_ds_f) return statistics @@ -309,28 +314,24 @@ def handle_click(self, station_id): def mapplot(self, colorby='kge', sim='exp1'): - # Utilize the already prepared datasets - if isinstance(self.sim_ds, dict): # If there are multiple experiments - self.ds_list = list(self.sim_ds.values()) - else: # If there's only one dataset - self.ds_list = [self.sim_ds] - - # Utilize the obs_ds to convert it to a dataframe (if needed elsewhere in the method) - self.obs_df = self.obs_ds.to_dataframe().reset_index() self.statistics_output.clear_output() # Assume all datasets have the same coordinates for simplicity # Determine center coordinates using the first dataset in the list - center_lat, center_lon = self.ds_list[0]['latitude'].mean().item(), self.ds_list[0]['longitude'].mean().item() + # lon = self.stations_metadata[self.config['station_coordinates'][0]] + # lat = self.stations_metadata[self.config['station_coordinates'][1]] + # center_lat, center_lon = lon.mean().item(), lat.mean().item() # Create a GeoMap centered on the mean coordinates - self.geo_map = IPyLeaflet(center_lat, center_lon) + print('Initialising ipyleaflet') + self.geo_map = IPyLeaflet() + print('Initialising plotly') self.f = self.geo_map.initialize_plot() # Initialize a plotly figure widget for the time series # Convert ds 'time' to datetime format for alignment with external_df - self.ds_time = self.ds_list[0]['time'].values.astype('datetime64[D]') + self.ds_time = self.obs_ds['time'].values.astype('datetime64[D]') # Create a label to indicate loading self.loading_label = Label(value="") @@ -350,7 +351,7 @@ def mapplot(self, colorby='kge', sim='exp1'): raise ValueError(f"Statistic '{colorby}' not found in computed statistics for simulation '{sim}'.") # Retrieve the desired data - stat_data = self.statistics[sim][colorby].values + stat_data = self.statistics[sim][colorby] # Define a colormap (you can choose any other colormap that you like) colormap = plt.cm.viridis @@ -358,23 +359,17 @@ def mapplot(self, colorby='kge', sim='exp1'): # Normalize the data for coloring norm = plt.Normalize(stat_data.min(), stat_data.max()) + print('Creating stations markers') # Create a GeoJSON structure from the stations_metadata DataFrame - for _, row in self.stations_metadata.iterrows(): lat, lon = row['StationLat'], row['StationLon'] - station_id = row['ObsID'] - - # Get the index of the station in the statistics data - station_indices = np.where(self.ds_list[0]['station'].values.astype(str) == str(station_id))[0] - - if len(station_indices) == 0: - color = 'gray' + station_id = row[self.config['station_id_column_name']] + print(station_id) + + if station_id in list(stat_data.station): + color = matplotlib.colors.rgb2hex(colormap(norm(stat_data.sel(station=station_id).values))) else: - if station_indices[0] >= len(stat_data) or np.isnan(stat_data[station_indices[0]]): - color = 'gray' - else: - color = matplotlib.colors.rgb2hex(colormap(norm(stat_data[station_indices[0]]))) - print(f"Station {station_id} has color {color} based on statistic value {stat_data[station_indices[0]]}") + color = 'gray' circle_marker = CircleMarker(location=(lat, lon), radius=5, color=color, fill_opacity=0.8) circle_marker.on_click(partial(self.handle_marker_click, row=row)) @@ -450,7 +445,9 @@ def generate_statistics_table(self, station_id): for exp_name, stats in self.statistics.items(): print("Loop initiated for experiment:", exp_name) - if str(station_id) in stats['station'].values: + print(station_id) + print(stats['station'].values) + if station_id in stats['station'].values: print("Available station IDs in stats:", stats['station'].values) row = [exp_name] + [stats[var].sel(station=station_id).values for var in stats.data_vars if var not in ['longitude', 'latitude']] data.append(row) @@ -458,8 +455,6 @@ def generate_statistics_table(self, station_id): # Convert the data to a DataFrame for display columns = ['Exp. name'] + list(stats.data_vars.keys()) - columns.remove('longitude') - columns.remove('latitude') statistics_df = pd.DataFrame(data, columns=columns) # Check if the dataframe has been generated correctly @@ -547,4 +542,4 @@ def filter_nan_values(dates, data_values): valid_dates = [date for date, val in zip(dates, data_values) if not np.isnan(val)] valid_data = [val for val in data_values if not np.isnan(val)] - return valid_dates, valid_data \ No newline at end of file + return valid_dates, valid_data diff --git a/notebooks/examples/4_visualisation_interactive.ipynb b/notebooks/examples/4_visualisation_interactive.ipynb index e3db842..6b8c4c8 100644 --- a/notebooks/examples/4_visualisation_interactive.ipynb +++ b/notebooks/examples/4_visualisation_interactive.ipynb @@ -9,7 +9,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 1, "metadata": { "tags": [] }, @@ -22,15 +22,16 @@ " \"i05j\": \"/ec/res4/scratch/macw/hat/destine/i05j/cama_i05j_stations.nc\",\n", " \"i05h\": \"/ec/res4/scratch/macw/hat/destine/i05h/cama_i05h_stations.nc\",\n", "}\n", + "statistics = {\n", + " \"i05j\": \"/ec/res4/scratch/macw/hat/destine/observations/statistics_i05j.nc\",\n", + " \"i05h\": \"/ec/res4/scratch/macw/hat/destine/observations/statistics_i05h.nc\",\n", + "}\n", "config = {\n", " \"station_epsg\": 4326,\n", " \"station_id_column_name\": \"station_id\",\n", - " \"station_filters\":\"station_id == G10162\",\n", + " \"station_filters\":\"\",\n", " \"station_coordinates\": [\"StationLon\", \"StationLat\"],\n", - " \"stats_threshold\": 10\n", - "}\n", - "\n", - "statistics = ['kge', 'rmse', 'mae', 'correlation']" + "}\n" ] }, { @@ -43,7 +44,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 2, "metadata": { "tags": [] }, @@ -67,80 +68,106 @@ " * station (station) object 'G0001' 'G0002' 'G0003' ... 'G10162' 'G10163'\n", "Data variables:\n", " dis (time, station) float32 ...}\n", - "['G10162']\n", + "1614\n", " version__origin__name version__version station_id station_id_num EFAS-ID \n", - "6929 GLOFAS 11 G10162 10162 5389 \\\n", + "2982 GLOFAS 11 G6215 6215 1 \\\n", + "2983 GLOFAS 11 G6216 6216 2 \n", + "2984 GLOFAS 11 G6217 6217 3 \n", + "2985 GLOFAS 11 G6218 6218 4 \n", + "2986 GLOFAS 11 G6219 6219 5 \n", + "... ... ... ... ... ... \n", + "6835 GLOFAS 11 G10068 10068 5218 \n", + "6836 GLOFAS 11 G10069 10069 5219 \n", + "6837 GLOFAS 11 G10070 10070 5220 \n", + "6838 GLOFAS 11 G10071 10071 5221 \n", + "6840 GLOFAS 11 G10073 10073 5223 \n", "\n", - " EC_calib5k EC_calib StationName StationLon StationLat ... \n", - "6929 -9999 0 Aforo en Formentera -0.743557 38.0835467 ... \\\n", + " EC_calib5k EC_calib StationName StationLon StationLat \n", + "2982 1 1 Schwabelweis 12.13873 49.023585 \\\n", + "2983 3 1 Hofkirchen 13.115212 48.676627 \n", + "2984 2 0 Pfelling 12.747379 48.879843 \n", + "2985 3 1 Barby 11.882244 51.984836 \n", + "2986 3 1 Wittenberg - Lutherstadt 12.646309 51.856531 \n", + "... ... ... ... ... ... \n", + "6835 -9999 1 Volari 17.11525 44.29215278 \n", + "6836 -9999 1 Vrbanja 17.272768 44.74743529 \n", + "6837 -9999 1 Novi Grad nizvodno 16.384199 45.05355904 \n", + "6838 -9999 0 Prijedor 16.70386 44.97385 \n", + "6840 -9999 0 Martinrive 5.637124 50.47889313 \n", "\n", - " CamaLat1_15min CamaLon1_15min CamaArea_15min \n", - "6929 38.125 -0.875 14817.6 \\\n", + " ... CamaLat1_15min CamaLon1_15min CamaArea_15min \n", + "2982 ... 49.125 12.125 35881.5 \\\n", + "2983 ... 48.625 13.125 49256.6 \n", + "2984 ... 48.875 12.625 37745.9 \n", + "2985 ... 51.875 11.875 93420.1 \n", + "2986 ... 51.875 12.625 60864 \n", + "... ... ... ... ... \n", + "6835 ... 44.125 17.125 556.4 \n", + "6836 ... 44.625 17.375 772.2 \n", + "6837 ... 45.125 16.375 7563.6 \n", + "6838 ... 44.875 16.625 2708.6 \n", + "6840 ... 50.375 5.875 996.1 \n", "\n", " EfasCamaVerif_Eccalib_500km2_1year24H \n", - "6929 \\\n", + "2982 1 \\\n", + "2983 1 \n", + "2984 \n", + "2985 1 \n", + "2986 1 \n", + "... ... \n", + "6835 1 \n", + "6836 1 \n", + "6837 1 \n", + "6838 \n", + "6840 \n", "\n", " EfasCamaVerif_Eccalib_500km2_1year24H_2017+ EfasCamaVerif500km2_1year24H \n", - "6929 \\\n", + "2982 1 1 \\\n", + "2983 1 1 \n", + "2984 1 \n", + "2985 1 1 \n", + "2986 1 1 \n", + "... ... ... \n", + "6835 1 1 \n", + "6836 1 1 \n", + "6837 1 1 \n", + "6838 1 \n", + "6840 \n", "\n", - " EFAS_Duplicate geometry x y \n", - "6929 POINT (-0.74356 38.08355) -0.743557 38.083547 \n", + " EFAS_Duplicate geometry x y \n", + "2982 POINT (12.13873 49.02358) 12.138730 49.023585 \n", + "2983 POINT (13.11521 48.67663) 13.115212 48.676627 \n", + "2984 POINT (12.74738 48.87984) 12.747379 48.879843 \n", + "2985 POINT (11.88224 51.98484) 11.882244 51.984836 \n", + "2986 POINT (12.64631 51.85653) 12.646309 51.856531 \n", + "... ... ... ... ... \n", + "6835 POINT (17.11525 44.29215) 17.115250 44.292153 \n", + "6836 POINT (17.27277 44.74744) 17.272768 44.747435 \n", + "6837 POINT (16.38420 45.05356) 16.384199 45.053559 \n", + "6838 POINT (16.70386 44.97385) 16.703860 44.973850 \n", + "6840 POINT (5.63712 50.47889) 5.637124 50.478893 \n", "\n", - "[1 rows x 115 columns]\n", + "[1614 rows x 115 columns]\n", "\n", - "Dimensions: (station: 1, time: 10228)\n", + "Dimensions: (station: 1614, time: 10228)\n", "Coordinates:\n", - " * station (station) object 'G10162'\n", + " * station (station) object 'G9473' 'G6502' 'G7052' ... 'G6243' 'G7303'\n", " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", "Data variables:\n", " obsdis (time, station) float32 ...\n", "{'i05j': \n", - "Dimensions: (time: 10228, station: 1)\n", + "Dimensions: (time: 10228, station: 1614)\n", "Coordinates:\n", " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", - " * station (station) object 'G10162'\n", + " * station (station) object 'G9473' 'G6502' 'G7052' ... 'G6243' 'G7303'\n", "Data variables:\n", " dis (time, station) float32 ..., 'i05h': \n", - "Dimensions: (time: 10228, station: 1)\n", + "Dimensions: (time: 10228, station: 1614)\n", "Coordinates:\n", " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", - " * station (station) object 'G10162'\n", + " * station (station) object 'G9473' 'G6502' 'G7052' ... 'G6243' 'G7303'\n", "Data variables:\n", - " dis (time, station) float32 ...}\n", - "['G10162']\n", - "\n", - "[10228 values with dtype=float32]\n", - "Coordinates:\n", - " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", - " * station (station) object 'G10162'\n", - "\n", - "array([[nan],\n", - " [nan],\n", - " [nan],\n", - " ...,\n", - " [nan],\n", - " [nan],\n", - " [nan]], dtype=float32)\n", - "Coordinates:\n", - " * station (station) object 'G10162'\n", - " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", - "Attributes:\n", - " units: m3/s\n", - " long_name: observed discharge\n" - ] - }, - { - "ename": "ModuleNotFoundError", - "evalue": "No module named 'np'", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mModuleNotFoundError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[4], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[38;5;28mmap\u001b[39m \u001b[38;5;241m=\u001b[39m \u001b[43mNotebookMap\u001b[49m\u001b[43m(\u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mstations\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mobservations\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43msimulations\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mstats\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mstatistics\u001b[49m\u001b[43m)\u001b[49m\n", - "File \u001b[0;32m/etc/ecmwf/nfs/dh1_home_a/macw/git/hat/hat/visualisation.py:126\u001b[0m, in \u001b[0;36mNotebookMap.__init__\u001b[0;34m(self, config, stations_metadata, observations, simulations, stats)\u001b[0m\n\u001b[1;32m 124\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstatistics \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mNone\u001b[39;00m\n\u001b[1;32m 125\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstats:\n\u001b[0;32m--> 126\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstatistics \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mcalculate_statistics\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 127\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstatistics_output \u001b[38;5;241m=\u001b[39m Output()\n", - "File \u001b[0;32m/etc/ecmwf/nfs/dh1_home_a/macw/git/hat/hat/visualisation.py:206\u001b[0m, in \u001b[0;36mNotebookMap.calculate_statistics\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 204\u001b[0m \u001b[38;5;66;03m# Dictionary of simulation datasets\u001b[39;00m\n\u001b[1;32m 205\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m exp, ds \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39msim_ds\u001b[38;5;241m.\u001b[39mitems():\n\u001b[0;32m--> 206\u001b[0m sim_ds_f, obs_ds_f \u001b[38;5;241m=\u001b[39m \u001b[43mfilter_timeseries\u001b[49m\u001b[43m(\u001b[49m\u001b[43mds\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mdis\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mobs_ds\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mthreshold\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 207\u001b[0m statistics[exp] \u001b[38;5;241m=\u001b[39m run_analysis(\u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstats, sim_ds_f, obs_ds_f)\n\u001b[1;32m 208\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m statistics\n", - "File \u001b[0;32m/etc/ecmwf/nfs/dh1_home_a/macw/git/hat/hat/filters.py:175\u001b[0m, in \u001b[0;36mfilter_timeseries\u001b[0;34m(sims_ds, obs_ds, threshold)\u001b[0m\n\u001b[1;32m 173\u001b[0m \u001b[38;5;28mprint\u001b[39m(sims_ds)\n\u001b[1;32m 174\u001b[0m \u001b[38;5;28mprint\u001b[39m(dis)\n\u001b[0;32m--> 175\u001b[0m \u001b[38;5;28;01mimport\u001b[39;00m \u001b[38;5;21;01mnp\u001b[39;00m\n\u001b[1;32m 176\u001b[0m \u001b[38;5;28mprint\u001b[39m(dis\u001b[38;5;241m.\u001b[39mvalues[np\u001b[38;5;241m.\u001b[39misfinite(dis\u001b[38;5;241m.\u001b[39mvalues)])\n\u001b[1;32m 178\u001b[0m \u001b[38;5;66;03m# Replace negative values with NaN\u001b[39;00m\n", - "\u001b[0;31mModuleNotFoundError\u001b[0m: No module named 'np'" + " dis (time, station) float32 ...}\n" ] } ], @@ -150,20 +177,1299 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "metadata": { "tags": [] }, "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loop initiated for experiment: i05j\n", + "G9989\n", + "['G10000' 'G10001' 'G10002' ... 'G9989' 'G9990' 'G9998']\n", + "Available station IDs in stats: ['G10000' 'G10001' 'G10002' ... 'G9989' 'G9990' 'G9998']\n", + "Data after processing: [['i05j', array(0.45353499), array(4.8293457, dtype=float32), array(2.8585353, dtype=float32), array(0.49560919)]]\n", + "Loop initiated for experiment: i05h\n", + "G9989\n", + "['G10000' 'G10001' 'G10002' ... 'G9989' 'G9990' 'G9998']\n", + "Available station IDs in stats: ['G10000' 'G10001' 'G10002' ... 'G9989' 'G9990' 'G9998']\n", + "Data after processing: [['i05j', array(0.45353499), array(4.8293457, dtype=float32), array(2.8585353, dtype=float32), array(0.49560919)], ['i05h', array(0.33572683), array(5.825837, dtype=float32), array(3.5438168, dtype=float32), array(0.45184272)]]\n", + " Exp. name kge rmse mae correlation\n", + "0 i05j 0.4535349871603854 4.8293457 2.8585353 0.49560919449950164\n", + "1 i05h 0.335726830625943 5.825837 3.5438168 0.4518427229842832\n" + ] + }, { "ename": "AttributeError", - "evalue": "type object 'map' has no attribute 'mapplot'", + "evalue": "'NotebookMap' object has no attribute 'geo_map'", "output_type": "error", "traceback": [ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[2], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[38;5;28;43mmap\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mmapplot\u001b[49m(colorby\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mkge\u001b[39m\u001b[38;5;124m'\u001b[39m, sim\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mi05j\u001b[39m\u001b[38;5;124m'\u001b[39m)\n", - "\u001b[0;31mAttributeError\u001b[0m: type object 'map' has no attribute 'mapplot'" + "Cell \u001b[0;32mIn[3], line 3\u001b[0m\n\u001b[1;32m 1\u001b[0m stats \u001b[38;5;241m=\u001b[39m \u001b[38;5;28mmap\u001b[39m\u001b[38;5;241m.\u001b[39mgenerate_statistics_table(\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mG9989\u001b[39m\u001b[38;5;124m'\u001b[39m)\n\u001b[1;32m 2\u001b[0m \u001b[38;5;28mprint\u001b[39m(stats)\n\u001b[0;32m----> 3\u001b[0m \u001b[38;5;28;43mmap\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mdisplay_dataframe_with_scroll\u001b[49m\u001b[43m(\u001b[49m\u001b[43mstats\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mtitle\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mStatistics Overview\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m/etc/ecmwf/nfs/dh1_home_a/macw/git/hat/hat/visualisation.py:216\u001b[0m, in \u001b[0;36mNotebookMap.display_dataframe_with_scroll\u001b[0;34m(self, df, title)\u001b[0m\n\u001b[1;32m 215\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mdisplay_dataframe_with_scroll\u001b[39m(\u001b[38;5;28mself\u001b[39m, df, title\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m\"\u001b[39m):\n\u001b[0;32m--> 216\u001b[0m \u001b[38;5;28;01mwith\u001b[39;00m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mgeo_map\u001b[49m\u001b[38;5;241m.\u001b[39mdf_output:\n\u001b[1;32m 217\u001b[0m clear_output(wait\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mTrue\u001b[39;00m)\n\u001b[1;32m 219\u001b[0m \u001b[38;5;66;03m# Define styles directly within the HTML content\u001b[39;00m\n", + "\u001b[0;31mAttributeError\u001b[0m: 'NotebookMap' object has no attribute 'geo_map'" + ] + } + ], + "source": [ + "stats = map.generate_statistics_table('G9989')\n", + "print(stats)\n", + "map.display_dataframe_with_scroll(stats, title=\"Statistics Overview\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Initialising ipyleaflet\n", + "Initialising plotly\n", + "Creating stations markers\n", + "G6215\n", + "G6216\n", + "G6217\n", + "G6218\n", + "G6219\n", + "G6220\n", + "G6222\n", + "G6224\n", + "G6225\n", + "G6226\n", + "G6227\n", + "G6228\n", + "G6229\n", + "G6230\n", + "G6231\n", + "G6232\n", + "G6233\n", + "G6235\n", + "G6240\n", + "G6241\n", + "G6242\n", + "G6243\n", + "G6244\n", + "G6245\n", + "G6246\n", + "G6247\n", + "G6248\n", + "G6249\n", + "G6250\n", + "G6252\n", + "G6253\n", + "G6254\n", + "G6256\n", + "G6257\n", + "G6259\n", + "G6260\n", + "G6261\n", + "G6262\n", + "G6263\n", + "G6265\n", + "G6266\n", + "G6267\n", + "G6268\n", + "G6271\n", + "G6272\n", + "G6273\n", + "G6274\n", + "G6276\n", + "G6277\n", + "G6281\n", + "G6282\n", + "G6284\n", + "G6285\n", + "G6286\n", + "G6287\n", + "G6288\n", + "G6290\n", + "G6291\n", + "G6292\n", + "G6293\n", + "G6294\n", + "G6295\n", + "G6297\n", + "G6298\n", + "G6299\n", + "G6301\n", + "G6303\n", + "G6304\n", + "G6305\n", + "G6306\n", + "G6307\n", + "G6308\n", + "G6309\n", + "G6310\n", + "G6311\n", + "G6312\n", + "G6313\n", + "G6314\n", + "G6315\n", + "G6316\n", + "G6317\n", + "G6318\n", + "G6319\n", + "G6320\n", + "G6321\n", + "G6322\n", + "G6323\n", + "G6324\n", + "G6325\n", + "G6326\n", + "G6327\n", + "G6328\n", + "G6329\n", + "G6330\n", + "G6332\n", + "G6333\n", + "G6334\n", + "G6336\n", + "G6339\n", + "G6342\n", + "G6343\n", + "G6345\n", + "G6346\n", + "G6348\n", + "G6349\n", + "G6350\n", + "G6352\n", + "G6353\n", + "G6355\n", + "G6356\n", + "G6357\n", + "G6358\n", + "G6359\n", + "G6360\n", + "G6361\n", + "G6362\n", + "G6363\n", + "G6365\n", + "G6366\n", + "G6368\n", + "G6369\n", + "G6371\n", + "G6373\n", + "G6374\n", + "G6375\n", + "G6376\n", + "G6377\n", + "G6378\n", + "G6379\n", + "G6380\n", + "G6383\n", + "G6385\n", + "G6386\n", + "G6387\n", + "G6388\n", + "G6389\n", + "G6390\n", + "G6391\n", + "G6392\n", + "G6393\n", + "G6394\n", + "G6395\n", + "G6396\n", + "G6397\n", + "G6398\n", + "G6399\n", + "G6401\n", + "G6402\n", + "G6405\n", + "G6406\n", + "G6407\n", + "G6408\n", + "G6409\n", + "G6410\n", + "G6411\n", + "G6412\n", + "G6413\n", + "G6417\n", + "G6418\n", + "G6419\n", + "G6421\n", + "G6422\n", + "G6423\n", + "G6424\n", + "G6425\n", + "G6426\n", + "G6431\n", + "G6433\n", + "G6435\n", + "G6436\n", + "G6437\n", + "G6438\n", + "G6440\n", + "G6441\n", + "G6442\n", + "G6443\n", + "G6444\n", + "G6445\n", + "G6446\n", + "G6447\n", + "G6448\n", + "G6449\n", + "G6450\n", + "G6451\n", + "G6452\n", + "G6453\n", + "G6454\n", + "G6455\n", + "G6456\n", + "G6457\n", + "G6458\n", + "G6459\n", + "G6460\n", + "G6467\n", + "G6468\n", + "G6469\n", + "G6471\n", + "G6473\n", + "G6474\n", + "G6478\n", + "G6480\n", + "G6481\n", + "G6482\n", + "G6483\n", + "G6484\n", + "G6485\n", + "G6486\n", + "G6487\n", + "G6488\n", + "G6489\n", + "G6490\n", + "G6491\n", + "G6492\n", + "G6493\n", + "G6494\n", + "G6495\n", + "G6496\n", + "G6497\n", + "G6498\n", + "G6499\n", + "G6500\n", + "G6501\n", + "G6502\n", + "G6503\n", + "G6504\n", + "G6505\n", + "G6506\n", + "G6508\n", + "G6509\n", + "G6510\n", + "G6512\n", + "G6513\n", + "G6517\n", + "G6518\n", + "G6519\n", + "G6520\n", + "G6521\n", + "G6523\n", + "G6527\n", + "G6532\n", + "G6536\n", + "G6541\n", + "G6543\n", + "G6544\n", + "G6545\n", + "G6546\n", + "G6547\n", + "G6548\n", + "G6549\n", + "G6552\n", + "G6554\n", + "G6556\n", + "G6557\n", + "G6558\n", + "G6559\n", + "G6560\n", + "G6561\n", + "G6562\n", + "G6563\n", + "G6564\n", + "G6565\n", + "G6566\n", + "G6567\n", + "G6568\n", + "G6570\n", + "G6571\n", + "G6575\n", + "G6576\n", + "G6578\n", + "G6579\n", + "G6580\n", + "G6581\n", + "G6588\n", + "G6589\n", + "G6596\n", + "G6597\n", + "G6598\n", + "G6599\n", + "G6601\n", + "G6602\n", + "G6603\n", + "G6604\n", + "G6605\n", + "G6606\n", + "G6610\n", + "G6613\n", + "G6618\n", + "G6621\n", + "G6628\n", + "G6629\n", + "G6630\n", + "G6632\n", + "G6634\n", + "G6636\n", + "G6637\n", + "G6642\n", + "G6644\n", + "G6651\n", + "G6658\n", + "G6662\n", + "G6664\n", + "G6665\n", + "G6666\n", + "G6667\n", + "G6668\n", + "G6669\n", + "G6670\n", + "G6671\n", + "G6672\n", + "G6673\n", + "G6674\n", + "G6675\n", + "G6676\n", + "G6677\n", + "G6678\n", + "G6679\n", + "G6680\n", + "G6681\n", + "G6682\n", + "G6683\n", + "G6684\n", + "G6685\n", + "G6686\n", + "G6687\n", + "G6688\n", + "G6689\n", + "G6690\n", + "G6691\n", + "G6692\n", + "G6693\n", + "G6694\n", + "G6695\n", + "G6696\n", + "G6697\n", + "G6698\n", + "G6700\n", + "G6704\n", + "G6705\n", + "G6707\n", + "G6708\n", + "G6709\n", + "G6710\n", + "G6711\n", + "G6712\n", + "G6714\n", + "G6717\n", + "G6718\n", + "G6719\n", + "G6720\n", + "G6723\n", + "G6724\n", + "G6725\n", + "G6728\n", + "G6736\n", + "G6737\n", + "G6738\n", + "G6739\n", + "G6740\n", + "G6742\n", + "G6743\n", + "G6745\n", + "G6746\n", + "G6750\n", + "G6751\n", + "G6752\n", + "G6753\n", + "G6754\n", + "G6755\n", + "G6756\n", + "G6758\n", + "G6759\n", + "G6760\n", + "G6767\n", + "G6768\n", + "G6769\n", + "G6771\n", + "G6772\n", + "G6775\n", + "G6776\n", + "G6779\n", + "G6781\n", + "G6783\n", + "G6784\n", + "G6785\n", + "G6786\n", + "G6787\n", + "G6788\n", + "G6801\n", + "G6802\n", + "G6803\n", + "G6804\n", + "G6805\n", + "G6806\n", + "G6807\n", + "G6808\n", + "G6809\n", + "G6810\n", + "G6811\n", + "G6812\n", + "G6813\n", + "G6815\n", + "G6816\n", + "G6817\n", + "G6818\n", + "G6819\n", + "G6820\n", + "G6821\n", + "G6822\n", + "G6823\n", + "G6824\n", + "G6825\n", + "G6826\n", + "G6827\n", + "G6828\n", + "G6829\n", + "G6830\n", + "G6831\n", + "G6832\n", + "G6833\n", + "G6834\n", + "G6835\n", + "G6836\n", + "G6837\n", + "G6838\n", + "G6839\n", + "G6840\n", + "G6841\n", + "G6842\n", + "G6843\n", + "G6845\n", + "G6846\n", + "G6847\n", + "G6848\n", + "G6849\n", + "G6850\n", + "G6851\n", + "G6852\n", + "G6853\n", + "G6854\n", + "G6855\n", + "G6856\n", + "G6857\n", + "G6860\n", + "G6861\n", + "G6862\n", + "G6864\n", + "G6866\n", + "G6867\n", + "G6870\n", + "G6871\n", + "G6872\n", + "G6874\n", + "G6875\n", + "G6877\n", + "G6878\n", + "G6879\n", + "G6881\n", + "G6882\n", + "G6883\n", + "G6884\n", + "G6885\n", + "G6889\n", + "G6891\n", + "G6892\n", + "G6893\n", + "G6895\n", + "G6896\n", + "G6897\n", + "G6898\n", + "G6899\n", + "G6900\n", + "G6901\n", + "G6902\n", + "G6903\n", + "G6904\n", + "G6906\n", + "G6907\n", + "G6908\n", + "G6910\n", + "G6912\n", + "G6913\n", + "G6914\n", + "G6920\n", + "G6922\n", + "G6926\n", + "G6927\n", + "G6928\n", + "G6929\n", + "G6930\n", + "G6931\n", + "G6932\n", + "G6933\n", + "G6934\n", + "G6935\n", + "G6936\n", + "G6937\n", + "G6938\n", + "G6939\n", + "G6940\n", + "G6941\n", + "G6944\n", + "G6945\n", + "G6947\n", + "G6948\n", + "G6950\n", + "G6953\n", + "G6954\n", + "G6955\n", + "G6956\n", + "G6957\n", + "G6958\n", + "G6971\n", + "G6976\n", + "G6979\n", + "G6983\n", + "G6984\n", + "G6985\n", + "G6986\n", + "G6987\n", + "G6988\n", + "G6989\n", + "G6990\n", + "G6992\n", + "G6993\n", + "G6994\n", + "G6995\n", + "G6997\n", + "G6998\n", + "G7003\n", + "G7004\n", + "G7005\n", + "G7006\n", + "G7007\n", + "G7009\n", + "G7010\n", + "G7011\n", + "G7012\n", + "G7013\n", + "G7016\n", + "G7017\n", + "G7019\n", + "G7020\n", + "G7021\n", + "G7022\n", + "G7023\n", + "G7024\n", + "G7025\n", + "G7026\n", + "G7027\n", + "G7028\n", + "G7030\n", + "G7032\n", + "G7035\n", + "G7036\n", + "G7037\n", + "G7038\n", + "G7039\n", + "G7040\n", + "G7042\n", + "G7043\n", + "G7044\n", + "G7045\n", + "G7046\n", + "G7048\n", + "G7049\n", + "G7050\n", + "G7051\n", + "G7052\n", + "G7054\n", + "G7057\n", + "G7058\n", + "G7059\n", + "G7060\n", + "G7061\n", + "G7062\n", + "G7063\n", + "G7064\n", + "G7067\n", + "G7068\n", + "G7069\n", + "G7071\n", + "G7073\n", + "G7080\n", + "G7081\n", + "G7082\n", + "G7084\n", + "G7088\n", + "G7096\n", + "G7097\n", + "G7098\n", + "G7100\n", + "G7101\n", + "G7102\n", + "G7103\n", + "G7104\n", + "G7105\n", + "G7106\n", + "G7107\n", + "G7108\n", + "G7112\n", + "G7113\n", + "G7114\n", + "G7116\n", + "G7118\n", + "G7122\n", + "G7125\n", + "G7126\n", + "G7130\n", + "G7131\n", + "G7132\n", + "G7133\n", + "G7134\n", + "G7135\n", + "G7136\n", + "G7138\n", + "G7139\n", + "G7140\n", + "G7141\n", + "G7142\n", + "G7143\n", + "G7144\n", + "G7145\n", + "G7148\n", + "G7149\n", + "G7150\n", + "G7151\n", + "G7154\n", + "G7155\n", + "G7156\n", + "G7157\n", + "G7158\n", + "G7159\n", + "G7160\n", + "G7161\n", + "G7163\n", + "G7164\n", + "G7165\n", + "G7166\n", + "G7168\n", + "G7169\n", + "G7174\n", + "G7176\n", + "G7178\n", + "G7179\n", + "G7182\n", + "G7183\n", + "G7186\n", + "G7187\n", + "G7188\n", + "G7189\n", + "G7191\n", + "G7193\n", + "G7194\n", + "G7195\n", + "G7196\n", + "G7198\n", + "G7199\n", + "G7201\n", + "G7202\n", + "G7203\n", + "G7204\n", + "G7206\n", + "G7207\n", + "G7208\n", + "G7209\n", + "G7210\n", + "G7211\n", + "G7212\n", + "G7213\n", + "G7214\n", + "G7217\n", + "G7218\n", + "G7219\n", + "G7220\n", + "G7224\n", + "G7225\n", + "G7226\n", + "G7227\n", + "G7228\n", + "G7229\n", + "G7230\n", + "G7233\n", + "G7234\n", + "G7235\n", + "G7236\n", + "G7239\n", + "G7240\n", + "G7241\n", + "G7242\n", + "G7244\n", + "G7245\n", + "G7247\n", + "G7248\n", + "G7249\n", + "G7251\n", + "G7254\n", + "G7257\n", + "G7258\n", + "G7260\n", + "G7261\n", + "G7262\n", + "G7264\n", + "G7265\n", + "G7266\n", + "G7267\n", + "G7268\n", + "G7278\n", + "G7279\n", + "G7283\n", + "G7284\n", + "G7285\n", + "G7287\n", + "G7288\n", + "G7289\n", + "G7290\n", + "G7291\n", + "G7292\n", + "G7293\n", + "G7294\n", + "G7295\n", + "G7298\n", + "G7299\n", + "G7300\n", + "G7301\n", + "G7302\n", + "G7303\n", + "G7304\n", + "G7305\n", + "G7306\n", + "G7307\n", + "G7308\n", + "G7309\n", + "G7310\n", + "G7311\n", + "G7312\n", + "G7313\n", + "G7314\n", + "G7315\n", + "G7316\n", + "G7317\n", + "G7318\n", + "G7319\n", + "G7320\n", + "G7321\n", + "G7322\n", + "G7323\n", + "G7324\n", + "G7325\n", + "G7326\n", + "G7327\n", + "G7328\n", + "G7329\n", + "G7330\n", + "G7331\n", + "G7333\n", + "G7335\n", + "G7336\n", + "G7340\n", + "G7343\n", + "G7346\n", + "G7348\n", + "G7349\n", + "G7352\n", + "G7354\n", + "G7356\n", + "G7357\n", + "G7358\n", + "G7359\n", + "G7360\n", + "G7361\n", + "G7362\n", + "G7363\n", + "G7364\n", + "G7365\n", + "G7366\n", + "G7367\n", + "G7368\n", + "G7373\n", + "G7376\n", + "G7377\n", + "G7378\n", + "G7379\n", + "G7381\n", + "G7382\n", + "G7383\n", + "G7386\n", + "G7388\n", + "G7389\n", + "G7390\n", + "G7391\n", + "G7392\n", + "G7394\n", + "G7395\n", + "G7397\n", + "G7398\n", + "G7400\n", + "G7401\n", + "G7403\n", + "G7405\n", + "G7407\n", + "G7410\n", + "G7411\n", + "G7415\n", + "G7416\n", + "G7417\n", + "G7418\n", + "G7424\n", + "G7426\n", + "G7427\n", + "G7432\n", + "G7433\n", + "G7479\n", + "G7486\n", + "G7487\n", + "G7488\n", + "G7489\n", + "G7490\n", + "G7491\n", + "G7492\n", + "G7493\n", + "G7494\n", + "G7495\n", + "G7496\n", + "G7497\n", + "G7498\n", + "G7499\n", + "G7500\n", + "G7501\n", + "G7502\n", + "G7503\n", + "G7504\n", + "G7505\n", + "G7506\n", + "G7507\n", + "G7508\n", + "G7509\n", + "G7510\n", + "G7511\n", + "G7512\n", + "G7513\n", + "G7514\n", + "G7515\n", + "G7516\n", + "G7517\n", + "G7518\n", + "G7519\n", + "G7520\n", + "G7521\n", + "G7522\n", + "G7523\n", + "G7524\n", + "G7525\n", + "G7583\n", + "G7584\n", + "G7585\n", + "G7586\n", + "G7587\n", + "G7588\n", + "G7589\n", + "G7590\n", + "G7591\n", + "G7592\n", + "G7593\n", + "G7595\n", + "G7596\n", + "G7597\n", + "G7598\n", + "G7599\n", + "G7600\n", + "G7602\n", + "G7604\n", + "G7605\n", + "G7606\n", + "G7607\n", + "G7610\n", + "G7611\n", + "G7615\n", + "G7618\n", + "G7619\n", + "G7620\n", + "G7623\n", + "G7627\n", + "G7628\n", + "G7629\n", + "G7630\n", + "G7632\n", + "G7635\n", + "G7636\n", + "G7637\n", + "G7638\n", + "G7643\n", + "G7644\n", + "G7646\n", + "G7647\n", + "G7651\n", + "G7652\n", + "G7653\n", + "G7654\n", + "G7655\n", + "G7659\n", + "G7660\n", + "G7661\n", + "G7663\n", + "G7664\n", + "G7665\n", + "G7666\n", + "G7669\n", + "G7670\n", + "G7671\n", + "G7672\n", + "G7673\n", + "G7674\n", + "G7675\n", + "G7676\n", + "G7678\n", + "G7680\n", + "G7681\n", + "G7683\n", + "G7684\n", + "G7685\n", + "G7781\n", + "G7782\n", + "G7784\n", + "G7786\n", + "G7789\n", + "G7791\n", + "G7795\n", + "G7797\n", + "G7798\n", + "G7799\n", + "G7829\n", + "G7830\n", + "G7831\n", + "G7832\n", + "G7833\n", + "G7834\n", + "G7835\n", + "G7836\n", + "G7837\n", + "G7838\n", + "G7839\n", + "G7840\n", + "G7841\n", + "G7842\n", + "G7843\n", + "G7844\n", + "G7845\n", + "G7847\n", + "G7849\n", + "G7896\n", + "G7898\n", + "G7899\n", + "G7900\n", + "G7901\n", + "G7902\n", + "G7903\n", + "G7905\n", + "G7906\n", + "G7907\n", + "G7908\n", + "G7909\n", + "G7910\n", + "G7911\n", + "G7912\n", + "G7913\n", + "G7915\n", + "G7916\n", + "G7917\n", + "G7918\n", + "G7919\n", + "G7920\n", + "G7921\n", + "G7922\n", + "G7923\n", + "G7924\n", + "G7925\n", + "G7926\n", + "G7927\n", + "G7929\n", + "G7930\n", + "G7932\n", + "G7934\n", + "G7935\n", + "G7936\n", + "G7937\n", + "G7938\n", + "G7940\n", + "G7941\n", + "G7943\n", + "G7944\n", + "G7945\n", + "G7946\n", + "G7948\n", + "G7949\n", + "G7950\n", + "G7951\n", + "G7952\n", + "G7953\n", + "G7954\n", + "G7955\n", + "G7956\n", + "G7957\n", + "G7958\n", + "G7959\n", + "G7960\n", + "G7961\n", + "G7962\n", + "G7967\n", + "G7968\n", + "G7970\n", + "G7977\n", + "G7978\n", + "G7979\n", + "G7980\n", + "G7981\n", + "G7982\n", + "G7983\n", + "G7984\n", + "G9117\n", + "G9121\n", + "G9122\n", + "G9123\n", + "G9124\n", + "G9125\n", + "G9126\n", + "G9127\n", + "G9128\n", + "G9129\n", + "G9133\n", + "G9134\n", + "G9135\n", + "G9137\n", + "G9138\n", + "G9139\n", + "G9170\n", + "G9171\n", + "G9172\n", + "G9173\n", + "G9175\n", + "G9178\n", + "G9179\n", + "G9184\n", + "G9185\n", + "G9186\n", + "G9187\n", + "G9188\n", + "G9189\n", + "G9190\n", + "G9191\n", + "G9192\n", + "G9193\n", + "G9194\n", + "G9195\n", + "G9196\n", + "G9197\n", + "G9198\n", + "G9199\n", + "G9200\n", + "G9201\n", + "G9202\n", + "G9203\n", + "G9204\n", + "G9205\n", + "G9206\n", + "G9207\n", + "G9208\n", + "G9209\n", + "G9210\n", + "G9211\n", + "G9212\n", + "G9213\n", + "G9214\n", + "G9215\n", + "G9216\n", + "G9217\n", + "G9218\n", + "G9219\n", + "G9220\n", + "G9221\n", + "G9222\n", + "G9223\n", + "G9224\n", + "G9225\n", + "G9226\n", + "G9227\n", + "G9228\n", + "G9229\n", + "G9230\n", + "G9231\n", + "G9232\n", + "G9233\n", + "G9234\n", + "G9235\n", + "G9236\n", + "G9237\n", + "G9238\n", + "G9239\n", + "G9240\n", + "G9241\n", + "G9242\n", + "G9243\n", + "G9244\n", + "G9245\n", + "G9246\n", + "G9247\n", + "G9248\n", + "G9249\n", + "G9250\n", + "G9251\n", + "G9252\n", + "G9253\n", + "G9254\n", + "G9255\n", + "G9256\n", + "G9257\n", + "G9258\n", + "G9259\n", + "G9260\n", + "G9261\n", + "G9262\n", + "G9263\n", + "G9264\n", + "G9265\n", + "G9266\n", + "G9267\n", + "G9268\n", + "G9269\n", + "G9270\n", + "G9271\n", + "G9272\n", + "G9273\n", + "G9274\n", + "G9275\n", + "G9276\n", + "G9277\n", + "G9278\n", + "G9279\n", + "G9280\n", + "G9281\n", + "G9282\n", + "G9283\n", + "G9284\n", + "G9285\n", + "G9286\n", + "G9287\n", + "G9288\n", + "G9289\n", + "G9290\n", + "G9291\n", + "G9292\n", + "G9293\n", + "G9294\n", + "G9295\n", + "G9296\n", + "G9297\n", + "G9298\n", + "G9299\n", + "G9300\n", + "G9301\n", + "G9302\n", + "G9303\n", + "G9304\n", + "G9305\n", + "G9306\n", + "G9307\n", + "G9308\n", + "G9309\n", + "G9310\n", + "G9311\n", + "G9312\n", + "G9313\n", + "G9314\n", + "G9315\n", + "G9316\n", + "G9317\n", + "G9318\n", + "G9319\n", + "G9320\n", + "G9321\n", + "G9322\n", + "G9323\n", + "G9324\n", + "G9325\n", + "G9326\n", + "G9327\n", + "G9328\n", + "G9329\n", + "G9330\n", + "G9331\n", + "G9332\n", + "G9333\n", + "G9334\n", + "G9335\n", + "G9336\n", + "G9337\n", + "G9338\n", + "G9339\n", + "G9340\n", + "G9341\n", + "G9342\n", + "G9343\n", + "G9344\n", + "G9345\n", + "G9346\n", + "G9347\n", + "G9348\n", + "G9349\n", + "G9350\n", + "G9351\n", + "G9357\n", + "G9358\n", + "G9360\n", + "G9361\n", + "G9362\n", + "G9363\n", + "G9364\n", + "G9365\n", + "G9367\n", + "G9370\n", + "G9371\n", + "G9372\n", + "G9373\n", + "G9374\n", + "G9375\n", + "G9376\n", + "G9377\n", + "G9378\n", + "G9379\n", + "G9380\n", + "G9381\n", + "G9382\n", + "G9392\n", + "G9393\n", + "G9395\n", + "G9397\n", + "G9398\n" ] } ], @@ -171,6 +1477,17 @@ "map.mapplot(colorby='kge', sim='i05j')" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "map.generate_statistics_table('G1253')" + ] + }, { "cell_type": "code", "execution_count": null, From c20c1ff2dd95551962175dde40658ba6e0030d1d Mon Sep 17 00:00:00 2001 From: Corentin Carton de Wiart Date: Wed, 11 Oct 2023 10:10:04 +0100 Subject: [PATCH 09/31] debug notebook vis --- .pre-commit-config.yaml | 8 +- hat/visualisation.py | 425 +++-- .../4_visualisation_interactive.ipynb | 1488 +---------------- 3 files changed, 277 insertions(+), 1644 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 30af686..5b536b2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: rev: 23.3.0 hooks: - id: black - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 +# - repo: https://github.com/PyCQA/flake8 +# rev: 6.0.0 +# hooks: +# - id: flake8 diff --git a/hat/visualisation.py b/hat/visualisation.py index de7ea78..368dfaa 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -3,56 +3,72 @@ for both spatial and temporal, e.g. netcdf, vector, raster, with time series etc """ -import os import json -import pandas as pd -import geopandas as gpd -from shapely.geometry import Point -import numpy as np -from ipywidgets import HBox, VBox, Layout, Label, Output, HTML -from ipyleaflet import Map, Marker, Rectangle, GeoJSON, Popup, AwesomeIcon, CircleMarker, LegendControl -import plotly.graph_objs as go -from hat.observations import read_station_metadata_file -from hat.hydrostats import run_analysis -from hat.filters import filter_timeseries -from IPython.core.display import display -from IPython.display import clear_output +import os import time -import xarray as xr -from typing import Dict, Union from functools import partial +from typing import Dict, Union + +import geopandas as gpd import matplotlib import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import plotly.graph_objs as go +import xarray as xr +from ipyleaflet import ( + AwesomeIcon, + CircleMarker, + GeoJSON, + LegendControl, + Map, + Marker, + Popup, + Rectangle, +) from ipyleaflet_legend import Legend +from IPython.core.display import display +from IPython.display import clear_output +from ipywidgets import HTML, HBox, Label, Layout, Output, VBox +from shapely.geometry import Point + +from hat.filters import filter_timeseries +from hat.hydrostats import run_analysis +from hat.observations import read_station_metadata_file class IPyLeaflet: """Visualization class for interactive map with IPyLeaflet and Plotly.""" - + def __init__(self): # Initialize the map widget - self.map = Map(zoom=5, layout=Layout(width='500px', height='500px')) - - pd.set_option('display.max_colwidth', None) - + self.map = Map(zoom=5, layout=Layout(width="500px", height="500px")) + + pd.set_option("display.max_colwidth", None) + # Initialize the output widget for time series plots and dataframe display self.output_widget = Output() self.df_output = Output() - + self.df_stats = Output() + # Create the title label for the properties table - self.feature_properties_title = Label("", layout=Layout(font_weight='bold', margin='10px 0')) - + self.feature_properties_title = Label( + "", layout=Layout(font_weight="bold", margin="10px 0") + ) + # Main layout: map and output widget on top, properties table at the bottom - self.layout = VBox([HBox([self.map, self.output_widget, self.df_output])]) - + self.layout = VBox( + [HBox([self.map, self.output_widget]), self.df_stats, self.df_output] + ) + def add_marker(self, lat: float, lon: float, on_click_callback): """Add a marker to the map.""" - marker = Marker(location=[lat, lon], draggable=False, opacity = 0.7) + marker = Marker(location=[lat, lon], draggable=False, opacity=0.7) marker.on_click(on_click_callback) self.map.add_layer(marker) - + def display(self): - display(self.vbox) + display(self.vbox) @staticmethod def initialize_plot(): @@ -61,19 +77,16 @@ def initialize_plot(): layout=go.Layout( width=600, legend=dict( - orientation="h", - yanchor="bottom", - y=1.02, - xanchor="right", - x=1 - ) + orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 + ), ) ) return f - + class ThrottledClick: """Class to throttle click events.""" + def __init__(self, delay=1.0): self.delay = delay self.last_call = 0 @@ -89,20 +102,31 @@ def should_process(self): class NotebookMap: """Main class for visualization in Jupyter Notebook.""" - def __init__(self, config: Dict, stations_metadata: str, observations: str, simulations: Dict, stats=None): + def __init__( + self, + config: Dict, + stations_metadata: str, + observations: str, + simulations: Dict, + stats=None, + ): self.config = config self.stations_metadata = read_station_metadata_file( fpath=stations_metadata, - coord_names=config['station_coordinates'], - epsg=config['station_epsg'], - filters=config['station_filters'] + coord_names=config["station_coordinates"], + epsg=config["station_epsg"], + filters=config["station_filters"], ) self.station_index = config["station_id_column_name"] - self.station_file_name = os.path.basename(stations_metadata) # Derive the file name here - self.stations_metadata[self.station_index] = self.stations_metadata[self.station_index].astype(str) + self.station_file_name = os.path.basename( + stations_metadata + ) # Derive the file name here + self.stations_metadata[self.station_index] = self.stations_metadata[ + self.station_index + ].astype(str) self.observations = observations # Ensure simulations is always a dictionary - + # Prepare sim_ds and obs_ds for statistics self.sim_ds = self.prepare_simulations_data(simulations) print(self.sim_ds) @@ -111,75 +135,78 @@ def __init__(self, config: Dict, stations_metadata: str, observations: str, simu self.statistics = {} if stats: for name, path in stats.items(): - self.statistics[name] = xr.open_dataset(path) + self.statistics[name] = xr.open_dataset(path).isel(station=range(100)) assert self.statistics.keys() == self.sim_ds.keys() common_id = self.find_common_station() print(len(common_id)) - self.stations_metadata = self.stations_metadata.loc[self.stations_metadata[self.station_index].isin(common_id)] - self.obs_ds = self.obs_ds.sel(station = common_id) - for sim, ds in self.sim_ds.items(): + self.stations_metadata = self.stations_metadata.loc[ + self.stations_metadata[self.station_index].isin(common_id) + ] + self.obs_ds = self.obs_ds.sel(station=common_id) + for sim, ds in self.sim_ds.items(): self.sim_ds[sim] = ds.sel(station=common_id) print(self.stations_metadata) print(self.obs_ds) print(self.sim_ds) - self.statistics_output = Output() - def prepare_simulations_data(self, simulations): - # If simulations is a dictionary, load data for each experiment datasets = {} for exp, path in simulations.items(): # Expanding the tilde expanded_path = os.path.expanduser(path) - + if os.path.isfile(expanded_path): # Check if it's a file ds = xr.open_dataset(expanded_path) elif os.path.isdir(expanded_path): # Check if it's a directory - # Handle the case when it's a directory; + # Handle the case when it's a directory; # assume all .nc files in the directory need to be combined - files = [f for f in os.listdir(expanded_path) if f.endswith('.nc')] - ds = xr.open_mfdataset([os.path.join(expanded_path, f) for f in files], combine='by_coords') + files = [f for f in os.listdir(expanded_path) if f.endswith(".nc")] + ds = xr.open_mfdataset( + [os.path.join(expanded_path, f) for f in files], combine="by_coords" + ) else: raise ValueError(f"Invalid path: {expanded_path}") datasets[exp] = ds - + return datasets def prepare_observations_data(self): file_extension = os.path.splitext(self.observations)[-1].lower() - - if file_extension == '.csv': + + if file_extension == ".csv": obs_df = pd.read_csv(self.observations, parse_dates=["Timestamp"]) - obs_melted = obs_df.melt(id_vars="Timestamp", var_name="station", value_name="obsdis") - + obs_melted = obs_df.melt( + id_vars="Timestamp", var_name="station", value_name="obsdis" + ) + # Convert the melted DataFrame to xarray Dataset obs_ds = obs_melted.set_index(["Timestamp", "station"]).to_xarray() obs_ds = obs_ds.rename({"Timestamp": "time"}) - elif file_extension == '.nc': + elif file_extension == ".nc": obs_ds = xr.open_dataset(self.observations) - + # # Check if the necessary attributes are present # if 'obsdis' not in obs_ds or 'time' not in obs_ds.coords: # raise ValueError("The NetCDF file does not have the expected variables or coordinates.") - + # multi_index = pd.MultiIndex.from_tuples(list(zip(lats, lons)), names=['lat', 'lon']) # obs_ds['station'] = ('station', multi_index) - + else: raise ValueError("Unsupported file format for observations.") - + # Subset obs_ds based on sim_ds time values if isinstance(self.sim_ds, xr.Dataset): - time_values = self.sim_ds['time'].values + time_values = self.sim_ds["time"].values elif isinstance(self.sim_ds, dict): # Use the first dataset in the dictionary to determine time values first_dataset = next(iter(self.sim_ds.values())) - time_values = first_dataset['time'].values + time_values = first_dataset["time"].values else: raise ValueError("Unexpected type for self.sim_ds") @@ -188,34 +215,36 @@ def prepare_observations_data(self): def find_common_station(self): ids = [] - ids += [list(self.obs_ds['station'].values)] - ids += [list(ds['station'].values) for ds in self.sim_ds.values()] + ids += [list(self.obs_ds["station"].values)] + ids += [list(ds["station"].values) for ds in self.sim_ds.values()] ids += [self.stations_metadata[self.station_index]] if self.statistics: - ids += [list(ds['station'].values) for ds in self.statistics.values()] + ids += [list(ds["station"].values) for ds in self.statistics.values()] common_ids = None - for id in ids: + for id in ids: if common_ids is None: common_ids = set(id) else: common_ids = set(id) & common_ids - return list(common_ids) - + return list(common_ids) + def calculate_statistics(self): statistics = {} # Dictionary of simulation datasets for exp, ds in self.sim_ds.items(): - sim_ds_f, obs_ds_f = filter_timeseries(ds.dis, self.obs_ds.obsdis, self.threshold) + sim_ds_f, obs_ds_f = filter_timeseries( + ds.dis, self.obs_ds.obsdis, self.threshold + ) print(sim_ds_f) print(obs_ds_f) statistics[exp] = run_analysis(self.stats, sim_ds_f, obs_ds_f) return statistics def display_dataframe_with_scroll(self, df, title=""): - with self.geo_map.df_output: + with self.geo_map.df_stats: clear_output(wait=True) - + # Define styles directly within the HTML content table_style = """ """ - table_html = df.to_html(classes='custom-table') + table_html = df.to_html(classes="custom-table") content = f"{table_style}

{title}

{table_html}
" - - display(HTML(content)) + display(HTML(content)) # Define a callback to handle marker clicks def handle_click(self, station_id): # Use the throttler to determine if we should process the click if not self.throttler.should_process(): return - + self.loading_label.value = "Loading..." # Indicate that data is being loaded - try: - # Convert station ID to string for consistency - station_id = str(station_id) - - # Clear existing data from the plot - self.f.data = [] - - # Convert ds_time to a list of string formatted dates for reindexing - ds_time_str = [dt.isoformat() for dt in pd.to_datetime(self.ds_time)] - - # Loop over all datasets to plot them - for ds, exp_name in zip(self.ds_list, self.simulations.keys()): - if station_id in ds['station'].values: - ds_time_series_data = ds['simulation_timeseries'].sel(station=station_id).values - - # Filter out NaN values and their associated dates using the utility function - valid_dates_ds, valid_data_ds = filter_nan_values(ds_time_str, ds_time_series_data) - - self.f.add_trace(go.Scatter(x=valid_dates_ds, y=valid_data_ds, mode='lines', name=exp_name)) - else: - print(f"Station ID: {station_id} not found in dataset {exp_name}.") - - # Time series from the obs - if station_id in self.obs_ds['station'].values: - obs_time_series = self.obs_ds['obsdis'].sel(station=station_id).values - + + # Convert station ID to string for consistency + station_id = str(station_id) + + # Clear existing data from the plot + self.f.data = [] + + # Convert ds_time to a list of string formatted dates for reindexing + ds_time_str = [dt.isoformat() for dt in pd.to_datetime(self.ds_time)] + + # Loop over all datasets to plot them + for name, ds in self.sim_ds.items(): + if station_id in ds["station"].values: + ds_time_series_data = ds["dis"].sel(station=station_id).values + # Filter out NaN values and their associated dates using the utility function - valid_dates_obs, valid_data_obs = filter_nan_values(ds_time_str, obs_time_series) - - self.f.add_trace(go.Scatter(x=valid_dates_obs, y=valid_data_obs, mode='lines', name='Obs. Data')) + valid_dates_ds, valid_data_ds = filter_nan_values( + ds_time_str, ds_time_series_data + ) + + self.f.add_trace( + go.Scatter( + x=valid_dates_ds, y=valid_data_ds, mode="lines", name=name + ) + ) else: - print(f"Station ID: {station_id} not found in obs_df. Columns are: {self.obs_ds.columns}") + print(f"Station ID: {station_id} not found in dataset {name}.") + + # Time series from the obs + if station_id in self.obs_ds["station"].values: + obs_time_series = self.obs_ds["obsdis"].sel(station=station_id).values + # Filter out NaN values and their associated dates using the utility function + valid_dates_obs, valid_data_obs = filter_nan_values( + ds_time_str, obs_time_series + ) + + self.f.add_trace( + go.Scatter( + x=valid_dates_obs, y=valid_data_obs, mode="lines", name="Obs. Data" + ) + ) + else: + print( + f"Station ID: {station_id} not found in obs_df. Columns are: {self.obs_ds.columns}" + ) + + # Update the x-axis and y-axis properties + self.f.layout.xaxis.title = "Date" + self.f.layout.xaxis.tickformat = "%d-%m-%Y" + self.f.layout.yaxis.title = "Discharge [m3/s]" - # Update the x-axis and y-axis properties - self.f.layout.xaxis.title = 'Date' - self.f.layout.xaxis.tickformat = '%d-%m-%Y' - self.f.layout.yaxis.title = 'Discharge [m3/s]' + # Generate and display the statistics table for the clicked station + with self.statistics_output: + df_stats = self.generate_statistics_table(station_id) + self.display_dataframe_with_scroll(df_stats, title="Statistics Overview") - # Generate and display the statistics table for the clicked station - with self.statistics_output: - df_stats = self.generate_statistics_table(station_id) - self.display_dataframe_with_scroll(df_stats, title="Statistics Overview") + self.loading_label.value = "" # Clear the loading message - self.loading_label.value = "" # Clear the loading message - - except Exception as e: - print(f"Error encountered: {e}") - self.loading_label.value = "Error encountered. Check the printed message." + # except Exception as e: + # print(f"Error encountered: {e}") + # self.loading_label.value = "Error encountered. Check the printed message." with self.geo_map.output_widget: clear_output(wait=True) # Clear any previous plots or messages display(self.f) - - def mapplot(self, colorby='kge', sim='exp1'): - - self.statistics_output.clear_output() - + def mapplot(self, colorby="kge", sim="exp1"): # Assume all datasets have the same coordinates for simplicity # Determine center coordinates using the first dataset in the list # lon = self.stations_metadata[self.config['station_coordinates'][0]] @@ -324,14 +361,16 @@ def mapplot(self, colorby='kge', sim='exp1'): # center_lat, center_lon = lon.mean().item(), lat.mean().item() # Create a GeoMap centered on the mean coordinates - print('Initialising ipyleaflet') + print("Initialising ipyleaflet") self.geo_map = IPyLeaflet() - print('Initialising plotly') - self.f = self.geo_map.initialize_plot() # Initialize a plotly figure widget for the time series + print("Initialising plotly") + self.f = ( + self.geo_map.initialize_plot() + ) # Initialize a plotly figure widget for the time series # Convert ds 'time' to datetime format for alignment with external_df - self.ds_time = self.obs_ds['time'].values.astype('datetime64[D]') + self.ds_time = self.obs_ds["time"].values.astype("datetime64[D]") # Create a label to indicate loading self.loading_label = Label(value="") @@ -340,7 +379,9 @@ def mapplot(self, colorby='kge', sim='exp1'): # Check if statistics were computed if self.statistics is None: - raise ValueError("Statistics have not been computed. Run `calculate_statistics` first.") + raise ValueError( + "Statistics have not been computed. Run `calculate_statistics` first." + ) # Check if the chosen simulation exists in the statistics if sim not in self.statistics: @@ -348,8 +389,10 @@ def mapplot(self, colorby='kge', sim='exp1'): # Check if the chosen statistic (colorby) exists for the simulation if colorby not in self.statistics[sim].data_vars: - raise ValueError(f"Statistic '{colorby}' not found in computed statistics for simulation '{sim}'.") - + raise ValueError( + f"Statistic '{colorby}' not found in computed statistics for simulation '{sim}'." + ) + # Retrieve the desired data stat_data = self.statistics[sim][colorby] @@ -359,102 +402,116 @@ def mapplot(self, colorby='kge', sim='exp1'): # Normalize the data for coloring norm = plt.Normalize(stat_data.min(), stat_data.max()) - print('Creating stations markers') + print("Creating stations markers") # Create a GeoJSON structure from the stations_metadata DataFrame for _, row in self.stations_metadata.iterrows(): - lat, lon = row['StationLat'], row['StationLon'] - station_id = row[self.config['station_id_column_name']] - print(station_id) - + lat, lon = row["StationLat"], row["StationLon"] + station_id = row[self.config["station_id_column_name"]] + if station_id in list(stat_data.station): - color = matplotlib.colors.rgb2hex(colormap(norm(stat_data.sel(station=station_id).values))) + color = matplotlib.colors.rgb2hex( + colormap(norm(stat_data.sel(station=station_id).values)) + ) else: - color = 'gray' + color = "gray" - circle_marker = CircleMarker(location=(lat, lon), radius=5, color=color, fill_opacity=0.8) + circle_marker = CircleMarker( + location=(lat, lon), radius=5, color=color, fill_opacity=0.8 + ) circle_marker.on_click(partial(self.handle_marker_click, row=row)) self.geo_map.map.add_layer(circle_marker) # Add legend to your map - + # legend_dict = self.colormap_to_legend(stat_data, colormap) # print("Legend Dict:", legend_dict) # my_legend = Legend(legend_dict, name=colorby) # self.geo_map.map.add_control(my_legend) - self.statistics_output = Output() # Initialize the layout only once # Create a new VBox for the plotly figure and the statistics output # plot_with_stats = VBox([self.f, self.statistics_output]) # Modify the main layout to use the new VBox - + # self.layout = VBox([HBox([self.geo_map.map, self.geo_map.df_output]), self.loading_label]) - self.layout = VBox([HBox([self.geo_map.map, self.f]), self.geo_map.df_output]) + self.layout = VBox( + [ + HBox([self.geo_map.map, self.f]), + HBox([self.geo_map.df_output, self.geo_map.df_stats]), + ] + ) # self.layout = VBox([HBox([self.geo_map.map, self.f, self.statistics_output]), self.geo_map.df_output, self.loading_label]) display(self.layout) - def colormap_to_legend(self, stat_data, colormap, n_labels=5): """ Convert a matplotlib colormap to a dictionary suitable for ipyleaflet-legend. - + Parameters: - colormap: A matplotlib colormap instance - n_labels: Number of labels/colors in the legend """ values = np.linspace(0, 1, n_labels) colors = [matplotlib.colors.rgb2hex(colormap(value)) for value in values] - labels = [f"{value:.2f}" for value in np.linspace(stat_data.min(), stat_data.max(), n_labels)] + labels = [ + f"{value:.2f}" + for value in np.linspace(stat_data.min(), stat_data.max(), n_labels) + ] return dict(zip(labels, colors)) - def handle_marker_click(self, row, **kwargs): - station_id = row['ObsID'] - station_name = row['StationName'] + station_id = row[self.config["station_id_column_name"]] + station_name = row["StationName"] + print(station_id) - self.handle_click(row['ObsID']) + self.handle_click(station_id) # Convert the station metadata to a DataFrame for display df = pd.DataFrame([row]) - title_plot = f"Time Series for Station ID: {station_id}, {station_name}" - title_table = f"Station property from metadata: {self.station_file_name}" + title_plot = f"Time Series for Station ID: {station_id}, {station_name}" self.f.layout.title.text = title_plot # Set title for the time series plot + print("hello") - # Display the DataFrame in the df_output widget - self.display_dataframe_with_scroll(df, title=title_table) - + # Display the DataFrame in the df_output widget + title_table = f"Station property from metadata: {self.station_file_name}" + self.display_dataframe_with_scroll(df, title=title_table) def handle_geojson_click(self, feature, **kwargs): # Extract properties directly from the feature - station_id = feature['properties']['station_id'] + station_id = feature["properties"]["station_id"] self.handle_click(station_id) # Display metadata of the station - df_station = self.stations_metadata[self.stations_metadata['ObsID'] == station_id] + df_station = self.stations_metadata[ + self.stations_metadata["ObsID"] == station_id + ] title_table = f"Station property from metadata: {self.station_file_name}" self.display_dataframe_with_scroll(df_station, title=title_table) - - + def generate_statistics_table(self, station_id): # print(f"Generating statistics table for station: {station_id}") - + data = [] # Loop through each simulation and get the statistics for the given station_id - + for exp_name, stats in self.statistics.items(): print("Loop initiated for experiment:", exp_name) print(station_id) - print(stats['station'].values) - if station_id in stats['station'].values: - print("Available station IDs in stats:", stats['station'].values) - row = [exp_name] + [stats[var].sel(station=station_id).values for var in stats.data_vars if var not in ['longitude', 'latitude']] + print(stats["station"].values) + if station_id in stats["station"].values: + print("Available station IDs in stats:", stats["station"].values) + row = [exp_name] + [ + stats[var].sel(station=station_id).values + for var in stats.data_vars + if var not in ["longitude", "latitude"] + ] data.append(row) print("Data after processing:", data) # Convert the data to a DataFrame for display - columns = ['Exp. name'] + list(stats.data_vars.keys()) + columns = ["Exp. name"] + list(stats.data_vars.keys()) statistics_df = pd.DataFrame(data, columns=columns) # Check if the dataframe has been generated correctly @@ -464,8 +521,9 @@ def generate_statistics_table(self, station_id): return statistics_df - - def overlay_external_vector(self, vector_data: str, style=None, fill_color=None, line_color=None): + def overlay_external_vector( + self, vector_data: str, style=None, fill_color=None, line_color=None + ): """Add vector data to the map.""" if style is None: style = { @@ -477,22 +535,22 @@ def overlay_external_vector(self, vector_data: str, style=None, fill_color=None, "fillColor": fill_color if fill_color else "#03f", "fillOpacity": 0.3, } - + # Load the GeoJSON data from the file - with open(vector_data, 'r') as f: + with open(vector_data, "r") as f: vector = json.load(f) # Create the GeoJSON layer geojson_layer = GeoJSON(data=vector, style=style) - + # Update the title label vector_name = os.path.basename(vector_data) - + # Define the callback to handle feature clicks def on_feature_click(event, feature, **kwargs): title = f"Feature property of the external vector: {vector_name}" - properties = feature['properties'] + properties = feature["properties"] df = properties_to_dataframe(properties) # Display the DataFrame in the df_output widget using the modified method @@ -500,7 +558,7 @@ def on_feature_click(event, feature, **kwargs): # Bind the callback to the layer geojson_layer.on_click(on_feature_click) - + self.geo_map.map.add_layer(geojson_layer) @staticmethod @@ -512,14 +570,15 @@ def plot_station(self, station_data, station_id, lat, lon): trace_name = f"Station {station_id} - {var}" if data_exists and trace_name not in existing_traces: # Use the time data from the dataset - x_data = station_data.get('time') - + x_data = station_data.get("time") + if x_data is not None: # Convert datetime64 array to native Python datetime objects x_data = pd.to_datetime(x_data) - self.f.add_scatter(x=x_data, y=y_data, mode='lines+markers', name=trace_name) - + self.f.add_scatter( + x=x_data, y=y_data, mode="lines+markers", name=trace_name + ) def properties_to_dataframe(properties: Dict) -> pd.DataFrame: @@ -530,16 +589,16 @@ def properties_to_dataframe(properties: Dict) -> pd.DataFrame: def filter_nan_values(dates, data_values): """ Filters out NaN values and their associated dates. - + Parameters: - dates: List of dates. - data_values: List of data values corresponding to the dates. - + Returns: - valid_dates: List of dates without NaN values. - valid_data: List of non-NaN data values. """ valid_dates = [date for date, val in zip(dates, data_values) if not np.isnan(val)] valid_data = [val for val in data_values if not np.isnan(val)] - + return valid_dates, valid_data diff --git a/notebooks/examples/4_visualisation_interactive.ipynb b/notebooks/examples/4_visualisation_interactive.ipynb index 6b8c4c8..628bd80 100644 --- a/notebooks/examples/4_visualisation_interactive.ipynb +++ b/notebooks/examples/4_visualisation_interactive.ipynb @@ -9,22 +9,20 @@ }, { "cell_type": "code", - "execution_count": 1, - "metadata": { - "tags": [] - }, + "execution_count": null, + "metadata": {}, "outputs": [], "source": [ "from hat.visualisation import NotebookMap\n", - "stations = \"/ec/vol/destine-hydro/OBSERVATIONS/outlets_v4.0_20230726_withEFAS.csv\"\n", - "observations = \"/ec/res4/scratch/macw/hat/destine/observations/destine_observations.nc\"\n", + "stations = \"~/git/hat/data/outlets_v4.0_20230726_withEFAS.csv\"\n", + "observations = \"~/git/hat/data/observations/destine_observations.nc\"\n", "simulations = {\n", - " \"i05j\": \"/ec/res4/scratch/macw/hat/destine/i05j/cama_i05j_stations.nc\",\n", - " \"i05h\": \"/ec/res4/scratch/macw/hat/destine/i05h/cama_i05h_stations.nc\",\n", + " \"i05j\": \"~/git/hat/data/cama_i05j_stations.nc\",\n", + " \"i05h\": \"~/git/hat/data/cama_i05h_stations.nc\",\n", "}\n", "statistics = {\n", - " \"i05j\": \"/ec/res4/scratch/macw/hat/destine/observations/statistics_i05j.nc\",\n", - " \"i05h\": \"/ec/res4/scratch/macw/hat/destine/observations/statistics_i05h.nc\",\n", + " \"i05j\": \"~/git/hat/data/observations/statistics_i05j.nc\",\n", + " \"i05h\": \"~/git/hat/data/observations/statistics_i05h.nc\",\n", "}\n", "config = {\n", " \"station_epsg\": 4326,\n", @@ -44,1450 +42,49 @@ }, { "cell_type": "code", - "execution_count": 2, - "metadata": { - "tags": [] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "station file\n", - "/ec/vol/destine-hydro/OBSERVATIONS/outlets_v4.0_20230726_withEFAS.csv\n", - "{'i05j': \n", - "Dimensions: (time: 10228, station: 6820)\n", - "Coordinates:\n", - " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", - " * station (station) object 'G0001' 'G0002' 'G0003' ... 'G10162' 'G10163'\n", - "Data variables:\n", - " dis (time, station) float32 ..., 'i05h': \n", - "Dimensions: (time: 10228, station: 6237)\n", - "Coordinates:\n", - " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", - " * station (station) object 'G0001' 'G0002' 'G0003' ... 'G10162' 'G10163'\n", - "Data variables:\n", - " dis (time, station) float32 ...}\n", - "1614\n", - " version__origin__name version__version station_id station_id_num EFAS-ID \n", - "2982 GLOFAS 11 G6215 6215 1 \\\n", - "2983 GLOFAS 11 G6216 6216 2 \n", - "2984 GLOFAS 11 G6217 6217 3 \n", - "2985 GLOFAS 11 G6218 6218 4 \n", - "2986 GLOFAS 11 G6219 6219 5 \n", - "... ... ... ... ... ... \n", - "6835 GLOFAS 11 G10068 10068 5218 \n", - "6836 GLOFAS 11 G10069 10069 5219 \n", - "6837 GLOFAS 11 G10070 10070 5220 \n", - "6838 GLOFAS 11 G10071 10071 5221 \n", - "6840 GLOFAS 11 G10073 10073 5223 \n", - "\n", - " EC_calib5k EC_calib StationName StationLon StationLat \n", - "2982 1 1 Schwabelweis 12.13873 49.023585 \\\n", - "2983 3 1 Hofkirchen 13.115212 48.676627 \n", - "2984 2 0 Pfelling 12.747379 48.879843 \n", - "2985 3 1 Barby 11.882244 51.984836 \n", - "2986 3 1 Wittenberg - Lutherstadt 12.646309 51.856531 \n", - "... ... ... ... ... ... \n", - "6835 -9999 1 Volari 17.11525 44.29215278 \n", - "6836 -9999 1 Vrbanja 17.272768 44.74743529 \n", - "6837 -9999 1 Novi Grad nizvodno 16.384199 45.05355904 \n", - "6838 -9999 0 Prijedor 16.70386 44.97385 \n", - "6840 -9999 0 Martinrive 5.637124 50.47889313 \n", - "\n", - " ... CamaLat1_15min CamaLon1_15min CamaArea_15min \n", - "2982 ... 49.125 12.125 35881.5 \\\n", - "2983 ... 48.625 13.125 49256.6 \n", - "2984 ... 48.875 12.625 37745.9 \n", - "2985 ... 51.875 11.875 93420.1 \n", - "2986 ... 51.875 12.625 60864 \n", - "... ... ... ... ... \n", - "6835 ... 44.125 17.125 556.4 \n", - "6836 ... 44.625 17.375 772.2 \n", - "6837 ... 45.125 16.375 7563.6 \n", - "6838 ... 44.875 16.625 2708.6 \n", - "6840 ... 50.375 5.875 996.1 \n", - "\n", - " EfasCamaVerif_Eccalib_500km2_1year24H \n", - "2982 1 \\\n", - "2983 1 \n", - "2984 \n", - "2985 1 \n", - "2986 1 \n", - "... ... \n", - "6835 1 \n", - "6836 1 \n", - "6837 1 \n", - "6838 \n", - "6840 \n", - "\n", - " EfasCamaVerif_Eccalib_500km2_1year24H_2017+ EfasCamaVerif500km2_1year24H \n", - "2982 1 1 \\\n", - "2983 1 1 \n", - "2984 1 \n", - "2985 1 1 \n", - "2986 1 1 \n", - "... ... ... \n", - "6835 1 1 \n", - "6836 1 1 \n", - "6837 1 1 \n", - "6838 1 \n", - "6840 \n", - "\n", - " EFAS_Duplicate geometry x y \n", - "2982 POINT (12.13873 49.02358) 12.138730 49.023585 \n", - "2983 POINT (13.11521 48.67663) 13.115212 48.676627 \n", - "2984 POINT (12.74738 48.87984) 12.747379 48.879843 \n", - "2985 POINT (11.88224 51.98484) 11.882244 51.984836 \n", - "2986 POINT (12.64631 51.85653) 12.646309 51.856531 \n", - "... ... ... ... ... \n", - "6835 POINT (17.11525 44.29215) 17.115250 44.292153 \n", - "6836 POINT (17.27277 44.74744) 17.272768 44.747435 \n", - "6837 POINT (16.38420 45.05356) 16.384199 45.053559 \n", - "6838 POINT (16.70386 44.97385) 16.703860 44.973850 \n", - "6840 POINT (5.63712 50.47889) 5.637124 50.478893 \n", - "\n", - "[1614 rows x 115 columns]\n", - "\n", - "Dimensions: (station: 1614, time: 10228)\n", - "Coordinates:\n", - " * station (station) object 'G9473' 'G6502' 'G7052' ... 'G6243' 'G7303'\n", - " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", - "Data variables:\n", - " obsdis (time, station) float32 ...\n", - "{'i05j': \n", - "Dimensions: (time: 10228, station: 1614)\n", - "Coordinates:\n", - " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", - " * station (station) object 'G9473' 'G6502' 'G7052' ... 'G6243' 'G7303'\n", - "Data variables:\n", - " dis (time, station) float32 ..., 'i05h': \n", - "Dimensions: (time: 10228, station: 1614)\n", - "Coordinates:\n", - " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", - " * station (station) object 'G9473' 'G6502' 'G7052' ... 'G6243' 'G7303'\n", - "Data variables:\n", - " dis (time, station) float32 ...}\n" - ] - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "map = NotebookMap(config, stations, observations, simulations, stats=statistics)" ] }, { "cell_type": "code", - "execution_count": 3, - "metadata": { - "tags": [] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Loop initiated for experiment: i05j\n", - "G9989\n", - "['G10000' 'G10001' 'G10002' ... 'G9989' 'G9990' 'G9998']\n", - "Available station IDs in stats: ['G10000' 'G10001' 'G10002' ... 'G9989' 'G9990' 'G9998']\n", - "Data after processing: [['i05j', array(0.45353499), array(4.8293457, dtype=float32), array(2.8585353, dtype=float32), array(0.49560919)]]\n", - "Loop initiated for experiment: i05h\n", - "G9989\n", - "['G10000' 'G10001' 'G10002' ... 'G9989' 'G9990' 'G9998']\n", - "Available station IDs in stats: ['G10000' 'G10001' 'G10002' ... 'G9989' 'G9990' 'G9998']\n", - "Data after processing: [['i05j', array(0.45353499), array(4.8293457, dtype=float32), array(2.8585353, dtype=float32), array(0.49560919)], ['i05h', array(0.33572683), array(5.825837, dtype=float32), array(3.5438168, dtype=float32), array(0.45184272)]]\n", - " Exp. name kge rmse mae correlation\n", - "0 i05j 0.4535349871603854 4.8293457 2.8585353 0.49560919449950164\n", - "1 i05h 0.335726830625943 5.825837 3.5438168 0.4518427229842832\n" - ] - }, - { - "ename": "AttributeError", - "evalue": "'NotebookMap' object has no attribute 'geo_map'", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[3], line 3\u001b[0m\n\u001b[1;32m 1\u001b[0m stats \u001b[38;5;241m=\u001b[39m \u001b[38;5;28mmap\u001b[39m\u001b[38;5;241m.\u001b[39mgenerate_statistics_table(\u001b[38;5;124m'\u001b[39m\u001b[38;5;124mG9989\u001b[39m\u001b[38;5;124m'\u001b[39m)\n\u001b[1;32m 2\u001b[0m \u001b[38;5;28mprint\u001b[39m(stats)\n\u001b[0;32m----> 3\u001b[0m \u001b[38;5;28;43mmap\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mdisplay_dataframe_with_scroll\u001b[49m\u001b[43m(\u001b[49m\u001b[43mstats\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mtitle\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mStatistics Overview\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n", - "File \u001b[0;32m/etc/ecmwf/nfs/dh1_home_a/macw/git/hat/hat/visualisation.py:216\u001b[0m, in \u001b[0;36mNotebookMap.display_dataframe_with_scroll\u001b[0;34m(self, df, title)\u001b[0m\n\u001b[1;32m 215\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mdisplay_dataframe_with_scroll\u001b[39m(\u001b[38;5;28mself\u001b[39m, df, title\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m\"\u001b[39m):\n\u001b[0;32m--> 216\u001b[0m \u001b[38;5;28;01mwith\u001b[39;00m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mgeo_map\u001b[49m\u001b[38;5;241m.\u001b[39mdf_output:\n\u001b[1;32m 217\u001b[0m clear_output(wait\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mTrue\u001b[39;00m)\n\u001b[1;32m 219\u001b[0m \u001b[38;5;66;03m# Define styles directly within the HTML content\u001b[39;00m\n", - "\u001b[0;31mAttributeError\u001b[0m: 'NotebookMap' object has no attribute 'geo_map'" - ] - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ - "stats = map.generate_statistics_table('G9989')\n", - "print(stats)\n", - "map.display_dataframe_with_scroll(stats, title=\"Statistics Overview\")" + "map.mapplot(colorby='kge', sim='i05j')" ] }, { "cell_type": "code", "execution_count": null, - "metadata": { - "tags": [] - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Initialising ipyleaflet\n", - "Initialising plotly\n", - "Creating stations markers\n", - "G6215\n", - "G6216\n", - "G6217\n", - "G6218\n", - "G6219\n", - "G6220\n", - "G6222\n", - "G6224\n", - "G6225\n", - "G6226\n", - "G6227\n", - "G6228\n", - "G6229\n", - "G6230\n", - "G6231\n", - "G6232\n", - "G6233\n", - "G6235\n", - "G6240\n", - "G6241\n", - "G6242\n", - "G6243\n", - "G6244\n", - "G6245\n", - "G6246\n", - "G6247\n", - "G6248\n", - "G6249\n", - "G6250\n", - "G6252\n", - "G6253\n", - "G6254\n", - "G6256\n", - "G6257\n", - "G6259\n", - "G6260\n", - "G6261\n", - "G6262\n", - "G6263\n", - "G6265\n", - "G6266\n", - "G6267\n", - "G6268\n", - "G6271\n", - "G6272\n", - "G6273\n", - "G6274\n", - "G6276\n", - "G6277\n", - "G6281\n", - "G6282\n", - "G6284\n", - "G6285\n", - "G6286\n", - "G6287\n", - "G6288\n", - "G6290\n", - "G6291\n", - "G6292\n", - "G6293\n", - "G6294\n", - "G6295\n", - "G6297\n", - "G6298\n", - "G6299\n", - "G6301\n", - "G6303\n", - "G6304\n", - "G6305\n", - "G6306\n", - "G6307\n", - "G6308\n", - "G6309\n", - "G6310\n", - "G6311\n", - "G6312\n", - "G6313\n", - "G6314\n", - "G6315\n", - "G6316\n", - "G6317\n", - "G6318\n", - "G6319\n", - "G6320\n", - "G6321\n", - "G6322\n", - "G6323\n", - "G6324\n", - "G6325\n", - "G6326\n", - "G6327\n", - "G6328\n", - "G6329\n", - "G6330\n", - "G6332\n", - "G6333\n", - "G6334\n", - "G6336\n", - "G6339\n", - "G6342\n", - "G6343\n", - "G6345\n", - "G6346\n", - "G6348\n", - "G6349\n", - "G6350\n", - "G6352\n", - "G6353\n", - "G6355\n", - "G6356\n", - "G6357\n", - "G6358\n", - "G6359\n", - "G6360\n", - "G6361\n", - "G6362\n", - "G6363\n", - "G6365\n", - "G6366\n", - "G6368\n", - "G6369\n", - "G6371\n", - "G6373\n", - "G6374\n", - "G6375\n", - "G6376\n", - "G6377\n", - "G6378\n", - "G6379\n", - "G6380\n", - "G6383\n", - "G6385\n", - "G6386\n", - "G6387\n", - "G6388\n", - "G6389\n", - "G6390\n", - "G6391\n", - "G6392\n", - "G6393\n", - "G6394\n", - "G6395\n", - "G6396\n", - "G6397\n", - "G6398\n", - "G6399\n", - "G6401\n", - "G6402\n", - "G6405\n", - "G6406\n", - "G6407\n", - "G6408\n", - "G6409\n", - "G6410\n", - "G6411\n", - "G6412\n", - "G6413\n", - "G6417\n", - "G6418\n", - "G6419\n", - "G6421\n", - "G6422\n", - "G6423\n", - "G6424\n", - "G6425\n", - "G6426\n", - "G6431\n", - "G6433\n", - "G6435\n", - "G6436\n", - "G6437\n", - "G6438\n", - "G6440\n", - "G6441\n", - "G6442\n", - "G6443\n", - "G6444\n", - "G6445\n", - "G6446\n", - "G6447\n", - "G6448\n", - "G6449\n", - "G6450\n", - "G6451\n", - "G6452\n", - "G6453\n", - "G6454\n", - "G6455\n", - "G6456\n", - "G6457\n", - "G6458\n", - "G6459\n", - "G6460\n", - "G6467\n", - "G6468\n", - "G6469\n", - "G6471\n", - "G6473\n", - "G6474\n", - "G6478\n", - "G6480\n", - "G6481\n", - "G6482\n", - "G6483\n", - "G6484\n", - "G6485\n", - "G6486\n", - "G6487\n", - "G6488\n", - "G6489\n", - "G6490\n", - "G6491\n", - "G6492\n", - "G6493\n", - "G6494\n", - "G6495\n", - "G6496\n", - "G6497\n", - "G6498\n", - "G6499\n", - "G6500\n", - "G6501\n", - "G6502\n", - "G6503\n", - "G6504\n", - "G6505\n", - "G6506\n", - "G6508\n", - "G6509\n", - "G6510\n", - "G6512\n", - "G6513\n", - "G6517\n", - "G6518\n", - "G6519\n", - "G6520\n", - "G6521\n", - "G6523\n", - "G6527\n", - "G6532\n", - "G6536\n", - "G6541\n", - "G6543\n", - "G6544\n", - "G6545\n", - "G6546\n", - "G6547\n", - "G6548\n", - "G6549\n", - "G6552\n", - "G6554\n", - "G6556\n", - "G6557\n", - "G6558\n", - "G6559\n", - "G6560\n", - "G6561\n", - "G6562\n", - "G6563\n", - "G6564\n", - "G6565\n", - "G6566\n", - "G6567\n", - "G6568\n", - "G6570\n", - "G6571\n", - "G6575\n", - "G6576\n", - "G6578\n", - "G6579\n", - "G6580\n", - "G6581\n", - "G6588\n", - "G6589\n", - "G6596\n", - "G6597\n", - "G6598\n", - "G6599\n", - "G6601\n", - "G6602\n", - "G6603\n", - "G6604\n", - "G6605\n", - "G6606\n", - "G6610\n", - "G6613\n", - "G6618\n", - "G6621\n", - "G6628\n", - "G6629\n", - "G6630\n", - "G6632\n", - "G6634\n", - "G6636\n", - "G6637\n", - "G6642\n", - "G6644\n", - "G6651\n", - "G6658\n", - "G6662\n", - "G6664\n", - "G6665\n", - "G6666\n", - "G6667\n", - "G6668\n", - "G6669\n", - "G6670\n", - "G6671\n", - "G6672\n", - "G6673\n", - "G6674\n", - "G6675\n", - "G6676\n", - "G6677\n", - "G6678\n", - "G6679\n", - "G6680\n", - "G6681\n", - "G6682\n", - "G6683\n", - "G6684\n", - "G6685\n", - "G6686\n", - "G6687\n", - "G6688\n", - "G6689\n", - "G6690\n", - "G6691\n", - "G6692\n", - "G6693\n", - "G6694\n", - "G6695\n", - "G6696\n", - "G6697\n", - "G6698\n", - "G6700\n", - "G6704\n", - "G6705\n", - "G6707\n", - "G6708\n", - "G6709\n", - "G6710\n", - "G6711\n", - "G6712\n", - "G6714\n", - "G6717\n", - "G6718\n", - "G6719\n", - "G6720\n", - "G6723\n", - "G6724\n", - "G6725\n", - "G6728\n", - "G6736\n", - "G6737\n", - "G6738\n", - "G6739\n", - "G6740\n", - "G6742\n", - "G6743\n", - "G6745\n", - "G6746\n", - "G6750\n", - "G6751\n", - "G6752\n", - "G6753\n", - "G6754\n", - "G6755\n", - "G6756\n", - "G6758\n", - "G6759\n", - "G6760\n", - "G6767\n", - "G6768\n", - "G6769\n", - "G6771\n", - "G6772\n", - "G6775\n", - "G6776\n", - "G6779\n", - "G6781\n", - "G6783\n", - "G6784\n", - "G6785\n", - "G6786\n", - "G6787\n", - "G6788\n", - "G6801\n", - "G6802\n", - "G6803\n", - "G6804\n", - "G6805\n", - "G6806\n", - "G6807\n", - "G6808\n", - "G6809\n", - "G6810\n", - "G6811\n", - "G6812\n", - "G6813\n", - "G6815\n", - "G6816\n", - "G6817\n", - "G6818\n", - "G6819\n", - "G6820\n", - "G6821\n", - "G6822\n", - "G6823\n", - "G6824\n", - "G6825\n", - "G6826\n", - "G6827\n", - "G6828\n", - "G6829\n", - "G6830\n", - "G6831\n", - "G6832\n", - "G6833\n", - "G6834\n", - "G6835\n", - "G6836\n", - "G6837\n", - "G6838\n", - "G6839\n", - "G6840\n", - "G6841\n", - "G6842\n", - "G6843\n", - "G6845\n", - "G6846\n", - "G6847\n", - "G6848\n", - "G6849\n", - "G6850\n", - "G6851\n", - "G6852\n", - "G6853\n", - "G6854\n", - "G6855\n", - "G6856\n", - "G6857\n", - "G6860\n", - "G6861\n", - "G6862\n", - "G6864\n", - "G6866\n", - "G6867\n", - "G6870\n", - "G6871\n", - "G6872\n", - "G6874\n", - "G6875\n", - "G6877\n", - "G6878\n", - "G6879\n", - "G6881\n", - "G6882\n", - "G6883\n", - "G6884\n", - "G6885\n", - "G6889\n", - "G6891\n", - "G6892\n", - "G6893\n", - "G6895\n", - "G6896\n", - "G6897\n", - "G6898\n", - "G6899\n", - "G6900\n", - "G6901\n", - "G6902\n", - "G6903\n", - "G6904\n", - "G6906\n", - "G6907\n", - "G6908\n", - "G6910\n", - "G6912\n", - "G6913\n", - "G6914\n", - "G6920\n", - "G6922\n", - "G6926\n", - "G6927\n", - "G6928\n", - "G6929\n", - "G6930\n", - "G6931\n", - "G6932\n", - "G6933\n", - "G6934\n", - "G6935\n", - "G6936\n", - "G6937\n", - "G6938\n", - "G6939\n", - "G6940\n", - "G6941\n", - "G6944\n", - "G6945\n", - "G6947\n", - "G6948\n", - "G6950\n", - "G6953\n", - "G6954\n", - "G6955\n", - "G6956\n", - "G6957\n", - "G6958\n", - "G6971\n", - "G6976\n", - "G6979\n", - "G6983\n", - "G6984\n", - "G6985\n", - "G6986\n", - "G6987\n", - "G6988\n", - "G6989\n", - "G6990\n", - "G6992\n", - "G6993\n", - "G6994\n", - "G6995\n", - "G6997\n", - "G6998\n", - "G7003\n", - "G7004\n", - "G7005\n", - "G7006\n", - "G7007\n", - "G7009\n", - "G7010\n", - "G7011\n", - "G7012\n", - "G7013\n", - "G7016\n", - "G7017\n", - "G7019\n", - "G7020\n", - "G7021\n", - "G7022\n", - "G7023\n", - "G7024\n", - "G7025\n", - "G7026\n", - "G7027\n", - "G7028\n", - "G7030\n", - "G7032\n", - "G7035\n", - "G7036\n", - "G7037\n", - "G7038\n", - "G7039\n", - "G7040\n", - "G7042\n", - "G7043\n", - "G7044\n", - "G7045\n", - "G7046\n", - "G7048\n", - "G7049\n", - "G7050\n", - "G7051\n", - "G7052\n", - "G7054\n", - "G7057\n", - "G7058\n", - "G7059\n", - "G7060\n", - "G7061\n", - "G7062\n", - "G7063\n", - "G7064\n", - "G7067\n", - "G7068\n", - "G7069\n", - "G7071\n", - "G7073\n", - "G7080\n", - "G7081\n", - "G7082\n", - "G7084\n", - "G7088\n", - "G7096\n", - "G7097\n", - "G7098\n", - "G7100\n", - "G7101\n", - "G7102\n", - "G7103\n", - "G7104\n", - "G7105\n", - "G7106\n", - "G7107\n", - "G7108\n", - "G7112\n", - "G7113\n", - "G7114\n", - "G7116\n", - "G7118\n", - "G7122\n", - "G7125\n", - "G7126\n", - "G7130\n", - "G7131\n", - "G7132\n", - "G7133\n", - "G7134\n", - "G7135\n", - "G7136\n", - "G7138\n", - "G7139\n", - "G7140\n", - "G7141\n", - "G7142\n", - "G7143\n", - "G7144\n", - "G7145\n", - "G7148\n", - "G7149\n", - "G7150\n", - "G7151\n", - "G7154\n", - "G7155\n", - "G7156\n", - "G7157\n", - "G7158\n", - "G7159\n", - "G7160\n", - "G7161\n", - "G7163\n", - "G7164\n", - "G7165\n", - "G7166\n", - "G7168\n", - "G7169\n", - "G7174\n", - "G7176\n", - "G7178\n", - "G7179\n", - "G7182\n", - "G7183\n", - "G7186\n", - "G7187\n", - "G7188\n", - "G7189\n", - "G7191\n", - "G7193\n", - "G7194\n", - "G7195\n", - "G7196\n", - "G7198\n", - "G7199\n", - "G7201\n", - "G7202\n", - "G7203\n", - "G7204\n", - "G7206\n", - "G7207\n", - "G7208\n", - "G7209\n", - "G7210\n", - "G7211\n", - "G7212\n", - "G7213\n", - "G7214\n", - "G7217\n", - "G7218\n", - "G7219\n", - "G7220\n", - "G7224\n", - "G7225\n", - "G7226\n", - "G7227\n", - "G7228\n", - "G7229\n", - "G7230\n", - "G7233\n", - "G7234\n", - "G7235\n", - "G7236\n", - "G7239\n", - "G7240\n", - "G7241\n", - "G7242\n", - "G7244\n", - "G7245\n", - "G7247\n", - "G7248\n", - "G7249\n", - "G7251\n", - "G7254\n", - "G7257\n", - "G7258\n", - "G7260\n", - "G7261\n", - "G7262\n", - "G7264\n", - "G7265\n", - "G7266\n", - "G7267\n", - "G7268\n", - "G7278\n", - "G7279\n", - "G7283\n", - "G7284\n", - "G7285\n", - "G7287\n", - "G7288\n", - "G7289\n", - "G7290\n", - "G7291\n", - "G7292\n", - "G7293\n", - "G7294\n", - "G7295\n", - "G7298\n", - "G7299\n", - "G7300\n", - "G7301\n", - "G7302\n", - "G7303\n", - "G7304\n", - "G7305\n", - "G7306\n", - "G7307\n", - "G7308\n", - "G7309\n", - "G7310\n", - "G7311\n", - "G7312\n", - "G7313\n", - "G7314\n", - "G7315\n", - "G7316\n", - "G7317\n", - "G7318\n", - "G7319\n", - "G7320\n", - "G7321\n", - "G7322\n", - "G7323\n", - "G7324\n", - "G7325\n", - "G7326\n", - "G7327\n", - "G7328\n", - "G7329\n", - "G7330\n", - "G7331\n", - "G7333\n", - "G7335\n", - "G7336\n", - "G7340\n", - "G7343\n", - "G7346\n", - "G7348\n", - "G7349\n", - "G7352\n", - "G7354\n", - "G7356\n", - "G7357\n", - "G7358\n", - "G7359\n", - "G7360\n", - "G7361\n", - "G7362\n", - "G7363\n", - "G7364\n", - "G7365\n", - "G7366\n", - "G7367\n", - "G7368\n", - "G7373\n", - "G7376\n", - "G7377\n", - "G7378\n", - "G7379\n", - "G7381\n", - "G7382\n", - "G7383\n", - "G7386\n", - "G7388\n", - "G7389\n", - "G7390\n", - "G7391\n", - "G7392\n", - "G7394\n", - "G7395\n", - "G7397\n", - "G7398\n", - "G7400\n", - "G7401\n", - "G7403\n", - "G7405\n", - "G7407\n", - "G7410\n", - "G7411\n", - "G7415\n", - "G7416\n", - "G7417\n", - "G7418\n", - "G7424\n", - "G7426\n", - "G7427\n", - "G7432\n", - "G7433\n", - "G7479\n", - "G7486\n", - "G7487\n", - "G7488\n", - "G7489\n", - "G7490\n", - "G7491\n", - "G7492\n", - "G7493\n", - "G7494\n", - "G7495\n", - "G7496\n", - "G7497\n", - "G7498\n", - "G7499\n", - "G7500\n", - "G7501\n", - "G7502\n", - "G7503\n", - "G7504\n", - "G7505\n", - "G7506\n", - "G7507\n", - "G7508\n", - "G7509\n", - "G7510\n", - "G7511\n", - "G7512\n", - "G7513\n", - "G7514\n", - "G7515\n", - "G7516\n", - "G7517\n", - "G7518\n", - "G7519\n", - "G7520\n", - "G7521\n", - "G7522\n", - "G7523\n", - "G7524\n", - "G7525\n", - "G7583\n", - "G7584\n", - "G7585\n", - "G7586\n", - "G7587\n", - "G7588\n", - "G7589\n", - "G7590\n", - "G7591\n", - "G7592\n", - "G7593\n", - "G7595\n", - "G7596\n", - "G7597\n", - "G7598\n", - "G7599\n", - "G7600\n", - "G7602\n", - "G7604\n", - "G7605\n", - "G7606\n", - "G7607\n", - "G7610\n", - "G7611\n", - "G7615\n", - "G7618\n", - "G7619\n", - "G7620\n", - "G7623\n", - "G7627\n", - "G7628\n", - "G7629\n", - "G7630\n", - "G7632\n", - "G7635\n", - "G7636\n", - "G7637\n", - "G7638\n", - "G7643\n", - "G7644\n", - "G7646\n", - "G7647\n", - "G7651\n", - "G7652\n", - "G7653\n", - "G7654\n", - "G7655\n", - "G7659\n", - "G7660\n", - "G7661\n", - "G7663\n", - "G7664\n", - "G7665\n", - "G7666\n", - "G7669\n", - "G7670\n", - "G7671\n", - "G7672\n", - "G7673\n", - "G7674\n", - "G7675\n", - "G7676\n", - "G7678\n", - "G7680\n", - "G7681\n", - "G7683\n", - "G7684\n", - "G7685\n", - "G7781\n", - "G7782\n", - "G7784\n", - "G7786\n", - "G7789\n", - "G7791\n", - "G7795\n", - "G7797\n", - "G7798\n", - "G7799\n", - "G7829\n", - "G7830\n", - "G7831\n", - "G7832\n", - "G7833\n", - "G7834\n", - "G7835\n", - "G7836\n", - "G7837\n", - "G7838\n", - "G7839\n", - "G7840\n", - "G7841\n", - "G7842\n", - "G7843\n", - "G7844\n", - "G7845\n", - "G7847\n", - "G7849\n", - "G7896\n", - "G7898\n", - "G7899\n", - "G7900\n", - "G7901\n", - "G7902\n", - "G7903\n", - "G7905\n", - "G7906\n", - "G7907\n", - "G7908\n", - "G7909\n", - "G7910\n", - "G7911\n", - "G7912\n", - "G7913\n", - "G7915\n", - "G7916\n", - "G7917\n", - "G7918\n", - "G7919\n", - "G7920\n", - "G7921\n", - "G7922\n", - "G7923\n", - "G7924\n", - "G7925\n", - "G7926\n", - "G7927\n", - "G7929\n", - "G7930\n", - "G7932\n", - "G7934\n", - "G7935\n", - "G7936\n", - "G7937\n", - "G7938\n", - "G7940\n", - "G7941\n", - "G7943\n", - "G7944\n", - "G7945\n", - "G7946\n", - "G7948\n", - "G7949\n", - "G7950\n", - "G7951\n", - "G7952\n", - "G7953\n", - "G7954\n", - "G7955\n", - "G7956\n", - "G7957\n", - "G7958\n", - "G7959\n", - "G7960\n", - "G7961\n", - "G7962\n", - "G7967\n", - "G7968\n", - "G7970\n", - "G7977\n", - "G7978\n", - "G7979\n", - "G7980\n", - "G7981\n", - "G7982\n", - "G7983\n", - "G7984\n", - "G9117\n", - "G9121\n", - "G9122\n", - "G9123\n", - "G9124\n", - "G9125\n", - "G9126\n", - "G9127\n", - "G9128\n", - "G9129\n", - "G9133\n", - "G9134\n", - "G9135\n", - "G9137\n", - "G9138\n", - "G9139\n", - "G9170\n", - "G9171\n", - "G9172\n", - "G9173\n", - "G9175\n", - "G9178\n", - "G9179\n", - "G9184\n", - "G9185\n", - "G9186\n", - "G9187\n", - "G9188\n", - "G9189\n", - "G9190\n", - "G9191\n", - "G9192\n", - "G9193\n", - "G9194\n", - "G9195\n", - "G9196\n", - "G9197\n", - "G9198\n", - "G9199\n", - "G9200\n", - "G9201\n", - "G9202\n", - "G9203\n", - "G9204\n", - "G9205\n", - "G9206\n", - "G9207\n", - "G9208\n", - "G9209\n", - "G9210\n", - "G9211\n", - "G9212\n", - "G9213\n", - "G9214\n", - "G9215\n", - "G9216\n", - "G9217\n", - "G9218\n", - "G9219\n", - "G9220\n", - "G9221\n", - "G9222\n", - "G9223\n", - "G9224\n", - "G9225\n", - "G9226\n", - "G9227\n", - "G9228\n", - "G9229\n", - "G9230\n", - "G9231\n", - "G9232\n", - "G9233\n", - "G9234\n", - "G9235\n", - "G9236\n", - "G9237\n", - "G9238\n", - "G9239\n", - "G9240\n", - "G9241\n", - "G9242\n", - "G9243\n", - "G9244\n", - "G9245\n", - "G9246\n", - "G9247\n", - "G9248\n", - "G9249\n", - "G9250\n", - "G9251\n", - "G9252\n", - "G9253\n", - "G9254\n", - "G9255\n", - "G9256\n", - "G9257\n", - "G9258\n", - "G9259\n", - "G9260\n", - "G9261\n", - "G9262\n", - "G9263\n", - "G9264\n", - "G9265\n", - "G9266\n", - "G9267\n", - "G9268\n", - "G9269\n", - "G9270\n", - "G9271\n", - "G9272\n", - "G9273\n", - "G9274\n", - "G9275\n", - "G9276\n", - "G9277\n", - "G9278\n", - "G9279\n", - "G9280\n", - "G9281\n", - "G9282\n", - "G9283\n", - "G9284\n", - "G9285\n", - "G9286\n", - "G9287\n", - "G9288\n", - "G9289\n", - "G9290\n", - "G9291\n", - "G9292\n", - "G9293\n", - "G9294\n", - "G9295\n", - "G9296\n", - "G9297\n", - "G9298\n", - "G9299\n", - "G9300\n", - "G9301\n", - "G9302\n", - "G9303\n", - "G9304\n", - "G9305\n", - "G9306\n", - "G9307\n", - "G9308\n", - "G9309\n", - "G9310\n", - "G9311\n", - "G9312\n", - "G9313\n", - "G9314\n", - "G9315\n", - "G9316\n", - "G9317\n", - "G9318\n", - "G9319\n", - "G9320\n", - "G9321\n", - "G9322\n", - "G9323\n", - "G9324\n", - "G9325\n", - "G9326\n", - "G9327\n", - "G9328\n", - "G9329\n", - "G9330\n", - "G9331\n", - "G9332\n", - "G9333\n", - "G9334\n", - "G9335\n", - "G9336\n", - "G9337\n", - "G9338\n", - "G9339\n", - "G9340\n", - "G9341\n", - "G9342\n", - "G9343\n", - "G9344\n", - "G9345\n", - "G9346\n", - "G9347\n", - "G9348\n", - "G9349\n", - "G9350\n", - "G9351\n", - "G9357\n", - "G9358\n", - "G9360\n", - "G9361\n", - "G9362\n", - "G9363\n", - "G9364\n", - "G9365\n", - "G9367\n", - "G9370\n", - "G9371\n", - "G9372\n", - "G9373\n", - "G9374\n", - "G9375\n", - "G9376\n", - "G9377\n", - "G9378\n", - "G9379\n", - "G9380\n", - "G9381\n", - "G9382\n", - "G9392\n", - "G9393\n", - "G9395\n", - "G9397\n", - "G9398\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ - "map.mapplot(colorby='kge', sim='i05j')" + "\n", + "df_stats = map.generate_statistics_table(\"G6267\")\n", + "map.display_dataframe_with_scroll(df_stats, title=\"Statistics Overview\")" ] }, { "cell_type": "code", "execution_count": null, - "metadata": { - "tags": [] - }, + "metadata": {}, "outputs": [], "source": [ "map.generate_statistics_table('G1253')" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, { "cell_type": "code", "execution_count": null, @@ -1509,18 +106,9 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "ename": "", - "evalue": "", - "output_type": "error", - "traceback": [ - "\u001b[1;31mThe Kernel crashed while executing code in the the current cell or a previous cell. Please review the code in the cell(s) to identify a possible cause of the failure. Click here for more info. View Jupyter log for further details." - ] - } - ], + "outputs": [], "source": [ "from hat.visualisation_v2 import NotebookMap as nbm\n", "stations= '~/destinE/outlets_v4.0_20230726_withEFAS.csv'\n", @@ -1616,22 +204,8 @@ } ], "metadata": { - "kernelspec": { - "display_name": "conda_hat", - "language": "python", - "name": "conda_hat" - }, "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.11" + "name": "python" } }, "nbformat": 4, From 04908efc28c3aab2ca755a53e03056476bd21f14 Mon Sep 17 00:00:00 2001 From: Corentin Carton de Wiart Date: Wed, 11 Oct 2023 12:27:07 +0100 Subject: [PATCH 10/31] update viz notebook, working! --- hat/visualisation.py | 61 +++++--- .../4_visualisation_interactive.ipynb | 137 ------------------ 2 files changed, 37 insertions(+), 161 deletions(-) diff --git a/hat/visualisation.py b/hat/visualisation.py index 368dfaa..e660147 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -42,7 +42,7 @@ class IPyLeaflet: def __init__(self): # Initialize the map widget - self.map = Map(zoom=5, layout=Layout(width="500px", height="500px")) + self.map = Map(zoom=5, layout=Layout(width="1000px", height="500px")) pd.set_option("display.max_colwidth", None) @@ -57,9 +57,9 @@ def __init__(self): ) # Main layout: map and output widget on top, properties table at the bottom - self.layout = VBox( - [HBox([self.map, self.output_widget]), self.df_stats, self.df_output] - ) + # self.layout = VBox( + # [HBox([self.map, self.output_widget]), self.df_stats, self.df_output] + # ) def add_marker(self, lat: float, lon: float, on_click_callback): """Add a marker to the map.""" @@ -67,15 +67,12 @@ def add_marker(self, lat: float, lon: float, on_click_callback): marker.on_click(on_click_callback) self.map.add_layer(marker) - def display(self): - display(self.vbox) - @staticmethod def initialize_plot(): """Initialize a plotly figure widget.""" f = go.FigureWidget( layout=go.Layout( - width=600, + width=1000, legend=dict( orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 ), @@ -241,8 +238,8 @@ def calculate_statistics(self): statistics[exp] = run_analysis(self.stats, sim_ds_f, obs_ds_f) return statistics - def display_dataframe_with_scroll(self, df, title=""): - with self.geo_map.df_stats: + def display_dataframe_with_scroll(self, df, output, title=""): + with output: clear_output(wait=True) # Define styles directly within the HTML content @@ -250,7 +247,7 @@ def display_dataframe_with_scroll(self, df, title=""): """ + + title_style = "style='font-size: 18px; font-weight: bold; text-align: center;'" table_html = df.to_html(classes="custom-table") - content = f"{table_style}

{title}

{table_html}
" + content = f"{table_style}

{title}

{table_html}
" display(HTML(content)) @@ -344,7 +343,7 @@ def handle_click(self, station_id): self.f.add_trace( go.Scatter( - x=valid_dates_ds, y=valid_data_ds, mode="lines", name=name + x=valid_dates_ds, y=valid_data_ds, mode="lines", name="Simulation: "+name ) ) else: @@ -387,22 +386,59 @@ def handle_click(self, station_id): clear_output(wait=True) # Clear any previous plots or messages display(self.f) - def mapplot(self, colorby="kge", sim = None): + def update_plot_based_on_date(self, change): + """Update the x-axis range of the plot based on selected dates.""" + start_date = self.start_date_picker.value.strftime('%Y-%m-%d') + end_date = self.end_date_picker.value.strftime('%Y-%m-%d') + + # Update the x-axis range of the plotly figure + self.f.update_layout(xaxis_range=[start_date, end_date]) + + def generate_html_legend(self, colormap, min_val, max_val): + # Convert the colormap to a list of RGB values + rgb_values = [matplotlib.colors.rgb2hex(colormap(i)) for i in np.linspace(0, 1, 256)] + + # Create a gradient style using the RGB values + gradient_style = ', '.join(rgb_values) + gradient_html = f""" +
+ """ + + # Create labels + labels_html = f""" +
+ Low: {min_val:.1f} + High: {max_val:.1f} +
+ """ + + # Combine gradient and labels + legend_html = gradient_html + labels_html + + return HTML(legend_html) + + + def mapplot(self, colorby="kge", sim = None, range = None): #If sim / experiement name is not provided, by default it takes the first one in the dictionary of simulation list if sim is None: sim = list(self.simulations.keys())[0] - + # Create an instance of IPyLeaflet with the calculated bounds self.geo_map = IPyLeaflet(bounds=self.bounds) - print("Initialising plotly") + # Initialize a plotly figure widget for the time series self.f = ( self.geo_map.initialize_plot() - ) # Initialize a plotly figure widget for the time series + ) - # # Initialize the dataframe widgets with empty dataframes - # self.geo_map.initialize_dataframes() + # self.geo_map.initialize_statistics_table() + # self.geo_map.initialize_station_property_table() # Convert ds 'time' to datetime format for alignment with external_df self.ds_time = self.obs_ds["time"].values.astype("datetime64[D]") @@ -428,17 +464,22 @@ def mapplot(self, colorby="kge", sim = None): f"Statistic '{colorby}' not found in computed statistics for simulation '{sim}'." ) - # Retrieve the desired data + # Retrieve the statistics data of simulation choice/ by default stat_data = self.statistics[sim][colorby] - # Define a colormap (you can choose any other colormap that you like) - colormap = plt.cm.viridis - # Normalize the data for coloring - norm = plt.Normalize(stat_data.min(), stat_data.max()) - + if range is None: + min_val, max_val = stat_data.values.min(), stat_data.values.max() + else: + min_val, max_val = range[0], range[1] + + norm = plt.Normalize(min_val, max_val) + + #create legend widget + colormap = plt.cm.YlGnBu + legend_widget = self.generate_html_legend(colormap, min_val, max_val) - # Create a GeoJSON structure from the stations_metadata DataFrame + # Create marker from stations_metadata for _, row in self.stations_metadata.iterrows(): lat, lon = row["StationLat"], row["StationLon"] station_id = row[self.config["station_id_column_name"]] @@ -452,45 +493,54 @@ def mapplot(self, colorby="kge", sim = None): circle_marker = CircleMarker( location=(lat, lon), - radius=5, + radius=7, color="gray", fill_color=color, fill_opacity=0.8, + weight = 1 ) circle_marker.on_click(partial(self.handle_marker_click, row=row)) self.geo_map.map.add_layer(circle_marker) + # Add date pickers for start and end dates + self.start_date_picker = DatePicker(description='Start Date') + self.end_date_picker = DatePicker(description='End Date') - center_layout = Layout(align_items='center') - top_right_frame = VBox([self.f, self.geo_map.df_stats],layout=center_layout ) - main_top_frame = HBox([self.geo_map.map, top_right_frame],layout=center_layout ) - layout = VBox([main_top_frame, self.geo_map.df_output],layout=center_layout ) + # Observe changes in the date pickers to update the plot + self.start_date_picker.observe(self.update_plot_based_on_date, names='value') + self.end_date_picker.observe(self.update_plot_based_on_date, names='value') + title_label = Label( + "Interactive Map Visualisation for Hydrological Model Performance", + layout=Layout(justify_content='center'), + style={'font_weight': 'bold', 'font_size': '24px', 'font_family': 'Arial'} + ) + date_label = Label("Please select the date to accurately change the date axis of the plot") - # self.layout = VBox([HBox([self.geo_map.map, self.f, self.statistics_output]), self.geo_map.df_output, self.loading_label]) - display(layout) + center_layout = Layout(justify_content='center',align_items='center',spacing='2px' ) + date_picker_box = HBox([self.start_date_picker, self.end_date_picker],layout=center_layout) + top_right_frame = VBox([self.f, date_label, date_picker_box, self.geo_map.df_stats],layout=center_layout) + top_left_frame = VBox([self.geo_map.map, legend_widget],layout=center_layout) + main_top_frame = HBox([top_left_frame, top_right_frame],layout=center_layout ) + layout = VBox([title_label, self.loading_label, main_top_frame, self.geo_map.df_output],layout=center_layout) - def colormap_to_legend(self, stat_data, colormap, n_labels=5): - """ - Convert a matplotlib colormap to a dictionary suitable for ipyleaflet-legend. - """ - values = np.linspace(0, 1, n_labels) - colors = [matplotlib.colors.rgb2hex(colormap(value)) for value in values] - labels = [ - f"{value:.2f}" - for value in np.linspace(stat_data.min(), stat_data.max(), n_labels) - ] - return dict(zip(labels, colors)) + + display(layout) + def handle_marker_click(self, row, **kwargs): station_id = row[self.config["station_id_column_name"]] station_name = row["StationName"] self.handle_click(station_id) - title_plot = f"Time Series for Station ID: {station_id}, {station_name}" - self.f.update_layout(title_x=0.5) # Centre title - self.f.layout.title.text = title_plot # Set title for the time series plot + title_plot = f"Time Series for the selected station:
ID: {station_id}, name: {station_name}" + self.f.update_layout( + title_text=title_plot, + title_font=dict(size=18, family="Arial", color="black"), + title_x=0.5, + title_y=0.96 # Adjust as needed to position the title appropriately + ) # Display the DataFrame in the df_output widget df = pd.DataFrame( @@ -545,46 +595,46 @@ def generate_statistics_table(self, station_id): return statistics_df - def overlay_external_vector( - self, vector_data: str, style=None, fill_color=None, line_color=None - ): - """Add vector data to the map.""" - if style is None: - style = { - "stroke": True, - "color": line_color if line_color else "#FF0000", - "weight": 2, - "opacity": 1, - "fill": True, - "fillColor": fill_color if fill_color else "#03f", - "fillOpacity": 0.3, - } + # def overlay_external_vector( + # self, vector_data: str, style=None, fill_color=None, line_color=None + # ): + # """Add vector data to the map.""" + # if style is None: + # style = { + # "stroke": True, + # "color": line_color if line_color else "#FF0000", + # "weight": 2, + # "opacity": 1, + # "fill": True, + # "fillColor": fill_color if fill_color else "#03f", + # "fillOpacity": 0.3, + # } - # Load the GeoJSON data from the file - with open(vector_data, "r") as f: - vector = json.load(f) + # # Load the GeoJSON data from the file + # with open(vector_data, "r") as f: + # vector = json.load(f) - # Create the GeoJSON layer - geojson_layer = GeoJSON(data=vector, style=style) + # # Create the GeoJSON layer + # geojson_layer = GeoJSON(data=vector, style=style) - # Update the title label - vector_name = os.path.basename(vector_data) + # # Update the title label + # # vector_name = os.path.basename(vector_data) - # # Define the callback to handle feature clicks - # def on_feature_click(event, feature, **kwargs): - # title = f"Feature property of the external vector: {vector_name}" + # # # Define the callback to handle feature clicks + # # def on_feature_click(event, feature, **kwargs): + # # title = f"Feature property of the external vector: {vector_name}" - # properties = feature["properties"] - # df = properties_to_dataframe(properties) + # # properties = feature["properties"] + # # df = properties_to_dataframe(properties) - # Bind the callback to the layer - geojson_layer.on_click(on_feature_click) + # # Bind the callback to the layer + # geojson_layer.on_click(on_feature_click) - self.geo_map.map.add_layer(geojson_layer) + # self.geo_map.map.add_layer(geojson_layer) @staticmethod - def plot_station(self, station_data, station_id, lat, lon): + def plot_station(self, station_data, station_id): existing_traces = [trace.name for trace in self.f.data] for var, y_data in station_data.items(): data_exists = len(y_data) > 0 diff --git a/notebooks/examples/4_visualisation_interactive_d.ipynb b/notebooks/examples/4_visualisation_interactive_d.ipynb index ece13da..0aa631c 100644 --- a/notebooks/examples/4_visualisation_interactive_d.ipynb +++ b/notebooks/examples/4_visualisation_interactive_d.ipynb @@ -4,32 +4,17 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Import necessart visualisation library and define inputs simulation and additional vector to add" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Display map and use the tool to show plot of the simulation time series.\n", - "Additionally add external vector file to overlay on the existsing map" + "1. Import necessart visualisation library and define inputs simulation and additional vector to add" ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Found 49 common stations\n" - ] - } - ], + "outputs": [], "source": [ "from hat.visualisation import NotebookMap\n", + "\n", "stations= '~/destinE/outlets_v4.0_20230726_withEFAS.csv'\n", "observations= '~/destinE/destine_observation.nc'\n", "\n", @@ -45,18 +30,45 @@ " \"station_coordinates\": [\"StationLon\", \"StationLat\"],\n", " \"obs_var_name\": \"obsdis\",\n", " \"sim_var_name\": \"dis\"\n", - "\n", "}\n", "\n", "statistics = {\n", " \"i05j\": \"~/destinE/statistics_i05j.nc\",\n", " \"i05h\": \"~/destinE/statistics_i05h.nc\"\n", - "}\n", - "\n", - "\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "2. Initialise & processing all the input data into the map object" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Found 49 common stations\n" + ] + } + ], + "source": [ "map = NotebookMap(config, stations, observations, simulations, stats=statistics)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "3. Display map interface for interactive viewing" + ] + }, { "cell_type": "code", "execution_count": 3, @@ -66,20 +78,18 @@ "name": "stdout", "output_type": "stream", "text": [ - "Initialising ipyleaflet\n", - "Initialising plotly\n", - "Creating stations markers\n" + "Initialising plotly\n" ] }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "283e6362ad2e41c692bcf262c6120e18", + "model_id": "152c7bedde3f4f0db087535b1eb1838d", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "VBox(children=(HBox(children=(Map(center=[0.0, 0.0], controls=(ZoomControl(options=['position', 'zoom_in_text'…" + "VBox(children=(Label(value='Interactive Map Visualisation for Hydrological Model Performance', layout=Layout(j…" ] }, "metadata": {}, @@ -87,124 +97,28 @@ } ], "source": [ - "map.mapplot(colorby='kge', sim='i05h')" + "map.mapplot(colorby='kge', sim='i05h', range=[-1, 1])" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{'i05j': \n", - " Dimensions: (time: 10228, station: 49)\n", - " Coordinates:\n", - " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", - " * station (station) object 'G10058' 'G10065' 'G10020' ... 'G10003' 'G10052'\n", - " Data variables:\n", - " dis (time, station) float32 ...,\n", - " 'i05h': \n", - " Dimensions: (time: 10228, station: 49)\n", - " Coordinates:\n", - " * time (time) datetime64[ns] 2016-01-01T06:00:00 ... 2023-01-01\n", - " * station (station) object 'G10058' 'G10065' 'G10020' ... 'G10003' 'G10052'\n", - " Data variables:\n", - " dis (time, station) float32 ...}" + "38.13340834" ] }, - "execution_count": 5, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "map.sim_ds" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import xarray as xr\n", - "import pandas as pd\n", - "\n", - "def find_common_station(self):\n", - " ids = []\n", - "\n", - " # Append sets of station IDs from datasets to the list\n", - " ids.append(set(self.obs_ds['station'].values))\n", - "\n", - " for ds in self.sim_ds.values():\n", - " ids.append(set(ds['station'].values))\n", - "\n", - " ids.append(set(self.stations_metadata[self.station_index]))\n", - "\n", - " print(\"Station IDs from datasets:\", ids)\n", - "\n", - " # Find common station IDs among the sets\n", - " common_ids = set.intersection(*ids)\n", - " print(\"Common Station IDs:\", common_ids)\n", - "\n", - " return list(common_ids)\n", - "\n", - "# Load the observations dataset\n", - "obs_ds = xr.open_dataset('~/destinE/station_attributes_with_obsdis06h_197001-202312_20230726_withEFAS.nc')\n", - "\n", - "# Load the simulations datasets\n", - "sim_i05j = xr.open_dataset('~/destinE/cama_i05j_stations.nc')\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "obs_ds.obsdis.values" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import xarray as xr\n", - "import os\n", - "\n", - "simulations = {\n", - " \"i05j\": \"~/destinE/cama_i05j_stations.nc\",\n", - " \"i05h\": \"~/destinE/cama_i05h_stations.nc\"\n", - "}\n", - "datasets = {}\n", - "datasets = {}\n", - "for exp, path in simulations.items():\n", - " # Expanding the tilde\n", - " expanded_path = os.path.expanduser(path)\n", - " \n", - " if os.path.isfile(expanded_path): # Check if it's a file\n", - " ds = xr.open_dataset(expanded_path)\n", - " elif os.path.isdir(expanded_path): # Check if it's a directory\n", - " # Handle the case when it's a directory; \n", - " # assume all .nc files in the directory need to be combined\n", - " files = [f for f in os.listdir(expanded_path) if f.endswith('.nc')]\n", - " ds = xr.open_mfdataset([os.path.join(expanded_path, f) for f in files], combine='by_coords')\n", - " else:\n", - " raise ValueError(f\"Invalid path: {expanded_path}\")\n", - " datasets[exp] = ds\n" + "map.bounds[0][0]\n" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { From 11d1840361d7cba2998b46a0f99737c37485dd42 Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Wed, 18 Oct 2023 16:11:44 +0200 Subject: [PATCH 14/31] aligned based on layout, sized sub layout width with % --- hat/visualisation.py | 50 +++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/hat/visualisation.py b/hat/visualisation.py index 7bde1b2..6bd286f 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -37,15 +37,10 @@ class IPyLeaflet: """Visualization class for interactive map with IPyLeaflet and Plotly.""" def __init__(self, bounds): - # Calculate the center of the map from the bounds - center_lat = (bounds[0][0] + bounds[1][0]) / 2 - center_lon = (bounds[0][1] + bounds[1][1]) / 2 # Initialize the map widget with the calculated center self.map = Map( - center=(center_lat, center_lon), - zoom=5, - layout=Layout(width="400px", height="600px") + layout=Layout(width= "100%", height="600px") ) # Fit the map to the provided bounds @@ -75,9 +70,7 @@ def initialize_plot(): """Initialize a plotly figure widget.""" f = go.FigureWidget( layout=go.Layout( - width=600, - height =350, - margin=dict(t=100, b=10, l=10, r=10), # Adjust margin + height=350, legend=dict( orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 ), @@ -90,7 +83,7 @@ def initialize_plot(): # columns = ["Exp. name", "...", "..."] # Add the required columns # data = [["", "", ""]] # Empty values # df = pd.DataFrame(data, columns=columns) - # self.display_dataframe_with_scroll(df, self.df_stats, title="Statistics Overview") + # self.display_dataframe_with_scroll(df, self.df_stats, title="") # def initialize_station_property_table(self): # # Create a placeholder dataframe for station property with blank header and one blank row @@ -373,6 +366,7 @@ def handle_click(self, station_id): self.f.layout.xaxis.tickformat = "%d-%m-%Y" self.f.layout.yaxis.title = "Discharge [m3/s]" + # Generate and display the statistics table for the clicked station if self.statistics: df_stats = self.generate_statistics_table(station_id) @@ -479,18 +473,20 @@ def mapplot(self, colorby="kge", sim = None, range = None): colormap = plt.cm.YlGnBu legend_widget = self.generate_html_legend(colormap, min_val, max_val) - # Create marker from stations_metadata + # Create marker from stations_metadata for _, row in self.stations_metadata.iterrows(): lat, lon = row["StationLat"], row["StationLon"] station_id = row[self.config["station_id_column_name"]] if station_id in list(stat_data.station): + #TODO should be in IpyLeaflet color = matplotlib.colors.rgb2hex( colormap(norm(stat_data.sel(station=station_id).values)) ) else: color = "gray" + #TODO should be in IpyLeaflet circle_marker = CircleMarker( location=(lat, lon), radius=7, @@ -517,18 +513,20 @@ def mapplot(self, colorby="kge", sim = None, range = None): ) date_label = Label("Please select the date to accurately change the date axis of the plot") - center_layout = Layout(justify_content='center',align_items='center',spacing='2px' ) - date_picker_box = HBox([self.start_date_picker, self.end_date_picker],layout=center_layout) - top_right_frame = VBox([self.f, date_label, date_picker_box, self.geo_map.df_stats],layout=center_layout) - top_left_frame = VBox([self.geo_map.map, legend_widget],layout=center_layout) - main_top_frame = HBox([top_left_frame, top_right_frame],layout=center_layout ) - layout = VBox([title_label, self.loading_label, main_top_frame, self.geo_map.df_output],layout=center_layout) - + main_layout = Layout(justify_content='space-around',align_items='stretch',spacing='2px', width= '1000px' ) + left_layout = Layout(justify_content='space-around', align_items='center',spacing='2px', width = '40%') + right_layout = Layout(justify_content='center',align_items='center',spacing='2px', width = '60%') + date_picker_box = HBox([self.start_date_picker, self.end_date_picker]) + top_right_frame = VBox([self.f, date_label, date_picker_box, self.geo_map.df_stats],layout=right_layout ) + top_left_frame = VBox([self.geo_map.map, legend_widget],layout=left_layout ) + main_top_frame = HBox([top_left_frame, top_right_frame]) + layout = VBox([title_label, self.loading_label, main_top_frame, self.geo_map.df_output],layout=main_layout) + #TODO list all object e.g. geomap (all in a dict or list), display(layout) - def handle_marker_click(self, row, **kwargs): + def handle_marker_click(self, row, **kwargs): #TODO Interactive map class station_id = row[self.config["station_id_column_name"]] station_name = row["StationName"] @@ -617,15 +615,15 @@ def generate_statistics_table(self, station_id): # # Create the GeoJSON layer # geojson_layer = GeoJSON(data=vector, style=style) - # # Update the title label - # # vector_name = os.path.basename(vector_data) + # Update the title label + # vector_name = os.path.basename(vector_data) - # # # Define the callback to handle feature clicks - # # def on_feature_click(event, feature, **kwargs): - # # title = f"Feature property of the external vector: {vector_name}" + # # Define the callback to handle feature clicks + # def on_feature_click(event, feature, **kwargs): + # title = f"Feature property of the external vector: {vector_name}" - # # properties = feature["properties"] - # # df = properties_to_dataframe(properties) + # properties = feature["properties"] + # df = properties_to_dataframe(properties) # # Bind the callback to the layer From c8bb93573d97e6593afdef649bc92d6c8d9d2940 Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Tue, 24 Oct 2023 12:55:02 +0200 Subject: [PATCH 15/31] added todo and changed add marker into ipyleaflet class --- hat/visualisation.py | 56 ++++++++++++++++++-------------------------- 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/hat/visualisation.py b/hat/visualisation.py index 6bd286f..b9e99d3 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -59,11 +59,19 @@ def __init__(self, bounds): ) - def add_marker(self, lat: float, lon: float, on_click_callback): - """Add a marker to the map.""" - marker = Marker(location=[lat, lon], draggable=False, opacity=0.7) - marker.on_click(on_click_callback) - self.map.add_layer(marker) + def add_circle_marker(self, lat, lon, color, on_click_callback=None): + """Add a circle marker to the map.""" + circle_marker = CircleMarker( + location=(lat, lon), + radius=7, + color="gray", + fill_color=color, + fill_opacity=0.8, + weight=1 + ) + if on_click_callback: + circle_marker.on_click(on_click_callback) + self.map.add_layer(circle_marker) @staticmethod def initialize_plot(): @@ -71,6 +79,7 @@ def initialize_plot(): f = go.FigureWidget( layout=go.Layout( height=350, + margin=dict(l=100), # Adjust left margin legend=dict( orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 ), @@ -78,19 +87,6 @@ def initialize_plot(): ) return f - # def initialize_statistics_table(self): - # # Create a placeholder dataframe with no values but with the title, header, and "Exp. name" - # columns = ["Exp. name", "...", "..."] # Add the required columns - # data = [["", "", ""]] # Empty values - # df = pd.DataFrame(data, columns=columns) - # self.display_dataframe_with_scroll(df, self.df_stats, title="") - - # def initialize_station_property_table(self): - # # Create a placeholder dataframe for station property with blank header and one blank row - # columns = ["...", "...", "..."] # Add the required columns - # data = [["", "", ""]] # Empty values - # df = pd.DataFrame(data, columns=columns) - # self.display_dataframe_with_scroll(df, self.df_output, title="Station Property") class ThrottledClick: @@ -108,7 +104,7 @@ def should_process(self): return False -class NotebookMap: +class InteractiveMap: """Main class for visualization in Jupyter Notebook.""" def __init__( @@ -117,6 +113,7 @@ def __init__( stations_metadata: str, observations: str, simulations: Dict, + created_objects = {} stats=None, ): self.config = config @@ -425,6 +422,8 @@ def mapplot(self, colorby="kge", sim = None, range = None): # Create an instance of IPyLeaflet with the calculated bounds self.geo_map = IPyLeaflet(bounds=self.bounds) + self.created_objects["geo_map"] = self.geo_map + # Initialize a plotly figure widget for the time series self.f = ( @@ -486,21 +485,12 @@ def mapplot(self, colorby="kge", sim = None, range = None): else: color = "gray" - #TODO should be in IpyLeaflet - circle_marker = CircleMarker( - location=(lat, lon), - radius=7, - color="gray", - fill_color=color, - fill_opacity=0.8, - weight = 1 - ) - circle_marker.on_click(partial(self.handle_marker_click, row=row)) - self.geo_map.map.add_layer(circle_marker) + self.geo_map.add_circle_marker(lat, lon, color, partial(self.handle_marker_click, row=row)) + # Add date pickers for start and end dates - self.start_date_picker = DatePicker(description='Start Date') - self.end_date_picker = DatePicker(description='End Date') + self.start_date_picker = DatePicker(description='Start') + self.end_date_picker = DatePicker(description='End') # Observe changes in the date pickers to update the plot self.start_date_picker.observe(self.update_plot_based_on_date, names='value') @@ -526,7 +516,7 @@ def mapplot(self, colorby="kge", sim = None, range = None): display(layout) - def handle_marker_click(self, row, **kwargs): #TODO Interactive map class + def handle_marker_click(self, row, **kwargs): #TODO add Interactive object class station_id = row[self.config["station_id_column_name"]] station_name = row["StationName"] From 12d17a74cc6b718b291e7b737a3cfced25d5e0c2 Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Tue, 24 Oct 2023 14:26:29 +0200 Subject: [PATCH 16/31] moved generate html legend and display dataframe with scroll to ipyleaflet class --- hat/visualisation.py | 165 ++++++++++++++++++++++--------------------- 1 file changed, 85 insertions(+), 80 deletions(-) diff --git a/hat/visualisation.py b/hat/visualisation.py index b9e99d3..3cd18ab 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -48,6 +48,8 @@ def __init__(self, bounds): pd.set_option("display.max_colwidth", None) + self.created_objects = {} + # Initialize the output widget for time series plots and dataframe display self.output_widget = Output() self.df_output = Output() @@ -73,6 +75,80 @@ def add_circle_marker(self, lat, lon, color, on_click_callback=None): circle_marker.on_click(on_click_callback) self.map.add_layer(circle_marker) + + def generate_html_legend(self, colormap, min_val, max_val): + # Convert the colormap to a list of RGB values + rgb_values = [matplotlib.colors.rgb2hex(colormap(i)) for i in np.linspace(0, 1, 256)] + + # Create a gradient style using the RGB values + gradient_style = ', '.join(rgb_values) + gradient_html = f""" +
+ """ + + # Create labels + labels_html = f""" +
+ Low: {min_val:.1f} + High: {max_val:.1f} +
+ """ + + # Combine gradient and labels + legend_html = gradient_html + labels_html + + return HTML(legend_html) + + + def display_dataframe_with_scroll(self,df, output, title=""): + """to display the dataframe as a html table with a horizontal scroll bar""" + with output: + clear_output(wait=True) + + # Define styles directly within the HTML content + table_style = """ + + """ + + title_style = "style='font-size: 18px; font-weight: bold; text-align: center;'" + table_html = df.to_html(classes="custom-table") + content = f"{table_style}

{title}

{table_html}
" + + display(HTML(content)) + @staticmethod def initialize_plot(): """Initialize a plotly figure widget.""" @@ -86,7 +162,8 @@ def initialize_plot(): ) ) return f - + + class ThrottledClick: @@ -113,7 +190,6 @@ def __init__( stations_metadata: str, observations: str, simulations: Dict, - created_objects = {} stats=None, ): self.config = config @@ -242,6 +318,7 @@ def find_common_station(self): else: common_ids = set(id) & common_ids return list(common_ids) + def calculate_statistics(self): """in progress: to calculate statistics using the run_analysis tools -- @@ -258,51 +335,7 @@ def calculate_statistics(self): statistics[exp] = run_analysis(self.stats, sim_ds_f, obs_ds_f) return statistics - def display_dataframe_with_scroll(self, df, output, title=""): - """to display the dataframe as a html table with a horizontal scroll bar""" - with output: - clear_output(wait=True) - - # Define styles directly within the HTML content - table_style = """ - - """ - - title_style = "style='font-size: 18px; font-weight: bold; text-align: center;'" - table_html = df.to_html(classes="custom-table") - content = f"{table_style}

{title}

{table_html}
" - - display(HTML(content)) - - + def handle_click(self, station_id): '''Define a callback to handle marker clicks it adds the plot figure''' @@ -367,7 +400,7 @@ def handle_click(self, station_id): # Generate and display the statistics table for the clicked station if self.statistics: df_stats = self.generate_statistics_table(station_id) - self.display_dataframe_with_scroll( + self.geo_map.display_dataframe_with_scroll( df_stats, self.geo_map.df_stats, title="Statistics Overview" ) @@ -385,34 +418,6 @@ def update_plot_based_on_date(self, change): # Update the x-axis range of the plotly figure self.f.update_layout(xaxis_range=[start_date, end_date]) - def generate_html_legend(self, colormap, min_val, max_val): - # Convert the colormap to a list of RGB values - rgb_values = [matplotlib.colors.rgb2hex(colormap(i)) for i in np.linspace(0, 1, 256)] - - # Create a gradient style using the RGB values - gradient_style = ', '.join(rgb_values) - gradient_html = f""" -
- """ - - # Create labels - labels_html = f""" -
- Low: {min_val:.1f} - High: {max_val:.1f} -
- """ - - # Combine gradient and labels - legend_html = gradient_html + labels_html - - return HTML(legend_html) - def mapplot(self, colorby="kge", sim = None, range = None): @@ -422,7 +427,7 @@ def mapplot(self, colorby="kge", sim = None, range = None): # Create an instance of IPyLeaflet with the calculated bounds self.geo_map = IPyLeaflet(bounds=self.bounds) - self.created_objects["geo_map"] = self.geo_map + # self.created_objects["geo_map"] = self.geo_map # Initialize a plotly figure widget for the time series @@ -470,7 +475,7 @@ def mapplot(self, colorby="kge", sim = None, range = None): #create legend widget colormap = plt.cm.YlGnBu - legend_widget = self.generate_html_legend(colormap, min_val, max_val) + legend_widget = self.geo_map.generate_html_legend(colormap, min_val, max_val) # Create marker from stations_metadata for _, row in self.stations_metadata.iterrows(): @@ -535,7 +540,7 @@ def handle_marker_click(self, row, **kwargs): #TODO add Interactive object class [row] ) # Convert the station metadata to a DataFrame for display title_table = f"Station property from metadata: {self.station_file_name}" - self.display_dataframe_with_scroll( + self.geo_map.display_dataframe_with_scroll( df, self.geo_map.df_output, title=title_table ) @@ -549,7 +554,7 @@ def handle_geojson_click(self, feature, **kwargs): self.stations_metadata["ObsID"] == station_id ] title_table = f"Station property from metadata: {self.station_file_name}" - self.display_dataframe_with_scroll(df_station, title=title_table) + self.geo_map.display_dataframe_with_scroll(df_station, title=title_table) def generate_statistics_table(self, station_id): From 9fe90999bb835f53cf30bee16498f510405bc6b2 Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Tue, 24 Oct 2023 15:00:22 +0200 Subject: [PATCH 17/31] attempt to add interactive object and plotly class --- hat/visualisation.py | 74 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/hat/visualisation.py b/hat/visualisation.py index 3cd18ab..b8090cd 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -149,6 +149,7 @@ def display_dataframe_with_scroll(self,df, output, title=""): display(HTML(content)) + @staticmethod def initialize_plot(): """Initialize a plotly figure widget.""" @@ -179,6 +180,51 @@ def should_process(self): self.last_call = current_time return True return False + + +class PlotlyObject: + def __init__(self): + self.figure = go.FigureWidget( + layout=go.Layout( + height=350, + margin=dict(l=100), + legend=dict( + orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 + ), + ) + ) + + def update_plot(self, x_data, y_data, name): + # Check if trace with the given name already exists + trace_exists = any([trace.name == name for trace in self.figure.data]) + + if trace_exists: + # Update the trace data + for trace in self.figure.data: + if trace.name == name: + trace.x = x_data + trace.y = y_data + else: + # Add a new trace + self.figure.add_trace( + go.Scatter(x=x_data, y=y_data, mode="lines", name=name) + ) + print(f"Updated plot with trace '{name}'. Total traces now: {len(self.figure.data)}") + + +class TableObject: + def __init__(self, geo_map_instance): + self.geo_map = geo_map_instance + + def update_table(self, df, title): + self.geo_map.display_dataframe_with_scroll(df, title=title) + + +class InteractiveObject: + def __init__(self, geo_map_instance): + self.plotly_obj = PlotlyObject() + self.table_obj = TableObject(geo_map_instance) + class InteractiveMap: @@ -364,11 +410,10 @@ def handle_click(self, station_id): ds_time_str, ds_time_series_data ) - self.f.add_trace( - go.Scatter( - x=valid_dates_ds, y=valid_data_ds, mode="lines", name="Simulation: "+name - ) - ) + print(f"Updating plot with data for station: {station_id}") + print("Simulation data:", valid_dates_ds, valid_data_ds) + self.interactive_obj.plotly_obj.update_plot(valid_dates_ds, valid_data_ds, name) + else: print(f"Station ID: {station_id} not found in dataset {name}.") @@ -380,12 +425,10 @@ def handle_click(self, station_id): valid_dates_obs, valid_data_obs = filter_nan_values( ds_time_str, obs_time_series ) + + print("Observation data:", valid_dates_obs, valid_data_obs) - self.f.add_trace( - go.Scatter( - x=valid_dates_obs, y=valid_data_obs, mode="lines", name="Obs. Data" - ) - ) + self.interactive_obj.plotly_obj.update_plot(valid_dates_obs, valid_data_obs, "Obs. Data") else: print( f"Station ID: {station_id} not found in obs_df. Columns are: {self.obs_ds.columns}" @@ -406,6 +449,9 @@ def handle_click(self, station_id): self.loading_label.value = "" # Clear the loading message + # Force a redraw of the figure + self.f.update() + with self.geo_map.output_widget: clear_output(wait=True) # Clear any previous plots or messages display(self.f) @@ -427,16 +473,13 @@ def mapplot(self, colorby="kge", sim = None, range = None): # Create an instance of IPyLeaflet with the calculated bounds self.geo_map = IPyLeaflet(bounds=self.bounds) - # self.created_objects["geo_map"] = self.geo_map - # Initialize a plotly figure widget for the time series self.f = ( self.geo_map.initialize_plot() ) - # self.geo_map.initialize_statistics_table() - # self.geo_map.initialize_station_property_table() + self.interactive_obj = InteractiveObject(self.geo_map) # Convert ds 'time' to datetime format for alignment with external_df self.ds_time = self.obs_ds["time"].values.astype("datetime64[D]") @@ -544,6 +587,9 @@ def handle_marker_click(self, row, **kwargs): #TODO add Interactive object class df, self.geo_map.df_output, title=title_table ) + print(f"Handling click for station: {station_id}") + + def handle_geojson_click(self, feature, **kwargs): # Extract properties directly from the feature station_id = feature["properties"]["station_id"] From a22ffef784e140b49ba706a05c29c9b17aa7065b Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Thu, 26 Oct 2023 16:26:39 +0200 Subject: [PATCH 18/31] revampt structure of visualisation script, lack of station property table --- hat/visualisation.py | 864 +++++++++++++++++-------------------------- 1 file changed, 349 insertions(+), 515 deletions(-) diff --git a/hat/visualisation.py b/hat/visualisation.py index b8090cd..5e43400 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -1,3 +1,4 @@ + """ Python module for visualising geospatial content using jupyter notebook, for both spatial and temporal, e.g. netcdf, vector, raster, with time series etc @@ -6,79 +7,43 @@ import json import os import time -from functools import partial from typing import Dict - import geopandas as gpd -import matplotlib import matplotlib.pyplot as plt import numpy as np import pandas as pd import plotly.graph_objs as go import xarray as xr -from ipyleaflet import ( - ImageOverlay, - CircleMarker, - Map, - Marker, -) - +from ipyleaflet import CircleMarker, Map, GeoJSON from IPython.core.display import display from IPython.display import clear_output from ipywidgets import HTML, HBox, Label, Layout, Output, VBox, DatePicker -from shapely.geometry import Point - -from hat.filters import filter_timeseries -from hat.hydrostats import run_analysis +import matplotlib as mpl from hat.observations import read_station_metadata_file -class IPyLeaflet: - """Visualization class for interactive map with IPyLeaflet and Plotly.""" - - def __init__(self, bounds): - - # Initialize the map widget with the calculated center - self.map = Map( - layout=Layout(width= "100%", height="600px") - ) - - # Fit the map to the provided bounds - self.map.fit_bounds(bounds) - - pd.set_option("display.max_colwidth", None) - - self.created_objects = {} - - # Initialize the output widget for time series plots and dataframe display +class InteractiveElements: + def __init__(self, bounds, map_instance, statistics=None): + self.map = self._initialize_map(bounds) + self.loading_label = Label(value="") + self.throttler = self._initialize_throttler() + self.plotly_obj = PlotlyObject() + self.table_obj = TableObject(self) + self.statistics = statistics self.output_widget = Output() - self.df_output = Output() - self.df_stats = Output() - - # Create the title label for the properties table - self.feature_properties_title = Label( - "", layout=Layout(font_weight="bold", margin="10px 0") - ) - - - def add_circle_marker(self, lat, lon, color, on_click_callback=None): - """Add a circle marker to the map.""" - circle_marker = CircleMarker( - location=(lat, lon), - radius=7, - color="gray", - fill_color=color, - fill_opacity=0.8, - weight=1 - ) - if on_click_callback: - circle_marker.on_click(on_click_callback) - self.map.add_layer(circle_marker) + self.map_instance = map_instance + self.statistics = map_instance.statistics + def _initialize_map(self, bounds): + """Initialize the map widget.""" + map_widget = Map(layout=Layout(width="100%", height="600px")) + map_widget.fit_bounds(bounds) + return map_widget + def generate_html_legend(self, colormap, min_val, max_val): # Convert the colormap to a list of RGB values - rgb_values = [matplotlib.colors.rgb2hex(colormap(i)) for i in np.linspace(0, 1, 256)] + rgb_values = [mpl.colors.rgb2hex(colormap(i)) for i in np.linspace(0, 1, 256)] # Create a gradient style using the RGB values gradient_style = ', '.join(rgb_values) @@ -98,20 +63,132 @@ def generate_html_legend(self, colormap, min_val, max_val): High: {max_val:.1f} """ - # Combine gradient and labels legend_html = gradient_html + labels_html return HTML(legend_html) + + def _initialize_throttler(self, delay=1.0): + """Initialize the throttler for click events.""" + return ThrottledClick(delay) + + + def add_plotly_object(self, plotly_obj): + """Add a PlotlyObject to the IPyLeaflet class.""" + self.plotly_obj = plotly_obj + def add_table_object(self, table_obj): + """Add a PlotlyObject to the IPyLeaflet class.""" + self.table_obj = table_obj + + def handle_marker_selection(self, feature, **kwargs): + """Handle the selection of a marker on the map.""" + # Extract station_id from the selected feature + station_id = feature["properties"]["station_id"] + + # Call the action handler with the extracted station_id + self.handle_marker_action(station_id) - def display_dataframe_with_scroll(self,df, output, title=""): - """to display the dataframe as a html table with a horizontal scroll bar""" - with output: - clear_output(wait=True) + def handle_marker_action(self, station_id): + '''Define a callback to handle marker clicks and add the plot figure.''' + + # Check if we should process the click + if not self.throttler.should_process(): + return + + self.loading_label.value = "Loading..." # Indicate that data is being loaded + station_id = str(station_id) # Convert station ID to string for consistency + print(f"Handling click for station: {station_id}") + + # Update the plot with simulation and observation data + self.plotly_obj.update(station_id) + + # Generate and display the statistics table for the clicked station + if self.statistics: + self.table_obj.update(station_id) + + # Update the table in the layout + children_list = list(self.map_instance.top_right_frame.children) + + # Find the index of the old table and replace it with the new table + for i, child in enumerate(children_list): + if isinstance(child, type(self.table_obj.stat_table_html)): + children_list[i] = self.table_obj.stat_table_html + break + else: + # If the old table wasn't found, append the new table + children_list.append(self.table_obj.stat_table_html) + + self.map_instance.top_right_frame.children = tuple(children_list) + + self.loading_label.value = "" # Clear the loading message + + with self.output_widget: + clear_output(wait=True) # Clear any previous plots or messages + + print("End of handle_marker_action") + + + +class PlotlyObject: + def __init__(self): + self.figure = go.FigureWidget( + layout=go.Layout( + height=350, + margin=dict(l=100), + legend=dict( + orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 + ), + xaxis_title="Date", + xaxis_tickformat="%d-%m-%Y", + yaxis_title="Discharge [m3/s]" + ) + ) - # Define styles directly within the HTML content - table_style = """ + def update_simulation_data(self, station_id): + for name, ds in self.sim_ds.items(): + if station_id in ds["station"].values: + ds_time_series_data = ds["dis"].sel(station=station_id).values + valid_dates_ds, valid_data_ds = filter_nan_values( + self.ds_time_str, ds_time_series_data + ) + self._update_trace(valid_dates_ds, valid_data_ds, name) + else: + print(f"Station ID: {station_id} not found in dataset {name}.") + + def update_observation_data(self, station_id): + if station_id in self.obs_ds["station"].values: + obs_time_series = self.obs_ds["obsdis"].sel(station=station_id).values + valid_dates_obs, valid_data_obs = filter_nan_values( + self.ds_time_str, obs_time_series + ) + self._update_trace(valid_dates_obs, valid_data_obs, "Obs. Data") + else: + print(f"Station ID: {station_id} not found in obs_df.") + + def _update_trace(self, x_data, y_data, name): + trace_exists = any([trace.name == name for trace in self.figure.data]) + if trace_exists: + for trace in self.figure.data: + if trace.name == name: + trace.x = x_data + trace.y = y_data + else: + self.figure.add_trace( + go.Scatter(x=x_data, y=y_data, mode="lines", name=name) + ) + print(f"Updated plot with trace '{name}'. Total traces now: {len(self.figure.data)}") + + def update(self, station_id): + self.update_simulation_data(station_id) + self.update_observation_data(station_id) + + +class TableObject: + def __init__(self, map_instance): + self.map_instance = map_instance + # Define the styles for the statistics table + self.table_style = """ """ + self.stat_title_style = "style='font-size: 18px; font-weight: bold; text-align: center;'" - title_style = "style='font-size: 18px; font-weight: bold; text-align: center;'" - table_html = df.to_html(classes="custom-table") - content = f"{table_style}

{title}

{table_html}
" - - display(HTML(content)) - - - @staticmethod - def initialize_plot(): - """Initialize a plotly figure widget.""" - f = go.FigureWidget( - layout=go.Layout( - height=350, - margin=dict(l=100), # Adjust left margin - legend=dict( - orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 - ), - ) - ) - return f - - - - -class ThrottledClick: - """Class to throttle click events.""" + - def __init__(self, delay=1.0): - self.delay = delay - self.last_call = 0 + def generate_statistics_table(self, station_id): + data = [] - def should_process(self): - current_time = time.time() - if current_time - self.last_call > self.delay: - self.last_call = current_time - return True - return False - + # Check if statistics is None or empty + if not self.map_instance.statistics: + print("No statistics data provided.") + return pd.DataFrame() # Return an empty dataframe -class PlotlyObject: - def __init__(self): - self.figure = go.FigureWidget( - layout=go.Layout( - height=350, - margin=dict(l=100), - legend=dict( - orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 - ), - ) - ) + # Loop through each simulation and get the statistics for the given station_id + for exp_name, stats in self.map_instance.statistics.items(): + if station_id in stats["station"].values: + row = [exp_name] + [ + round(stats[var].sel(station=station_id).values.item(), 2) + for var in stats.data_vars + if var not in ["longitude", "latitude"] + ] + data.append(row) - def update_plot(self, x_data, y_data, name): - # Check if trace with the given name already exists - trace_exists = any([trace.name == name for trace in self.figure.data]) + # Check if data has any items + if not data: + print(f"No statistics data found for station ID: {station_id}.") + return pd.DataFrame() # Return an empty dataframe - if trace_exists: - # Update the trace data - for trace in self.figure.data: - if trace.name == name: - trace.x = x_data - trace.y = y_data - else: - # Add a new trace - self.figure.add_trace( - go.Scatter(x=x_data, y=y_data, mode="lines", name=name) - ) - print(f"Updated plot with trace '{name}'. Total traces now: {len(self.figure.data)}") + # Convert the data to a DataFrame for display + columns = ["Exp. name"] + list(stats.data_vars.keys()) + statistics_df = pd.DataFrame(data, columns=columns) + # Round the numerical columns to 2 decimal places + numerical_columns = [col for col in statistics_df.columns if col != "Exp. name"] + statistics_df[numerical_columns] = statistics_df[numerical_columns].round(2) -class TableObject: - def __init__(self, geo_map_instance): - self.geo_map = geo_map_instance + return statistics_df - def update_table(self, df, title): - self.geo_map.display_dataframe_with_scroll(df, title=title) + def display_dataframe_with_scroll(self, df, title=""): + table_html = df.to_html(classes="custom-table") + content = f"{self.table_style}

{title}

{table_html}
" + return(HTML(content)) + def update(self, station_id): + df_stats = self.generate_statistics_table(station_id) + self.stat_table_html = self.display_dataframe_with_scroll(df_stats, title="Statistics Overview") + print("Updated stat_table_html:", self.stat_table_html) -class InteractiveObject: - def __init__(self, geo_map_instance): - self.plotly_obj = PlotlyObject() - self.table_obj = TableObject(geo_map_instance) class InteractiveMap: - """Main class for visualization in Jupyter Notebook.""" - - def __init__( - self, - config: Dict, - stations_metadata: str, - observations: str, - simulations: Dict, - stats=None, - ): + def __init__(self, config, stations, observations, simulations, stats=None): self.config = config self.stations_metadata = read_station_metadata_file( - fpath=stations_metadata, + fpath=stations, coord_names=config["station_coordinates"], epsg=config["station_epsg"], filters=config["station_filters"], ) - self.station_file_name = os.path.basename(stations_metadata) # station metadata file name here - + obs_var_name = config["obs_var_name"] + + # Use the external functions to prepare data + self.sim_ds = prepare_simulations_data(simulations) + self.obs_ds = prepare_observations_data(observations, self.sim_ds, obs_var_name) + + # Convert ds 'time' to datetime format for alignment with external_df + self.ds_time = self.obs_ds["time"].values.astype("datetime64[D]") + # set station index self.station_index = config["station_id_column_name"] - self.stations_metadata[self.station_index] = self.stations_metadata[ - self.station_index - ].astype(str) - - - # Prepare sim_ds and obs_ds for statistics, simuation is a directory dict {:} - self.observations = observations - self.obs_var_name = config["obs_var_name"] - self.simulations = simulations - self.sim_ds = self.prepare_simulations_data() - self.obs_ds = self.prepare_observations_data() - - # retrieve statistics from the statistics netcdf input + self.stations_metadata[self.station_index] = self.stations_metadata[self.station_index].astype(str) + + # Retrieve statistics from the statistics netcdf input self.statistics = {} if stats: for name, path in stats.items(): self.statistics[name] = xr.open_dataset(path) - - assert self.statistics.keys() == self.sim_ds.keys() - + + # Ensure the keys of self.statistics match the keys of self.sim_ds + assert set(self.statistics.keys()) == set(self.sim_ds.keys()), "Mismatch between statistics and simulations keys." # find common station ids between metadata, observation and simulations self.common_id = self.find_common_station() + print(f"Found {len(self.common_id)} common stations") - self.stations_metadata = self.stations_metadata.loc[ - self.stations_metadata[self.station_index].isin(self.common_id) - ] + self.stations_metadata = self.stations_metadata.loc[self.stations_metadata[self.station_index].isin(self.common_id)] self.obs_ds = self.obs_ds.sel(station=self.common_id) for sim, ds in self.sim_ds.items(): self.sim_ds[sim] = ds.sel(station=self.common_id) + # Pass the map bound to the interactive elements + self.bounds = compute_bounds(self.stations_metadata, self.common_id, self.station_index, self.config["station_coordinates"]) + # self.interactive_elements = InteractiveElements(self.bounds) + self.interactive_elements = InteractiveElements(self.bounds, self) - # Calculate the bounding extent to display map (min/max latitudes and longitudes) - common_stations_df = self.stations_metadata[self.stations_metadata['station_id'].isin(self.common_id)] - min_lat = common_stations_df['y'].min() - max_lat = common_stations_df['y'].max() - min_lon = common_stations_df['x'].min() - max_lon = common_stations_df['x'].max() - self.bounds = ((min_lat, min_lon), (max_lat, max_lon)) - + # Pass the necessary data to the interactive elements + self.interactive_elements.plotly_obj.sim_ds = self.sim_ds + self.interactive_elements.plotly_obj.obs_ds = self.obs_ds + self.interactive_elements.plotly_obj.ds_time_str = [dt.isoformat() for dt in pd.to_datetime(self.ds_time)] - def prepare_simulations_data(self): - """process simulations raw datasets to a standard dataframe""" - - # If simulations is a dictionary, load data for each experiment - sim_ds = {} - for exp, path in self.simulations.items(): - # Expanding the tilde - expanded_path = os.path.expanduser(path) - - if os.path.isfile(expanded_path): # Check if it's a file - ds = xr.open_dataset(expanded_path) - elif os.path.isdir(expanded_path): # Check if it's a directory - # Handle the case when it's a directory; - # assume all .nc files in the directory need to be combined - files = [f for f in os.listdir(expanded_path) if f.endswith(".nc")] - ds = xr.open_mfdataset( - [os.path.join(expanded_path, f) for f in files], combine="by_coords" - ) - else: - raise ValueError(f"Invalid path: {expanded_path}") - sim_ds[exp] = ds - return sim_ds - - def prepare_observations_data(self): - """process observation raw dataset to a standard dataframe""" - file_extension = os.path.splitext(self.observations)[-1].lower() - - if file_extension == ".csv": - obs_df = pd.read_csv(self.observations, parse_dates=["Timestamp"]) - obs_melted = obs_df.melt( - id_vars="Timestamp", var_name="station", value_name=self.obs_var_name - ) - - # Convert the melted DataFrame to xarray Dataset - obs_ds = obs_melted.set_index(["Timestamp", "station"]).to_xarray() - obs_ds = obs_ds.rename({"Timestamp": "time"}) - - elif file_extension == ".nc": - obs_ds = xr.open_dataset(self.observations) - - else: - raise ValueError("Unsupported file format for observations.") - - # Subset obs_ds based on sim_ds time values - if isinstance(self.sim_ds, xr.Dataset): - time_values = self.sim_ds["time"].values - elif isinstance(self.sim_ds, dict): - # Use the first dataset in the dictionary to determine time values - first_dataset = next(iter(self.sim_ds.values())) - time_values = first_dataset["time"].values - else: - raise ValueError("Unexpected type for self.sim_ds") - - obs_ds = obs_ds.sel(time=time_values) - return obs_ds def find_common_station(self): """find common station between observation and simulation and station metadata""" @@ -366,148 +340,17 @@ def find_common_station(self): return list(common_ids) - def calculate_statistics(self): - """in progress: to calculate statistics using the run_analysis tools -- - takes a long time more recommended to load the statistic file""" - - statistics = {} - # Dictionary of simulation datasets - for exp, ds in self.sim_ds.items(): - sim_ds_f, obs_ds_f = filter_timeseries( - ds.dis, self.obs_ds.obsdis, self.threshold - ) - print(sim_ds_f) - print(obs_ds_f) - statistics[exp] = run_analysis(self.stats, sim_ds_f, obs_ds_f) - return statistics - - - def handle_click(self, station_id): - '''Define a callback to handle marker clicks - it adds the plot figure''' - # Use the throttler to determine if we should process the click - if not self.throttler.should_process(): - return - - self.loading_label.value = "Loading..." # Indicate that data is being loaded - - # Convert station ID to string for consistency - station_id = str(station_id) - - # Clear existing data from the plot - self.f.data = [] - - # Convert ds_time to a list of string formatted dates for reindexing - ds_time_str = [dt.isoformat() for dt in pd.to_datetime(self.ds_time)] - - # Loop over all simulation datasets to plot them - for name, ds in self.sim_ds.items(): - if station_id in ds["station"].values: - ds_time_series_data = ds["dis"].sel(station=station_id).values - - # Filter out NaN values and their associated dates using the utility function - valid_dates_ds, valid_data_ds = filter_nan_values( - ds_time_str, ds_time_series_data - ) - - print(f"Updating plot with data for station: {station_id}") - print("Simulation data:", valid_dates_ds, valid_data_ds) - self.interactive_obj.plotly_obj.update_plot(valid_dates_ds, valid_data_ds, name) - - else: - print(f"Station ID: {station_id} not found in dataset {name}.") - - # Time series from the obs - if station_id in self.obs_ds["station"].values: - obs_time_series = self.obs_ds["obsdis"].sel(station=station_id).values - - # Filter out NaN values and their associated dates using the utility function - valid_dates_obs, valid_data_obs = filter_nan_values( - ds_time_str, obs_time_series - ) - - print("Observation data:", valid_dates_obs, valid_data_obs) - - self.interactive_obj.plotly_obj.update_plot(valid_dates_obs, valid_data_obs, "Obs. Data") - else: - print( - f"Station ID: {station_id} not found in obs_df. Columns are: {self.obs_ds.columns}" - ) - - # Update the x-axis and y-axis properties - self.f.layout.xaxis.title = "Date" - self.f.layout.xaxis.tickformat = "%d-%m-%Y" - self.f.layout.yaxis.title = "Discharge [m3/s]" - - - # Generate and display the statistics table for the clicked station - if self.statistics: - df_stats = self.generate_statistics_table(station_id) - self.geo_map.display_dataframe_with_scroll( - df_stats, self.geo_map.df_stats, title="Statistics Overview" - ) - - self.loading_label.value = "" # Clear the loading message - - # Force a redraw of the figure - self.f.update() - - with self.geo_map.output_widget: - clear_output(wait=True) # Clear any previous plots or messages - display(self.f) - - def update_plot_based_on_date(self, change): - """Update the x-axis range of the plot based on selected dates.""" + def _update_plot_dates(self, change): start_date = self.start_date_picker.value.strftime('%Y-%m-%d') end_date = self.end_date_picker.value.strftime('%Y-%m-%d') - - # Update the x-axis range of the plotly figure - self.f.update_layout(xaxis_range=[start_date, end_date]) - - - def mapplot(self, colorby="kge", sim = None, range = None): - - #If sim / experiement name is not provided, by default it takes the first one in the dictionary of simulation list - if sim is None: - sim = list(self.simulations.keys())[0] - - # Create an instance of IPyLeaflet with the calculated bounds - self.geo_map = IPyLeaflet(bounds=self.bounds) - - # Initialize a plotly figure widget for the time series - self.f = ( - self.geo_map.initialize_plot() - ) - - self.interactive_obj = InteractiveObject(self.geo_map) - - # Convert ds 'time' to datetime format for alignment with external_df - self.ds_time = self.obs_ds["time"].values.astype("datetime64[D]") - - # Create a label to indicate loading - self.loading_label = Label(value="") - - self.throttler = ThrottledClick() - - # Check if statistics were computed - if self.statistics is None: - raise ValueError( - "Statistics have not been computed. Run `calculate_statistics` first." - ) - - # Check if the chosen simulation exists in the statistics - if sim not in self.statistics: - raise ValueError(f"Simulation '{sim}' not found in computed statistics.") - - # Check if the chosen statistic (colorby) exists for the simulation - if colorby not in self.statistics[sim].data_vars: - raise ValueError( - f"Statistic '{colorby}' not found in computed statistics for simulation '{sim}'." - ) + self.interactive_elements.plotly_obj.figure.update_layout(xaxis_range=[start_date, end_date]) + + def mapplot(self, colorby="kge", sim=None, range=None, colormap=None): + # Retrieve the statistics data of simulation choice/ by default stat_data = self.statistics[sim][colorby] - + # Normalize the data for coloring if range is None: min_val, max_val = stat_data.values.min(), stat_data.values.max() @@ -515,181 +358,156 @@ def mapplot(self, colorby="kge", sim = None, range = None): min_val, max_val = range[0], range[1] norm = plt.Normalize(min_val, max_val) - - #create legend widget - colormap = plt.cm.YlGnBu - legend_widget = self.geo_map.generate_html_legend(colormap, min_val, max_val) - - # Create marker from stations_metadata - for _, row in self.stations_metadata.iterrows(): - lat, lon = row["StationLat"], row["StationLon"] - station_id = row[self.config["station_id_column_name"]] - - if station_id in list(stat_data.station): - #TODO should be in IpyLeaflet - color = matplotlib.colors.rgb2hex( - colormap(norm(stat_data.sel(station=station_id).values)) - ) - else: - color = "gray" - self.geo_map.add_circle_marker(lat, lon, color, partial(self.handle_marker_click, row=row)) + if colormap is None: + colormap = plt.cm.get_cmap("viridis") + def map_color(feature): + station_id = feature['properties'][self.config["station_id_column_name"]] + color = mpl.colors.rgb2hex( + colormap(norm(stat_data.sel(station=station_id).values)) + ) + return { + 'color': 'black', + 'fillColor': color, + } + + geo_data = GeoJSON( + data=json.loads(self.stations_metadata.to_json()), + style={'radius': 7, 'opacity':0.5, 'weight':1.9, 'dashArray':'2', 'fillOpacity':0.5}, + hover_style={'radius': 10, 'fillOpacity': 1}, + point_style={'radius': 5}, + style_callback=map_color, + ) + + + self.legend_widget = self.interactive_elements.generate_html_legend(colormap, min_val, max_val) + + + geo_data.on_click(self.interactive_elements.handle_marker_selection) + self.interactive_elements.map.add_layer(geo_data) # Add date pickers for start and end dates self.start_date_picker = DatePicker(description='Start') self.end_date_picker = DatePicker(description='End') # Observe changes in the date pickers to update the plot - self.start_date_picker.observe(self.update_plot_based_on_date, names='value') - self.end_date_picker.observe(self.update_plot_based_on_date, names='value') - - title_label = Label( - "Interactive Map Visualisation for Hydrological Model Performance", - layout=Layout(justify_content='center'), - style={'font_weight': 'bold', 'font_size': '24px', 'font_family': 'Arial'} - ) - date_label = Label("Please select the date to accurately change the date axis of the plot") - - main_layout = Layout(justify_content='space-around',align_items='stretch',spacing='2px', width= '1000px' ) - left_layout = Layout(justify_content='space-around', align_items='center',spacing='2px', width = '40%') - right_layout = Layout(justify_content='center',align_items='center',spacing='2px', width = '60%') - date_picker_box = HBox([self.start_date_picker, self.end_date_picker]) - top_right_frame = VBox([self.f, date_label, date_picker_box, self.geo_map.df_stats],layout=right_layout ) - top_left_frame = VBox([self.geo_map.map, legend_widget],layout=left_layout ) - main_top_frame = HBox([top_left_frame, top_right_frame]) - layout = VBox([title_label, self.loading_label, main_top_frame, self.geo_map.df_output],layout=main_layout) - #TODO list all object e.g. geomap (all in a dict or list), + self.start_date_picker.observe(self._update_plot_dates, names='value') + self.end_date_picker.observe(self._update_plot_dates, names='value') + + #initialise stat table with first ID + default_station_id = self.common_id[0] + self.interactive_elements.table_obj.update(default_station_id) - display(layout) + # Initialize layout elements + self._initialize_layout_elements() + + # Display the main layout + display(self.layout) - def handle_marker_click(self, row, **kwargs): #TODO add Interactive object class - station_id = row[self.config["station_id_column_name"]] - station_name = row["StationName"] - - self.handle_click(station_id) - - title_plot = f"Time Series for the selected station:
ID: {station_id}, name: {station_name}" - self.f.update_layout( - title_text=title_plot, - title_font=dict(size=18, family="Arial", color="black"), - title_x=0.5, - title_y=0.96 # Adjust as needed to position the title appropriately - ) - - # Display the DataFrame in the df_output widget - df = pd.DataFrame( - [row] - ) # Convert the station metadata to a DataFrame for display - title_table = f"Station property from metadata: {self.station_file_name}" - self.geo_map.display_dataframe_with_scroll( - df, self.geo_map.df_output, title=title_table + def _initialize_layout_elements(self): + # Title label + self.title_label = Label( + "Interactive Map Visualisation for Hydrological Model Performance", + layout=Layout(justify_content='center'), + style={'font_weight': 'bold', 'font_size': '24px', 'font_family': 'Arial'} ) - print(f"Handling click for station: {station_id}") + # Date label + self.date_label = Label("Please select the date to accurately change the date axis of the plot") + + # Layouts + main_layout = Layout(justify_content='space-around', align_items='stretch', spacing='2px', width='1000px') + left_layout = Layout(justify_content='space-around', align_items='center', spacing='2px', width='40%') + right_layout = Layout(justify_content='center', align_items='center', spacing='2px', width='60%') - def handle_geojson_click(self, feature, **kwargs): - # Extract properties directly from the feature - station_id = feature["properties"]["station_id"] - self.handle_click(station_id) + # Date picker box + self.date_picker_box = HBox([self.start_date_picker, self.end_date_picker]) - # Display metadata of the station - df_station = self.stations_metadata[ - self.stations_metadata["ObsID"] == station_id - ] - title_table = f"Station property from metadata: {self.station_file_name}" - self.geo_map.display_dataframe_with_scroll(df_station, title=title_table) + # Frames + self.top_right_frame = VBox([self.interactive_elements.plotly_obj.figure, + self.date_label, self.date_picker_box, self.interactive_elements.table_obj.stat_table_html + ], layout=right_layout) + self.top_left_frame = VBox([self.interactive_elements.map, self.legend_widget], layout=left_layout) # Assuming self.map is the map widget and self.legend_widget is the legend + self.main_top_frame = HBox([self.top_left_frame, self.top_right_frame]) - def generate_statistics_table(self, station_id): + # Main layout + self.layout = VBox([self.title_label, self.interactive_elements.loading_label, self.main_top_frame], layout=main_layout) - data = [] - # Loop through each simulation and get the statistics for the given station_id + +class ThrottledClick: + """Class to throttle click events.""" + def __init__(self, delay=1.0): + self.delay = delay + self.last_call = 0 - for exp_name, stats in self.statistics.items(): + def should_process(self): + current_time = time.time() + if current_time - self.last_call > self.delay: + self.last_call = current_time + return True + return False - if station_id in stats["station"].values: - # print("Available station IDs in stats:", stats["station"].values) - row = [exp_name] + [ - round(stats[var].sel(station=station_id).values.item(), 2) - for var in stats.data_vars - if var not in ["longitude", "latitude"] - ] - data.append(row) +def prepare_simulations_data(simulations): + """process simulations raw datasets to a standard dataframe""" - # Convert the data to a DataFrame for display - columns = ["Exp. name"] + list(stats.data_vars.keys()) - statistics_df = pd.DataFrame(data, columns=columns) + # If simulations is a dictionary, load data for each experiment + sim_ds = {} + for exp, path in simulations.items(): + # Expanding the tilde + expanded_path = os.path.expanduser(path) - # Round the numerical columns to 2 decimal places - numerical_columns = [col for col in statistics_df.columns if col != "Exp. name"] - statistics_df[numerical_columns] = statistics_df[numerical_columns].round(2) + if os.path.isfile(expanded_path): # Check if it's a file + ds = xr.open_dataset(expanded_path) + elif os.path.isdir(expanded_path): # Check if it's a directory + # Handle the case when it's a directory; + # assume all .nc files in the directory need to be combined + files = [f for f in os.listdir(expanded_path) if f.endswith(".nc")] + ds = xr.open_mfdataset( + [os.path.join(expanded_path, f) for f in files], combine="by_coords" + ) + else: + raise ValueError(f"Invalid path: {expanded_path}") + sim_ds[exp] = ds - # Check if the dataframe has been generated correctly - if statistics_df.empty: - print(f"No statistics data found for station ID: {station_id}.") - return pd.DataFrame() # Return an empty dataframe + return sim_ds - return statistics_df - # def overlay_external_vector( - # self, vector_data: str, style=None, fill_color=None, line_color=None - # ): - # """Add vector data to the map.""" - # if style is None: - # style = { - # "stroke": True, - # "color": line_color if line_color else "#FF0000", - # "weight": 2, - # "opacity": 1, - # "fill": True, - # "fillColor": fill_color if fill_color else "#03f", - # "fillOpacity": 0.3, - # } - - # # Load the GeoJSON data from the file - # with open(vector_data, "r") as f: - # vector = json.load(f) - - # # Create the GeoJSON layer - # geojson_layer = GeoJSON(data=vector, style=style) - - # Update the title label - # vector_name = os.path.basename(vector_data) - - # # Define the callback to handle feature clicks - # def on_feature_click(event, feature, **kwargs): - # title = f"Feature property of the external vector: {vector_name}" - - # properties = feature["properties"] - # df = properties_to_dataframe(properties) - - - # # Bind the callback to the layer - # geojson_layer.on_click(on_feature_click) - - # self.geo_map.map.add_layer(geojson_layer) - - @staticmethod - def plot_station(self, station_data, station_id): - existing_traces = [trace.name for trace in self.f.data] - for var, y_data in station_data.items(): - data_exists = len(y_data) > 0 - - trace_name = f"Station {station_id} - {var}" - if data_exists and trace_name not in existing_traces: - # Use the time data from the dataset - x_data = station_data.get("time") - - if x_data is not None: - # Convert datetime64 array to native Python datetime objects - x_data = pd.to_datetime(x_data) - - self.f.add_scatter( - x=x_data, y=y_data, mode="lines+markers", name=trace_name - ) +def prepare_observations_data(observations, sim_ds, obs_var_name): + """process observation raw dataset to a standard dataframe""" + file_extension = os.path.splitext(observations)[-1].lower() + + if file_extension == ".csv": + obs_df = pd.read_csv(observations, parse_dates=["Timestamp"]) + obs_melted = obs_df.melt( + id_vars="Timestamp", var_name="station", value_name=obs_var_name + ) + + # Convert the melted DataFrame to xarray Dataset + obs_ds = obs_melted.set_index(["Timestamp", "station"]).to_xarray() + obs_ds = obs_ds.rename({"Timestamp": "time"}) + + elif file_extension == ".nc": + obs_ds = xr.open_dataset(observations) + + else: + raise ValueError("Unsupported file format for observations.") + + # Subset obs_ds based on sim_ds time values + if isinstance(sim_ds, xr.Dataset): + time_values = sim_ds["time"].values + elif isinstance(sim_ds, dict): + # Use the first dataset in the dictionary to determine time values + first_dataset = next(iter(sim_ds.values())) + time_values = first_dataset["time"].values + else: + raise ValueError("Unexpected type for sim_ds") + + obs_ds = obs_ds.sel(time=time_values) + return obs_ds def properties_to_dataframe(properties: Dict) -> pd.DataFrame: @@ -712,4 +530,20 @@ def filter_nan_values(dates, data_values): valid_dates = [date for date, val in zip(dates, data_values) if not np.isnan(val)] valid_data = [val for val in data_values if not np.isnan(val)] - return valid_dates, valid_data \ No newline at end of file + return valid_dates, valid_data + +def compute_bounds(stations_metadata, common_ids, station_index, coord_names): + # Filter the metadata to only include stations with common IDs + filtered_stations = stations_metadata[stations_metadata[station_index].isin(common_ids)] + + lon_column = coord_names[0] + lat_column = coord_names[1] + + lons = filtered_stations[lon_column].values + lats = filtered_stations[lat_column].values + + min_lat, max_lat = min(lats), max(lats) + min_lon, max_lon = min(lons), max(lons) + + return [(float(min_lat), float(min_lon)), (float(max_lat), float(max_lon))] + From daffeebd4c65b0a39489f4fba6bf6ec8fef02c6f Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Fri, 27 Oct 2023 01:04:46 +0200 Subject: [PATCH 19/31] Fully functioning restructured attempt, Corentin will do more cleanup --- hat/visualisation.py | 122 +++++++++++++++++++++++++++++-------------- 1 file changed, 83 insertions(+), 39 deletions(-) diff --git a/hat/visualisation.py b/hat/visualisation.py index 5e43400..daedd39 100644 --- a/hat/visualisation.py +++ b/hat/visualisation.py @@ -1,4 +1,3 @@ - """ Python module for visualising geospatial content using jupyter notebook, for both spatial and temporal, e.g. netcdf, vector, raster, with time series etc @@ -24,6 +23,7 @@ class InteractiveElements: def __init__(self, bounds, map_instance, statistics=None): + """Initialize the interactive elements for the map.""" self.map = self._initialize_map(bounds) self.loading_label = Label(value="") self.throttler = self._initialize_throttler() @@ -33,6 +33,8 @@ def __init__(self, bounds, map_instance, statistics=None): self.output_widget = Output() self.map_instance = map_instance self.statistics = map_instance.statistics + self.stations_metadata = map_instance.stations_metadata + self.station_index = map_instance.station_index def _initialize_map(self, bounds): @@ -42,6 +44,7 @@ def _initialize_map(self, bounds): return map_widget def generate_html_legend(self, colormap, min_val, max_val): + """Generate an HTML legend for the map.""" # Convert the colormap to a list of RGB values rgb_values = [mpl.colors.rgb2hex(colormap(i)) for i in np.linspace(0, 1, 256)] @@ -90,7 +93,7 @@ def handle_marker_selection(self, feature, **kwargs): self.handle_marker_action(station_id) def handle_marker_action(self, station_id): - '''Define a callback to handle marker clicks and add the plot figure.''' + '''Define a callback to handle marker clicks and add the plot figure and statistics and station property.''' # Check if we should process the click if not self.throttler.should_process(): @@ -98,10 +101,23 @@ def handle_marker_action(self, station_id): self.loading_label.value = "Loading..." # Indicate that data is being loaded station_id = str(station_id) # Convert station ID to string for consistency - print(f"Handling click for station: {station_id}") + station_name = self.stations_metadata.loc[self.stations_metadata[self.station_index] == station_id, "StationName"].values[0] + updated_title = f"Selected station:
ID: {station_id}, name: {station_name}
" # Update the plot with simulation and observation data self.plotly_obj.update(station_id) + self.plotly_obj.figure.update_layout(title={ + 'text': updated_title, + 'y': 0.9, + 'x': 0.5, + 'xanchor': 'center', + 'yanchor': 'top', + 'font': { + 'color': 'black', + 'size': 16 + } + } + ) # Generate and display the statistics table for the clicked station if self.statistics: @@ -121,19 +137,36 @@ def handle_marker_action(self, station_id): self.map_instance.top_right_frame.children = tuple(children_list) + + # Update the station table in the layout + children_list_main = list(self.map_instance.layout.children) + + # Find the index of the old station table and replace it with the new table + for i, child in enumerate(children_list_main): + if isinstance(child, type(self.table_obj.station_table_html)): + children_list_main[i] = self.table_obj.station_table_html + break + else: + # If the old station table wasn't found, append the new table + children_list_main.append(self.table_obj.station_table_html) + + self.map_instance.layout.children = tuple(children_list_main) + + self.table_obj.update(station_id) + self.loading_label.value = "" # Clear the loading message with self.output_widget: clear_output(wait=True) # Clear any previous plots or messages - print("End of handle_marker_action") - - class PlotlyObject: def __init__(self): + """Initialize the Plotly object for visualization.""" + initial_title = "Click on your desired station location!" self.figure = go.FigureWidget( layout=go.Layout( + title = initial_title, height=350, margin=dict(l=100), legend=dict( @@ -146,6 +179,7 @@ def __init__(self): ) def update_simulation_data(self, station_id): + """Update the simulation data for the given station ID.""" for name, ds in self.sim_ds.items(): if station_id in ds["station"].values: ds_time_series_data = ds["dis"].sel(station=station_id).values @@ -157,6 +191,7 @@ def update_simulation_data(self, station_id): print(f"Station ID: {station_id} not found in dataset {name}.") def update_observation_data(self, station_id): + """Update the observation data for the given station ID.""" if station_id in self.obs_ds["station"].values: obs_time_series = self.obs_ds["obsdis"].sel(station=station_id).values valid_dates_obs, valid_data_obs = filter_nan_values( @@ -167,6 +202,7 @@ def update_observation_data(self, station_id): print(f"Station ID: {station_id} not found in obs_df.") def _update_trace(self, x_data, y_data, name): + """Update or add a trace to the Plotly figure.""" trace_exists = any([trace.name == name for trace in self.figure.data]) if trace_exists: for trace in self.figure.data: @@ -177,15 +213,16 @@ def _update_trace(self, x_data, y_data, name): self.figure.add_trace( go.Scatter(x=x_data, y=y_data, mode="lines", name=name) ) - print(f"Updated plot with trace '{name}'. Total traces now: {len(self.figure.data)}") def update(self, station_id): + """Update the overall plot with new data for the given station ID.""" self.update_simulation_data(station_id) self.update_observation_data(station_id) class TableObject: def __init__(self, map_instance): + """Initialize the table object for displaying statistics and station properties.""" self.map_instance = map_instance # Define the styles for the statistics table self.table_style = """ @@ -220,10 +257,14 @@ def __init__(self, map_instance): """ self.stat_title_style = "style='font-size: 18px; font-weight: bold; text-align: center;'" + # Initialize the stat_table_html and station_table_html with empty tables + empty_df = pd.DataFrame() + self.stat_table_html = self.display_dataframe_with_scroll(empty_df, title="Model Performance Statistics Overview") + self.station_table_html = self.display_dataframe_with_scroll(empty_df, title="Station Property") - def generate_statistics_table(self, station_id): + """Generate a statistics table for the given station ID.""" data = [] # Check if statistics is None or empty @@ -255,22 +296,33 @@ def generate_statistics_table(self, station_id): statistics_df[numerical_columns] = statistics_df[numerical_columns].round(2) return statistics_df + + def generate_station_table(self, station_id): + """Generate a station property table for the given station ID.""" + stations_df = self.map_instance.stations_metadata + selected_station_df = stations_df[stations_df[self.map_instance.station_index] == station_id] + + return selected_station_df def display_dataframe_with_scroll(self, df, title=""): + """Display a DataFrame with a scrollable view.""" table_html = df.to_html(classes="custom-table") content = f"{self.table_style}

{title}

{table_html}
" return(HTML(content)) def update(self, station_id): - df_stats = self.generate_statistics_table(station_id) - self.stat_table_html = self.display_dataframe_with_scroll(df_stats, title="Statistics Overview") - print("Updated stat_table_html:", self.stat_table_html) + """Update the tables with new data for the given station ID.""" + df_stat = self.generate_statistics_table(station_id) + self.stat_table_html = self.display_dataframe_with_scroll(df_stat, title="Model Performance Statistics Overview") + df_station = self.generate_station_table(station_id) + self.station_table_html = self.display_dataframe_with_scroll(df_station, title="Station Property") class InteractiveMap: def __init__(self, config, stations, observations, simulations, stats=None): + """Initialize the interactive map with configurations and data sources.""" self.config = config self.stations_metadata = read_station_metadata_file( fpath=stations, @@ -321,7 +373,6 @@ def __init__(self, config, stations, observations, simulations, stats=None): self.interactive_elements.plotly_obj.ds_time_str = [dt.isoformat() for dt in pd.to_datetime(self.ds_time)] - def find_common_station(self): """find common station between observation and simulation and station metadata""" ids = [] @@ -347,6 +398,12 @@ def _update_plot_dates(self, change): def mapplot(self, colorby="kge", sim=None, range=None, colormap=None): + """Plot the map with stations colored by a given metric. + input example: + colorby = "kge" # this should be the objective functions of the statistics + range = [, ] #min and max values of the color bar + colormap = plt.cm.get_cmap("RdYlGn") # color map to be used based on matplotlib colormap + """ # Retrieve the statistics data of simulation choice/ by default stat_data = self.statistics[sim][colorby] @@ -379,8 +436,6 @@ def map_color(feature): point_style={'radius': 5}, style_callback=map_color, ) - - self.legend_widget = self.interactive_elements.generate_html_legend(colormap, min_val, max_val) @@ -395,10 +450,6 @@ def map_color(feature): self.start_date_picker.observe(self._update_plot_dates, names='value') self.end_date_picker.observe(self._update_plot_dates, names='value') - #initialise stat table with first ID - default_station_id = self.common_id[0] - self.interactive_elements.table_obj.update(default_station_id) - # Initialize layout elements self._initialize_layout_elements() @@ -407,6 +458,7 @@ def map_color(feature): def _initialize_layout_elements(self): + """Initialize the layout elements for the map visualization.""" # Title label self.title_label = Label( "Interactive Map Visualisation for Hydrological Model Performance", @@ -414,36 +466,37 @@ def _initialize_layout_elements(self): style={'font_weight': 'bold', 'font_size': '24px', 'font_family': 'Arial'} ) - # Date label - self.date_label = Label("Please select the date to accurately change the date axis of the plot") - - # Layouts main_layout = Layout(justify_content='space-around', align_items='stretch', spacing='2px', width='1000px') left_layout = Layout(justify_content='space-around', align_items='center', spacing='2px', width='40%') right_layout = Layout(justify_content='center', align_items='center', spacing='2px', width='60%') - # Date picker box + # Date picker box and label + self.date_label = Label("Please select the date to accurately change the date axis of the plot") self.date_picker_box = HBox([self.start_date_picker, self.end_date_picker]) # Frames self.top_right_frame = VBox([self.interactive_elements.plotly_obj.figure, self.date_label, self.date_picker_box, self.interactive_elements.table_obj.stat_table_html ], layout=right_layout) - self.top_left_frame = VBox([self.interactive_elements.map, self.legend_widget], layout=left_layout) # Assuming self.map is the map widget and self.legend_widget is the legend + self.top_left_frame = VBox([self.interactive_elements.map, self.legend_widget], layout=left_layout) self.main_top_frame = HBox([self.top_left_frame, self.top_right_frame]) # Main layout - self.layout = VBox([self.title_label, self.interactive_elements.loading_label, self.main_top_frame], layout=main_layout) + self.layout = VBox([self.title_label, + self.interactive_elements.loading_label, + self.main_top_frame, + self.interactive_elements.table_obj.station_table_html], layout=main_layout) class ThrottledClick: - """Class to throttle click events.""" + """Initialize a click throttler with a given delay. to prevent user from swift multiple events clicking that results in crashing""" def __init__(self, delay=1.0): self.delay = delay self.last_call = 0 def should_process(self): + """Determine if a click should be processed based on the delay.""" current_time = time.time() if current_time - self.last_call > self.delay: self.last_call = current_time @@ -516,23 +569,15 @@ def properties_to_dataframe(properties: Dict) -> pd.DataFrame: def filter_nan_values(dates, data_values): - """ - Filters out NaN values and their associated dates. - - Parameters: - - dates: List of dates. - - data_values: List of data values corresponding to the dates. - - Returns: - - valid_dates: List of dates without NaN values. - - valid_data: List of non-NaN data values. - """ + """Filters out NaN values and their associated dates.""" valid_dates = [date for date, val in zip(dates, data_values) if not np.isnan(val)] valid_data = [val for val in data_values if not np.isnan(val)] return valid_dates, valid_data def compute_bounds(stations_metadata, common_ids, station_index, coord_names): + """Compute the bounds of the map based on the stations metadata.""" + # Filter the metadata to only include stations with common IDs filtered_stations = stations_metadata[stations_metadata[station_index].isin(common_ids)] @@ -545,5 +590,4 @@ def compute_bounds(stations_metadata, common_ids, station_index, coord_names): min_lat, max_lat = min(lats), max(lats) min_lon, max_lon = min(lons), max(lons) - return [(float(min_lat), float(min_lon)), (float(max_lat), float(max_lon))] - + return [(float(min_lat), float(min_lon)), (float(max_lat), float(max_lon))] \ No newline at end of file From ca5481fdb722f232baef7e38f12b8cb08a54ae92 Mon Sep 17 00:00:00 2001 From: dadiyorto Date: Fri, 27 Oct 2023 01:09:48 +0200 Subject: [PATCH 20/31] added draft template for notebook, currently based on my local pc --- ...4_visualisation_interactive_template.ipynb | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 notebooks/examples/4_visualisation_interactive_template.ipynb diff --git a/notebooks/examples/4_visualisation_interactive_template.ipynb b/notebooks/examples/4_visualisation_interactive_template.ipynb new file mode 100644 index 0000000..544522f --- /dev/null +++ b/notebooks/examples/4_visualisation_interactive_template.ipynb @@ -0,0 +1,119 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "1. Import visualisation library and define inputs (stations, observations, simulation, statistics, and also the config that may differ depending on the simulation)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from hat.visualisation import InteractiveMap\n", + "\n", + "stations= '~/destinE/outlets_v4.0_20230726_withEFAS.csv'\n", + "observations= '~/destinE/destine_observation.nc'\n", + "simulations = {\n", + " \"i05j\": \"~/destinE/cama_i05j_stations.nc\",\n", + " \"i05h\": \"~/destinE/cama_i05h_stations.nc\"\n", + "}\n", + "# xarray input\n", + "\n", + "statistics = {\n", + " \"i05j\": \"~/destinE/statistics_i05j.nc\",\n", + " \"i05h\": \"~/destinE/statistics_i05h.nc\"\n", + "}\n", + "# xarray input\n", + "\n", + "config = {\n", + " \"station_epsg\": 4326,\n", + " \"station_id_column_name\": \"station_id\",\n", + " \"station_filters\":\"\",\n", + " \"station_coordinates\": [\"StationLon\", \"StationLat\"],\n", + " \"obs_var_name\": \"obsdis\"\n", + "}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "2. Initialise & processing all the input data into the map object" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Found 1614 common stations\n" + ] + } + ], + "source": [ + "map = InteractiveMap(config, stations, observations, simulations, stats=statistics)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "3. Display map interface for interactive viewing" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "44784a504c27442990df5eb6a5f26a72", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "VBox(children=(Label(value='Interactive Map Visualisation for Hydrological Model Performance', layout=Layout(j…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "map.mapplot(colorby='kge', sim='i05h', range=[-1, 1]) " + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "hat-dev", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} From be9d02de638e9eb7a6c745efc46237884d554c3b Mon Sep 17 00:00:00 2001 From: corentincarton Date: Tue, 31 Oct 2023 13:53:01 +0000 Subject: [PATCH 21/31] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0d825dc..69cc149 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Interfaces and functionality are likely to change, and the project itself may be Clone source code repository - $ git clone git@github.com:ecmwf-projects/hat.git + $ git clone https://github.com/ecmwf/hat.git Create conda python environment @@ -63,4 +63,4 @@ does it submit to any jurisdiction. ### Citing -In publications, please use a link to this repository (https://github.com/ecmwf/hat) and its documentation (https://hydro-analysis-toolkit.readthedocs.io) \ No newline at end of file +In publications, please use a link to this repository (https://github.com/ecmwf/hat) and its documentation (https://hydro-analysis-toolkit.readthedocs.io) From 122e4c32e3cdafca09757baf314365ca1ae6c3f4 Mon Sep 17 00:00:00 2001 From: Corentin Carton De Wiart Date: Tue, 31 Oct 2023 16:02:53 +0000 Subject: [PATCH 22/31] refactor interactive notebook --- hat/clock.py | 71 --- hat/filters.py | 2 +- hat/graphs.py | 30 - hat/hydrostats.py | 9 +- hat/images.py | 29 - hat/interactive/__init__.py | 0 hat/interactive/explorers.py | 258 ++++++++ hat/interactive/leaflet.py | 150 +++++ hat/interactive/widgets.py | 319 ++++++++++ hat/networking.py | 44 -- hat/parsers.py | 17 - hat/plots.py | 72 --- hat/tools/hydrostats_cli.py | 3 +- hat/visualisation.py | 593 ------------------ hat/visualisation_v2.py | 199 ------ .../4_visualisation_interactive.ipynb | 20 +- tests/test_hydrostats_decorators.py | 11 +- 17 files changed, 755 insertions(+), 1072 deletions(-) delete mode 100644 hat/clock.py delete mode 100644 hat/graphs.py delete mode 100644 hat/images.py create mode 100644 hat/interactive/__init__.py create mode 100644 hat/interactive/explorers.py create mode 100644 hat/interactive/leaflet.py create mode 100644 hat/interactive/widgets.py delete mode 100644 hat/networking.py delete mode 100644 hat/parsers.py delete mode 100644 hat/plots.py delete mode 100644 hat/visualisation.py delete mode 100644 hat/visualisation_v2.py diff --git a/hat/clock.py b/hat/clock.py deleted file mode 100644 index 409fa97..0000000 --- a/hat/clock.py +++ /dev/null @@ -1,71 +0,0 @@ -import datetime -import functools -import time - -import humanize - - -def digital_clock(func): - "A quiet decorator for timing functions" - - @functools.wraps(func) - def clocked(*args, **kwargs): - # time - t0 = time.perf_counter() - result = func(*args, **kwargs) - elapsed = time.perf_counter() - t0 - human_readable_time = humanize.naturaldelta(datetime.timedelta(seconds=elapsed)) - - # name - name = func.__name__ - - print(f"{name}() took {human_readable_time}") - return result - - # return function with timing decorator - return clocked - - -def clock(func): - "A verbose decorator for timing functions" - - @functools.wraps(func) - def clocked(*args, **kwargs): - # time - t0 = time.perf_counter() - result = func(*args, **kwargs) - elapsed = time.perf_counter() - t0 - human_readable_time = humanize.naturaldelta(datetime.timedelta(seconds=elapsed)) - - # name - name = func.__name__ - - # arguments - arg_str = ", ".join(repr(arg) for arg in args) - if arg_str == "": - arg_str = "no arguments" - - # keywords - pairs = [f"{k}={w}" for k, w in sorted(kwargs.items())] - key_str = ", ".join(pairs) - if key_str == "": - key_str = "no keywords" - - print( - f"""{name}() took {human_readable_time} to run - with following inputs {arg_str} and {key_str}""" - ) - return result - - # return function with timing decorator - return clocked - - -if __name__ == "__main__": - - @clock - def test(arg1, keyword=False): - time.sleep(0.1) - pass - - test(1, keyword="hello") diff --git a/hat/filters.py b/hat/filters.py index cae74c8..bc0aebb 100644 --- a/hat/filters.py +++ b/hat/filters.py @@ -164,7 +164,7 @@ def filter_timeseries(sims_ds: xr.DataArray, obs_ds: xr.DataArray, threshold=80) obs_ds = obs_ds.sel(station=matching_stations) obs_ds = obs_ds.sel(time=sims_ds.time) - obs_ds = obs_ds.dropna(dim='station', how='all') + obs_ds = obs_ds.dropna(dim="station", how="all") sims_ds = sims_ds.sel(station=obs_ds.station) # Only keep observations in the same time period as the simulations diff --git a/hat/graphs.py b/hat/graphs.py deleted file mode 100644 index 8f6ba39..0000000 --- a/hat/graphs.py +++ /dev/null @@ -1,30 +0,0 @@ -import pandas as pd -import plotly.express as px - - -def graph_sims_and_obs( - sims, - obs, - ID, - sims_data_name="simulation_timeseries", - obs_data_name="obsdis", - height=500, - width=1200, -): - # observations, simulations, time - o = obs.sel(station=ID)[obs_data_name].values - s = sims.sel(station=ID)[sims_data_name].values - t = obs.sel(station=ID).time.values - - df = pd.DataFrame({"time": t, "simulations": s, "observations": o}) - fig = px.line( - df, - x="time", - y=["simulations", "observations"], - title="Simulations & Observations", - ) - fig.data[0].line.color = "#34eb7d" - fig.data[1].line.color = "#3495eb" - fig.update_layout(height=height, width=width) - fig.update_yaxes(title_text="discharge") - fig.show() diff --git a/hat/hydrostats.py b/hat/hydrostats.py index 67cc14b..c2712d3 100644 --- a/hat/hydrostats.py +++ b/hat/hydrostats.py @@ -3,7 +3,6 @@ import folium import geopandas as gpd -import pandas as pd import numpy as np import xarray as xr from branca.colormap import linear @@ -17,7 +16,9 @@ def run_analysis( sims_ds: xr.DataArray, obs_ds: xr.DataArray, ) -> xr.Dataset: - """Run statistical analysis on simulation and observation timeseries""" + """ + Run statistical analysis on simulation and observation timeseries + """ # list of stations stations = sims_ds.coords["station"].values @@ -35,7 +36,7 @@ def run_analysis( for station in stations: sims = sims_ds.sel(station=station).to_numpy() obs = obs_ds.sel(station=station).to_numpy() - + stat = func(sims, obs) if stat is None: print(f"Warning! All NaNs for station {station}") @@ -44,7 +45,7 @@ def run_analysis( statistics = np.array(statistics) # Add the Series to the DataFrame - ds[name] = xr.DataArray(statistics, coords={'station': stations}) + ds[name] = xr.DataArray(statistics, coords={"station": stations}) return ds diff --git a/hat/images.py b/hat/images.py deleted file mode 100644 index 14ebeae..0000000 --- a/hat/images.py +++ /dev/null @@ -1,29 +0,0 @@ -import matplotlib -import numpy as np -from quicklook import quicklook - - -def arr_to_image(arr: np.array) -> np.array: - """modify array so that it is optimized for viewing""" - - # image array - img = np.array(arr) - - img = quicklook.replace_nan(img) - img = quicklook.percentile_clip(img, 2) - img = quicklook.bytescale(img) - img = quicklook.reshape_array(img) - - return img - - -def numpy_to_png( - arr: np.array, dim="time", index="somedate", fpath="image.png" -) -> None: - """Save numpy array to png""" - - # image from array - img = arr_to_image(arr) - - # save to file - matplotlib.image.imsave(fpath, img) diff --git a/hat/interactive/__init__.py b/hat/interactive/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hat/interactive/explorers.py b/hat/interactive/explorers.py new file mode 100644 index 0000000..4026318 --- /dev/null +++ b/hat/interactive/explorers.py @@ -0,0 +1,258 @@ +import os + +import ipywidgets +import pandas as pd +import xarray as xr +from IPython.core.display import display + +from hat.interactive.leaflet import LeafletMap, PyleafletColormap +from hat.interactive.widgets import ( + MetaDataWidget, + PlotlyWidget, + StatisticsWidget, + WidgetsManager, +) +from hat.observations import read_station_metadata_file + + +def prepare_simulations_data(simulations, sims_var_name): + """ + process simulations raw datasets to a standard dataframe + """ + + # If simulations is a dictionary, load data for each experiment + sim_ds = {} + for exp, path in simulations.items(): + # Expanding the tilde + expanded_path = os.path.expanduser(path) + + if os.path.isfile(expanded_path): # Check if it's a file + ds = xr.open_dataset(expanded_path) + elif os.path.isdir(expanded_path): # Check if it's a directory + # Handle the case when it's a directory; + # assume all .nc files in the directory need to be combined + files = [f for f in os.listdir(expanded_path) if f.endswith(".nc")] + ds = xr.open_mfdataset( + [os.path.join(expanded_path, f) for f in files], combine="by_coords" + ) + else: + raise ValueError(f"Invalid path: {expanded_path}") + sim_ds[exp] = ds[sims_var_name] + + return sim_ds + + +def prepare_observations_data(observations, sim_ds, obs_var_name): + """ + process observation raw dataset to a standard dataframe + """ + file_extension = os.path.splitext(observations)[-1].lower() + + if file_extension == ".csv": + obs_df = pd.read_csv(observations, parse_dates=["Timestamp"]) + obs_melted = obs_df.melt( + id_vars="Timestamp", var_name="station", value_name=obs_var_name + ) + # Convert the melted DataFrame to xarray Dataset + obs_ds = obs_melted.set_index(["Timestamp", "station"]).to_xarray() + obs_ds = obs_ds.rename({"Timestamp": "time"}) + elif file_extension == ".nc": + obs_ds = xr.open_dataset(observations) + else: + raise ValueError("Unsupported file format for observations.") + + # Subset obs_ds based on sim_ds time values + if isinstance(sim_ds, xr.Dataset): + time_values = sim_ds["time"].values + elif isinstance(sim_ds, dict): + # Use the first dataset in the dictionary to determine time values + first_dataset = next(iter(sim_ds.values())) + time_values = first_dataset["time"].values + else: + raise ValueError("Unexpected type for sim_ds") + + obs_ds = obs_ds[obs_var_name].sel(time=time_values) + return obs_ds + + +def find_common_station(station_index, stations_metadata, statistics, sim_ds, obs_ds): + """ + find common station between observation and simulation and station metadata + """ + ids = [] + ids += [list(obs_ds["station"].values)] + ids += [list(ds["station"].values) for ds in sim_ds.values()] + ids += [stations_metadata[station_index]] + if statistics: + ids += [list(ds["station"].values) for ds in statistics.values()] + + common_ids = None + for id in ids: + if common_ids is None: + common_ids = set(id) + else: + common_ids = set(id) & common_ids + return list(common_ids) + + +class TimeSeriesExplorer: + """ + Initialize the interactive map with configurations and data sources. + """ + + def __init__(self, config, stations, observations, simulations, stats=None): + self.config = config + self.stations_metadata = read_station_metadata_file( + fpath=stations, + coord_names=config["station_coordinates"], + epsg=config["station_epsg"], + filters=config["station_filters"], + ) + + # Use the external functions to prepare data + sim_ds = prepare_simulations_data(simulations, config["sims_var_name"]) + obs_ds = prepare_observations_data(observations, sim_ds, config["obs_var_name"]) + + # set station index + self.station_index = config["station_id_column_name"] + + # Retrieve statistics from the statistics netcdf input + self.statistics = {} + if stats: + for name, path in stats.items(): + self.statistics[name] = xr.open_dataset(path) + + # Ensure the keys of self.statistics match the keys of self.sim_ds + assert set(self.statistics.keys()) == set( + sim_ds.keys() + ), "Mismatch between statistics and simulations keys." + + # find common station ids between metadata, observation and simulations + common_ids = find_common_station( + self.station_index, self.stations_metadata, self.statistics, sim_ds, obs_ds + ) + + print(f"Found {len(common_ids)} common stations") + self.stations_metadata = self.stations_metadata.loc[ + self.stations_metadata[self.station_index].isin(common_ids) + ] + obs_ds = obs_ds.sel(station=common_ids) + for sim, ds in sim_ds.items(): + sim_ds[sim] = ds.sel(station=common_ids) + + # Create loading widget + self.loading_widget = ipywidgets.Label(value="") + + # Title label + self.title_label = ipywidgets.Label( + "Interactive Map Visualisation for Hydrological Model Performance", + layout=ipywidgets.Layout(justify_content="center"), + style={"font_weight": "bold", "font_size": "24px", "font_family": "Arial"}, + ) + + # Create the interactive widgets + datasets = sim_ds + datasets["obs"] = obs_ds + widgets = {} + widgets["plot"] = PlotlyWidget(datasets) + widgets["stats"] = StatisticsWidget(self.statistics) + widgets["meta"] = MetaDataWidget(self.stations_metadata, self.station_index) + self.widgets = WidgetsManager( + widgets, config["station_id_column_name"], self.loading_widget + ) + + # Create the main leaflet map + self.leafletmap = LeafletMap() + + def create_frame(self): + """ + Initialize the layout elements for the map visualization. + """ + + # # Layouts 1 + # main_layout = ipywidgets.Layout( + # justify_content='space-around', + # align_items='stretch', + # spacing='2px', + # width='1000px' + # ) + # half_layout = ipywidgets.Layout( + # justify_content='space-around', + # align_items='center', + # spacing='2px', + # width='50%' + # ) + + # # Frames + # stats_frame = ipywidgets.HBox( + # [self.widgets['plot'].output, self.widgets['stats'].output], + # # layout=main_layout + # ) + # main_frame = ipywidgets.VBox( + # [ + # self.title_label, + # self.loading_widget, + # self.leafletmap.output(main_layout), + # self.widgets['meta'].output, stats_frame + # ], + # layout=main_layout + # ) + + # Layouts 2 + main_layout = ipywidgets.Layout( + justify_content="space-around", + align_items="stretch", + spacing="2px", + width="1000px", + ) + left_layout = ipywidgets.Layout( + justify_content="space-around", + align_items="center", + spacing="2px", + width="40%", + ) + right_layout = ipywidgets.Layout( + justify_content="center", align_items="center", spacing="2px", width="60%" + ) + + # Frames + top_left_frame = self.leafletmap.output(left_layout) + top_right_frame = ipywidgets.VBox( + [self.widgets["plot"].output, self.widgets["stats"].output], + layout=right_layout, + ) + main_top_frame = ipywidgets.HBox([top_left_frame, top_right_frame]) + + # Main layout + main_frame = ipywidgets.VBox( + [self.title_label, main_top_frame, self.widgets["meta"].output], + layout=main_layout, + ) + return main_frame + + def mapplot(self, colorby=None, sim=None, limits=None, mp_colormap="viridis"): + """Plot the map with stations colored by a given metric. + input example: + colorby = "kge" this should be the objective functions of the statistics + limits = [, ] min and max values of the color bar + mp_colormap = "viridis" colormap name to be used based on matplotlib colormap + """ + # create colormap from statistics + stats = None + if self.statistics and colorby is not None and sim is not None: + stats = self.statistics[sim][colorby] + colormap = PyleafletColormap(self.config, stats, mp_colormap, limits) + + # add layer to the leaflet map + self.leafletmap.add_geolayer( + self.stations_metadata, + colormap, + self.widgets, + self.config["station_coordinates"], + ) + + # Initialize frame elements + frame = self.create_frame() + + # Display the main layout + display(frame) diff --git a/hat/interactive/leaflet.py b/hat/interactive/leaflet.py new file mode 100644 index 0000000..9585d35 --- /dev/null +++ b/hat/interactive/leaflet.py @@ -0,0 +1,150 @@ +import json + +import ipyleaflet +import ipywidgets +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np + + +def _compute_bounds(stations_metadata, coord_names): + """Compute the bounds of the map based on the stations metadata.""" + + lon_column = coord_names[0] + lat_column = coord_names[1] + + lons = stations_metadata[lon_column].values + lats = stations_metadata[lat_column].values + + min_lat, max_lat = min(lats), max(lats) + min_lon, max_lon = min(lons), max(lons) + + return [(float(min_lat), float(min_lon)), (float(max_lat), float(max_lon))] + + +class LeafletMap: + def __init__( + self, + basemap=ipyleaflet.basemaps.OpenStreetMap.Mapnik, + ): + self.map = ipyleaflet.Map( + basemap=basemap, layout=ipywidgets.Layout(width="100%", height="600px") + ) + self.legend_widget = ipywidgets.Output() + + def _set_boundaries(self, stations_metadata, coord_names): + """Compute the boundaries of the map based on the stations metadata.""" + + lon_column = coord_names[0] + lat_column = coord_names[1] + + lons = stations_metadata[lon_column].values + lats = stations_metadata[lat_column].values + + min_lat, max_lat = min(lats), max(lats) + min_lon, max_lon = min(lons), max(lons) + + bounds = [(float(min_lat), float(min_lon)), (float(max_lat), float(max_lon))] + self.map.fit_bounds(bounds) + + def add_geolayer(self, geodata, colormap, widgets, coord_names=None): + geojson = ipyleaflet.GeoJSON( + data=json.loads(geodata.to_json()), + style={ + "radius": 7, + "opacity": 0.5, + "weight": 1.9, + "dashArray": "2", + "fillOpacity": 0.5, + }, + hover_style={"radius": 10, "fillOpacity": 1}, + point_style={"radius": 5}, + style_callback=colormap.style_callback(), + ) + geojson.on_click(widgets.update) + self.map.add_layer(geojson) + + if coord_names is not None: + self._set_boundaries(geodata, coord_names) + + self.legend_widget = colormap.legend() + + def output(self, layout): + output = ipywidgets.VBox([self.map, self.legend_widget], layout=layout) + return output + + +class PyleafletColormap: + def __init__(self, config, stats=None, colormap_style="viridis", range=None): + self.config = config + self.stats = stats + print(self.stats) + if self.stats is not None: + # Normalize the data for coloring + if range is None: + self.min_val = self.stats.values.min() + self.max_val = self.stats.values.max() + else: + self.min_val = range[0] + self.max_val = range[1] + else: + self.min_val = 0 + self.max_val = 1 + + self.colormap = plt.cm.get_cmap(colormap_style) + + def style_callback(self): + if self.stats is not None: + norm = plt.Normalize(self.min_val, self.max_val) + + def map_color(feature): + station_id = feature["properties"][ + self.config["station_id_column_name"] + ] + color = mpl.colors.rgb2hex( + self.colormap(norm(self.stats.sel(station=station_id).values)) + ) + return { + "color": "black", + "fillColor": color, + } + + else: + + def map_color(feature): + return { + "color": "black", + "fillColor": "blue", + } + + return map_color + + def legend(self): + """Generate an HTML legend for the map.""" + # Convert the colormap to a list of RGB values + rgb_values = [ + mpl.colors.rgb2hex(self.colormap(i)) for i in np.linspace(0, 1, 256) + ] + + # Create a gradient style using the RGB values + gradient_style = ", ".join(rgb_values) + gradient_html = f""" +
+ """ + + # Create labels + labels_html = f""" +
+ Low: {self.min_val:.1f} + High: {self.max_val:.1f} +
+ """ + # Combine gradient and labels + legend_html = gradient_html + labels_html + + return ipywidgets.HTML(legend_html) diff --git a/hat/interactive/widgets.py b/hat/interactive/widgets.py new file mode 100644 index 0000000..f4ce147 --- /dev/null +++ b/hat/interactive/widgets.py @@ -0,0 +1,319 @@ +import time + +import numpy as np +import pandas as pd +import plotly.graph_objs as go +from IPython.core.display import display +from IPython.display import clear_output +from ipywidgets import HTML, DatePicker, HBox, Label, Layout, Output, VBox + + +class ThrottledClick: + """ + Initialize a click throttler with a given delay. + to prevent user from swift multiple events clicking that results in crashing + """ + + def __init__(self, delay=1.0): + self.delay = delay + self.last_call = 0 + + def should_process(self): + """ + Determine if a click should be processed based on the delay. + """ + current_time = time.time() + if current_time - self.last_call > self.delay: + self.last_call = current_time + return True + return False + + +class WidgetsManager: + def __init__(self, widgets, index_column, loading_widget=None): + self.widgets = widgets + self.index_column = index_column + self.throttler = self._initialize_throttler() + self.loading_widget = loading_widget + + def _initialize_throttler(self, delay=1.0): + """Initialize the throttler for click events.""" + return ThrottledClick(delay) + + def update(self, feature, **kwargs): + """Handle the selection of a marker on the map.""" + + # Check if we should process the click + if not self.throttler.should_process(): + return + + if self.loading_widget is not None: + self.loading_widget.value = ( + "Loading..." # Indicate that data is being loaded + ) + + # Extract station_id from the selected feature + metadata = feature["properties"] + index = metadata[self.index_column] + + # update widgets + for wgt in self.widgets.values(): + wgt.update(index, metadata) + + if self.loading_widget is not None: + self.loading_widget.value = "" # Clear the loading message + + def __getitem__(self, item): + return self.widgets[item] + + +class Widget: + def __init__(self, output): + self.output = output + + def update(self, index, metadata): + raise NotImplementedError + + +def filter_nan_values(dates, data_values): + """Filters out NaN values and their associated dates.""" + valid_dates = [date for date, val in zip(dates, data_values) if not np.isnan(val)] + valid_data = [val for val in data_values if not np.isnan(val)] + + return valid_dates, valid_data + + +class PlotlyWidget(Widget): + """Plotly widget to display timeseries.""" + + def __init__(self, datasets): + self.datasets = datasets + # initial_title = { + # 'text': "Click on your desired station location", + # 'y':0.9, + # 'x':0.5, + # 'xanchor': 'center', + # 'yanchor': 'top' + # } + self.figure = go.FigureWidget( + layout=go.Layout( + # title = initial_title, + height=350, + margin=dict(l=120), + legend=dict( + orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 + ), + xaxis_title="Date", + xaxis_tickformat="%d-%m-%Y", + yaxis_title="Discharge [m3/s]", + ) + ) + ds_time = datasets["obs"]["time"].values.astype("datetime64[D]") + self.ds_time_str = [dt.isoformat() for dt in pd.to_datetime(ds_time)] + + # Add date pickers for start and end dates + self.start_date_picker = DatePicker(description="Start") + self.end_date_picker = DatePicker(description="End") + + # Observe changes in the date pickers to update the plot + self.start_date_picker.observe(self._update_plot_dates, names="value") + self.end_date_picker.observe(self._update_plot_dates, names="value") + + # Date picker title + date_label = Label( + "Please select the date to accurately change the date axis of the plot" + ) + date_picker_box = HBox([self.start_date_picker, self.end_date_picker]) + + layout = Layout(justify_content="center", align_items="center") + output = VBox([self.figure, date_label, date_picker_box], layout=layout) + super().__init__(output) + + def _update_plot_dates(self, change): + start_date = self.start_date_picker.value.strftime("%Y-%m-%d") + end_date = self.end_date_picker.value.strftime("%Y-%m-%d") + self.figure.update_layout(xaxis_range=[start_date, end_date]) + + def update_data(self, station_id): + """Update the simulation data for the given station ID.""" + for name, ds in self.datasets.items(): + if station_id in ds["station"].values: + ds_time_series_data = ds.sel(station=station_id).values + valid_dates_ds, valid_data_ds = filter_nan_values( + self.ds_time_str, ds_time_series_data + ) + self._update_trace(valid_dates_ds, valid_data_ds, name) + else: + print(f"Station ID: {station_id} not found in dataset {name}.") + + def _update_trace(self, x_data, y_data, name): + """Update or add a trace to the Plotly figure.""" + trace_exists = any([trace.name == name for trace in self.figure.data]) + if trace_exists: + for trace in self.figure.data: + if trace.name == name: + trace.x = x_data + trace.y = y_data + else: + self.figure.add_trace( + go.Scatter(x=x_data, y=y_data, mode="lines", name=name) + ) + + def update_title(self, metadata): + station_id = metadata["station_id"] + station_name = metadata["StationName"] + updated_title = ( + f"Selected station:
ID: {station_id}, name: {station_name}
" + ) + self.figure.update_layout( + title={ + "text": updated_title, + "y": 0.9, + "x": 0.5, + "xanchor": "center", + "yanchor": "top", + "font": {"color": "black", "size": 16}, + } + ) + + def update(self, index, metadata): + """Update the overall plot with new data for the given station ID.""" + # self.update_title(metadata) + self.update_data(index) + + +class HTMLTableWidget(Widget): + def __init__(self, dataframe, title): + """ + Initialize the table object for displaying statistics and station properties. + """ + self.dataframe = dataframe + self.title = title + super().__init__(Output()) + + # Define the styles for the statistics table + self.table_style = """ + + """ + self.stat_title_style = ( + "style='font-size: 18px; font-weight: bold; text-align: center;'" + ) + # Initialize the stat_table_html and station_table_html with empty tables + empty_df = pd.DataFrame() + self.display_dataframe_with_scroll(empty_df, title=self.title) + + def display_dataframe_with_scroll(self, df, title=""): + """Display a DataFrame with a scrollable view.""" + table_html = df.to_html(classes="custom-table") + content = f"{self.table_style}

{title}

{table_html}
" # noqa: E501 + with self.output: + clear_output(wait=True) # Clear any previous plots or messages + display(HTML(content)) + + def update(self, index, metadata): + dataframe = self.extract_dataframe(index) + self.display_dataframe_with_scroll(dataframe, title=self.title) + + +class DataFrameWidget(Widget): + def __init__(self, dataframe, title): + """ + Initialize the table object for displaying statistics and station properties. + """ + self.dataframe = dataframe + self.title = title + super().__init__(output=Output(title=self.title)) + + # Initialize the stat_table_html and station_table_html with empty tables + empty_df = pd.DataFrame() + with self.output: + clear_output(wait=True) # Clear any previous plots or messages + display(empty_df) + + def update(self, index, metadata): + dataframe = self.extract_dataframe(index) + with self.output: + clear_output(wait=True) # Clear any previous plots or messages + display(dataframe) + + +class MetaDataWidget(HTMLTableWidget): + def __init__(self, dataframe, station_index): + title = "Station Metadata" + self.station_index = station_index + super().__init__(dataframe, title) + + def extract_dataframe(self, station_id): + """Generate a station property table for the given station ID.""" + stations_df = self.dataframe + selected_station_df = stations_df[stations_df[self.station_index] == station_id] + + return selected_station_df + + +class StatisticsWidget(HTMLTableWidget): + def __init__(self, dataframe): + title = "Model Performance Statistics Overview" + super().__init__(dataframe, title) + + def extract_dataframe(self, station_id): + """Generate a statistics table for the given station ID.""" + data = [] + + # Check if statistics is None or empty + if not self.dataframe: + print("No statistics data provided.") + return pd.DataFrame() # Return an empty dataframe + + # Loop through each simulation and get the statistics for the given station_id + for exp_name, stats in self.dataframe.items(): + if station_id in stats["station"].values: + row = [exp_name] + [ + round(stats[var].sel(station=station_id).values.item(), 2) + for var in stats.data_vars + if var not in ["longitude", "latitude"] + ] + data.append(row) + + # Check if data has any items + if not data: + print(f"No statistics data found for station ID: {station_id}.") + return pd.DataFrame() # Return an empty dataframe + + # Convert the data to a DataFrame for display + columns = ["Exp. name"] + list(stats.data_vars.keys()) + statistics_df = pd.DataFrame(data, columns=columns) + + # Round the numerical columns to 2 decimal places + numerical_columns = [col for col in statistics_df.columns if col != "Exp. name"] + statistics_df[numerical_columns] = statistics_df[numerical_columns].round(2) + + return statistics_df diff --git a/hat/networking.py b/hat/networking.py deleted file mode 100644 index a5b04ab..0000000 --- a/hat/networking.py +++ /dev/null @@ -1,44 +0,0 @@ -import os -import platform -import socket - - -def get_host(): - """Local host on Mac and network host on HPC - (note the network address on HPC is not constant)""" - - # get the hostname - hostname = socket.gethostname() - - # get the IP address(es) associated with the hostname - ip_addresses = socket.getaddrinfo( - hostname, None, socket.AF_INET, socket.SOCK_STREAM - ) - - # return first valid address - for ip_address in ip_addresses: - network_host = ip_address[4][0] - return network_host - - -def mac_or_hpc(): - """Is this running on a Mac or the HPC or other?""" - - if platform.system() == "Darwin": - return "mac" - elif platform.system() == "Linux" and os.environ.get("ECPLATFORM"): - return "hpc" - else: - return "other" - - -def host_and_port(host="127.0.0.1", port=8000): - """return network host and port for tiler app to use""" - - computer = mac_or_hpc() - - if computer == "hpc": - host = get_host() - port = 8700 - - return (host, port) diff --git a/hat/parsers.py b/hat/parsers.py deleted file mode 100644 index 0c50b64..0000000 --- a/hat/parsers.py +++ /dev/null @@ -1,17 +0,0 @@ -import dateutil.parser -import streamlit as st - - -@st.cache_data -def datetime_from_cftime(cftimes): - """parse CFTimeIndex to python datetime, - e.g. from a NetCDF file ds.indexes['time']""" - return [dateutil.parser.parse(x.isoformat()) for x in cftimes] - - -def simulation_timeperiod(sim): - # simulation timeperiod - min_time = min(sim.indexes["time"]) - max_time = max(sim.indexes["time"]) - - return (min_time, max_time) diff --git a/hat/plots.py b/hat/plots.py deleted file mode 100644 index 3581409..0000000 --- a/hat/plots.py +++ /dev/null @@ -1,72 +0,0 @@ -from typing import Union - -import numpy as np -import pandas as pd -import plotly.express as px -import streamlit as st -from matplotlib import pyplot as plt - - -# PLOTLY (interactive) -def plotly_timeseries(t, y): - df = pd.DataFrame({"time": t, "discharge": y}) - return px.line(df, x="time", y="discharge", title="Discharge Timeseries") - - -# MATPLOTLIB (not interactive) -def plot_timeseries(t, y, jupyter=False): - fig, ax1 = plt.subplots() - fig.set_size_inches(14, 6) - ax1.plot(t, y, "dodgerblue") - ax1.set_xlabel("time (s)") - ax1.set_ylabel("discharge", color="b") - ax1.tick_params("y", colors="b") - - if jupyter: - return fig - - st.write(fig) - - -def histogram( - arr: Union[np.array, np.ma.MaskedArray], - bins=10, - clip=None, - title="Histogram", - figsize=(6, 4), -): - """plot histogram of a numpy array or masked numpy array""" - - # apply mask (if one exists) - if isinstance(arr, np.ma.MaskedArray): - arr = arr.compressed() - - # return if not numpy - if not isinstance(arr, np.ndarray): - print("histogram() requires a numpy array or masked numpy array") - return - - # remove flat dimensions - arr = arr.squeeze() - - # remove nans - arr = arr[~np.isnan(arr)] - - # histogram range (percentile clip or minmax) - if clip: - histogram_range = ( - round(np.percentile(arr, clip)), - round(np.percentile(arr, 100 - clip)), - ) - else: - histogram_range = (np.min(arr), np.max(arr)) - - # count number of values in each bin - counts, bins = np.histogram(arr, bins=bins, range=histogram_range) - - _ = plt.figure(figsize=figsize) - plt.hist(bins[:-1], bins, weights=counts) - plt.title(title) - - # show plot - plt.show() diff --git a/hat/tools/hydrostats_cli.py b/hat/tools/hydrostats_cli.py index 7aae11f..204f92a 100644 --- a/hat/tools/hydrostats_cli.py +++ b/hat/tools/hydrostats_cli.py @@ -4,10 +4,10 @@ import xarray as xr from hat import hydrostats_functions +from hat.data import find_main_var from hat.exceptions import UserError from hat.filters import filter_timeseries from hat.hydrostats import run_analysis -from hat.data import find_main_var def check_inputs(functions, sims, obs): @@ -80,7 +80,6 @@ def hydrostats_cli( if not functions: return - # simulations sims_ds = xr.open_dataset(sims) var = find_main_var(sims_ds, min_dim=2) diff --git a/hat/visualisation.py b/hat/visualisation.py deleted file mode 100644 index daedd39..0000000 --- a/hat/visualisation.py +++ /dev/null @@ -1,593 +0,0 @@ -""" -Python module for visualising geospatial content using jupyter notebook, -for both spatial and temporal, e.g. netcdf, vector, raster, with time series etc -""" - -import json -import os -import time -from typing import Dict -import geopandas as gpd -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd -import plotly.graph_objs as go -import xarray as xr -from ipyleaflet import CircleMarker, Map, GeoJSON -from IPython.core.display import display -from IPython.display import clear_output -from ipywidgets import HTML, HBox, Label, Layout, Output, VBox, DatePicker -import matplotlib as mpl -from hat.observations import read_station_metadata_file - - -class InteractiveElements: - def __init__(self, bounds, map_instance, statistics=None): - """Initialize the interactive elements for the map.""" - self.map = self._initialize_map(bounds) - self.loading_label = Label(value="") - self.throttler = self._initialize_throttler() - self.plotly_obj = PlotlyObject() - self.table_obj = TableObject(self) - self.statistics = statistics - self.output_widget = Output() - self.map_instance = map_instance - self.statistics = map_instance.statistics - self.stations_metadata = map_instance.stations_metadata - self.station_index = map_instance.station_index - - - def _initialize_map(self, bounds): - """Initialize the map widget.""" - map_widget = Map(layout=Layout(width="100%", height="600px")) - map_widget.fit_bounds(bounds) - return map_widget - - def generate_html_legend(self, colormap, min_val, max_val): - """Generate an HTML legend for the map.""" - # Convert the colormap to a list of RGB values - rgb_values = [mpl.colors.rgb2hex(colormap(i)) for i in np.linspace(0, 1, 256)] - - # Create a gradient style using the RGB values - gradient_style = ', '.join(rgb_values) - gradient_html = f""" -
- """ - - # Create labels - labels_html = f""" -
- Low: {min_val:.1f} - High: {max_val:.1f} -
- """ - # Combine gradient and labels - legend_html = gradient_html + labels_html - - return HTML(legend_html) - - def _initialize_throttler(self, delay=1.0): - """Initialize the throttler for click events.""" - return ThrottledClick(delay) - - - def add_plotly_object(self, plotly_obj): - """Add a PlotlyObject to the IPyLeaflet class.""" - self.plotly_obj = plotly_obj - - def add_table_object(self, table_obj): - """Add a PlotlyObject to the IPyLeaflet class.""" - self.table_obj = table_obj - - def handle_marker_selection(self, feature, **kwargs): - """Handle the selection of a marker on the map.""" - # Extract station_id from the selected feature - station_id = feature["properties"]["station_id"] - - # Call the action handler with the extracted station_id - self.handle_marker_action(station_id) - - def handle_marker_action(self, station_id): - '''Define a callback to handle marker clicks and add the plot figure and statistics and station property.''' - - # Check if we should process the click - if not self.throttler.should_process(): - return - - self.loading_label.value = "Loading..." # Indicate that data is being loaded - station_id = str(station_id) # Convert station ID to string for consistency - station_name = self.stations_metadata.loc[self.stations_metadata[self.station_index] == station_id, "StationName"].values[0] - updated_title = f"Selected station:
ID: {station_id}, name: {station_name}
" - - # Update the plot with simulation and observation data - self.plotly_obj.update(station_id) - self.plotly_obj.figure.update_layout(title={ - 'text': updated_title, - 'y': 0.9, - 'x': 0.5, - 'xanchor': 'center', - 'yanchor': 'top', - 'font': { - 'color': 'black', - 'size': 16 - } - } - ) - - # Generate and display the statistics table for the clicked station - if self.statistics: - self.table_obj.update(station_id) - - # Update the table in the layout - children_list = list(self.map_instance.top_right_frame.children) - - # Find the index of the old table and replace it with the new table - for i, child in enumerate(children_list): - if isinstance(child, type(self.table_obj.stat_table_html)): - children_list[i] = self.table_obj.stat_table_html - break - else: - # If the old table wasn't found, append the new table - children_list.append(self.table_obj.stat_table_html) - - self.map_instance.top_right_frame.children = tuple(children_list) - - - # Update the station table in the layout - children_list_main = list(self.map_instance.layout.children) - - # Find the index of the old station table and replace it with the new table - for i, child in enumerate(children_list_main): - if isinstance(child, type(self.table_obj.station_table_html)): - children_list_main[i] = self.table_obj.station_table_html - break - else: - # If the old station table wasn't found, append the new table - children_list_main.append(self.table_obj.station_table_html) - - self.map_instance.layout.children = tuple(children_list_main) - - self.table_obj.update(station_id) - - self.loading_label.value = "" # Clear the loading message - - with self.output_widget: - clear_output(wait=True) # Clear any previous plots or messages - - -class PlotlyObject: - def __init__(self): - """Initialize the Plotly object for visualization.""" - initial_title = "Click on your desired station location!" - self.figure = go.FigureWidget( - layout=go.Layout( - title = initial_title, - height=350, - margin=dict(l=100), - legend=dict( - orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 - ), - xaxis_title="Date", - xaxis_tickformat="%d-%m-%Y", - yaxis_title="Discharge [m3/s]" - ) - ) - - def update_simulation_data(self, station_id): - """Update the simulation data for the given station ID.""" - for name, ds in self.sim_ds.items(): - if station_id in ds["station"].values: - ds_time_series_data = ds["dis"].sel(station=station_id).values - valid_dates_ds, valid_data_ds = filter_nan_values( - self.ds_time_str, ds_time_series_data - ) - self._update_trace(valid_dates_ds, valid_data_ds, name) - else: - print(f"Station ID: {station_id} not found in dataset {name}.") - - def update_observation_data(self, station_id): - """Update the observation data for the given station ID.""" - if station_id in self.obs_ds["station"].values: - obs_time_series = self.obs_ds["obsdis"].sel(station=station_id).values - valid_dates_obs, valid_data_obs = filter_nan_values( - self.ds_time_str, obs_time_series - ) - self._update_trace(valid_dates_obs, valid_data_obs, "Obs. Data") - else: - print(f"Station ID: {station_id} not found in obs_df.") - - def _update_trace(self, x_data, y_data, name): - """Update or add a trace to the Plotly figure.""" - trace_exists = any([trace.name == name for trace in self.figure.data]) - if trace_exists: - for trace in self.figure.data: - if trace.name == name: - trace.x = x_data - trace.y = y_data - else: - self.figure.add_trace( - go.Scatter(x=x_data, y=y_data, mode="lines", name=name) - ) - - def update(self, station_id): - """Update the overall plot with new data for the given station ID.""" - self.update_simulation_data(station_id) - self.update_observation_data(station_id) - - -class TableObject: - def __init__(self, map_instance): - """Initialize the table object for displaying statistics and station properties.""" - self.map_instance = map_instance - # Define the styles for the statistics table - self.table_style = """ - - """ - self.stat_title_style = "style='font-size: 18px; font-weight: bold; text-align: center;'" - # Initialize the stat_table_html and station_table_html with empty tables - empty_df = pd.DataFrame() - self.stat_table_html = self.display_dataframe_with_scroll(empty_df, title="Model Performance Statistics Overview") - self.station_table_html = self.display_dataframe_with_scroll(empty_df, title="Station Property") - - - def generate_statistics_table(self, station_id): - """Generate a statistics table for the given station ID.""" - data = [] - - # Check if statistics is None or empty - if not self.map_instance.statistics: - print("No statistics data provided.") - return pd.DataFrame() # Return an empty dataframe - - # Loop through each simulation and get the statistics for the given station_id - for exp_name, stats in self.map_instance.statistics.items(): - if station_id in stats["station"].values: - row = [exp_name] + [ - round(stats[var].sel(station=station_id).values.item(), 2) - for var in stats.data_vars - if var not in ["longitude", "latitude"] - ] - data.append(row) - - # Check if data has any items - if not data: - print(f"No statistics data found for station ID: {station_id}.") - return pd.DataFrame() # Return an empty dataframe - - # Convert the data to a DataFrame for display - columns = ["Exp. name"] + list(stats.data_vars.keys()) - statistics_df = pd.DataFrame(data, columns=columns) - - # Round the numerical columns to 2 decimal places - numerical_columns = [col for col in statistics_df.columns if col != "Exp. name"] - statistics_df[numerical_columns] = statistics_df[numerical_columns].round(2) - - return statistics_df - - def generate_station_table(self, station_id): - """Generate a station property table for the given station ID.""" - stations_df = self.map_instance.stations_metadata - selected_station_df = stations_df[stations_df[self.map_instance.station_index] == station_id] - - return selected_station_df - - def display_dataframe_with_scroll(self, df, title=""): - """Display a DataFrame with a scrollable view.""" - table_html = df.to_html(classes="custom-table") - content = f"{self.table_style}

{title}

{table_html}
" - return(HTML(content)) - - def update(self, station_id): - """Update the tables with new data for the given station ID.""" - df_stat = self.generate_statistics_table(station_id) - self.stat_table_html = self.display_dataframe_with_scroll(df_stat, title="Model Performance Statistics Overview") - - df_station = self.generate_station_table(station_id) - self.station_table_html = self.display_dataframe_with_scroll(df_station, title="Station Property") - - - -class InteractiveMap: - def __init__(self, config, stations, observations, simulations, stats=None): - """Initialize the interactive map with configurations and data sources.""" - self.config = config - self.stations_metadata = read_station_metadata_file( - fpath=stations, - coord_names=config["station_coordinates"], - epsg=config["station_epsg"], - filters=config["station_filters"], - ) - - obs_var_name = config["obs_var_name"] - - # Use the external functions to prepare data - self.sim_ds = prepare_simulations_data(simulations) - self.obs_ds = prepare_observations_data(observations, self.sim_ds, obs_var_name) - - # Convert ds 'time' to datetime format for alignment with external_df - self.ds_time = self.obs_ds["time"].values.astype("datetime64[D]") - - # set station index - self.station_index = config["station_id_column_name"] - self.stations_metadata[self.station_index] = self.stations_metadata[self.station_index].astype(str) - - # Retrieve statistics from the statistics netcdf input - self.statistics = {} - if stats: - for name, path in stats.items(): - self.statistics[name] = xr.open_dataset(path) - - # Ensure the keys of self.statistics match the keys of self.sim_ds - assert set(self.statistics.keys()) == set(self.sim_ds.keys()), "Mismatch between statistics and simulations keys." - - # find common station ids between metadata, observation and simulations - self.common_id = self.find_common_station() - - print(f"Found {len(self.common_id)} common stations") - self.stations_metadata = self.stations_metadata.loc[self.stations_metadata[self.station_index].isin(self.common_id)] - self.obs_ds = self.obs_ds.sel(station=self.common_id) - for sim, ds in self.sim_ds.items(): - self.sim_ds[sim] = ds.sel(station=self.common_id) - - # Pass the map bound to the interactive elements - self.bounds = compute_bounds(self.stations_metadata, self.common_id, self.station_index, self.config["station_coordinates"]) - # self.interactive_elements = InteractiveElements(self.bounds) - self.interactive_elements = InteractiveElements(self.bounds, self) - - # Pass the necessary data to the interactive elements - self.interactive_elements.plotly_obj.sim_ds = self.sim_ds - self.interactive_elements.plotly_obj.obs_ds = self.obs_ds - self.interactive_elements.plotly_obj.ds_time_str = [dt.isoformat() for dt in pd.to_datetime(self.ds_time)] - - - def find_common_station(self): - """find common station between observation and simulation and station metadata""" - ids = [] - ids += [list(self.obs_ds["station"].values)] - ids += [list(ds["station"].values) for ds in self.sim_ds.values()] - ids += [self.stations_metadata[self.station_index]] - if self.statistics: - ids += [list(ds["station"].values) for ds in self.statistics.values()] - - common_ids = None - for id in ids: - if common_ids is None: - common_ids = set(id) - else: - common_ids = set(id) & common_ids - return list(common_ids) - - - def _update_plot_dates(self, change): - start_date = self.start_date_picker.value.strftime('%Y-%m-%d') - end_date = self.end_date_picker.value.strftime('%Y-%m-%d') - self.interactive_elements.plotly_obj.figure.update_layout(xaxis_range=[start_date, end_date]) - - - def mapplot(self, colorby="kge", sim=None, range=None, colormap=None): - """Plot the map with stations colored by a given metric. - input example: - colorby = "kge" # this should be the objective functions of the statistics - range = [, ] #min and max values of the color bar - colormap = plt.cm.get_cmap("RdYlGn") # color map to be used based on matplotlib colormap - """ - - # Retrieve the statistics data of simulation choice/ by default - stat_data = self.statistics[sim][colorby] - - # Normalize the data for coloring - if range is None: - min_val, max_val = stat_data.values.min(), stat_data.values.max() - else: - min_val, max_val = range[0], range[1] - - norm = plt.Normalize(min_val, max_val) - - if colormap is None: - colormap = plt.cm.get_cmap("viridis") - - def map_color(feature): - station_id = feature['properties'][self.config["station_id_column_name"]] - color = mpl.colors.rgb2hex( - colormap(norm(stat_data.sel(station=station_id).values)) - ) - return { - 'color': 'black', - 'fillColor': color, - } - - geo_data = GeoJSON( - data=json.loads(self.stations_metadata.to_json()), - style={'radius': 7, 'opacity':0.5, 'weight':1.9, 'dashArray':'2', 'fillOpacity':0.5}, - hover_style={'radius': 10, 'fillOpacity': 1}, - point_style={'radius': 5}, - style_callback=map_color, - ) - self.legend_widget = self.interactive_elements.generate_html_legend(colormap, min_val, max_val) - - - geo_data.on_click(self.interactive_elements.handle_marker_selection) - self.interactive_elements.map.add_layer(geo_data) - - # Add date pickers for start and end dates - self.start_date_picker = DatePicker(description='Start') - self.end_date_picker = DatePicker(description='End') - - # Observe changes in the date pickers to update the plot - self.start_date_picker.observe(self._update_plot_dates, names='value') - self.end_date_picker.observe(self._update_plot_dates, names='value') - - # Initialize layout elements - self._initialize_layout_elements() - - # Display the main layout - display(self.layout) - - - def _initialize_layout_elements(self): - """Initialize the layout elements for the map visualization.""" - # Title label - self.title_label = Label( - "Interactive Map Visualisation for Hydrological Model Performance", - layout=Layout(justify_content='center'), - style={'font_weight': 'bold', 'font_size': '24px', 'font_family': 'Arial'} - ) - - # Layouts - main_layout = Layout(justify_content='space-around', align_items='stretch', spacing='2px', width='1000px') - left_layout = Layout(justify_content='space-around', align_items='center', spacing='2px', width='40%') - right_layout = Layout(justify_content='center', align_items='center', spacing='2px', width='60%') - - # Date picker box and label - self.date_label = Label("Please select the date to accurately change the date axis of the plot") - self.date_picker_box = HBox([self.start_date_picker, self.end_date_picker]) - - # Frames - self.top_right_frame = VBox([self.interactive_elements.plotly_obj.figure, - self.date_label, self.date_picker_box, self.interactive_elements.table_obj.stat_table_html - ], layout=right_layout) - self.top_left_frame = VBox([self.interactive_elements.map, self.legend_widget], layout=left_layout) - self.main_top_frame = HBox([self.top_left_frame, self.top_right_frame]) - - # Main layout - self.layout = VBox([self.title_label, - self.interactive_elements.loading_label, - self.main_top_frame, - self.interactive_elements.table_obj.station_table_html], layout=main_layout) - - -class ThrottledClick: - """Initialize a click throttler with a given delay. to prevent user from swift multiple events clicking that results in crashing""" - def __init__(self, delay=1.0): - self.delay = delay - self.last_call = 0 - - def should_process(self): - """Determine if a click should be processed based on the delay.""" - current_time = time.time() - if current_time - self.last_call > self.delay: - self.last_call = current_time - return True - return False - - -def prepare_simulations_data(simulations): - """process simulations raw datasets to a standard dataframe""" - - # If simulations is a dictionary, load data for each experiment - sim_ds = {} - for exp, path in simulations.items(): - # Expanding the tilde - expanded_path = os.path.expanduser(path) - - if os.path.isfile(expanded_path): # Check if it's a file - ds = xr.open_dataset(expanded_path) - elif os.path.isdir(expanded_path): # Check if it's a directory - # Handle the case when it's a directory; - # assume all .nc files in the directory need to be combined - files = [f for f in os.listdir(expanded_path) if f.endswith(".nc")] - ds = xr.open_mfdataset( - [os.path.join(expanded_path, f) for f in files], combine="by_coords" - ) - else: - raise ValueError(f"Invalid path: {expanded_path}") - sim_ds[exp] = ds - - return sim_ds - - -def prepare_observations_data(observations, sim_ds, obs_var_name): - """process observation raw dataset to a standard dataframe""" - file_extension = os.path.splitext(observations)[-1].lower() - - if file_extension == ".csv": - obs_df = pd.read_csv(observations, parse_dates=["Timestamp"]) - obs_melted = obs_df.melt( - id_vars="Timestamp", var_name="station", value_name=obs_var_name - ) - - # Convert the melted DataFrame to xarray Dataset - obs_ds = obs_melted.set_index(["Timestamp", "station"]).to_xarray() - obs_ds = obs_ds.rename({"Timestamp": "time"}) - - elif file_extension == ".nc": - obs_ds = xr.open_dataset(observations) - - else: - raise ValueError("Unsupported file format for observations.") - - # Subset obs_ds based on sim_ds time values - if isinstance(sim_ds, xr.Dataset): - time_values = sim_ds["time"].values - elif isinstance(sim_ds, dict): - # Use the first dataset in the dictionary to determine time values - first_dataset = next(iter(sim_ds.values())) - time_values = first_dataset["time"].values - else: - raise ValueError("Unexpected type for sim_ds") - - obs_ds = obs_ds.sel(time=time_values) - return obs_ds - - -def properties_to_dataframe(properties: Dict) -> pd.DataFrame: - """Convert feature properties to a DataFrame for display.""" - return pd.DataFrame([properties]) - - -def filter_nan_values(dates, data_values): - """Filters out NaN values and their associated dates.""" - valid_dates = [date for date, val in zip(dates, data_values) if not np.isnan(val)] - valid_data = [val for val in data_values if not np.isnan(val)] - - return valid_dates, valid_data - -def compute_bounds(stations_metadata, common_ids, station_index, coord_names): - """Compute the bounds of the map based on the stations metadata.""" - - # Filter the metadata to only include stations with common IDs - filtered_stations = stations_metadata[stations_metadata[station_index].isin(common_ids)] - - lon_column = coord_names[0] - lat_column = coord_names[1] - - lons = filtered_stations[lon_column].values - lats = filtered_stations[lat_column].values - - min_lat, max_lat = min(lats), max(lats) - min_lon, max_lon = min(lons), max(lons) - - return [(float(min_lat), float(min_lon)), (float(max_lat), float(max_lon))] \ No newline at end of file diff --git a/hat/visualisation_v2.py b/hat/visualisation_v2.py deleted file mode 100644 index 9cf723d..0000000 --- a/hat/visualisation_v2.py +++ /dev/null @@ -1,199 +0,0 @@ -""" -Python module for visualising geospatial content using jupyter notebook, -for both spatial and temporal, e.g. netcdf, vector, raster, with time series etc -""" -import os -import pandas as pd -from typing import Dict, Union, List - -import geopandas as gpd -from shapely.geometry import Point -import xarray as xr - -from hat.hydrostats import run_analysis -from hat.filters import filter_timeseries - -class NotebookMap: - def __init__(self, config: Dict, stations_metadata: str, observations: str, simulations: Union[Dict, str], stats=None): - self.config = config - - # Prepare Station Metadata - self.stations_metadata = self.prepare_station_metadata( - fpath=stations_metadata, - station_id_column_name=config["station_id_column_name"], - coord_names=config['station_coordinates'], - epsg=config['station_epsg'], - filters=config['station_filters'] - ) - - # Prepare Observations Data - self.observation = self.prepare_observations_data(observations) - - # Prepare Simulations Data - self.simulations = self.prepare_simulations_data(simulations) - - # Ensure stations in obs and sims are present in metadata - valid_stations = set(self.stations_metadata[self.config["station_id_column_name"]].values) - - # Filter data based on valid stations - self.observation = self.filter_stations_by_metadata(self.observation, valid_stations) - self.simulations = {exp: self.filter_stations_by_metadata(ds, valid_stations) for exp, ds in self.simulations.items()} - - self.stats_input = stats - self.stat_threshold = 70 # default for now, may need to be added as option - self.stats_output = {} - if self.stats_input: - self.stats_output = self.calculate_statistics() - - def filter_stations_by_metadata(self, ds, valid_stations): - """Filter the stations in the dataset to only include those in valid_stations.""" - return ds.sel(station=[s for s in ds.station.values if s in valid_stations]) - - - def prepare_station_metadata(self, fpath: str, station_id_column_name: str, coord_names: List[str], epsg: int, filters=None) -> xr.Dataset: - # Read the station metadata file - df = pd.read_csv(fpath) - - # Convert to a GeoDataFrame - geometry = [Point(xy) for xy in zip(df[coord_names[0]], df[coord_names[1]])] - gdf = gpd.GeoDataFrame(df, crs=f"EPSG:{epsg}", geometry=geometry) - - # Apply filters if provided - if filters: - for column, value in filters.items(): - gdf = gdf[gdf[column] == value] - - return gdf - - def prepare_observations_data(self, observations: str) -> xr.Dataset: - """ - Load and preprocess observations data. - - Parameters: - - observations: Path to the observations data file. - - Returns: - - obs_ds: An xarray Dataset containing the observations data. - """ - file_extension = os.path.splitext(observations)[-1].lower() - station_id_column_name = self.config.get('station_id_column_name', 'station_id_num') - - if file_extension == '.csv': - obs_df = pd.read_csv(observations, parse_dates=["Timestamp"]) - obs_melted = obs_df.melt(id_vars="Timestamp", var_name="station", value_name="obsdis") - - # Convert melted DataFrame to xarray Dataset - obs_ds = obs_melted.set_index(["Timestamp", "station"]).to_xarray() - obs_ds = obs_ds.rename({"Timestamp": "time"}) - - elif file_extension == '.nc': - obs_ds = xr.open_dataset(observations) - - # Check for necessary attributes - if 'obsdis' not in obs_ds or 'time' not in obs_ds.coords: - raise ValueError("The NetCDF file lacks the expected variables or coordinates.") - - # Rename the station_id to station and set it as an index - obs_ds = obs_ds.rename({station_id_column_name: "station"}) - obs_ds = obs_ds.set_index(station="station") - else: - raise ValueError("Unsupported file format for observations.") - - return obs_ds - - - def prepare_simulations_data(self, simulations: Union[Dict, str]) -> Dict[str, xr.Dataset]: - """ - Load and preprocess simulations data. - - Parameters: - - simulations: Either a string path to the simulations data file or a dictionary mapping - experiment names to file paths. - - Returns: - - datasets: A dictionary mapping experiment names to their respective xarray Datasets. - """ - sim_ds = {} - - # Handle the case where simulations is a single string path - if isinstance(simulations, str): - sim_ds["default"] = xr.open_dataset(simulations) - - # Handle the case where simulations is a dictionary of experiment names to paths - elif isinstance(simulations, dict): - for exp, path in simulations.items(): - expanded_path = os.path.expanduser(path) - - if os.path.isfile(expanded_path): # If it's a file - ds = xr.open_dataset(expanded_path) - - elif os.path.isdir(expanded_path): # If it's a directory - # Assume all .nc files in the directory need to be combined - files = [f for f in os.listdir(expanded_path) if f.endswith('.nc')] - ds = xr.open_mfdataset([os.path.join(expanded_path, f) for f in files], combine='by_coords') - - else: - raise ValueError(f"Invalid path: {expanded_path}") - - sim_ds[exp] = ds - else: - raise TypeError("Expected simulations to be either str or dict.") - - return sim_ds - - - - def calculate_statistics(self) -> Dict[str, xr.Dataset]: - """ - Calculate statistics for the simulations against the observations. - - Returns: - - statistics: A dictionary mapping experiment names to their respective statistics xarray Datasets. - """ - stats_output = {} - - if isinstance(self.simulations, xr.Dataset): - # For a single simulation dataset - sim_filtered, obs_filtered = filter_timeseries(self.simulations, self.observation, self.stat_threshold) - stats_output["default"] = run_analysis(self.stats_input, sim_filtered, obs_filtered) - elif isinstance(self.simulations, dict): - # For multiple simulation datasets - for exp, ds in self.simulations.items(): - # print(f"Processing experiment: {exp}") - # print("Simulation dataset stations:", ds.station.values) - # print("Observation dataset stations:", self.observation.station.values) - - sim_filtered, obs_filtered = filter_timeseries(ds, self.observation, self.stat_threshold) - # stats_output[exp] = run_analysis(self.stats_input, sim_filtered, obs_filtered) - - stations_series = pd.Series(ds.station.values) - duplicates = stations_series.value_counts().loc[lambda x: x > 1] - print(exp, ":", duplicates) - - else: - raise ValueError("Unexpected type for self.simulations") - - return stats_output - -# Utility Functions - -def properties_to_dataframe(properties: Dict) -> pd.DataFrame: - """Convert feature properties to a DataFrame for display.""" - return pd.DataFrame([properties]) - -def filter_nan_values(dates, data_values): - """ - Filters out NaN values and their associated dates. - - Parameters: - - dates: List of dates. - - data_values: List of data values corresponding to the dates. - - Returns: - - valid_dates: List of dates without NaN values. - - valid_data: List of non-NaN data values. - """ - valid_dates = [date for date, val in zip(dates, data_values) if not np.isnan(val)] - valid_data = [val for val in data_values if not np.isnan(val)] - - return valid_dates, valid_data \ No newline at end of file diff --git a/notebooks/examples/4_visualisation_interactive.ipynb b/notebooks/examples/4_visualisation_interactive.ipynb index 1c498cf..26b7365 100644 --- a/notebooks/examples/4_visualisation_interactive.ipynb +++ b/notebooks/examples/4_visualisation_interactive.ipynb @@ -13,7 +13,6 @@ "metadata": {}, "outputs": [], "source": [ - "from hat.visualisation import NotebookMap\n", "stations = \"~/git/hat/data/outlets_v4.0_20230726_withEFAS.csv\"\n", "observations = \"~/git/hat/data/observations/destine_observations.nc\"\n", "simulations = {\n", @@ -45,7 +44,8 @@ "metadata": {}, "outputs": [], "source": [ - "map = NotebookMap(config, stations, observations, simulations, stats=statistics)" + "from hat.interactive.explorers import TimeSeriesExplorer\n", + "map = TimeSeriesExplorer(config, stations, observations, simulations, stats=statistics)" ] }, { @@ -73,8 +73,22 @@ } ], "metadata": { + "kernelspec": { + "display_name": "hat-venv", + "language": "python", + "name": "hat-venv" + }, "language_info": { - "name": "python" + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.10" } }, "nbformat": 4, diff --git a/tests/test_hydrostats_decorators.py b/tests/test_hydrostats_decorators.py index 3a53a38..c3839aa 100644 --- a/tests/test_hydrostats_decorators.py +++ b/tests/test_hydrostats_decorators.py @@ -63,10 +63,8 @@ def test_filter_nan(): assert np.allclose(decorated(nan3, nan3), np.array([2, 4])) # all nans - with pytest.raises(ValueError): - decorated(arr, nans) - with pytest.raises(ValueError): - decorated(nans, arr) + assert decorated(arr, nans) is None + assert decorated(nans, arr) is None # def test_handle_divide_by_zero_error(): @@ -124,8 +122,7 @@ def test_hydrostat(): # # all zero division # with pytest.raises(ZeroDivisionError): - # decorated_divide(ones, zeros) + # print(decorated_divide(ones, zeros)) # all nans - with pytest.raises(ValueError): - decorated_divide(nans, nans) + assert decorated_divide(nans, nans) is None From f78634480ff577a34a33601bff551f8984e2801f Mon Sep 17 00:00:00 2001 From: Corentin Carton De Wiart Date: Mon, 6 Nov 2023 09:21:03 +0000 Subject: [PATCH 23/31] update github actions to use mamba --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43a5334..8e894c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,16 +28,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - uses: conda-incubator/setup-miniconda@v2 + - uses: mamba-org/setup-micromamba@v1 with: activate-environment: test environment-file: environment.yml auto-activate-base: false - - name: conda check + - name: mamba check shell: bash -l {0} run: | - conda info - conda list + mamba info + mamba list - name: install hat package shell: bash -l {0} run: pip install . From 9ecc2272dd68692f89b0cf3dd9adaaaf5042f50f Mon Sep 17 00:00:00 2001 From: Corentin Carton De Wiart Date: Mon, 6 Nov 2023 09:24:37 +0000 Subject: [PATCH 24/31] minor fix in github actions --- .github/workflows/ci.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e894c4..17c11b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,11 +33,6 @@ jobs: activate-environment: test environment-file: environment.yml auto-activate-base: false - - name: mamba check - shell: bash -l {0} - run: | - mamba info - mamba list - name: install hat package shell: bash -l {0} run: pip install . From 2bc81c9b8a81f3ed567ddea2ded3a9e1e07eec97 Mon Sep 17 00:00:00 2001 From: Corentin Carton De Wiart Date: Tue, 14 Nov 2023 20:59:31 +0000 Subject: [PATCH 25/31] add documentation for the interactive objects --- hat/interactive/explorers.py | 219 ++++++++++---- hat/interactive/leaflet.py | 80 +++++- hat/interactive/widgets.py | 271 ++++++++++++++---- .../4_visualisation_interactive.ipynb | 24 +- 4 files changed, 463 insertions(+), 131 deletions(-) diff --git a/hat/interactive/explorers.py b/hat/interactive/explorers.py index 4026318..f55844b 100644 --- a/hat/interactive/explorers.py +++ b/hat/interactive/explorers.py @@ -3,6 +3,7 @@ import ipywidgets import pandas as pd import xarray as xr +import earthkit.data from IPython.core.display import display from hat.interactive.leaflet import LeafletMap, PyleafletColormap @@ -17,9 +18,23 @@ def prepare_simulations_data(simulations, sims_var_name): """ - process simulations raw datasets to a standard dataframe - """ + Process simulations and put then in a dictionnary of xarray data arrays. + + Parameters + ---------- + simulations : dict + A dictionary of paths to the simulation netCDF files, with the keys + being the simulation names. + sims_var_name : str + The name of the variable in the simulation netCDF files that contains + the simulated values. + + Returns + ------- + dict + A dictionary of xarray data arrays containing the simulation data. + """ # If simulations is a dictionary, load data for each experiment sim_ds = {} for exp, path in simulations.items(): @@ -27,16 +42,9 @@ def prepare_simulations_data(simulations, sims_var_name): expanded_path = os.path.expanduser(path) if os.path.isfile(expanded_path): # Check if it's a file - ds = xr.open_dataset(expanded_path) - elif os.path.isdir(expanded_path): # Check if it's a directory - # Handle the case when it's a directory; - # assume all .nc files in the directory need to be combined - files = [f for f in os.listdir(expanded_path) if f.endswith(".nc")] - ds = xr.open_mfdataset( - [os.path.join(expanded_path, f) for f in files], combine="by_coords" - ) - else: - raise ValueError(f"Invalid path: {expanded_path}") + fs = earthkit.data.from_source("file", expanded_path) + ds = fs.to_xarray() + sim_ds[exp] = ds[sims_var_name] return sim_ds @@ -44,7 +52,32 @@ def prepare_simulations_data(simulations, sims_var_name): def prepare_observations_data(observations, sim_ds, obs_var_name): """ - process observation raw dataset to a standard dataframe + Process observation raw dataset to a standard xarray dataset. + The observation dataset can be either a csv file or a netcdf file. + The observation dataset is subsetted based on the time values of the + simulation dataset. + + Parameters + ---------- + observations : str + The path to the observation netCDF file. + sim_ds : dict or xarray.Dataset + A dictionary of xarray datasets containing the simulation data, or a + single xarray dataset. + obs_var_name : str + The name of the variable in the observation netCDF file that contains + the observed values. + + Returns + ------- + xarray.Dataset + An xarray dataset containing the observation data. + + Raises + ------ + ValueError + If the file format of the observations file is not supported. + """ file_extension = os.path.splitext(observations)[-1].lower() @@ -75,9 +108,30 @@ def prepare_observations_data(observations, sim_ds, obs_var_name): return obs_ds -def find_common_station(station_index, stations_metadata, statistics, sim_ds, obs_ds): +def find_common_stations(station_index, stations_metadata, obs_ds, sim_ds, statistics): """ - find common station between observation and simulation and station metadata + Find common stations between observations, simulations and station + metadata. + + Parameters + ---------- + station_index : str + The name of the column in the station metadata file that contains the + station IDs. + stations_metadata : pandas.DataFrame + A pandas DataFrame containing the station metadata. + obs_ds : xarray.Dataset + An xarray dataset containing the observation data. + sim_ds : dict or xarray.Dataset + A dictionary of xarray data arrays containing the simulation data. + statistics : dict + A dictionary of xarray data arrays containing the statistics data. + + Returns + ------- + list + A list of common station IDs. + """ ids = [] ids += [list(obs_ds["station"].values)] @@ -100,25 +154,78 @@ class TimeSeriesExplorer: Initialize the interactive map with configurations and data sources. """ - def __init__(self, config, stations, observations, simulations, stats=None): + def __init__(self, config): + """ + Initializes an instance of the Explorer class. + + Parameters + ---------- + config : dict + A dictionary containing the configuration parameters for the + Explorer. + + Notes + ----- + This method initializes an instance of the Explorer class with the + given configuration parameters. + The configuration parameters should be provided as a dictionary with + the following keys: + + - stations : str + The path to the station metadata file. + - observations : str + The path to the observation netCDF file. + - simulations : dict + A dictionary of paths to the simulation netCDF files, with the keys + being the simulation names. + - statistics : dict, optional + A dictionary of paths to the statistics netCDF files, with the keys + being the simulation names. + - station_coordinates : list of str + The names of the columns in the station metadata file that contain + the station coordinates. + - station_epsg : int + The EPSG code of the coordinate reference system used by the + station coordinates. + - station_filters : dict + A dictionary of filters to apply to the station metadata file. + - sims_var_name : str + The name of the variable in the simulation netCDF files that + contains the simulated values. + - obs_var_name : str + The name of the variable in the observation netCDF file that + contains the observed values. + - station_id_column_name : str + The name of the column in the station metadata file that contains + the station IDs. + + Raises + ------ + AssertionError + If there is a mismatch between the keys of the statistics netCDF + files and the simulation netCDF files. + + """ self.config = config + self.stations_metadata = read_station_metadata_file( - fpath=stations, + fpath=config["stations"], coord_names=config["station_coordinates"], epsg=config["station_epsg"], filters=config["station_filters"], ) # Use the external functions to prepare data - sim_ds = prepare_simulations_data(simulations, config["sims_var_name"]) - obs_ds = prepare_observations_data(observations, sim_ds, config["obs_var_name"]) + sim_ds = prepare_simulations_data(config["simulations"], config["sims_var_name"]) + obs_ds = prepare_observations_data(config["observations"], sim_ds, config["obs_var_name"]) # set station index self.station_index = config["station_id_column_name"] # Retrieve statistics from the statistics netcdf input self.statistics = {} - if stats: + stats = config.get("statistics") + if stats is not None: for name, path in stats.items(): self.statistics[name] = xr.open_dataset(path) @@ -128,8 +235,12 @@ def __init__(self, config, stations, observations, simulations, stats=None): ), "Mismatch between statistics and simulations keys." # find common station ids between metadata, observation and simulations - common_ids = find_common_station( - self.station_index, self.stations_metadata, self.statistics, sim_ds, obs_ds + common_ids = find_common_stations( + self.station_index, + self.stations_metadata, + obs_ds, + sim_ds, + self.statistics, ) print(f"Found {len(common_ids)} common stations") @@ -166,38 +277,15 @@ def __init__(self, config, stations, observations, simulations, stats=None): def create_frame(self): """ - Initialize the layout elements for the map visualization. - """ + Initialize the layout of the widgets for the map visualization. - # # Layouts 1 - # main_layout = ipywidgets.Layout( - # justify_content='space-around', - # align_items='stretch', - # spacing='2px', - # width='1000px' - # ) - # half_layout = ipywidgets.Layout( - # justify_content='space-around', - # align_items='center', - # spacing='2px', - # width='50%' - # ) - - # # Frames - # stats_frame = ipywidgets.HBox( - # [self.widgets['plot'].output, self.widgets['stats'].output], - # # layout=main_layout - # ) - # main_frame = ipywidgets.VBox( - # [ - # self.title_label, - # self.loading_widget, - # self.leafletmap.output(main_layout), - # self.widgets['meta'].output, stats_frame - # ], - # layout=main_layout - # ) + Returns + ------- + ipywidgets.VBox + A vertical box containing the layout elements for the map + visualization. + """ # Layouts 2 main_layout = ipywidgets.Layout( justify_content="space-around", @@ -212,7 +300,10 @@ def create_frame(self): width="40%", ) right_layout = ipywidgets.Layout( - justify_content="center", align_items="center", spacing="2px", width="60%" + justify_content="center", + align_items="center", + spacing="2px", + width="60%" ) # Frames @@ -230,12 +321,22 @@ def create_frame(self): ) return main_frame - def mapplot(self, colorby=None, sim=None, limits=None, mp_colormap="viridis"): - """Plot the map with stations colored by a given metric. - input example: - colorby = "kge" this should be the objective functions of the statistics - limits = [, ] min and max values of the color bar - mp_colormap = "viridis" colormap name to be used based on matplotlib colormap + def plot(self, colorby=None, sim=None, limits=None, mp_colormap="viridis"): + """ + Plot the stations markers colored by a given metric. + + Parameters + ---------- + colorby : str, optional + The name of the metric to color the stations by. + sim : str, optional + The name of the simulation to use for the metric. + limits : list, optional + A list of two values representing the minimum and maximum values + for the color bar. + mp_colormap : str, optional + The name of the matplotlib colormap to use for the color bar. + """ # create colormap from statistics stats = None diff --git a/hat/interactive/leaflet.py b/hat/interactive/leaflet.py index 9585d35..f84e4fc 100644 --- a/hat/interactive/leaflet.py +++ b/hat/interactive/leaflet.py @@ -23,6 +23,17 @@ def _compute_bounds(stations_metadata, coord_names): class LeafletMap: + """ + A class for creating interactive leaflet maps. + + Parameters + ---------- + basemap : ipyleaflet.basemaps, optional + The basemap to use for the map. Default is + ipyleaflet.basemaps.OpenStreetMap.Mapnik. + + """ + def __init__( self, basemap=ipyleaflet.basemaps.OpenStreetMap.Mapnik, @@ -33,8 +44,9 @@ def __init__( self.legend_widget = ipywidgets.Output() def _set_boundaries(self, stations_metadata, coord_names): - """Compute the boundaries of the map based on the stations metadata.""" - + """ + Compute the boundaries of the map based on the stations metadata. + """ lon_column = coord_names[0] lat_column = coord_names[1] @@ -48,6 +60,21 @@ def _set_boundaries(self, stations_metadata, coord_names): self.map.fit_bounds(bounds) def add_geolayer(self, geodata, colormap, widgets, coord_names=None): + """ + Add a geolayer to the map. + + Parameters + ---------- + geodata : geopandas.GeoDataFrame + The geodataframe containing the geospatial data. + colormap : hat.PyleafletColormap + The colormap to use for the geolayer. + widgets : hat.WidgetsManager + The widgets to use for the geolayer. + coord_names : list of str, optional + The names of the columns containing the spatial coordinates. + Default is None. + """ geojson = ipyleaflet.GeoJSON( data=json.loads(geodata.to_json()), style={ @@ -70,11 +97,41 @@ def add_geolayer(self, geodata, colormap, widgets, coord_names=None): self.legend_widget = colormap.legend() def output(self, layout): + """ + Return the output widget. + + Parameters + ---------- + layout : ipywidgets.Layout + The layout of the widget. + + Returns + ------- + ipywidgets.VBox + The output widget. + + """ output = ipywidgets.VBox([self.map, self.legend_widget], layout=layout) return output class PyleafletColormap: + """ + A class handling the colormap of a pyleaflet map. + + Parameters + ---------- + config : dict + A dictionary containing configuration options for the map. + stats : xarray.Dataset or None, optional + A dataset containing the data to be plotted on the map. + If None, a default constant colormap will be used. + colormap_style : str, optional + The name of the matplotlib colormap to use. Default is 'viridis'. + range : tuple of float, optional + The minimum and maximum values of the colormap. If None, the + minimum and maximum values in `stats` will be used. + """ def __init__(self, config, stats=None, colormap_style="viridis", range=None): self.config = config self.stats = stats @@ -94,6 +151,16 @@ def __init__(self, config, stats=None, colormap_style="viridis", range=None): self.colormap = plt.cm.get_cmap(colormap_style) def style_callback(self): + """ + Returns a function that can be used as input style for the ipyleaflet + layer. + + Returns + ------- + function + A function that takes a dataframe feature as input and returns a + dictionary of style options for the ipyleaflet layer. + """ if self.stats is not None: norm = plt.Normalize(self.min_val, self.max_val) @@ -120,7 +187,14 @@ def map_color(feature): return map_color def legend(self): - """Generate an HTML legend for the map.""" + """ + Generates an HTML legend for the colormap. + + Returns + ------- + ipywidgets.HTML + An HTML widget containing the colormap legend. + """ # Convert the colormap to a list of RGB values rgb_values = [ mpl.colors.rgb2hex(self.colormap(i)) for i in np.linspace(0, 1, 256) diff --git a/hat/interactive/widgets.py b/hat/interactive/widgets.py index f4ce147..70c711c 100644 --- a/hat/interactive/widgets.py +++ b/hat/interactive/widgets.py @@ -11,7 +11,22 @@ class ThrottledClick: """ Initialize a click throttler with a given delay. - to prevent user from swift multiple events clicking that results in crashing + + Parameters + ---------- + delay : float, optional + The delay in seconds between clicks. Defaults to 1.0. + + Notes + ----- + This class is used to prevent users from rapidly clicking a button or widget + multiple times, which can cause the application to crash or behave unexpectedly. + + Examples + -------- + >>> click_throttler = ThrottledClick(delay=0.5) + >>> if click_throttler.should_process(): + ... # do something """ def __init__(self, delay=1.0): @@ -21,6 +36,17 @@ def __init__(self, delay=1.0): def should_process(self): """ Determine if a click should be processed based on the delay. + + Returns + ------- + bool + True if the click should be processed, False otherwise. + + Notes + ----- + This method should be called before processing a click event. If the + time since the last click is greater than the delay, the method returns + True and updates the last_call attribute. Otherwise, it returns False. """ current_time = time.time() if current_time - self.last_call > self.delay: @@ -30,18 +56,51 @@ def should_process(self): class WidgetsManager: + """ + A class for managing a collection of widgets and updating following + a user interaction, providing an index. + + Parameters + ---------- + widgets : dict + A dictionary of widgets to manage. + index_column : str + The name of the column containing the index used to update the widgets. + loading_widget : optional + A widget to display a loading message while data is being loaded. + + Attributes + ---------- + widgets : dict + A dictionary of widgets being managed. + index_column : str + The name of the column containing the index used to update the widgets. + throttler : ThrottledClick + A throttler for click events. + loading_widget : optional + A widget to display a loading message while data is being loaded. + """ + def __init__(self, widgets, index_column, loading_widget=None): self.widgets = widgets self.index_column = index_column - self.throttler = self._initialize_throttler() + self.throttler = ThrottledClick() self.loading_widget = loading_widget - def _initialize_throttler(self, delay=1.0): - """Initialize the throttler for click events.""" - return ThrottledClick(delay) + def __getitem__(self, item): + return self.widgets[item] def update(self, feature, **kwargs): - """Handle the selection of a marker on the map.""" + """ + Handle the selection of a marker on the map. + + Parameters + ---------- + feature : dict + A dictionary containing information about the selected feature. + **kwargs : dict + Additional keyword arguments to pass to the widgets update method. + """ # Check if we should process the click if not self.throttler.should_process(): @@ -58,24 +117,39 @@ def update(self, feature, **kwargs): # update widgets for wgt in self.widgets.values(): - wgt.update(index, metadata) + wgt.update(index, metadata, **kwargs) if self.loading_widget is not None: self.loading_widget.value = "" # Clear the loading message - def __getitem__(self, item): - return self.widgets[item] - class Widget: + """ + A base class for interactive widgets. + + Parameters + ---------- + output : Output + The ipywidget compatible object to display the widget's content. + + Attributes + ---------- + output : Output + The ipywidget compatible object to display the widget's content. + + Methods + ------- + update(index, metadata, **kwargs) + Update the widget's content based on the given index and metadata. + """ def __init__(self, output): self.output = output - def update(self, index, metadata): + def update(self, index, metadata, **kwargs): raise NotImplementedError -def filter_nan_values(dates, data_values): +def _filter_nan_values(dates, data_values): """Filters out NaN values and their associated dates.""" valid_dates = [date for date, val in zip(dates, data_values) if not np.isnan(val)] valid_data = [val for val in data_values if not np.isnan(val)] @@ -84,20 +158,32 @@ def filter_nan_values(dates, data_values): class PlotlyWidget(Widget): - """Plotly widget to display timeseries.""" + """ + A widget to display timeseries data using Plotly. + + Parameters + ---------- + datasets : dict + A dictionary containing the xarray timeseries datasets to be displayed. + + Attributes + ---------- + datasets : dict + A dictionary containing the xarray timeseries datasets to be displayed. + figure : plotly.graph_objs._figurewidget.FigureWidget + The Plotly figure widget. + ds_time_str : list + A list of strings representing the dates in the timeseries data. + start_date_picker : DatePicker + The date picker widget for selecting the start date. + end_date_picker : DatePicker + The date picker widget for selecting the end date. + """ def __init__(self, datasets): self.datasets = datasets - # initial_title = { - # 'text': "Click on your desired station location", - # 'y':0.9, - # 'x':0.5, - # 'xanchor': 'center', - # 'yanchor': 'top' - # } self.figure = go.FigureWidget( layout=go.Layout( - # title = initial_title, height=350, margin=dict(l=120), legend=dict( @@ -111,15 +197,12 @@ def __init__(self, datasets): ds_time = datasets["obs"]["time"].values.astype("datetime64[D]") self.ds_time_str = [dt.isoformat() for dt in pd.to_datetime(ds_time)] - # Add date pickers for start and end dates self.start_date_picker = DatePicker(description="Start") self.end_date_picker = DatePicker(description="End") - # Observe changes in the date pickers to update the plot self.start_date_picker.observe(self._update_plot_dates, names="value") self.end_date_picker.observe(self._update_plot_dates, names="value") - # Date picker title date_label = Label( "Please select the date to accurately change the date axis of the plot" ) @@ -129,17 +212,22 @@ def __init__(self, datasets): output = VBox([self.figure, date_label, date_picker_box], layout=layout) super().__init__(output) - def _update_plot_dates(self, change): + def _update_plot_dates(self): + """ + Updates the plot with the selected start and end dates. + """ start_date = self.start_date_picker.value.strftime("%Y-%m-%d") end_date = self.end_date_picker.value.strftime("%Y-%m-%d") self.figure.update_layout(xaxis_range=[start_date, end_date]) - def update_data(self, station_id): - """Update the simulation data for the given station ID.""" + def _update_data(self, station_id): + """ + Updates the simulation data for the given station ID. + """ for name, ds in self.datasets.items(): if station_id in ds["station"].values: ds_time_series_data = ds.sel(station=station_id).values - valid_dates_ds, valid_data_ds = filter_nan_values( + valid_dates_ds, valid_data_ds = _filter_nan_values( self.ds_time_str, ds_time_series_data ) self._update_trace(valid_dates_ds, valid_data_ds, name) @@ -147,7 +235,9 @@ def update_data(self, station_id): print(f"Station ID: {station_id} not found in dataset {name}.") def _update_trace(self, x_data, y_data, name): - """Update or add a trace to the Plotly figure.""" + """ + Updates the plot trace for the given name with the given x and y data. + """ trace_exists = any([trace.name == name for trace in self.figure.data]) if trace_exists: for trace in self.figure.data: @@ -159,7 +249,10 @@ def _update_trace(self, x_data, y_data, name): go.Scatter(x=x_data, y=y_data, mode="lines", name=name) ) - def update_title(self, metadata): + def _update_title(self, metadata): + """ + Updates the plot title following the point metadata. + """ station_id = metadata["station_id"] station_name = metadata["StationName"] updated_title = ( @@ -177,17 +270,29 @@ def update_title(self, metadata): ) def update(self, index, metadata): - """Update the overall plot with new data for the given station ID.""" - # self.update_title(metadata) - self.update_data(index) + """ + Updates the overall plot with new data for the given index. + + Parameters + ---------- + index : str + The ID of the station to update the data for. + metadata : dict + A dictionary containing the metadata for the selected station. + """ + self._update_data(index) class HTMLTableWidget(Widget): - def __init__(self, dataframe, title): - """ - Initialize the table object for displaying statistics and station properties. - """ - self.dataframe = dataframe + """ + A widget to display a pandas dataframe with the HTML format. + + Parameters + ---------- + title : str + The title of the table. + """ + def __init__(self, title): self.title = title super().__init__(Output()) @@ -228,10 +333,9 @@ def __init__(self, dataframe, title): ) # Initialize the stat_table_html and station_table_html with empty tables empty_df = pd.DataFrame() - self.display_dataframe_with_scroll(empty_df, title=self.title) + self._display_dataframe_with_scroll(empty_df, title=self.title) - def display_dataframe_with_scroll(self, df, title=""): - """Display a DataFrame with a scrollable view.""" + def _display_dataframe_with_scroll(self, df, title=""): table_html = df.to_html(classes="custom-table") content = f"{self.table_style}

{title}

{table_html}
" # noqa: E501 with self.output: @@ -239,16 +343,31 @@ def display_dataframe_with_scroll(self, df, title=""): display(HTML(content)) def update(self, index, metadata): - dataframe = self.extract_dataframe(index) - self.display_dataframe_with_scroll(dataframe, title=self.title) + """ + Update the table with the dataframe as the given index. + + Parameters + ---------- + index : int + The index of the data to be displayed. + metadata : dict + The metadata associated with the data index. + """ + dataframe = self._extract_dataframe(index) + self._display_dataframe_with_scroll(dataframe, title=self.title) class DataFrameWidget(Widget): - def __init__(self, dataframe, title): - """ - Initialize the table object for displaying statistics and station properties. - """ - self.dataframe = dataframe + """ + A widget to display a pandas dataframe with the default pandas display + style. + + Parameters + ---------- + title : str + The title of the table. + """ + def __init__(self, title): self.title = title super().__init__(output=Output(title=self.title)) @@ -259,42 +378,78 @@ def __init__(self, dataframe, title): display(empty_df) def update(self, index, metadata): - dataframe = self.extract_dataframe(index) + """ + Update the table with the dataframe as the given index. + + Parameters + ---------- + index : int + The index of the data to be displayed. + metadata : dict + The metadata associated with the data index. + """ + dataframe = self._extract_dataframe(index) with self.output: clear_output(wait=True) # Clear any previous plots or messages display(dataframe) + def _extract_dataframe(self, index): + """ + Virtual method to return the object dataframe at the index. + """ + raise NotImplementedError + class MetaDataWidget(HTMLTableWidget): + """ + An extension of the HTMLTableWidget class to display a station metadata. + + Parameters + ---------- + dataframe : pd.DataFrame + A pandas dataframe to be displayed in the table. + station_index : str + Column name of the station index. + """ def __init__(self, dataframe, station_index): title = "Station Metadata" + self.dataframe = dataframe self.station_index = station_index - super().__init__(dataframe, title) + super().__init__(title) - def extract_dataframe(self, station_id): - """Generate a station property table for the given station ID.""" + def _extract_dataframe(self, station_id): stations_df = self.dataframe selected_station_df = stations_df[stations_df[self.station_index] == station_id] - return selected_station_df class StatisticsWidget(HTMLTableWidget): - def __init__(self, dataframe): + """ + An extension of the HTMLTableWidget to display statistics at stations. + + Parameters + ---------- + dataframe : pd.DataFrame + A pandas dataframe to be displayed in the table. + station_index : str + Column name of the station index. + """ + def __init__(self, statistics): title = "Model Performance Statistics Overview" - super().__init__(dataframe, title) + self.statistics = statistics + super().__init__(title) - def extract_dataframe(self, station_id): + def _extract_dataframe(self, station_id): """Generate a statistics table for the given station ID.""" data = [] # Check if statistics is None or empty - if not self.dataframe: + if not self.statistics: print("No statistics data provided.") return pd.DataFrame() # Return an empty dataframe # Loop through each simulation and get the statistics for the given station_id - for exp_name, stats in self.dataframe.items(): + for exp_name, stats in self.statistics.items(): if station_id in stats["station"].values: row = [exp_name] + [ round(stats[var].sel(station=station_id).values.item(), 2) diff --git a/notebooks/examples/4_visualisation_interactive.ipynb b/notebooks/examples/4_visualisation_interactive.ipynb index 26b7365..c446cf0 100644 --- a/notebooks/examples/4_visualisation_interactive.ipynb +++ b/notebooks/examples/4_visualisation_interactive.ipynb @@ -13,21 +13,23 @@ "metadata": {}, "outputs": [], "source": [ - "stations = \"~/git/hat/data/outlets_v4.0_20230726_withEFAS.csv\"\n", - "observations = \"~/git/hat/data/observations/destine_observations.nc\"\n", - "simulations = {\n", - " \"i05j\": \"~/git/hat/data/cama_i05j_stations.nc\",\n", - " \"i05h\": \"~/git/hat/data/cama_i05h_stations.nc\",\n", - "}\n", - "statistics = {\n", - " \"i05j\": \"~/git/hat/data/observations/statistics_i05j.nc\",\n", - " \"i05h\": \"~/git/hat/data/observations/statistics_i05h.nc\",\n", - "}\n", "config = {\n", + " \"stations\": \"~/git/hat/data/outlets_v4.0_20230726_withEFAS.csv\",\n", + " \"observations\": \"~/git/hat/data/observations/destine_observations.nc\",\n", + " \"simulations\": {\n", + " \"i05j\": \"~/git/hat/data/cama_i05j_stations.nc\",\n", + " \"i05h\": \"~/git/hat/data/cama_i05h_stations.nc\",\n", + " },\n", + " \"statistics\": {\n", + " \"i05j\": \"~/git/hat/data/observations/statistics_i05j.nc\",\n", + " \"i05h\": \"~/git/hat/data/observations/statistics_i05h.nc\",\n", + " },\n", " \"station_epsg\": 4326,\n", " \"station_id_column_name\": \"station_id\",\n", " \"station_filters\":\"\",\n", " \"station_coordinates\": [\"StationLon\", \"StationLat\"],\n", + " \"obs_var_name\": \"obsdis\",\n", + " \"sims_var_name\": \"dis\",\n", "}\n" ] }, @@ -45,7 +47,7 @@ "outputs": [], "source": [ "from hat.interactive.explorers import TimeSeriesExplorer\n", - "map = TimeSeriesExplorer(config, stations, observations, simulations, stats=statistics)" + "map = TimeSeriesExplorer(config)" ] }, { From fbdc9179572b2013374e470f76f5cf9e3042ef47 Mon Sep 17 00:00:00 2001 From: Corentin Carton De Wiart Date: Tue, 14 Nov 2023 21:35:36 +0000 Subject: [PATCH 26/31] debug notebook --- hat/interactive/explorers.py | 19 ++++----- hat/interactive/leaflet.py | 3 +- hat/interactive/widgets.py | 11 +++-- .../4_visualisation_interactive.ipynb | 41 +++++++++++++++++-- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/hat/interactive/explorers.py b/hat/interactive/explorers.py index f55844b..8075c5b 100644 --- a/hat/interactive/explorers.py +++ b/hat/interactive/explorers.py @@ -3,7 +3,6 @@ import ipywidgets import pandas as pd import xarray as xr -import earthkit.data from IPython.core.display import display from hat.interactive.leaflet import LeafletMap, PyleafletColormap @@ -42,8 +41,7 @@ def prepare_simulations_data(simulations, sims_var_name): expanded_path = os.path.expanduser(path) if os.path.isfile(expanded_path): # Check if it's a file - fs = earthkit.data.from_source("file", expanded_path) - ds = fs.to_xarray() + ds = xr.open_dataset(expanded_path) sim_ds[exp] = ds[sims_var_name] @@ -116,7 +114,7 @@ def find_common_stations(station_index, stations_metadata, obs_ds, sim_ds, stati Parameters ---------- station_index : str - The name of the column in the station metadata file that contains the + The name of the column in the station metadata file that contains the station IDs. stations_metadata : pandas.DataFrame A pandas DataFrame containing the station metadata. @@ -216,8 +214,12 @@ def __init__(self, config): ) # Use the external functions to prepare data - sim_ds = prepare_simulations_data(config["simulations"], config["sims_var_name"]) - obs_ds = prepare_observations_data(config["observations"], sim_ds, config["obs_var_name"]) + sim_ds = prepare_simulations_data( + config["simulations"], config["sims_var_name"] + ) + obs_ds = prepare_observations_data( + config["observations"], sim_ds, config["obs_var_name"] + ) # set station index self.station_index = config["station_id_column_name"] @@ -300,10 +302,7 @@ def create_frame(self): width="40%", ) right_layout = ipywidgets.Layout( - justify_content="center", - align_items="center", - spacing="2px", - width="60%" + justify_content="center", align_items="center", spacing="2px", width="60%" ) # Frames diff --git a/hat/interactive/leaflet.py b/hat/interactive/leaflet.py index f84e4fc..22cefda 100644 --- a/hat/interactive/leaflet.py +++ b/hat/interactive/leaflet.py @@ -29,7 +29,7 @@ class LeafletMap: Parameters ---------- basemap : ipyleaflet.basemaps, optional - The basemap to use for the map. Default is + The basemap to use for the map. Default is ipyleaflet.basemaps.OpenStreetMap.Mapnik. """ @@ -132,6 +132,7 @@ class PyleafletColormap: The minimum and maximum values of the colormap. If None, the minimum and maximum values in `stats` will be used. """ + def __init__(self, config, stats=None, colormap_style="viridis", range=None): self.config = config self.stats = stats diff --git a/hat/interactive/widgets.py b/hat/interactive/widgets.py index 70c711c..b47c9b9 100644 --- a/hat/interactive/widgets.py +++ b/hat/interactive/widgets.py @@ -142,6 +142,7 @@ class Widget: update(index, metadata, **kwargs) Update the widget's content based on the given index and metadata. """ + def __init__(self, output): self.output = output @@ -269,7 +270,7 @@ def _update_title(self, metadata): } ) - def update(self, index, metadata): + def update(self, index, metadata, **kwargs): """ Updates the overall plot with new data for the given index. @@ -292,6 +293,7 @@ class HTMLTableWidget(Widget): title : str The title of the table. """ + def __init__(self, title): self.title = title super().__init__(Output()) @@ -342,7 +344,7 @@ def _display_dataframe_with_scroll(self, df, title=""): clear_output(wait=True) # Clear any previous plots or messages display(HTML(content)) - def update(self, index, metadata): + def update(self, index, metadata, **kwargs): """ Update the table with the dataframe as the given index. @@ -367,6 +369,7 @@ class DataFrameWidget(Widget): title : str The title of the table. """ + def __init__(self, title): self.title = title super().__init__(output=Output(title=self.title)) @@ -377,7 +380,7 @@ def __init__(self, title): clear_output(wait=True) # Clear any previous plots or messages display(empty_df) - def update(self, index, metadata): + def update(self, index, metadata, **kwargs): """ Update the table with the dataframe as the given index. @@ -411,6 +414,7 @@ class MetaDataWidget(HTMLTableWidget): station_index : str Column name of the station index. """ + def __init__(self, dataframe, station_index): title = "Station Metadata" self.dataframe = dataframe @@ -434,6 +438,7 @@ class StatisticsWidget(HTMLTableWidget): station_index : str Column name of the station index. """ + def __init__(self, statistics): title = "Model Performance Statistics Overview" self.statistics = statistics diff --git a/notebooks/examples/4_visualisation_interactive.ipynb b/notebooks/examples/4_visualisation_interactive.ipynb index c446cf0..2194b6d 100644 --- a/notebooks/examples/4_visualisation_interactive.ipynb +++ b/notebooks/examples/4_visualisation_interactive.ipynb @@ -9,8 +9,10 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, + "execution_count": 1, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "config = {\n", @@ -42,9 +44,40 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, - "outputs": [], + "outputs": [ + { + "ename": "Exception", + "evalue": "Could not open file ~/git/hat/data/outlets_v4.0_20230726_withEFAS.csv", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mCPLE_OpenFailedError\u001b[0m Traceback (most recent call last)", + "File \u001b[0;32mfiona/_shim.pyx:83\u001b[0m, in \u001b[0;36mfiona._shim.gdal_open_vector\u001b[0;34m()\u001b[0m\n", + "File \u001b[0;32mfiona/_err.pyx:291\u001b[0m, in \u001b[0;36mfiona._err.exc_wrap_pointer\u001b[0;34m()\u001b[0m\n", + "\u001b[0;31mCPLE_OpenFailedError\u001b[0m: /home/macw/git/hat/data/outlets_v4.0_20230726_withEFAS.csv: No such file or directory", + "\nDuring handling of the above exception, another exception occurred:\n", + "\u001b[0;31mDriverError\u001b[0m Traceback (most recent call last)", + "File \u001b[0;32m/etc/ecmwf/nfs/dh1_home_a/macw/git/hat/hat/observations.py:42\u001b[0m, in \u001b[0;36mread_station_metadata_file\u001b[0;34m(fpath, coord_names, epsg, filters)\u001b[0m\n\u001b[1;32m 41\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m is_csv(fpath):\n\u001b[0;32m---> 42\u001b[0m gdf \u001b[38;5;241m=\u001b[39m \u001b[43mread_csv_and_cache\u001b[49m\u001b[43m(\u001b[49m\u001b[43mfpath\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 43\u001b[0m gdf \u001b[38;5;241m=\u001b[39m add_geometry_column(gdf, coord_names)\n", + "File \u001b[0;32m/etc/ecmwf/nfs/dh1_home_a/macw/git/hat/hat/data.py:234\u001b[0m, in \u001b[0;36mread_csv_and_cache\u001b[0;34m(fpath)\u001b[0m\n\u001b[1;32m 232\u001b[0m \u001b[38;5;66;03m# otherwise load from user defined filepath (and then cache)\u001b[39;00m\n\u001b[1;32m 233\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[0;32m--> 234\u001b[0m gdf \u001b[38;5;241m=\u001b[39m \u001b[43mgpd\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mread_file\u001b[49m\u001b[43m(\u001b[49m\u001b[43mfpath\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 235\u001b[0m gdf\u001b[38;5;241m.\u001b[39mto_pickle(cache_fpath)\n", + "File \u001b[0;32m/usr/local/apps/python3/3.10.10-01/lib/python3.10/site-packages/geopandas/io/file.py:259\u001b[0m, in \u001b[0;36m_read_file\u001b[0;34m(filename, bbox, mask, rows, engine, **kwargs)\u001b[0m\n\u001b[1;32m 258\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m engine \u001b[38;5;241m==\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mfiona\u001b[39m\u001b[38;5;124m\"\u001b[39m:\n\u001b[0;32m--> 259\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43m_read_file_fiona\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 260\u001b[0m \u001b[43m \u001b[49m\u001b[43mpath_or_bytes\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mfrom_bytes\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mbbox\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mbbox\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mmask\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mmask\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mrows\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mrows\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\n\u001b[1;32m 261\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 262\u001b[0m \u001b[38;5;28;01melif\u001b[39;00m engine \u001b[38;5;241m==\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mpyogrio\u001b[39m\u001b[38;5;124m\"\u001b[39m:\n", + "File \u001b[0;32m/usr/local/apps/python3/3.10.10-01/lib/python3.10/site-packages/geopandas/io/file.py:303\u001b[0m, in \u001b[0;36m_read_file_fiona\u001b[0;34m(path_or_bytes, from_bytes, bbox, mask, rows, where, **kwargs)\u001b[0m\n\u001b[1;32m 302\u001b[0m \u001b[38;5;28;01mwith\u001b[39;00m fiona_env():\n\u001b[0;32m--> 303\u001b[0m \u001b[38;5;28;01mwith\u001b[39;00m \u001b[43mreader\u001b[49m\u001b[43m(\u001b[49m\u001b[43mpath_or_bytes\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m \u001b[38;5;28;01mas\u001b[39;00m features:\n\u001b[1;32m 304\u001b[0m crs \u001b[38;5;241m=\u001b[39m features\u001b[38;5;241m.\u001b[39mcrs_wkt\n", + "File \u001b[0;32m/usr/local/apps/python3/3.10.10-01/lib/python3.10/site-packages/fiona/env.py:408\u001b[0m, in \u001b[0;36mensure_env_with_credentials..wrapper\u001b[0;34m(*args, **kwargs)\u001b[0m\n\u001b[1;32m 407\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m local\u001b[38;5;241m.\u001b[39m_env:\n\u001b[0;32m--> 408\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mf\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 409\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n", + "File \u001b[0;32m/usr/local/apps/python3/3.10.10-01/lib/python3.10/site-packages/fiona/__init__.py:264\u001b[0m, in \u001b[0;36mopen\u001b[0;34m(fp, mode, driver, schema, crs, encoding, layer, vfs, enabled_drivers, crs_wkt, **kwargs)\u001b[0m\n\u001b[1;32m 263\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m mode \u001b[38;5;129;01min\u001b[39;00m (\u001b[38;5;124m'\u001b[39m\u001b[38;5;124ma\u001b[39m\u001b[38;5;124m'\u001b[39m, \u001b[38;5;124m'\u001b[39m\u001b[38;5;124mr\u001b[39m\u001b[38;5;124m'\u001b[39m):\n\u001b[0;32m--> 264\u001b[0m c \u001b[38;5;241m=\u001b[39m \u001b[43mCollection\u001b[49m\u001b[43m(\u001b[49m\u001b[43mpath\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mmode\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mdriver\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mdriver\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mencoding\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mencoding\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 265\u001b[0m \u001b[43m \u001b[49m\u001b[43mlayer\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mlayer\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43menabled_drivers\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43menabled_drivers\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 266\u001b[0m \u001b[38;5;28;01melif\u001b[39;00m mode \u001b[38;5;241m==\u001b[39m \u001b[38;5;124m'\u001b[39m\u001b[38;5;124mw\u001b[39m\u001b[38;5;124m'\u001b[39m:\n", + "File \u001b[0;32m/usr/local/apps/python3/3.10.10-01/lib/python3.10/site-packages/fiona/collection.py:162\u001b[0m, in \u001b[0;36mCollection.__init__\u001b[0;34m(self, path, mode, driver, schema, crs, encoding, layer, vsi, archive, enabled_drivers, crs_wkt, ignore_fields, ignore_geometry, **kwargs)\u001b[0m\n\u001b[1;32m 161\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39msession \u001b[38;5;241m=\u001b[39m Session()\n\u001b[0;32m--> 162\u001b[0m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43msession\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mstart\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 163\u001b[0m \u001b[38;5;28;01melif\u001b[39;00m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mmode \u001b[38;5;129;01min\u001b[39;00m (\u001b[38;5;124m'\u001b[39m\u001b[38;5;124ma\u001b[39m\u001b[38;5;124m'\u001b[39m, \u001b[38;5;124m'\u001b[39m\u001b[38;5;124mw\u001b[39m\u001b[38;5;124m'\u001b[39m):\n", + "File \u001b[0;32mfiona/ogrext.pyx:540\u001b[0m, in \u001b[0;36mfiona.ogrext.Session.start\u001b[0;34m()\u001b[0m\n", + "File \u001b[0;32mfiona/_shim.pyx:90\u001b[0m, in \u001b[0;36mfiona._shim.gdal_open_vector\u001b[0;34m()\u001b[0m\n", + "\u001b[0;31mDriverError\u001b[0m: /home/macw/git/hat/data/outlets_v4.0_20230726_withEFAS.csv: No such file or directory", + "\nDuring handling of the above exception, another exception occurred:\n", + "\u001b[0;31mException\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[2], line 2\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[38;5;28;01mfrom\u001b[39;00m \u001b[38;5;21;01mhat\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01minteractive\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mexplorers\u001b[39;00m \u001b[38;5;28;01mimport\u001b[39;00m TimeSeriesExplorer\n\u001b[0;32m----> 2\u001b[0m \u001b[38;5;28mmap\u001b[39m \u001b[38;5;241m=\u001b[39m \u001b[43mTimeSeriesExplorer\u001b[49m\u001b[43m(\u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m/etc/ecmwf/nfs/dh1_home_a/macw/git/hat/hat/interactive/explorers.py:211\u001b[0m, in \u001b[0;36mTimeSeriesExplorer.__init__\u001b[0;34m(self, config)\u001b[0m\n\u001b[1;32m 158\u001b[0m \u001b[38;5;250m\u001b[39m\u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[1;32m 159\u001b[0m \u001b[38;5;124;03mInitializes an instance of the Explorer class.\u001b[39;00m\n\u001b[1;32m 160\u001b[0m \n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 207\u001b[0m \n\u001b[1;32m 208\u001b[0m \u001b[38;5;124;03m\"\"\"\u001b[39;00m\n\u001b[1;32m 209\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mconfig \u001b[38;5;241m=\u001b[39m config\n\u001b[0;32m--> 211\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstations_metadata \u001b[38;5;241m=\u001b[39m \u001b[43mread_station_metadata_file\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 212\u001b[0m \u001b[43m \u001b[49m\u001b[43mfpath\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mconfig\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mstations\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 213\u001b[0m \u001b[43m \u001b[49m\u001b[43mcoord_names\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mconfig\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mstation_coordinates\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 214\u001b[0m \u001b[43m \u001b[49m\u001b[43mepsg\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mconfig\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mstation_epsg\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 215\u001b[0m \u001b[43m \u001b[49m\u001b[43mfilters\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mconfig\u001b[49m\u001b[43m[\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mstation_filters\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 216\u001b[0m \u001b[43m\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 218\u001b[0m \u001b[38;5;66;03m# Use the external functions to prepare data\u001b[39;00m\n\u001b[1;32m 219\u001b[0m sim_ds \u001b[38;5;241m=\u001b[39m prepare_simulations_data(\n\u001b[1;32m 220\u001b[0m config[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124msimulations\u001b[39m\u001b[38;5;124m\"\u001b[39m], config[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124msims_var_name\u001b[39m\u001b[38;5;124m\"\u001b[39m]\n\u001b[1;32m 221\u001b[0m )\n", + "File \u001b[0;32m/etc/ecmwf/nfs/dh1_home_a/macw/git/hat/hat/observations.py:48\u001b[0m, in \u001b[0;36mread_station_metadata_file\u001b[0;34m(fpath, coord_names, epsg, filters)\u001b[0m\n\u001b[1;32m 46\u001b[0m gdf \u001b[38;5;241m=\u001b[39m gpd\u001b[38;5;241m.\u001b[39mread_file(fpath)\n\u001b[1;32m 47\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mException\u001b[39;00m:\n\u001b[0;32m---> 48\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mException\u001b[39;00m(\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mCould not open file \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mfpath\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 50\u001b[0m \u001b[38;5;66;03m# (optionally) filter the stations, e.g. 'Contintent == Europe'\u001b[39;00m\n\u001b[1;32m 51\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m filters \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n", + "\u001b[0;31mException\u001b[0m: Could not open file ~/git/hat/data/outlets_v4.0_20230726_withEFAS.csv" + ] + } + ], "source": [ "from hat.interactive.explorers import TimeSeriesExplorer\n", "map = TimeSeriesExplorer(config)" From 55026eb7c40280a88b4e08dcc65bedd3abfb8681 Mon Sep 17 00:00:00 2001 From: Corentin Carton De Wiart Date: Tue, 14 Nov 2023 21:36:16 +0000 Subject: [PATCH 27/31] pdate version --- hat/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hat/version.py b/hat/version.py index 3d18726..906d362 100644 --- a/hat/version.py +++ b/hat/version.py @@ -1 +1 @@ -__version__ = "0.5.0" +__version__ = "0.6.0" From 81f777ee5781ab5a2cfbb3f1e8c5f9e6f8b06d88 Mon Sep 17 00:00:00 2001 From: Corentin Carton De Wiart Date: Wed, 15 Nov 2023 14:52:27 +0000 Subject: [PATCH 28/31] add interactive tests --- hat/interactive/__init__.py | 12 ++ hat/interactive/explorers.py | 2 +- hat/interactive/leaflet.py | 43 +++++-- hat/interactive/widgets.py | 32 ++++-- tests/test_interactive.py | 215 +++++++++++++++++++++++++++++++++++ 5 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 tests/test_interactive.py diff --git a/hat/interactive/__init__.py b/hat/interactive/__init__.py index e69de29..99567ee 100644 --- a/hat/interactive/__init__.py +++ b/hat/interactive/__init__.py @@ -0,0 +1,12 @@ +# flake8: noqa +from .explorers import TimeSeriesExplorer +from .leaflet import LeafletMap, PyleafletColormap +from .widgets import ( + DataFrameWidget, + HTMLTableWidget, + MetaDataWidget, + PlotlyWidget, + StatisticsWidget, + Widget, + WidgetsManager, +) diff --git a/hat/interactive/explorers.py b/hat/interactive/explorers.py index 8075c5b..0c88d4c 100644 --- a/hat/interactive/explorers.py +++ b/hat/interactive/explorers.py @@ -3,7 +3,7 @@ import ipywidgets import pandas as pd import xarray as xr -from IPython.core.display import display +from IPython.display import display from hat.interactive.leaflet import LeafletMap, PyleafletColormap from hat.interactive.widgets import ( diff --git a/hat/interactive/leaflet.py b/hat/interactive/leaflet.py index 22cefda..10ff223 100644 --- a/hat/interactive/leaflet.py +++ b/hat/interactive/leaflet.py @@ -89,14 +89,14 @@ def add_geolayer(self, geodata, colormap, widgets, coord_names=None): style_callback=colormap.style_callback(), ) geojson.on_click(widgets.update) - self.map.add_layer(geojson) + self.map.add(geojson) if coord_names is not None: self._set_boundaries(geodata, coord_names) self.legend_widget = colormap.legend() - def output(self, layout): + def output(self, layout={}): """ Return the output widget. @@ -133,11 +133,23 @@ class PyleafletColormap: minimum and maximum values in `stats` will be used. """ - def __init__(self, config, stats=None, colormap_style="viridis", range=None): + def __init__( + self, + config={}, + stats=None, + colormap_style="viridis", + range=None, + empty_color="white", + default_color="blue", + ): self.config = config self.stats = stats - print(self.stats) + self.empty_color = empty_color + self.default_color = default_color if self.stats is not None: + assert ( + "station_id_column_name" in self.config + ), 'Config must contain "station_id_column_name"' # Normalize the data for coloring if range is None: self.min_val = self.stats.values.min() @@ -149,7 +161,13 @@ def __init__(self, config, stats=None, colormap_style="viridis", range=None): self.min_val = 0 self.max_val = 1 - self.colormap = plt.cm.get_cmap(colormap_style) + try: + self.colormap = mpl.colormaps[colormap_style] + except KeyError: + raise KeyError( + f"Colormap {colormap_style} not found. " + f"Available colormaps are: {mpl.colormaps}" + ) def style_callback(self): """ @@ -169,20 +187,25 @@ def map_color(feature): station_id = feature["properties"][ self.config["station_id_column_name"] ] - color = mpl.colors.rgb2hex( - self.colormap(norm(self.stats.sel(station=station_id).values)) - ) - return { + if station_id in self.stats.station.values: + station_stats = self.stats.sel(station=station_id) + color = mpl.colors.rgb2hex( + self.colormap(norm(station_stats.values)) + ) + else: + color = self.empty_color + style = { "color": "black", "fillColor": color, } + return style else: def map_color(feature): return { "color": "black", - "fillColor": "blue", + "fillColor": self.default_color, } return map_color diff --git a/hat/interactive/widgets.py b/hat/interactive/widgets.py index b47c9b9..ba7c5d0 100644 --- a/hat/interactive/widgets.py +++ b/hat/interactive/widgets.py @@ -3,8 +3,7 @@ import numpy as np import pandas as pd import plotly.graph_objs as go -from IPython.core.display import display -from IPython.display import clear_output +from IPython.display import clear_output, display from ipywidgets import HTML, DatePicker, HBox, Label, Layout, Output, VBox @@ -146,12 +145,17 @@ class Widget: def __init__(self, output): self.output = output - def update(self, index, metadata, **kwargs): + def update(self, index, *args, **kwargs): raise NotImplementedError def _filter_nan_values(dates, data_values): - """Filters out NaN values and their associated dates.""" + """ + Filters out NaN values and their associated dates. + """ + assert len(dates) == len( + data_values + ), "Dates and data values must be the same length." valid_dates = [date for date, val in zip(dates, data_values) if not np.isnan(val)] valid_data = [val for val in data_values if not np.isnan(val)] @@ -234,6 +238,8 @@ def _update_data(self, station_id): self._update_trace(valid_dates_ds, valid_data_ds, name) else: print(f"Station ID: {station_id} not found in dataset {name}.") + return False + return True def _update_trace(self, x_data, y_data, name): """ @@ -270,7 +276,7 @@ def _update_title(self, metadata): } ) - def update(self, index, metadata, **kwargs): + def update(self, index, *args, **kwargs): """ Updates the overall plot with new data for the given index. @@ -281,7 +287,7 @@ def update(self, index, metadata, **kwargs): metadata : dict A dictionary containing the metadata for the selected station. """ - self._update_data(index) + return self._update_data(index) class HTMLTableWidget(Widget): @@ -344,7 +350,7 @@ def _display_dataframe_with_scroll(self, df, title=""): clear_output(wait=True) # Clear any previous plots or messages display(HTML(content)) - def update(self, index, metadata, **kwargs): + def update(self, index, *args, **kwargs): """ Update the table with the dataframe as the given index. @@ -357,6 +363,9 @@ def update(self, index, metadata, **kwargs): """ dataframe = self._extract_dataframe(index) self._display_dataframe_with_scroll(dataframe, title=self.title) + if dataframe.empty: + return False + return True class DataFrameWidget(Widget): @@ -380,7 +389,7 @@ def __init__(self, title): clear_output(wait=True) # Clear any previous plots or messages display(empty_df) - def update(self, index, metadata, **kwargs): + def update(self, index, *args, **kwargs): """ Update the table with the dataframe as the given index. @@ -395,6 +404,9 @@ def update(self, index, metadata, **kwargs): with self.output: clear_output(wait=True) # Clear any previous plots or messages display(dataframe) + if dataframe.empty: + return False + return True def _extract_dataframe(self, index): """ @@ -442,6 +454,10 @@ class StatisticsWidget(HTMLTableWidget): def __init__(self, statistics): title = "Model Performance Statistics Overview" self.statistics = statistics + for stat in self.statistics.values(): + assert ( + "station" in stat.dims + ), 'Dimension "station" not found in statistics datasets.' # noqa: E501 super().__init__(title) def _extract_dataframe(self, station_id): diff --git a/tests/test_interactive.py b/tests/test_interactive.py new file mode 100644 index 0000000..de24d32 --- /dev/null +++ b/tests/test_interactive.py @@ -0,0 +1,215 @@ +import time + +import geopandas as gpd +import numpy as np +import pandas as pd +import pytest +import xarray as xr +from shapely.geometry import Point + +from hat.interactive import leaflet as lf +from hat.interactive import widgets as wd + + +class TestThrottledClick: + def test_delay(self): + throttler = wd.ThrottledClick(0.01) + assert throttler.should_process() is True + assert throttler.should_process() is False + time.sleep(0.015) + assert throttler.should_process() is True + + +class DummyWidget(wd.Widget): + def __init__(self): + super().__init__(output=None) + self.index = None + + def update(self, index, metadata, **kwargs): + self.index = index + + +class TestWidgetsManager: + def test_update(self): + dummy = DummyWidget() + widgets = wd.WidgetsManager(widgets={"dummy": dummy}, index_column="station") + feature = { + "properties": { + "station": "A", + } + } + widgets.update(feature) + assert dummy.index == "A" + + +def test_filter_nan(): + dates = np.arange(10) + values = np.arange(10, dtype=float) + values[5] = np.nan + dates_new, values_new = wd._filter_nan_values(dates, values) + assert len(dates_new) == 9 + assert len(values_new) == 9 + + +class TestPlotlyWidget: + def test_update(self): + datasets = { + "obs": xr.DataArray( + [[0, 3, 6], [0, 3, 6]], + coords={ + "time": np.array( + ["2007-07-13", "2007-01-14"], dtype="datetime64[ns]" + ), + "station": [0, 1, 2], + }, + ), + "sim1": xr.DataArray( + [[1, 2, 3], [1, 2, 3]], + coords={ + "time": np.array( + ["2007-07-13", "2007-01-14"], dtype="datetime64[ns]" + ), + "station": [0, 1, 2], + }, + ), + "sim2": xr.DataArray( + [[4, 5, 6], [4, 5, 6]], + coords={ + "time": np.array( + ["2007-07-13", "2007-01-14"], dtype="datetime64[ns]" + ), + "station": [0, 1, 2], + }, + ), + } + widget = wd.PlotlyWidget( + datasets=datasets, + ) + assert widget.update(1) is True # if id found + assert widget.update(3) is False # if id not found + + +class TestMetaDataWidget: + def test_update(self): + df = pd.DataFrame( + {"col1": [1, 2, 3], "col2": ["a", "b", "c"], "col3": [0.1, 0.2, 0.3]} + ) + widget = wd.MetaDataWidget(df, "col2") + assert widget.update("a") is True + assert widget.update("f") is False + + +class TestStatisticsWidget: + def test_update(self): + datasets = { + "sim1": xr.Dataset( + {"data": [0, 1, 2]}, + coords={ + "station": [0, 1, 2], + }, + ), + "sim2": xr.Dataset( + {"data": [4, 5, 6]}, + coords={ + "station": [0, 1, 2], + }, + ), + } + widget = wd.StatisticsWidget(datasets) + assert widget.update(0) is True + assert widget.update(4) is False + + def test_fail_creation(self): + datasets = { + "sim1": xr.Dataset( + {"data": [0, 1, 2]}, + coords={ + "id": [0, 1, 2], + }, + ), + } + with pytest.raises(AssertionError): + wd.StatisticsWidget(datasets) + + +class TestPyleafletColormap: + config = { + "station_id_column_name": "station", + } + stats = xr.DataArray( + [0, 1, 2], + coords={ + "station": [0, 1, 2], + }, + ) + + def test_default_creation(self): + lf.PyleafletColormap() + + def test_creation_with_stats(self): + lf.PyleafletColormap( + self.config, self.stats, colormap_style="plasma", range=[1, 2] + ) + + def test_creation_wrong_colormap(self): + with pytest.raises(KeyError): + lf.PyleafletColormap( + self.config, self.stats, colormap_style="awdawd", range=[1, 2] + ) + + def test_fail_creation(self): + config = {} + with pytest.raises(AssertionError): + lf.PyleafletColormap(config, self.stats) + + def test_default_style(self): + colormap = lf.PyleafletColormap(self.config) + style_fct = colormap.style_callback() + style_fct(feature={}) + + def test_stats_style(self): + feature = { + "properties": { + "station": 2, + } + } + colormap = lf.PyleafletColormap(self.config, self.stats) + style_fct = colormap.style_callback() + style_fct(feature) + + def test_stats_style_fail(self): + feature = { + "properties": { + "station": 4, + } + } + colormap = lf.PyleafletColormap(self.config, self.stats, empty_color="black") + style_fct = colormap.style_callback() + style = style_fct(feature) + assert style["fillColor"] == "black" + + def test_stats_style_default(self): + colormap = lf.PyleafletColormap(default_color="blue") + style_fct = colormap.style_callback() + style = style_fct({}) + assert style["fillColor"] == "blue" + + def test_legend(self): + colormap = lf.PyleafletColormap(self.config, self.stats) + colormap.legend() + + +class TestLeafletMap: + def test_creation(self): + lf.LeafletMap() + + def test_output(self): + map = lf.LeafletMap() + map.output() + + def test_add_geolayer(self): + map = lf.LeafletMap() + widgets = {} + colormap = lf.PyleafletColormap() + gdf = gpd.GeoDataFrame(geometry=[Point(0, 0)]) + map.add_geolayer(gdf, colormap, widgets) From 5377c31001fe0aa30584452d301e67031585d420 Mon Sep 17 00:00:00 2001 From: Corentin Carton De Wiart Date: Wed, 15 Nov 2023 16:15:47 +0000 Subject: [PATCH 29/31] update dependencies --- environment.yml | 6 ++-- hat/hydrostats.py | 77 ----------------------------------------------- 2 files changed, 3 insertions(+), 80 deletions(-) diff --git a/environment.yml b/environment.yml index 199838b..1c2e5c2 100644 --- a/environment.yml +++ b/environment.yml @@ -2,11 +2,10 @@ name: hat channels: - conda-forge dependencies: - - python=3.10 + - python<=3.10 - netCDF4 - eccodes - cfgrib - - cftime - geopandas - xarray - plotly @@ -15,8 +14,9 @@ dependencies: - tqdm - typer - humanize - - folium - typer + - ipyleaflet + - ipywidgets - pip # - pytest # - mkdocs diff --git a/hat/hydrostats.py b/hat/hydrostats.py index c2712d3..79238a9 100644 --- a/hat/hydrostats.py +++ b/hat/hydrostats.py @@ -1,12 +1,8 @@ """high level python api for hydrological statistics""" from typing import List -import folium -import geopandas as gpd import numpy as np import xarray as xr -from branca.colormap import linear -from folium.plugins import Fullscreen from hat import hydrostats_functions @@ -48,76 +44,3 @@ def run_analysis( ds[name] = xr.DataArray(statistics, coords={"station": stations}) return ds - - -def display_map(ds: xr.Dataset, name: str, minv: float = 0, maxv: float = 1): - # xarray to geopandas - gdf = gpd.GeoDataFrame( - ds.to_dataframe(), - geometry=gpd.points_from_xy(ds["longitude"], ds["latitude"]), - crs="epsg:4326", - ) - gdf["station_id"] = gdf.index - gdf = gdf[~gdf[name].isnull()] - - # Create a color map - colormap = linear.Blues_09.scale(minv, maxv) - - # Define style function - def style_function(feature): - property = feature["properties"][name] - return {"fillOpacity": 0.7, "weight": 0, "fillColor": colormap(property)} - - m = folium.Map(location=[48, 5], zoom_start=5, prefer_canvas=True, tiles=None) - _ = folium.GeoJson( - gdf, - marker=folium.CircleMarker(fillColor="white", fillOpacity=0.5, radius=5), - name=name, - style_function=style_function, - tooltip=folium.GeoJsonTooltip(fields=["station_id", name]), - popup=folium.GeoJsonPopup(fields=["station_id", name]), - ).add_to(m) - - # Add the CartoDB Positron tileset as a layer - cartodb_positron = folium.TileLayer( - tiles="CartoDB Dark_Matter", - name="Dark", - overlay=False, - control=True, - ) - cartodb_positron.add_to(m) - - # Add the CartoDB Positron tileset as a layer - cartodb_positron = folium.TileLayer( - tiles="CartoDB Positron", - name="Light", - overlay=False, - control=True, - ) - cartodb_positron.add_to(m) - - # Add OpenStreetMap layer - open_street_map = folium.TileLayer( - tiles="OpenStreetMap", - name="Open Street Map", - overlay=False, - control=True, - ) - open_street_map.add_to(m) - - # Add the satellite layer - esri_satellite = folium.TileLayer( - tiles="""https://server.arcgisonline.com/ArcGIS/rest/services/ - World_Imagery/MapServer/tile/{z}/{y}/{x}""", - attr="Esri", - name="Satellite", - overlay=False, - control=True, - ) - esri_satellite.add_to(m) - - # add controls - folium.LayerControl().add_to(m) - Fullscreen().add_to(m) - - return m From 8d7eaa31c945b1d04080db12e10a1044d4f99c4e Mon Sep 17 00:00:00 2001 From: Corentin Carton De Wiart Date: Wed, 15 Nov 2023 16:22:54 +0000 Subject: [PATCH 30/31] removing extra notebook --- .../4_visualisation_interactive_d.ipynb | 146 ------------------ 1 file changed, 146 deletions(-) delete mode 100644 notebooks/examples/4_visualisation_interactive_d.ipynb diff --git a/notebooks/examples/4_visualisation_interactive_d.ipynb b/notebooks/examples/4_visualisation_interactive_d.ipynb deleted file mode 100644 index 0aa631c..0000000 --- a/notebooks/examples/4_visualisation_interactive_d.ipynb +++ /dev/null @@ -1,146 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "1. Import necessart visualisation library and define inputs simulation and additional vector to add" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from hat.visualisation import NotebookMap\n", - "\n", - "stations= '~/destinE/outlets_v4.0_20230726_withEFAS.csv'\n", - "observations= '~/destinE/destine_observation.nc'\n", - "\n", - "simulations = {\n", - " \"i05j\": \"~/destinE/cama_i05j_stations.nc\",\n", - " \"i05h\": \"~/destinE/cama_i05h_stations.nc\"\n", - "}\n", - "\n", - "config = {\n", - " \"station_epsg\": 4326,\n", - " \"station_id_column_name\": \"station_id\",\n", - " \"station_filters\":\"station_id_num <= 5000\",\n", - " \"station_coordinates\": [\"StationLon\", \"StationLat\"],\n", - " \"obs_var_name\": \"obsdis\",\n", - " \"sim_var_name\": \"dis\"\n", - "}\n", - "\n", - "statistics = {\n", - " \"i05j\": \"~/destinE/statistics_i05j.nc\",\n", - " \"i05h\": \"~/destinE/statistics_i05h.nc\"\n", - "}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "2. Initialise & processing all the input data into the map object" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Found 49 common stations\n" - ] - } - ], - "source": [ - "map = NotebookMap(config, stations, observations, simulations, stats=statistics)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "3. Display map interface for interactive viewing" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Initialising plotly\n" - ] - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "152c7bedde3f4f0db087535b1eb1838d", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(Label(value='Interactive Map Visualisation for Hydrological Model Performance', layout=Layout(j…" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "map.mapplot(colorby='kge', sim='i05h', range=[-1, 1])" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "38.13340834" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "map.bounds[0][0]\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "hat-dev", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.12" - }, - "orig_nbformat": 4 - }, - "nbformat": 4, - "nbformat_minor": 2 -} From 41e690cda04c6a3c246ccf53ec62705bae289987 Mon Sep 17 00:00:00 2001 From: Corentin Carton De Wiart Date: Thu, 16 Nov 2023 10:16:09 +0000 Subject: [PATCH 31/31] update on-push ci --- .github/workflows/on-push.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/on-push.yml b/.github/workflows/on-push.yml index c796d57..968e5fa 100644 --- a/.github/workflows/on-push.yml +++ b/.github/workflows/on-push.yml @@ -32,16 +32,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - uses: conda-incubator/setup-miniconda@v2 + - uses: mamba-org/setup-micromamba@v1 with: activate-environment: test environment-file: environment.yml auto-activate-base: false - - name: Conda check - shell: bash -l {0} - run: | - conda info - conda list - name: install hat package shell: bash -l {0} run: pip install .