Skip to content

Commit 3466d9f

Browse files
authored
Merge pull request #198 from CliDyn/datatab
Datatab
2 parents 37616dc + 64da4fa commit 3466d9f

8 files changed

Lines changed: 919 additions & 39 deletions

CLAUDE.md

Lines changed: 334 additions & 0 deletions
Large diffs are not rendered by default.

DATA_ANALYSIS_AGENT_PROMPT.md

Lines changed: 427 additions & 0 deletions
Large diffs are not rendered by default.

PULL_REQUEST.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# FIRST ACCEPT PREVIOUS PR ;)
2+
3+
**This PR is based on PR #197 (analysis modes) and must be merged after it.**
4+
5+
---
6+
7+
## Data Tab: Downloadable Datasets
8+
9+
Adds a new **Data** tab to the UI where users can download all datasets generated during a session. Also renames "Additional information" → "Figures".
10+
11+
### What's new
12+
13+
- **`downloadable_datasets` tracking** — a new field on `AgentState` that accumulates dataset entries (`{label, path, source}`) as they're created throughout the pipeline
14+
- **Climate model CSVs** — tracked after `write_climate_data_manifest()` in `data_agent`
15+
- **ERA5 climatology JSON** — tracked in `prepare_predefined_data()` after extraction
16+
- **ERA5 time series Zarr** — tracked in `data_analysis_agent` after `retrieve_era5_data` tool execution
17+
- **DestinE time series Zarr** — tracked in `data_analysis_agent` after `retrieve_destine_data` tool execution
18+
- **Data tab in UI** — lists all tracked datasets with download buttons; Zarr directories are zipped on the fly, JSON/CSV files download directly
19+
- **Tab rename** — "Additional information" → "Figures"
20+
- **Data tab always visible** — shown regardless of whether figures are available
21+
22+
### Pipeline fix
23+
24+
Each agent node now **returns** `downloadable_datasets` in its return dict so LangGraph properly merges state across stages (in-place mutation alone is not enough).
25+
26+
### Files changed
27+
28+
| File | Change |
29+
|------|--------|
30+
| `climsight_classes.py` | Add `downloadable_datasets: list = []` to `AgentState` |
31+
| `climsight_engine.py` | Track datasets in `data_agent`, `prepare_predefined_data`, pass through `combine_agent` |
32+
| `data_analysis_agent.py` | Track ERA5/DestinE Zarr outputs from tool intermediate steps |
33+
| `streamlit_interface.py` | Rename tab, add Data tab with download buttons |
34+
35+
### Works in all modes
36+
37+
- **fast** — climate model CSVs + ERA5 climatology JSON
38+
- **smart** — above + ERA5 time series Zarr
39+
- **deep** — above + DestinE time series Zarr

src/climsight/climsight_classes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,5 @@ class AgentState(BaseModel):
4141
hazard_data: Optional[Any] = None # filtered_events_square for disaster plotting
4242
population_config: dict = {} # {'pop_path': str, 'country': str} for population plotting
4343
predefined_plots: list = [] # List of paths to auto-generated plots
44+
downloadable_datasets: list = [] # List of {"label": str, "path": str, "source": str}
4445
# stream_handler: StreamHandler # Uncomment if needed

src/climsight/climsight_engine.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,22 @@ def data_agent(state: AgentState, data={}, df={}):
888888
state.input_params.update(sandbox_paths)
889889
state.input_params["climate_data_manifest"] = manifest_path
890890

891+
# Track climate CSVs as downloadable datasets
892+
state.downloadable_datasets.append({
893+
"label": f"Climate Data Manifest ({climate_source})",
894+
"path": manifest_path,
895+
"source": climate_source,
896+
})
897+
climate_dir = sandbox_paths["climate_data_dir"]
898+
if os.path.isdir(climate_dir):
899+
for fname in sorted(os.listdir(climate_dir)):
900+
if fname.endswith(".csv"):
901+
state.downloadable_datasets.append({
902+
"label": f"Climate Model CSV: {fname}",
903+
"path": os.path.join(climate_dir, fname),
904+
"source": climate_source,
905+
})
906+
891907
# Add appropriate references based on data source
892908
ref_key_map = {
893909
'nextGEMS': 'high_resolution_climate_model',
@@ -909,7 +925,11 @@ def data_agent(state: AgentState, data={}, df={}):
909925

910926
logger.info(f"Data agent in work (source: {climate_source}).")
911927

912-
respond = {'data_agent_response': data_agent_response, 'df_list': df_list}
928+
respond = {
929+
'data_agent_response': data_agent_response,
930+
'df_list': df_list,
931+
'downloadable_datasets': state.downloadable_datasets,
932+
}
913933

914934
logger.info(f"data_agent_response: {data_agent_response}")
915935
return respond
@@ -954,6 +974,14 @@ def prepare_predefined_data(state: AgentState):
954974
state.era5_climatology_response = era5_result
955975
if "reference" in era5_result:
956976
collected_references.append(era5_result["reference"])
977+
# Track ERA5 climatology JSON as downloadable
978+
era5_json_path = os.path.join(state.uuid_main_dir, "era5_climatology.json")
979+
if os.path.exists(era5_json_path):
980+
state.downloadable_datasets.append({
981+
"label": "ERA5 Climatology (monthly, 2015-2025)",
982+
"path": era5_json_path,
983+
"source": "ERA5",
984+
})
957985
logger.info(f"Extracted ERA5 climatology for ({lat}, {lon})")
958986
else:
959987
logger.warning(f"ERA5 climatology: {era5_result.get('error', 'unknown error')}")
@@ -1019,6 +1047,7 @@ def prepare_predefined_data(state: AgentState):
10191047
'predefined_plots': predefined_plot_paths,
10201048
'era5_climatology_response': era5_data or {},
10211049
'data_analysis_images': predefined_plot_paths, # For UI display
1050+
'downloadable_datasets': state.downloadable_datasets,
10221051
}
10231052

10241053
def route_after_prepare(state: AgentState) -> str:
@@ -1279,9 +1308,12 @@ def combine_agent(state: AgentState):
12791308
#print("chat_prompt_text: ", chat_prompt_text)
12801309
#print("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
12811310

1311+
# Pass downloadable datasets to input_params for UI access
1312+
state.input_params['downloadable_datasets'] = state.downloadable_datasets
1313+
12821314
return {
1283-
'final_answer': output_content,
1284-
'input_params': state.input_params,
1315+
'final_answer': output_content,
1316+
'input_params': state.input_params,
12851317
'content_message': state.content_message,
12861318
'combine_agent_prompt_text': chat_prompt_text
12871319
}

src/climsight/data_analysis_agent.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,6 @@ def _create_tool_prompt(datasets_text: str, config: dict, lat: float = None, lon
190190
ideal_calls = mode_config.get("ideal_tool_calls", "6-7")
191191
max_per_resp = mode_config.get("max_per_response", 4)
192192
max_reflect = mode_config.get("max_reflect", 2)
193-
has_era5_download = config.get("use_era5_data", False)
194-
has_destine = config.get("use_destine_data", False)
195193

196194
# --- Build prompt without f-strings for code blocks to avoid brace escaping ---
197195
sections = []
@@ -344,17 +342,6 @@ def _create_tool_prompt(datasets_text: str, config: dict, lat: float = None, lon
344342
" But don't go overboard with tiny one-liner calls either — find a reasonable balance.\n"
345343
" Each script should be self-contained: import what it needs, do meaningful work, print results."
346344
)
347-
tools_list.append(
348-
"- **Python_REPL** — execute Python code in a sandboxed environment.\n"
349-
" All files are relative to the sandbox root.\n"
350-
" The `results/` directory is pre-created for saving plots.\n"
351-
" Datasets are pre-loaded into the sandbox (see paths below).\n"
352-
" STRATEGY: DIVIDE AND CONQUER. Split your work into a few focused scripts,\n"
353-
" each tackling ONE logical task (e.g., load+explore, then analyze+plot-set-1,\n"
354-
" then analyze+plot-set-2). This avoids cascading errors from monolithic scripts.\n"
355-
" But don't go overboard with tiny one-liner calls either — find a reasonable balance.\n"
356-
" Each script should be self-contained: import what it needs, do meaningful work, print results."
357-
)
358345
tools_list.append("- **list_plotting_data_files** — discover files in sandbox directories")
359346
tools_list.append("- **image_viewer** — view and analyze plots in `results/` (use relative paths)")
360347
if has_python_repl and max_reflect > 0:
@@ -562,6 +549,10 @@ def _create_tool_prompt(datasets_text: str, config: dict, lat: float = None, lon
562549
"Splitting into reasonable chunks lets you catch and fix errors between steps.\n\n"
563550
)
564551

552+
budget_lines.append(
553+
"ANTI-SPAM RULES:\n"
554+
f"- Never call more than {max_per_resp} tools in a single response.\n"
555+
)
565556
if has_python_repl and max_reflect > 0:
566557
budget_lines.append("- Never call reflect_on_image in the same response as Python_REPL.\n")
567558
budget_lines.append(f"- Never call reflect_on_image more than {max_reflect} times total.\n")
@@ -825,6 +816,14 @@ def data_analysis_agent(
825816
# Collect reference from ERA5 retrieval
826817
if "reference" in obs:
827818
agent_references.append(obs["reference"])
819+
# Track downloaded Zarr for Data tab
820+
if "output_path_zarr" in obs:
821+
variable = obs.get("variable", "unknown")
822+
state.downloadable_datasets.append({
823+
"label": f"ERA5 Time Series: {variable}",
824+
"path": obs["output_path_zarr"],
825+
"source": "ERA5",
826+
})
828827
elif hasattr(obs, 'content'):
829828
era5_output = obs.content
830829
else:
@@ -837,6 +836,14 @@ def data_analysis_agent(
837836
if isinstance(obs, dict):
838837
if "reference" in obs:
839838
agent_references.append(obs["reference"])
839+
# Track downloaded Zarr for Data tab
840+
if "output_path_zarr" in obs:
841+
variable = obs.get("variable", obs.get("parameter", "unknown"))
842+
state.downloadable_datasets.append({
843+
"label": f"DestinE Time Series: {variable}",
844+
"path": obs["output_path_zarr"],
845+
"source": "DestinE",
846+
})
840847
state.destine_tool_response = str(obs)
841848
state.input_params.setdefault("destine_results", []).append(obs)
842849

@@ -882,4 +889,5 @@ def data_analysis_agent(
882889
"era5_tool_response": getattr(state, 'era5_tool_response', None),
883890
"destine_tool_response": getattr(state, 'destine_tool_response', None),
884891
"references": state.references, # Propagate collected references
892+
"downloadable_datasets": state.downloadable_datasets,
885893
}

src/climsight/streamlit_interface.py

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -196,22 +196,6 @@ def _on_mode_change():
196196
with col1:
197197
# Always show additional information (removed toggle per user request)
198198
show_add_info = True
199-
smart_agent = st.toggle("Use extra search", value=False, help="""If this is activated, ClimSight will make additional requests to Wikipedia and RAG, which can significantly increase response time.""")
200-
use_era5_data = st.toggle(
201-
"Enable ERA5 data",
202-
value=config.get("use_era5_data", False),
203-
help="Allow the data analysis agent to retrieve ERA5 data into the sandbox.",
204-
)
205-
use_destine_data = st.toggle(
206-
"Enable DestinE data",
207-
value=config.get("use_destine_data", False),
208-
help="Allow retrieval of DestinE Climate DT projections (SSP3-7.0, 82 parameters).",
209-
)
210-
use_powerful_data_analysis = st.toggle(
211-
"Enable Python analysis",
212-
value=config.get("use_powerful_data_analysis", False),
213-
help="Allow the data analysis agent to use the Python REPL and generate plots.",
214-
)
215199
# remove the llmModeKey_box from the form, as we tend to run the agent mode, direct mode is for development only
216200
#llmModeKey_box = st.radio("Select LLM mode 👉", key="visibility", options=["Direct", "Agent (experimental)"])
217201

@@ -480,9 +464,9 @@ def update_progress_ui(message):
480464
show_add_info_display = st.session_state.get('last_show_add_info', False)
481465

482466
if show_add_info_display:
483-
tab_text, tab_add, tab_refs = st.tabs(["Report", "Additional information", "References"])
467+
tab_text, tab_figs, tab_data, tab_refs = st.tabs(["Report", "Figures", "Data", "References"])
484468
else:
485-
tab_text, tab_refs = st.tabs(["Report", "References"])
469+
tab_text, tab_data, tab_refs = st.tabs(["Report", "Data", "References"])
486470

487471
with tab_text:
488472
st.markdown(st.session_state['last_output'])
@@ -493,12 +477,12 @@ def update_progress_ui(message):
493477
st.markdown(f"- {ref}")
494478

495479
if show_add_info_display:
496-
with tab_add:
480+
with tab_figs:
497481
stored_input_params = st.session_state.get('last_input_params', {})
498482
stored_figs = st.session_state.get('last_figs', {})
499483
stored_climatemodel_name = st.session_state.get('last_climatemodel_name', 'unknown')
500-
501-
st.subheader("Additional information", divider='rainbow')
484+
485+
st.subheader("Figures", divider='rainbow')
502486
if 'lat' in stored_input_params and 'lon' in stored_input_params:
503487
st.markdown(f"**Coordinates:** {stored_input_params['lat']}, {stored_input_params['lon']}")
504488
if 'elevation' in stored_input_params:
@@ -632,6 +616,51 @@ def update_progress_ui(message):
632616
for image_path in other_plots:
633617
st.image(image_path)
634618

619+
# Data tab - downloadable datasets
620+
with tab_data:
621+
stored_input_params_data = st.session_state.get('last_input_params', {})
622+
datasets = stored_input_params_data.get('downloadable_datasets', [])
623+
if datasets:
624+
st.subheader("Available Datasets", divider='rainbow')
625+
for idx, ds_entry in enumerate(datasets):
626+
path = ds_entry.get("path", "")
627+
label = ds_entry.get("label", "Dataset")
628+
source = ds_entry.get("source", "")
629+
if path and os.path.exists(path):
630+
col_label, col_btn = st.columns([3, 1])
631+
with col_label:
632+
st.markdown(f"**{label}**")
633+
st.caption(f"{source}{os.path.basename(path)}")
634+
with col_btn:
635+
if os.path.isdir(path):
636+
# Zarr directories: zip on the fly
637+
import io
638+
import zipfile
639+
buf = io.BytesIO()
640+
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
641+
for root, dirs, files in os.walk(path):
642+
for f in files:
643+
fp = os.path.join(root, f)
644+
zf.write(fp, os.path.relpath(fp, os.path.dirname(path)))
645+
st.download_button(
646+
"Download",
647+
buf.getvalue(),
648+
file_name=os.path.basename(path) + ".zip",
649+
mime="application/zip",
650+
key=f"dl_data_{idx}",
651+
)
652+
else:
653+
with open(path, "rb") as f:
654+
file_data = f.read()
655+
st.download_button(
656+
"Download",
657+
file_data,
658+
file_name=os.path.basename(path),
659+
key=f"dl_data_{idx}",
660+
)
661+
else:
662+
st.info("No datasets were generated for this query.")
663+
635664
# Download buttons
636665
st.markdown("---") # Add a separator
637666

test/plot_destine_data.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1-
"""Quick script to inspect and plot DestinE Zarr data."""
1+
"""Quick script to inspect and plot DestinE Zarr data.
2+
3+
Usage:
4+
python plot_destine_data.py path/to/destine_167_sfc_20200101_20211231.zarr
5+
"""
6+
7+
import argparse
8+
import sys
29

310
import xarray as xr
411
import matplotlib.pyplot as plt
512

6-
zarr_path = "/Users/ikuznets/work/projects/climsight/code/climsight/tmp/sandbox/38c864498d174b8a90ebb24ac67cf70e/destine_data/destine_167_sfc_20200101_20211231.zarr"
13+
parser = argparse.ArgumentParser(description="Inspect and plot a DestinE Zarr dataset.")
14+
parser.add_argument("zarr_path", help="Path to the DestinE .zarr directory")
15+
args = parser.parse_args()
16+
zarr_path = args.zarr_path
717

818
ds = xr.open_dataset(zarr_path, engine="zarr")
919

0 commit comments

Comments
 (0)