From 1fbb68dad1834a54dcb8e27efb4e3154ad67fdea Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 6 May 2025 09:51:28 -0400 Subject: [PATCH 001/151] initial project commit --- .gitignore | 179 +- README.md | 4 +- earthreach/__init__.py => data/.gitkeep | 0 tests/test.py => docs/.gitkeep | 0 docs/index.rst | 4 - earthreach/version.py | 1 - notebooks/earthkit_plots_exploration.py | 214 ++ pyproject.toml | 27 + requirements.txt | 1 - setup.py | 23 - src/earthreach-agent/__init__.py | 0 src/earthreach-agent/config/__init__.py | 0 src/earthreach-agent/core/__init__.py | 0 src/earthreach-agent/core/prompts.py | 50 + src/tests/.gitkeep | 0 uv.lock | 2397 +++++++++++++++++++++++ 16 files changed, 2706 insertions(+), 194 deletions(-) rename earthreach/__init__.py => data/.gitkeep (100%) rename tests/test.py => docs/.gitkeep (100%) delete mode 100644 docs/index.rst delete mode 100644 earthreach/version.py create mode 100644 notebooks/earthkit_plots_exploration.py create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.py create mode 100644 src/earthreach-agent/__init__.py create mode 100644 src/earthreach-agent/config/__init__.py create mode 100644 src/earthreach-agent/core/__init__.py create mode 100644 src/earthreach-agent/core/prompts.py create mode 100644 src/tests/.gitkeep create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 0a19790..d597c51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,174 +1,25 @@ -# Byte-compiled / optimized / DLL files +# Python-generated files __pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python +*.py[oc] build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock +*.egg-info -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env +# Virtual environments .venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ -# Spyder project settings -.spyderproject -.spyproject +# Notebooks +.ipynb_checkpoints/ +*.ipynb -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Environment variables file +.env -# Ruff stuff: -.ruff_cache/ +# Common +*.DS_Store -# PyPI configuration file -.pypirc +# Images +*.png +*.jpg +*.jpeg \ No newline at end of file diff --git a/README.md b/README.md index 6d662f9..a348a55 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# EarthReach Agent: Dual-LLM Framework for Validated Meteorological Chart Descriptions +

License: Apache 2.0 @@ -10,7 +12,7 @@

-# EarthReach +## EarthReach EarthReach is a challenge from the 2025 edition dedicated to enhancing the accessibility of meteorological data visualisations produced by Earthkit, by equipping the plots module with LLM-powered alternative text generation capabilities. diff --git a/earthreach/__init__.py b/data/.gitkeep similarity index 100% rename from earthreach/__init__.py rename to data/.gitkeep diff --git a/tests/test.py b/docs/.gitkeep similarity index 100% rename from tests/test.py rename to docs/.gitkeep diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 357adc0..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,4 +0,0 @@ -EarthReach -========== - -Welcome to the documentation for EarthReach. diff --git a/earthreach/version.py b/earthreach/version.py deleted file mode 100644 index b3c06d4..0000000 --- a/earthreach/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.0.1" \ No newline at end of file diff --git a/notebooks/earthkit_plots_exploration.py b/notebooks/earthkit_plots_exploration.py new file mode 100644 index 0000000..560f80c --- /dev/null +++ b/notebooks/earthkit_plots_exploration.py @@ -0,0 +1,214 @@ +# --- +# jupyter: +# jupytext: +# cell_metadata_filter: -all +# formats: ipynb,py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.16.7 +# kernelspec: +# display_name: .venv +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Earthkit Plots Exploration + +# %% [markdown] +# ## Configuration & Imports + +# %% +import base64 +import os +import warnings +from io import BytesIO +from pathlib import Path +from typing import Literal + +import earthkit as ek +import matplotlib.pyplot as plt +import openai +import PIL +from dotenv import load_dotenv +from IPython.display import Markdown, display +from PIL.Image import Image + +load_dotenv(Path().cwd().parent / ".env") + +warnings.filterwarnings("ignore", category=UserWarning) + + +# %% +def display_markdown(text: str) -> None: + """ + Display a string as Markdown in a Jupyter notebook. + + Args: + text (str): The text to display as Markdown. + """ + display(Markdown(text)) + + +def img_to_base64(image_path: str | None = None, img: Image | None = None) -> str: + """ + Convert an image to a base64 string. + + Args: + image_path (str): The path to the image file. Either this or img must be provided. + img (PIL.Image): The image object. Either this or image_path must be provided. + + Returns: + str: The base64 string representation of the image. + """ + if image_path is None and img is None: + raise ValueError("Either image_path or img must be provided.") + + if img is not None: + bytes_io = BytesIO() + img.save(bytes_io, format="PNG") + return base64.b64encode(bytes_io.getvalue()).decode("utf-8") + + with open(image_path, "rb") as img_file: + return base64.b64encode(img_file.read()).decode("utf-8") + + +# FIX(high): Can't save earthkit-plots figures to a file +def earthkit_fig_to_pil(figure, format: Literal["JPEG", "PNG"] = "JPEG") -> PIL.Image: + """ + Convert an earthkit-plots figure to a PIL Image + + Args: + figure: earthkit-plots figure object + + Returns: + PIL Image object + """ + figure._release_queue() + + fig = plt.gcf() # matplotlib figure + fig.canvas.draw() + + buf = BytesIO() + fig.savefig(buf, format=format) + buf.seek(0) + + return PIL.Image.open(buf) + + +# %% [markdown] +# ## High-level API + +# %% +data = ek.data.from_source("sample", "era5-2t-msl-1985122512.grib") +data.ls() + +# %% +figure = ek.plots.quickplot(data, domain=["France", "Greece"], mode="overlay") +figure.show() + +# %% [markdown] +# Observations: +# - Geographic information is represented almost purely visually (there are latitudes and longitudes information on the axes but they are hardly interpretable), so a model should recognize the domains, or have access to them in the forms of variable valuess, default being the whole world +# - Pressure values are only represented as visual isobars, which might be the information the hardest to understand visually. A better approach could be to use the data values. +# - Temperature values are visually represented with a gradient of colors, and a clear legend. I think it would be quite easily interpreted by a model. +# - Title provides important context about the information represented and metadata such as the date. It should probably be transmitted to an end user. It can be accessed visually but also through the figure attributes. +# - In the end, the goal of interpreting this type of images could be phrased as "understanding the spatial distribution of meteorological variables." + +# %% [markdown] +# ## Trying a Simple VLLM Summary Generation + +# %% +assert os.environ.get("GROQ_API_KEY", None), "GROQ_API_KEY is not set." +client = openai.OpenAI( + base_url="https://api.groq.com/openai/v1", + api_key=os.environ.get("GROQ_API_KEY"), # Using GROQ free API provider +) + +# %% +img_path = Path().cwd().parent / "data" / "temperature_pressure_france_greece.png" +base64_image = img_to_base64(image_path=img_path) + +response = client.chat.completions.create( + model="meta-llama/llama-4-maverick-17b-128e-instruct", # vision-enabled chat model + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{base64_image}", + }, + }, + ], + } + ], +) + +display_markdown(response.choices[0].message.content) + +# %% [markdown] +# Observations (from a very simple prompt): +# - Good structured and objective documentation of visual elements +# - Can accurately identify technical components of scientific visualizations +# - Remains cautious about making interpretive claims beyond what's explicitly shown +# - Prioritizes factual accuracy over speculative analysis +# +# These first observations are promising. We now need to try a generation with a more specialized prompt, including best prompt-engineering techniques from the start. + +# %% +# TODO: add the method (COT), response format, examples. The model boasts a huge context window so we should take advantage of it. +prompt = """# Instructions for Describing Meteorological Visualizations for Blind Scientists + +Your task is to create a comprehensive, scientifically accurate description of meteorological visualizations that will be accessible to blind scientists. Follow these detailed guidelines: + +## 1. Structural Organization +- Begin with a concise overview (2-3 sentences) stating the visualization type, geographic region, time period, and primary variables displayed. +- Organize your description hierarchically from most to least scientifically significant features. +- Structure your description in clearly defined sections with standardized headings. + +## 2. Scientific Content Requirements +- Use precise meteorological terminology consistent with scientific publications. +- Always include quantitative values with appropriate units (hPa for pressure, K/°C for temperature). +- Describe patterns and gradients rather than individual data points. +- Explicitly state the range of values shown for each variable. +- Identify and describe major meteorological features (high/low pressure systems, fronts, temperature gradients). +- Include geographic context using cardinal directions and recognized regional references. + +## 3. Spatial Relationship Guidelines +- Systematically describe the geographic distribution of data using cardinal directions (N, S, E, W, NE, etc.). +- Reference latitude and longitude coordinates when describing specific features. +- Describe gradients and transitions using directional language (e.g., "increasing from south to north"). +- Use recognized geographic features (countries, seas, mountain ranges) as reference points. + +## 4. Pattern Description Protocol +- Identify dominant patterns for each variable (e.g., pressure systems, temperature fronts). +- Describe the spatial relationships between different variables (e.g., how temperature aligns with pressure systems). +- Communicate the intensity of gradients, using terms like "sharp gradient," "gentle slope," or "uniform distribution." +- Note any anomalies or unusual features in the data pattern. + +## 5. Technical Elements +- Describe the scale and units prominently displayed in the visualization. +- Explain the visualization technique used for each variable (e.g., color gradient for temperature, contour lines for pressure). +- Note the resolution and any apparent limitations of the data. + +## 6. Scientific Context +- Include relevant seasonal context (e.g., winter conditions for December visualization). +- Provide brief meteorological context for the significance of the date, if applicable. +- Relate the patterns shown to typical or expected meteorological conditions for the region and season. + +## 7. Accessibility-Specific Considerations +- Use clear, unambiguous language that doesn't rely on visual references. +- Avoid phrases like "as you can see" or "looking at the map." +- Use directional and relative terms that don't require visual understanding. +- Ensure all information conveyed by color is also expressed verbally in terms of values and patterns. + +## 8. Language Style +- Maintain a formal, scientific tone throughout. +- Use precise, concise language without unnecessary elaboration. +- Be objective in descriptions, clearly differentiating between observed data and interpretations. +""" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..83c8db3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "earthreach-agent-challenge" +version = "0.1.0" +description = "EarthReach Agent: Dual-LLM Framework for Validated Meteorological Chart Descriptions" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "earthkit>=0.10.3", + "earthkit-data>=0.13.8", + "earthkit-plots>=0.3.1", + "openai>=1.77.0", + "python-dotenv>=1.1.0", + "hatchling>=1.27.0", +] + +[dependency-groups] +dev = [ + "jupytext>=1.17.1", + "ipykernel>=6.29.5", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/earthreach-agent"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f778c93..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -setuptools>=71.0.0 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 4830099..0000000 --- a/setup.py +++ /dev/null @@ -1,23 +0,0 @@ -import io -import os -import re - -from setuptools import setup - -__version__ = re.search( - r'__version__\s*=\s*[\'"]([^\'"]*)[\'"]', - io.open("earthreach/version.py", encoding="utf_8_sig").read(), -).group(1) - - -here = os.path.abspath(os.path.dirname(__file__)) -with open(os.path.join(here, "requirements.txt")) as f: - requirements = f.read().splitlines() - -setup( - name="earthreach", - version=__version__, - description="Alt text generation for earthkit-plots.", - include_package_data=True, - install_requires=requirements, -) \ No newline at end of file diff --git a/src/earthreach-agent/__init__.py b/src/earthreach-agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/earthreach-agent/config/__init__.py b/src/earthreach-agent/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/earthreach-agent/core/__init__.py b/src/earthreach-agent/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/earthreach-agent/core/prompts.py b/src/earthreach-agent/core/prompts.py new file mode 100644 index 0000000..6047638 --- /dev/null +++ b/src/earthreach-agent/core/prompts.py @@ -0,0 +1,50 @@ +system_prompt = """# Instructions for Describing Meteorological Visualizations for Blind Scientists + +Your task is to create a comprehensive, scientifically accurate description of meteorological visualizations that will be accessible to blind scientists. Follow these detailed guidelines: + +## 1. Structural Organization +- Begin with a concise overview (2-3 sentences) stating the visualization type, geographic region, time period, and primary variables displayed. +- Organize your description hierarchically from most to least scientifically significant features. +- Structure your description in clearly defined sections with standardized headings. + +## 2. Scientific Content Requirements +- Use precise meteorological terminology consistent with scientific publications. +- Always include quantitative values with appropriate units (hPa for pressure, K/°C for temperature). +- Describe patterns and gradients rather than individual data points. +- Explicitly state the range of values shown for each variable. +- Identify and describe major meteorological features (high/low pressure systems, fronts, temperature gradients). +- Include geographic context using cardinal directions and recognized regional references. + +## 3. Spatial Relationship Guidelines +- Systematically describe the geographic distribution of data using cardinal directions (N, S, E, W, NE, etc.). +- Reference latitude and longitude coordinates when describing specific features. +- Describe gradients and transitions using directional language (e.g., "increasing from south to north"). +- Use recognized geographic features (countries, seas, mountain ranges) as reference points. + +## 4. Pattern Description Protocol +- Identify dominant patterns for each variable (e.g., pressure systems, temperature fronts). +- Describe the spatial relationships between different variables (e.g., how temperature aligns with pressure systems). +- Communicate the intensity of gradients, using terms like "sharp gradient," "gentle slope," or "uniform distribution." +- Note any anomalies or unusual features in the data pattern. + +## 5. Technical Elements +- Describe the scale and units prominently displayed in the visualization. +- Explain the visualization technique used for each variable (e.g., color gradient for temperature, contour lines for pressure). +- Note the resolution and any apparent limitations of the data. + +## 6. Scientific Context +- Include relevant seasonal context (e.g., winter conditions for December visualization). +- Provide brief meteorological context for the significance of the date, if applicable. +- Relate the patterns shown to typical or expected meteorological conditions for the region and season. + +## 7. Accessibility-Specific Considerations +- Use clear, unambiguous language that doesn't rely on visual references. +- Avoid phrases like "as you can see" or "looking at the map." +- Use directional and relative terms that don't require visual understanding. +- Ensure all information conveyed by color is also expressed verbally in terms of values and patterns. + +## 8. Language Style +- Maintain a formal, scientific tone throughout. +- Use precise, concise language without unnecessary elaboration. +- Be objective in descriptions, clearly differentiating between observed data and interpretations. +""" diff --git a/src/tests/.gitkeep b/src/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f19396b --- /dev/null +++ b/uv.lock @@ -0,0 +1,2397 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "adjusttext" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/d4/6585f3b6fdb75648bca294664af4becc8aa2fb3fb08f4e4e9fd27e10d773/adjusttext-1.3.0.tar.gz", hash = "sha256:4ab75cd4453af4828876ac3e964f2c49be642ea834f0c1f7449558d5f12cbca1", size = 15724 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/1c/8feedd607cc14c5df9aef74fe3af9a99bf660743b842a9b5b1865326b4aa/adjustText-1.3.0-py3-none-any.whl", hash = "sha256:da23d7b24b6db5ffa039bb136bfa556207365e32f48ac74b07ad26dd485bc691", size = 13154 }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321 }, +] + +[[package]] +name = "argparse" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/dd/e617cfc3f6210ae183374cd9f6a26b20514bbb5a792af97949c5aacddf0f/argparse-1.4.0.tar.gz", hash = "sha256:62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4", size = 70508 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/94/3af39d34be01a24a6e65433d19e107099374224905f1e0cc6bbe1fd22a2f/argparse-1.4.0-py2.py3-none-any.whl", hash = "sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314", size = 23000 }, +] + +[[package]] +name = "array-api-compat" +version = "1.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/1e/d04312a19a67744298b7546885149488b8afbb965dfe693aa4964bb60586/array_api_compat-1.11.2.tar.gz", hash = "sha256:a3b7f7b6af18f4c42e79423b1b2479798998b6a74355069d77a01a5282755b5d", size = 50776 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d8/3388c7da49f522e51ab2f919797db28782216cadc9ecc9976160302cfcd6/array_api_compat-1.11.2-py3-none-any.whl", hash = "sha256:b1d0059714a4153b3ae37c989e47b07418f727be5b22908dd3cf9d19bdc2c547", size = 53149 }, +] + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + +[[package]] +name = "cartopy" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyproj" }, + { name = "pyshp" }, + { name = "shapely" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/75/94aff4fef338887641aa780d13795609861e6e9f9593bd66d4917ab7954b/cartopy-0.24.1.tar.gz", hash = "sha256:01c910d5634c69a7efdec46e0a17d473d2328767f001d4dc0b5c4b48e585c8bd", size = 10741277 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/76/774a4f808c6a4fc19b87c2cc38dd8731d413aad606689451c017ff93ad12/Cartopy-0.24.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a984e33977daed8f760c09c331c8368a6af060db1190af89d74a027c272e39c3", size = 10983939 }, + { url = "https://files.pythonhosted.org/packages/2f/48/8517d5d1cc56ce5c4abda1de6454593474a23412115a543f7981aa7e4377/Cartopy-0.24.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71d8a6d061d0764aba3baf357a68f3d73796a8a46d34b8c9fb241171b273c69e", size = 10972374 }, + { url = "https://files.pythonhosted.org/packages/c8/84/cb1577d5ac2f0deb002001c6e25b291735151c8c3033c97f212dc482ef72/Cartopy-0.24.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f354a1d902a8d6ee33b099acc86ac2e1af528bbc0ea718b834111c97e604981", size = 11715215 }, + { url = "https://files.pythonhosted.org/packages/11/95/40c7abae8789aae22ad2a5da3974d3270dc3526b46cee253f680f72ee6cc/Cartopy-0.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:b1bb2d02b31884ee1d4f14e5b436bbf95745eac39c6fc0d6c67c83bb907b55b3", size = 10959875 }, + { url = "https://files.pythonhosted.org/packages/e6/e8/38e00eb35743f22d4ee9bcb131ab273fb47ec39cc03ce5144686a3142756/Cartopy-0.24.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d279968b845f72e3423e454b2b0b985fb2389e6ccd18fb73324abeca4e43f516", size = 10982533 }, + { url = "https://files.pythonhosted.org/packages/66/60/cc852a0835a053db18085dec1d1dd6f9cedc764d1524bfe30fd8be5ee3a7/Cartopy-0.24.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f0963b80a048252815c56fbd21bc4e5d163618a9eaa36c8898ce2c60b6c03979", size = 10971183 }, + { url = "https://files.pythonhosted.org/packages/a3/5b/476c8f3a277d7c78e5a5318bd32f234b994bfdc5d7731ae84218f2fa8a8f/Cartopy-0.24.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfdde0a6e0e56c5fc46f4e7d332237eb31bbd9908417f0f190fda5d322754184", size = 11709060 }, + { url = "https://files.pythonhosted.org/packages/93/31/50bf07ba820c5aa5d4e674d72cdb5da90bbd012ba1b9c6c95c3f96afe233/Cartopy-0.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:b17cf23dd74d0a922c2a5682dacef3c0bf89fa8c0bd0eae96b87fb684f966b15", size = 10959830 }, +] + +[[package]] +name = "cdsapi" +version = "0.7.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "datapi" }, + { name = "requests" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/62/81b38105ef75486308179d510360c1aa251ce0b7d17fe88c0e1de05c6c94/cdsapi-0.7.5.tar.gz", hash = "sha256:55221c573b8cefe83cc0bfe01a3d31213c82bf9acce70455350dd24b8095c23a", size = 13188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/c4/49f01f1382d449581d5d3db0fa49ccc23a1b4f91d615108b631c7cff40cd/cdsapi-0.7.5-py2.py3-none-any.whl", hash = "sha256:8586b837aea89ceeae379b388fbb0ace0a19b94b221f731c65632417007f69fb", size = 12201 }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "cfgrib" +version = "0.9.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "eccodes" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/3b/0ccbbc67866a4a2df570d6bf0f53d6d22220c44e1f3684455b5eae298936/cfgrib-0.9.15.0.tar.gz", hash = "sha256:d455034e19b9560a75d008ba9d09b2d4e65762adfb2e911f28b841f4b9c6b47f", size = 6511752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/d7/96b4209c99f1fd6c19f502cebe8c91983c23331c380f3f521250f268ae8c/cfgrib-0.9.15.0-py3-none-any.whl", hash = "sha256:469cfd25dc173863795e596263b3b6b5ea1402b1715f2b7b1d4b995b40b32c18", size = 48908 }, +] + +[[package]] +name = "cftime" +version = "1.6.4.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/c8/1155d1d58003105307c7e5985f422ae5bcb2ca0cbc553cc828f3c5a934a7/cftime-1.6.4.post1.tar.gz", hash = "sha256:50ac76cc9f10ab7bd46e44a71c51a6927051b499b4407df4f29ab13d741b942f", size = 54631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/81/0bb28d54088a61592f61a11e7fcabcea6d261c47af79e18d0f9cbcd940ae/cftime-1.6.4.post1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a590f73506f4704ba5e154ef55bfbaed5e1b4ac170f3caeb8c58e4f2c619ee4e", size = 226615 }, + { url = "https://files.pythonhosted.org/packages/f3/1e/38dbbf8a828dfb5e0e6e5c912818b77aacf2e7bcb97b262ac6126beeb29f/cftime-1.6.4.post1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:933cb10e1af4e362e77f513e3eb92b34a688729ddbf938bbdfa5ac20a7f44ba0", size = 209193 }, + { url = "https://files.pythonhosted.org/packages/9b/60/0db884c76311ecaaf31f628aa9358beae5fcb0fbbdc2eb0b790a93aa258f/cftime-1.6.4.post1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf17a1b36f62e9e73c4c9363dd811e1bbf1170f5ac26d343fb26012ccf482908", size = 1320215 }, + { url = "https://files.pythonhosted.org/packages/8d/7d/2d5fc7af06da4f3bdea59a204f741bf7a30bc5019355991b2f083e557e4e/cftime-1.6.4.post1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e18021f421aa26527bad8688c1acf0c85fa72730beb6efce969c316743294f2", size = 1367426 }, + { url = "https://files.pythonhosted.org/packages/5d/ab/e8b26d05323fc5629356c82a7f64026248f121ea1361b49df441bbc8f2d7/cftime-1.6.4.post1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5835b9d622f9304d1c23a35603a0f068739f428d902860f25e6e7e5a1b7cd8ea", size = 1385593 }, + { url = "https://files.pythonhosted.org/packages/af/7b/ca72a075a3f660315b031d62d39a3e9cfef71f7929da2621d5120077a75f/cftime-1.6.4.post1-cp312-cp312-win_amd64.whl", hash = "sha256:7f50bf0d1b664924aaee636eb2933746b942417d1f8b82ab6c1f6e8ba0da6885", size = 178918 }, + { url = "https://files.pythonhosted.org/packages/da/d8/81f086dbdc6f5a4e0bb068263471f1d12861b72562fe8c18df38268e4e29/cftime-1.6.4.post1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5c89766ebf088c097832ea618c24ed5075331f0b7bf8e9c2d4144aefbf2f1850", size = 223418 }, + { url = "https://files.pythonhosted.org/packages/4a/cc/60a825d92a4023655e330470758280a31e7b82665ef77d0e2a0fe71ea958/cftime-1.6.4.post1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f27113f7ccd1ca32881fdcb9a4bec806a5f54ae621fc1c374f1171f3ed98ef2", size = 207395 }, + { url = "https://files.pythonhosted.org/packages/ca/90/f5b26949899decce262fc76a1e64915b92050473114e0160cd6f7297f854/cftime-1.6.4.post1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da367b23eea7cf4df071c88e014a1600d6c5bbf22e3393a4af409903fa397e28", size = 1318113 }, + { url = "https://files.pythonhosted.org/packages/c3/f8/6f13d37abb7ade46e65a08acc31af776a96dde0eb569e05d4c4b01422ba6/cftime-1.6.4.post1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6579c5c83cdf09d73aa94c7bc34925edd93c5f2c7dd28e074f568f7e376271a0", size = 1366034 }, + { url = "https://files.pythonhosted.org/packages/fa/08/335cb17f3b708f9a24f96ca4abb00889c7aa20b0ae273313e7c11faf1f97/cftime-1.6.4.post1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6b731c7133d17b479ca0c3c46a7a04f96197f0a4d753f4c2284c3ff0447279b4", size = 1390156 }, + { url = "https://files.pythonhosted.org/packages/f3/2d/980323fb5ec1ef369604b61ba259a41d0336cc1a85b639ed7bd210bd1290/cftime-1.6.4.post1-cp313-cp313-win_amd64.whl", hash = "sha256:d2a8c223faea7f1248ab469cc0d7795dd46f2a423789038f439fee7190bae259", size = 178496 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936 }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790 }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924 }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626 }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567 }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957 }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408 }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399 }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815 }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537 }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565 }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/39/069100b84d7418bc358d81669d5748efb14b9cceacd2f9c75f550424132f/cloudpickle-3.1.1.tar.gz", hash = "sha256:b216fa8ae4019d5482a8ac3c95d8f6346115d8835911fd4aefd1a445e4242c64", size = 22113 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/e8/64c37fadfc2816a7701fa8a6ed8d87327c7d54eacfbfb6edab14a2f2be75/cloudpickle-3.1.1-py3-none-any.whl", hash = "sha256:c8c5a44295039331ee9dad40ba100a9c7297b6f988e50e87ccdf3765a668350e", size = 20992 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "comm" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/a8/fb783cb0abe2b5fded9f55e5703015cdf1c9c85b3669087c538dd15a6a86/comm-0.2.2.tar.gz", hash = "sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e", size = 6210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180 }, +] + +[[package]] +name = "conflator" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argparse" }, + { name = "pydantic" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "rich-argparse" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/8d/aedf06fad74b5444c55e88d1161712c1319324e312722d72991435d2be78/conflator-0.1.7.tar.gz", hash = "sha256:114d7f85f448561ab8288e5f907ce79ed1ad1cb35735eccc087f9e2f1d160bd7", size = 9765 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/11/1a4cdd696949d77990224a9da68dbed3a6bda2ed82050e6eff55a1886f32/conflator-0.1.7-py3-none-any.whl", hash = "sha256:fce3a648e540d55cf80bdbc893b9176b034d4dd06fd3480891244ee56f177d08", size = 10769 }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580 }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530 }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688 }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331 }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963 }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681 }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674 }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480 }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489 }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042 }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630 }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670 }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694 }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986 }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060 }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747 }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895 }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098 }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535 }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096 }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090 }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643 }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443 }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865 }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162 }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355 }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935 }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168 }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550 }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214 }, +] + +[[package]] +name = "covjson-pydantic" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/47/e31f9f6388d396436ede3ec0c267dd6ac1bee06e8230e75bec391b7a7cb9/covjson_pydantic-0.6.0.tar.gz", hash = "sha256:a6ffeffbbe98b127ea021ab320938c7ca47ea899ffa58e80a78897f681357c07", size = 21244 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/1d/8460293af172800e6d2a11013876e196910d8f658636355d5ea4a2751a57/covjson_pydantic-0.6.0-py3-none-any.whl", hash = "sha256:ead99de37875db60356980cf7a079ceab9a42c27ba70b72b5675396f6865a312", size = 14112 }, +] + +[[package]] +name = "covjsonkit" +version = "0.1.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "conflator" }, + { name = "covjson-pydantic" }, + { name = "orjson" }, + { name = "pandas" }, + { name = "xarray" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/0a/b66a2bf9575fe09a07401f5c41ce4cf68b05e9cae3ebf2e820933d09744a/covjsonkit-0.1.11.tar.gz", hash = "sha256:51b4d753f5ad6f80d38cb6164f8a30a75157d1a337e693ae59eee91bb4141395", size = 506192 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/03/3bf7c3646135433a2e7de2866132d15e08594f44b9c21c443adb735adf43/covjsonkit-0.1.11-py3-none-any.whl", hash = "sha256:774a8fead55342c0f4435b2afb0d210dc49fbfe3da972811903ad7b28beca8d1", size = 477387 }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 }, +] + +[[package]] +name = "dask" +version = "2025.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "cloudpickle" }, + { name = "fsspec" }, + { name = "packaging" }, + { name = "partd" }, + { name = "pyyaml" }, + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/62/07d1dadcfa41c9f5584aa1ab10f4042d2cdb2c6655b123800d9f30185b71/dask-2025.4.1.tar.gz", hash = "sha256:3b4b5d6e29d858c48339a5b9a99c39f11cb44111d3836d77ff32da51e0f51243", size = 10963890 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/12/f9effea5fe2bebfdd8b0d9c857f798382afacd57dc1cd0e9ce21e66c1bc2/dask-2025.4.1-py3-none-any.whl", hash = "sha256:aacbb0a9667856fe58385015efd64aca22f0c0b2c5e1b5e633531060303bb4be", size = 1471761 }, +] + +[[package]] +name = "datapi" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "multiurl" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/0d/7c39dea40ce986c29f1c6ecfe84026895c924572c8f37329a720d2f8e280/datapi-0.4.0.tar.gz", hash = "sha256:6355544b01a51192a87016368527fc7e55429f546ff786c6042a78876d3c497f", size = 46341 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/44/ccd9cce6d204c840abba87089d230334b84582d7bd109a6e1ded701baaa3/datapi-0.4.0-py3-none-any.whl", hash = "sha256:092a1d84b7232f2925952d64e4ee704d27555c884d5f3acf22610eacb28cccf0", size = 28495 }, +] + +[[package]] +name = "debugpy" +version = "1.8.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/75/087fe07d40f490a78782ff3b0a30e3968936854105487decdb33446d4b0e/debugpy-1.8.14.tar.gz", hash = "sha256:7cd287184318416850aa8b60ac90105837bb1e59531898c07569d197d2ed5322", size = 1641444 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/2a/ac2df0eda4898f29c46eb6713a5148e6f8b2b389c8ec9e425a4a1d67bf07/debugpy-1.8.14-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:8899c17920d089cfa23e6005ad9f22582fd86f144b23acb9feeda59e84405b84", size = 2501268 }, + { url = "https://files.pythonhosted.org/packages/10/53/0a0cb5d79dd9f7039169f8bf94a144ad3efa52cc519940b3b7dde23bcb89/debugpy-1.8.14-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6bb5c0dcf80ad5dbc7b7d6eac484e2af34bdacdf81df09b6a3e62792b722826", size = 4221077 }, + { url = "https://files.pythonhosted.org/packages/f8/d5/84e01821f362327bf4828728aa31e907a2eca7c78cd7c6ec062780d249f8/debugpy-1.8.14-cp312-cp312-win32.whl", hash = "sha256:281d44d248a0e1791ad0eafdbbd2912ff0de9eec48022a5bfbc332957487ed3f", size = 5255127 }, + { url = "https://files.pythonhosted.org/packages/33/16/1ed929d812c758295cac7f9cf3dab5c73439c83d9091f2d91871e648093e/debugpy-1.8.14-cp312-cp312-win_amd64.whl", hash = "sha256:5aa56ef8538893e4502a7d79047fe39b1dae08d9ae257074c6464a7b290b806f", size = 5297249 }, + { url = "https://files.pythonhosted.org/packages/4d/e4/395c792b243f2367d84202dc33689aa3d910fb9826a7491ba20fc9e261f5/debugpy-1.8.14-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:329a15d0660ee09fec6786acdb6e0443d595f64f5d096fc3e3ccf09a4259033f", size = 2485676 }, + { url = "https://files.pythonhosted.org/packages/ba/f1/6f2ee3f991327ad9e4c2f8b82611a467052a0fb0e247390192580e89f7ff/debugpy-1.8.14-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f920c7f9af409d90f5fd26e313e119d908b0dd2952c2393cd3247a462331f15", size = 4217514 }, + { url = "https://files.pythonhosted.org/packages/79/28/b9d146f8f2dc535c236ee09ad3e5ac899adb39d7a19b49f03ac95d216beb/debugpy-1.8.14-cp313-cp313-win32.whl", hash = "sha256:3784ec6e8600c66cbdd4ca2726c72d8ca781e94bce2f396cc606d458146f8f4e", size = 5254756 }, + { url = "https://files.pythonhosted.org/packages/e0/62/a7b4a57013eac4ccaef6977966e6bec5c63906dd25a86e35f155952e29a1/debugpy-1.8.14-cp313-cp313-win_amd64.whl", hash = "sha256:684eaf43c95a3ec39a96f1f5195a7ff3d4144e4a18d69bb66beeb1a6de605d6e", size = 5297119 }, + { url = "https://files.pythonhosted.org/packages/97/1a/481f33c37ee3ac8040d3d51fc4c4e4e7e61cb08b8bc8971d6032acc2279f/debugpy-1.8.14-py2.py3-none-any.whl", hash = "sha256:5cd9a579d553b6cb9759a7908a41988ee6280b961f24f63336835d9418216a20", size = 5256230 }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, +] + +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178 }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, +] + +[[package]] +name = "docstring-parser" +version = "0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/12/9c22a58c0b1e29271051222d8906257616da84135af9ed167c9e28f85cb3/docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e", size = 26565 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533 }, +] + +[[package]] +name = "earthkit" +version = "0.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "earthkit-data", extra = ["all"] }, + { name = "earthkit-geo" }, + { name = "earthkit-hydro" }, + { name = "earthkit-meteo" }, + { name = "earthkit-plots" }, + { name = "earthkit-regrid" }, + { name = "earthkit-time" }, + { name = "earthkit-transforms" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/b2/9fa85be0c7b5a35d3703ca9a3e329a3d706d944e418cdb24ea3dc41d654c/earthkit-0.10.3.tar.gz", hash = "sha256:33f4d4f5d93be9a1370e3ec8a4996a53a41b2dc94e3ef8862e1b4017695b6813", size = 571655 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/37/39630e1cc681a81e88e9529e7642cc5e515efff697b09ed136c6660c5faa/earthkit-0.10.3-py3-none-any.whl", hash = "sha256:caa98d5caf7d401547b36fa96d87809c25704e522eeee4133b00ef42c7b7f536", size = 8116 }, +] + +[[package]] +name = "earthkit-data" +version = "0.13.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "array-api-compat" }, + { name = "cfgrib" }, + { name = "dask" }, + { name = "deprecation" }, + { name = "earthkit-meteo" }, + { name = "eccodes" }, + { name = "entrypoints" }, + { name = "filelock" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "lru-dict" }, + { name = "markdown" }, + { name = "multiurl" }, + { name = "netcdf4" }, + { name = "pandas" }, + { name = "pdbufr" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "xarray" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/54/0a67beed0f5d2a0f632065354061980f4efe312e065f2b15a335d1a2e68a/earthkit_data-0.13.8.tar.gz", hash = "sha256:092c8e30d2ca85b37216ec191a0a42cf4d9405e26eb685b9ab33efc25b833562", size = 4811480 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/af/954ca2336016f7d35ca0665c0bb7e2f9feed2cd2c35357bf9fb92696ea88/earthkit_data-0.13.8-py3-none-any.whl", hash = "sha256:f5ce6b5b07ee53d21f7d1b87179d6a7ebd228a427df615f9b58ede8f31278dc5", size = 363442 }, +] + +[package.optional-dependencies] +all = [ + { name = "cartopy" }, + { name = "cdsapi" }, + { name = "covjsonkit" }, + { name = "earthkit-geo" }, + { name = "ecmwf-api-client" }, + { name = "ecmwf-opendata" }, + { name = "geopandas" }, + { name = "hda" }, + { name = "polytope-client" }, + { name = "pyfdb" }, + { name = "pyodc" }, +] + +[[package]] +name = "earthkit-geo" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cartopy" }, + { name = "pyproj" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/5c/74aba1621533e696c0f6c74b65ed9c80c54895930ca139d5342e8f4e4542/earthkit_geo-0.3.0.tar.gz", hash = "sha256:9f713c670c2b7670f0401525e0eb26829d1fb0d4911e7f4ed3a1f17dc53bb464", size = 639109 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/84/cbe3a0786a4fc102b8a844ebc2f67237c25a568b451b74c1ddcb912983d6/earthkit_geo-0.3.0-py3-none-any.whl", hash = "sha256:4ea310b66f734a6dea29d8f44031fefc7bfa75de446409e0c7c8ca318c500659", size = 17350 }, +] + +[[package]] +name = "earthkit-hydro" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/19/ac8251466bfd705b761418af24b90fff720b0e1e9a72b54edf2f113e0957/earthkit_hydro-0.1.3.tar.gz", hash = "sha256:5f77b86f40fb96ab2dfa2b29148ba290bba8b774567f9b456e05331eb6b061b1", size = 481543 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/24/1c30386b48fb0a6e56d6099bfb9249862fc790bf3993fe9d9977865cbfdd/earthkit_hydro-0.1.3-py3-none-any.whl", hash = "sha256:6cca9b8158472499b885341fe4121c8618669667b5d12183517af24482f65e7f", size = 32145 }, +] + +[[package]] +name = "earthkit-meteo" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/88/6dff619cac69f5eb5866742cc4ee6bb275977c962d39e6854e8d148917b1/earthkit_meteo-0.3.0.tar.gz", hash = "sha256:0164336934d5bf758b57a2c4914348fe90183994c4fa33ddb48996b0527c1204", size = 334451 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/6c/b809322ae3405803e5957fa4ea495955689a300a107f0727c70f91f29865/earthkit_meteo-0.3.0-py3-none-any.whl", hash = "sha256:2aec3ff6a505dcb129fbbafc7b20d7868420c82eb7e7229c7d7679a87b2efcd4", size = 44550 }, +] + +[[package]] +name = "earthkit-plots" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adjusttext" }, + { name = "cartopy" }, + { name = "earthkit-data" }, + { name = "earthkit-plots-default-styles" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pint" }, + { name = "plotly" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/83/756d882fc9fe1a52a5d45e14b36355900d0d2392d36b93f91923d885ab0e/earthkit_plots-0.3.1.tar.gz", hash = "sha256:938cc57246aff71b004f7871c4d7bc2f301b4120809d4936c6dcbf339e314eed", size = 46439720 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/22/b0dc62ed4f2108975efe53ce45a60856e8be3d31bde3a7f85ca05f9f290f/earthkit_plots-0.3.1-py3-none-any.whl", hash = "sha256:35dc0e0d44c213109ae306d1449407f93db97f1d83f9cc408a35685f6f4c4b0c", size = 2643861 }, +] + +[[package]] +name = "earthkit-plots-default-styles" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6a31d8c6bd3f0e82aaa3e743591bc634308e85f3aaf71f88c8488899d511/earthkit_plots_default_styles-0.1.0.tar.gz", hash = "sha256:618a77b9dad37e624226001311e3619250efc044a254340b28ef043eb9e9a49a", size = 15993 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/c4/e17dd32f84f26ffc913a946116504d85bf90815bac62b31e35caddc24f3e/earthkit_plots_default_styles-0.1.0-py3-none-any.whl", hash = "sha256:8e4d57d2f55a269a44a90db11857da846ecb61c37350777fa7f1e1215837aacb", size = 18987 }, +] + +[[package]] +name = "earthkit-regrid" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "multiurl" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/4b/2472567db7e8d4b6e2b4d2f0f6da8c3846f1b7c0870138dfaae1d8cd8217/earthkit_regrid-0.4.0.tar.gz", hash = "sha256:779cfb89b2d7c2ae75dbb4572f72a5ec2052f4c649703924e4ef4be6aadf7dda", size = 582851 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/68/116626e164472e928bd849e13e89c1ed186cb1471e09f60f6d933d81ddcc/earthkit_regrid-0.4.0-py3-none-any.whl", hash = "sha256:1335b5da4869c732fda5dcf3c7b8c489385b7c32f7698b7e6bf5764a4e8ec710", size = 36892 }, +] + +[[package]] +name = "earthkit-time" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/fe/28373c9a6129999d7b3a39b5194628762ef1b9d0bf4e7d77c2f16c13ed16/earthkit_time-0.1.7.tar.gz", hash = "sha256:34da33c632078b371399a18f915415bcbea640e807257f567829a496d2311884", size = 31520 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/47/90c7faa92558870adb71b599a12663d8593712f0232a8a8a7a4909911d4a/earthkit_time-0.1.7-py3-none-any.whl", hash = "sha256:fb813444feccc62a7c53e0f6251fcb699c505a752fb1d6d8bff6ea2c5e1a358e", size = 27451 }, +] + +[[package]] +name = "earthkit-transforms" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "earthkit-data" }, + { name = "geopandas" }, + { name = "numpy" }, + { name = "xarray" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/6a/ba0501bb108d395fd0323c94e87dd488d03b31634911efe98a34fbaf1d60/earthkit_transforms-0.4.0.tar.gz", hash = "sha256:ab89f345d626999b3200c38457868664fba72acba164c78ab472cc1c0305e017", size = 1328074 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/f9/1ca7480e4a22d39d884c3fa855cb7fb8cf922c5cba203905efcdf6a67a42/earthkit_transforms-0.4.0-py3-none-any.whl", hash = "sha256:258df72cdbe3c662edab37226013a45c44a907a117afb64c8065eb49617ce2a7", size = 34480 }, +] + +[[package]] +name = "earthreach-agent-challenge" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "earthkit" }, + { name = "earthkit-data" }, + { name = "earthkit-plots" }, + { name = "hatchling" }, + { name = "openai" }, + { name = "python-dotenv" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ipykernel" }, + { name = "jupytext" }, +] + +[package.metadata] +requires-dist = [ + { name = "earthkit", specifier = ">=0.10.3" }, + { name = "earthkit-data", specifier = ">=0.13.8" }, + { name = "earthkit-plots", specifier = ">=0.3.1" }, + { name = "hatchling", specifier = ">=1.27.0" }, + { name = "openai", specifier = ">=1.77.0" }, + { name = "python-dotenv", specifier = ">=1.1.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "ipykernel", specifier = ">=6.29.5" }, + { name = "jupytext", specifier = ">=1.17.1" }, +] + +[[package]] +name = "eccodes" +version = "2.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi" }, + { name = "findlibs" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/c2/e2f8d98dea0b2d8d77c99edb72b5831286abfaf80d94edf13ad127f6979a/eccodes-2.41.0.tar.gz", hash = "sha256:f3e209f5da5a7fcee4942295db4ee7888e077bd2e0342e6170ec5fedb9b29840", size = 2268345 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/d5/7803aa1bbff4161b147c11cd6531d421a2ad38a0bb2fd29a7265fb369c3d/eccodes-2.41.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:56803ac38e5b50377054cb2b944c8982d6fcfca0c25b4d04fe1ef94ba81b202c", size = 6557422 }, + { url = "https://files.pythonhosted.org/packages/d1/17/bf8f714f5dd483d0da11515dbcb1b4f0992e900abef540c318a93b55edb9/eccodes-2.41.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:ae3a0f62a4b1107fe9f8362d58e4f452891ccbcc6aaecca5437724223f71a974", size = 6659630 }, + { url = "https://files.pythonhosted.org/packages/45/4a/7a45f8fc7d8f2047b023befd17155fa7d2d1274feda9796b1e69b68b7033/eccodes-2.41.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:bb0182f7ce3c907671860ab554430be053f37f227789a7a27b2d06118fe48c97", size = 7431350 }, + { url = "https://files.pythonhosted.org/packages/31/f7/f48a3ab347941243064060e3b0139aeb8d9414f1775f54239bed7fe66a29/eccodes-2.41.0-cp312-cp312-win_amd64.whl", hash = "sha256:81ca80d251be3fa66c42c020538cd67b12ed6e7c79e1e10299dc36dc07d28678", size = 6239631 }, + { url = "https://files.pythonhosted.org/packages/4d/fc/6d2c6125fad3f556d1099d0b7e761f995f3bfb9808e454e032879086f32c/eccodes-2.41.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:1c139783f385e0e102556f468fd089cfd6a27990711869aa73a589512533c8ba", size = 6557424 }, + { url = "https://files.pythonhosted.org/packages/d8/e2/8fb2abc5bc7f5d0d93fba3347c763415b4bfb53e99abb583e7f9b7fedf22/eccodes-2.41.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:d30637b18f75f654340f08a0e003b6aa1db28aa62cc041571a1c7529922a3922", size = 6659633 }, + { url = "https://files.pythonhosted.org/packages/4e/39/c91a1c27b2e4e761eaa491ae1ce7a56319720715629f73c055cbbd368484/eccodes-2.41.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e32c531e45ec818bcc9c0d7b2b6ff68d11f446f4c9b129e9cc8d31a23b0f401c", size = 7433837 }, + { url = "https://files.pythonhosted.org/packages/2b/f1/a12b3797ae231fe8fd1b8dabe8b31a3f23a1708eca8661da3a6d7a07da3d/eccodes-2.41.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a2d6dd911e146cfad69641dae0a90969b45a974a448c5279ab909237a2c6c08", size = 6239632 }, + { url = "https://files.pythonhosted.org/packages/bd/42/ac29e37149f36807e8f979707f5ae0d466d4a2c4b340597e2177809a016b/eccodes-2.41.0-py3-none-any.whl", hash = "sha256:f3f4444757aac6a249cc47947dee5660309d48854ebfc5e6ca8515374398e1bf", size = 44012 }, +] + +[[package]] +name = "eckitlib" +version = "1.29.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/a7/b02b381af4a6a34bbb3f3276b0abfb6588731eead4fcf896ca05dd24d585/eckitlib-1.29.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:023f456bca735f0503ddd6ffb2ec243c5b31c2f68d1b2388e51e112df138bdc0", size = 2891142 }, + { url = "https://files.pythonhosted.org/packages/93/98/43c02e4cdf14406b61f8ac2a5504102bda698988ae5352c9936dfb55f2da/eckitlib-1.29.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:743f9e4a868200c543de109d9102df10579826aeb3b0c5b977ee4dce69a2d6a7", size = 2995573 }, + { url = "https://files.pythonhosted.org/packages/a2/10/304bc84112c7f63196134d021f6ecfce500a53541eadd9800d3d99428746/eckitlib-1.29.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4095630bd972793c4d52156f64c3639730e8f092707a73ab3b6f22866fcc5839", size = 43874148 }, + { url = "https://files.pythonhosted.org/packages/b8/f1/8a33b581ece690742e55ffa3360e4232d0f92be8569e95db23ffb45ac281/eckitlib-1.29.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:bd8b224ea3c3f8740206581b3edc7a0a1d13ecd220f536a4f90babf5cd01bd8f", size = 2891129 }, + { url = "https://files.pythonhosted.org/packages/57/c1/6bd0058d2f0e026b8f04f26994bcca6b68c046969a9c842f0d7d2307d4bd/eckitlib-1.29.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:f91c6c4e77538b3ff51a687f3c6d1b894255bae8f007226e8bf2eb3d98d49932", size = 2995530 }, + { url = "https://files.pythonhosted.org/packages/6e/45/eabca21a5854537786565cc9c99f8285e6152fd40a39adfba3a4aa679374/eckitlib-1.29.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8a7e09e2b7e43df97a4ba8558ba54193b1c5f075def27e97f932c3c2e065c542", size = 43874147 }, +] + +[[package]] +name = "ecmwf-api-client" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b5/837fa2daee28118c592cd003b910433894008d5a1f9e3bed9f2c3cabe34b/ecmwf_api_client-1.6.5.tar.gz", hash = "sha256:1fc0eba27163686b7fba5da027cc5dc0a361f75b652508fa7805f50599368b1c", size = 12974 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/f3/7a5832d5fb8e16db4d1f3be58fcb2537e68ccffea1270bff8b65de7c85b5/ecmwf_api_client-1.6.5-py3-none-any.whl", hash = "sha256:82678c2c18083b44be74b23e2a7e82d2343061966c0bf3a895272b9e9a802836", size = 13495 }, +] + +[[package]] +name = "ecmwf-opendata" +version = "0.3.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "multiurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/4a/689e17773fb66cf323394dd82670cdaf402f86dfc655f6597a4b843c0c3b/ecmwf_opendata-0.3.19.tar.gz", hash = "sha256:90ab69d07e2d73a6ddd1789050aeeef1d6f44fe568ef4ab61a41c676fe840ce8", size = 26163 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/2c/2079f54572b83d0c1fe5a8f7d67bd1438280380a8fec101adcf261c1f1f9/ecmwf_opendata-0.3.19-py3-none-any.whl", hash = "sha256:513364162fa53beed513e0fa140c33326e66778bd19b4b71c10c8ce70339edd8", size = 20067 }, +] + +[[package]] +name = "entrypoints" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/8d/a7121ffe5f402dc015277d2d31eb82d2187334503a011c18f2e78ecbb9b2/entrypoints-0.4.tar.gz", hash = "sha256:b706eddaa9218a19ebcd67b56818f05bb27589b1ca9e8d797b74affad4ccacd4", size = 13974 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/a8/365059bbcd4572cbc41de17fd5b682be5868b218c3c5479071865cab9078/entrypoints-0.4-py3-none-any.whl", hash = "sha256:f174b5ff827504fd3cd97cc3f8649f3693f51538c7e4bdf3ef002c8429d42f9f", size = 5294 }, +] + +[[package]] +name = "executing" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/50/4b769ce1ac4071a1ef6d86b1a3fb56cdc3a37615e8c5519e1af96cdac366/fastjsonschema-2.21.1.tar.gz", hash = "sha256:794d4f0a58f848961ba16af7b9c85a3e88cd360df008c59aac6fc5ae9323b5d4", size = 373939 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/2b/0817a2b257fe88725c25589d89aec060581aabf668707a8d03b2e9e0cb2a/fastjsonschema-2.21.1-py3-none-any.whl", hash = "sha256:c9e5b7e908310918cf494a434eeb31384dd84a98b57a30bcb1f535015b554667", size = 23924 }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, +] + +[[package]] +name = "findlibs" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/66/4ed4652c0f91dceb13403192793caaf1d58c28296b1f5af5efaea63e2616/findlibs-0.1.1.tar.gz", hash = "sha256:dc7a0d4fe2bb6635f1e4bdcde60aecec42b6755c4783c2f629069c2f9321d732", size = 11284 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/f8/945427d587d3d3ec6dea3297f5a5b6b1cd4c5e126d0638cb148d3c360487/findlibs-0.1.1-py3-none-any.whl", hash = "sha256:1b611a040d41e491049568e530c7e032f5c2be8f582386961b8d14a6936406a9", size = 10713 }, +] + +[[package]] +name = "flexcache" +version = "0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/b0/8a21e330561c65653d010ef112bf38f60890051d244ede197ddaa08e50c1/flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", size = 15816 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32", size = 13263 }, +] + +[[package]] +name = "flexparser" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/99/b4de7e39e8eaf8207ba1a8fa2241dd98b2ba72ae6e16960d8351736d8702/flexparser-0.4.tar.gz", hash = "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", size = 31799 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625 }, +] + +[[package]] +name = "fonttools" +version = "4.57.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/2d/a9a0b6e3a0cf6bd502e64fc16d894269011930cabfc89aee20d1635b1441/fonttools-4.57.0.tar.gz", hash = "sha256:727ece10e065be2f9dd239d15dd5d60a66e17eac11aea47d447f9f03fdbc42de", size = 3492448 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/98/d4bc42d43392982eecaaca117d79845734d675219680cd43070bb001bc1f/fonttools-4.57.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:889e45e976c74abc7256d3064aa7c1295aa283c6bb19810b9f8b604dfe5c7f31", size = 2751824 }, + { url = "https://files.pythonhosted.org/packages/1a/62/7168030eeca3742fecf45f31e63b5ef48969fa230a672216b805f1d61548/fonttools-4.57.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0425c2e052a5f1516c94e5855dbda706ae5a768631e9fcc34e57d074d1b65b92", size = 2283072 }, + { url = "https://files.pythonhosted.org/packages/5d/82/121a26d9646f0986ddb35fbbaf58ef791c25b59ecb63ffea2aab0099044f/fonttools-4.57.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44c26a311be2ac130f40a96769264809d3b0cb297518669db437d1cc82974888", size = 4788020 }, + { url = "https://files.pythonhosted.org/packages/5b/26/e0f2fb662e022d565bbe280a3cfe6dafdaabf58889ff86fdef2d31ff1dde/fonttools-4.57.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84c41ba992df5b8d680b89fd84c6a1f2aca2b9f1ae8a67400c8930cd4ea115f6", size = 4859096 }, + { url = "https://files.pythonhosted.org/packages/9e/44/9075e323347b1891cdece4b3f10a3b84a8f4c42a7684077429d9ce842056/fonttools-4.57.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ea1e9e43ca56b0c12440a7c689b1350066595bebcaa83baad05b8b2675129d98", size = 4964356 }, + { url = "https://files.pythonhosted.org/packages/48/28/caa8df32743462fb966be6de6a79d7f30393859636d7732e82efa09fbbb4/fonttools-4.57.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:84fd56c78d431606332a0627c16e2a63d243d0d8b05521257d77c6529abe14d8", size = 5226546 }, + { url = "https://files.pythonhosted.org/packages/f6/46/95ab0f0d2e33c5b1a4fc1c0efe5e286ba9359602c0a9907adb1faca44175/fonttools-4.57.0-cp312-cp312-win32.whl", hash = "sha256:f4376819c1c778d59e0a31db5dc6ede854e9edf28bbfa5b756604727f7f800ac", size = 2146776 }, + { url = "https://files.pythonhosted.org/packages/06/5d/1be5424bb305880e1113631f49a55ea7c7da3a5fe02608ca7c16a03a21da/fonttools-4.57.0-cp312-cp312-win_amd64.whl", hash = "sha256:57e30241524879ea10cdf79c737037221f77cc126a8cdc8ff2c94d4a522504b9", size = 2193956 }, + { url = "https://files.pythonhosted.org/packages/e9/2f/11439f3af51e4bb75ac9598c29f8601aa501902dcedf034bdc41f47dd799/fonttools-4.57.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:408ce299696012d503b714778d89aa476f032414ae57e57b42e4b92363e0b8ef", size = 2739175 }, + { url = "https://files.pythonhosted.org/packages/25/52/677b55a4c0972dc3820c8dba20a29c358197a78229daa2ea219fdb19e5d5/fonttools-4.57.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bbceffc80aa02d9e8b99f2a7491ed8c4a783b2fc4020119dc405ca14fb5c758c", size = 2276583 }, + { url = "https://files.pythonhosted.org/packages/64/79/184555f8fa77b827b9460a4acdbbc0b5952bb6915332b84c615c3a236826/fonttools-4.57.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f022601f3ee9e1f6658ed6d184ce27fa5216cee5b82d279e0f0bde5deebece72", size = 4766437 }, + { url = "https://files.pythonhosted.org/packages/f8/ad/c25116352f456c0d1287545a7aa24e98987b6d99c5b0456c4bd14321f20f/fonttools-4.57.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dea5893b58d4637ffa925536462ba626f8a1b9ffbe2f5c272cdf2c6ebadb817", size = 4838431 }, + { url = "https://files.pythonhosted.org/packages/53/ae/398b2a833897297797a44f519c9af911c2136eb7aa27d3f1352c6d1129fa/fonttools-4.57.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dff02c5c8423a657c550b48231d0a48d7e2b2e131088e55983cfe74ccc2c7cc9", size = 4951011 }, + { url = "https://files.pythonhosted.org/packages/b7/5d/7cb31c4bc9ffb9a2bbe8b08f8f53bad94aeb158efad75da645b40b62cb73/fonttools-4.57.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:767604f244dc17c68d3e2dbf98e038d11a18abc078f2d0f84b6c24571d9c0b13", size = 5205679 }, + { url = "https://files.pythonhosted.org/packages/4c/e4/6934513ec2c4d3d69ca1bc3bd34d5c69dafcbf68c15388dd3bb062daf345/fonttools-4.57.0-cp313-cp313-win32.whl", hash = "sha256:8e2e12d0d862f43d51e5afb8b9751c77e6bec7d2dc00aad80641364e9df5b199", size = 2144833 }, + { url = "https://files.pythonhosted.org/packages/c4/0d/2177b7fdd23d017bcfb702fd41e47d4573766b9114da2fddbac20dcc4957/fonttools-4.57.0-cp313-cp313-win_amd64.whl", hash = "sha256:f1d6bc9c23356908db712d282acb3eebd4ae5ec6d8b696aa40342b1d84f8e9e3", size = 2190799 }, + { url = "https://files.pythonhosted.org/packages/90/27/45f8957c3132917f91aaa56b700bcfc2396be1253f685bd5c68529b6f610/fonttools-4.57.0-py3-none-any.whl", hash = "sha256:3122c604a675513c68bd24c6a8f9091f1c2376d18e8f5fe5a101746c81b3e98f", size = 1093605 }, +] + +[[package]] +name = "fsspec" +version = "2025.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/d8/8425e6ba5fcec61a1d16e41b1b71d2bf9344f1fe48012c2b48b9620feae5/fsspec-2025.3.2.tar.gz", hash = "sha256:e52c77ef398680bbd6a98c0e628fbc469491282981209907bbc8aea76a04fdc6", size = 299281 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/4b/e0cfc1a6f17e990f3e64b7d941ddc4acdc7b19d6edd51abf495f32b1a9e4/fsspec-2025.3.2-py3-none-any.whl", hash = "sha256:2daf8dc3d1dfa65b6aa37748d112773a7a08416f6c70d96b264c96476ecaf711", size = 194435 }, +] + +[[package]] +name = "geopandas" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pyogrio" }, + { name = "pyproj" }, + { name = "shapely" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/08/2cf5d85356e45b10b8d066cf4c3ba1e9e3185423c48104eed87e8afd0455/geopandas-1.0.1.tar.gz", hash = "sha256:b8bf70a5534588205b7a56646e2082fb1de9a03599651b3d80c99ea4c2ca08ab", size = 317736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/64/7d344cfcef5efddf9cf32f59af7f855828e9d74b5f862eddf5bfd9f25323/geopandas-1.0.1-py3-none-any.whl", hash = "sha256:01e147d9420cc374d26f51fc23716ac307f32b49406e4bd8462c07e82ed1d3d6", size = 323587 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "hatchling" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/8a/cc1debe3514da292094f1c3a700e4ca25442489731ef7c0814358816bb03/hatchling-1.27.0.tar.gz", hash = "sha256:971c296d9819abb3811112fc52c7a9751c8d381898f36533bb16f9791e941fd6", size = 54983 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/e7/ae38d7a6dfba0533684e0b2136817d667588ae3ec984c1a4e5df5eb88482/hatchling-1.27.0-py3-none-any.whl", hash = "sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b", size = 75794 }, +] + +[[package]] +name = "hda" +version = "2.29" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/86/325d66a72640b521e01f7362b7a436540f9968b88799b6b407aa35fb1be4/hda-2.29.tar.gz", hash = "sha256:64966ed5797eba2b080061c51ddd06d4073f009d8cd77e6970de028a8c66a115", size = 21012 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/53/167044585e216e90b9f976ede910832c4e011c64b6c0dde525b77f90d66b/hda-2.29-py3-none-any.whl", hash = "sha256:f08b95677adac06a0331ce29aa380341e712b8d4b3f956f2cdb2cc0faf109990", size = 19382 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "ipykernel" +version = "6.29.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/67594cb0c7055dc50814b21731c22a601101ea3b1b50a9a1b090e11f5d0f/ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215", size = 163367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5", size = 117173 }, +] + +[[package]] +name = "ipython" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/02/63a84444a7409b3c0acd1de9ffe524660e0e5d82ee473e78b45e5bfb64a4/ipython-9.2.0.tar.gz", hash = "sha256:62a9373dbc12f28f9feaf4700d052195bf89806279fc8ca11f3f54017d04751b", size = 4424394 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/ce/5e897ee51b7d26ab4e47e5105e7368d40ce6cfae2367acdf3165396d50be/ipython-9.2.0-py3-none-any.whl", hash = "sha256:fef5e33c4a1ae0759e0bba5917c9db4eb8c53fee917b6a526bd973e1ca5159f6", size = 604277 }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "jiter" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/c2/e4562507f52f0af7036da125bb699602ead37a2332af0788f8e0a3417f36/jiter-0.9.0.tar.gz", hash = "sha256:aadba0964deb424daa24492abc3d229c60c4a31bfee205aedbf1acc7639d7893", size = 162604 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/d7/c55086103d6f29b694ec79156242304adf521577530d9031317ce5338c59/jiter-0.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7b46249cfd6c48da28f89eb0be3f52d6fdb40ab88e2c66804f546674e539ec11", size = 309203 }, + { url = "https://files.pythonhosted.org/packages/b0/01/f775dfee50beb420adfd6baf58d1c4d437de41c9b666ddf127c065e5a488/jiter-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:609cf3c78852f1189894383cf0b0b977665f54cb38788e3e6b941fa6d982c00e", size = 319678 }, + { url = "https://files.pythonhosted.org/packages/ab/b8/09b73a793714726893e5d46d5c534a63709261af3d24444ad07885ce87cb/jiter-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d726a3890a54561e55a9c5faea1f7655eda7f105bd165067575ace6e65f80bb2", size = 341816 }, + { url = "https://files.pythonhosted.org/packages/35/6f/b8f89ec5398b2b0d344257138182cc090302854ed63ed9c9051e9c673441/jiter-0.9.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e89dc075c1fef8fa9be219e249f14040270dbc507df4215c324a1839522ea75", size = 364152 }, + { url = "https://files.pythonhosted.org/packages/9b/ca/978cc3183113b8e4484cc7e210a9ad3c6614396e7abd5407ea8aa1458eef/jiter-0.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04e8ffa3c353b1bc4134f96f167a2082494351e42888dfcf06e944f2729cbe1d", size = 406991 }, + { url = "https://files.pythonhosted.org/packages/13/3a/72861883e11a36d6aa314b4922125f6ae90bdccc225cd96d24cc78a66385/jiter-0.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:203f28a72a05ae0e129b3ed1f75f56bc419d5f91dfacd057519a8bd137b00c42", size = 395824 }, + { url = "https://files.pythonhosted.org/packages/87/67/22728a86ef53589c3720225778f7c5fdb617080e3deaed58b04789418212/jiter-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca1a02ad60ec30bb230f65bc01f611c8608b02d269f998bc29cca8619a919dc", size = 351318 }, + { url = "https://files.pythonhosted.org/packages/69/b9/f39728e2e2007276806d7a6609cda7fac44ffa28ca0d02c49a4f397cc0d9/jiter-0.9.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:237e5cee4d5d2659aaf91bbf8ec45052cc217d9446070699441a91b386ae27dc", size = 384591 }, + { url = "https://files.pythonhosted.org/packages/eb/8f/8a708bc7fd87b8a5d861f1c118a995eccbe6d672fe10c9753e67362d0dd0/jiter-0.9.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:528b6b71745e7326eed73c53d4aa57e2a522242320b6f7d65b9c5af83cf49b6e", size = 520746 }, + { url = "https://files.pythonhosted.org/packages/95/1e/65680c7488bd2365dbd2980adaf63c562d3d41d3faac192ebc7ef5b4ae25/jiter-0.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9f48e86b57bc711eb5acdfd12b6cb580a59cc9a993f6e7dcb6d8b50522dcd50d", size = 512754 }, + { url = "https://files.pythonhosted.org/packages/78/f3/fdc43547a9ee6e93c837685da704fb6da7dba311fc022e2766d5277dfde5/jiter-0.9.0-cp312-cp312-win32.whl", hash = "sha256:699edfde481e191d81f9cf6d2211debbfe4bd92f06410e7637dffb8dd5dfde06", size = 207075 }, + { url = "https://files.pythonhosted.org/packages/cd/9d/742b289016d155f49028fe1bfbeb935c9bf0ffeefdf77daf4a63a42bb72b/jiter-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:099500d07b43f61d8bd780466d429c45a7b25411b334c60ca875fa775f68ccb0", size = 207999 }, + { url = "https://files.pythonhosted.org/packages/e7/1b/4cd165c362e8f2f520fdb43245e2b414f42a255921248b4f8b9c8d871ff1/jiter-0.9.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2764891d3f3e8b18dce2cff24949153ee30c9239da7c00f032511091ba688ff7", size = 308197 }, + { url = "https://files.pythonhosted.org/packages/13/aa/7a890dfe29c84c9a82064a9fe36079c7c0309c91b70c380dc138f9bea44a/jiter-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:387b22fbfd7a62418d5212b4638026d01723761c75c1c8232a8b8c37c2f1003b", size = 318160 }, + { url = "https://files.pythonhosted.org/packages/6a/38/5888b43fc01102f733f085673c4f0be5a298f69808ec63de55051754e390/jiter-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d8da8629ccae3606c61d9184970423655fb4e33d03330bcdfe52d234d32f69", size = 341259 }, + { url = "https://files.pythonhosted.org/packages/3d/5e/bbdbb63305bcc01006de683b6228cd061458b9b7bb9b8d9bc348a58e5dc2/jiter-0.9.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1be73d8982bdc278b7b9377426a4b44ceb5c7952073dd7488e4ae96b88e1103", size = 363730 }, + { url = "https://files.pythonhosted.org/packages/75/85/53a3edc616992fe4af6814c25f91ee3b1e22f7678e979b6ea82d3bc0667e/jiter-0.9.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2228eaaaa111ec54b9e89f7481bffb3972e9059301a878d085b2b449fbbde635", size = 405126 }, + { url = "https://files.pythonhosted.org/packages/ae/b3/1ee26b12b2693bd3f0b71d3188e4e5d817b12e3c630a09e099e0a89e28fa/jiter-0.9.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11509bfecbc319459647d4ac3fd391d26fdf530dad00c13c4dadabf5b81f01a4", size = 393668 }, + { url = "https://files.pythonhosted.org/packages/11/87/e084ce261950c1861773ab534d49127d1517b629478304d328493f980791/jiter-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f22238da568be8bbd8e0650e12feeb2cfea15eda4f9fc271d3b362a4fa0604d", size = 352350 }, + { url = "https://files.pythonhosted.org/packages/f0/06/7dca84b04987e9df563610aa0bc154ea176e50358af532ab40ffb87434df/jiter-0.9.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17f5d55eb856597607562257c8e36c42bc87f16bef52ef7129b7da11afc779f3", size = 384204 }, + { url = "https://files.pythonhosted.org/packages/16/2f/82e1c6020db72f397dd070eec0c85ebc4df7c88967bc86d3ce9864148f28/jiter-0.9.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6a99bed9fbb02f5bed416d137944419a69aa4c423e44189bc49718859ea83bc5", size = 520322 }, + { url = "https://files.pythonhosted.org/packages/36/fd/4f0cd3abe83ce208991ca61e7e5df915aa35b67f1c0633eb7cf2f2e88ec7/jiter-0.9.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e057adb0cd1bd39606100be0eafe742de2de88c79df632955b9ab53a086b3c8d", size = 512184 }, + { url = "https://files.pythonhosted.org/packages/a0/3c/8a56f6d547731a0b4410a2d9d16bf39c861046f91f57c98f7cab3d2aa9ce/jiter-0.9.0-cp313-cp313-win32.whl", hash = "sha256:f7e6850991f3940f62d387ccfa54d1a92bd4bb9f89690b53aea36b4364bcab53", size = 206504 }, + { url = "https://files.pythonhosted.org/packages/f4/1c/0c996fd90639acda75ed7fa698ee5fd7d80243057185dc2f63d4c1c9f6b9/jiter-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:c8ae3bf27cd1ac5e6e8b7a27487bf3ab5f82318211ec2e1346a5b058756361f7", size = 204943 }, + { url = "https://files.pythonhosted.org/packages/78/0f/77a63ca7aa5fed9a1b9135af57e190d905bcd3702b36aca46a01090d39ad/jiter-0.9.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f0b2827fb88dda2cbecbbc3e596ef08d69bda06c6f57930aec8e79505dc17001", size = 317281 }, + { url = "https://files.pythonhosted.org/packages/f9/39/a3a1571712c2bf6ec4c657f0d66da114a63a2e32b7e4eb8e0b83295ee034/jiter-0.9.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:062b756ceb1d40b0b28f326cba26cfd575a4918415b036464a52f08632731e5a", size = 350273 }, + { url = "https://files.pythonhosted.org/packages/ee/47/3729f00f35a696e68da15d64eb9283c330e776f3b5789bac7f2c0c4df209/jiter-0.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6f7838bc467ab7e8ef9f387bd6de195c43bad82a569c1699cb822f6609dd4cdf", size = 206867 }, +] + +[[package]] +name = "joblib" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/33/60135848598c076ce4b231e1b1895170f45fbcaeaa2c9d5e38b04db70c35/joblib-1.4.2.tar.gz", hash = "sha256:2382c5816b2636fbd20a09e0f4e9dad4736765fdfb7dca582943b9c1366b3f0e", size = 2116621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/29/df4b9b42f2be0b623cbd5e2140cafcaa2bef0759a00b7b70104dcfe2fb51/joblib-1.4.2-py3-none-any.whl", hash = "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6", size = 301817 }, +] + +[[package]] +name = "jsonschema" +version = "4.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/ce/46fbd9c8119cfc3581ee5643ea49464d168028cfb5caff5fc0596d0cf914/jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608", size = 15513 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437 }, +] + +[[package]] +name = "jupyter-client" +version = "8.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105 }, +] + +[[package]] +name = "jupyter-core" +version = "5.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/11/b56381fa6c3f4cc5d2cf54a7dbf98ad9aa0b339ef7a601d6053538b079a7/jupyter_core-5.7.2.tar.gz", hash = "sha256:aa5f8d32bbf6b431ac830496da7392035d6f61b4f54872f15c4bd2a9c3f536d9", size = 87629 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/fb/108ecd1fe961941959ad0ee4e12ee7b8b1477247f30b1fdfd83ceaf017f0/jupyter_core-5.7.2-py3-none-any.whl", hash = "sha256:4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409", size = 28965 }, +] + +[[package]] +name = "jupytext" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/d9/b7acd3bed66c194cec1915c5bbec30994dbb50693ec209e5b115c28ddf63/jupytext-1.17.1.tar.gz", hash = "sha256:c02fda8af76ffd6e064a04cf2d3cc8aae242b2f0e38c42b4cd80baf89c3325d3", size = 3746897 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b7/e7e3d34c8095c19228874b1babedfb5d901374e40d51ae66f2a90203be53/jupytext-1.17.1-py3-none-any.whl", hash = "sha256:99145b1e1fa96520c21ba157de7d354ffa4904724dcebdcd70b8413688a312de", size = 164286 }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/59/7c91426a8ac292e1cdd53a63b6d9439abd573c875c3f92c146767dd33faf/kiwisolver-1.4.8.tar.gz", hash = "sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e", size = 97538 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/aa/cea685c4ab647f349c3bc92d2daf7ae34c8e8cf405a6dcd3a497f58a2ac3/kiwisolver-1.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d6af5e8815fd02997cb6ad9bbed0ee1e60014438ee1a5c2444c96f87b8843502", size = 124152 }, + { url = "https://files.pythonhosted.org/packages/c5/0b/8db6d2e2452d60d5ebc4ce4b204feeb16176a851fd42462f66ade6808084/kiwisolver-1.4.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bade438f86e21d91e0cf5dd7c0ed00cda0f77c8c1616bd83f9fc157fa6760d31", size = 66555 }, + { url = "https://files.pythonhosted.org/packages/60/26/d6a0db6785dd35d3ba5bf2b2df0aedc5af089962c6eb2cbf67a15b81369e/kiwisolver-1.4.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b83dc6769ddbc57613280118fb4ce3cd08899cc3369f7d0e0fab518a7cf37fdb", size = 65067 }, + { url = "https://files.pythonhosted.org/packages/c9/ed/1d97f7e3561e09757a196231edccc1bcf59d55ddccefa2afc9c615abd8e0/kiwisolver-1.4.8-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111793b232842991be367ed828076b03d96202c19221b5ebab421ce8bcad016f", size = 1378443 }, + { url = "https://files.pythonhosted.org/packages/29/61/39d30b99954e6b46f760e6289c12fede2ab96a254c443639052d1b573fbc/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:257af1622860e51b1a9d0ce387bf5c2c4f36a90594cb9514f55b074bcc787cfc", size = 1472728 }, + { url = "https://files.pythonhosted.org/packages/0c/3e/804163b932f7603ef256e4a715e5843a9600802bb23a68b4e08c8c0ff61d/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b5637c3f316cab1ec1c9a12b8c5f4750a4c4b71af9157645bf32830e39c03a", size = 1478388 }, + { url = "https://files.pythonhosted.org/packages/8a/9e/60eaa75169a154700be74f875a4d9961b11ba048bef315fbe89cb6999056/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:782bb86f245ec18009890e7cb8d13a5ef54dcf2ebe18ed65f795e635a96a1c6a", size = 1413849 }, + { url = "https://files.pythonhosted.org/packages/bc/b3/9458adb9472e61a998c8c4d95cfdfec91c73c53a375b30b1428310f923e4/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc978a80a0db3a66d25767b03688f1147a69e6237175c0f4ffffaaedf744055a", size = 1475533 }, + { url = "https://files.pythonhosted.org/packages/e4/7a/0a42d9571e35798de80aef4bb43a9b672aa7f8e58643d7bd1950398ffb0a/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36dbbfd34838500a31f52c9786990d00150860e46cd5041386f217101350f0d3", size = 2268898 }, + { url = "https://files.pythonhosted.org/packages/d9/07/1255dc8d80271400126ed8db35a1795b1a2c098ac3a72645075d06fe5c5d/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eaa973f1e05131de5ff3569bbba7f5fd07ea0595d3870ed4a526d486fe57fa1b", size = 2425605 }, + { url = "https://files.pythonhosted.org/packages/84/df/5a3b4cf13780ef6f6942df67b138b03b7e79e9f1f08f57c49957d5867f6e/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a66f60f8d0c87ab7f59b6fb80e642ebb29fec354a4dfad687ca4092ae69d04f4", size = 2375801 }, + { url = "https://files.pythonhosted.org/packages/8f/10/2348d068e8b0f635c8c86892788dac7a6b5c0cb12356620ab575775aad89/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858416b7fb777a53f0c59ca08190ce24e9abbd3cffa18886a5781b8e3e26f65d", size = 2520077 }, + { url = "https://files.pythonhosted.org/packages/32/d8/014b89fee5d4dce157d814303b0fce4d31385a2af4c41fed194b173b81ac/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:085940635c62697391baafaaeabdf3dd7a6c3643577dde337f4d66eba021b2b8", size = 2338410 }, + { url = "https://files.pythonhosted.org/packages/bd/72/dfff0cc97f2a0776e1c9eb5bef1ddfd45f46246c6533b0191887a427bca5/kiwisolver-1.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:01c3d31902c7db5fb6182832713d3b4122ad9317c2c5877d0539227d96bb2e50", size = 71853 }, + { url = "https://files.pythonhosted.org/packages/dc/85/220d13d914485c0948a00f0b9eb419efaf6da81b7d72e88ce2391f7aed8d/kiwisolver-1.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:a3c44cb68861de93f0c4a8175fbaa691f0aa22550c331fefef02b618a9dcb476", size = 65424 }, + { url = "https://files.pythonhosted.org/packages/79/b3/e62464a652f4f8cd9006e13d07abad844a47df1e6537f73ddfbf1bc997ec/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1c8ceb754339793c24aee1c9fb2485b5b1f5bb1c2c214ff13368431e51fc9a09", size = 124156 }, + { url = "https://files.pythonhosted.org/packages/8d/2d/f13d06998b546a2ad4f48607a146e045bbe48030774de29f90bdc573df15/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a62808ac74b5e55a04a408cda6156f986cefbcf0ada13572696b507cc92fa1", size = 66555 }, + { url = "https://files.pythonhosted.org/packages/59/e3/b8bd14b0a54998a9fd1e8da591c60998dc003618cb19a3f94cb233ec1511/kiwisolver-1.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68269e60ee4929893aad82666821aaacbd455284124817af45c11e50a4b42e3c", size = 65071 }, + { url = "https://files.pythonhosted.org/packages/f0/1c/6c86f6d85ffe4d0ce04228d976f00674f1df5dc893bf2dd4f1928748f187/kiwisolver-1.4.8-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34d142fba9c464bc3bbfeff15c96eab0e7310343d6aefb62a79d51421fcc5f1b", size = 1378053 }, + { url = "https://files.pythonhosted.org/packages/4e/b9/1c6e9f6dcb103ac5cf87cb695845f5fa71379021500153566d8a8a9fc291/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc373e0eef45b59197de815b1b28ef89ae3955e7722cc9710fb91cd77b7f47", size = 1472278 }, + { url = "https://files.pythonhosted.org/packages/ee/81/aca1eb176de671f8bda479b11acdc42c132b61a2ac861c883907dde6debb/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77e6f57a20b9bd4e1e2cedda4d0b986ebd0216236f0106e55c28aea3d3d69b16", size = 1478139 }, + { url = "https://files.pythonhosted.org/packages/49/f4/e081522473671c97b2687d380e9e4c26f748a86363ce5af48b4a28e48d06/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08e77738ed7538f036cd1170cbed942ef749137b1311fa2bbe2a7fda2f6bf3cc", size = 1413517 }, + { url = "https://files.pythonhosted.org/packages/8f/e9/6a7d025d8da8c4931522922cd706105aa32b3291d1add8c5427cdcd66e63/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5ce1e481a74b44dd5e92ff03ea0cb371ae7a0268318e202be06c8f04f4f1246", size = 1474952 }, + { url = "https://files.pythonhosted.org/packages/82/13/13fa685ae167bee5d94b415991c4fc7bb0a1b6ebea6e753a87044b209678/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc2ace710ba7c1dfd1a3b42530b62b9ceed115f19a1656adefce7b1782a37794", size = 2269132 }, + { url = "https://files.pythonhosted.org/packages/ef/92/bb7c9395489b99a6cb41d502d3686bac692586db2045adc19e45ee64ed23/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3452046c37c7692bd52b0e752b87954ef86ee2224e624ef7ce6cb21e8c41cc1b", size = 2425997 }, + { url = "https://files.pythonhosted.org/packages/ed/12/87f0e9271e2b63d35d0d8524954145837dd1a6c15b62a2d8c1ebe0f182b4/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e9a60b50fe8b2ec6f448fe8d81b07e40141bfced7f896309df271a0b92f80f3", size = 2376060 }, + { url = "https://files.pythonhosted.org/packages/02/6e/c8af39288edbce8bf0fa35dee427b082758a4b71e9c91ef18fa667782138/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:918139571133f366e8362fa4a297aeba86c7816b7ecf0bc79168080e2bd79957", size = 2520471 }, + { url = "https://files.pythonhosted.org/packages/13/78/df381bc7b26e535c91469f77f16adcd073beb3e2dd25042efd064af82323/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e063ef9f89885a1d68dd8b2e18f5ead48653176d10a0e324e3b0030e3a69adeb", size = 2338793 }, + { url = "https://files.pythonhosted.org/packages/d0/dc/c1abe38c37c071d0fc71c9a474fd0b9ede05d42f5a458d584619cfd2371a/kiwisolver-1.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:a17b7c4f5b2c51bb68ed379defd608a03954a1845dfed7cc0117f1cc8a9b7fd2", size = 71855 }, + { url = "https://files.pythonhosted.org/packages/a0/b6/21529d595b126ac298fdd90b705d87d4c5693de60023e0efcb4f387ed99e/kiwisolver-1.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:3cd3bc628b25f74aedc6d374d5babf0166a92ff1317f46267f12d2ed54bc1d30", size = 65430 }, + { url = "https://files.pythonhosted.org/packages/34/bd/b89380b7298e3af9b39f49334e3e2a4af0e04819789f04b43d560516c0c8/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:370fd2df41660ed4e26b8c9d6bbcad668fbe2560462cba151a721d49e5b6628c", size = 126294 }, + { url = "https://files.pythonhosted.org/packages/83/41/5857dc72e5e4148eaac5aa76e0703e594e4465f8ab7ec0fc60e3a9bb8fea/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:84a2f830d42707de1d191b9490ac186bf7997a9495d4e9072210a1296345f7dc", size = 67736 }, + { url = "https://files.pythonhosted.org/packages/e1/d1/be059b8db56ac270489fb0b3297fd1e53d195ba76e9bbb30e5401fa6b759/kiwisolver-1.4.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7a3ad337add5148cf51ce0b55642dc551c0b9d6248458a757f98796ca7348712", size = 66194 }, + { url = "https://files.pythonhosted.org/packages/e1/83/4b73975f149819eb7dcf9299ed467eba068ecb16439a98990dcb12e63fdd/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7506488470f41169b86d8c9aeff587293f530a23a23a49d6bc64dab66bedc71e", size = 1465942 }, + { url = "https://files.pythonhosted.org/packages/c7/2c/30a5cdde5102958e602c07466bce058b9d7cb48734aa7a4327261ac8e002/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f0121b07b356a22fb0414cec4666bbe36fd6d0d759db3d37228f496ed67c880", size = 1595341 }, + { url = "https://files.pythonhosted.org/packages/ff/9b/1e71db1c000385aa069704f5990574b8244cce854ecd83119c19e83c9586/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6d6bd87df62c27d4185de7c511c6248040afae67028a8a22012b010bc7ad062", size = 1598455 }, + { url = "https://files.pythonhosted.org/packages/85/92/c8fec52ddf06231b31cbb779af77e99b8253cd96bd135250b9498144c78b/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:291331973c64bb9cce50bbe871fb2e675c4331dab4f31abe89f175ad7679a4d7", size = 1522138 }, + { url = "https://files.pythonhosted.org/packages/0b/51/9eb7e2cd07a15d8bdd976f6190c0164f92ce1904e5c0c79198c4972926b7/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893f5525bb92d3d735878ec00f781b2de998333659507d29ea4466208df37bed", size = 1582857 }, + { url = "https://files.pythonhosted.org/packages/0f/95/c5a00387a5405e68ba32cc64af65ce881a39b98d73cc394b24143bebc5b8/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b47a465040146981dc9db8647981b8cb96366fbc8d452b031e4f8fdffec3f26d", size = 2293129 }, + { url = "https://files.pythonhosted.org/packages/44/83/eeb7af7d706b8347548313fa3a3a15931f404533cc54fe01f39e830dd231/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:99cea8b9dd34ff80c521aef46a1dddb0dcc0283cf18bde6d756f1e6f31772165", size = 2421538 }, + { url = "https://files.pythonhosted.org/packages/05/f9/27e94c1b3eb29e6933b6986ffc5fa1177d2cd1f0c8efc5f02c91c9ac61de/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:151dffc4865e5fe6dafce5480fab84f950d14566c480c08a53c663a0020504b6", size = 2390661 }, + { url = "https://files.pythonhosted.org/packages/d9/d4/3c9735faa36ac591a4afcc2980d2691000506050b7a7e80bcfe44048daa7/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:577facaa411c10421314598b50413aa1ebcf5126f704f1e5d72d7e4e9f020d90", size = 2546710 }, + { url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213 }, +] + +[[package]] +name = "locket" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/83/97b29fe05cb6ae28d2dbd30b81e2e402a3eed5f460c26e9eaa5895ceacf5/locket-1.0.0.tar.gz", hash = "sha256:5c0d4c052a8bbbf750e056a8e65ccd309086f4f0f18a2eac306a8dfa4112a632", size = 4350 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398 }, +] + +[[package]] +name = "lru-dict" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/e3/42c87871920602a3c8300915bd0292f76eccc66c38f782397acbf8a62088/lru-dict-1.3.0.tar.gz", hash = "sha256:54fd1966d6bd1fcde781596cb86068214edeebff1db13a2cea11079e3fd07b6b", size = 13123 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/5c/385f080747eb3083af87d8e4c9068f3c4cab89035f6982134889940dafd8/lru_dict-1.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c279068f68af3b46a5d649855e1fb87f5705fe1f744a529d82b2885c0e1fc69d", size = 17174 }, + { url = "https://files.pythonhosted.org/packages/3c/de/5ef2ed75ce55d7059d1b96177ba04fa7ee1f35564f97bdfcd28fccfbe9d2/lru_dict-1.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:350e2233cfee9f326a0d7a08e309372d87186565e43a691b120006285a0ac549", size = 10742 }, + { url = "https://files.pythonhosted.org/packages/ca/05/f69a6abb0062d2cf2ce0aaf0284b105b97d1da024ca6d3d0730e6151242e/lru_dict-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4eafb188a84483b3231259bf19030859f070321b00326dcb8e8c6cbf7db4b12f", size = 11079 }, + { url = "https://files.pythonhosted.org/packages/ea/59/cf891143abe58a455b8eaa9175f0e80f624a146a2bf9a1ca842ee0ef930a/lru_dict-1.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73593791047e36b37fdc0b67b76aeed439fcea80959c7d46201240f9ec3b2563", size = 32469 }, + { url = "https://files.pythonhosted.org/packages/59/88/d5976e9f70107ce11e45d93c6f0c2d5eaa1fc30bb3c8f57525eda4510dff/lru_dict-1.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1958cb70b9542773d6241974646e5410e41ef32e5c9e437d44040d59bd80daf2", size = 33496 }, + { url = "https://files.pythonhosted.org/packages/6c/f8/94d6e910d54fc1fa05c0ee1cd608c39401866a18cf5e5aff238449b33c11/lru_dict-1.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bc1cd3ed2cee78a47f11f3b70be053903bda197a873fd146e25c60c8e5a32cd6", size = 29914 }, + { url = "https://files.pythonhosted.org/packages/ca/b9/9db79780c8a3cfd66bba6847773061e5cf8a3746950273b9985d47bbfe53/lru_dict-1.3.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82eb230d48eaebd6977a92ddaa6d788f14cf4f4bcf5bbffa4ddfd60d051aa9d4", size = 32241 }, + { url = "https://files.pythonhosted.org/packages/9b/b6/08a623019daec22a40c4d6d2c40851dfa3d129a53b2f9469db8eb13666c1/lru_dict-1.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5ad659cbc349d0c9ba8e536b5f40f96a70c360f43323c29f4257f340d891531c", size = 37320 }, + { url = "https://files.pythonhosted.org/packages/70/0b/d3717159c26155ff77679cee1b077d22e1008bf45f19921e193319cd8e46/lru_dict-1.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ba490b8972531d153ac0d4e421f60d793d71a2f4adbe2f7740b3c55dce0a12f1", size = 35054 }, + { url = "https://files.pythonhosted.org/packages/04/74/f2ae00de7c27984a19b88d2b09ac877031c525b01199d7841ec8fa657fd6/lru_dict-1.3.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:c0131351b8a7226c69f1eba5814cbc9d1d8daaf0fdec1ae3f30508e3de5262d4", size = 38613 }, + { url = "https://files.pythonhosted.org/packages/5a/0b/e30236aafe31b4247aa9ae61ba8aac6dde75c3ea0e47a8fb7eef53f6d5ce/lru_dict-1.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0e88dba16695f17f41701269fa046197a3fd7b34a8dba744c8749303ddaa18df", size = 37143 }, + { url = "https://files.pythonhosted.org/packages/1c/28/b59bcebb8d76ba8147a784a8be7eab6a4ad3395b9236e73740ff675a5a52/lru_dict-1.3.0-cp312-cp312-win32.whl", hash = "sha256:6ffaf595e625b388babc8e7d79b40f26c7485f61f16efe76764e32dce9ea17fc", size = 12653 }, + { url = "https://files.pythonhosted.org/packages/bd/18/06d9710cb0a0d3634f8501e4bdcc07abe64a32e404d82895a6a36fab97f6/lru_dict-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf9da32ef2582434842ab6ba6e67290debfae72771255a8e8ab16f3e006de0aa", size = 13811 }, +] + +[[package]] +name = "markdown" +version = "3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/15/222b423b0b88689c266d9eac4e61396fe2cc53464459d6a37618ac863b24/markdown-3.8.tar.gz", hash = "sha256:7df81e63f0df5c4b24b7d156eb81e4690595239b7d70937d0409f1b0de319c6f", size = 360906 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/3f/afe76f8e2246ffbc867440cbcf90525264df0e658f8a5ca1f872b3f6192a/markdown-3.8-py3-none-any.whl", hash = "sha256:794a929b79c5af141ef5ab0f2f642d0f7b1872981250230e72682346f7cc90dc", size = 106210 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "matplotlib" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/08/b89867ecea2e305f408fbb417139a8dd941ecf7b23a2e02157c36da546f0/matplotlib-3.10.1.tar.gz", hash = "sha256:e8d2d0e3881b129268585bf4765ad3ee73a4591d77b9a18c214ac7e3a79fb2ba", size = 36743335 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/1d/5e0dc3b59c034e43de16f94deb68f4ad8a96b3ea00f4b37c160b7474928e/matplotlib-3.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:66e907a06e68cb6cfd652c193311d61a12b54f56809cafbed9736ce5ad92f107", size = 8175488 }, + { url = "https://files.pythonhosted.org/packages/7a/81/dae7e14042e74da658c3336ab9799128e09a1ee03964f2d89630b5d12106/matplotlib-3.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e9b4bb156abb8fa5e5b2b460196f7db7264fc6d62678c03457979e7d5254b7be", size = 8046264 }, + { url = "https://files.pythonhosted.org/packages/21/c4/22516775dcde10fc9c9571d155f90710761b028fc44f660508106c363c97/matplotlib-3.10.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1985ad3d97f51307a2cbfc801a930f120def19ba22864182dacef55277102ba6", size = 8452048 }, + { url = "https://files.pythonhosted.org/packages/63/23/c0615001f67ce7c96b3051d856baedc0c818a2ed84570b9bf9bde200f85d/matplotlib-3.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c96f2c2f825d1257e437a1482c5a2cf4fee15db4261bd6fc0750f81ba2b4ba3d", size = 8597111 }, + { url = "https://files.pythonhosted.org/packages/ca/c0/a07939a82aed77770514348f4568177d7dadab9787ebc618a616fe3d665e/matplotlib-3.10.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35e87384ee9e488d8dd5a2dd7baf471178d38b90618d8ea147aced4ab59c9bea", size = 9402771 }, + { url = "https://files.pythonhosted.org/packages/a6/b6/a9405484fb40746fdc6ae4502b16a9d6e53282ba5baaf9ebe2da579f68c4/matplotlib-3.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:cfd414bce89cc78a7e1d25202e979b3f1af799e416010a20ab2b5ebb3a02425c", size = 8063742 }, + { url = "https://files.pythonhosted.org/packages/60/73/6770ff5e5523d00f3bc584acb6031e29ee5c8adc2336b16cd1d003675fe0/matplotlib-3.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42eee41e1b60fd83ee3292ed83a97a5f2a8239b10c26715d8a6172226988d7b", size = 8176112 }, + { url = "https://files.pythonhosted.org/packages/08/97/b0ca5da0ed54a3f6599c3ab568bdda65269bc27c21a2c97868c1625e4554/matplotlib-3.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4f0647b17b667ae745c13721602b540f7aadb2a32c5b96e924cd4fea5dcb90f1", size = 8046931 }, + { url = "https://files.pythonhosted.org/packages/df/9a/1acbdc3b165d4ce2dcd2b1a6d4ffb46a7220ceee960c922c3d50d8514067/matplotlib-3.10.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa3854b5f9473564ef40a41bc922be978fab217776e9ae1545c9b3a5cf2092a3", size = 8453422 }, + { url = "https://files.pythonhosted.org/packages/51/d0/2bc4368abf766203e548dc7ab57cf7e9c621f1a3c72b516cc7715347b179/matplotlib-3.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e496c01441be4c7d5f96d4e40f7fca06e20dcb40e44c8daa2e740e1757ad9e6", size = 8596819 }, + { url = "https://files.pythonhosted.org/packages/ab/1b/8b350f8a1746c37ab69dda7d7528d1fc696efb06db6ade9727b7887be16d/matplotlib-3.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d45d3f5245be5b469843450617dcad9af75ca50568acf59997bed9311131a0b", size = 9402782 }, + { url = "https://files.pythonhosted.org/packages/89/06/f570373d24d93503988ba8d04f213a372fa1ce48381c5eb15da985728498/matplotlib-3.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:8e8e25b1209161d20dfe93037c8a7f7ca796ec9aa326e6e4588d8c4a5dd1e473", size = 8063812 }, + { url = "https://files.pythonhosted.org/packages/fc/e0/8c811a925b5a7ad75135f0e5af46408b78af88bbb02a1df775100ef9bfef/matplotlib-3.10.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:19b06241ad89c3ae9469e07d77efa87041eac65d78df4fcf9cac318028009b01", size = 8214021 }, + { url = "https://files.pythonhosted.org/packages/4a/34/319ec2139f68ba26da9d00fce2ff9f27679fb799a6c8e7358539801fd629/matplotlib-3.10.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01e63101ebb3014e6e9f80d9cf9ee361a8599ddca2c3e166c563628b39305dbb", size = 8090782 }, + { url = "https://files.pythonhosted.org/packages/77/ea/9812124ab9a99df5b2eec1110e9b2edc0b8f77039abf4c56e0a376e84a29/matplotlib-3.10.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f06bad951eea6422ac4e8bdebcf3a70c59ea0a03338c5d2b109f57b64eb3972", size = 8478901 }, + { url = "https://files.pythonhosted.org/packages/c9/db/b05bf463689134789b06dea85828f8ebe506fa1e37593f723b65b86c9582/matplotlib-3.10.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3dfb036f34873b46978f55e240cff7a239f6c4409eac62d8145bad3fc6ba5a3", size = 8613864 }, + { url = "https://files.pythonhosted.org/packages/c2/04/41ccec4409f3023a7576df3b5c025f1a8c8b81fbfe922ecfd837ac36e081/matplotlib-3.10.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dc6ab14a7ab3b4d813b88ba957fc05c79493a037f54e246162033591e770de6f", size = 9409487 }, + { url = "https://files.pythonhosted.org/packages/ac/c2/0d5aae823bdcc42cc99327ecdd4d28585e15ccd5218c453b7bcd827f3421/matplotlib-3.10.1-cp313-cp313t-win_amd64.whl", hash = "sha256:bc411ebd5889a78dabbc457b3fa153203e22248bfa6eedc6797be5df0164dbf9", size = 8134832 }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "multiurl" +version = "0.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "requests" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/05/f6cb8e9506936c638550dda75e97c70535acf89eb147bfb8655e6d154256/multiurl-0.3.5.tar.gz", hash = "sha256:c2fb8b85227caa453fa0c9e711c5a83e3fd6d9a30b5010ce8a8a4e872d31211e", size = 18527 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/b4/4dd3f8f8bdb79bf65a1882fbf769a27e3ce27e9566faa0aeaa295ed755d7/multiurl-0.3.5-py3-none-any.whl", hash = "sha256:37b920c3116861198ec5b24080fed5344514006021eec969784dabc76fcf3d63", size = 21323 }, +] + +[[package]] +name = "narwhals" +version = "1.37.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/e1/a602073ef14da8f065b08c178861cf782f08a3676492042566ae3ccc5671/narwhals-1.37.1.tar.gz", hash = "sha256:1eb8f17ff00e6c471d5afb704e9068f41657234eb73bde2ee66ad975a170015b", size = 272029 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/e3/79d95ed89f46b134dd801b54375e362c505303bd739a6737efc14d0de835/narwhals-1.37.1-py3-none-any.whl", hash = "sha256:6f358a23b7351897d6efb45496dc0528918ce4ca6c8f9631594885cd873576a7", size = 332366 }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454 }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, +] + +[[package]] +name = "netcdf4" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cftime" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/ed/4d27fcfa40ebfdad3d2088a3de7ee48dbff7f35163e815ec1870d2a7398c/netcdf4-1.7.2.tar.gz", hash = "sha256:a4c6375540b19989896136943abb6d44850ff6f1fa7d3f063253b1ad3f8b7fce", size = 835064 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/7f/3a0f18a39efca0e093b54d634b66573c25ecab5c482d73138ae14aa55c6d/netCDF4-1.7.2-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:e73e3baa0b74afc414e53ff5095748fdbec7fb346eda351e567c23f2f0d247f1", size = 2952127 }, + { url = "https://files.pythonhosted.org/packages/ed/c4/8aac0f8ca95a41bdf1364d34ff4e9bcc24494bfe69a1157301d884c2e392/netCDF4-1.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a51da09258b31776f474c1d47e484fc7214914cdc59edf4cee789ba632184591", size = 2460781 }, + { url = "https://files.pythonhosted.org/packages/2d/1a/32b7427aaf62fed3d4e4456f874b25ce39373dbddf6cfde9edbcfc2417fc/netCDF4-1.7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb95b11804fe051897d1f2044b05d82a1847bc2549631cdd2f655dde7de77a9c", size = 9377415 }, + { url = "https://files.pythonhosted.org/packages/fd/bf/5e671495c8bdf6b628e091aa8980793579474a10e51bc6ba302a3af6a778/netCDF4-1.7.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9d8a848373723f41ef662590b4f5e1832227501c9fd4513e8ad8da58c269977", size = 9260579 }, + { url = "https://files.pythonhosted.org/packages/d4/57/0a0bcdebcfaf72e96e7bcaa512f80ee096bf71945a3318d38253338e9c25/netCDF4-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:568ea369e00b581302d77fc5fd0b8f78e520c7e08d0b5af5219ba51f3f1cd694", size = 6991523 }, + { url = "https://files.pythonhosted.org/packages/e6/7a/ce4f9038d8726c9c90e07b2d3a404ae111a27720d712cfcded0c8ef160e8/netCDF4-1.7.2-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:205a5f1de3ddb993c7c97fb204a923a22408cc2e5facf08d75a8eb89b3e7e1a8", size = 2948911 }, + { url = "https://files.pythonhosted.org/packages/58/3e/5736880a607edabca4c4fc49f1ccf9a2bb2485f84478e4cd19ba11c3b803/netCDF4-1.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:96653fc75057df196010818367c63ba6d7e9af603df0a7fe43fcdad3fe0e9e56", size = 2455078 }, + { url = "https://files.pythonhosted.org/packages/71/96/d5d8859a6dac29f8ebc815ff8e75770bd513db9f08d7a711e21ae562a948/netCDF4-1.7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30d20e56b9ba2c48884eb89c91b63e6c0612b4927881707e34402719153ef17f", size = 9378149 }, + { url = "https://files.pythonhosted.org/packages/d1/80/b9c19f1bb4ac6c5fa6f94a4f278bc68a778473d1814a86a375d7cffa193a/netCDF4-1.7.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d6bfd38ba0bde04d56f06c1554714a2ea9dab75811c89450dc3ec57a9d36b80", size = 9254471 }, + { url = "https://files.pythonhosted.org/packages/66/b5/e04550fd53de57001dbd5a87242da7ff784c80790adc48897977b6ccf891/netCDF4-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:5c5fbee6134ee1246c397e1508e5297d825aa19221fdf3fa8dc9727ad824d7a5", size = 6990521 }, +] + +[[package]] +name = "numpy" +version = "2.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/b2/ce4b867d8cd9c0ee84938ae1e6a6f7926ebf928c9090d036fc3c6a04f946/numpy-2.2.5.tar.gz", hash = "sha256:a9c0d994680cd991b1cb772e8b297340085466a6fe964bc9d4e80f5e2f43c291", size = 20273920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/f7/1fd4ff108cd9d7ef929b8882692e23665dc9c23feecafbb9c6b80f4ec583/numpy-2.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ee461a4eaab4f165b68780a6a1af95fb23a29932be7569b9fab666c407969051", size = 20948633 }, + { url = "https://files.pythonhosted.org/packages/12/03/d443c278348371b20d830af155ff2079acad6a9e60279fac2b41dbbb73d8/numpy-2.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ec31367fd6a255dc8de4772bd1658c3e926d8e860a0b6e922b615e532d320ddc", size = 14176123 }, + { url = "https://files.pythonhosted.org/packages/2b/0b/5ca264641d0e7b14393313304da48b225d15d471250376f3fbdb1a2be603/numpy-2.2.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:47834cde750d3c9f4e52c6ca28a7361859fcaf52695c7dc3cc1a720b8922683e", size = 5163817 }, + { url = "https://files.pythonhosted.org/packages/04/b3/d522672b9e3d28e26e1613de7675b441bbd1eaca75db95680635dd158c67/numpy-2.2.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2c1a1c6ccce4022383583a6ded7bbcda22fc635eb4eb1e0a053336425ed36dfa", size = 6698066 }, + { url = "https://files.pythonhosted.org/packages/a0/93/0f7a75c1ff02d4b76df35079676b3b2719fcdfb39abdf44c8b33f43ef37d/numpy-2.2.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d75f338f5f79ee23548b03d801d28a505198297534f62416391857ea0479571", size = 14087277 }, + { url = "https://files.pythonhosted.org/packages/b0/d9/7c338b923c53d431bc837b5b787052fef9ae68a56fe91e325aac0d48226e/numpy-2.2.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a801fef99668f309b88640e28d261991bfad9617c27beda4a3aec4f217ea073", size = 16135742 }, + { url = "https://files.pythonhosted.org/packages/2d/10/4dec9184a5d74ba9867c6f7d1e9f2e0fb5fe96ff2bf50bb6f342d64f2003/numpy-2.2.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:abe38cd8381245a7f49967a6010e77dbf3680bd3627c0fe4362dd693b404c7f8", size = 15581825 }, + { url = "https://files.pythonhosted.org/packages/80/1f/2b6fcd636e848053f5b57712a7d1880b1565eec35a637fdfd0a30d5e738d/numpy-2.2.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5a0ac90e46fdb5649ab6369d1ab6104bfe5854ab19b645bf5cda0127a13034ae", size = 17899600 }, + { url = "https://files.pythonhosted.org/packages/ec/87/36801f4dc2623d76a0a3835975524a84bd2b18fe0f8835d45c8eae2f9ff2/numpy-2.2.5-cp312-cp312-win32.whl", hash = "sha256:0cd48122a6b7eab8f06404805b1bd5856200e3ed6f8a1b9a194f9d9054631beb", size = 6312626 }, + { url = "https://files.pythonhosted.org/packages/8b/09/4ffb4d6cfe7ca6707336187951992bd8a8b9142cf345d87ab858d2d7636a/numpy-2.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:ced69262a8278547e63409b2653b372bf4baff0870c57efa76c5703fd6543282", size = 12645715 }, + { url = "https://files.pythonhosted.org/packages/e2/a0/0aa7f0f4509a2e07bd7a509042967c2fab635690d4f48c6c7b3afd4f448c/numpy-2.2.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:059b51b658f4414fff78c6d7b1b4e18283ab5fa56d270ff212d5ba0c561846f4", size = 20935102 }, + { url = "https://files.pythonhosted.org/packages/7e/e4/a6a9f4537542912ec513185396fce52cdd45bdcf3e9d921ab02a93ca5aa9/numpy-2.2.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:47f9ed103af0bc63182609044b0490747e03bd20a67e391192dde119bf43d52f", size = 14191709 }, + { url = "https://files.pythonhosted.org/packages/be/65/72f3186b6050bbfe9c43cb81f9df59ae63603491d36179cf7a7c8d216758/numpy-2.2.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:261a1ef047751bb02f29dfe337230b5882b54521ca121fc7f62668133cb119c9", size = 5149173 }, + { url = "https://files.pythonhosted.org/packages/e5/e9/83e7a9432378dde5802651307ae5e9ea07bb72b416728202218cd4da2801/numpy-2.2.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4520caa3807c1ceb005d125a75e715567806fed67e315cea619d5ec6e75a4191", size = 6684502 }, + { url = "https://files.pythonhosted.org/packages/ea/27/b80da6c762394c8ee516b74c1f686fcd16c8f23b14de57ba0cad7349d1d2/numpy-2.2.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d14b17b9be5f9c9301f43d2e2a4886a33b53f4e6fdf9ca2f4cc60aeeee76372", size = 14084417 }, + { url = "https://files.pythonhosted.org/packages/aa/fc/ebfd32c3e124e6a1043e19c0ab0769818aa69050ce5589b63d05ff185526/numpy-2.2.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ba321813a00e508d5421104464510cc962a6f791aa2fca1c97b1e65027da80d", size = 16133807 }, + { url = "https://files.pythonhosted.org/packages/bf/9b/4cc171a0acbe4666f7775cfd21d4eb6bb1d36d3a0431f48a73e9212d2278/numpy-2.2.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4cbdef3ddf777423060c6f81b5694bad2dc9675f110c4b2a60dc0181543fac7", size = 15575611 }, + { url = "https://files.pythonhosted.org/packages/a3/45/40f4135341850df48f8edcf949cf47b523c404b712774f8855a64c96ef29/numpy-2.2.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54088a5a147ab71a8e7fdfd8c3601972751ded0739c6b696ad9cb0343e21ab73", size = 17895747 }, + { url = "https://files.pythonhosted.org/packages/f8/4c/b32a17a46f0ffbde8cc82df6d3daeaf4f552e346df143e1b188a701a8f09/numpy-2.2.5-cp313-cp313-win32.whl", hash = "sha256:c8b82a55ef86a2d8e81b63da85e55f5537d2157165be1cb2ce7cfa57b6aef38b", size = 6309594 }, + { url = "https://files.pythonhosted.org/packages/13/ae/72e6276feb9ef06787365b05915bfdb057d01fceb4a43cb80978e518d79b/numpy-2.2.5-cp313-cp313-win_amd64.whl", hash = "sha256:d8882a829fd779f0f43998e931c466802a77ca1ee0fe25a3abe50278616b1471", size = 12638356 }, + { url = "https://files.pythonhosted.org/packages/79/56/be8b85a9f2adb688e7ded6324e20149a03541d2b3297c3ffc1a73f46dedb/numpy-2.2.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e8b025c351b9f0e8b5436cf28a07fa4ac0204d67b38f01433ac7f9b870fa38c6", size = 20963778 }, + { url = "https://files.pythonhosted.org/packages/ff/77/19c5e62d55bff507a18c3cdff82e94fe174957bad25860a991cac719d3ab/numpy-2.2.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dfa94b6a4374e7851bbb6f35e6ded2120b752b063e6acdd3157e4d2bb922eba", size = 14207279 }, + { url = "https://files.pythonhosted.org/packages/75/22/aa11f22dc11ff4ffe4e849d9b63bbe8d4ac6d5fae85ddaa67dfe43be3e76/numpy-2.2.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:97c8425d4e26437e65e1d189d22dff4a079b747ff9c2788057bfb8114ce1e133", size = 5199247 }, + { url = "https://files.pythonhosted.org/packages/4f/6c/12d5e760fc62c08eded0394f62039f5a9857f758312bf01632a81d841459/numpy-2.2.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:352d330048c055ea6db701130abc48a21bec690a8d38f8284e00fab256dc1376", size = 6711087 }, + { url = "https://files.pythonhosted.org/packages/ef/94/ece8280cf4218b2bee5cec9567629e61e51b4be501e5c6840ceb593db945/numpy-2.2.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b4c0773b6ada798f51f0f8e30c054d32304ccc6e9c5d93d46cb26f3d385ab19", size = 14059964 }, + { url = "https://files.pythonhosted.org/packages/39/41/c5377dac0514aaeec69115830a39d905b1882819c8e65d97fc60e177e19e/numpy-2.2.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55f09e00d4dccd76b179c0f18a44f041e5332fd0e022886ba1c0bbf3ea4a18d0", size = 16121214 }, + { url = "https://files.pythonhosted.org/packages/db/54/3b9f89a943257bc8e187145c6bc0eb8e3d615655f7b14e9b490b053e8149/numpy-2.2.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:02f226baeefa68f7d579e213d0f3493496397d8f1cff5e2b222af274c86a552a", size = 15575788 }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2e407e85df35b29f79945751b8f8e671057a13a376497d7fb2151ba0d290/numpy-2.2.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c26843fd58f65da9491165072da2cccc372530681de481ef670dcc8e27cfb066", size = 17893672 }, + { url = "https://files.pythonhosted.org/packages/29/7e/d0b44e129d038dba453f00d0e29ebd6eaf2f06055d72b95b9947998aca14/numpy-2.2.5-cp313-cp313t-win32.whl", hash = "sha256:1a161c2c79ab30fe4501d5a2bbfe8b162490757cf90b7f05be8b80bc02f7bb8e", size = 6377102 }, + { url = "https://files.pythonhosted.org/packages/63/be/b85e4aa4bf42c6502851b971f1c326d583fcc68227385f92089cf50a7b45/numpy-2.2.5-cp313-cp313t-win_amd64.whl", hash = "sha256:d403c84991b5ad291d3809bace5e85f4bbf44a04bdc9a88ed2bb1807b3360bb8", size = 12750096 }, +] + +[[package]] +name = "odclib" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "eckitlib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/03/c10d15c9da7cd32d401c8991046bc4f9d4cfb705f66fae6b07ce4ace5b20/odclib-1.6.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:3f6aabab0847b55dd95970f4bf9ade3c9e752e38f87ff51c35bbb5201a2005ae", size = 673143 }, + { url = "https://files.pythonhosted.org/packages/a6/de/73da8b01893e909d5ecaafac032075538805bae341df6d1be85decace0f0/odclib-1.6.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:3f3183e00a408ddb5b8f95e2dbdcd6e67f3b8236095aca543513ed40a7328c68", size = 670521 }, + { url = "https://files.pythonhosted.org/packages/fd/b3/cab76b744c17561dc60896c4e132bf1e59a0563f1476e1c77ae66a9b30ea/odclib-1.6.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e42b8932d18607953a0a107e6a9a72b5bb502038cc65c114f4f74de9b4ca1a8e", size = 11971013 }, + { url = "https://files.pythonhosted.org/packages/b6/89/fec85f4325160a20931acb6197a6591fccfb739d7daeab0c03d1c4632f14/odclib-1.6.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:7f6a2660a79853d32d6495a15595532c73aa13ed96a9fbc253a3689a3ad98942", size = 673123 }, + { url = "https://files.pythonhosted.org/packages/70/04/138927c0153237c4a1bf5690585ccc43a0be179f71ea3190bc9cb84df3f8/odclib-1.6.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:3d6c7fcf2503ef411c5959a7729d2e13b83966a4fcad7b98a51518764d85482b", size = 670536 }, + { url = "https://files.pythonhosted.org/packages/c4/a1/40de63accbce8cab037f0d10d2d23af0b5e8df8859440b2254a89224c33b/odclib-1.6.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:25a5f34fbc333c97ad95ea58492688a9080d0559bc32aa9c054d7290c6576066", size = 11971011 }, +] + +[[package]] +name = "openai" +version = "1.77.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c0/ea2e9a78bf88404b97e7b708f0823b4699ab2ee3f5564425b8531a890a43/openai-1.77.0.tar.gz", hash = "sha256:897969f927f0068b8091b4b041d1f8175bcf124f7ea31bab418bf720971223bc", size = 435778 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/58/37ae3ca75936b824a0a5ca30491c968192007857319d6836764b548b9d9b/openai-1.77.0-py3-none-any.whl", hash = "sha256:07706e91eb71631234996989a8ea991d5ee56f0744ef694c961e0824d4f39218", size = 662031 }, +] + +[[package]] +name = "orjson" +version = "3.10.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/0b/fea456a3ffe74e70ba30e01ec183a9b26bec4d497f61dcfce1b601059c60/orjson-3.10.18.tar.gz", hash = "sha256:e8da3947d92123eda795b68228cafe2724815621fe35e8e320a9e9593a4bcd53", size = 5422810 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/1a/67236da0916c1a192d5f4ccbe10ec495367a726996ceb7614eaa687112f2/orjson-3.10.18-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:50c15557afb7f6d63bc6d6348e0337a880a04eaa9cd7c9d569bcb4e760a24753", size = 249184 }, + { url = "https://files.pythonhosted.org/packages/b3/bc/c7f1db3b1d094dc0c6c83ed16b161a16c214aaa77f311118a93f647b32dc/orjson-3.10.18-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:356b076f1662c9813d5fa56db7d63ccceef4c271b1fb3dd522aca291375fcf17", size = 133279 }, + { url = "https://files.pythonhosted.org/packages/af/84/664657cd14cc11f0d81e80e64766c7ba5c9b7fc1ec304117878cc1b4659c/orjson-3.10.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:559eb40a70a7494cd5beab2d73657262a74a2c59aff2068fdba8f0424ec5b39d", size = 136799 }, + { url = "https://files.pythonhosted.org/packages/9a/bb/f50039c5bb05a7ab024ed43ba25d0319e8722a0ac3babb0807e543349978/orjson-3.10.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3c29eb9a81e2fbc6fd7ddcfba3e101ba92eaff455b8d602bf7511088bbc0eae", size = 132791 }, + { url = "https://files.pythonhosted.org/packages/93/8c/ee74709fc072c3ee219784173ddfe46f699598a1723d9d49cbc78d66df65/orjson-3.10.18-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6612787e5b0756a171c7d81ba245ef63a3533a637c335aa7fcb8e665f4a0966f", size = 137059 }, + { url = "https://files.pythonhosted.org/packages/6a/37/e6d3109ee004296c80426b5a62b47bcadd96a3deab7443e56507823588c5/orjson-3.10.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ac6bd7be0dcab5b702c9d43d25e70eb456dfd2e119d512447468f6405b4a69c", size = 138359 }, + { url = "https://files.pythonhosted.org/packages/4f/5d/387dafae0e4691857c62bd02839a3bf3fa648eebd26185adfac58d09f207/orjson-3.10.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f72f100cee8dde70100406d5c1abba515a7df926d4ed81e20a9730c062fe9ad", size = 142853 }, + { url = "https://files.pythonhosted.org/packages/27/6f/875e8e282105350b9a5341c0222a13419758545ae32ad6e0fcf5f64d76aa/orjson-3.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dca85398d6d093dd41dc0983cbf54ab8e6afd1c547b6b8a311643917fbf4e0c", size = 133131 }, + { url = "https://files.pythonhosted.org/packages/48/b2/73a1f0b4790dcb1e5a45f058f4f5dcadc8a85d90137b50d6bbc6afd0ae50/orjson-3.10.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22748de2a07fcc8781a70edb887abf801bb6142e6236123ff93d12d92db3d406", size = 134834 }, + { url = "https://files.pythonhosted.org/packages/56/f5/7ed133a5525add9c14dbdf17d011dd82206ca6840811d32ac52a35935d19/orjson-3.10.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a83c9954a4107b9acd10291b7f12a6b29e35e8d43a414799906ea10e75438e6", size = 413368 }, + { url = "https://files.pythonhosted.org/packages/11/7c/439654221ed9c3324bbac7bdf94cf06a971206b7b62327f11a52544e4982/orjson-3.10.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:303565c67a6c7b1f194c94632a4a39918e067bd6176a48bec697393865ce4f06", size = 153359 }, + { url = "https://files.pythonhosted.org/packages/48/e7/d58074fa0cc9dd29a8fa2a6c8d5deebdfd82c6cfef72b0e4277c4017563a/orjson-3.10.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:86314fdb5053a2f5a5d881f03fca0219bfdf832912aa88d18676a5175c6916b5", size = 137466 }, + { url = "https://files.pythonhosted.org/packages/57/4d/fe17581cf81fb70dfcef44e966aa4003360e4194d15a3f38cbffe873333a/orjson-3.10.18-cp312-cp312-win32.whl", hash = "sha256:187ec33bbec58c76dbd4066340067d9ece6e10067bb0cc074a21ae3300caa84e", size = 142683 }, + { url = "https://files.pythonhosted.org/packages/e6/22/469f62d25ab5f0f3aee256ea732e72dc3aab6d73bac777bd6277955bceef/orjson-3.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:f9f94cf6d3f9cd720d641f8399e390e7411487e493962213390d1ae45c7814fc", size = 134754 }, + { url = "https://files.pythonhosted.org/packages/10/b0/1040c447fac5b91bc1e9c004b69ee50abb0c1ffd0d24406e1350c58a7fcb/orjson-3.10.18-cp312-cp312-win_arm64.whl", hash = "sha256:3d600be83fe4514944500fa8c2a0a77099025ec6482e8087d7659e891f23058a", size = 131218 }, + { url = "https://files.pythonhosted.org/packages/04/f0/8aedb6574b68096f3be8f74c0b56d36fd94bcf47e6c7ed47a7bd1474aaa8/orjson-3.10.18-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:69c34b9441b863175cc6a01f2935de994025e773f814412030f269da4f7be147", size = 249087 }, + { url = "https://files.pythonhosted.org/packages/bc/f7/7118f965541aeac6844fcb18d6988e111ac0d349c9b80cda53583e758908/orjson-3.10.18-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:1ebeda919725f9dbdb269f59bc94f861afbe2a27dce5608cdba2d92772364d1c", size = 133273 }, + { url = "https://files.pythonhosted.org/packages/fb/d9/839637cc06eaf528dd8127b36004247bf56e064501f68df9ee6fd56a88ee/orjson-3.10.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5adf5f4eed520a4959d29ea80192fa626ab9a20b2ea13f8f6dc58644f6927103", size = 136779 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/f226ecfef31a1f0e7d6bf9a31a0bbaf384c7cbe3fce49cc9c2acc51f902a/orjson-3.10.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7592bb48a214e18cd670974f289520f12b7aed1fa0b2e2616b8ed9e069e08595", size = 132811 }, + { url = "https://files.pythonhosted.org/packages/73/2d/371513d04143c85b681cf8f3bce743656eb5b640cb1f461dad750ac4b4d4/orjson-3.10.18-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f872bef9f042734110642b7a11937440797ace8c87527de25e0c53558b579ccc", size = 137018 }, + { url = "https://files.pythonhosted.org/packages/69/cb/a4d37a30507b7a59bdc484e4a3253c8141bf756d4e13fcc1da760a0b00cb/orjson-3.10.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0315317601149c244cb3ecef246ef5861a64824ccbcb8018d32c66a60a84ffbc", size = 138368 }, + { url = "https://files.pythonhosted.org/packages/1e/ae/cd10883c48d912d216d541eb3db8b2433415fde67f620afe6f311f5cd2ca/orjson-3.10.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0da26957e77e9e55a6c2ce2e7182a36a6f6b180ab7189315cb0995ec362e049", size = 142840 }, + { url = "https://files.pythonhosted.org/packages/6d/4c/2bda09855c6b5f2c055034c9eda1529967b042ff8d81a05005115c4e6772/orjson-3.10.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb70d489bc79b7519e5803e2cc4c72343c9dc1154258adf2f8925d0b60da7c58", size = 133135 }, + { url = "https://files.pythonhosted.org/packages/13/4a/35971fd809a8896731930a80dfff0b8ff48eeb5d8b57bb4d0d525160017f/orjson-3.10.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9e86a6af31b92299b00736c89caf63816f70a4001e750bda179e15564d7a034", size = 134810 }, + { url = "https://files.pythonhosted.org/packages/99/70/0fa9e6310cda98365629182486ff37a1c6578e34c33992df271a476ea1cd/orjson-3.10.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c382a5c0b5931a5fc5405053d36c1ce3fd561694738626c77ae0b1dfc0242ca1", size = 413491 }, + { url = "https://files.pythonhosted.org/packages/32/cb/990a0e88498babddb74fb97855ae4fbd22a82960e9b06eab5775cac435da/orjson-3.10.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8e4b2ae732431127171b875cb2668f883e1234711d3c147ffd69fe5be51a8012", size = 153277 }, + { url = "https://files.pythonhosted.org/packages/92/44/473248c3305bf782a384ed50dd8bc2d3cde1543d107138fd99b707480ca1/orjson-3.10.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d808e34ddb24fc29a4d4041dcfafbae13e129c93509b847b14432717d94b44f", size = 137367 }, + { url = "https://files.pythonhosted.org/packages/ad/fd/7f1d3edd4ffcd944a6a40e9f88af2197b619c931ac4d3cfba4798d4d3815/orjson-3.10.18-cp313-cp313-win32.whl", hash = "sha256:ad8eacbb5d904d5591f27dee4031e2c1db43d559edb8f91778efd642d70e6bea", size = 142687 }, + { url = "https://files.pythonhosted.org/packages/4b/03/c75c6ad46be41c16f4cfe0352a2d1450546f3c09ad2c9d341110cd87b025/orjson-3.10.18-cp313-cp313-win_amd64.whl", hash = "sha256:aed411bcb68bf62e85588f2a7e03a6082cc42e5a2796e06e72a962d7c6310b52", size = 134794 }, + { url = "https://files.pythonhosted.org/packages/c2/28/f53038a5a72cc4fd0b56c1eafb4ef64aec9685460d5ac34de98ca78b6e29/orjson-3.10.18-cp313-cp313-win_arm64.whl", hash = "sha256:f54c1385a0e6aba2f15a40d703b858bedad36ded0491e55d35d905b2c34a4cc3", size = 131186 }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "pandas" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/a3/fb2734118db0af37ea7433f57f722c0a56687e14b14690edff0cdb4b7e58/pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9", size = 12529893 }, + { url = "https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4", size = 11363475 }, + { url = "https://files.pythonhosted.org/packages/c6/2a/4bba3f03f7d07207481fed47f5b35f556c7441acddc368ec43d6643c5777/pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3", size = 15188645 }, + { url = "https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319", size = 12739445 }, + { url = "https://files.pythonhosted.org/packages/20/e8/45a05d9c39d2cea61ab175dbe6a2de1d05b679e8de2011da4ee190d7e748/pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", size = 16359235 }, + { url = "https://files.pythonhosted.org/packages/1d/99/617d07a6a5e429ff90c90da64d428516605a1ec7d7bea494235e1c3882de/pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", size = 14056756 }, + { url = "https://files.pythonhosted.org/packages/29/d4/1244ab8edf173a10fd601f7e13b9566c1b525c4f365d6bee918e68381889/pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", size = 11504248 }, + { url = "https://files.pythonhosted.org/packages/64/22/3b8f4e0ed70644e85cfdcd57454686b9057c6c38d2f74fe4b8bc2527214a/pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", size = 12477643 }, + { url = "https://files.pythonhosted.org/packages/e4/93/b3f5d1838500e22c8d793625da672f3eec046b1a99257666c94446969282/pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", size = 11281573 }, + { url = "https://files.pythonhosted.org/packages/f5/94/6c79b07f0e5aab1dcfa35a75f4817f5c4f677931d4234afcd75f0e6a66ca/pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", size = 15196085 }, + { url = "https://files.pythonhosted.org/packages/e8/31/aa8da88ca0eadbabd0a639788a6da13bb2ff6edbbb9f29aa786450a30a91/pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", size = 12711809 }, + { url = "https://files.pythonhosted.org/packages/ee/7c/c6dbdb0cb2a4344cacfb8de1c5808ca885b2e4dcfde8008266608f9372af/pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", size = 16356316 }, + { url = "https://files.pythonhosted.org/packages/57/b7/8b757e7d92023b832869fa8881a992696a0bfe2e26f72c9ae9f255988d42/pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", size = 14022055 }, + { url = "https://files.pythonhosted.org/packages/3b/bc/4b18e2b8c002572c5a441a64826252ce5da2aa738855747247a971988043/pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", size = 11481175 }, + { url = "https://files.pythonhosted.org/packages/76/a3/a5d88146815e972d40d19247b2c162e88213ef51c7c25993942c39dbf41d/pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", size = 12615650 }, + { url = "https://files.pythonhosted.org/packages/9c/8c/f0fd18f6140ddafc0c24122c8a964e48294acc579d47def376fef12bcb4a/pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", size = 11290177 }, + { url = "https://files.pythonhosted.org/packages/ed/f9/e995754eab9c0f14c6777401f7eece0943840b7a9fc932221c19d1abee9f/pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", size = 14651526 }, + { url = "https://files.pythonhosted.org/packages/25/b0/98d6ae2e1abac4f35230aa756005e8654649d305df9a28b16b9ae4353bff/pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", size = 11871013 }, + { url = "https://files.pythonhosted.org/packages/cc/57/0f72a10f9db6a4628744c8e8f0df4e6e21de01212c7c981d31e50ffc8328/pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", size = 15711620 }, + { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436 }, +] + +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, +] + +[[package]] +name = "partd" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "locket" }, + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/3a/3f06f34820a31257ddcabdfafc2672c5816be79c7e353b02c1f318daa7d4/partd-1.4.2.tar.gz", hash = "sha256:d022c33afbdc8405c226621b015e8067888173d85f7f5ecebb3cafed9a20f02c", size = 21029 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl", hash = "sha256:978e4ac767ec4ba5b86c6eaa52e5a2a3bc748a2ca839e8cc798f1cc6ce6efb0f", size = 18905 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "pdbufr" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "eccodes" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/12/c6c1c25d1cb7903d820720b35d49414d10dacd3b2aa04c7b9dba6a4c587c/pdbufr-0.12.2.tar.gz", hash = "sha256:5127db40ac3a84850dafe547193ff02f845a8a7c58ed9e68476ef5f4675b7709", size = 8699120 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/26/3cca5548ab32da7728d09e43e43ad4640b447ba2ddac48f25ee5f578d903/pdbufr-0.12.2-py3-none-any.whl", hash = "sha256:783100eb2fd2a590d2566f9ccaabe8f1df9f46f62936ed285a1c12d406d7d532", size = 23586 }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + +[[package]] +name = "pillow" +version = "11.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/40/052610b15a1b8961f52537cc8326ca6a881408bc2bdad0d852edeb6ed33b/pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f", size = 3190185 }, + { url = "https://files.pythonhosted.org/packages/e5/7e/b86dbd35a5f938632093dc40d1682874c33dcfe832558fc80ca56bfcb774/pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b", size = 3030306 }, + { url = "https://files.pythonhosted.org/packages/a4/5c/467a161f9ed53e5eab51a42923c33051bf8d1a2af4626ac04f5166e58e0c/pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d", size = 4416121 }, + { url = "https://files.pythonhosted.org/packages/62/73/972b7742e38ae0e2ac76ab137ca6005dcf877480da0d9d61d93b613065b4/pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4", size = 4501707 }, + { url = "https://files.pythonhosted.org/packages/e4/3a/427e4cb0b9e177efbc1a84798ed20498c4f233abde003c06d2650a6d60cb/pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d", size = 4522921 }, + { url = "https://files.pythonhosted.org/packages/fe/7c/d8b1330458e4d2f3f45d9508796d7caf0c0d3764c00c823d10f6f1a3b76d/pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4", size = 4612523 }, + { url = "https://files.pythonhosted.org/packages/b3/2f/65738384e0b1acf451de5a573d8153fe84103772d139e1e0bdf1596be2ea/pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443", size = 4587836 }, + { url = "https://files.pythonhosted.org/packages/6a/c5/e795c9f2ddf3debb2dedd0df889f2fe4b053308bb59a3cc02a0cd144d641/pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c", size = 4669390 }, + { url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309 }, + { url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768 }, + { url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087 }, + { url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098 }, + { url = "https://files.pythonhosted.org/packages/b5/09/29d5cd052f7566a63e5b506fac9c60526e9ecc553825551333e1e18a4858/pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830", size = 3030166 }, + { url = "https://files.pythonhosted.org/packages/71/5d/446ee132ad35e7600652133f9c2840b4799bbd8e4adba881284860da0a36/pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0", size = 4408674 }, + { url = "https://files.pythonhosted.org/packages/69/5f/cbe509c0ddf91cc3a03bbacf40e5c2339c4912d16458fcb797bb47bcb269/pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1", size = 4496005 }, + { url = "https://files.pythonhosted.org/packages/f9/b3/dd4338d8fb8a5f312021f2977fb8198a1184893f9b00b02b75d565c33b51/pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f", size = 4518707 }, + { url = "https://files.pythonhosted.org/packages/13/eb/2552ecebc0b887f539111c2cd241f538b8ff5891b8903dfe672e997529be/pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155", size = 4610008 }, + { url = "https://files.pythonhosted.org/packages/72/d1/924ce51bea494cb6e7959522d69d7b1c7e74f6821d84c63c3dc430cbbf3b/pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14", size = 4585420 }, + { url = "https://files.pythonhosted.org/packages/43/ab/8f81312d255d713b99ca37479a4cb4b0f48195e530cdc1611990eb8fd04b/pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b", size = 4667655 }, + { url = "https://files.pythonhosted.org/packages/94/86/8f2e9d2dc3d308dfd137a07fe1cc478df0a23d42a6c4093b087e738e4827/pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2", size = 2332329 }, + { url = "https://files.pythonhosted.org/packages/6d/ec/1179083b8d6067a613e4d595359b5fdea65d0a3b7ad623fee906e1b3c4d2/pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691", size = 2676388 }, + { url = "https://files.pythonhosted.org/packages/23/f1/2fc1e1e294de897df39fa8622d829b8828ddad938b0eaea256d65b84dd72/pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c", size = 2414950 }, + { url = "https://files.pythonhosted.org/packages/c4/3e/c328c48b3f0ead7bab765a84b4977acb29f101d10e4ef57a5e3400447c03/pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22", size = 3192759 }, + { url = "https://files.pythonhosted.org/packages/18/0e/1c68532d833fc8b9f404d3a642991441d9058eccd5606eab31617f29b6d4/pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7", size = 3033284 }, + { url = "https://files.pythonhosted.org/packages/b7/cb/6faf3fb1e7705fd2db74e070f3bf6f88693601b0ed8e81049a8266de4754/pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16", size = 4445826 }, + { url = "https://files.pythonhosted.org/packages/07/94/8be03d50b70ca47fb434a358919d6a8d6580f282bbb7af7e4aa40103461d/pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b", size = 4527329 }, + { url = "https://files.pythonhosted.org/packages/fd/a4/bfe78777076dc405e3bd2080bc32da5ab3945b5a25dc5d8acaa9de64a162/pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406", size = 4549049 }, + { url = "https://files.pythonhosted.org/packages/65/4d/eaf9068dc687c24979e977ce5677e253624bd8b616b286f543f0c1b91662/pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91", size = 4635408 }, + { url = "https://files.pythonhosted.org/packages/1d/26/0fd443365d9c63bc79feb219f97d935cd4b93af28353cba78d8e77b61719/pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751", size = 4614863 }, + { url = "https://files.pythonhosted.org/packages/49/65/dca4d2506be482c2c6641cacdba5c602bc76d8ceb618fd37de855653a419/pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9", size = 4692938 }, + { url = "https://files.pythonhosted.org/packages/b3/92/1ca0c3f09233bd7decf8f7105a1c4e3162fb9142128c74adad0fb361b7eb/pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd", size = 2335774 }, + { url = "https://files.pythonhosted.org/packages/a5/ac/77525347cb43b83ae905ffe257bbe2cc6fd23acb9796639a1f56aa59d191/pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e", size = 2681895 }, + { url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234 }, +] + +[[package]] +name = "pint" +version = "0.24.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flexcache" }, + { name = "flexparser" }, + { name = "platformdirs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/bb/52b15ddf7b7706ed591134a895dbf6e41c8348171fb635e655e0a4bbb0ea/pint-0.24.4.tar.gz", hash = "sha256:35275439b574837a6cd3020a5a4a73645eb125ce4152a73a2f126bf164b91b80", size = 342225 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/16/bd2f5904557265882108dc2e04f18abc05ab0c2b7082ae9430091daf1d5c/Pint-0.24.4-py3-none-any.whl", hash = "sha256:aa54926c8772159fcf65f82cc0d34de6768c151b32ad1deb0331291c38fe7659", size = 302029 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, +] + +[[package]] +name = "plotly" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/cc/e41b5f697ae403f0b50e47b7af2e36642a193085f553bf7cc1169362873a/plotly-6.0.1.tar.gz", hash = "sha256:dd8400229872b6e3c964b099be699f8d00c489a974f2cfccfad5e8240873366b", size = 8094643 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/65/ad2bc85f7377f5cfba5d4466d5474423a3fb7f6a97fd807c06f92dd3e721/plotly-6.0.1-py3-none-any.whl", hash = "sha256:4714db20fea57a435692c548a4eb4fae454f7daddf15f8d8ba7e1045681d7768", size = 14805757 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "polytope-client" +version = "0.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "docstring-parser" }, + { name = "jsonschema" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/96/b4bc2a4a7922cbe3fe89c398f9b3da105d65e7989c35725bdd6e45b0cd5e/polytope-client-0.7.4.tar.gz", hash = "sha256:42dfa62e23d3e7ce5a60bec8cea70a154277ce3a5cf1c10cd72a1e4bfcb37e02", size = 36102 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/75/e3fd211c9b273df86ff387b989c794b6bd44ddc01dd00c720a18bfd01cde/polytope_client-0.7.4-py3-none-any.whl", hash = "sha256:fc038b41c6e0c6ad11efc8953f58e6399ef65e66dfa3953edfbb84510f49a823", size = 49680 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810 }, +] + +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051 }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535 }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004 }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986 }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544 }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053 }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885 }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pydantic" +version = "2.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, +] + +[[package]] +name = "pyfdb" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "eccodes" }, + { name = "findlibs" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/0c/50f3294977012257dcb17790bab6722c9a58bc307392cf56b9bc2091f430/pyfdb-0.1.2.tar.gz", hash = "sha256:cbb79f159db6bf5453d0f3a26504624ce0fc9450ea9e2d8f7691705506dbf276", size = 17170 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d0/20e57d63e8a67aefc1c600c2d13fe89f095297d75e38839841c308da11ab/pyfdb-0.1.2-py3-none-any.whl", hash = "sha256:31af9d05063440253c9ab48b4e96fc6f1e76f5e5fbf0cdea5f19dbad1593e837", size = 16883 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pyodc" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "findlibs" }, + { name = "odclib" }, + { name = "packaging" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/8b/a2f8ab8a1c6bca623e5781d8bfaf06a8ecbbe988e2db4adf6025268104f1/pyodc-1.6.0.tar.gz", hash = "sha256:702ce63f91e5a0ac75812c1b7a298c7bc95e7287531155f8a6e10ca31cd345aa", size = 41193 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/47/7c905f0a7bee5694d71de3dc46904aa4c808e108a463fc49f855b690ca1c/pyodc-1.6.0-py3-none-any.whl", hash = "sha256:af3e083273285ff9e29020ae18e21523132d438173526e18078da27ed1810420", size = 32383 }, +] + +[[package]] +name = "pyogrio" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "numpy" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/8f/5a784595524a79c269f2b1c880f4fdb152867df700c97005dda51997da02/pyogrio-0.10.0.tar.gz", hash = "sha256:ec051cb568324de878828fae96379b71858933413e185148acb6c162851ab23c", size = 281950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/b5/3c5dfd0b50cbce6f3d4e42c0484647feb1809dbe20e225c4c6abd067e69f/pyogrio-0.10.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2d6558b180e020f71ab7aa7f82d592ed3305c9f698d98f6d0a4637ec7a84c4ce", size = 15079211 }, + { url = "https://files.pythonhosted.org/packages/b8/9a/1ba9c707a094976f343bd0177741eaba0e842fa05ecd8ab97192db4f2ec1/pyogrio-0.10.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:a99102037eead8ba491bc57825c1e395ee31c9956d7bff7b4a9e4fdbff3a13c2", size = 16442782 }, + { url = "https://files.pythonhosted.org/packages/5e/bb/b4250746c2c85fea5004cae93e9e25ad01516e9e94e04de780a2e78139da/pyogrio-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a4c373281d7cbf560c5b61f8f3c7442103ad7f1c7ac4ef3a84572ed7a5dd2f6", size = 23899832 }, + { url = "https://files.pythonhosted.org/packages/bd/4c/79e47e40a8e54e79a45133786a0a58209534f580591c933d40c5ed314fe7/pyogrio-0.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:19f18411bdf836d24cdc08b9337eb3ec415e4ac4086ba64516b36b73a2e88622", size = 23081469 }, + { url = "https://files.pythonhosted.org/packages/47/78/2b62c8a340bcb0ea56b9ddf2ef5fd3d1f101dc0e98816b9e6da87c5ac3b7/pyogrio-0.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1abbcdd9876f30bebf1df8a0273f6cdeb29d03259290008275c7fddebe139f20", size = 24024758 }, + { url = "https://files.pythonhosted.org/packages/43/97/34605480f06b0ad9611bf58a174eccc6f3673275f3d519cf763391892881/pyogrio-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2a3e09839590d71ff832aa95c4f23fa00a2c63c3de82c1fbd4fb8d265792acfc", size = 16160294 }, + { url = "https://files.pythonhosted.org/packages/14/4a/4c8e4f5b9edbca46e0f8d6c1c0b56c0d4af0900c29f4bea22d37853c07f3/pyogrio-0.10.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c90478209537a31dcc65664a87a04c094bb0e08efe502908a6682b8cec0259bf", size = 15076879 }, + { url = "https://files.pythonhosted.org/packages/5f/be/7db0644eef9ef3382518399aaf3332827c43018112d2a74f78784fd496ec/pyogrio-0.10.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:fec45e1963b7058e5a1aa98598aed07c0858512c833d6aad2c672c3ec98bbf04", size = 16440405 }, + { url = "https://files.pythonhosted.org/packages/96/77/f199230ba86fe88b1f57e71428c169ed982de68a32d6082cd7c12d0f5d55/pyogrio-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28cb139f8a5d0365ede602230104b407ae52bb6b55173c8d5a35424d28c4a2c5", size = 23871511 }, + { url = "https://files.pythonhosted.org/packages/25/ac/ca483bec408b59c54f7129b0244cc9de21d8461aefe89ece7bd74ad33807/pyogrio-0.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:cea0187fcc2d574e52af8cfab041fa0a7ad71d5ef6b94b49a3f3d2a04534a27e", size = 23048830 }, + { url = "https://files.pythonhosted.org/packages/d7/3e/c35f2d8dad95b24e568c468f09ff60fb61945065465e0ec7868400596566/pyogrio-0.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:7c02b207ea8cf09c501ea3e95d29152781a00d3c32267286bc36fa457c332205", size = 23996873 }, + { url = "https://files.pythonhosted.org/packages/27/5d/0deb16d228362a097ee3258d0a887c9c0add4b9678bb4847b08a241e124d/pyogrio-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:02e54bcfb305af75f829044b0045f74de31b77c2d6546f7aaf96822066147848", size = 16158260 }, +] + +[[package]] +name = "pyparsing" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120 }, +] + +[[package]] +name = "pyproj" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/10/a8480ea27ea4bbe896c168808854d00f2a9b49f95c0319ddcbba693c8a90/pyproj-3.7.1.tar.gz", hash = "sha256:60d72facd7b6b79853f19744779abcd3f804c4e0d4fa8815469db20c9f640a47", size = 226339 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/c9/876d4345b8d17f37ac59ebd39f8fa52fc6a6a9891a420f72d050edb6b899/pyproj-3.7.1-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:2781029d90df7f8d431e29562a3f2d8eafdf233c4010d6fc0381858dc7373217", size = 6264087 }, + { url = "https://files.pythonhosted.org/packages/ff/e6/5f8691f8c90e7f402cc80a6276eb19d2ec1faa150d5ae2dd9c7b0a254da8/pyproj-3.7.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d61bf8ab04c73c1da08eedaf21a103b72fa5b0a9b854762905f65ff8b375d394", size = 4669628 }, + { url = "https://files.pythonhosted.org/packages/42/ec/16475bbb79c1c68845c0a0d9c60c4fb31e61b8a2a20bc18b1a81e81c7f68/pyproj-3.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04abc517a8555d1b05fcee768db3280143fe42ec39fdd926a2feef31631a1f2f", size = 9721415 }, + { url = "https://files.pythonhosted.org/packages/b3/a3/448f05b15e318bd6bea9a32cfaf11e886c4ae61fa3eee6e09ed5c3b74bb2/pyproj-3.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084c0a475688f934d386c2ab3b6ce03398a473cd48adfda70d9ab8f87f2394a0", size = 9556447 }, + { url = "https://files.pythonhosted.org/packages/6a/ae/bd15fe8d8bd914ead6d60bca7f895a4e6f8ef7e3928295134ff9a7dad14c/pyproj-3.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a20727a23b1e49c7dc7fe3c3df8e56a8a7acdade80ac2f5cca29d7ca5564c145", size = 10758317 }, + { url = "https://files.pythonhosted.org/packages/9d/d9/5ccefb8bca925f44256b188a91c31238cae29ab6ee7f53661ecc04616146/pyproj-3.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bf84d766646f1ebd706d883755df4370aaf02b48187cedaa7e4239f16bc8213d", size = 10771259 }, + { url = "https://files.pythonhosted.org/packages/2a/7d/31dedff9c35fa703162f922eeb0baa6c44a3288469a5fd88d209e2892f9e/pyproj-3.7.1-cp312-cp312-win32.whl", hash = "sha256:5f0da2711364d7cb9f115b52289d4a9b61e8bca0da57f44a3a9d6fc9bdeb7274", size = 5859914 }, + { url = "https://files.pythonhosted.org/packages/3e/47/c6ab03d6564a7c937590cff81a2742b5990f096cce7c1a622d325be340ee/pyproj-3.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:aee664a9d806612af30a19dba49e55a7a78ebfec3e9d198f6a6176e1d140ec98", size = 6273196 }, + { url = "https://files.pythonhosted.org/packages/ef/01/984828464c9960036c602753fc0f21f24f0aa9043c18fa3f2f2b66a86340/pyproj-3.7.1-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:5f8d02ef4431dee414d1753d13fa82a21a2f61494737b5f642ea668d76164d6d", size = 6253062 }, + { url = "https://files.pythonhosted.org/packages/68/65/6ecdcdc829811a2c160cdfe2f068a009fc572fd4349664f758ccb0853a7c/pyproj-3.7.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0b853ae99bda66cbe24b4ccfe26d70601d84375940a47f553413d9df570065e0", size = 4660548 }, + { url = "https://files.pythonhosted.org/packages/67/da/dda94c4490803679230ba4c17a12f151b307a0d58e8110820405ca2d98db/pyproj-3.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83db380c52087f9e9bdd8a527943b2e7324f275881125e39475c4f9277bdeec4", size = 9662464 }, + { url = "https://files.pythonhosted.org/packages/6f/57/f61b7d22c91ae1d12ee00ac4c0038714e774ebcd851b9133e5f4f930dd40/pyproj-3.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b35ed213892e211a3ce2bea002aa1183e1a2a9b79e51bb3c6b15549a831ae528", size = 9497461 }, + { url = "https://files.pythonhosted.org/packages/b7/f6/932128236f79d2ac7d39fe1a19667fdf7155d9a81d31fb9472a7a497790f/pyproj-3.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a8b15b0463d1303bab113d1a6af2860a0d79013c3a66fcc5475ce26ef717fd4f", size = 10708869 }, + { url = "https://files.pythonhosted.org/packages/1d/0d/07ac7712994454a254c383c0d08aff9916a2851e6512d59da8dc369b1b02/pyproj-3.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:87229e42b75e89f4dad6459200f92988c5998dfb093c7c631fb48524c86cd5dc", size = 10729260 }, + { url = "https://files.pythonhosted.org/packages/b0/d0/9c604bc72c37ba69b867b6df724d6a5af6789e8c375022c952f65b2af558/pyproj-3.7.1-cp313-cp313-win32.whl", hash = "sha256:d666c3a3faaf3b1d7fc4a544059c4eab9d06f84a604b070b7aa2f318e227798e", size = 5855462 }, + { url = "https://files.pythonhosted.org/packages/98/df/68a2b7f5fb6400c64aad82d72bcc4bc531775e62eedff993a77c780defd0/pyproj-3.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:d3caac7473be22b6d6e102dde6c46de73b96bc98334e577dfaee9886f102ea2e", size = 6266573 }, +] + +[[package]] +name = "pyshp" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9f/0dd21250c60375a532c35e89fad8d5e8a3f1a2e3f7c389ccc5a60b05263e/pyshp-2.3.1.tar.gz", hash = "sha256:4caec82fd8dd096feba8217858068bacb2a3b5950f43c048c6dc32a3489d5af1", size = 1731544 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/2f/68116db5b36b895c0450e3072b8cb6c2fac0359279b182ea97014d3c8ac0/pyshp-2.3.1-py2.py3-none-any.whl", hash = "sha256:67024c0ccdc352ba5db777c4e968483782dfa78f8e200672a90d2d30fd8b7b49", size = 46537 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, +] + +[[package]] +name = "pywin32" +version = "310" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/ec/4fdbe47932f671d6e348474ea35ed94227fb5df56a7c30cbbb42cd396ed0/pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d", size = 8796239 }, + { url = "https://files.pythonhosted.org/packages/e3/e5/b0627f8bb84e06991bea89ad8153a9e50ace40b2e1195d68e9dff6b03d0f/pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060", size = 9503839 }, + { url = "https://files.pythonhosted.org/packages/1f/32/9ccf53748df72301a89713936645a664ec001abd35ecc8578beda593d37d/pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966", size = 8459470 }, + { url = "https://files.pythonhosted.org/packages/1c/09/9c1b978ffc4ae53999e89c19c77ba882d9fce476729f23ef55211ea1c034/pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab", size = 8794384 }, + { url = "https://files.pythonhosted.org/packages/45/3c/b4640f740ffebadd5d34df35fecba0e1cfef8fde9f3e594df91c28ad9b50/pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e", size = 9503039 }, + { url = "https://files.pythonhosted.org/packages/b4/f4/f785020090fb050e7fb6d34b780f2231f302609dc964672f72bfaeb59a28/pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33", size = 8458152 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "pyzmq" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/11/b9213d25230ac18a71b39b3723494e57adebe36e066397b961657b3b41c1/pyzmq-26.4.0.tar.gz", hash = "sha256:4bd13f85f80962f91a651a7356fe0472791a5f7a92f227822b5acf44795c626d", size = 278293 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/44/a778555ebfdf6c7fc00816aad12d185d10a74d975800341b1bc36bad1187/pyzmq-26.4.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:5227cb8da4b6f68acfd48d20c588197fd67745c278827d5238c707daf579227b", size = 1341586 }, + { url = "https://files.pythonhosted.org/packages/9c/4f/f3a58dc69ac757e5103be3bd41fb78721a5e17da7cc617ddb56d973a365c/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1c07a7fa7f7ba86554a2b1bef198c9fed570c08ee062fd2fd6a4dcacd45f905", size = 665880 }, + { url = "https://files.pythonhosted.org/packages/fe/45/50230bcfb3ae5cb98bee683b6edeba1919f2565d7cc1851d3c38e2260795/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae775fa83f52f52de73183f7ef5395186f7105d5ed65b1ae65ba27cb1260de2b", size = 902216 }, + { url = "https://files.pythonhosted.org/packages/41/59/56bbdc5689be5e13727491ad2ba5efd7cd564365750514f9bc8f212eef82/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c760d0226ebd52f1e6b644a9e839b5db1e107a23f2fcd46ec0569a4fdd4e63", size = 859814 }, + { url = "https://files.pythonhosted.org/packages/81/b1/57db58cfc8af592ce94f40649bd1804369c05b2190e4cbc0a2dad572baeb/pyzmq-26.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ef8c6ecc1d520debc147173eaa3765d53f06cd8dbe7bd377064cdbc53ab456f5", size = 855889 }, + { url = "https://files.pythonhosted.org/packages/e8/92/47542e629cbac8f221c230a6d0f38dd3d9cff9f6f589ed45fdf572ffd726/pyzmq-26.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3150ef4084e163dec29ae667b10d96aad309b668fac6810c9e8c27cf543d6e0b", size = 1197153 }, + { url = "https://files.pythonhosted.org/packages/07/e5/b10a979d1d565d54410afc87499b16c96b4a181af46e7645ab4831b1088c/pyzmq-26.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4448c9e55bf8329fa1dcedd32f661bf611214fa70c8e02fee4347bc589d39a84", size = 1507352 }, + { url = "https://files.pythonhosted.org/packages/ab/58/5a23db84507ab9c01c04b1232a7a763be66e992aa2e66498521bbbc72a71/pyzmq-26.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e07dde3647afb084d985310d067a3efa6efad0621ee10826f2cb2f9a31b89d2f", size = 1406834 }, + { url = "https://files.pythonhosted.org/packages/22/74/aaa837b331580c13b79ac39396601fb361454ee184ca85e8861914769b99/pyzmq-26.4.0-cp312-cp312-win32.whl", hash = "sha256:ba034a32ecf9af72adfa5ee383ad0fd4f4e38cdb62b13624278ef768fe5b5b44", size = 577992 }, + { url = "https://files.pythonhosted.org/packages/30/0f/55f8c02c182856743b82dde46b2dc3e314edda7f1098c12a8227eeda0833/pyzmq-26.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:056a97aab4064f526ecb32f4343917a4022a5d9efb6b9df990ff72e1879e40be", size = 640466 }, + { url = "https://files.pythonhosted.org/packages/e4/29/073779afc3ef6f830b8de95026ef20b2d1ec22d0324d767748d806e57379/pyzmq-26.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f23c750e485ce1eb639dbd576d27d168595908aa2d60b149e2d9e34c9df40e0", size = 556342 }, + { url = "https://files.pythonhosted.org/packages/d7/20/fb2c92542488db70f833b92893769a569458311a76474bda89dc4264bd18/pyzmq-26.4.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:c43fac689880f5174d6fc864857d1247fe5cfa22b09ed058a344ca92bf5301e3", size = 1339484 }, + { url = "https://files.pythonhosted.org/packages/58/29/2f06b9cabda3a6ea2c10f43e67ded3e47fc25c54822e2506dfb8325155d4/pyzmq-26.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:902aca7eba477657c5fb81c808318460328758e8367ecdd1964b6330c73cae43", size = 666106 }, + { url = "https://files.pythonhosted.org/packages/77/e4/dcf62bd29e5e190bd21bfccaa4f3386e01bf40d948c239239c2f1e726729/pyzmq-26.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5e48a830bfd152fe17fbdeaf99ac5271aa4122521bf0d275b6b24e52ef35eb6", size = 902056 }, + { url = "https://files.pythonhosted.org/packages/1a/cf/b36b3d7aea236087d20189bec1a87eeb2b66009731d7055e5c65f845cdba/pyzmq-26.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31be2b6de98c824c06f5574331f805707c667dc8f60cb18580b7de078479891e", size = 860148 }, + { url = "https://files.pythonhosted.org/packages/18/a6/f048826bc87528c208e90604c3bf573801e54bd91e390cbd2dfa860e82dc/pyzmq-26.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6332452034be001bbf3206ac59c0d2a7713de5f25bb38b06519fc6967b7cf771", size = 855983 }, + { url = "https://files.pythonhosted.org/packages/0a/27/454d34ab6a1d9772a36add22f17f6b85baf7c16e14325fa29e7202ca8ee8/pyzmq-26.4.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:da8c0f5dd352136853e6a09b1b986ee5278dfddfebd30515e16eae425c872b30", size = 1197274 }, + { url = "https://files.pythonhosted.org/packages/f4/3d/7abfeab6b83ad38aa34cbd57c6fc29752c391e3954fd12848bd8d2ec0df6/pyzmq-26.4.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f4ccc1a0a2c9806dda2a2dd118a3b7b681e448f3bb354056cad44a65169f6d86", size = 1507120 }, + { url = "https://files.pythonhosted.org/packages/13/ff/bc8d21dbb9bc8705126e875438a1969c4f77e03fc8565d6901c7933a3d01/pyzmq-26.4.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c0b5fceadbab461578daf8d1dcc918ebe7ddd2952f748cf30c7cf2de5d51101", size = 1406738 }, + { url = "https://files.pythonhosted.org/packages/f5/5d/d4cd85b24de71d84d81229e3bbb13392b2698432cf8fdcea5afda253d587/pyzmq-26.4.0-cp313-cp313-win32.whl", hash = "sha256:28e2b0ff5ba4b3dd11062d905682bad33385cfa3cc03e81abd7f0822263e6637", size = 577826 }, + { url = "https://files.pythonhosted.org/packages/c6/6c/f289c1789d7bb6e5a3b3bef7b2a55089b8561d17132be7d960d3ff33b14e/pyzmq-26.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:23ecc9d241004c10e8b4f49d12ac064cd7000e1643343944a10df98e57bc544b", size = 640406 }, + { url = "https://files.pythonhosted.org/packages/b3/99/676b8851cb955eb5236a0c1e9ec679ea5ede092bf8bf2c8a68d7e965cac3/pyzmq-26.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:1edb0385c7f025045d6e0f759d4d3afe43c17a3d898914ec6582e6f464203c08", size = 556216 }, + { url = "https://files.pythonhosted.org/packages/65/c2/1fac340de9d7df71efc59d9c50fc7a635a77b103392d1842898dd023afcb/pyzmq-26.4.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:93a29e882b2ba1db86ba5dd5e88e18e0ac6b627026c5cfbec9983422011b82d4", size = 1333769 }, + { url = "https://files.pythonhosted.org/packages/5c/c7/6c03637e8d742c3b00bec4f5e4cd9d1c01b2f3694c6f140742e93ca637ed/pyzmq-26.4.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45684f276f57110bb89e4300c00f1233ca631f08f5f42528a5c408a79efc4a", size = 658826 }, + { url = "https://files.pythonhosted.org/packages/a5/97/a8dca65913c0f78e0545af2bb5078aebfc142ca7d91cdaffa1fbc73e5dbd/pyzmq-26.4.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f72073e75260cb301aad4258ad6150fa7f57c719b3f498cb91e31df16784d89b", size = 891650 }, + { url = "https://files.pythonhosted.org/packages/7d/7e/f63af1031eb060bf02d033732b910fe48548dcfdbe9c785e9f74a6cc6ae4/pyzmq-26.4.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be37e24b13026cfedd233bcbbccd8c0bcd2fdd186216094d095f60076201538d", size = 849776 }, + { url = "https://files.pythonhosted.org/packages/f6/fa/1a009ce582802a895c0d5fe9413f029c940a0a8ee828657a3bb0acffd88b/pyzmq-26.4.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:237b283044934d26f1eeff4075f751b05d2f3ed42a257fc44386d00df6a270cf", size = 842516 }, + { url = "https://files.pythonhosted.org/packages/6e/bc/f88b0bad0f7a7f500547d71e99f10336f2314e525d4ebf576a1ea4a1d903/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b30f862f6768b17040929a68432c8a8be77780317f45a353cb17e423127d250c", size = 1189183 }, + { url = "https://files.pythonhosted.org/packages/d9/8c/db446a3dd9cf894406dec2e61eeffaa3c07c3abb783deaebb9812c4af6a5/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:c80fcd3504232f13617c6ab501124d373e4895424e65de8b72042333316f64a8", size = 1495501 }, + { url = "https://files.pythonhosted.org/packages/05/4c/bf3cad0d64c3214ac881299c4562b815f05d503bccc513e3fd4fdc6f67e4/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:26a2a7451606b87f67cdeca2c2789d86f605da08b4bd616b1a9981605ca3a364", size = 1395540 }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, +] + +[[package]] +name = "rich-argparse" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/b9/ff53663ee7fa6a4195fa96d91da499f2e00ca067541e016d345cce1c9ad2/rich_argparse-1.7.0.tar.gz", hash = "sha256:f31d809c465ee43f367d599ccaf88b73bc2c4d75d74ed43f2d538838c53544ba", size = 38009 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/9c/dc7cbeb99a7b7422392ed7f327efdbb958bc0faf424aef5f130309320bda/rich_argparse-1.7.0-py3-none-any.whl", hash = "sha256:b8ec8943588e9731967f4f97b735b03dc127c416f480a083060433a97baf2fd3", size = 25339 }, +] + +[[package]] +name = "rpds-py" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/b3/52b213298a0ba7097c7ea96bee95e1947aa84cc816d48cebb539770cdf41/rpds_py-0.24.0.tar.gz", hash = "sha256:772cc1b2cd963e7e17e6cc55fe0371fb9c704d63e44cacec7b9b7f523b78919e", size = 26863 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/e0/1c55f4a3be5f1ca1a4fd1f3ff1504a1478c1ed48d84de24574c4fa87e921/rpds_py-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8551e733626afec514b5d15befabea0dd70a343a9f23322860c4f16a9430205", size = 366945 }, + { url = "https://files.pythonhosted.org/packages/39/1b/a3501574fbf29118164314dbc800d568b8c1c7b3258b505360e8abb3902c/rpds_py-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e374c0ce0ca82e5b67cd61fb964077d40ec177dd2c4eda67dba130de09085c7", size = 351935 }, + { url = "https://files.pythonhosted.org/packages/dc/47/77d3d71c55f6a374edde29f1aca0b2e547325ed00a9da820cabbc9497d2b/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d69d003296df4840bd445a5d15fa5b6ff6ac40496f956a221c4d1f6f7b4bc4d9", size = 390817 }, + { url = "https://files.pythonhosted.org/packages/4e/ec/1e336ee27484379e19c7f9cc170f4217c608aee406d3ae3a2e45336bff36/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8212ff58ac6dfde49946bea57474a386cca3f7706fc72c25b772b9ca4af6b79e", size = 401983 }, + { url = "https://files.pythonhosted.org/packages/07/f8/39b65cbc272c635eaea6d393c2ad1ccc81c39eca2db6723a0ca4b2108fce/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:528927e63a70b4d5f3f5ccc1fa988a35456eb5d15f804d276709c33fc2f19bda", size = 451719 }, + { url = "https://files.pythonhosted.org/packages/32/05/05c2b27dd9c30432f31738afed0300659cb9415db0ff7429b05dfb09bbde/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a824d2c7a703ba6daaca848f9c3d5cb93af0505be505de70e7e66829affd676e", size = 442546 }, + { url = "https://files.pythonhosted.org/packages/7d/e0/19383c8b5d509bd741532a47821c3e96acf4543d0832beba41b4434bcc49/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44d51febb7a114293ffd56c6cf4736cb31cd68c0fddd6aa303ed09ea5a48e029", size = 393695 }, + { url = "https://files.pythonhosted.org/packages/9d/15/39f14e96d94981d0275715ae8ea564772237f3fa89bc3c21e24de934f2c7/rpds_py-0.24.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fab5f4a2c64a8fb64fc13b3d139848817a64d467dd6ed60dcdd6b479e7febc9", size = 427218 }, + { url = "https://files.pythonhosted.org/packages/22/b9/12da7124905a680f690da7a9de6f11de770b5e359f5649972f7181c8bf51/rpds_py-0.24.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9be4f99bee42ac107870c61dfdb294d912bf81c3c6d45538aad7aecab468b6b7", size = 568062 }, + { url = "https://files.pythonhosted.org/packages/88/17/75229017a2143d915f6f803721a6d721eca24f2659c5718a538afa276b4f/rpds_py-0.24.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:564c96b6076a98215af52f55efa90d8419cc2ef45d99e314fddefe816bc24f91", size = 596262 }, + { url = "https://files.pythonhosted.org/packages/aa/64/8e8a1d8bd1b6b638d6acb6d41ab2cec7f2067a5b8b4c9175703875159a7c/rpds_py-0.24.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:75a810b7664c17f24bf2ffd7f92416c00ec84b49bb68e6a0d93e542406336b56", size = 564306 }, + { url = "https://files.pythonhosted.org/packages/68/1c/a7eac8d8ed8cb234a9b1064647824c387753343c3fab6ed7c83481ed0be7/rpds_py-0.24.0-cp312-cp312-win32.whl", hash = "sha256:f6016bd950be4dcd047b7475fdf55fb1e1f59fc7403f387be0e8123e4a576d30", size = 224281 }, + { url = "https://files.pythonhosted.org/packages/bb/46/b8b5424d1d21f2f2f3f2d468660085318d4f74a8df8289e3dd6ad224d488/rpds_py-0.24.0-cp312-cp312-win_amd64.whl", hash = "sha256:998c01b8e71cf051c28f5d6f1187abbdf5cf45fc0efce5da6c06447cba997034", size = 239719 }, + { url = "https://files.pythonhosted.org/packages/9d/c3/3607abc770395bc6d5a00cb66385a5479fb8cd7416ddef90393b17ef4340/rpds_py-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2d8e4508e15fc05b31285c4b00ddf2e0eb94259c2dc896771966a163122a0c", size = 367072 }, + { url = "https://files.pythonhosted.org/packages/d8/35/8c7ee0fe465793e3af3298dc5a9f3013bd63e7a69df04ccfded8293a4982/rpds_py-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f00c16e089282ad68a3820fd0c831c35d3194b7cdc31d6e469511d9bffc535c", size = 351919 }, + { url = "https://files.pythonhosted.org/packages/91/d3/7e1b972501eb5466b9aca46a9c31bcbbdc3ea5a076e9ab33f4438c1d069d/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951cc481c0c395c4a08639a469d53b7d4afa252529a085418b82a6b43c45c240", size = 390360 }, + { url = "https://files.pythonhosted.org/packages/a2/a8/ccabb50d3c91c26ad01f9b09a6a3b03e4502ce51a33867c38446df9f896b/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9ca89938dff18828a328af41ffdf3902405a19f4131c88e22e776a8e228c5a8", size = 400704 }, + { url = "https://files.pythonhosted.org/packages/53/ae/5fa5bf0f3bc6ce21b5ea88fc0ecd3a439e7cb09dd5f9ffb3dbe1b6894fc5/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0ef550042a8dbcd657dfb284a8ee00f0ba269d3f2286b0493b15a5694f9fe8", size = 450839 }, + { url = "https://files.pythonhosted.org/packages/e3/ac/c4e18b36d9938247e2b54f6a03746f3183ca20e1edd7d3654796867f5100/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b2356688e5d958c4d5cb964af865bea84db29971d3e563fb78e46e20fe1848b", size = 441494 }, + { url = "https://files.pythonhosted.org/packages/bf/08/b543969c12a8f44db6c0f08ced009abf8f519191ca6985509e7c44102e3c/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78884d155fd15d9f64f5d6124b486f3d3f7fd7cd71a78e9670a0f6f6ca06fb2d", size = 393185 }, + { url = "https://files.pythonhosted.org/packages/da/7e/f6eb6a7042ce708f9dfc781832a86063cea8a125bbe451d663697b51944f/rpds_py-0.24.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a4a535013aeeef13c5532f802708cecae8d66c282babb5cd916379b72110cf7", size = 426168 }, + { url = "https://files.pythonhosted.org/packages/38/b0/6cd2bb0509ac0b51af4bb138e145b7c4c902bb4b724d6fd143689d6e0383/rpds_py-0.24.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:84e0566f15cf4d769dade9b366b7b87c959be472c92dffb70462dd0844d7cbad", size = 567622 }, + { url = "https://files.pythonhosted.org/packages/64/b0/c401f4f077547d98e8b4c2ec6526a80e7cb04f519d416430ec1421ee9e0b/rpds_py-0.24.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:823e74ab6fbaa028ec89615ff6acb409e90ff45580c45920d4dfdddb069f2120", size = 595435 }, + { url = "https://files.pythonhosted.org/packages/9f/ec/7993b6e803294c87b61c85bd63e11142ccfb2373cf88a61ec602abcbf9d6/rpds_py-0.24.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c61a2cb0085c8783906b2f8b1f16a7e65777823c7f4d0a6aaffe26dc0d358dd9", size = 563762 }, + { url = "https://files.pythonhosted.org/packages/1f/29/4508003204cb2f461dc2b83dd85f8aa2b915bc98fe6046b9d50d4aa05401/rpds_py-0.24.0-cp313-cp313-win32.whl", hash = "sha256:60d9b630c8025b9458a9d114e3af579a2c54bd32df601c4581bd054e85258143", size = 223510 }, + { url = "https://files.pythonhosted.org/packages/f9/12/09e048d1814195e01f354155fb772fb0854bd3450b5f5a82224b3a319f0e/rpds_py-0.24.0-cp313-cp313-win_amd64.whl", hash = "sha256:6eea559077d29486c68218178ea946263b87f1c41ae7f996b1f30a983c476a5a", size = 239075 }, + { url = "https://files.pythonhosted.org/packages/d2/03/5027cde39bb2408d61e4dd0cf81f815949bb629932a6c8df1701d0257fc4/rpds_py-0.24.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:d09dc82af2d3c17e7dd17120b202a79b578d79f2b5424bda209d9966efeed114", size = 362974 }, + { url = "https://files.pythonhosted.org/packages/bf/10/24d374a2131b1ffafb783e436e770e42dfdb74b69a2cd25eba8c8b29d861/rpds_py-0.24.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5fc13b44de6419d1e7a7e592a4885b323fbc2f46e1f22151e3a8ed3b8b920405", size = 348730 }, + { url = "https://files.pythonhosted.org/packages/7a/d1/1ef88d0516d46cd8df12e5916966dbf716d5ec79b265eda56ba1b173398c/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c347a20d79cedc0a7bd51c4d4b7dbc613ca4e65a756b5c3e57ec84bd43505b47", size = 387627 }, + { url = "https://files.pythonhosted.org/packages/4e/35/07339051b8b901ecefd449ebf8e5522e92bcb95e1078818cbfd9db8e573c/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20f2712bd1cc26a3cc16c5a1bfee9ed1abc33d4cdf1aabd297fe0eb724df4272", size = 394094 }, + { url = "https://files.pythonhosted.org/packages/dc/62/ee89ece19e0ba322b08734e95441952062391065c157bbd4f8802316b4f1/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aad911555286884be1e427ef0dc0ba3929e6821cbeca2194b13dc415a462c7fd", size = 449639 }, + { url = "https://files.pythonhosted.org/packages/15/24/b30e9f9e71baa0b9dada3a4ab43d567c6b04a36d1cb531045f7a8a0a7439/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0aeb3329c1721c43c58cae274d7d2ca85c1690d89485d9c63a006cb79a85771a", size = 438584 }, + { url = "https://files.pythonhosted.org/packages/28/d9/49f7b8f3b4147db13961e19d5e30077cd0854ccc08487026d2cb2142aa4a/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a0f156e9509cee987283abd2296ec816225145a13ed0391df8f71bf1d789e2d", size = 391047 }, + { url = "https://files.pythonhosted.org/packages/49/b0/e66918d0972c33a259ba3cd7b7ff10ed8bd91dbcfcbec6367b21f026db75/rpds_py-0.24.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa6800adc8204ce898c8a424303969b7aa6a5e4ad2789c13f8648739830323b7", size = 418085 }, + { url = "https://files.pythonhosted.org/packages/e1/6b/99ed7ea0a94c7ae5520a21be77a82306aac9e4e715d4435076ead07d05c6/rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a18fc371e900a21d7392517c6f60fe859e802547309e94313cd8181ad9db004d", size = 564498 }, + { url = "https://files.pythonhosted.org/packages/28/26/1cacfee6b800e6fb5f91acecc2e52f17dbf8b0796a7c984b4568b6d70e38/rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9168764133fd919f8dcca2ead66de0105f4ef5659cbb4fa044f7014bed9a1797", size = 590202 }, + { url = "https://files.pythonhosted.org/packages/a9/9e/57bd2f9fba04a37cef673f9a66b11ca8c43ccdd50d386c455cd4380fe461/rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f6e3cec44ba05ee5cbdebe92d052f69b63ae792e7d05f1020ac5e964394080c", size = 561771 }, + { url = "https://files.pythonhosted.org/packages/9f/cf/b719120f375ab970d1c297dbf8de1e3c9edd26fe92c0ed7178dd94b45992/rpds_py-0.24.0-cp313-cp313t-win32.whl", hash = "sha256:8ebc7e65ca4b111d928b669713865f021b7773350eeac4a31d3e70144297baba", size = 221195 }, + { url = "https://files.pythonhosted.org/packages/2d/e5/22865285789f3412ad0c3d7ec4dc0a3e86483b794be8a5d9ed5a19390900/rpds_py-0.24.0-cp313-cp313t-win_amd64.whl", hash = "sha256:675269d407a257b8c00a6b58205b72eec8231656506c56fd429d924ca00bb350", size = 237354 }, +] + +[[package]] +name = "scipy" +version = "1.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/b9/31ba9cd990e626574baf93fbc1ac61cf9ed54faafd04c479117517661637/scipy-1.15.2.tar.gz", hash = "sha256:cd58a314d92838f7e6f755c8a2167ead4f27e1fd5c1251fd54289569ef3495ec", size = 59417316 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/5d/3c78815cbab499610f26b5bae6aed33e227225a9fa5290008a733a64f6fc/scipy-1.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c4697a10da8f8765bb7c83e24a470da5797e37041edfd77fd95ba3811a47c4fd", size = 38756184 }, + { url = "https://files.pythonhosted.org/packages/37/20/3d04eb066b471b6e171827548b9ddb3c21c6bbea72a4d84fc5989933910b/scipy-1.15.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:869269b767d5ee7ea6991ed7e22b3ca1f22de73ab9a49c44bad338b725603301", size = 30163558 }, + { url = "https://files.pythonhosted.org/packages/a4/98/e5c964526c929ef1f795d4c343b2ff98634ad2051bd2bbadfef9e772e413/scipy-1.15.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bad78d580270a4d32470563ea86c6590b465cb98f83d760ff5b0990cb5518a93", size = 22437211 }, + { url = "https://files.pythonhosted.org/packages/1d/cd/1dc7371e29195ecbf5222f9afeedb210e0a75057d8afbd942aa6cf8c8eca/scipy-1.15.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:b09ae80010f52efddb15551025f9016c910296cf70adbf03ce2a8704f3a5ad20", size = 25232260 }, + { url = "https://files.pythonhosted.org/packages/f0/24/1a181a9e5050090e0b5138c5f496fee33293c342b788d02586bc410c6477/scipy-1.15.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a6fd6eac1ce74a9f77a7fc724080d507c5812d61e72bd5e4c489b042455865e", size = 35198095 }, + { url = "https://files.pythonhosted.org/packages/c0/53/eaada1a414c026673eb983f8b4a55fe5eb172725d33d62c1b21f63ff6ca4/scipy-1.15.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b871df1fe1a3ba85d90e22742b93584f8d2b8e6124f8372ab15c71b73e428b8", size = 37297371 }, + { url = "https://files.pythonhosted.org/packages/e9/06/0449b744892ed22b7e7b9a1994a866e64895363572677a316a9042af1fe5/scipy-1.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:03205d57a28e18dfd39f0377d5002725bf1f19a46f444108c29bdb246b6c8a11", size = 36872390 }, + { url = "https://files.pythonhosted.org/packages/6a/6f/a8ac3cfd9505ec695c1bc35edc034d13afbd2fc1882a7c6b473e280397bb/scipy-1.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:601881dfb761311045b03114c5fe718a12634e5608c3b403737ae463c9885d53", size = 39700276 }, + { url = "https://files.pythonhosted.org/packages/f5/6f/e6e5aff77ea2a48dd96808bb51d7450875af154ee7cbe72188afb0b37929/scipy-1.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:e7c68b6a43259ba0aab737237876e5c2c549a031ddb7abc28c7b47f22e202ded", size = 40942317 }, + { url = "https://files.pythonhosted.org/packages/53/40/09319f6e0f276ea2754196185f95cd191cb852288440ce035d5c3a931ea2/scipy-1.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01edfac9f0798ad6b46d9c4c9ca0e0ad23dbf0b1eb70e96adb9fa7f525eff0bf", size = 38717587 }, + { url = "https://files.pythonhosted.org/packages/fe/c3/2854f40ecd19585d65afaef601e5e1f8dbf6758b2f95b5ea93d38655a2c6/scipy-1.15.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:08b57a9336b8e79b305a143c3655cc5bdbe6d5ece3378578888d2afbb51c4e37", size = 30100266 }, + { url = "https://files.pythonhosted.org/packages/dd/b1/f9fe6e3c828cb5930b5fe74cb479de5f3d66d682fa8adb77249acaf545b8/scipy-1.15.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:54c462098484e7466362a9f1672d20888f724911a74c22ae35b61f9c5919183d", size = 22373768 }, + { url = "https://files.pythonhosted.org/packages/15/9d/a60db8c795700414c3f681908a2b911e031e024d93214f2d23c6dae174ab/scipy-1.15.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:cf72ff559a53a6a6d77bd8eefd12a17995ffa44ad86c77a5df96f533d4e6c6bb", size = 25154719 }, + { url = "https://files.pythonhosted.org/packages/37/3b/9bda92a85cd93f19f9ed90ade84aa1e51657e29988317fabdd44544f1dd4/scipy-1.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9de9d1416b3d9e7df9923ab23cd2fe714244af10b763975bea9e4f2e81cebd27", size = 35163195 }, + { url = "https://files.pythonhosted.org/packages/03/5a/fc34bf1aa14dc7c0e701691fa8685f3faec80e57d816615e3625f28feb43/scipy-1.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb530e4794fc8ea76a4a21ccb67dea33e5e0e60f07fc38a49e821e1eae3b71a0", size = 37255404 }, + { url = "https://files.pythonhosted.org/packages/4a/71/472eac45440cee134c8a180dbe4c01b3ec247e0338b7c759e6cd71f199a7/scipy-1.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5ea7ed46d437fc52350b028b1d44e002646e28f3e8ddc714011aaf87330f2f32", size = 36860011 }, + { url = "https://files.pythonhosted.org/packages/01/b3/21f890f4f42daf20e4d3aaa18182dddb9192771cd47445aaae2e318f6738/scipy-1.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11e7ad32cf184b74380f43d3c0a706f49358b904fa7d5345f16ddf993609184d", size = 39657406 }, + { url = "https://files.pythonhosted.org/packages/0d/76/77cf2ac1f2a9cc00c073d49e1e16244e389dd88e2490c91d84e1e3e4d126/scipy-1.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:a5080a79dfb9b78b768cebf3c9dcbc7b665c5875793569f48bf0e2b1d7f68f6f", size = 40961243 }, + { url = "https://files.pythonhosted.org/packages/4c/4b/a57f8ddcf48e129e6054fa9899a2a86d1fc6b07a0e15c7eebff7ca94533f/scipy-1.15.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:447ce30cee6a9d5d1379087c9e474628dab3db4a67484be1b7dc3196bfb2fac9", size = 38870286 }, + { url = "https://files.pythonhosted.org/packages/0c/43/c304d69a56c91ad5f188c0714f6a97b9c1fed93128c691148621274a3a68/scipy-1.15.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:c90ebe8aaa4397eaefa8455a8182b164a6cc1d59ad53f79943f266d99f68687f", size = 30141634 }, + { url = "https://files.pythonhosted.org/packages/44/1a/6c21b45d2548eb73be9b9bff421aaaa7e85e22c1f9b3bc44b23485dfce0a/scipy-1.15.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:def751dd08243934c884a3221156d63e15234a3155cf25978b0a668409d45eb6", size = 22415179 }, + { url = "https://files.pythonhosted.org/packages/74/4b/aefac4bba80ef815b64f55da06f62f92be5d03b467f2ce3668071799429a/scipy-1.15.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:302093e7dfb120e55515936cb55618ee0b895f8bcaf18ff81eca086c17bd80af", size = 25126412 }, + { url = "https://files.pythonhosted.org/packages/b1/53/1cbb148e6e8f1660aacd9f0a9dfa2b05e9ff1cb54b4386fe868477972ac2/scipy-1.15.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cd5b77413e1855351cdde594eca99c1f4a588c2d63711388b6a1f1c01f62274", size = 34952867 }, + { url = "https://files.pythonhosted.org/packages/2c/23/e0eb7f31a9c13cf2dca083828b97992dd22f8184c6ce4fec5deec0c81fcf/scipy-1.15.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d0194c37037707b2afa7a2f2a924cf7bac3dc292d51b6a925e5fcb89bc5c776", size = 36890009 }, + { url = "https://files.pythonhosted.org/packages/03/f3/e699e19cabe96bbac5189c04aaa970718f0105cff03d458dc5e2b6bd1e8c/scipy-1.15.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:bae43364d600fdc3ac327db99659dcb79e6e7ecd279a75fe1266669d9a652828", size = 36545159 }, + { url = "https://files.pythonhosted.org/packages/af/f5/ab3838e56fe5cc22383d6fcf2336e48c8fe33e944b9037fbf6cbdf5a11f8/scipy-1.15.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f031846580d9acccd0044efd1a90e6f4df3a6e12b4b6bd694a7bc03a89892b28", size = 39136566 }, + { url = "https://files.pythonhosted.org/packages/0a/c8/b3f566db71461cabd4b2d5b39bcc24a7e1c119535c8361f81426be39bb47/scipy-1.15.2-cp313-cp313t-win_amd64.whl", hash = "sha256:fe8a9eb875d430d81755472c5ba75e84acc980e4a8f6204d402849234d3017db", size = 40477705 }, +] + +[[package]] +name = "shapely" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/fe/3b0d2f828ffaceadcdcb51b75b9c62d98e62dd95ce575278de35f24a1c20/shapely-2.1.0.tar.gz", hash = "sha256:2cbe90e86fa8fc3ca8af6ffb00a77b246b918c7cf28677b7c21489b678f6b02e", size = 313617 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d1/6a9371ec39d3ef08e13225594e6c55b045209629afd9e6d403204507c2a8/shapely-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53e7ee8bd8609cf12ee6dce01ea5affe676976cf7049315751d53d8db6d2b4b2", size = 1830732 }, + { url = "https://files.pythonhosted.org/packages/32/87/799e3e48be7ce848c08509b94d2180f4ddb02e846e3c62d0af33da4d78d3/shapely-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cab20b665d26dbec0b380e15749bea720885a481fa7b1eedc88195d4a98cfa4", size = 1638404 }, + { url = "https://files.pythonhosted.org/packages/85/00/6665d77f9dd09478ab0993b8bc31668aec4fd3e5f1ddd1b28dd5830e47be/shapely-2.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4a38b39a09340273c3c92b3b9a374272a12cc7e468aeeea22c1c46217a03e5c", size = 2945316 }, + { url = "https://files.pythonhosted.org/packages/34/49/738e07d10bbc67cae0dcfe5a484c6e518a517f4f90550dda2adf3a78b9f2/shapely-2.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:edaec656bdd9b71278b98e6f77c464b1c3b2daa9eace78012ff0f0b4b5b15b04", size = 3063099 }, + { url = "https://files.pythonhosted.org/packages/88/b8/138098674559362ab29f152bff3b6630de423378fbb0324812742433a4ef/shapely-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c8a732ddd9b25e7a54aa748e7df8fd704e23e5d5d35b7d376d80bffbfc376d04", size = 3887873 }, + { url = "https://files.pythonhosted.org/packages/67/a8/fdae7c2db009244991d86f4d2ca09d2f5ccc9d41c312c3b1ee1404dc55da/shapely-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9c93693ad8adfdc9138a5a2d42da02da94f728dd2e82d2f0f442f10e25027f5f", size = 4067004 }, + { url = "https://files.pythonhosted.org/packages/ed/78/17e17d91b489019379df3ee1afc4bd39787b232aaa1d540f7d376f0280b7/shapely-2.1.0-cp312-cp312-win32.whl", hash = "sha256:d8ac6604eefe807e71a908524de23a37920133a1729fe3a4dfe0ed82c044cbf4", size = 1527366 }, + { url = "https://files.pythonhosted.org/packages/b8/bd/9249bd6dda948441e25e4fb14cbbb5205146b0fff12c66b19331f1ff2141/shapely-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f4f47e631aa4f9ec5576eac546eb3f38802e2f82aeb0552f9612cb9a14ece1db", size = 1708265 }, + { url = "https://files.pythonhosted.org/packages/8d/77/4e368704b2193e74498473db4461d697cc6083c96f8039367e59009d78bd/shapely-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b64423295b563f43a043eb786e7a03200ebe68698e36d2b4b1c39f31dfb50dfb", size = 1830029 }, + { url = "https://files.pythonhosted.org/packages/71/3c/d888597bda680e4de987316b05ca9db07416fa29523beff64f846503302f/shapely-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1b5578f45adc25b235b22d1ccb9a0348c8dc36f31983e57ea129a88f96f7b870", size = 1637999 }, + { url = "https://files.pythonhosted.org/packages/03/8d/ee0e23b7ef88fba353c63a81f1f329c77f5703835db7b165e7c0b8b7f839/shapely-2.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a7e83d383b27f02b684e50ab7f34e511c92e33b6ca164a6a9065705dd64bcb", size = 2929348 }, + { url = "https://files.pythonhosted.org/packages/d1/a7/5c9cb413e4e2ce52c16be717e94abd40ce91b1f8974624d5d56154c5d40b/shapely-2.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:942031eb4d8f7b3b22f43ba42c09c7aa3d843aa10d5cc1619fe816e923b66e55", size = 3048973 }, + { url = "https://files.pythonhosted.org/packages/84/23/45b90c0bd2157b238490ca56ef2eedf959d3514c7d05475f497a2c88b6d9/shapely-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d2843c456a2e5627ee6271800f07277c0d2652fb287bf66464571a057dbc00b3", size = 3873148 }, + { url = "https://files.pythonhosted.org/packages/c0/bc/ed7d5d37f5395166042576f0c55a12d7e56102799464ba7ea3a72a38c769/shapely-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8c4b17469b7f39a5e6a7cfea79f38ae08a275427f41fe8b48c372e1449147908", size = 4052655 }, + { url = "https://files.pythonhosted.org/packages/c0/8f/a1dafbb10d20d1c569f2db3fb1235488f624dafe8469e8ce65356800ba31/shapely-2.1.0-cp313-cp313-win32.whl", hash = "sha256:30e967abd08fce49513d4187c01b19f139084019f33bec0673e8dbeb557c45e4", size = 1526600 }, + { url = "https://files.pythonhosted.org/packages/e3/f0/9f8cdf2258d7aed742459cea51c70d184de92f5d2d6f5f7f1ded90a18c31/shapely-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:1dc8d4364483a14aba4c844b7bd16a6fa3728887e2c33dfa1afa34a3cf4d08a5", size = 1707115 }, + { url = "https://files.pythonhosted.org/packages/75/ed/32952df461753a65b3e5d24c8efb361d3a80aafaef0b70d419063f6f2c11/shapely-2.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:673e073fea099d1c82f666fb7ab0a00a77eff2999130a69357ce11941260d855", size = 1824847 }, + { url = "https://files.pythonhosted.org/packages/ff/b9/2284de512af30b02f93ddcdd2e5c79834a3cf47fa3ca11b0f74396feb046/shapely-2.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6d1513f915a56de67659fe2047c1ad5ff0f8cbff3519d1e74fced69c9cb0e7da", size = 1631035 }, + { url = "https://files.pythonhosted.org/packages/35/16/a59f252a7e736b73008f10d0950ffeeb0d5953be7c0bdffd39a02a6ba310/shapely-2.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d6a7043178890b9e028d80496ff4c79dc7629bff4d78a2f25323b661756bab8", size = 2968639 }, + { url = "https://files.pythonhosted.org/packages/a5/0a/6a20eca7b0092cfa243117e8e145a58631a4833a0a519ec9b445172e83a0/shapely-2.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb638378dc3d76f7e85b67d7e2bb1366811912430ac9247ac00c127c2b444cdc", size = 3055713 }, + { url = "https://files.pythonhosted.org/packages/fb/44/eeb0c7583b1453d1cf7a319a1d738e08f98a5dc993fa1ef3c372983e4cb5/shapely-2.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:737124e87d91d616acf9a911f74ac55e05db02a43a6a7245b3d663817b876055", size = 3890478 }, + { url = "https://files.pythonhosted.org/packages/5d/6e/37ff3c6af1d408cacb0a7d7bfea7b8ab163a5486e35acb08997eae9d8756/shapely-2.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e6c229e7bb87aae5df82fa00b6718987a43ec168cc5affe095cca59d233f314", size = 4036148 }, + { url = "https://files.pythonhosted.org/packages/c8/6a/8c0b7de3aeb5014a23f06c5e9d3c7852ebcf0d6b00fe660b93261e310e24/shapely-2.1.0-cp313-cp313t-win32.whl", hash = "sha256:a9580bda119b1f42f955aa8e52382d5c73f7957e0203bc0c0c60084846f3db94", size = 1535993 }, + { url = "https://files.pythonhosted.org/packages/a8/91/ae80359a58409d52e4d62c7eacc7eb3ddee4b9135f1db884b6a43cf2e174/shapely-2.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e8ff4e5cfd799ba5b6f37b5d5527dbd85b4a47c65b6d459a03d0962d2a9d4d10", size = 1717777 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, +] + +[[package]] +name = "toolz" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/0b/d80dfa675bf592f636d1ea0b835eab4ec8df6e9415d8cfd766df54456123/toolz-1.0.0.tar.gz", hash = "sha256:2c86e3d9a04798ac556793bced838816296a2f085017664e4995cb40a1047a02", size = 66790 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/98/eb27cc78ad3af8e302c9d8ff4977f5026676e130d28dd7578132a457170c/toolz-1.0.0-py3-none-any.whl", hash = "sha256:292c8f1c4e7516bf9086f8850935c799a874039c8bcf959d47b600e4c44a6236", size = 56383 }, +] + +[[package]] +name = "tornado" +version = "6.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/45/a0daf161f7d6f36c3ea5fc0c2de619746cc3dd4c76402e9db545bd920f63/tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b", size = 501135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/7e/71f604d8cea1b58f82ba3590290b66da1e72d840aeb37e0d5f7291bd30db/tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1", size = 436299 }, + { url = "https://files.pythonhosted.org/packages/96/44/87543a3b99016d0bf54fdaab30d24bf0af2e848f1d13d34a3a5380aabe16/tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803", size = 434253 }, + { url = "https://files.pythonhosted.org/packages/cb/fb/fdf679b4ce51bcb7210801ef4f11fdac96e9885daa402861751353beea6e/tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec", size = 437602 }, + { url = "https://files.pythonhosted.org/packages/4f/3b/e31aeffffc22b475a64dbeb273026a21b5b566f74dee48742817626c47dc/tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946", size = 436972 }, + { url = "https://files.pythonhosted.org/packages/22/55/b78a464de78051a30599ceb6983b01d8f732e6f69bf37b4ed07f642ac0fc/tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf", size = 437173 }, + { url = "https://files.pythonhosted.org/packages/79/5e/be4fb0d1684eb822c9a62fb18a3e44a06188f78aa466b2ad991d2ee31104/tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634", size = 437892 }, + { url = "https://files.pythonhosted.org/packages/f5/33/4f91fdd94ea36e1d796147003b490fe60a0215ac5737b6f9c65e160d4fe0/tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73", size = 437334 }, + { url = "https://files.pythonhosted.org/packages/2b/ae/c1b22d4524b0e10da2f29a176fb2890386f7bd1f63aacf186444873a88a0/tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c", size = 437261 }, + { url = "https://files.pythonhosted.org/packages/b5/25/36dbd49ab6d179bcfc4c6c093a51795a4f3bed380543a8242ac3517a1751/tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482", size = 438463 }, + { url = "https://files.pythonhosted.org/packages/61/cc/58b1adeb1bb46228442081e746fcdbc4540905c87e8add7c277540934edb/tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38", size = 438907 }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, +] + +[[package]] +name = "trove-classifiers" +version = "2025.5.1.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/81/a43db75173e49b1204d2a826b2a3a0e2dee45fb0d9abfac2ce1ff928eebf/trove_classifiers-2025.5.1.12.tar.gz", hash = "sha256:28d24c3d043dc6b0459813d6bf4a231e788509b55ee3d54ba08ce72638031182", size = 16876 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/dd/915b914cfbaf91a46560083519be825ee65d3f955a7797ecf0ecf271559d/trove_classifiers-2025.5.1.12-py3-none-any.whl", hash = "sha256:9ed1030cfcc8d0eb944155f05b4add4efaceba4ba6aca3f9f348a21f1e700404", size = 14041 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] + +[[package]] +name = "xarray" +version = "2025.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/29/37761364e137db13898cf5a790574dd7883f7355d5dfb42b66ee7a9a6318/xarray-2025.4.0.tar.gz", hash = "sha256:2a89cd6a1dfd589aa90ac45f4e483246f31fc641836db45dd2790bb78bd333dc", size = 2974151 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/1e/96fd96419fec1a37da998a1ca3d558f2cae2f6f3cd5015170371b05a2b6b/xarray-2025.4.0-py3-none-any.whl", hash = "sha256:b27defd082c5cb85d32c695708de6bb05c2838fb7caaf3f952982e602a35b9b8", size = 1290171 }, +] From 3401fe6a293a0c63b31c90e80bf7b0002396076e Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Wed, 7 May 2025 08:18:15 -0400 Subject: [PATCH 002/151] fix: fixing earthplot figure to PIL image conversion --- notebooks/earthkit_plots_exploration.py | 43 ++++++++----------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/notebooks/earthkit_plots_exploration.py b/notebooks/earthkit_plots_exploration.py index 560f80c..682df79 100644 --- a/notebooks/earthkit_plots_exploration.py +++ b/notebooks/earthkit_plots_exploration.py @@ -7,7 +7,7 @@ # extension: .py # format_name: percent # format_version: '1.3' -# jupytext_version: 1.16.7 +# jupytext_version: 1.17.1 # kernelspec: # display_name: .venv # language: python @@ -75,29 +75,6 @@ def img_to_base64(image_path: str | None = None, img: Image | None = None) -> st return base64.b64encode(img_file.read()).decode("utf-8") -# FIX(high): Can't save earthkit-plots figures to a file -def earthkit_fig_to_pil(figure, format: Literal["JPEG", "PNG"] = "JPEG") -> PIL.Image: - """ - Convert an earthkit-plots figure to a PIL Image - - Args: - figure: earthkit-plots figure object - - Returns: - PIL Image object - """ - figure._release_queue() - - fig = plt.gcf() # matplotlib figure - fig.canvas.draw() - - buf = BytesIO() - fig.savefig(buf, format=format) - buf.seek(0) - - return PIL.Image.open(buf) - - # %% [markdown] # ## High-level API @@ -106,8 +83,14 @@ def earthkit_fig_to_pil(figure, format: Literal["JPEG", "PNG"] = "JPEG") -> PIL. data.ls() # %% -figure = ek.plots.quickplot(data, domain=["France", "Greece"], mode="overlay") -figure.show() +buffer = BytesIO() +ek.plots.quickplot(data, domain=["France", "Greece"], mode="overlay").save( + buffer, format="png" +) + +buffer.seek(0) + +img = PIL.Image.open(buffer) # %% [markdown] # Observations: @@ -121,15 +104,17 @@ def earthkit_fig_to_pil(figure, format: Literal["JPEG", "PNG"] = "JPEG") -> PIL. # ## Trying a Simple VLLM Summary Generation # %% -assert os.environ.get("GROQ_API_KEY", None), "GROQ_API_KEY is not set." +assert os.environ.get("GROQ_API_KEY"), ( + "GROQ_API_KEY not set. Please set it in your environment variables." +) client = openai.OpenAI( base_url="https://api.groq.com/openai/v1", api_key=os.environ.get("GROQ_API_KEY"), # Using GROQ free API provider ) # %% -img_path = Path().cwd().parent / "data" / "temperature_pressure_france_greece.png" -base64_image = img_to_base64(image_path=img_path) +# img_path = Path().cwd().parent / 'data' / 'temperature_pressure_france_greece.png' +base64_image = img_to_base64(img=img) response = client.chat.completions.create( model="meta-llama/llama-4-maverick-17b-128e-instruct", # vision-enabled chat model From 07309c1fb3c4bfa95dfc5d01240d4519ae1a4cc9 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 10 May 2025 11:43:19 -0400 Subject: [PATCH 003/151] feat: implementing secured private VLLM inference server --- vllm/.env.example | 14 ++++++++++++++ vllm/Caddyfile | 9 +++++++++ vllm/docker-compose.yaml | 33 +++++++++++++++++++++++++++++++++ vllm/setup.sh | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+) create mode 100644 vllm/.env.example create mode 100644 vllm/Caddyfile create mode 100644 vllm/docker-compose.yaml create mode 100644 vllm/setup.sh diff --git a/vllm/.env.example b/vllm/.env.example new file mode 100644 index 0000000..4a86ad1 --- /dev/null +++ b/vllm/.env.example @@ -0,0 +1,14 @@ +# Domain configuration +DOMAIN= + +# Paths +CADDY_DATA_DIR=/opt/caddy/data +CADDY_CONFIG_DIR=/opt/caddy/config +CADDY_FILE_PATH=/opt/caddy/Caddyfile +HF_CACHE_DIR=~/.cache/huggingface + +# VLLM configuration +MODEL_NAME="google/gemma-3-4b-it" +HF_HUB_TOKEN= +VLLM_SERVER_API_KEY= +VLLM_PORT=8000 \ No newline at end of file diff --git a/vllm/Caddyfile b/vllm/Caddyfile new file mode 100644 index 0000000..cfc3f69 --- /dev/null +++ b/vllm/Caddyfile @@ -0,0 +1,9 @@ +{$DOMAIN} { + # Redirect HTTP to HTTPS + redir https://{host}{uri} permanent +} + +https://{$DOMAIN} { + # HTTPS with auto SSL provisioning + reverse_proxy vllm-server:{$VLLM_PORT} +} \ No newline at end of file diff --git a/vllm/docker-compose.yaml b/vllm/docker-compose.yaml new file mode 100644 index 0000000..226ab85 --- /dev/null +++ b/vllm/docker-compose.yaml @@ -0,0 +1,33 @@ +services: + caddy: + image: caddy + container_name: caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ${CADDY_FILE_PATH}:/etc/caddy/Caddyfile + - ${CADDY_DATA_DIR}:/data + - ${CADDY_CONFIG_DIR}:/config + environment: + - DOMAIN=${DOMAIN} + depends_on: + - vllm-server + + vllm-server: + image: vllm/vllm-openai:latest + container_name: vllm-server + restart: unless-stopped + runtime: nvidia + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + volumes: + - ${HF_CACHE_DIR}:/root/.cache/huggingface + ipc: host + command: --model ${MODEL_NAME} --port ${VLLM_PORT} --api-key ${API_KEY} diff --git a/vllm/setup.sh b/vllm/setup.sh new file mode 100644 index 0000000..12a4c42 --- /dev/null +++ b/vllm/setup.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Update system dependencies +sudo dnf upgrade -y + +# Install and configure Firewalld +sudo dnf install firewalld -y + +# Start and enable firewalld +sudo systemctl start firewalld +sudo systemctl enable firewalld + +# Add SSH, HTTP and HTTPS services +sudo firewall-cmd --permanent --add-service=ssh +sudo firewall-cmd --permanent --add-service=http +sudo firewall-cmd --permanent --add-service=https + +# Reload to apply changes +sudo firewall-cmd --reload + +# Install Docker dependencies (as per https://docs.docker.com/engine/install/rhel/) +sudo dnf -y install dnf-plugins-core +sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo +sudo dnf install -y docker-ce \ + docker-ce-cli \ + containerd.io \ + docker-buildx-plugin \ + docker-compose-plugin + +# Enable and start Docker +sudo systemctl enable --now docker + +# Create necessary Caddy directories +mkdir -p $CADDY_DATA_DIR $CADDY_CONFIG_DIR + +# Copy the Caddyfile to the config directory +cp ./Caddyfile $CADDY_FILE_PATH + +echo "Setup complete! You can now run 'sudo docker compose up -d'" \ No newline at end of file From 7b72577aa493a6489604d8cb9514013b16db3c06 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 10 May 2025 11:43:44 -0400 Subject: [PATCH 004/151] doc: adding doc for VLLM inference server configuration and deployment --- README.md | 8 +++++ vllm/setup.md | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 vllm/setup.md diff --git a/README.md b/README.md index a348a55..c3cc099 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,14 @@ EarthReach is a challenge from the 2025 edition dedicated to enhancing the accessibility of meteorological data visualisations produced by Earthkit, by equipping the plots module with LLM-powered alternative text generation capabilities. +## Installation + +### LLM Inference Server + +To run this project, you will need to have an openAI-compatible LLM inference server. + +We provide instructions on how to set your own secured inference server using [VLLM](./vllm/setup.md). + ## License See [LICENSE](LICENSE) diff --git a/vllm/setup.md b/vllm/setup.md new file mode 100644 index 0000000..9a2dfc5 --- /dev/null +++ b/vllm/setup.md @@ -0,0 +1,98 @@ +# Setting Up Production-Ready VLLM Inference Server + +To setup this private LLM inference server, you will need: + +- To have access to a linux machine +- Docker and docker compose installed +- A domain name pointing towards your linux macine's public IP address + +## Installation + +We provide a helper script `setup.sh` to facilitate the configuration. Please review it before using it. + +Please set the right values for the environment variables in a `vllm/.env` file (see `.env.example`). + +```sh +chmod +x ./setup.sh +sudo ./setup.sh +``` + +If you prefer running the commands manually, follow the ones provided below: + +### System Update + +Start by making sure your server is up to date: + +```sh +# Search for updates and apply them +sudo dnf upgrade +``` + +### Firewall + +Let's allow HTTP, HTTPS connections and deny the other types by default. + + +```sh +# Install firewalld +sudo dnf install firewalld + +# Start and enable firewalld +sudo systemctl start firewalld +sudo systemctl enable firewalld + +# Check status +sudo systemctl status firewalld + +# Add HTTP and HTTPS services +sudo firewall-cmd --permanent --add-service=ssh +sudo firewall-cmd --permanent --add-service=http +sudo firewall-cmd --permanent --add-service=https + +# Reload to apply changes +sudo firewall-cmd --reload + +# Verify configuration +sudo firewall-cmd --list-all +``` + +### Docker + +```sh +# Install Docker dependencies (as per https://docs.docker.com/engine/install/rhel/) +sudo dnf -y install dnf-plugins-core +sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo +sudo dnf install -y docker-ce \ + docker-ce-cli \ + containerd.io \ + docker-buildx-plugin \ + docker-compose-plugin + +# Enable and start Docker +sudo systemctl enable --now docker +``` + +### Caddy + +```sh +# Create necessary Caddy directories +mkdir -p $CADDY_DATA_DIR $CADDY_CONFIG_DIR + +# Copy the Caddyfile to the config directory +cp Caddyfile $CADDY_FILE_PATH +``` + +## Usage + +Try requesting the server with: + +```sh +curl https://$DOMAIN/v1/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "$MODEL_NAME", + "prompt": "What's the weather like in Bologna ?", + "max_tokens": 20, + "temperature": 0.1 + }' +``` \ No newline at end of file From 171df87fc914572380f4a1ca5908871870261b07 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 10 May 2025 12:15:09 -0400 Subject: [PATCH 005/151] feat: adding nvidia docker runtime configuration --- vllm/docker-compose.yaml | 2 +- vllm/setup.md | 21 +++++++++++++++++++++ vllm/setup.sh | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/vllm/docker-compose.yaml b/vllm/docker-compose.yaml index 226ab85..622509a 100644 --- a/vllm/docker-compose.yaml +++ b/vllm/docker-compose.yaml @@ -30,4 +30,4 @@ services: volumes: - ${HF_CACHE_DIR}:/root/.cache/huggingface ipc: host - command: --model ${MODEL_NAME} --port ${VLLM_PORT} --api-key ${API_KEY} + command: --model ${MODEL_NAME} --port ${VLLM_PORT} --api-key ${VLLM_SERVER_API_KEY} diff --git a/vllm/setup.md b/vllm/setup.md index 9a2dfc5..7869c64 100644 --- a/vllm/setup.md +++ b/vllm/setup.md @@ -56,6 +56,27 @@ sudo firewall-cmd --reload sudo firewall-cmd --list-all ``` +### Nvidia Runtime + +Make sure the drivers for your GPU are correctly installed. + +Then run: + +```sh +# Configure production repository +curl -s -L https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo | \ + sudo tee /etc/yum.repos.d/nvidia-container-toolkit.repo + +# Install nvidia container toolkit +sudo dnf install -y nvidia-container-toolkit + +# Configure the nvidia runtime for docker containers +sudo nvidia-ctk runtime configure --runtime=docker + +# Restart docker +sudo systemctl docker restart +``` + ### Docker ```sh diff --git a/vllm/setup.sh b/vllm/setup.sh index 12a4c42..101d087 100644 --- a/vllm/setup.sh +++ b/vllm/setup.sh @@ -30,6 +30,20 @@ sudo dnf install -y docker-ce \ # Enable and start Docker sudo systemctl enable --now docker +# Configure production repository +distribution=$(. /etc/os-release;echo $ID$VERSION_ID) +curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.repo | \ + sudo tee /etc/yum.repos.d/nvidia-container-toolkit.repo + +# Install nvidia container toolkit +sudo dnf install -y nvidia-container-toolkit + +# Configure the nvidia runtime for docker containers +sudo nvidia-ctk runtime configure --runtime=docker + +# Restart docker +sudo systemctl docker restart + # Create necessary Caddy directories mkdir -p $CADDY_DATA_DIR $CADDY_CONFIG_DIR From d88edb392c69c2da4ac0eb3f49ea6e67a2a14576 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 10 May 2025 12:30:54 -0400 Subject: [PATCH 006/151] doc: small update about nvidia runtime --- README.md | 2 +- vllm/setup.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c3cc099..2b827e2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ EarthReach is a challenge from the 2025 edition dedicated to enhancing the acces ## Installation -### LLM Inference Server +### VLLM Inference Server To run this project, you will need to have an openAI-compatible LLM inference server. diff --git a/vllm/setup.md b/vllm/setup.md index 7869c64..7e0008b 100644 --- a/vllm/setup.md +++ b/vllm/setup.md @@ -58,9 +58,9 @@ sudo firewall-cmd --list-all ### Nvidia Runtime -Make sure the drivers for your GPU are correctly installed. +Make sure the drivers and NVIDIA CUDA Runtime for your GPU are correctly installed and up-to-date. -Then run: +Then run the following commands to install the NVIDIA runtime for docker containers: ```sh # Configure production repository From befdbda18e003381134fc81812c6207db909ba61 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 10 May 2025 13:27:24 -0400 Subject: [PATCH 007/151] doc: updating comment --- vllm/setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vllm/setup.sh b/vllm/setup.sh index 101d087..a91c8b7 100644 --- a/vllm/setup.sh +++ b/vllm/setup.sh @@ -30,7 +30,7 @@ sudo dnf install -y docker-ce \ # Enable and start Docker sudo systemctl enable --now docker -# Configure production repository +# Configure production repository for nvidia-container-toolkit distribution=$(. /etc/os-release;echo $ID$VERSION_ID) curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.repo | \ sudo tee /etc/yum.repos.d/nvidia-container-toolkit.repo From e2344941fe3b269971db6243374f33cb041a0e14 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Wed, 14 May 2025 20:41:40 -0400 Subject: [PATCH 008/151] feat: creating custom Dockerfile for vllm --- vllm/Dockerfile | 13 +++++++++++++ vllm/docker-compose.yaml | 9 ++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 vllm/Dockerfile diff --git a/vllm/Dockerfile b/vllm/Dockerfile new file mode 100644 index 0000000..f007f0f --- /dev/null +++ b/vllm/Dockerfile @@ -0,0 +1,13 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +WORKDIR /app + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv pip install vllm>=0.8.5 + +ENV PATH="/app/.venv/bin:$PATH" + +ENTRYPOINT [ "uv", "run" ] \ No newline at end of file diff --git a/vllm/docker-compose.yaml b/vllm/docker-compose.yaml index 622509a..54fce3e 100644 --- a/vllm/docker-compose.yaml +++ b/vllm/docker-compose.yaml @@ -16,7 +16,10 @@ services: - vllm-server vllm-server: - image: vllm/vllm-openai:latest + image: vllm-server + build: + context: . + dockerfile: Dockerfile container_name: vllm-server restart: unless-stopped runtime: nvidia @@ -28,6 +31,6 @@ services: count: all capabilities: [gpu] volumes: - - ${HF_CACHE_DIR}:/root/.cache/huggingface + - ${HF_CACHE_DIR}:/app/.cache/huggingface ipc: host - command: --model ${MODEL_NAME} --port ${VLLM_PORT} --api-key ${VLLM_SERVER_API_KEY} + command: vllm serve --model ${MODEL_NAME} --port ${VLLM_PORT} --api-key ${VLLM_SERVER_API_KEY} From 06078fdae9a036ba286bf9395372e239523d58e4 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Wed, 14 May 2025 21:38:03 -0400 Subject: [PATCH 009/151] feat: adding hf hub token as env variable in docker file --- vllm/Dockerfile | 16 +++++++++------- vllm/docker-compose.yaml | 4 +++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/vllm/Dockerfile b/vllm/Dockerfile index f007f0f..31ec14c 100644 --- a/vllm/Dockerfile +++ b/vllm/Dockerfile @@ -1,13 +1,15 @@ -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim - +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder WORKDIR /app - -ENV UV_COMPILE_BYTECODE=1 \ - UV_LINK_MODE=copy - +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + TMPDIR=/dev/shm + RUN --mount=type=cache,target=/root/.cache/uv \ + uv venv --python=python3.12 && \ uv pip install vllm>=0.8.5 +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim +WORKDIR /app +COPY --from=builder /app/.venv /app/.venv ENV PATH="/app/.venv/bin:$PATH" - ENTRYPOINT [ "uv", "run" ] \ No newline at end of file diff --git a/vllm/docker-compose.yaml b/vllm/docker-compose.yaml index 54fce3e..de13f16 100644 --- a/vllm/docker-compose.yaml +++ b/vllm/docker-compose.yaml @@ -7,7 +7,7 @@ services: - "80:80" - "443:443" volumes: - - ${CADDY_FILE_PATH}:/etc/caddy/Caddyfile + - ${CADDY_FILE_PATH}:/etc/caddy/Caddyfile:ro - ${CADDY_DATA_DIR}:/data - ${CADDY_CONFIG_DIR}:/config environment: @@ -32,5 +32,7 @@ services: capabilities: [gpu] volumes: - ${HF_CACHE_DIR}:/app/.cache/huggingface + environment: + - HF_HUB_TOKEN=${HF_HUB_TOKEN} ipc: host command: vllm serve --model ${MODEL_NAME} --port ${VLLM_PORT} --api-key ${VLLM_SERVER_API_KEY} From 968066398b50f2ba5f5ec846f57c54bb96d58ca7 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Wed, 14 May 2025 21:41:46 -0400 Subject: [PATCH 010/151] fix: removing kwarg in vllm command line --- vllm/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vllm/docker-compose.yaml b/vllm/docker-compose.yaml index de13f16..f5b0e8a 100644 --- a/vllm/docker-compose.yaml +++ b/vllm/docker-compose.yaml @@ -35,4 +35,4 @@ services: environment: - HF_HUB_TOKEN=${HF_HUB_TOKEN} ipc: host - command: vllm serve --model ${MODEL_NAME} --port ${VLLM_PORT} --api-key ${VLLM_SERVER_API_KEY} + command: vllm serve ${MODEL_NAME} --port ${VLLM_PORT} --api-key ${VLLM_SERVER_API_KEY} From e4044fc7be172a9a9b5a62dc9fb2d06ec6886954 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Thu, 15 May 2025 20:41:27 -0400 Subject: [PATCH 011/151] feat: adding model download in setup script --- vllm/docker-compose.yaml | 4 ++-- vllm/setup.sh | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/vllm/docker-compose.yaml b/vllm/docker-compose.yaml index f5b0e8a..c7fc711 100644 --- a/vllm/docker-compose.yaml +++ b/vllm/docker-compose.yaml @@ -33,6 +33,6 @@ services: volumes: - ${HF_CACHE_DIR}:/app/.cache/huggingface environment: - - HF_HUB_TOKEN=${HF_HUB_TOKEN} + - HF_HOME=/app/.cache/huggingface ipc: host - command: vllm serve ${MODEL_NAME} --port ${VLLM_PORT} --api-key ${VLLM_SERVER_API_KEY} + command: vllm serve ${MODEL_DIR_PATH} --port ${VLLM_PORT} --api-key ${VLLM_SERVER_API_KEY} diff --git a/vllm/setup.sh b/vllm/setup.sh index a91c8b7..552ef89 100644 --- a/vllm/setup.sh +++ b/vllm/setup.sh @@ -49,5 +49,15 @@ mkdir -p $CADDY_DATA_DIR $CADDY_CONFIG_DIR # Copy the Caddyfile to the config directory cp ./Caddyfile $CADDY_FILE_PATH + +# Install uv python manager +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Create Huggingface cache directory +mkdir -p $HF_CACHE_DIR + +# Install HF-CLI and download the model to the cache directory +uv run --with "huggingface_hub[cli]" huggingface-cli login --token $HF_HUB_TOKEN +uv run --with "huggingface_hub[cli]" huggingface-cli download $MODEL_NAME --cache-dir=$HF_CACHE_DIR echo "Setup complete! You can now run 'sudo docker compose up -d'" \ No newline at end of file From fd326d8e53ac9af697579f22d8a2c0cb50b20444 Mon Sep 17 00:00:00 2001 From: Romain Date: Fri, 16 May 2025 20:51:30 +0000 Subject: [PATCH 012/151] Fixing Dockerfile for vllm server --- vllm/Dockerfile | 16 +++++++++++++--- vllm/test.sh | 10 ++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 vllm/test.sh diff --git a/vllm/Dockerfile b/vllm/Dockerfile index 31ec14c..c8f136a 100644 --- a/vllm/Dockerfile +++ b/vllm/Dockerfile @@ -3,13 +3,23 @@ WORKDIR /app ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy \ TMPDIR=/dev/shm - + RUN --mount=type=cache,target=/root/.cache/uv \ uv venv --python=python3.12 && \ uv pip install vllm>=0.8.5 FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim +RUN apt-get update -y && \ + apt-get install -y --no-install-recommends \ + gcc \ + g++ \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY --from=builder /app/.venv /app/.venv -ENV PATH="/app/.venv/bin:$PATH" -ENTRYPOINT [ "uv", "run" ] \ No newline at end of file +ENV PATH="/app/.venv/bin:$PATH" \ + CC=gcc \ + CXX=g++ + +ENTRYPOINT [ "uv", "run" ] diff --git a/vllm/test.sh b/vllm/test.sh new file mode 100644 index 0000000..e2494de --- /dev/null +++ b/vllm/test.sh @@ -0,0 +1,10 @@ +curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${VLLM_SERVER_API_KEY}" \ + -d '{ + "model": "google/gemma-3-4b-it", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Who won the world series in 2020?"} + ] + }' From 54bf040c836b6e4633110cb4f9f891c2ac4ddea1 Mon Sep 17 00:00:00 2001 From: Romain Date: Sun, 18 May 2025 21:58:22 +0000 Subject: [PATCH 013/151] feat: adding model served name for model loaded locally --- vllm/docker-compose.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/vllm/docker-compose.yaml b/vllm/docker-compose.yaml index c7fc711..ba72a2d 100644 --- a/vllm/docker-compose.yaml +++ b/vllm/docker-compose.yaml @@ -12,6 +12,7 @@ services: - ${CADDY_CONFIG_DIR}:/config environment: - DOMAIN=${DOMAIN} + - VLLM_PORT=${VLLM_PORT} depends_on: - vllm-server @@ -23,6 +24,8 @@ services: container_name: vllm-server restart: unless-stopped runtime: nvidia + ports: + - 8000:${VLLM_PORT} deploy: resources: reservations: @@ -35,4 +38,4 @@ services: environment: - HF_HOME=/app/.cache/huggingface ipc: host - command: vllm serve ${MODEL_DIR_PATH} --port ${VLLM_PORT} --api-key ${VLLM_SERVER_API_KEY} + command: vllm serve ${MODEL_DIR_PATH} --served-model-name ${MODEL_NAME} --port ${VLLM_PORT} --api-key ${VLLM_SERVER_API_KEY} From 70f3d8f9c7f61a657599f42c5949022a433a4ff6 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 19 May 2025 22:55:50 -0400 Subject: [PATCH 014/151] feat: implementing multi stages prompt, with clear requirements breakdown --- notebooks/earthkit_plots_exploration.py | 212 ++++++++++++++++++------ 1 file changed, 162 insertions(+), 50 deletions(-) diff --git a/notebooks/earthkit_plots_exploration.py b/notebooks/earthkit_plots_exploration.py index 682df79..4d2d7ff 100644 --- a/notebooks/earthkit_plots_exploration.py +++ b/notebooks/earthkit_plots_exploration.py @@ -38,7 +38,7 @@ load_dotenv(Path().cwd().parent / ".env") -warnings.filterwarnings("ignore", category=UserWarning) +warnings.filterwarnings("ignore") # %% @@ -71,27 +71,143 @@ def img_to_base64(image_path: str | None = None, img: Image | None = None) -> st img.save(bytes_io, format="PNG") return base64.b64encode(bytes_io.getvalue()).decode("utf-8") - with open(image_path, "rb") as img_file: + with open(image_path, "rb") as img_file: # type: ignore return base64.b64encode(img_file.read()).decode("utf-8") +# %% +assert os.environ.get("GROQ_API_KEY"), ( + "GROQ_API_KEY not set. Please set it in your environment variables." +) +client = openai.OpenAI( + base_url="https://api.groq.com/openai/v1", + api_key=os.environ.get("GROQ_API_KEY"), # Using GROQ free API provider +) + +MODEL_NAME = ( + "meta-llama/llama-4-maverick-17b-128e-instruct" # vision-enabled chat model +) + + +def call_api(user_prompt: str, image: Image): + """Call the LLM generation API""" + base64_image = img_to_base64(img=image) + try: + response = client.chat.completions.create( + model=MODEL_NAME, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": user_prompt}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{base64_image}", + }, + }, + ], + } + ], + ) + description = response.choices[0].message.content + + if not description: + raise ValueError("The description generated is empty") + + return description + + except Exception as e: + print("Encountered unexpected error when calling the api: {e}") + raise e + + # %% [markdown] -# ## High-level API +# ## Loading Data + +# %% +from earthkit.data import cache, config + +config.set("cache-policy", "user") +cache.directory() # %% +# Example, quickly accessible data data = ek.data.from_source("sample", "era5-2t-msl-1985122512.grib") data.ls() +# %% +# Copernicus datastore, requires API key set +dataset = "reanalysis-era5-single-levels" +request = { + "product_type": ["reanalysis"], + "variable": ["2m_temperature", "mean_sea_level_pressure"], + "year": ["2024"], + "month": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"], + "day": ["01"], + "time": ["12:00"], + "data_format": "grib", + "download_format": "unarchived", +} + +data = ek.data.from_source("cds", dataset, request) +data.ls() + +# %% [markdown] +# ## Exploring GRIB data + +# %% [markdown] +# When loaded through the `.from_source()` method, a GRIB file will yield a `GribFielList` object. Documentation available [here](https://earthkit-data.readthedocs.io/en/latest/guide/data_format/grib.html) +# +# Some information about fields and GRIB data: +# - The fields can be iterated through with for loops +# - A field represents a single meteorological phenomenon (like temperature), measured at a certain time, at a certain height, from a certain center + +# %% +len(data) # Number of fields + +# %% +data.head() # List first 5 fields and "some" metadata for each field + +# %% +data.ls() # List all fields and *some* metadata for each field + +# %% +data.describe() # Describe the fields + +# %% +data[0].dump() # Access different metadata namespaces + +# %% +data[0].metadata(namespaces="statistics") + +# %% +data.indices() # List of the metadata and the values they can take + +# %% [markdown] +# ## Earthkit-plot Quickplot API + +# %% +data.head() + +# %% +# Sub-select the data +sub_data = data.sel(param=["2t", "msl"], typeOfLevel="surface", dataDate=20240101) +sub_data.head() + # %% buffer = BytesIO() -ek.plots.quickplot(data, domain=["France", "Greece"], mode="overlay").save( - buffer, format="png" -) +figure = ek.plots.quickplot(sub_data, domain=["France", "Greece"], mode="overlay") + +figure.save(buffer, format="png") buffer.seek(0) img = PIL.Image.open(buffer) +# %% +figure.title() # TODO: extract the string title + # %% [markdown] # Observations: # - Geographic information is represented almost purely visually (there are latitudes and longitudes information on the axes but they are hardly interpretable), so a model should recognize the domains, or have access to them in the forms of variable valuess, default being the whole world @@ -103,39 +219,6 @@ def img_to_base64(image_path: str | None = None, img: Image | None = None) -> st # %% [markdown] # ## Trying a Simple VLLM Summary Generation -# %% -assert os.environ.get("GROQ_API_KEY"), ( - "GROQ_API_KEY not set. Please set it in your environment variables." -) -client = openai.OpenAI( - base_url="https://api.groq.com/openai/v1", - api_key=os.environ.get("GROQ_API_KEY"), # Using GROQ free API provider -) - -# %% -# img_path = Path().cwd().parent / 'data' / 'temperature_pressure_france_greece.png' -base64_image = img_to_base64(img=img) - -response = client.chat.completions.create( - model="meta-llama/llama-4-maverick-17b-128e-instruct", # vision-enabled chat model - messages=[ - { - "role": "user", - "content": [ - {"type": "text", "text": "What's in this image?"}, - { - "type": "image_url", - "image_url": { - "url": f"data:image/jpeg;base64,{base64_image}", - }, - }, - ], - } - ], -) - -display_markdown(response.choices[0].message.content) - # %% [markdown] # Observations (from a very simple prompt): # - Good structured and objective documentation of visual elements @@ -146,17 +229,22 @@ def img_to_base64(image_path: str | None = None, img: Image | None = None) -> st # These first observations are promising. We now need to try a generation with a more specialized prompt, including best prompt-engineering techniques from the start. # %% -# TODO: add the method (COT), response format, examples. The model boasts a huge context window so we should take advantage of it. -prompt = """# Instructions for Describing Meteorological Visualizations for Blind Scientists +task_prompt = """# Instructions for Describing Meteorological Visualizations for Blind Scientists + +Your task is to create a comprehensive, scientifically accurate description of meteorological visualizations that will be accessible to blind scientists. -Your task is to create a comprehensive, scientifically accurate description of meteorological visualizations that will be accessible to blind scientists. Follow these detailed guidelines: +""" -## 1. Structural Organization +requirements_prompt = """## Requirements + +Here are requirements that you will have to follow rigorously when writing your description. + +### 1. Structural Organization - Begin with a concise overview (2-3 sentences) stating the visualization type, geographic region, time period, and primary variables displayed. - Organize your description hierarchically from most to least scientifically significant features. - Structure your description in clearly defined sections with standardized headings. -## 2. Scientific Content Requirements +### 2. Scientific Content Requirements - Use precise meteorological terminology consistent with scientific publications. - Always include quantitative values with appropriate units (hPa for pressure, K/°C for temperature). - Describe patterns and gradients rather than individual data points. @@ -164,36 +252,60 @@ def img_to_base64(image_path: str | None = None, img: Image | None = None) -> st - Identify and describe major meteorological features (high/low pressure systems, fronts, temperature gradients). - Include geographic context using cardinal directions and recognized regional references. -## 3. Spatial Relationship Guidelines +### 3. Spatial Relationship Guidelines - Systematically describe the geographic distribution of data using cardinal directions (N, S, E, W, NE, etc.). - Reference latitude and longitude coordinates when describing specific features. - Describe gradients and transitions using directional language (e.g., "increasing from south to north"). - Use recognized geographic features (countries, seas, mountain ranges) as reference points. -## 4. Pattern Description Protocol +### 4. Pattern Description Protocol - Identify dominant patterns for each variable (e.g., pressure systems, temperature fronts). - Describe the spatial relationships between different variables (e.g., how temperature aligns with pressure systems). - Communicate the intensity of gradients, using terms like "sharp gradient," "gentle slope," or "uniform distribution." - Note any anomalies or unusual features in the data pattern. -## 5. Technical Elements +### 5. Technical Elements - Describe the scale and units prominently displayed in the visualization. - Explain the visualization technique used for each variable (e.g., color gradient for temperature, contour lines for pressure). - Note the resolution and any apparent limitations of the data. -## 6. Scientific Context +### 6. Scientific Context - Include relevant seasonal context (e.g., winter conditions for December visualization). - Provide brief meteorological context for the significance of the date, if applicable. - Relate the patterns shown to typical or expected meteorological conditions for the region and season. -## 7. Accessibility-Specific Considerations +### 7. Accessibility-Specific Considerations - Use clear, unambiguous language that doesn't rely on visual references. - Avoid phrases like "as you can see" or "looking at the map." - Use directional and relative terms that don't require visual understanding. - Ensure all information conveyed by color is also expressed verbally in terms of values and patterns. -## 8. Language Style +### 8. Language Style - Maintain a formal, scientific tone throughout. - Use precise, concise language without unnecessary elaboration. - Be objective in descriptions, clearly differentiating between observed data and interpretations. + +""" + +method_prompt = """## Method + +To achieve this task, you will reflect on the meteorological visualizations and the metadata provided, step by step, critically, and without omitting any detail, to make sure you understand them and the key information they convey. + +Once you've completed your full breakdown of the visualization and the metadata, you should be able to write a comprehensive, scientifically accurate description of the visualization. + +So you will once again start a reflection process, to plan what you will convey and how you will convey it in the desciption, in order to respect the requirements established previously + """ + +format_prompt = """""" + +examples_prompt = """""" + +user_prompt = ( + task_prompt + requirements_prompt + method_prompt + format_prompt + examples_prompt +) + +# %% +description = call_api(user_prompt, img) + +display_markdown(description) From 47500759c4e1dc17206c2d3cab185e252f74a31b Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 19 May 2025 22:58:52 -0400 Subject: [PATCH 015/151] feat: interverting method and requirements in prompt to make method more important --- notebooks/earthkit_plots_exploration.py | 27 +++++++++++++++---------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/notebooks/earthkit_plots_exploration.py b/notebooks/earthkit_plots_exploration.py index 4d2d7ff..d5a3eaa 100644 --- a/notebooks/earthkit_plots_exploration.py +++ b/notebooks/earthkit_plots_exploration.py @@ -235,6 +235,21 @@ def call_api(user_prompt: str, image: Image): """ +method_prompt = """## Method + +To achieve this task, you will work in two three steps. +1. Understanding the information of the visualization +2. Planning your description +3. Writing your description + +First,reflect on the meteorological visualizations and the metadata provided, step by step, critically, and without omitting any detail, to make sure you understand them and the key information they convey. + +Once you've completed your full breakdown of the visualization and the metadata, you should be able to write a comprehensive, scientifically accurate description of the visualization. + +So you will once again start a reflection process, to plan what you will convey and how you will convey it in the desciption, in order to respect the requirements established previously + +""" + requirements_prompt = """## Requirements Here are requirements that you will have to follow rigorously when writing your description. @@ -287,22 +302,12 @@ def call_api(user_prompt: str, image: Image): """ -method_prompt = """## Method - -To achieve this task, you will reflect on the meteorological visualizations and the metadata provided, step by step, critically, and without omitting any detail, to make sure you understand them and the key information they convey. - -Once you've completed your full breakdown of the visualization and the metadata, you should be able to write a comprehensive, scientifically accurate description of the visualization. - -So you will once again start a reflection process, to plan what you will convey and how you will convey it in the desciption, in order to respect the requirements established previously - -""" - format_prompt = """""" examples_prompt = """""" user_prompt = ( - task_prompt + requirements_prompt + method_prompt + format_prompt + examples_prompt + task_prompt + method_prompt + requirements_prompt + format_prompt + examples_prompt ) # %% From ac48de8994e4ba82e6ea28977c85be5d80d0734e Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 20 May 2025 21:13:16 -0400 Subject: [PATCH 016/151] feat: implementing function to generate special weather events images from date --- notebooks/earthkit_plots_exploration.py | 138 +++++++++++++++++++----- 1 file changed, 114 insertions(+), 24 deletions(-) diff --git a/notebooks/earthkit_plots_exploration.py b/notebooks/earthkit_plots_exploration.py index d5a3eaa..3804022 100644 --- a/notebooks/earthkit_plots_exploration.py +++ b/notebooks/earthkit_plots_exploration.py @@ -26,20 +26,25 @@ import warnings from io import BytesIO from pathlib import Path -from typing import Literal +import pandas as pd +from earthkit.data import cache, config import earthkit as ek -import matplotlib.pyplot as plt import openai -import PIL +from PIL import Image from dotenv import load_dotenv from IPython.display import Markdown, display -from PIL.Image import Image load_dotenv(Path().cwd().parent / ".env") warnings.filterwarnings("ignore") +config.set("cache-policy", "user") +print("cache:", cache.directory()) + + +# %% [markdown] +# ## Definitions # %% def display_markdown(text: str) -> None: @@ -89,7 +94,7 @@ def img_to_base64(image_path: str | None = None, img: Image | None = None) -> st ) -def call_api(user_prompt: str, image: Image): +def call_api(user_prompt: str, image) -> str: """Call the LLM generation API""" base64_image = img_to_base64(img=image) try: @@ -125,12 +130,6 @@ def call_api(user_prompt: str, image: Image): # %% [markdown] # ## Loading Data -# %% -from earthkit.data import cache, config - -config.set("cache-policy", "user") -cache.directory() - # %% # Example, quickly accessible data data = ek.data.from_source("sample", "era5-2t-msl-1985122512.grib") @@ -139,20 +138,111 @@ def call_api(user_prompt: str, image: Image): # %% # Copernicus datastore, requires API key set dataset = "reanalysis-era5-single-levels" -request = { - "product_type": ["reanalysis"], - "variable": ["2m_temperature", "mean_sea_level_pressure"], - "year": ["2024"], - "month": ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"], - "day": ["01"], - "time": ["12:00"], - "data_format": "grib", - "download_format": "unarchived", -} -data = ek.data.from_source("cds", dataset, request) + +def get_weather_data(date: str): + date_list = date.split("/") + + request = { + "product_type": ["reanalysis"], + "variable": ["2m_temperature", "mean_sea_level_pressure"], + "day": [date_list[0]], + "month": [date_list[1]], + "year": [date_list[2]], + "time": ["12:00"], + "data_format": "grib", + "download_format": "unarchived", + } + + data = ek.data.from_source("cds", dataset, request) + + return data + + +event_date = "12/03/2011" +data = get_weather_data(event_date) + data.ls() +# %% [markdown] +# ## Generating Plots from Interesting Weather Events + +# %% +# NOTE: those dates are probably most relevant for temperature but are they also for pressure ? +# Maybe we should consider events that have two interesting events ? +# Possible configurations: interesting event present / not present, parameter shown / not shown +weather_events = { + "average_days": {"global": ["12/03/2011", "20/07/2010", "05/11/2003"]}, + "cold_days": {"global": ["13/03/1976", "21/07/1943", "05/11/1975"]}, + "hot_days": {"global": ["14/03/2024", "22/07/2024", "08/11/2023"]}, + "heatwave": {"France": ["30/04/2025"], "Arctic": ["17/04/2015"]}, + "cold_wave": {"United States": ["31/01/2019"], "Europe": ["27/02/2018"]}, +} + + +# %% +def save_weather_event_images(weather_events, output_dir="weather_images"): + """ + Save weather event images and create a CSV mapping file. + + Args: + weather_events: Dictionary of weather events + output_dir: Directory to save the images and CSV file + """ + os.makedirs(output_dir, exist_ok=True) + + figure_metadata = [] + figure_id = 1 + try: + for event_type, domains in weather_events.items(): + for domain_type, dates in domains.items(): + for date_str in dates: + data = get_weather_data(date_str) + + sub_data = data.sel(param=["2t", "msl"], typeOfLevel="surface") + + plot_domain = ( + "Europe" if domain_type.lower() == "global" else domain_type + ) + + buffer = BytesIO() + figure = ek.plots.quickplot( + sub_data, + domain=[plot_domain], + units=["celsius", "hPa"], + mode="overlay", + ) + figure.save(buffer, format="png") + + image_filename = f"figure_{figure_id}.png" + image_path = os.path.join(output_dir, image_filename) + buffer.seek(0) + img = Image.open(buffer) + img.save(image_path) + + figure_metadata.append( + { + "figure_id": figure_id, + "event_type": event_type, + "domain_type": domain_type, + "date": date_str, + } + ) + + figure_id += 1 + except: + pass + + metadata_df = pd.DataFrame(figure_metadata) + csv_path = os.path.join(output_dir, "figure_metadata.csv") + metadata_df.to_csv(csv_path, index=False) + + print(f"Saved {figure_id - 1} figures and metadata to {output_dir}") + return metadata_df + + +metadata_df = save_weather_event_images(weather_events) + # %% [markdown] # ## Exploring GRIB data @@ -192,12 +282,12 @@ def call_api(user_prompt: str, image: Image): # %% # Sub-select the data -sub_data = data.sel(param=["2t", "msl"], typeOfLevel="surface", dataDate=20240101) +sub_data = data.sel(param=["2t", "msl"], typeOfLevel="surface") sub_data.head() # %% buffer = BytesIO() -figure = ek.plots.quickplot(sub_data, domain=["France", "Greece"], mode="overlay") +figure = ek.plots.quickplot(sub_data, domain=["France"], mode="overlay") figure.save(buffer, format="png") From 062474ff1e9332ee800654a2cc03a57b92e0153e Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 24 May 2025 10:24:04 -0400 Subject: [PATCH 017/151] feat: switching caddy for traefik reverse proxy --- .gitignore | 3 ++ vllm/docker-compose.yaml | 80 ++++++++++++++++++++++++++++++---------- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index d597c51..2c54a30 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ wheels/ .ipynb_checkpoints/ *.ipynb +# Data +weather_images/ + # Environment variables file .env diff --git a/vllm/docker-compose.yaml b/vllm/docker-compose.yaml index ba72a2d..ffea94b 100644 --- a/vllm/docker-compose.yaml +++ b/vllm/docker-compose.yaml @@ -1,21 +1,4 @@ services: - caddy: - image: caddy - container_name: caddy - restart: unless-stopped - ports: - - "80:80" - - "443:443" - volumes: - - ${CADDY_FILE_PATH}:/etc/caddy/Caddyfile:ro - - ${CADDY_DATA_DIR}:/data - - ${CADDY_CONFIG_DIR}:/config - environment: - - DOMAIN=${DOMAIN} - - VLLM_PORT=${VLLM_PORT} - depends_on: - - vllm-server - vllm-server: image: vllm-server build: @@ -23,9 +6,14 @@ services: dockerfile: Dockerfile container_name: vllm-server restart: unless-stopped + command: | + vllm serve ${MODEL_DIR_PATH} \ + --served-model-name ${MODEL_NAME} \ + --port ${VLLM_PORT} \ + --api-key ${VLLM_SERVER_API_KEY} + #ports: + #- 8000:${VLLM_PORT} # Enable for debugging runtime: nvidia - ports: - - 8000:${VLLM_PORT} deploy: resources: reservations: @@ -38,4 +26,56 @@ services: environment: - HF_HOME=/app/.cache/huggingface ipc: host - command: vllm serve ${MODEL_DIR_PATH} --served-model-name ${MODEL_NAME} --port ${VLLM_PORT} --api-key ${VLLM_SERVER_API_KEY} + labels: + - "traefik.enable=true" + - "traefik.http.routers.vllm-server.rule=Host(`${DOMAIN}`)" + - "traefik.http.routers.vllm-server.tls=true" + - "traefik.http.routers.vllm-server.entrypoints=https" + - "traefik.http.routers.vllm-server.tls.certresolver=cloudflare" + - "traefik.http.routers.vllm-server.tls.domains[0].main=${DOMAIN}" + - "traefik.http.routers.vllm-server.tls.domains[0].sans=*.${DOMAIN}" + - "traefik.http.services.vllm-server.loadbalancer.server.port=${VLLM_PORT}" + + traefik: + image: traefik:v2.9 + container_name: traefik + restart: unless-stopped + command: + # ----- Basic config ----- + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + # ----- Set up HTTP(S) and redirects (to force HTTPS) ----- + - "--entrypoints.http.address=:80" + - "--entrypoints.https.address=:443" + - "--entrypoints.http.http.redirections.entryPoint.to=https" + - "--entrypoints.http.http.redirections.entryPoint.scheme=https" + # ----- Prevents insecure headers forwarding ----- + - "--entryPoints.http.forwardedHeaders.insecure=false" + - "--entryPoints.http.proxyProtocol.insecure=false" + - "--entryPoints.https.forwardedHeaders.insecure=false" + - "--entryPoints.https.proxyProtocol.insecure=false" + # ----- Set up TLS with LetsEncrypt ----- + - "--certificatesresolvers.cloudflare.acme.dnsChallenge.provider=cloudflare" + - "--certificatesresolvers.cloudflare.acme.email=${CF_API_EMAIL}" + - "--certificatesresolvers.cloudflare.acme.storage=/var/cert/acme.json" + # ----- Logging & Debugging & API ----- + - "--log=true" + - "--log.level=DEBUG" # Can be ERROR (default), DEBUG, PANIC, FATAL, WARN, and INFO + - "--api=false" + - "--api.dashboard=false" + - "--api.insecure=false" # Set to true to access the dashboard locally + - "--pilot.dashboard=false" + - "--global.sendAnonymousUsage=false" + - "--global.checkNewVersion=false" + ports: + - 80:80 + - 443:443 + # - 8080:8080 # Enable to access the dashboard locally + environment: + - CF_API_EMAIL=${CF_API_EMAIL} + - CF_API_KEY=${CF_API_KEY} + volumes: + - ${TRAEFIK_ROOT_DIR}/traefik/cert:/var/cert + - /var/run/docker.sock:/var/run/docker.sock:ro + depends_on: + - vllm-server From b88b9edf37ce0e0dc9873eb37e8dd40d10b209d0 Mon Sep 17 00:00:00 2001 From: Romain Date: Sat, 24 May 2025 17:10:14 +0000 Subject: [PATCH 018/151] feat: replacing caddy with traefik reverse proxy --- vllm/docker-compose.yaml | 44 ++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/vllm/docker-compose.yaml b/vllm/docker-compose.yaml index ffea94b..d40891f 100644 --- a/vllm/docker-compose.yaml +++ b/vllm/docker-compose.yaml @@ -6,11 +6,7 @@ services: dockerfile: Dockerfile container_name: vllm-server restart: unless-stopped - command: | - vllm serve ${MODEL_DIR_PATH} \ - --served-model-name ${MODEL_NAME} \ - --port ${VLLM_PORT} \ - --api-key ${VLLM_SERVER_API_KEY} + command: vllm serve ${MODEL_DIR_PATH} --served-model-name ${MODEL_NAME} --port ${VLLM_PORT} --api-key ${VLLM_SERVER_API_KEY} #ports: #- 8000:${VLLM_PORT} # Enable for debugging runtime: nvidia @@ -26,15 +22,15 @@ services: environment: - HF_HOME=/app/.cache/huggingface ipc: host - labels: - - "traefik.enable=true" - - "traefik.http.routers.vllm-server.rule=Host(`${DOMAIN}`)" - - "traefik.http.routers.vllm-server.tls=true" - - "traefik.http.routers.vllm-server.entrypoints=https" - - "traefik.http.routers.vllm-server.tls.certresolver=cloudflare" - - "traefik.http.routers.vllm-server.tls.domains[0].main=${DOMAIN}" - - "traefik.http.routers.vllm-server.tls.domains[0].sans=*.${DOMAIN}" - - "traefik.http.services.vllm-server.loadbalancer.server.port=${VLLM_PORT}" + # labels: + # - "traefik.enable=true" + # - "traefik.http.routers.vllm-server.rule=Host(`${DOMAIN}`)" + # - "traefik.http.routers.vllm-server.tls=true" + # - "traefik.http.routers.vllm-server.entrypoints=https" + # - "traefik.http.routers.vllm-server.tls.certresolver=cloudflare" + # - "traefik.http.routers.vllm-server.tls.domains[0].main=${DOMAIN}" + # - "traefik.http.routers.vllm-server.tls.domains[0].sans=*.${DOMAIN}" + # - "traefik.http.services.vllm-server.loadbalancer.server.port=${VLLM_PORT}" traefik: image: traefik:v2.9 @@ -54,9 +50,11 @@ services: - "--entryPoints.http.proxyProtocol.insecure=false" - "--entryPoints.https.forwardedHeaders.insecure=false" - "--entryPoints.https.proxyProtocol.insecure=false" + - "--entryPoints.http.proxyProtocol.trustedIPs=${CF_IPS}" + - "--entryPoints.https.proxyProtocol.trustedIPs=${CF_IPS}" # ----- Set up TLS with LetsEncrypt ----- - "--certificatesresolvers.cloudflare.acme.dnsChallenge.provider=cloudflare" - - "--certificatesresolvers.cloudflare.acme.email=${CF_API_EMAIL}" + - "--certificatesresolvers.cloudflare.acme.email=${CF_ACME_EMAIL}" - "--certificatesresolvers.cloudflare.acme.storage=/var/cert/acme.json" # ----- Logging & Debugging & API ----- - "--log=true" @@ -72,10 +70,22 @@ services: - 443:443 # - 8080:8080 # Enable to access the dashboard locally environment: - - CF_API_EMAIL=${CF_API_EMAIL} - - CF_API_KEY=${CF_API_KEY} + - CF_DNS_API_TOKEN=${CF_DNS_TOKEN} volumes: - ${TRAEFIK_ROOT_DIR}/traefik/cert:/var/cert - /var/run/docker.sock:/var/run/docker.sock:ro depends_on: - vllm-server + + + whoami: + image: traefik/whoami + container_name: whoami + # labels: + # - "traefik.enable=true" + # - "traefik.http.routers.whoami.rule=Host(`${DOMAIN}`)" + # - "traefik.http.routers.whoami.tls=true" + # - "traefik.http.routers.whoami.entrypoints=https" + # - "traefik.http.routers.whoami.tls.certresolver=cloudflare" + # - "traefik.http.routers.whoami.tls.domains[0].main=${DOMAIN}" + # - "traefik.http.routers.whoami.tls.domains[0].sans=*.${DOMAIN}" From 4b675028e73507522f8f9b2d60065d0be84831da Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 24 May 2025 13:15:12 -0400 Subject: [PATCH 019/151] feat: updating example and instructions files for reverse proxy --- vllm/.env.example | 11 ++++++----- vllm/Caddyfile | 9 --------- vllm/setup.md | 1 + vllm/setup.sh | 1 + 4 files changed, 8 insertions(+), 14 deletions(-) delete mode 100644 vllm/Caddyfile diff --git a/vllm/.env.example b/vllm/.env.example index 4a86ad1..5a08d0f 100644 --- a/vllm/.env.example +++ b/vllm/.env.example @@ -1,11 +1,12 @@ # Domain configuration DOMAIN= +CF_DNS_TOKEN= +CF_ACME_EMAIL= +CF_IPS= -# Paths -CADDY_DATA_DIR=/opt/caddy/data -CADDY_CONFIG_DIR=/opt/caddy/config -CADDY_FILE_PATH=/opt/caddy/Caddyfile -HF_CACHE_DIR=~/.cache/huggingface +# Paths +TRAEFIK_ROOT_DIR=/opt/traefik +HF_CACHE_DIR=/dev/shm/hf-cache # VLLM configuration MODEL_NAME="google/gemma-3-4b-it" diff --git a/vllm/Caddyfile b/vllm/Caddyfile deleted file mode 100644 index cfc3f69..0000000 --- a/vllm/Caddyfile +++ /dev/null @@ -1,9 +0,0 @@ -{$DOMAIN} { - # Redirect HTTP to HTTPS - redir https://{host}{uri} permanent -} - -https://{$DOMAIN} { - # HTTPS with auto SSL provisioning - reverse_proxy vllm-server:{$VLLM_PORT} -} \ No newline at end of file diff --git a/vllm/setup.md b/vllm/setup.md index 7e0008b..e2c5911 100644 --- a/vllm/setup.md +++ b/vllm/setup.md @@ -93,6 +93,7 @@ sudo dnf install -y docker-ce \ sudo systemctl enable --now docker ``` +TODO: replace caddy configuration with traefik configuration instructions ### Caddy ```sh diff --git a/vllm/setup.sh b/vllm/setup.sh index 552ef89..4dca202 100644 --- a/vllm/setup.sh +++ b/vllm/setup.sh @@ -44,6 +44,7 @@ sudo nvidia-ctk runtime configure --runtime=docker # Restart docker sudo systemctl docker restart +# TODO: replace caddy automated configuration with traefik # Create necessary Caddy directories mkdir -p $CADDY_DATA_DIR $CADDY_CONFIG_DIR From 919dd3ad7fd10872bb28e73a192e566b6b3a7e36 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 24 May 2025 13:32:29 -0400 Subject: [PATCH 020/151] feat: adding todos for security against bots --- vllm/setup.md | 1 + vllm/setup.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/vllm/setup.md b/vllm/setup.md index e2c5911..42e0988 100644 --- a/vllm/setup.md +++ b/vllm/setup.md @@ -32,6 +32,7 @@ sudo dnf upgrade Let's allow HTTP, HTTPS connections and deny the other types by default. +TODO(high): update to only accept requests from cloudflare servers for requests other than SSH ```sh # Install firewalld diff --git a/vllm/setup.sh b/vllm/setup.sh index 4dca202..4784daa 100644 --- a/vllm/setup.sh +++ b/vllm/setup.sh @@ -11,6 +11,7 @@ sudo systemctl start firewalld sudo systemctl enable firewalld # Add SSH, HTTP and HTTPS services +# TODO(high): update to only accept requests from cloudflare servers for requests other than SSH sudo firewall-cmd --permanent --add-service=ssh sudo firewall-cmd --permanent --add-service=http sudo firewall-cmd --permanent --add-service=https From bcd90d88608af981c18a41cfee9b7e8aca44c14d Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 1 Jun 2025 11:55:46 -0400 Subject: [PATCH 021/151] fix: fixing project import issue --- pyproject.toml | 4 +- .../__init__.py | 0 .../config/__init__.py | 0 .../core/__init__.py | 0 src/earth_reach_agent/core/llm.py | 161 ++++++++++++++++++ .../core/prompts.py | 0 uv.lock | 70 ++++---- 7 files changed, 198 insertions(+), 37 deletions(-) rename src/{earthreach-agent => earth_reach_agent}/__init__.py (100%) rename src/{earthreach-agent => earth_reach_agent}/config/__init__.py (100%) rename src/{earthreach-agent => earth_reach_agent}/core/__init__.py (100%) create mode 100644 src/earth_reach_agent/core/llm.py rename src/{earthreach-agent => earth_reach_agent}/core/prompts.py (100%) diff --git a/pyproject.toml b/pyproject.toml index 83c8db3..54ffbaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "earthreach-agent-challenge" +name = "earth-reach-agent" version = "0.1.0" description = "EarthReach Agent: Dual-LLM Framework for Validated Meteorological Chart Descriptions" readme = "README.md" @@ -24,4 +24,4 @@ dev = [ ] [tool.hatch.build.targets.wheel] -packages = ["src/earthreach-agent"] \ No newline at end of file +packages = ["src/earth_reach_agent"] \ No newline at end of file diff --git a/src/earthreach-agent/__init__.py b/src/earth_reach_agent/__init__.py similarity index 100% rename from src/earthreach-agent/__init__.py rename to src/earth_reach_agent/__init__.py diff --git a/src/earthreach-agent/config/__init__.py b/src/earth_reach_agent/config/__init__.py similarity index 100% rename from src/earthreach-agent/config/__init__.py rename to src/earth_reach_agent/config/__init__.py diff --git a/src/earthreach-agent/core/__init__.py b/src/earth_reach_agent/core/__init__.py similarity index 100% rename from src/earthreach-agent/core/__init__.py rename to src/earth_reach_agent/core/__init__.py diff --git a/src/earth_reach_agent/core/llm.py b/src/earth_reach_agent/core/llm.py new file mode 100644 index 0000000..d8af186 --- /dev/null +++ b/src/earth_reach_agent/core/llm.py @@ -0,0 +1,161 @@ +from typing import Optional + +import openai +from earth_reach_agent.core.utils import img_to_base64 + + +class BaseLLM: + """Base class for a LLM interface.""" + + def __init__( + self, + model_name: str, + base_url: str, + api_key: Optional[str] = None, + ) -> None: + """ + Initialize the LLM with a model name and optional keyword arguments. + + Args: + model_name (str): The name of the model to use. + base_url (str): The base URL for the LLM API. + api_key (Optional[str]): The API key for authentication with the LLM provider. + **kwargs: Additional keyword arguments for the LLM configuration. + """ + self.model_name = model_name + self.base_url = base_url + self.api_key = api_key + + self.client = openai.OpenAI( + base_url=base_url, + api_key=api_key, + ) + + def generate( + self, user_prompt: str, system_prompt: Optional[str] = None, image=None + ) -> str: + """ + Generate a response from the LLM API based on the user prompt and optional system prompt. + + Args: + user_prompt (str): The prompt provided by the user to define the task. + system_prompt (Optional[str]): An optional system prompt to guide the model's response. + image: Optional image to include in the request (will be converted to base64). + + Returns: + str: The generated response content from the LLM. + + Raises: + ValueError: If user_prompt is empty/None or if the API response is empty. + RuntimeError: For other run-time errors. + """ + + if not user_prompt or not user_prompt.strip(): + raise ValueError("user_prompt cannot be empty or None") + + messages = [] + + if system_prompt and system_prompt.strip(): + messages.append({"role": "system", "content": system_prompt.strip()}) + + try: + if image: + base64_image = img_to_base64(img=image) + if not base64_image: + raise ValueError("Failed to convert image to base64") + + user_content = [ + {"type": "text", "text": user_prompt.strip()}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{base64_image}", + }, + }, + ] + else: + user_content = user_prompt.strip() + + messages.append({"role": "user", "content": user_content}) + + except Exception as e: + raise ValueError(f"Failed to process input data: {e}") + + try: + response = self.client.chat.completions.create( + model=self.model_name, + messages=messages, + ) + + content = response.choices[0].message.content + + if not content or not content.strip(): + raise ValueError("The generated response content is empty") + + return content.strip() + + except ValueError: + raise + except Exception as e: + error_msg = f"API call failed: {type(e).__name__}: {e}" + print(error_msg) + raise RuntimeError(error_msg) from e + + def __repr__(self) -> str: + return f"LLM(model_name={self.model_name}, base_url={self.base_url})" + + +class GroqLLM(BaseLLM): + """Implementation of the BaseLLM for Groq LLM API Provider.""" + + def __init__(self, model_name: str, api_key: Optional[str] = None) -> None: + """Initialize the Groq LLM with a model name and optional API key. + + Args: + model_name (str): The name of the Groq model to use. + api_key (Optional[str]): The API key for authentication with the Groq API. + + Raises: + AssertionError: If the API key is not provided and not found in environment variables. + """ + + if not api_key: + import os + + api_key = os.environ.get("GROQ_API_KEY", None) + if not api_key: + raise AssertionError( + "GROQ_API_KEY not set. Please set it in your environment variables, or pass it as an argument." + ) + + super().__init__( + model_name=model_name, api_key=api_key, base_url="https://api.groq.com/v1" + ) + + +class OpenAILLM(BaseLLM): + """Implementation of the BaseLLM for OpenAI API Provider.""" + + def __init__(self, model_name: str, api_key: Optional[str] = None) -> None: + """Initialize the OpenAI LLM with a model name and optional API key. + + Args: + model_name (str): The name of the OpenAI model to use. + api_key (Optional[str]): The API key for authentication with the OpenAI API. + + Raises: + AssertionError: If the API key is not provided and not found in environment variables. + """ + + if not api_key: + import os + + api_key = os.environ.get("OPENAI_API_KEY", None) + if not api_key: + raise AssertionError( + "OPENAI_API_KEY not set. Please set it in your environment variables, or pass it as an argument." + ) + + super().__init__( + model_name=model_name, api_key=api_key, base_url="https://api.openai.com/v1" + ) diff --git a/src/earthreach-agent/core/prompts.py b/src/earth_reach_agent/core/prompts.py similarity index 100% rename from src/earthreach-agent/core/prompts.py rename to src/earth_reach_agent/core/prompts.py diff --git a/uv.lock b/uv.lock index f19396b..eaf6ba2 100644 --- a/uv.lock +++ b/uv.lock @@ -462,6 +462,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533 }, ] +[[package]] +name = "earth-reach-agent" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "earthkit" }, + { name = "earthkit-data" }, + { name = "earthkit-plots" }, + { name = "hatchling" }, + { name = "openai" }, + { name = "python-dotenv" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ipykernel" }, + { name = "jupytext" }, +] + +[package.metadata] +requires-dist = [ + { name = "earthkit", specifier = ">=0.10.3" }, + { name = "earthkit-data", specifier = ">=0.13.8" }, + { name = "earthkit-plots", specifier = ">=0.3.1" }, + { name = "hatchling", specifier = ">=1.27.0" }, + { name = "openai", specifier = ">=1.77.0" }, + { name = "python-dotenv", specifier = ">=1.1.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "ipykernel", specifier = ">=6.29.5" }, + { name = "jupytext", specifier = ">=1.17.1" }, +] + [[package]] name = "earthkit" version = "0.10.3" @@ -636,41 +671,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/f9/1ca7480e4a22d39d884c3fa855cb7fb8cf922c5cba203905efcdf6a67a42/earthkit_transforms-0.4.0-py3-none-any.whl", hash = "sha256:258df72cdbe3c662edab37226013a45c44a907a117afb64c8065eb49617ce2a7", size = 34480 }, ] -[[package]] -name = "earthreach-agent-challenge" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "earthkit" }, - { name = "earthkit-data" }, - { name = "earthkit-plots" }, - { name = "hatchling" }, - { name = "openai" }, - { name = "python-dotenv" }, -] - -[package.dev-dependencies] -dev = [ - { name = "ipykernel" }, - { name = "jupytext" }, -] - -[package.metadata] -requires-dist = [ - { name = "earthkit", specifier = ">=0.10.3" }, - { name = "earthkit-data", specifier = ">=0.13.8" }, - { name = "earthkit-plots", specifier = ">=0.3.1" }, - { name = "hatchling", specifier = ">=1.27.0" }, - { name = "openai", specifier = ">=1.77.0" }, - { name = "python-dotenv", specifier = ">=1.1.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "ipykernel", specifier = ">=6.29.5" }, - { name = "jupytext", specifier = ">=1.17.1" }, -] - [[package]] name = "eccodes" version = "2.41.0" From fad246fff978461ce303d71b3007167befe7312f Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 1 Jun 2025 11:56:15 -0400 Subject: [PATCH 022/151] feat: implementing LLM wrapper class for openai and groq llm api providers --- src/earth_reach_agent/core/evaluator.py | 30 +++++++++++++++++++++++++ src/earth_reach_agent/core/generator.py | 12 ++++++++++ src/earth_reach_agent/core/utils.py | 25 +++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 src/earth_reach_agent/core/evaluator.py create mode 100644 src/earth_reach_agent/core/generator.py create mode 100644 src/earth_reach_agent/core/utils.py diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach_agent/core/evaluator.py new file mode 100644 index 0000000..beee614 --- /dev/null +++ b/src/earth_reach_agent/core/evaluator.py @@ -0,0 +1,30 @@ +class EvaluatorAgent: + pass + + +def is_text_length_lesser_than_max(text: str, max_length: int = 1000) -> bool: + """ + Check if the length of the text is lesser than the specified maximum length. + + Args: + text (str): The text to check. + max_length (int): The maximum length of the text. + + Returns: + bool: True if the text length is valid, False otherwise. + """ + return len(text) <= max_length + + +def is_text_length_greater_than_min(text: str, min_length: int = 100) -> bool: + """ + Check if the length of the text is greater than the specified minimum length. + + Args: + text (str): The text to check. + min_length (int): The minimum length of the text. + + Returns: + bool: True if the text length is more than the minimum, False otherwise. + """ + return min_length <= len(text) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py new file mode 100644 index 0000000..6973c8c --- /dev/null +++ b/src/earth_reach_agent/core/generator.py @@ -0,0 +1,12 @@ +from earth_reach_agent.core.llm import BaseLLM + + +class GeneratorAgent: + def __init__(self, llm: BaseLLM) -> None: + """ + Initialize the GeneratorAgent with a BaseLLM instance. + + Args: + llm (BaseLLM): An instance of a BaseLLM to handle LLM interactions. + """ + self.llm = llm diff --git a/src/earth_reach_agent/core/utils.py b/src/earth_reach_agent/core/utils.py new file mode 100644 index 0000000..98d1f95 --- /dev/null +++ b/src/earth_reach_agent/core/utils.py @@ -0,0 +1,25 @@ +import base64 +from io import BytesIO + + +def img_to_base64(image_path: str | None = None, img=None) -> str: + """ + Convert an image to a base64 string. + + Args: + image_path (str): The path to the image file. Either this or img must be provided. + img (PIL.Image): The image object. Either this or image_path must be provided. + + Returns: + str: The base64 string representation of the image. + """ + if image_path is None and img is None: + raise ValueError("Either image_path or img must be provided.") + + if img is not None: + bytes_io = BytesIO() + img.save(bytes_io, format="PNG") + return base64.b64encode(bytes_io.getvalue()).decode("utf-8") + + with open(image_path, "rb") as img_file: # type: ignore + return base64.b64encode(img_file.read()).decode("utf-8") From 346bd06a84e84ff793ef306e851b0e9616710842 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 1 Jun 2025 12:24:33 -0400 Subject: [PATCH 023/151] feat: enhancing user prompt with task decomposition and output format --- src/earth_reach_agent/core/evaluator.py | 65 ++++---- src/earth_reach_agent/core/generator.py | 165 ++++++++++++++++++ src/earth_reach_agent/core/prompts.py | 212 ++++++++++++++++++------ 3 files changed, 364 insertions(+), 78 deletions(-) diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach_agent/core/evaluator.py index beee614..c43a8fc 100644 --- a/src/earth_reach_agent/core/evaluator.py +++ b/src/earth_reach_agent/core/evaluator.py @@ -1,30 +1,37 @@ class EvaluatorAgent: - pass - - -def is_text_length_lesser_than_max(text: str, max_length: int = 1000) -> bool: - """ - Check if the length of the text is lesser than the specified maximum length. - - Args: - text (str): The text to check. - max_length (int): The maximum length of the text. - - Returns: - bool: True if the text length is valid, False otherwise. - """ - return len(text) <= max_length - - -def is_text_length_greater_than_min(text: str, min_length: int = 100) -> bool: - """ - Check if the length of the text is greater than the specified minimum length. - - Args: - text (str): The text to check. - min_length (int): The minimum length of the text. - - Returns: - bool: True if the text length is more than the minimum, False otherwise. - """ - return min_length <= len(text) + """EvaluatorAgent class for evaluating the quality of Generator Agent's weather chart descriptions.""" + + def __init__(self) -> None: + """ + Initialize the EvaluatorAgent. + + Args: + parameters, quality criteria, etc.. + """ + pass + + def is_text_length_lesser_than_max(self, text: str, max_length: int = 1000) -> bool: + """ + Check if the length of the text is lesser than the specified maximum length. + + Args: + text (str): The text to check. + max_length (int): The maximum length of the text. + + Returns: + bool: True if the text length is valid, False otherwise. + """ + return len(text) <= max_length + + def is_text_length_greater_than_min(self, text: str, min_length: int = 100) -> bool: + """ + Check if the length of the text is greater than the specified minimum length. + + Args: + text (str): The text to check. + min_length (int): The minimum length of the text. + + Returns: + bool: True if the text length is more than the minimum, False otherwise. + """ + return min_length <= len(text) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index 6973c8c..cf518b2 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -1,7 +1,13 @@ +import re +from dataclasses import dataclass +from typing import List, Optional + from earth_reach_agent.core.llm import BaseLLM class GeneratorAgent: + """GeneratorAgent class for generating weather charts scientific descriptions.""" + def __init__(self, llm: BaseLLM) -> None: """ Initialize the GeneratorAgent with a BaseLLM instance. @@ -10,3 +16,162 @@ def __init__(self, llm: BaseLLM) -> None: llm (BaseLLM): An instance of a BaseLLM to handle LLM interactions. """ self.llm = llm + + +@dataclass +class GeneratorOutput: + """ + Structured representation of the generator agent's output. + + Attributes: + step_1: Data extraction and analysis content + step_2: Pattern recognition and spatial analysis content + step_3: Description planning and structure design content + step_4: Description writing with verification content + final_description: The final consolidated weather description + """ + + step_1: Optional[str] = None + step_2: Optional[str] = None + step_3: Optional[str] = None + step_4: Optional[str] = None + final_description: Optional[str] = None + + def is_complete(self) -> bool: + """ + Check if all required fields were successfully parsed. + + Returns: + bool: True if all fields contain content, False otherwise + """ + return all( + [ + self.step_1 is not None and self.step_1.strip(), + self.step_2 is not None and self.step_2.strip(), + self.step_3 is not None and self.step_3.strip(), + self.step_4 is not None and self.step_4.strip(), + self.final_description is not None and self.final_description.strip(), + ] + ) + + def get_missing_fields(self) -> List[str]: + """ + Return list of field names that were not successfully parsed. + + Returns: + List[str]: Names of fields that are None or empty + """ + missing = [] + if not self.step_1 or not self.step_1.strip(): + missing.append("step_1") + if not self.step_2 or not self.step_2.strip(): + missing.append("step_2") + if not self.step_3 or not self.step_3.strip(): + missing.append("step_3") + if not self.step_4 or not self.step_4.strip(): + missing.append("step_4") + if not self.final_description or not self.final_description.strip(): + missing.append("final_description") + return missing + + def get_step_word_count(self, step_name: str) -> int: + """ + Get word count for a specific step. + + Args: + step_name: Name of the step ('step_1', 'step_2', etc.) + + Returns: + int: Word count for the specified step, 0 if step is None/empty + """ + content = getattr(self, step_name, None) + if not content: + return 0 + return len(content.split()) + + def get_final_description_word_count(self) -> int: + """ + Get word count for the final description. + + Returns: + int: Word count for final description, 0 if None/empty + """ + if not self.final_description: + return 0 + return len(self.final_description.split()) + + +# TODO(high): refactor this to add it to the generator agent class +def parse_generator_output(output: str) -> GeneratorOutput: + """ + Parse the XML-tagged output from the generator agent into structured data. + + Args: + output (str): The full output string containing XML tags + + Returns: + GeneratorOutput: Parsed content with individual step results + + Raises: + ValueError: If the output string is empty or None + """ + if not output or not output.strip(): + raise ValueError("Output string is empty or None") + + result = GeneratorOutput() + + patterns = { + "step_1": r"(.*?)", + "step_2": r"(.*?)", + "step_3": r"(.*?)", + "step_4": r"(.*?)", + "final_description": r"(.*?)", + } + + for field_name, pattern in patterns.items(): + try: + match = re.search(pattern, output, re.DOTALL) + if match: + content = match.group(1).strip() + if content: + setattr(result, field_name, content) + except Exception as e: + print(f"Warning: Failed to parse {field_name}: {e}") + continue + + return result + + +def validate_generator_output( + parsed_output: GeneratorOutput, require_all_steps: bool = True +) -> tuple[bool, List[str]]: + """ + Validate that the parsed output meets expected criteria. + + Args: + parsed_output: The parsed GeneratorOutput object + require_all_steps: Whether to require all steps to be present + + Returns: + tuple[bool, List[str]]: (is_valid, list_of_issues) + """ + issues = [] + + if require_all_steps: + missing = parsed_output.get_missing_fields() + if missing: + issues.append(f"Missing required fields: {', '.join(missing)}") + + final_word_count = parsed_output.get_final_description_word_count() + if parsed_output.final_description and ( + final_word_count < 300 or final_word_count > 500 + ): + issues.append( + f"Final description word count ({final_word_count}) outside expected range (300-500)" + ) + + if not parsed_output.final_description: + issues.append("Final description is missing or empty") + + is_valid = len(issues) == 0 + return is_valid, issues diff --git a/src/earth_reach_agent/core/prompts.py b/src/earth_reach_agent/core/prompts.py index 6047638..1b8a36d 100644 --- a/src/earth_reach_agent/core/prompts.py +++ b/src/earth_reach_agent/core/prompts.py @@ -1,50 +1,164 @@ -system_prompt = """# Instructions for Describing Meteorological Visualizations for Blind Scientists - -Your task is to create a comprehensive, scientifically accurate description of meteorological visualizations that will be accessible to blind scientists. Follow these detailed guidelines: - -## 1. Structural Organization -- Begin with a concise overview (2-3 sentences) stating the visualization type, geographic region, time period, and primary variables displayed. -- Organize your description hierarchically from most to least scientifically significant features. -- Structure your description in clearly defined sections with standardized headings. - -## 2. Scientific Content Requirements -- Use precise meteorological terminology consistent with scientific publications. -- Always include quantitative values with appropriate units (hPa for pressure, K/°C for temperature). -- Describe patterns and gradients rather than individual data points. -- Explicitly state the range of values shown for each variable. -- Identify and describe major meteorological features (high/low pressure systems, fronts, temperature gradients). -- Include geographic context using cardinal directions and recognized regional references. - -## 3. Spatial Relationship Guidelines -- Systematically describe the geographic distribution of data using cardinal directions (N, S, E, W, NE, etc.). -- Reference latitude and longitude coordinates when describing specific features. -- Describe gradients and transitions using directional language (e.g., "increasing from south to north"). -- Use recognized geographic features (countries, seas, mountain ranges) as reference points. - -## 4. Pattern Description Protocol -- Identify dominant patterns for each variable (e.g., pressure systems, temperature fronts). -- Describe the spatial relationships between different variables (e.g., how temperature aligns with pressure systems). -- Communicate the intensity of gradients, using terms like "sharp gradient," "gentle slope," or "uniform distribution." -- Note any anomalies or unusual features in the data pattern. - -## 5. Technical Elements -- Describe the scale and units prominently displayed in the visualization. -- Explain the visualization technique used for each variable (e.g., color gradient for temperature, contour lines for pressure). -- Note the resolution and any apparent limitations of the data. - -## 6. Scientific Context -- Include relevant seasonal context (e.g., winter conditions for December visualization). -- Provide brief meteorological context for the significance of the date, if applicable. -- Relate the patterns shown to typical or expected meteorological conditions for the region and season. - -## 7. Accessibility-Specific Considerations -- Use clear, unambiguous language that doesn't rely on visual references. -- Avoid phrases like "as you can see" or "looking at the map." -- Use directional and relative terms that don't require visual understanding. -- Ensure all information conveyed by color is also expressed verbally in terms of values and patterns. - -## 8. Language Style -- Maintain a formal, scientific tone throughout. -- Use precise, concise language without unnecessary elaboration. -- Be objective in descriptions, clearly differentiating between observed data and interpretations. +user_prompt = """# Enhanced Weather Chart Alt-Text Generation System + +## ROLE AND CONTEXT SETTING + +You are a specialist scientific communication assistant working with meteorological researchers who are blind or visually impaired. Your expertise lies in converting complex weather visualizations into precise, scientifically accurate text descriptions that preserve all critical meteorological information while being fully accessible. + +**Your Mission**: Transform weather charts and maps into comprehensive text descriptions that enable blind scientists to conduct the same quality of meteorological analysis as their sighted colleagues. + +**Critical Context**: Your descriptions will be used for: +- Research analysis and data interpretation +- Scientific paper writing and peer review +- Teaching and educational materials +- Operational weather forecasting decisions + +## TASK DECOMPOSITION + +### Step 1: Data Extraction and Analysis (200-500 words) +**Objective**: Systematically catalog all quantitative information and meteorological features + +**Actions**: +1. Identify and record the exact value ranges for each variable (temperature, pressure, wind speed, etc.) +2. Note the geographic boundaries (latitude/longitude ranges), map projection, and spatial domain (e.g., "North America", "Europe", "Spain", "Global") +3. List all meteorological systems visible (highs, lows, fronts, convergence zones) +4. Document the time period, date, and any temporal information +5. Record the data source, resolution, and any technical specifications shown + +**Success Check**: Can you list specific numbers for every major feature without using vague terms like "high" or "low"? + +### Step 2: Pattern Recognition and Spatial Analysis (200-500 words) +**Objective**: Identify the dominant meteorological patterns and their spatial relationships + +**Actions**: +1. Determine the primary weather systems and rank them by meteorological significance +2. Map the directional flow of gradients (temperature, pressure) using cardinal directions +3. Identify spatial correlations between different variables +4. Note any unusual or anomalous features that deviate from expected patterns +5. Assess the seasonal/temporal context and typical vs. atypical conditions + +**Success Check**: Can you explain how each major system influences the others spatially? + +### Step 3: Description Planning and Structure Design (100-150 words) +**Objective**: Create a hierarchical outline that prioritizes information by scientific importance + +**Actions**: +1. Rank meteorological features by their significance for understanding weather patterns +2. Plan the logical flow from overview to specific details +3. Determine which quantitative values are essential vs. supplementary +4. Identify the most effective way to convey spatial relationships +5. Plan transitions between sections to maintain scientific coherence + +**Success Check**: Would a meteorologist reading your outline understand the complete weather situation? + +### Step 4: Description Writing with Verification (300-500 words) +**Objective**: Produce the final description following all specifications + +**Actions**: +1. Write a concise overview (2-3 sentences) summarizing the visualization type, geographic region, time period, and primary variables +2. Develop the main body with 4-6 sentences, covering: + - Dominant systems and their characteristics + - Variable distributions (temperature, pressure, wind) + - Spatial relationships and correlations + - Notable features or anomalies +3. Ensure all quantitative values include specific ranges and units +4. Use precise meteorological terminology and avoid visual-dependent language + +**Success Check**: Does your description pass all items in the quality verification checklist? + +## CONCRETE CONSTRAINTS AND SPECIFICATIONS + +### Length and Structure Requirements +- **Total description length**: 300-500 words maximum +- **Overview section**: Exactly 2-3 sentences +- **Main body**: 4-6 sentences + +### Quantitative Requirements +- **Include exact ranges**: Every variable must have minimum and maximum values with units +- **Spatial precision**: Use cardinal directions and geographic references +- **Coordinate references**: Include latitude/longitude for major features (±2 degrees acceptable) +- **Unit consistency**: Use standard meteorological units (hPa, m/s, K or °C, km) + +### Language Specifications +- **Terminology level**: Graduate-level meteorological vocabulary (assume PhD-level audience) +- **Sentence structure**: Maximum 25 words per sentence +- **Accessibility compliance**: Zero visual-dependent phrases (see prohibited terms list below) +- **Objectivity standard**: Separate observations from interpretations using phrases like "The data shows..." vs. "This suggests..." + +## ERROR PREVENTION GUIDELINES + +### Common Pitfalls to Avoid +1. **Vague descriptions**: Never use "high," "low," "warm," "cold" without specific values +2. **Visual dependency**: Prohibited phrases include: "as shown," "visible," "looking at," "clearly," "obviously" +3. **Geographic ambiguity**: Avoid "here," "there," "this area" - use specific location references +4. **Unit omissions**: Every quantitative statement must include appropriate units +5. **Pattern assumptions**: Don't assume readers can visualize spatial relationships - describe explicitly + +## SUCCESS CRITERIA + +### Objective Standards +1. **Completeness Test**: A meteorologist should be able to sketch the general pattern from your description +2. **Quantitative Accuracy**: All numerical values within ±5% of actual data +3. **Accessibility Compliance**: 100% of visual information conveyed through text +4. **Scientific Utility**: Description enables the same analytical conclusions as the visual +5. **Terminology Precision**: All meteorological terms used correctly per AMS Glossary standards + +## EXAMPLES AND REFERENCE PATTERNS + +Not yet available, but will be provided in the future. + +## OUTPUT FORMAT REQUIREMENTS + +### XML Tag Structure +**CRITICAL**: You must wrap the content of each step and the final description in specific XML tags for programmatic parsing. + +**Required XML Tags:** +- `...` - Wrap the entire content of Step 1 (excluding the markdown header) +- `...` - Wrap the entire content of Step 2 (excluding the markdown header) +- `...` - Wrap the entire content of Step 3 (excluding the markdown header) +- `...` - Wrap the entire content of Step 4 (excluding the markdown header) +- `...` - Wrap only the final consolidated description (without headers or meta-commentary) + +### Formatting Rules +1. **Tag Placement**: Place opening and closing tags on their own lines +2. **Content Scope**: Include all step content within tags, but exclude markdown headers (## Step X) +3. **Final Description**: The `` should contain ONLY the final weather description without any meta-commentary about word count or requirements +4. **No Tag Omission**: All five XML tag pairs must be present, even if a step is brief + +### Example Structure: +``` +## Step 1: Data Extraction and Analysis + +The chart displays 2-meter temperature and mean sea level pressure... +[rest of step 1 content] + + +## Step 2: Pattern Recognition and Spatial Analysis + +The dominant feature is a high-pressure system... +[rest of step 2 content] + + +## Step 3: Description Planning and Structure Design + +To structure the description, we will start with an overview... +[rest of step 3 content] + + +## Step 4: Description Writing with Verification + +### Overview +This weather chart displays... +### Main Body +A high-pressure system is centered... +[rest of step 4 content] + + + +This weather chart visualization shows 2-meter temperature and mean sea level pressure over Western Europe on March 12, 2011, at 12:00 UTC. The region covers latitudes from 40.5°N to 51°N and longitudes from 2.5°W to 10°E. A high-pressure system is centered at 45°N, 7.5°E with a pressure of 102080 Pa. The 2-meter temperature varies from 280 K in the north to 294 K in the southeast, showing a significant north-south temperature gradient. The pressure distribution is dominated by this high-pressure system, with pressure decreasing towards the northwest. The highest temperatures are located southeast of the high-pressure center. + +``` + +**IMPORTANT**: Ensure all XML tags are properly opened and closed. Malformed XML will cause parsing failures. + +**Remember**: Your description is not just text, it's a scientific instrument that must preserve the analytical power of the original visualization while being fully accessible to blind scientists. """ From ed1501e9d886ac4e5a1b97a84e94b88688c180ee Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 1 Jun 2025 12:26:07 -0400 Subject: [PATCH 024/151] feat: developing improved prompt --- notebooks/earthkit_plots_exploration.py | 208 ++++++++++++++++++++++-- 1 file changed, 192 insertions(+), 16 deletions(-) diff --git a/notebooks/earthkit_plots_exploration.py b/notebooks/earthkit_plots_exploration.py index 3804022..42ba1a0 100644 --- a/notebooks/earthkit_plots_exploration.py +++ b/notebooks/earthkit_plots_exploration.py @@ -32,6 +32,7 @@ import earthkit as ek import openai from PIL import Image +from typing import List, Dict from dotenv import load_dotenv from IPython.display import Markdown, display @@ -57,7 +58,7 @@ def display_markdown(text: str) -> None: display(Markdown(text)) -def img_to_base64(image_path: str | None = None, img: Image | None = None) -> str: +def img_to_base64(image_path: str | None = None, img = None) -> str: """ Convert an image to a base64 string. @@ -94,7 +95,7 @@ def img_to_base64(image_path: str | None = None, img: Image | None = None) -> st ) -def call_api(user_prompt: str, image) -> str: +def call_llm_api(user_prompt: str, image) -> str: """Call the LLM generation API""" base64_image = img_to_base64(img=image) try: @@ -201,16 +202,18 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): sub_data = data.sel(param=["2t", "msl"], typeOfLevel="surface") - plot_domain = ( - "Europe" if domain_type.lower() == "global" else domain_type + kwargs: Dict[str, List] = ( + {"domain": [domain_type]} + if domain_type.lower() != "global" + else {} ) buffer = BytesIO() figure = ek.plots.quickplot( sub_data, - domain=[plot_domain], units=["celsius", "hPa"], mode="overlay", + **kwargs, # type: ignore ) figure.save(buffer, format="png") @@ -293,7 +296,7 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): buffer.seek(0) -img = PIL.Image.open(buffer) +img = Image.open(buffer) # %% figure.title() # TODO: extract the string title @@ -309,15 +312,6 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): # %% [markdown] # ## Trying a Simple VLLM Summary Generation -# %% [markdown] -# Observations (from a very simple prompt): -# - Good structured and objective documentation of visual elements -# - Can accurately identify technical components of scientific visualizations -# - Remains cautious about making interpretive claims beyond what's explicitly shown -# - Prioritizes factual accuracy over speculative analysis -# -# These first observations are promising. We now need to try a generation with a more specialized prompt, including best prompt-engineering techniques from the start. - # %% task_prompt = """# Instructions for Describing Meteorological Visualizations for Blind Scientists @@ -401,6 +395,188 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): ) # %% -description = call_api(user_prompt, img) +description = call_llm_api(user_prompt, img) + +display_markdown(description) + +# %% [markdown] +# Observations (from a very simple prompt): +# - Good structured and objective documentation of visual elements +# - Can accurately identify technical components of scientific visualizations +# - Remains cautious about making interpretive claims beyond what's explicitly shown +# - Prioritizes factual accuracy over speculative analysis +# +# These first observations are promising. We now need to try a generation with a more specialized prompt, including best prompt-engineering techniques from the start. + +# %% +enhanced_prompt = """# Enhanced Weather Chart Alt-Text Generation System + +## ROLE AND CONTEXT SETTING + +You are a specialist scientific communication assistant working with meteorological researchers who are blind or visually impaired. Your expertise lies in converting complex weather visualizations into precise, scientifically accurate text descriptions that preserve all critical meteorological information while being fully accessible. + +**Your Mission**: Transform weather charts and maps into comprehensive text descriptions that enable blind scientists to conduct the same quality of meteorological analysis as their sighted colleagues. + +**Critical Context**: Your descriptions will be used for: +- Research analysis and data interpretation +- Scientific paper writing and peer review +- Teaching and educational materials +- Operational weather forecasting decisions + +## TASK DECOMPOSITION + +### Step 1: Data Extraction and Analysis (200-500 words) +**Objective**: Systematically catalog all quantitative information and meteorological features + +**Actions**: +1. Identify and record the exact value ranges for each variable (temperature, pressure, wind speed, etc.) +2. Note the geographic boundaries (latitude/longitude ranges), map projection, and spatial domain (e.g., "North America", "Europe", "Spain", "Global") +3. List all meteorological systems visible (highs, lows, fronts, convergence zones) +4. Document the time period, date, and any temporal information +5. Record the data source, resolution, and any technical specifications shown + +**Success Check**: Can you list specific numbers for every major feature without using vague terms like "high" or "low"? + +### Step 2: Pattern Recognition and Spatial Analysis (200-500 words) +**Objective**: Identify the dominant meteorological patterns and their spatial relationships + +**Actions**: +1. Determine the primary weather systems and rank them by meteorological significance +2. Map the directional flow of gradients (temperature, pressure) using cardinal directions +3. Identify spatial correlations between different variables +4. Note any unusual or anomalous features that deviate from expected patterns +5. Assess the seasonal/temporal context and typical vs. atypical conditions + +**Success Check**: Can you explain how each major system influences the others spatially? + +### Step 3: Description Planning and Structure Design (100-150 words) +**Objective**: Create a hierarchical outline that prioritizes information by scientific importance + +**Actions**: +1. Rank meteorological features by their significance for understanding weather patterns +2. Plan the logical flow from overview to specific details +3. Determine which quantitative values are essential vs. supplementary +4. Identify the most effective way to convey spatial relationships +5. Plan transitions between sections to maintain scientific coherence + +**Success Check**: Would a meteorologist reading your outline understand the complete weather situation? + +### Step 4: Description Writing with Verification (300-500 words) +**Objective**: Produce the final description following all specifications + +**Actions**: +1. Write a concise overview (2-3 sentences) summarizing the visualization type, geographic region, time period, and primary variables +2. Develop the main body with 4-6 sentences, covering: + - Dominant systems and their characteristics + - Variable distributions (temperature, pressure, wind) + - Spatial relationships and correlations + - Notable features or anomalies +3. Ensure all quantitative values include specific ranges and units +4. Use precise meteorological terminology and avoid visual-dependent language + +**Success Check**: Does your description pass all items in the quality verification checklist? + +## CONCRETE CONSTRAINTS AND SPECIFICATIONS + +### Length and Structure Requirements +- **Total description length**: 300-500 words maximum +- **Overview section**: Exactly 2-3 sentences +- **Main body**: 4-6 sentences + +### Quantitative Requirements +- **Include exact ranges**: Every variable must have minimum and maximum values with units +- **Spatial precision**: Use cardinal directions and geographic references +- **Coordinate references**: Include latitude/longitude for major features (±2 degrees acceptable) +- **Unit consistency**: Use standard meteorological units (hPa, m/s, K or °C, km) + +### Language Specifications +- **Terminology level**: Graduate-level meteorological vocabulary (assume PhD-level audience) +- **Sentence structure**: Maximum 25 words per sentence +- **Accessibility compliance**: Zero visual-dependent phrases (see prohibited terms list below) +- **Objectivity standard**: Separate observations from interpretations using phrases like "The data shows..." vs. "This suggests..." + +## ERROR PREVENTION GUIDELINES + +### Common Pitfalls to Avoid +1. **Vague descriptions**: Never use "high," "low," "warm," "cold" without specific values +2. **Visual dependency**: Prohibited phrases include: "as shown," "visible," "looking at," "clearly," "obviously" +3. **Geographic ambiguity**: Avoid "here," "there," "this area" - use specific location references +4. **Unit omissions**: Every quantitative statement must include appropriate units +5. **Pattern assumptions**: Don't assume readers can visualize spatial relationships - describe explicitly + +## SUCCESS CRITERIA + +### Objective Standards +1. **Completeness Test**: A meteorologist should be able to sketch the general pattern from your description +2. **Quantitative Accuracy**: All numerical values within ±5% of actual data +3. **Accessibility Compliance**: 100% of visual information conveyed through text +4. **Scientific Utility**: Description enables the same analytical conclusions as the visual +5. **Terminology Precision**: All meteorological terms used correctly per AMS Glossary standards + +## EXAMPLES AND REFERENCE PATTERNS + +Not yet available, but will be provided in the future. + +## OUTPUT FORMAT REQUIREMENTS + +### XML Tag Structure +**CRITICAL**: You must wrap the content of each step and the final description in specific XML tags for programmatic parsing. + +**Required XML Tags:** +- `...` - Wrap the entire content of Step 1 (excluding the markdown header) +- `...` - Wrap the entire content of Step 2 (excluding the markdown header) +- `...` - Wrap the entire content of Step 3 (excluding the markdown header) +- `...` - Wrap the entire content of Step 4 (excluding the markdown header) +- `...` - Wrap only the final consolidated description (without headers or meta-commentary) + +### Formatting Rules +1. **Tag Placement**: Place opening and closing tags on their own lines +2. **Content Scope**: Include all step content within tags, but exclude markdown headers (## Step X) +3. **Final Description**: The `` should contain ONLY the final weather description without any meta-commentary about word count or requirements +4. **No Tag Omission**: All five XML tag pairs must be present, even if a step is brief + +### Example Structure: +``` +## Step 1: Data Extraction and Analysis + +The chart displays 2-meter temperature and mean sea level pressure... +[rest of step 1 content] + + +## Step 2: Pattern Recognition and Spatial Analysis + +The dominant feature is a high-pressure system... +[rest of step 2 content] + + +## Step 3: Description Planning and Structure Design + +To structure the description, we will start with an overview... +[rest of step 3 content] + + +## Step 4: Description Writing with Verification + +### Overview +This weather chart displays... +### Main Body +A high-pressure system is centered... +[rest of step 4 content] + + + +This weather chart visualization shows 2-meter temperature and mean sea level pressure over Western Europe on March 12, 2011, at 12:00 UTC. The region covers latitudes from 40.5°N to 51°N and longitudes from 2.5°W to 10°E. A high-pressure system is centered at 45°N, 7.5°E with a pressure of 102080 Pa. The 2-meter temperature varies from 280 K in the north to 294 K in the southeast, showing a significant north-south temperature gradient. The pressure distribution is dominated by this high-pressure system, with pressure decreasing towards the northwest. The highest temperatures are located southeast of the high-pressure center. + +``` + +**IMPORTANT**: Ensure all XML tags are properly opened and closed. Malformed XML will cause parsing failures. + +**Remember**: Your description is not just text, it's a scientific instrument that must preserve the analytical power of the original visualization while being fully accessible to blind scientists. +""" + +# display_markdown(enhanced_prompt) + +# %% +description = call_llm_api(enhanced_prompt, img) display_markdown(description) From a5d172918b63de5be8a9169d5125721e7d608d48 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Thu, 5 Jun 2025 11:59:28 -0400 Subject: [PATCH 025/151] feat: creating cli for description generations --- pyproject.toml | 10 +- src/earth_reach_agent/core/generator.py | 187 ++++++++++---------- src/earth_reach_agent/core/llm.py | 19 +- src/earth_reach_agent/core/prompts.py | 2 +- src/earth_reach_agent/main.py | 221 ++++++++++++++++++++++++ uv.lock | 19 ++ 6 files changed, 347 insertions(+), 111 deletions(-) create mode 100644 src/earth_reach_agent/main.py diff --git a/pyproject.toml b/pyproject.toml index 54ffbaf..0abe09b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "openai>=1.77.0", "python-dotenv>=1.1.0", "hatchling>=1.27.0", + "fire @ git+https://github.com/google/python-fire.git@2025-03-23-ipython", ] [dependency-groups] @@ -23,5 +24,12 @@ dev = [ "ipykernel>=6.29.5", ] +[project.scripts] +earth-reach-agent = "earth_reach_agent.main:cli" +era = "earth_reach_agent.main:cli" + [tool.hatch.build.targets.wheel] -packages = ["src/earth_reach_agent"] \ No newline at end of file +packages = ["src/earth_reach_agent"] + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index cf518b2..73975c1 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -1,23 +1,10 @@ import re -from dataclasses import dataclass -from typing import List, Optional +from dataclasses import dataclass, fields +from typing import List from earth_reach_agent.core.llm import BaseLLM -class GeneratorAgent: - """GeneratorAgent class for generating weather charts scientific descriptions.""" - - def __init__(self, llm: BaseLLM) -> None: - """ - Initialize the GeneratorAgent with a BaseLLM instance. - - Args: - llm (BaseLLM): An instance of a BaseLLM to handle LLM interactions. - """ - self.llm = llm - - @dataclass class GeneratorOutput: """ @@ -31,11 +18,11 @@ class GeneratorOutput: final_description: The final consolidated weather description """ - step_1: Optional[str] = None - step_2: Optional[str] = None - step_3: Optional[str] = None - step_4: Optional[str] = None - final_description: Optional[str] = None + step_1: str | None = None + step_2: str | None = None + step_3: str | None = None + step_4: str | None = None + final_description: str | None = None def is_complete(self) -> bool: """ @@ -45,13 +32,8 @@ def is_complete(self) -> bool: bool: True if all fields contain content, False otherwise """ return all( - [ - self.step_1 is not None and self.step_1.strip(), - self.step_2 is not None and self.step_2.strip(), - self.step_3 is not None and self.step_3.strip(), - self.step_4 is not None and self.step_4.strip(), - self.final_description is not None and self.final_description.strip(), - ] + getattr(self, field.name) is not None and getattr(self, field.name).strip() + for field in fields(self) ) def get_missing_fields(self) -> List[str]: @@ -61,18 +43,11 @@ def get_missing_fields(self) -> List[str]: Returns: List[str]: Names of fields that are None or empty """ - missing = [] - if not self.step_1 or not self.step_1.strip(): - missing.append("step_1") - if not self.step_2 or not self.step_2.strip(): - missing.append("step_2") - if not self.step_3 or not self.step_3.strip(): - missing.append("step_3") - if not self.step_4 or not self.step_4.strip(): - missing.append("step_4") - if not self.final_description or not self.final_description.strip(): - missing.append("final_description") - return missing + return [ + field.name + for field in fields(self) + if not getattr(self, field.name) or not getattr(self, field.name).strip() + ] def get_step_word_count(self, step_name: str) -> int: """ @@ -101,77 +76,91 @@ def get_final_description_word_count(self) -> int: return len(self.final_description.split()) -# TODO(high): refactor this to add it to the generator agent class -def parse_generator_output(output: str) -> GeneratorOutput: - """ - Parse the XML-tagged output from the generator agent into structured data. +class GeneratorAgent: + """GeneratorAgent class for generating weather charts scientific descriptions.""" - Args: - output (str): The full output string containing XML tags + def __init__( + self, llm: BaseLLM, system_prompt: str | None, user_prompt: str + ) -> None: + """ + Initialize the GeneratorAgent with a BaseLLM instance. - Returns: - GeneratorOutput: Parsed content with individual step results + Args: + llm (BaseLLM): An instance of a BaseLLM to handle LLM interactions. + """ + self.llm = llm + self.system_prompt = system_prompt + self.user_prompt = user_prompt - Raises: - ValueError: If the output string is empty or None - """ - if not output or not output.strip(): - raise ValueError("Output string is empty or None") + def generate(self, image=None) -> str: + """ + Generate a structured weather description using the LLM. - result = GeneratorOutput() + Args: + image: Optional image to include in the request (will be converted to base64). - patterns = { - "step_1": r"(.*?)", - "step_2": r"(.*?)", - "step_3": r"(.*?)", - "step_4": r"(.*?)", - "final_description": r"(.*?)", - } + Returns: + str: The final weather description. - for field_name, pattern in patterns.items(): + Raises: + ValueError: If the output string is empty or None. + RuntimeError: For other run-time errors. + Exception: If the LLM response is incomplete or parsing fails. + """ try: - match = re.search(pattern, output, re.DOTALL) - if match: - content = match.group(1).strip() - if content: - setattr(result, field_name, content) - except Exception as e: - print(f"Warning: Failed to parse {field_name}: {e}") - continue + response = self.llm.generate( + user_prompt=self.user_prompt, + system_prompt=self.system_prompt, + image=image, + ) - return result + parsed_output = self.parse_llm_response(response) + if not parsed_output.is_complete(): + raise ValueError( + "Parsed output is incomplete. Missing fields: " + f"{parsed_output.get_missing_fields()}" + ) + description = parsed_output.final_description -def validate_generator_output( - parsed_output: GeneratorOutput, require_all_steps: bool = True -) -> tuple[bool, List[str]]: - """ - Validate that the parsed output meets expected criteria. + except Exception as e: + raise RuntimeError(f"Failed to generate response: {e}") from e - Args: - parsed_output: The parsed GeneratorOutput object - require_all_steps: Whether to require all steps to be present + return description # type: ignore[return-value] - Returns: - tuple[bool, List[str]]: (is_valid, list_of_issues) - """ - issues = [] - - if require_all_steps: - missing = parsed_output.get_missing_fields() - if missing: - issues.append(f"Missing required fields: {', '.join(missing)}") - - final_word_count = parsed_output.get_final_description_word_count() - if parsed_output.final_description and ( - final_word_count < 300 or final_word_count > 500 - ): - issues.append( - f"Final description word count ({final_word_count}) outside expected range (300-500)" - ) + def parse_llm_response(self, response: str) -> GeneratorOutput: + """ + Parse the XML-tagged response from the generator agent into structured data. - if not parsed_output.final_description: - issues.append("Final description is missing or empty") + Args: + response (str): The full llm response string containing XML tags + + Returns: + GeneratorOutput: Parsed content with individual step results - is_valid = len(issues) == 0 - return is_valid, issues + Raises: + ValueError: If the response string is empty or None + Exception: If parsing fails for any step + """ + if not response or not response.strip(): + raise ValueError("Response string is empty or None") + + result = GeneratorOutput() + + patterns = { + field.name: rf"<{field.name}>(.*?)" + for field in fields(GeneratorOutput) + } + + for field_name, pattern in patterns.items(): + try: + match = re.search(pattern, response, re.DOTALL) + if match: + content = match.group(1).strip() + if content: + setattr(result, field_name, content) + except Exception as e: + print(f"Warning: Failed to parse {field_name}: {e}") + continue + + return result diff --git a/src/earth_reach_agent/core/llm.py b/src/earth_reach_agent/core/llm.py index d8af186..149fe2f 100644 --- a/src/earth_reach_agent/core/llm.py +++ b/src/earth_reach_agent/core/llm.py @@ -1,6 +1,5 @@ -from typing import Optional - import openai + from earth_reach_agent.core.utils import img_to_base64 @@ -11,7 +10,7 @@ def __init__( self, model_name: str, base_url: str, - api_key: Optional[str] = None, + api_key: str | None = None, ) -> None: """ Initialize the LLM with a model name and optional keyword arguments. @@ -19,7 +18,7 @@ def __init__( Args: model_name (str): The name of the model to use. base_url (str): The base URL for the LLM API. - api_key (Optional[str]): The API key for authentication with the LLM provider. + api_key (str | None): The API key for authentication with the LLM provider. **kwargs: Additional keyword arguments for the LLM configuration. """ self.model_name = model_name @@ -32,14 +31,14 @@ def __init__( ) def generate( - self, user_prompt: str, system_prompt: Optional[str] = None, image=None + self, user_prompt: str, system_prompt: str | None = None, image=None ) -> str: """ Generate a response from the LLM API based on the user prompt and optional system prompt. Args: user_prompt (str): The prompt provided by the user to define the task. - system_prompt (Optional[str]): An optional system prompt to guide the model's response. + system_prompt (str | None): An optional system prompt to guide the model's response. image: Optional image to include in the request (will be converted to base64). Returns: @@ -108,12 +107,12 @@ def __repr__(self) -> str: class GroqLLM(BaseLLM): """Implementation of the BaseLLM for Groq LLM API Provider.""" - def __init__(self, model_name: str, api_key: Optional[str] = None) -> None: + def __init__(self, model_name: str, api_key: str | None = None) -> None: """Initialize the Groq LLM with a model name and optional API key. Args: model_name (str): The name of the Groq model to use. - api_key (Optional[str]): The API key for authentication with the Groq API. + api_key (str | None): The API key for authentication with the Groq API. Raises: AssertionError: If the API key is not provided and not found in environment variables. @@ -136,12 +135,12 @@ def __init__(self, model_name: str, api_key: Optional[str] = None) -> None: class OpenAILLM(BaseLLM): """Implementation of the BaseLLM for OpenAI API Provider.""" - def __init__(self, model_name: str, api_key: Optional[str] = None) -> None: + def __init__(self, model_name: str, api_key: str | None = None) -> None: """Initialize the OpenAI LLM with a model name and optional API key. Args: model_name (str): The name of the OpenAI model to use. - api_key (Optional[str]): The API key for authentication with the OpenAI API. + api_key (str | None): The API key for authentication with the OpenAI API. Raises: AssertionError: If the API key is not provided and not found in environment variables. diff --git a/src/earth_reach_agent/core/prompts.py b/src/earth_reach_agent/core/prompts.py index 1b8a36d..41cfbe2 100644 --- a/src/earth_reach_agent/core/prompts.py +++ b/src/earth_reach_agent/core/prompts.py @@ -1,4 +1,4 @@ -user_prompt = """# Enhanced Weather Chart Alt-Text Generation System +DEFAULT_USER_PROMPT = """# Enhanced Weather Chart Alt-Text Generation System ## ROLE AND CONTEXT SETTING diff --git a/src/earth_reach_agent/main.py b/src/earth_reach_agent/main.py new file mode 100644 index 0000000..5e91ea4 --- /dev/null +++ b/src/earth_reach_agent/main.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +""" +CLI entrypoint for generating weather chart descriptions. +""" + +import os +import sys +from pathlib import Path + +import fire + +from earth_reach_agent.core.generator import GeneratorAgent +from earth_reach_agent.core.llm import BaseLLM, OpenAILLM +from earth_reach_agent.core.prompts import DEFAULT_USER_PROMPT + + +def load_prompt_from_file(file_path: str) -> str: + """ + Load prompt text from a file. + + Args: + file_path (str): Path to the text file containing the prompt + + Returns: + str: The loaded prompt text + + Raises: + FileNotFoundError: If file doesn't exist + IOError: If file can't be read + ValueError: If file is empty + """ + if not os.path.isfile(file_path): + raise FileNotFoundError(f"Prompt file not found: {file_path}") + + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read().strip() + if not content: + raise ValueError(f"Prompt file is empty: {file_path}") + return content + except Exception as e: + raise IOError(f"Failed to read prompt file '{file_path}': {e}") + + +def resolve_prompt( + direct_prompt: str | None, + file_path: str | None, + default_prompt: str | None, +) -> str | None: + """ + Resolve final prompt from direct string, file path, or default. + + Args: + direct_prompt (str | None): Direct prompt text + file_path (str | None): Path to prompt file + default_prompt (str | None): Default prompt to use if neither is provided + + Returns: + str | None: The resolved prompt text + + Raises: + ValueError: If both direct_prompt and file_path are provided + """ + if direct_prompt is not None and file_path is not None: + raise ValueError( + "Cannot specify both prompt file and prompt text. Please use only one." + ) + + if direct_prompt is not None: + if not direct_prompt.strip(): + raise ValueError("Prompt text is an empty string.") + return direct_prompt.strip() + + if file_path is not None: + return load_prompt_from_file(file_path) + + return default_prompt + + +def validate_image_path(image_path: str) -> Path: + """ + Validate that image path exists and is a supported format. + + Args: + image_path (str): Path to the image file + + Returns: + Path: Validated Path object + + Raises: + FileNotFoundError: If image file doesn't exist + ValueError: If image format is not supported + """ + path = Path(image_path) + + if not path.exists(): + raise FileNotFoundError(f"Image file not found: {image_path}") + + if not path.is_file(): + raise ValueError(f"Path is not a file: {image_path}") + + valid_extensions = {".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG"} + if path.suffix not in valid_extensions: + raise ValueError( + f"Unsupported image format: {path.suffix}. " + f"Supported formats: {', '.join(sorted(valid_extensions))}" + ) + + return path + + +def create_llm() -> BaseLLM: + """ + Create and return LLM instance. + + This function can be modified to support different LLM configurations + or to read from environment variables/config files. + + Returns: + BaseLLM: Configured LLM instance + """ + return OpenAILLM(model_name="o4-mini-2025-04-16") + + +def main( + image_path: str, + system_prompt: str | None = None, + system_prompt_file_path: str | None = None, + user_prompt: str | None = None, + user_prompt_file_path: str | None = None, + verbose: bool = False, +) -> None: + """ + Generate a scientific description of a weather chart from an image. + + Args: + image_path (str): Path to the weather chart image (JPEG or PNG) + system_prompt (str | None): System prompt text (optional) + system_prompt_file_path (str | None): Path to system prompt file (optional) + user_prompt (str | None): User prompt text (optional) + user_prompt_file_path (str | None): Path to user prompt file (optional) + verbose (bool): Enable verbose output (optional) + + Returns: + None: Prints the generated weather description + + Raises: + FileNotFoundError: If image or prompt files don't exist + ValueError: If arguments are invalid or conflicting + RuntimeError: If description generation fails + """ + try: + if verbose: + print(f"Validating image: {image_path}") + validated_image_path = validate_image_path(image_path) + + if verbose: + print("Resolving prompts...") + system_prompt_text = resolve_prompt( + system_prompt, + system_prompt_file_path, + None, + ) + user_prompt_text = resolve_prompt( + user_prompt, user_prompt_file_path, DEFAULT_USER_PROMPT + ) + if not user_prompt_text: + raise ValueError( + "User prompt cannot be empty. Please provide a valid prompt." + ) + + if verbose: + if system_prompt_text: + print(f"System prompt length: {len(system_prompt_text)} characters") + + if user_prompt_text: + print(f"User prompt length: {len(user_prompt_text)} characters") + + if verbose: + print("Initializing LLM...") + llm = create_llm() + + if verbose: + print("Creating generator agent...") + generator = GeneratorAgent( + llm=llm, system_prompt=system_prompt_text, user_prompt=user_prompt_text + ) + + if verbose: + print(f"Generating description for: {validated_image_path.name}") + description = generator.generate(image=str(validated_image_path)) + + if verbose: + print("Description generated successfully!") + print(f"Description length: {len(description)} characters") + print("-" * 50) + + print(description) + + return None + + except (FileNotFoundError, ValueError, IOError) as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except RuntimeError as e: + print(f"Generation failed: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Unexpected error: {e}", file=sys.stderr) + sys.exit(1) + + +def cli(): + """ + CLI entrypoint for generating weather chart descriptions. + """ + fire.Fire(main) + + +if __name__ == "__main__": + cli() diff --git a/uv.lock b/uv.lock index eaf6ba2..8aa5b6a 100644 --- a/uv.lock +++ b/uv.lock @@ -470,6 +470,7 @@ dependencies = [ { name = "earthkit" }, { name = "earthkit-data" }, { name = "earthkit-plots" }, + { name = "fire" }, { name = "hatchling" }, { name = "openai" }, { name = "python-dotenv" }, @@ -486,6 +487,7 @@ requires-dist = [ { name = "earthkit", specifier = ">=0.10.3" }, { name = "earthkit-data", specifier = ">=0.13.8" }, { name = "earthkit-plots", specifier = ">=0.3.1" }, + { name = "fire", git = "https://github.com/google/python-fire.git?rev=2025-03-23-ipython" }, { name = "hatchling", specifier = ">=1.27.0" }, { name = "openai", specifier = ">=1.77.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, @@ -773,6 +775,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/f8/945427d587d3d3ec6dea3297f5a5b6b1cd4c5e126d0638cb148d3c360487/findlibs-0.1.1-py3-none-any.whl", hash = "sha256:1b611a040d41e491049568e530c7e032f5c2be8f582386961b8d14a6936406a9", size = 10713 }, ] +[[package]] +name = "fire" +version = "0.7.0" +source = { git = "https://github.com/google/python-fire.git?rev=2025-03-23-ipython#0ae088aabc1809bc298ef80488e613aac76274aa" } +dependencies = [ + { name = "termcolor" }, +] + [[package]] name = "flexcache" version = "0.3" @@ -2277,6 +2287,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, ] +[[package]] +name = "termcolor" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684 }, +] + [[package]] name = "toolz" version = "1.0.0" From eaaf738f157e241f82e8e1fda65ae8b9f7576af6 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Thu, 5 Jun 2025 12:02:17 -0400 Subject: [PATCH 026/151] doc: commenting fire and ipython compatibility issue --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0abe09b..0066924 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "openai>=1.77.0", "python-dotenv>=1.1.0", "hatchling>=1.27.0", - "fire @ git+https://github.com/google/python-fire.git@2025-03-23-ipython", + "fire @ git+https://github.com/google/python-fire.git@2025-03-23-ipython", # Fixes IPython v9.0 compatibility issue ] [dependency-groups] From d0cf58dfc4c6b4e7a50d9d68d29e152c870e7be8 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Fri, 6 Jun 2025 10:41:37 -0400 Subject: [PATCH 027/151] fix: fixing wrong groq url endpoint --- src/earth_reach_agent/core/llm.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/earth_reach_agent/core/llm.py b/src/earth_reach_agent/core/llm.py index 149fe2f..6c42fb6 100644 --- a/src/earth_reach_agent/core/llm.py +++ b/src/earth_reach_agent/core/llm.py @@ -97,7 +97,6 @@ def generate( raise except Exception as e: error_msg = f"API call failed: {type(e).__name__}: {e}" - print(error_msg) raise RuntimeError(error_msg) from e def __repr__(self) -> str: @@ -128,7 +127,9 @@ def __init__(self, model_name: str, api_key: str | None = None) -> None: ) super().__init__( - model_name=model_name, api_key=api_key, base_url="https://api.groq.com/v1" + model_name=model_name, + api_key=api_key, + base_url="https://api.groq.com/openai/v1", ) From 561d59507582dbd9d3e0a4462f20cc5f35a6b6b6 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Fri, 6 Jun 2025 10:41:46 -0400 Subject: [PATCH 028/151] feat: adding logging --- src/earth_reach_agent/main.py | 66 ++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/src/earth_reach_agent/main.py b/src/earth_reach_agent/main.py index 5e91ea4..c6b72c1 100644 --- a/src/earth_reach_agent/main.py +++ b/src/earth_reach_agent/main.py @@ -3,16 +3,27 @@ CLI entrypoint for generating weather chart descriptions. """ +import logging import os import sys from pathlib import Path import fire +from dotenv import load_dotenv +from PIL import Image from earth_reach_agent.core.generator import GeneratorAgent -from earth_reach_agent.core.llm import BaseLLM, OpenAILLM +from earth_reach_agent.core.llm import BaseLLM, GroqLLM, OpenAILLM from earth_reach_agent.core.prompts import DEFAULT_USER_PROMPT +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +load_dotenv() + def load_prompt_from_file(file_path: str) -> str: """ @@ -109,17 +120,35 @@ def validate_image_path(image_path: str) -> Path: return path -def create_llm() -> BaseLLM: +def create_llm(provider="groq") -> BaseLLM: """ Create and return LLM instance. This function can be modified to support different LLM configurations or to read from environment variables/config files. + Args: + provider (str): LLM provider name (default: "groq") + Returns: BaseLLM: Configured LLM instance """ - return OpenAILLM(model_name="o4-mini-2025-04-16") + if provider.lower() == "groq": + api_key = os.getenv("GROQ_API_KEY") + if not api_key: + raise ValueError("GROQ_API_KEY environment variable is not set.") + return GroqLLM( + model_name="meta-llama/llama-4-maverick-17b-128e-instruct", api_key=api_key + ) + elif provider.lower() == "openai": + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise ValueError("OPENAI_API_KEY environment variable is not set.") + return OpenAILLM(model_name="o4-mini-2025-04-16", api_key=api_key) + else: + raise ValueError( + f"Unsupported LLM provider: {provider}. Supported providers: 'groq', 'openai'." + ) def main( @@ -151,11 +180,12 @@ def main( """ try: if verbose: - print(f"Validating image: {image_path}") + logger.info(f"Validating image: {image_path}") validated_image_path = validate_image_path(image_path) + image = Image.open(validated_image_path) if verbose: - print("Resolving prompts...") + logger.info("Resolving prompts...") system_prompt_text = resolve_prompt( system_prompt, system_prompt_file_path, @@ -171,42 +201,44 @@ def main( if verbose: if system_prompt_text: - print(f"System prompt length: {len(system_prompt_text)} characters") + logger.info( + f"System prompt length: {len(system_prompt_text)} characters" + ) if user_prompt_text: - print(f"User prompt length: {len(user_prompt_text)} characters") + logger.info(f"User prompt length: {len(user_prompt_text)} characters") if verbose: - print("Initializing LLM...") + logger.info("Initializing LLM...") llm = create_llm() if verbose: - print("Creating generator agent...") + logger.info("Creating generator agent...") generator = GeneratorAgent( llm=llm, system_prompt=system_prompt_text, user_prompt=user_prompt_text ) if verbose: - print(f"Generating description for: {validated_image_path.name}") - description = generator.generate(image=str(validated_image_path)) + logger.info(f"Generating description for: {validated_image_path.name}") + description = generator.generate(image=image) if verbose: - print("Description generated successfully!") - print(f"Description length: {len(description)} characters") - print("-" * 50) + logger.info("Description generated successfully!") + logger.info(f"Description length: {len(description)} characters") + logger.info("-" * 50) print(description) return None except (FileNotFoundError, ValueError, IOError) as e: - print(f"Error: {e}", file=sys.stderr) + logger.error(f"Could not load image file: {e}", exc_info=True) sys.exit(1) except RuntimeError as e: - print(f"Generation failed: {e}", file=sys.stderr) + logger.error(f"Generation failed: {e}", exc_info=True) sys.exit(1) except Exception as e: - print(f"Unexpected error: {e}", file=sys.stderr) + logger.error(f"Unexpected error: {e}", exc_info=True) sys.exit(1) From 2e9295199d196bf9af6b6877a621ace2206c1a05 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 9 Jun 2025 22:38:44 -0400 Subject: [PATCH 029/151] feat: adding earthkit plot figure as possible input to generator agent --- src/earth_reach_agent/core/generator.py | 85 ++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index 73975c1..b7ec739 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -1,10 +1,26 @@ import re from dataclasses import dataclass, fields +from io import BytesIO from typing import List +import earthkit.plots as ekp +from PIL import Image +from PIL.ImageFile import ImageFile + from earth_reach_agent.core.llm import BaseLLM +@dataclass +class FigureMetadata: + """Metadata extracted from a figure.""" + + title: str | None = None + xlabel: str | None = None + ylabel: str | None = None + domain: str | None = None + variables: List[str] | None = None + + @dataclass class GeneratorOutput: """ @@ -92,12 +108,15 @@ def __init__( self.system_prompt = system_prompt self.user_prompt = user_prompt - def generate(self, image=None) -> str: + def generate( + self, figure: ekp.Figure | None = None, image: ImageFile | None = None + ) -> str: """ Generate a structured weather description using the LLM. Args: - image: Optional image to include in the request (will be converted to base64). + figure (Figure | None): Optional figure to include in the request. Can't be used with image. + image (ImageFile | None): Optional image to include in the request (will be converted to base64). Can't be used with figure. Returns: str: The final weather description. @@ -107,6 +126,29 @@ def generate(self, image=None) -> str: RuntimeError: For other run-time errors. Exception: If the LLM response is incomplete or parsing fails. """ + if figure is not None and image is not None: + raise ValueError( + "Only one of 'figure' or 'image' can be provided, not both." + ) + if figure is not None: + # TODO(high): implement the following process: + # 1. should extract metadata from the figure like (earthkit.plots.Figure): + # - title ✅ + # - axes ✅ + # - labels✅ + # - domain✅ + # - variables ❌ (can only come from earthkit.data) + # - distribution values ❌ (can only come from earthkit.data) + # 2. should convert the figure to an image file. ✅ + # 3. should add metadata to the user prompt. 🟨 + metadata = self._get_metadata_from_figure(figure) + image = self._get_image_from_figure(figure) + + elif image is None and figure is None: + raise ValueError( + "Either 'figure' or 'image' must be provided to generate a description." + ) + try: response = self.llm.generate( user_prompt=self.user_prompt, @@ -164,3 +206,42 @@ def parse_llm_response(self, response: str) -> GeneratorOutput: continue return result + + def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata: + """ + Extract metadata from the given figure. + + Args: + figure (ekp.Figure): The figure to extract metadata from. + + Returns: + A FigureMetadata object containing the extracted metadata. + """ + metadata = FigureMetadata() + + plt_fig = figure.fig + if plt_fig is None: + raise ValueError("Matplotlib figure is None, cannot convert to image.") + + axes = plt_fig.get_axes() + + metadata.title = axes[0].get_title() + metadata.xlabel = axes[0].get_xlabel() + metadata.ylabel = axes[0].get_ylabel() + metadata.domain = figure._domain + return metadata + + def _get_image_from_figure(self, figure: ekp.Figure) -> ImageFile: + """ + Convert the given figure to an image file. + + Args: + figure (ekp.Figure): The figure to convert. + + Returns: + ImageFile: The converted image file. + """ + buffer = BytesIO() + figure.save(buffer, format="png") + buffer.seek(0) + return Image.open(buffer) From a42ad90e4c163a9ed9c87171c74712ee445502fa Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 10 Jun 2025 10:49:53 -0400 Subject: [PATCH 030/151] feat: updating the user prompt with extracted figure metadata --- src/earth_reach_agent/core/generator.py | 72 ++++++++++++++++++------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index b7ec739..3d286c9 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -1,5 +1,5 @@ import re -from dataclasses import dataclass, fields +from dataclasses import dataclass, field, fields from io import BytesIO from typing import List @@ -14,11 +14,24 @@ class FigureMetadata: """Metadata extracted from a figure.""" - title: str | None = None - xlabel: str | None = None - ylabel: str | None = None - domain: str | None = None - variables: List[str] | None = None + title: str | None = field( + default=None, metadata={"description": "Figure title or heading"} + ) + xlabel: str | None = field( + default=None, + metadata={"description": "X-axis label describing the horizontal dimension"}, + ) + ylabel: str | None = field( + default=None, + metadata={"description": "Y-axis label describing the vertical dimension"}, + ) + domain: str | None = field( + default=None, metadata={"description": "Geographic domain of the figure"} + ) + variables: List[str] | None = field( + default=None, + metadata={"description": "Key variables shown in the figure"}, + ) @dataclass @@ -131,19 +144,11 @@ def generate( "Only one of 'figure' or 'image' can be provided, not both." ) if figure is not None: - # TODO(high): implement the following process: - # 1. should extract metadata from the figure like (earthkit.plots.Figure): - # - title ✅ - # - axes ✅ - # - labels✅ - # - domain✅ - # - variables ❌ (can only come from earthkit.data) - # - distribution values ❌ (can only come from earthkit.data) - # 2. should convert the figure to an image file. ✅ - # 3. should add metadata to the user prompt. 🟨 metadata = self._get_metadata_from_figure(figure) + self.user_prompt = self._update_user_prompt_with_metadata( + self.user_prompt, metadata + ) image = self._get_image_from_figure(figure) - elif image is None and figure is None: raise ValueError( "Either 'figure' or 'image' must be provided to generate a description." @@ -215,7 +220,7 @@ def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata: figure (ekp.Figure): The figure to extract metadata from. Returns: - A FigureMetadata object containing the extracted metadata. + FigureMetadata: An object containing the extracted metadata. """ metadata = FigureMetadata() @@ -231,6 +236,37 @@ def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata: metadata.domain = figure._domain return metadata + def _update_user_prompt_with_metadata( + self, user_prompt: str, metadata: "FigureMetadata" + ) -> str: + """ + Update the user prompt with metadata extracted from the figure. + + Args: + user_prompt (str): The original user prompt. + metadata (FigureMetadata): The metadata to include in the prompt. + + Returns: + str: The updated user prompt with metadata included. + """ + metadata_items = [] + for field_info in fields(metadata): + value = getattr(metadata, field_info.name) + if value is not None: + description = field_info.metadata.get( + "description", "No description available" + ) + metadata_items.append(f"- {field_info.name} ({description}): {value}") + + if not metadata_items: + return user_prompt + + metadata_str = "# FIGURE METADATA\n\n" + metadata_str += "The following metadata was extracted from the figure:\n\n" + metadata_str += "\n".join(metadata_items) + + return f"{user_prompt}\n\n{metadata_str}" + def _get_image_from_figure(self, figure: ekp.Figure) -> ImageFile: """ Convert the given figure to an image file. From eeab6f08324347242395f364fc123cd9dee094cb Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 10 Jun 2025 14:05:56 -0400 Subject: [PATCH 031/151] feat: creating orchestrator placeholder file --- src/earth_reach_agent/core/orchestrator.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/earth_reach_agent/core/orchestrator.py diff --git a/src/earth_reach_agent/core/orchestrator.py b/src/earth_reach_agent/core/orchestrator.py new file mode 100644 index 0000000..ada2a90 --- /dev/null +++ b/src/earth_reach_agent/core/orchestrator.py @@ -0,0 +1,4 @@ +class AgentsOrchestrator: + """Orchestrates multiple agents to process weather figure and generate informative description.""" + + pass From e91bae5975b1383e21f34d79a8bba53fd7fc4bf5 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 10 Jun 2025 14:07:50 -0400 Subject: [PATCH 032/151] feat: fixing value error message for figure metadata extraction --- src/earth_reach_agent/core/generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index 3d286c9..1508a9c 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -226,7 +226,7 @@ def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata: plt_fig = figure.fig if plt_fig is None: - raise ValueError("Matplotlib figure is None, cannot convert to image.") + raise ValueError("Matplotlib figure is None, cannot extract metadata.") axes = plt_fig.get_axes() From 1c6a9ea2a89e6f20b86e5f62d593d91dd8646d60 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 10 Jun 2025 14:08:38 -0400 Subject: [PATCH 033/151] doc: adding metadata hanling todo --- src/earth_reach_agent/core/generator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index 1508a9c..9b21490 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -144,6 +144,7 @@ def generate( "Only one of 'figure' or 'image' can be provided, not both." ) if figure is not None: + # TODO(medium): If metadata extraction failes, continue without it metadata = self._get_metadata_from_figure(figure) self.user_prompt = self._update_user_prompt_with_metadata( self.user_prompt, metadata From 6d0074adb7ee639493f40d801272855a61146359 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 22 Jun 2025 22:01:46 -0400 Subject: [PATCH 034/151] refact: refactoring llm creation --- src/earth_reach_agent/main.py | 43 +++++------------------------------ 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/src/earth_reach_agent/main.py b/src/earth_reach_agent/main.py index c6b72c1..727078d 100644 --- a/src/earth_reach_agent/main.py +++ b/src/earth_reach_agent/main.py @@ -13,8 +13,8 @@ from PIL import Image from earth_reach_agent.core.generator import GeneratorAgent -from earth_reach_agent.core.llm import BaseLLM, GroqLLM, OpenAILLM -from earth_reach_agent.core.prompts import DEFAULT_USER_PROMPT +from earth_reach_agent.core.llm import create_llm +from earth_reach_agent.core.prompts import DEFAULT_GENERATOR_USER_PROMPT logging.basicConfig( level=logging.INFO, @@ -56,7 +56,7 @@ def load_prompt_from_file(file_path: str) -> str: def resolve_prompt( direct_prompt: str | None, file_path: str | None, - default_prompt: str | None, + DEFAULT_USER_PROMPT: str | None, ) -> str | None: """ Resolve final prompt from direct string, file path, or default. @@ -64,7 +64,7 @@ def resolve_prompt( Args: direct_prompt (str | None): Direct prompt text file_path (str | None): Path to prompt file - default_prompt (str | None): Default prompt to use if neither is provided + DEFAULT_USER_PROMPT (str | None): Default prompt to use if neither is provided Returns: str | None: The resolved prompt text @@ -85,7 +85,7 @@ def resolve_prompt( if file_path is not None: return load_prompt_from_file(file_path) - return default_prompt + return DEFAULT_USER_PROMPT def validate_image_path(image_path: str) -> Path: @@ -120,37 +120,6 @@ def validate_image_path(image_path: str) -> Path: return path -def create_llm(provider="groq") -> BaseLLM: - """ - Create and return LLM instance. - - This function can be modified to support different LLM configurations - or to read from environment variables/config files. - - Args: - provider (str): LLM provider name (default: "groq") - - Returns: - BaseLLM: Configured LLM instance - """ - if provider.lower() == "groq": - api_key = os.getenv("GROQ_API_KEY") - if not api_key: - raise ValueError("GROQ_API_KEY environment variable is not set.") - return GroqLLM( - model_name="meta-llama/llama-4-maverick-17b-128e-instruct", api_key=api_key - ) - elif provider.lower() == "openai": - api_key = os.getenv("OPENAI_API_KEY") - if not api_key: - raise ValueError("OPENAI_API_KEY environment variable is not set.") - return OpenAILLM(model_name="o4-mini-2025-04-16", api_key=api_key) - else: - raise ValueError( - f"Unsupported LLM provider: {provider}. Supported providers: 'groq', 'openai'." - ) - - def main( image_path: str, system_prompt: str | None = None, @@ -192,7 +161,7 @@ def main( None, ) user_prompt_text = resolve_prompt( - user_prompt, user_prompt_file_path, DEFAULT_USER_PROMPT + user_prompt, user_prompt_file_path, DEFAULT_GENERATOR_USER_PROMPT ) if not user_prompt_text: raise ValueError( From db4fc43ae9aaae0e98ebdbb42bc41c16eef5af23 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 22 Jun 2025 22:02:11 -0400 Subject: [PATCH 035/151] feat: implementing single criteria evaluator agent --- src/earth_reach_agent/core/evaluator.py | 312 +++++++++++++++++++++++- 1 file changed, 310 insertions(+), 2 deletions(-) diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach_agent/core/evaluator.py index c43a8fc..c447590 100644 --- a/src/earth_reach_agent/core/evaluator.py +++ b/src/earth_reach_agent/core/evaluator.py @@ -1,7 +1,312 @@ +import re +from dataclasses import MISSING, dataclass, fields +from io import BytesIO +from typing import Any, List, Union, get_args, get_origin + +import earthkit.plots as ekp +from PIL import Image +from PIL.ImageFile import ImageFile + +from earth_reach_agent.core.generator import FigureMetadata +from earth_reach_agent.core.llm import BaseLLM, create_llm +from earth_reach_agent.core.prompts import get_default_criteria_evaluator_user_prompt + + +@dataclass +class CriteriaEvaluatorOutput: + criteria: str + score: int + reasoning: str | None = None + + def is_score_valid(self) -> bool: + """ + Check if the score is within a valid range (0 to 5). + + Returns: + bool: True if the score is valid, False otherwise. + """ + return 0 <= self.score <= 5 + + def __post_init__(self): + if not self.is_score_valid(): + raise ValueError("Score must be between 0 and 5.") + + +class CriteriaEvaluator: + """Evaluator class for evaluating the quality of text based on a specified criteria.""" + + def __init__( + self, llm: BaseLLM, system_prompt: str | None, user_prompt: str + ) -> None: + self.llm = llm + self.system_prompt = system_prompt + self.user_prompt = user_prompt + + def evaluate( + self, + description: str, + figure: ekp.Figure | None = None, + image: ImageFile | None = None, + ) -> CriteriaEvaluatorOutput: + if figure is not None and image is not None: + raise ValueError( + "Only one of 'figure' or 'image' can be provided, not both." + ) + if figure is not None: + # TODO(medium): If metadata extraction failes, continue without it + metadata = self._get_metadata_from_figure(figure) + self.user_prompt = self._update_user_prompt_with_metadata( + self.user_prompt, metadata + ) + image = self._get_image_from_figure(figure) + elif image is None and figure is None: + raise ValueError( + "Either 'figure' or 'image' must be provided to generate a description." + ) + try: + user_prompt = ( + self.user_prompt + + f"\n\n# Description to evaluate\n\n{description}" + + "\n\nPlease provide your evaluation of the description against the criteria." + ) + response = self.llm.generate( + user_prompt=user_prompt, + system_prompt=self.system_prompt, + ) + return self.parse_llm_response(response) + except Exception as e: + raise RuntimeError(f"Failed to generate response: {e}") from e + + def parse_llm_response(self, response: str) -> CriteriaEvaluatorOutput: + """ + Parse the XML-tagged response from the CriteriaEvaluator Agent into structured data. + + Args: + response (str): The full llm response string containing XML tags + + Returns: + CriteriaEvaluatorOutput: Parsed evaluation content + + Raises: + ValueError: If the response string is empty or None, or if required fields are missing + Exception: If a parsing error occurs for any field + """ + if not response or not response.strip(): + raise ValueError("Response string is empty or None") + + dataclass_fields = fields(CriteriaEvaluatorOutput) + + extracted_values = {} + parsing_errors = [] + + for field in dataclass_fields: + field_name = field.name + field_type = field.type + + pattern = rf"<{field_name}>(.*?)" + + try: + match = re.search(pattern, response, re.DOTALL) + if match: + content = match.group(1).strip() + if content: + converted_value = self.convert_to_field_type( + content, field_name, field_type + ) + extracted_values[field_name] = converted_value + except Exception as e: + parsing_errors.append(f"Failed to parse field '{field_name}': {str(e)}") + + required_fields = [ + f.name + for f in dataclass_fields + if f.default == MISSING and f.default_factory == MISSING + ] + + missing_required = [f for f in required_fields if f not in extracted_values] + if missing_required: + raise ValueError( + f"Missing required fields in XML response: {missing_required}" + ) + + if parsing_errors: + raise Exception(f"Parsing errors occurred: {'; '.join(parsing_errors)}") + + try: + return CriteriaEvaluatorOutput(**extracted_values) + except Exception as e: + raise Exception( + f"Failed to create CriteriaEvaluatorOutput instance: {str(e)}" + ) + + def convert_to_field_type( + self, content: str, field_name: str, field_type: Any + ) -> Any: + """ + Convert string content to the appropriate type based on field type annotation. + + Args: + content (str): The extracted string content + field_name (str): Name of the field (for error reporting) + field_type: The type annotation of the field + + Returns: + The converted value with the correct type + + Raises: + ValueError: If type conversion fails + """ + origin = get_origin(field_type) + + if origin is Union: + args = get_args(field_type) + non_none_types = [arg for arg in args if arg is not type(None)] + if non_none_types: + field_type = non_none_types[0] + else: + return content + + if field_type is int: + try: + return int(content) + except ValueError: + raise ValueError( + f"Cannot convert '{content}' to integer for field '{field_name}'" + ) + + elif field_type is float: + try: + return float(content) + except ValueError: + raise ValueError( + f"Cannot convert '{content}' to float for field '{field_name}'" + ) + + elif field_type is bool: + lower_content = content.lower().strip() + if lower_content in ("true", "1", "yes", "on", "y"): + return True + elif lower_content in ("false", "0", "no", "off", "n"): + return False + else: + raise ValueError( + f"Cannot convert '{content}' to boolean for field '{field_name}'. " + f"Expected: true/false, 1/0, yes/no, on/off, y/n" + ) + + elif field_type is str: + return content + + else: + if hasattr(field_type, "__call__"): + try: + return field_type(content) + except Exception: + return content + else: + return content + + def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata: + """ + Extract metadata from the given figure. + + Args: + figure (ekp.Figure): The figure to extract metadata from. + + Returns: + FigureMetadata: An object containing the extracted metadata. + """ + metadata = FigureMetadata() + + plt_fig = figure.fig + if plt_fig is None: + raise ValueError("Matplotlib figure is None, cannot extract metadata.") + + axes = plt_fig.get_axes() + + metadata.title = axes[0].get_title() + metadata.xlabel = axes[0].get_xlabel() + metadata.ylabel = axes[0].get_ylabel() + metadata.domain = figure._domain + return metadata + + def _update_user_prompt_with_metadata( + self, user_prompt: str, metadata: FigureMetadata + ) -> str: + """ + Update the user prompt with metadata extracted from the figure. + + Args: + user_prompt (str): The original user prompt. + metadata (FigureMetadata): The metadata to include in the prompt. + + Returns: + str: The updated user prompt with metadata included. + """ + metadata_items = [] + for field_info in fields(metadata): + value = getattr(metadata, field_info.name) + if value is not None: + description = field_info.metadata.get( + "description", "No description available" + ) + metadata_items.append(f"- {field_info.name} ({description}): {value}") + + if not metadata_items: + return user_prompt + + metadata_str = "# FIGURE METADATA\n\n" + metadata_str += "The following metadata was extracted from the figure:\n\n" + metadata_str += "\n".join(metadata_items) + + return f"{user_prompt}\n\n{metadata_str}" + + def _get_image_from_figure(self, figure: ekp.Figure) -> ImageFile: + """ + Convert the given figure to an image file. + + Args: + figure (ekp.Figure): The figure to convert. + + Returns: + ImageFile: The converted image file. + """ + buffer = BytesIO() + figure.save(buffer, format="png") + buffer.seek(0) + return Image.open(buffer) + + +class EvaluatorCriteriaFactory: + """Factory class for creating single criteria evaluator agents.""" + + @staticmethod + def create(criterion: str, llm: BaseLLM | None = None) -> CriteriaEvaluator: + """ + Create a CriteriaEvaluator instance based on the provided criteria. + + Args: + criteria (str): Criteria name to create evaluators for. + llm (BaseLLM | None): Optional LLM instance to use for evaluation. + + Returns: + CriteriaEvaluator: CriteriaEvaluator instance. + """ + if criterion not in ["coherence", "fluency", "consistency", "relevance"]: + raise ValueError(f"Unsupported criteria: {criterion}") + + if not llm: + llm = create_llm() + + user_prompt = get_default_criteria_evaluator_user_prompt(criterion) + + return CriteriaEvaluator(llm=llm, system_prompt=None, user_prompt=user_prompt) + + class EvaluatorAgent: - """EvaluatorAgent class for evaluating the quality of Generator Agent's weather chart descriptions.""" + """Agent class for evaluating the quality of weather chart descriptions.""" - def __init__(self) -> None: + def __init__(self, criteria: List[str]) -> None: """ Initialize the EvaluatorAgent. @@ -10,6 +315,9 @@ def __init__(self) -> None: """ pass + def evaluate(self, text: str, criteria: dict): + pass + def is_text_length_lesser_than_max(self, text: str, max_length: int = 1000) -> bool: """ Check if the length of the text is lesser than the specified maximum length. From cd0852c32a6aa1575eaa236d7ed59f058a2cb5da Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 22 Jun 2025 22:02:27 -0400 Subject: [PATCH 036/151] fix: fixing typos --- src/earth_reach_agent/core/generator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index 9b21490..597649e 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -112,10 +112,12 @@ def __init__( self, llm: BaseLLM, system_prompt: str | None, user_prompt: str ) -> None: """ - Initialize the GeneratorAgent with a BaseLLM instance. + Initialize the GeneratorAgent with a BaseLLM instance and prompts. Args: llm (BaseLLM): An instance of a BaseLLM to handle LLM interactions. + system_prompt (str | None): Optional system prompt to guide the style of the LLM. + user_prompt (str): The user prompt containing the instructions for description generation. """ self.llm = llm self.system_prompt = system_prompt @@ -238,7 +240,7 @@ def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata: return metadata def _update_user_prompt_with_metadata( - self, user_prompt: str, metadata: "FigureMetadata" + self, user_prompt: str, metadata: FigureMetadata ) -> str: """ Update the user prompt with metadata extracted from the figure. From b09f81c77c535e67355d1d21d6aa3bb90408ac52 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 22 Jun 2025 22:02:46 -0400 Subject: [PATCH 037/151] refact: refactoring LLM creation --- src/earth_reach_agent/core/llm.py | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/earth_reach_agent/core/llm.py b/src/earth_reach_agent/core/llm.py index 6c42fb6..a9a1e02 100644 --- a/src/earth_reach_agent/core/llm.py +++ b/src/earth_reach_agent/core/llm.py @@ -1,3 +1,5 @@ +import os + import openai from earth_reach_agent.core.utils import img_to_base64 @@ -159,3 +161,34 @@ def __init__(self, model_name: str, api_key: str | None = None) -> None: super().__init__( model_name=model_name, api_key=api_key, base_url="https://api.openai.com/v1" ) + + +def create_llm(provider="groq") -> BaseLLM: + """ + Create and return LLM instance. + + This function can be modified to support different LLM configurations + or to read from environment variables/config files. + + Args: + provider (str): LLM provider name (default: "groq") + + Returns: + BaseLLM: Configured LLM instance + """ + if provider.lower() == "groq": + api_key = os.getenv("GROQ_API_KEY") + if not api_key: + raise ValueError("GROQ_API_KEY environment variable is not set.") + return GroqLLM( + model_name="meta-llama/llama-4-maverick-17b-128e-instruct", api_key=api_key + ) + elif provider.lower() == "openai": + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise ValueError("OPENAI_API_KEY environment variable is not set.") + return OpenAILLM(model_name="o4-mini-2025-04-16", api_key=api_key) + else: + raise ValueError( + f"Unsupported LLM provider: {provider}. Supported providers: 'groq', 'openai'." + ) From 4c15dc6ecd3d03dd2cfeab488487ef97741c7c9e Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 22 Jun 2025 22:03:10 -0400 Subject: [PATCH 038/151] feat: writing prompts for each quality criteria evaluation agent --- src/earth_reach_agent/core/prompts.py | 579 +++++++++++++++++++++++++- 1 file changed, 578 insertions(+), 1 deletion(-) diff --git a/src/earth_reach_agent/core/prompts.py b/src/earth_reach_agent/core/prompts.py index 41cfbe2..ad61b0d 100644 --- a/src/earth_reach_agent/core/prompts.py +++ b/src/earth_reach_agent/core/prompts.py @@ -1,4 +1,4 @@ -DEFAULT_USER_PROMPT = """# Enhanced Weather Chart Alt-Text Generation System +DEFAULT_GENERATOR_USER_PROMPT = """# Enhanced Weather Chart Alt-Text Generation System ## ROLE AND CONTEXT SETTING @@ -162,3 +162,580 @@ **Remember**: Your description is not just text, it's a scientific instrument that must preserve the analytical power of the original visualization while being fully accessible to blind scientists. """ + + +DEFAULT_COHERENCE_CRITERIA_EVALUATOR_USER_PROMPT = """# "Coherence" Quality Criteria Evaluation Instructions + +## ROLE AND CONTEXT SETTING + +You are a scientific communication specialist evaluating weather chart descriptions for coherence. Your task is to assess how well a meteorological text description maintains logical flow and structural organization, specifically for blind scientists who rely entirely on textual information to understand complex weather patterns. + +**Critical Context**: These descriptions replace visual weather charts for blind meteorologists conducting research, teaching, and operational forecasting. Coherence failures directly impair scientific analysis and decision-making capabilities. + +## EVALUATION PROCESS + +### Step 1: Structural Analysis (Foundation Assessment) +**Objective**: Examine the overall organizational framework + +**Assessment Actions**: +1. Identify if the description follows a clear information hierarchy (overview → specific details → relationships) +2. Verify logical progression from broad patterns to specific features +3. Check for appropriate sectioning or natural information groupings +4. Assess whether geographic and temporal context is established early +5. Determine if the structure compensates for lack of visual reference points + +**Key Questions**: +- Does the description start with essential context (region, time, variables)? +- Is there a logical sequence from dominant to secondary meteorological features? +- Are spatial relationships clearly established before detailed analysis? + +### Step 2: Transition and Flow Evaluation (Connection Assessment) +**Objective**: Analyze how information elements connect and flow together + +**Assessment Actions**: +1. Examine transitions between different meteorological systems or geographic regions +2. Assess connection quality between quantitative data and qualitative patterns +3. Verify smooth integration of coordinate references and spatial descriptions +4. Check for logical bridges between related phenomena +5. Identify any abrupt topic changes or missing connections + +**Key Questions**: +- Are relationships between weather systems explicitly connected? +- Do quantitative values integrate smoothly with descriptive analysis? +- Are geographic transitions clearly signposted? + +### Step 3: Accessibility-Specific Coherence Check (Adaptation Assessment) +**Objective**: Evaluate coherence from a blind scientist's perspective + +**Assessment Actions**: +1. Assess whether information sequence builds spatial understanding progressively +2. Verify that complex relationships are broken down into logical components +3. Check for consistent reference frameworks (coordinates, directions, system names) +4. Evaluate if the description enables mental model construction +5. Ensure scientific conclusions flow logically from presented evidence + +**Key Questions**: +- Can a blind scientist follow the spatial logic without visual aids? +- Are complex meteorological relationships explained in digestible, sequential steps? +- Does the description build understanding cumulatively? + +## SCORING FRAMEWORK + +**Score 5 - Exceptional Coherence** +- Flawless logical progression from overview to detailed analysis +- Seamless transitions that enhance understanding +- Perfect adaptation for non-visual consumption +- Information builds systematically to enable complete scientific comprehension +- Structure actively facilitates meteorological analysis + +**Score 4 - Strong Coherence** +- Clear logical flow with minor structural imperfections +- Most transitions are smooth and helpful +- Well-adapted for blind scientists with occasional navigation challenges +- Information sequence supports scientific understanding effectively +- Structure enhances rather than hinders analysis + +**Score 3 - Adequate Coherence** +- Generally logical progression with noticeable structural issues +- Some transitions are unclear or missing +- Adequately accessible but requires extra effort to follow +- Scientific understanding achievable but not optimally supported +- Structure is functional but could be more effective + +**Score 2 - Poor Coherence** +- Significant logical flow problems that impede understanding +- Frequent unclear or missing transitions +- Accessibility compromised by structural confusion +- Scientific analysis hindered by organizational issues +- Structure creates barriers to comprehension + +**Score 1 - Very Poor Coherence** +- Major logical sequence problems throughout +- Information poorly connected or randomly ordered +- Severely compromised accessibility +- Scientific understanding significantly impaired +- Structure actively interferes with analysis + +**Score 0 - Incoherent** +- No discernible logical organization +- Unintelligible information sequence +- Completely inaccessible structure +- Scientific analysis impossible due to organizational chaos +- No coherent structure present + +## COMMON PITFALLS TO AVOID + +1. **Visual Bias**: Penalize descriptions that assume visual understanding (e.g., "as seen in the chart") +2. **Oversimplification**: Remember these are PhD-level scientists requiring sophisticated analysis +3. **Length Confusion**: Coherence isn't about brevity—evaluate logical flow, not word count +4. **Format Fixation**: Focus on logical progression, not rigid template adherence +5. **Missing Context**: Consider that spatial relationships must be explicit without visual cues + +## OUTPUT REQUIREMENTS + +Provide your evaluation in the following XML format: + +```xml +Coherence +[Your detailed analysis explaining the score, referencing specific aspects of information flow, structural organization, and accessibility-adapted coherence. Include concrete examples from the description to support your assessment.] +[0-5] +``` + +**Critical Requirements**: +- Use exactly `Coherence` +- Score must be an integer from 0 to 5 +- Reasoning should reference specific textual evidence +- All XML tags must be properly closed +- No additional formatting or text outside the XML structure + +**Success Check**: Your evaluation should enable a developer to understand exactly what coherence strengths or weaknesses exist in the description and how to improve them. +""" + +DEFAULT_FLUENCY_CRITERIA_EVALUATOR_USER_PROMPT = """# "Fluency" Quality Criteria Evaluation Prompt + +## ROLE AND CONTEXT SETTING + +You are a scientific communication expert specializing in technical writing assessment. Your task is to evaluate the linguistic quality of meteorological text descriptions, focusing on grammatical correctness, readability, and professional scientific expression. These descriptions serve blind meteorologists who must rely entirely on well-crafted language to understand complex weather patterns. + +**Critical Context**: Poor fluency directly impairs scientific comprehension for blind researchers. Awkward phrasing, grammatical errors, or unclear expression can render precise meteorological data unusable for research and operational decisions. + +## EVALUATION PROCESS + +### Step 1: Grammar and Syntax Assessment (Foundation Analysis) +**Objective**: Examine basic linguistic correctness and sentence construction + +**Assessment Actions**: +1. Check for grammatical errors (subject-verb agreement, tense consistency, pronoun clarity) +2. Assess sentence structure variety and appropriateness for scientific writing +3. Verify punctuation accuracy, especially with complex quantitative information +4. Examine adherence to 25-word sentence limit while maintaining clarity +5. Identify any awkward or unclear sentence constructions + +**Key Questions**: +- Are all sentences grammatically correct and properly punctuated? +- Does sentence structure support clear communication of complex information? +- Are sentences appropriately varied in structure while maintaining scientific tone? + +### Step 2: Technical Language and Terminology Evaluation (Precision Analysis) +**Objective**: Assess accuracy and consistency of meteorological language use + +**Assessment Actions**: +1. Verify correct usage of meteorological terminology per AMS Glossary standards +2. Check consistency in unit usage (hPa, m/s, K or °C, km) throughout description +3. Assess precision in coordinate and geographic references +4. Evaluate appropriate level of technical vocabulary for PhD-level audience +5. Identify any imprecise or ambiguous technical expressions + +**Key Questions**: +- Are meteorological terms used correctly and consistently? +- Do all quantitative values include appropriate and consistent units? +- Is the technical language precise without being unnecessarily complex? + +### Step 3: Readability and Flow Assessment (Accessibility Analysis) +**Objective**: Evaluate how well the text reads aloud and flows for screen reader users + +**Assessment Actions**: +1. Assess natural reading rhythm and flow when text is read aloud +2. Check for smooth integration of quantitative data within sentences +3. Evaluate transition smoothness between sentences within paragraphs +4. Verify absence of visual-dependent language that disrupts accessibility +5. Assess whether complex spatial relationships are expressed clearly in linear text + +**Key Questions**: +- Does the text flow naturally when read aloud or via screen reader? +- Are numerical values and coordinates integrated smoothly into prose? +- Is the language accessible to blind scientists without sacrificing precision? + +### Step 4: Scientific Voice and Consistency Check (Professional Standards Analysis) +**Objective**: Evaluate maintenance of appropriate scientific tone and style + +**Assessment Actions**: +1. Assess consistency in objective, scientific tone throughout description +2. Verify appropriate separation of observations from interpretations +3. Check for consistent perspective and voice (avoiding shifts between first/third person) +4. Evaluate professional appropriateness of language choices +5. Assess whether the writing maintains scientific credibility + +**Key Questions**: +- Is the scientific tone consistent and appropriate throughout? +- Does the language maintain objectivity while being engaging? +- Are observations clearly distinguished from interpretations? + +## SCORING FRAMEWORK + +**Score 5 - Exceptional Fluency** +- Flawless grammar, punctuation, and sentence construction +- Perfect meteorological terminology usage and unit consistency +- Exceptional readability optimized for screen reader accessibility +- Seamless integration of complex quantitative information +- Exemplary scientific voice that enhances comprehension + +**Score 4 - Strong Fluency** +- Minor grammatical issues that don't impair understanding +- Correct meteorological terminology with possible minor inconsistencies +- Good readability with occasional flow disruptions +- Quantitative information generally well-integrated +- Professional scientific tone with minor voice variations + +**Score 3 - Adequate Fluency** +- Some grammatical errors or awkward constructions +- Generally correct terminology with noticeable inconsistencies +- Readable but requires effort due to flow issues +- Quantitative integration adequate but sometimes disruptive +- Scientific tone maintained with occasional lapses + +**Score 2 - Poor Fluency** +- Frequent grammatical errors that impede comprehension +- Inconsistent or incorrect meteorological terminology usage +- Significant readability issues that burden screen reader users +- Poor integration of quantitative information +- Inconsistent or inappropriate scientific tone + +**Score 1 - Very Poor Fluency** +- Major grammatical problems throughout +- Substantial meteorological terminology errors +- Severely compromised readability +- Quantitative information poorly expressed +- Unprofessional or highly inconsistent tone + +**Score 0 - No Fluency** +- Extensive grammatical errors making text barely comprehensible +- Incorrect or inappropriate terminology throughout +- Unreadable for accessibility technology users +- Incomprehensible quantitative expressions +- Completely inappropriate scientific voice + +## COMMON PITFALLS TO AVOID + +1. **Over-penalizing Complexity**: Don't confuse necessary scientific complexity with poor fluency +2. **Visual Bias**: Remember accessibility requirements may create different but valid syntax patterns +3. **Perfectionist Standards**: Minor issues shouldn't overshadow overall linguistic quality +4. **Terminology Assumptions**: Verify meteorological term correctness rather than assuming +5. **Screen Reader Ignorance**: Consider how punctuation and structure affect audio consumption + +## OUTPUT REQUIREMENTS + +Provide your evaluation in the following XML format: + +```xml +Fluency +[Your detailed analysis explaining the score, referencing specific examples of grammatical correctness, terminology usage, readability, and scientific voice. Include concrete textual evidence to support your assessment.] +[0-5] +``` + +**Critical Requirements**: +- Use exactly `Fluency` +- Score must be an integer from 0 to 5 +- Reasoning should reference specific linguistic evidence from the description +- All XML tags must be properly closed +- No additional formatting or text outside the XML structure + +**Success Check**: Your evaluation should enable a developer to understand exactly what linguistic strengths or weaknesses exist in the description and provide actionable guidance for improvement. +""" +DEFAULT_CONSISTENCY_CRITERIA_EVALUATOR_USER_PROMPT = """# "Consistency" Criteria Evaluation Prompt + +## ROLE AND CONTEXT SETTING + +You are a meteorological data validation specialist evaluating weather chart descriptions for factual accuracy and internal consistency. Your task is to assess how well a text description aligns with its source weather chart and whether all described elements are logically consistent with each other and with meteorological principles. + +**Critical Context**: These descriptions replace visual weather charts for blind meteorologists conducting research and operations. Consistency errors can lead to incorrect scientific conclusions, flawed forecasts, and compromised safety decisions in weather-sensitive operations. + +## EVALUATION PROCESS + +### Step 1: Source-Description Alignment Assessment (Fidelity Analysis) +**Objective**: Verify accuracy between the weather chart and its textual description + +**Assessment Actions**: +1. Compare described pressure values and locations with actual chart readings +2. Verify temperature ranges and spatial distributions against chart data +3. Check coordinate references (latitude/longitude) for major weather systems +4. Validate wind patterns and directions if present in the chart +5. Confirm temporal information (date, time, forecast period) matches chart metadata + +**Key Questions**: +- Do the pressure values and system locations accurately reflect the chart? +- Are temperature ranges and gradients correctly described? +- Do coordinate references precisely match chart features? + +### Step 2: Internal Logical Consistency Check (Coherence Analysis) +**Objective**: Assess whether all described elements align logically within the description + +**Assessment Actions**: +1. Verify spatial relationships consistency (if A is north of B, coordinates should reflect this) +2. Check that pressure gradients align with described system strengths +3. Assess whether temperature-pressure relationships follow meteorological principles +4. Validate that system interactions described are physically plausible +5. Ensure consistent use of coordinate systems and measurement units throughout + +**Key Questions**: +- Are spatial relationships between features internally consistent? +- Do pressure and temperature patterns align with described weather systems? +- Are all coordinate references using the same datum and format? + +### Step 3: Meteorological Plausibility Evaluation (Scientific Validity Analysis) +**Objective**: Determine if described weather patterns follow atmospheric physics principles + +**Assessment Actions**: +1. Assess whether pressure-temperature relationships follow expected atmospheric behavior +2. Verify that wind patterns (if described) align with pressure gradients +3. Check seasonal appropriateness of described patterns for the given date/location +4. Evaluate whether system interactions described are meteorologically realistic +5. Confirm that gradient magnitudes are physically reasonable + +**Key Questions**: +- Do the described meteorological relationships follow atmospheric physics? +- Are pressure gradients and system intensities realistic? +- Do seasonal and geographic contexts match described patterns? + +### Step 4: Quantitative Accuracy Assessment (Precision Analysis) +**Objective**: Evaluate numerical accuracy and measurement consistency + +**Assessment Actions**: +1. Verify all pressure readings are within reasonable atmospheric ranges +2. Check temperature values for geographic and seasonal appropriateness +3. Assess coordinate precision and accuracy for major features +4. Validate unit consistency throughout the description +5. Confirm that value ranges accurately reflect chart data distribution + +**Key Questions**: +- Are all quantitative values within realistic meteorological ranges? +- Is unit usage consistent and appropriate throughout? +- Do numerical ranges accurately represent chart data spread? + +## SCORING FRAMEWORK + +**Score 5 - Perfect Consistency** +- Complete accuracy between chart and description +- Flawless internal logical alignment +- All meteorological relationships scientifically sound +- Perfect quantitative precision with appropriate units +- No contradictions or inconsistencies detected + +**Score 4 - Strong Consistency** +- Minor discrepancies that don't affect scientific interpretation +- Generally strong internal alignment with occasional minor issues +- Meteorological relationships mostly sound with minor implausibilities +- Quantitative accuracy high with possible minor unit inconsistencies +- Negligible contradictions that don't impair understanding + +**Score 3 - Adequate Consistency** +- Noticeable but not critical discrepancies with source chart +- Some internal consistency issues that require clarification +- Most meteorological relationships plausible with some questionable aspects +- Generally accurate quantitative data with some unit or precision issues +- Minor contradictions present but don't severely compromise description + +**Score 2 - Poor Consistency** +- Significant discrepancies between chart and description +- Multiple internal contradictions affecting understanding +- Several meteorologically implausible relationships described +- Quantitative errors that could mislead scientific analysis +- Contradictions that substantially compromise description reliability + +**Score 1 - Very Poor Consistency** +- Major inaccuracies compared to source chart +- Extensive internal contradictions throughout description +- Multiple violations of meteorological principles +- Substantial quantitative errors affecting interpretation +- Pervasive contradictions severely undermining description credibility + +**Score 0 - No Consistency** +- Description bears little resemblance to source chart +- Completely contradictory internal elements +- Described patterns violate basic atmospheric physics +- Quantitative data largely incorrect or nonsensical +- Contradictions make description scientifically unusable + +## COMMON PITFALLS TO AVOID + +1. **Perfectionist Expectations**: Minor measurement variations don't always indicate major consistency problems +2. **Chart Resolution Limits**: Consider that exact numerical precision may be limited by chart resolution +3. **Meteorological Complexity**: Some apparent inconsistencies may reflect complex but real atmospheric phenomena +4. **Temporal Snapshots**: Remember descriptions represent single time moments, not evolving patterns +5. **Accessibility Adaptations**: Some rephrasing for blind users may appear different but remain factually consistent + +## OUTPUT REQUIREMENTS + +Provide your evaluation in the following XML format: + +```xml +Consistency +[Your detailed analysis explaining the score, referencing specific examples of source-description alignment, internal consistency, meteorological plausibility, and quantitative accuracy. Include concrete evidence from both the chart and description to support your assessment.] +[0-5] +``` + +**Critical Requirements**: +- Use exactly `Consistency` +- Score must be an integer from 0 to 5 +- Reasoning should reference specific examples comparing chart features to description elements +- All XML tags must be properly closed +- No additional formatting or text outside the XML structure + +**Success Check**: Your evaluation should enable a developer to understand exactly what consistency strengths or weaknesses exist between the source chart and description, and provide actionable guidance for improving accuracy. +""" + +DEFAULT_RELEVANCE_CRITERIA_EVALUATOR_USER_PROMPT = """# "Relevance" Criteria Evaluation Prompt + +## ROLE AND CONTEXT SETTING + +You are a meteorological analysis expert evaluating weather chart descriptions for scientific relevance and information prioritization. Your task is to assess whether a text description captures and emphasizes the most meteorologically significant patterns from the source weather chart, enabling blind scientists to conduct the same quality analysis as their sighted colleagues. + +**Critical Context**: These descriptions must distill complex weather charts into the most scientifically valuable information within strict word limits. Poor relevance assessment can render descriptions analytically useless, forcing blind meteorologists to miss critical weather patterns or waste time on insignificant details. + +## EVALUATION PROCESS + +### Step 1: Meteorological Significance Assessment (Primary Pattern Analysis) +**Objective**: Evaluate whether the most important weather systems receive appropriate emphasis + +**Assessment Actions**: +1. Identify the dominant meteorological features visible in the chart (strongest systems, major gradients) +2. Assess whether these primary features receive proportional attention in the description +3. Evaluate if system intensities and meteorological significance are accurately prioritized +4. Check that the most analytically important patterns are emphasized early and clearly +5. Verify that dominant weather drivers are given precedence over secondary effects + +**Key Questions**: +- Are the strongest pressure systems and steepest gradients given primary focus? +- Does the description lead with the most meteorologically significant patterns? +- Are weather system intensities ranked appropriately in the text emphasis? + +### Step 2: Information Density Optimization (Efficiency Analysis) +**Objective**: Assess how effectively the description uses its word limit for maximum scientific value + +**Assessment Actions**: +1. Evaluate whether included quantitative details support primary meteorological conclusions +2. Assess if spatial descriptions focus on scientifically important boundaries and gradients +3. Check that coordinate references prioritize major system centers and critical transition zones +4. Verify that secondary details don't overshadow primary patterns +5. Determine if the description enables the same analytical conclusions as the visual chart + +**Key Questions**: +- Do the included details directly support understanding of major weather patterns? +- Are quantitative values focused on the most analytically important features? +- Does the description enable equivalent scientific analysis to visual inspection? + +### Step 3: Analytical Enablement Evaluation (Research Utility Analysis) +**Objective**: Determine if the description supports high-quality meteorological analysis + +**Assessment Actions**: +1. Assess whether described patterns enable forecast reasoning and weather prediction +2. Evaluate if the description supports identification of meteorological processes and mechanisms +3. Check that enough context is provided for understanding system interactions +4. Verify that the description enables comparison with climatological patterns +5. Determine if operational or research decisions could be made based on the description + +**Key Questions**: +- Can a meteorologist make informed forecasting decisions from this description? +- Are the described patterns sufficient for understanding atmospheric processes? +- Does the description support research-quality meteorological analysis? + +### Step 4: Contextual Appropriateness Assessment (Scale and Purpose Analysis) +**Objective**: Evaluate whether information priorities match the chart's scale and meteorological context + +**Assessment Actions**: +1. Assess appropriateness of detail level for the chart's spatial and temporal scale +2. Evaluate whether seasonal and geographic context influences appropriate information priorities +3. Check that described patterns match the meteorological significance for the given region/time +4. Verify that the description addresses the most relevant phenomena for the chart's domain +5. Assess whether the emphasis aligns with typical meteorological analysis priorities + +**Key Questions**: +- Is the detail level appropriate for the chart's geographic and temporal scale? +- Do the emphasized patterns match typical meteorological priorities for this context? +- Are regionally or seasonally important features given appropriate attention? + +## SCORING FRAMEWORK + +**Score 5 - Exceptional Relevance** +- Perfect identification and emphasis of most meteorologically significant features +- Optimal information density maximizing scientific value within word limits +- Description fully enables equivalent analytical capabilities to visual chart inspection +- Information priorities perfectly matched to meteorological context and scale +- Every included detail directly supports primary meteorological understanding + +**Score 4 - Strong Relevance** +- Most important meteorological features appropriately emphasized +- Good information density with minor inclusion of less critical details +- Description enables high-quality meteorological analysis with minor limitations +- Information priorities generally well-matched to context with occasional misalignment +- Most details support primary meteorological conclusions effectively + +**Score 3 - Adequate Relevance** +- Primary meteorological features identified but emphasis could be stronger +- Reasonable information density but some space wasted on less important details +- Description supports basic meteorological analysis but misses some analytical opportunities +- Information priorities generally appropriate but some contextual mismatches +- Mix of relevant and less relevant details affecting overall analytical efficiency + +**Score 2 - Poor Relevance** +- Important meteorological features under-emphasized or missed +- Poor information density with significant space devoted to minor details +- Description provides limited analytical capability compared to visual chart +- Information priorities poorly matched to meteorological context +- Many included details don't support primary meteorological understanding + +**Score 1 - Very Poor Relevance** +- Major meteorological features largely ignored or severely under-emphasized +- Very poor information density focusing on insignificant details +- Description severely limits meteorological analytical capability +- Information priorities inappropriate for meteorological context and scale +- Most included details irrelevant to primary meteorological understanding + +**Score 0 - No Relevance** +- Description fails to identify or emphasize any significant meteorological patterns +- Information completely unfocused with no clear meteorological priorities +- Description provides no useful analytical capability +- No appropriate meteorological context or scale consideration +- Content largely irrelevant to meteorological analysis needs + +## COMMON PITFALLS TO AVOID + +1. **Completeness Confusion**: Don't confuse comprehensive coverage with relevance—focus beats breadth +2. **Detail Fetishism**: Precise quantitative data doesn't automatically mean relevance +3. **Template Thinking**: Different meteorological situations require different information priorities +4. **Academic Bias**: Balance research completeness with analytical efficiency +5. **Scale Mismatching**: Regional charts need different detail emphasis than global analyses + +## OUTPUT REQUIREMENTS + +Provide your evaluation in the following XML format: + +```xml +Relevance +[Your detailed analysis explaining the score, referencing specific examples of meteorological significance prioritization, information density optimization, analytical enablement, and contextual appropriateness. Include concrete evidence of what important information is emphasized or missed.] +[0-5] +``` + +**Critical Requirements**: +- Use exactly `Relevance` +- Score must be an integer from 0 to 5 +- Reasoning should reference specific examples of information prioritization and meteorological significance +- All XML tags must be properly closed +- No additional formatting or text outside the XML structure + +**Success Check**: Your evaluation should enable a developer to understand exactly what meteorological information priorities are appropriate and how well the description serves analytical needs for blind scientists. +""" + + +def get_default_criteria_evaluator_user_prompt(criteria: str) -> str: + """ + Get the default user prompt for the specified CriteriaEvaluatorAgent. + + Args: + criteria (str): The criteria for which to get the default user prompt. Should be one of: coherence, fluency, consistency, relevance. + + Returns: + str: The default criteria user prompt text. + """ + if criteria == "coherence": + return DEFAULT_COHERENCE_CRITERIA_EVALUATOR_USER_PROMPT + elif criteria == "fluency": + return DEFAULT_FLUENCY_CRITERIA_EVALUATOR_USER_PROMPT + elif criteria == "consistency": + return DEFAULT_CONSISTENCY_CRITERIA_EVALUATOR_USER_PROMPT + elif criteria == "relevance": + return DEFAULT_RELEVANCE_CRITERIA_EVALUATOR_USER_PROMPT + else: + raise ValueError( + f"Unknown criteria: {criteria}. Valid options are: coherence, fluency, consistency, relevance." + ) From e324cca8a5862dbe7ebe72498e79fe38a8b6269d Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 24 Jun 2025 10:57:56 -0400 Subject: [PATCH 039/151] feat: implementing evaluator agent orchestrating criteria agents --- src/earth_reach_agent/core/evaluator.py | 115 ++++++++++++++++++------ 1 file changed, 89 insertions(+), 26 deletions(-) diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach_agent/core/evaluator.py index c447590..5b3e777 100644 --- a/src/earth_reach_agent/core/evaluator.py +++ b/src/earth_reach_agent/core/evaluator.py @@ -9,12 +9,20 @@ from earth_reach_agent.core.generator import FigureMetadata from earth_reach_agent.core.llm import BaseLLM, create_llm -from earth_reach_agent.core.prompts import get_default_criteria_evaluator_user_prompt +from earth_reach_agent.core.prompts import get_default_criterion_evaluator_user_prompt @dataclass -class CriteriaEvaluatorOutput: - criteria: str +class CriterionEvaluatorOutput: + """Structured representation of the criterion evaluator's output. + + Attributes: + criterion: The criterion against which the text was evaluated. + score: The score assigned to the text based on the criterion (0 to 5). + reasoning: Optional reasoning for the score, if provided by the LLM. + """ + + criterion: str score: int reasoning: str | None = None @@ -32,12 +40,16 @@ def __post_init__(self): raise ValueError("Score must be between 0 and 5.") -class CriteriaEvaluator: - """Evaluator class for evaluating the quality of text based on a specified criteria.""" +class CriterionEvaluator: + """Evaluator class for evaluating the quality of text based on a specified criterion.""" def __init__( - self, llm: BaseLLM, system_prompt: str | None, user_prompt: str + self, criterion: str, llm: BaseLLM, system_prompt: str | None, user_prompt: str ) -> None: + if criterion not in ["coherence", "fluency", "consistency", "relevance"]: + raise ValueError(f"Unsupported criterion: {criterion}") + + self.criterion = criterion self.llm = llm self.system_prompt = system_prompt self.user_prompt = user_prompt @@ -47,7 +59,7 @@ def evaluate( description: str, figure: ekp.Figure | None = None, image: ImageFile | None = None, - ) -> CriteriaEvaluatorOutput: + ) -> CriterionEvaluatorOutput: if figure is not None and image is not None: raise ValueError( "Only one of 'figure' or 'image' can be provided, not both." @@ -77,15 +89,15 @@ def evaluate( except Exception as e: raise RuntimeError(f"Failed to generate response: {e}") from e - def parse_llm_response(self, response: str) -> CriteriaEvaluatorOutput: + def parse_llm_response(self, response: str) -> CriterionEvaluatorOutput: """ - Parse the XML-tagged response from the CriteriaEvaluator Agent into structured data. + Parse the XML-tagged response from the CriterionEvaluator Agent into structured data. Args: response (str): The full llm response string containing XML tags Returns: - CriteriaEvaluatorOutput: Parsed evaluation content + CriterionEvaluatorOutput: Parsed evaluation content Raises: ValueError: If the response string is empty or None, or if required fields are missing @@ -94,7 +106,7 @@ def parse_llm_response(self, response: str) -> CriteriaEvaluatorOutput: if not response or not response.strip(): raise ValueError("Response string is empty or None") - dataclass_fields = fields(CriteriaEvaluatorOutput) + dataclass_fields = fields(CriterionEvaluatorOutput) extracted_values = {} parsing_errors = [] @@ -133,10 +145,12 @@ def parse_llm_response(self, response: str) -> CriteriaEvaluatorOutput: raise Exception(f"Parsing errors occurred: {'; '.join(parsing_errors)}") try: - return CriteriaEvaluatorOutput(**extracted_values) + return CriterionEvaluatorOutput( + criterion=self.criterion, **extracted_values + ) except Exception as e: raise Exception( - f"Failed to create CriteriaEvaluatorOutput instance: {str(e)}" + f"Failed to create CriterionEvaluatorOutput instance: {str(e)}" ) def convert_to_field_type( @@ -277,30 +291,32 @@ def _get_image_from_figure(self, figure: ekp.Figure) -> ImageFile: return Image.open(buffer) -class EvaluatorCriteriaFactory: - """Factory class for creating single criteria evaluator agents.""" +class CriterionEvaluatorFactory: + """Factory class for creating single criterion evaluator agents.""" @staticmethod - def create(criterion: str, llm: BaseLLM | None = None) -> CriteriaEvaluator: + def create(criterion: str, llm: BaseLLM | None = None) -> CriterionEvaluator: """ - Create a CriteriaEvaluator instance based on the provided criteria. + Create a CriterionEvaluator instance based on the provided criterion. Args: - criteria (str): Criteria name to create evaluators for. + criterion (str): Criterion name to create evaluators for. llm (BaseLLM | None): Optional LLM instance to use for evaluation. Returns: - CriteriaEvaluator: CriteriaEvaluator instance. + CriterionEvaluator: CriterionEvaluator instance. """ if criterion not in ["coherence", "fluency", "consistency", "relevance"]: - raise ValueError(f"Unsupported criteria: {criterion}") + raise ValueError(f"Unsupported criterion: {criterion}") if not llm: llm = create_llm() - user_prompt = get_default_criteria_evaluator_user_prompt(criterion) + user_prompt = get_default_criterion_evaluator_user_prompt(criterion) - return CriteriaEvaluator(llm=llm, system_prompt=None, user_prompt=user_prompt) + return CriterionEvaluator( + criterion=criterion, llm=llm, system_prompt=None, user_prompt=user_prompt + ) class EvaluatorAgent: @@ -311,12 +327,59 @@ def __init__(self, criteria: List[str]) -> None: Initialize the EvaluatorAgent. Args: - parameters, quality criteria, etc.. + criteria (List[str]): List of criteria to evaluate against. + Supported criteria: "coherence", "fluency", "consistency", "relevance". + + Raises: + ValueError: If an unsupported criterion is provided. + RuntimeError: If the evaluator creation fails. """ - pass + for criterion in criteria: + if criterion not in ["coherence", "fluency", "consistency", "relevance"]: + raise ValueError(f"Unsupported criterion: {criterion}") + + self.criteria = criteria - def evaluate(self, text: str, criteria: dict): - pass + try: + self.evaluators = [ + CriterionEvaluatorFactory.create(criterion) for criterion in criteria + ] + except Exception as e: + raise RuntimeError( + f"Failed to create evaluators for criteria {criteria}: {e}" + ) from e + + def evaluate( + self, + description: str, + figure: ekp.Figure | None = None, + image: ImageFile | None = None, + ) -> List[CriterionEvaluatorOutput]: + """ + Evaluate the given text against the specified criteria. + + Args: + text (str): The text to evaluate. + + Returns: + List[CriterionEvaluatorOutput]: A list of evaluation results for each criterion. + """ + if figure is not None and image is not None: + raise ValueError( + "Only one of 'figure' or 'image' can be provided, not both." + ) + + try: + evaluations = [] + for evaluator in self.evaluators: + result = evaluator.evaluate( + description=description, figure=figure, image=image + ) + evaluations.append(result) + + return evaluations + except Exception as e: + raise RuntimeError(f"Failed to evaluate description: {e}") from e def is_text_length_lesser_than_max(self, text: str, max_length: int = 1000) -> bool: """ From 92447c3cc03264c68e1b711f49981d33ef25f10e Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 24 Jun 2025 10:58:23 -0400 Subject: [PATCH 040/151] feat: removing criteria name from llm output to increase reliability --- src/earth_reach_agent/core/prompts.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/earth_reach_agent/core/prompts.py b/src/earth_reach_agent/core/prompts.py index ad61b0d..e45f3ab 100644 --- a/src/earth_reach_agent/core/prompts.py +++ b/src/earth_reach_agent/core/prompts.py @@ -276,13 +276,11 @@ Provide your evaluation in the following XML format: ```xml -Coherence [Your detailed analysis explaining the score, referencing specific aspects of information flow, structural organization, and accessibility-adapted coherence. Include concrete examples from the description to support your assessment.] [0-5] ``` **Critical Requirements**: -- Use exactly `Coherence` - Score must be an integer from 0 to 5 - Reasoning should reference specific textual evidence - All XML tags must be properly closed @@ -418,13 +416,11 @@ Provide your evaluation in the following XML format: ```xml -Fluency [Your detailed analysis explaining the score, referencing specific examples of grammatical correctness, terminology usage, readability, and scientific voice. Include concrete textual evidence to support your assessment.] [0-5] ``` **Critical Requirements**: -- Use exactly `Fluency` - Score must be an integer from 0 to 5 - Reasoning should reference specific linguistic evidence from the description - All XML tags must be properly closed @@ -559,13 +555,11 @@ Provide your evaluation in the following XML format: ```xml -Consistency [Your detailed analysis explaining the score, referencing specific examples of source-description alignment, internal consistency, meteorological plausibility, and quantitative accuracy. Include concrete evidence from both the chart and description to support your assessment.] [0-5] ``` **Critical Requirements**: -- Use exactly `Consistency` - Score must be an integer from 0 to 5 - Reasoning should reference specific examples comparing chart features to description elements - All XML tags must be properly closed @@ -701,13 +695,11 @@ Provide your evaluation in the following XML format: ```xml -Relevance [Your detailed analysis explaining the score, referencing specific examples of meteorological significance prioritization, information density optimization, analytical enablement, and contextual appropriateness. Include concrete evidence of what important information is emphasized or missed.] [0-5] ``` **Critical Requirements**: -- Use exactly `Relevance` - Score must be an integer from 0 to 5 - Reasoning should reference specific examples of information prioritization and meteorological significance - All XML tags must be properly closed @@ -717,7 +709,7 @@ """ -def get_default_criteria_evaluator_user_prompt(criteria: str) -> str: +def get_default_criterion_evaluator_user_prompt(criteria: str) -> str: """ Get the default user prompt for the specified CriteriaEvaluatorAgent. From d1456e209daa748f359c22253c6654ffe0c9bd37 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 24 Jun 2025 14:35:37 -0400 Subject: [PATCH 041/151] refact: refactoring prompt organization --- src/earth_reach_agent/core/evaluator.py | 4 +- .../core/prompts/__init__.py | 0 .../core/{prompts.py => prompts/criteria.py} | 184 +----------------- .../core/prompts/generator.py | 169 ++++++++++++++++ src/earth_reach_agent/main.py | 4 +- 5 files changed, 183 insertions(+), 178 deletions(-) create mode 100644 src/earth_reach_agent/core/prompts/__init__.py rename src/earth_reach_agent/core/{prompts.py => prompts/criteria.py} (75%) create mode 100644 src/earth_reach_agent/core/prompts/generator.py diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach_agent/core/evaluator.py index 5b3e777..0e8ef90 100644 --- a/src/earth_reach_agent/core/evaluator.py +++ b/src/earth_reach_agent/core/evaluator.py @@ -9,7 +9,9 @@ from earth_reach_agent.core.generator import FigureMetadata from earth_reach_agent.core.llm import BaseLLM, create_llm -from earth_reach_agent.core.prompts import get_default_criterion_evaluator_user_prompt +from earth_reach_agent.core.prompts.criteria import ( + get_default_criterion_evaluator_user_prompt, +) @dataclass diff --git a/src/earth_reach_agent/core/prompts/__init__.py b/src/earth_reach_agent/core/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/earth_reach_agent/core/prompts.py b/src/earth_reach_agent/core/prompts/criteria.py similarity index 75% rename from src/earth_reach_agent/core/prompts.py rename to src/earth_reach_agent/core/prompts/criteria.py index e45f3ab..364d84e 100644 --- a/src/earth_reach_agent/core/prompts.py +++ b/src/earth_reach_agent/core/prompts/criteria.py @@ -1,169 +1,3 @@ -DEFAULT_GENERATOR_USER_PROMPT = """# Enhanced Weather Chart Alt-Text Generation System - -## ROLE AND CONTEXT SETTING - -You are a specialist scientific communication assistant working with meteorological researchers who are blind or visually impaired. Your expertise lies in converting complex weather visualizations into precise, scientifically accurate text descriptions that preserve all critical meteorological information while being fully accessible. - -**Your Mission**: Transform weather charts and maps into comprehensive text descriptions that enable blind scientists to conduct the same quality of meteorological analysis as their sighted colleagues. - -**Critical Context**: Your descriptions will be used for: -- Research analysis and data interpretation -- Scientific paper writing and peer review -- Teaching and educational materials -- Operational weather forecasting decisions - -## TASK DECOMPOSITION - -### Step 1: Data Extraction and Analysis (200-500 words) -**Objective**: Systematically catalog all quantitative information and meteorological features - -**Actions**: -1. Identify and record the exact value ranges for each variable (temperature, pressure, wind speed, etc.) -2. Note the geographic boundaries (latitude/longitude ranges), map projection, and spatial domain (e.g., "North America", "Europe", "Spain", "Global") -3. List all meteorological systems visible (highs, lows, fronts, convergence zones) -4. Document the time period, date, and any temporal information -5. Record the data source, resolution, and any technical specifications shown - -**Success Check**: Can you list specific numbers for every major feature without using vague terms like "high" or "low"? - -### Step 2: Pattern Recognition and Spatial Analysis (200-500 words) -**Objective**: Identify the dominant meteorological patterns and their spatial relationships - -**Actions**: -1. Determine the primary weather systems and rank them by meteorological significance -2. Map the directional flow of gradients (temperature, pressure) using cardinal directions -3. Identify spatial correlations between different variables -4. Note any unusual or anomalous features that deviate from expected patterns -5. Assess the seasonal/temporal context and typical vs. atypical conditions - -**Success Check**: Can you explain how each major system influences the others spatially? - -### Step 3: Description Planning and Structure Design (100-150 words) -**Objective**: Create a hierarchical outline that prioritizes information by scientific importance - -**Actions**: -1. Rank meteorological features by their significance for understanding weather patterns -2. Plan the logical flow from overview to specific details -3. Determine which quantitative values are essential vs. supplementary -4. Identify the most effective way to convey spatial relationships -5. Plan transitions between sections to maintain scientific coherence - -**Success Check**: Would a meteorologist reading your outline understand the complete weather situation? - -### Step 4: Description Writing with Verification (300-500 words) -**Objective**: Produce the final description following all specifications - -**Actions**: -1. Write a concise overview (2-3 sentences) summarizing the visualization type, geographic region, time period, and primary variables -2. Develop the main body with 4-6 sentences, covering: - - Dominant systems and their characteristics - - Variable distributions (temperature, pressure, wind) - - Spatial relationships and correlations - - Notable features or anomalies -3. Ensure all quantitative values include specific ranges and units -4. Use precise meteorological terminology and avoid visual-dependent language - -**Success Check**: Does your description pass all items in the quality verification checklist? - -## CONCRETE CONSTRAINTS AND SPECIFICATIONS - -### Length and Structure Requirements -- **Total description length**: 300-500 words maximum -- **Overview section**: Exactly 2-3 sentences -- **Main body**: 4-6 sentences - -### Quantitative Requirements -- **Include exact ranges**: Every variable must have minimum and maximum values with units -- **Spatial precision**: Use cardinal directions and geographic references -- **Coordinate references**: Include latitude/longitude for major features (±2 degrees acceptable) -- **Unit consistency**: Use standard meteorological units (hPa, m/s, K or °C, km) - -### Language Specifications -- **Terminology level**: Graduate-level meteorological vocabulary (assume PhD-level audience) -- **Sentence structure**: Maximum 25 words per sentence -- **Accessibility compliance**: Zero visual-dependent phrases (see prohibited terms list below) -- **Objectivity standard**: Separate observations from interpretations using phrases like "The data shows..." vs. "This suggests..." - -## ERROR PREVENTION GUIDELINES - -### Common Pitfalls to Avoid -1. **Vague descriptions**: Never use "high," "low," "warm," "cold" without specific values -2. **Visual dependency**: Prohibited phrases include: "as shown," "visible," "looking at," "clearly," "obviously" -3. **Geographic ambiguity**: Avoid "here," "there," "this area" - use specific location references -4. **Unit omissions**: Every quantitative statement must include appropriate units -5. **Pattern assumptions**: Don't assume readers can visualize spatial relationships - describe explicitly - -## SUCCESS CRITERIA - -### Objective Standards -1. **Completeness Test**: A meteorologist should be able to sketch the general pattern from your description -2. **Quantitative Accuracy**: All numerical values within ±5% of actual data -3. **Accessibility Compliance**: 100% of visual information conveyed through text -4. **Scientific Utility**: Description enables the same analytical conclusions as the visual -5. **Terminology Precision**: All meteorological terms used correctly per AMS Glossary standards - -## EXAMPLES AND REFERENCE PATTERNS - -Not yet available, but will be provided in the future. - -## OUTPUT FORMAT REQUIREMENTS - -### XML Tag Structure -**CRITICAL**: You must wrap the content of each step and the final description in specific XML tags for programmatic parsing. - -**Required XML Tags:** -- `...` - Wrap the entire content of Step 1 (excluding the markdown header) -- `...` - Wrap the entire content of Step 2 (excluding the markdown header) -- `...` - Wrap the entire content of Step 3 (excluding the markdown header) -- `...` - Wrap the entire content of Step 4 (excluding the markdown header) -- `...` - Wrap only the final consolidated description (without headers or meta-commentary) - -### Formatting Rules -1. **Tag Placement**: Place opening and closing tags on their own lines -2. **Content Scope**: Include all step content within tags, but exclude markdown headers (## Step X) -3. **Final Description**: The `` should contain ONLY the final weather description without any meta-commentary about word count or requirements -4. **No Tag Omission**: All five XML tag pairs must be present, even if a step is brief - -### Example Structure: -``` -## Step 1: Data Extraction and Analysis - -The chart displays 2-meter temperature and mean sea level pressure... -[rest of step 1 content] - - -## Step 2: Pattern Recognition and Spatial Analysis - -The dominant feature is a high-pressure system... -[rest of step 2 content] - - -## Step 3: Description Planning and Structure Design - -To structure the description, we will start with an overview... -[rest of step 3 content] - - -## Step 4: Description Writing with Verification - -### Overview -This weather chart displays... -### Main Body -A high-pressure system is centered... -[rest of step 4 content] - - - -This weather chart visualization shows 2-meter temperature and mean sea level pressure over Western Europe on March 12, 2011, at 12:00 UTC. The region covers latitudes from 40.5°N to 51°N and longitudes from 2.5°W to 10°E. A high-pressure system is centered at 45°N, 7.5°E with a pressure of 102080 Pa. The 2-meter temperature varies from 280 K in the north to 294 K in the southeast, showing a significant north-south temperature gradient. The pressure distribution is dominated by this high-pressure system, with pressure decreasing towards the northwest. The highest temperatures are located southeast of the high-pressure center. - -``` - -**IMPORTANT**: Ensure all XML tags are properly opened and closed. Malformed XML will cause parsing failures. - -**Remember**: Your description is not just text, it's a scientific instrument that must preserve the analytical power of the original visualization while being fully accessible to blind scientists. -""" - - DEFAULT_COHERENCE_CRITERIA_EVALUATOR_USER_PROMPT = """# "Coherence" Quality Criteria Evaluation Instructions ## ROLE AND CONTEXT SETTING @@ -709,25 +543,25 @@ """ -def get_default_criterion_evaluator_user_prompt(criteria: str) -> str: +def get_default_criterion_evaluator_user_prompt(criterion: str) -> str: """ - Get the default user prompt for the specified CriteriaEvaluatorAgent. + Get the default CriteriaEvaluatorAgent user prompt for the specified criterion. Args: - criteria (str): The criteria for which to get the default user prompt. Should be one of: coherence, fluency, consistency, relevance. + criterion (str): The criterion for which to get the default user prompt. Should be one of: coherence, fluency, consistency, relevance. Returns: - str: The default criteria user prompt text. + str: The default criterion user prompt text. """ - if criteria == "coherence": + if criterion == "coherence": return DEFAULT_COHERENCE_CRITERIA_EVALUATOR_USER_PROMPT - elif criteria == "fluency": + elif criterion == "fluency": return DEFAULT_FLUENCY_CRITERIA_EVALUATOR_USER_PROMPT - elif criteria == "consistency": + elif criterion == "consistency": return DEFAULT_CONSISTENCY_CRITERIA_EVALUATOR_USER_PROMPT - elif criteria == "relevance": + elif criterion == "relevance": return DEFAULT_RELEVANCE_CRITERIA_EVALUATOR_USER_PROMPT else: raise ValueError( - f"Unknown criteria: {criteria}. Valid options are: coherence, fluency, consistency, relevance." + f"Unknown criterion: {criterion}. Valid options are: coherence, fluency, consistency, relevance." ) diff --git a/src/earth_reach_agent/core/prompts/generator.py b/src/earth_reach_agent/core/prompts/generator.py new file mode 100644 index 0000000..388889b --- /dev/null +++ b/src/earth_reach_agent/core/prompts/generator.py @@ -0,0 +1,169 @@ +DEFAULT_GENERATOR_USER_PROMPT = """# Enhanced Weather Chart Alt-Text Generation System + +## ROLE AND CONTEXT SETTING + +You are a specialist scientific communication assistant working with meteorological researchers who are blind or visually impaired. Your expertise lies in converting complex weather visualizations into precise, scientifically accurate text descriptions that preserve all critical meteorological information while being fully accessible. + +**Your Mission**: Transform weather charts and maps into comprehensive text descriptions that enable blind scientists to conduct the same quality of meteorological analysis as their sighted colleagues. + +**Critical Context**: Your descriptions will be used for: +- Research analysis and data interpretation +- Scientific paper writing and peer review +- Teaching and educational materials +- Operational weather forecasting decisions + +## TASK DECOMPOSITION + +### Step 1: Data Extraction and Analysis (200-500 words) +**Objective**: Systematically catalog all quantitative information and meteorological features + +**Actions**: +1. Identify and record the exact value ranges for each variable (temperature, pressure, wind speed, etc.) +2. Note the geographic boundaries (latitude/longitude ranges), map projection, and spatial domain (e.g., "North America", "Europe", "Spain", "Global") +3. List all meteorological systems visible (highs, lows, fronts, convergence zones) +4. Document the time period, date, and any temporal information +5. Record the data source, resolution, and any technical specifications shown + +**Success Check**: Can you list specific numbers for every major feature without using vague terms like "high" or "low"? + +### Step 2: Pattern Recognition and Spatial Analysis (200-500 words) +**Objective**: Identify the dominant meteorological patterns and their spatial relationships + +**Actions**: +1. Determine the primary weather systems and rank them by meteorological significance +2. Map the directional flow of gradients (temperature, pressure) using cardinal directions +3. Identify spatial correlations between different variables +4. Note any unusual or anomalous features that deviate from expected patterns +5. Assess the seasonal/temporal context and typical vs. atypical conditions + +**Success Check**: Can you explain how each major system influences the others spatially? + +### Step 3: Description Planning and Structure Design (100-150 words) +**Objective**: Create a hierarchical outline that prioritizes information by scientific importance + +**Actions**: +1. Rank meteorological features by their significance for understanding weather patterns +2. Plan the logical flow from overview to specific details +3. Determine which quantitative values are essential vs. supplementary +4. Identify the most effective way to convey spatial relationships +5. Plan transitions between sections to maintain scientific coherence + +**Success Check**: Would a meteorologist reading your outline understand the complete weather situation? + +### Step 4: Description Writing with Verification (300-500 words) +**Objective**: Produce the final description following all specifications + +**Actions**: +1. Write a concise overview (2-3 sentences) summarizing the visualization type, geographic region, time period, and primary variables +2. Develop the main body with 4-6 sentences, covering: + - Dominant systems and their characteristics + - Variable distributions (temperature, pressure, wind) + - Spatial relationships and correlations + - Notable features or anomalies +3. Ensure all quantitative values include specific ranges and units +4. Use precise meteorological terminology and avoid visual-dependent language + +**Success Check**: Does your description pass all items in the quality verification checklist? + +## CONCRETE CONSTRAINTS AND SPECIFICATIONS + +### Length and Structure Requirements +- **Total description length**: 300-500 words maximum +- **Overview section**: Exactly 2-3 sentences +- **Main body**: 4-6 sentences + +### Quantitative Requirements +- **Include exact ranges**: Every variable must have minimum and maximum values with units +- **Spatial precision**: Use cardinal directions and geographic references +- **Coordinate references**: Include latitude/longitude for major features (±2 degrees acceptable) +- **Unit consistency**: Use standard meteorological units (hPa, m/s, K or °C, km) + +### Language Specifications +- **Terminology level**: Graduate-level meteorological vocabulary (assume PhD-level audience) +- **Sentence structure**: Maximum 25 words per sentence +- **Accessibility compliance**: Zero visual-dependent phrases (see prohibited terms list below) +- **Objectivity standard**: Separate observations from interpretations using phrases like "The data shows..." vs. "This suggests..." + +## ERROR PREVENTION GUIDELINES + +### Common Pitfalls to Avoid +1. **Vague descriptions**: Never use "high," "low," "warm," "cold" without specific values +2. **Visual dependency**: Prohibited phrases include: "as shown," "visible," "looking at," "clearly," "obviously" +3. **Geographic ambiguity**: Avoid "here," "there," "this area" - use specific location references +4. **Unit omissions**: Every quantitative statement must include appropriate units +5. **Pattern assumptions**: Don't assume readers can visualize spatial relationships - describe explicitly + +## SUCCESS CRITERIA + +### Objective Standards +1. **Completeness Test**: A meteorologist should be able to sketch the general pattern from your description +2. **Quantitative Accuracy**: All numerical values within ±5% of actual data +3. **Accessibility Compliance**: 100% of visual information conveyed through text +4. **Scientific Utility**: Description enables the same analytical conclusions as the visual +5. **Terminology Precision**: All meteorological terms used correctly per AMS Glossary standards + +## EXAMPLES AND REFERENCE PATTERNS + +Not yet available, but will be provided in the future. + +## OUTPUT FORMAT REQUIREMENTS + +### XML Tag Structure +**CRITICAL**: You must wrap the content of each step and the final description in specific XML tags for programmatic parsing. + +**Required XML Tags:** +- `...` - Wrap the entire content of Step 1 (excluding the markdown header) +- `...` - Wrap the entire content of Step 2 (excluding the markdown header) +- `...` - Wrap the entire content of Step 3 (excluding the markdown header) +- `...` - Wrap the entire content of Step 4 (excluding the markdown header) +- `...` - Wrap only the final consolidated description (without headers or meta-commentary) + +### Formatting Rules +1. **Tag Placement**: Place opening and closing tags on their own lines +2. **Content Scope**: Include all step content within tags, but exclude markdown headers (## Step X) +3. **Final Description**: The `` should contain ONLY the final weather description without any meta-commentary about word count or requirements +4. **No Tag Omission**: All five XML tag pairs must be present, even if a step is brief + +### Example Structure: +``` +## Step 1: Data Extraction and Analysis + +The chart displays 2-meter temperature and mean sea level pressure... +[rest of step 1 content] + + +## Step 2: Pattern Recognition and Spatial Analysis + +The dominant feature is a high-pressure system... +[rest of step 2 content] + + +## Step 3: Description Planning and Structure Design + +To structure the description, we will start with an overview... +[rest of step 3 content] + + +## Step 4: Description Writing with Verification + +### Overview +This weather chart displays... +### Main Body +A high-pressure system is centered... +[rest of step 4 content] + + + +This weather chart visualization shows 2-meter temperature and mean sea level pressure over Western Europe on March 12, 2011, at 12:00 UTC. The region covers latitudes from 40.5°N to 51°N and longitudes from 2.5°W to 10°E. A high-pressure system is centered at 45°N, 7.5°E with a pressure of 102080 Pa. The 2-meter temperature varies from 280 K in the north to 294 K in the southeast, showing a significant north-south temperature gradient. The pressure distribution is dominated by this high-pressure system, with pressure decreasing towards the northwest. The highest temperatures are located southeast of the high-pressure center. + +``` + +**IMPORTANT**: Ensure all XML tags are properly opened and closed. Malformed XML will cause parsing failures. + +**Remember**: Your description is not just text, it's a scientific instrument that must preserve the analytical power of the original visualization while being fully accessible to blind scientists. +""" + + +def get_default_generator_user_prompt() -> str: + """Get the default user prompt for the weather chart description generator.""" + return DEFAULT_GENERATOR_USER_PROMPT diff --git a/src/earth_reach_agent/main.py b/src/earth_reach_agent/main.py index 727078d..87eb651 100644 --- a/src/earth_reach_agent/main.py +++ b/src/earth_reach_agent/main.py @@ -14,7 +14,7 @@ from earth_reach_agent.core.generator import GeneratorAgent from earth_reach_agent.core.llm import create_llm -from earth_reach_agent.core.prompts import DEFAULT_GENERATOR_USER_PROMPT +from earth_reach_agent.core.prompts.generator import get_default_generator_user_prompt logging.basicConfig( level=logging.INFO, @@ -161,7 +161,7 @@ def main( None, ) user_prompt_text = resolve_prompt( - user_prompt, user_prompt_file_path, DEFAULT_GENERATOR_USER_PROMPT + user_prompt, user_prompt_file_path, get_default_generator_user_prompt() ) if not user_prompt_text: raise ValueError( From b8393be9837b2d26c11b2ff7922c3fcb3a02e648 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Fri, 27 Jun 2025 15:02:09 -0400 Subject: [PATCH 042/151] refact: improving todo item for clarity --- src/earth_reach_agent/core/evaluator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach_agent/core/evaluator.py index 0e8ef90..dd91aac 100644 --- a/src/earth_reach_agent/core/evaluator.py +++ b/src/earth_reach_agent/core/evaluator.py @@ -67,7 +67,7 @@ def evaluate( "Only one of 'figure' or 'image' can be provided, not both." ) if figure is not None: - # TODO(medium): If metadata extraction failes, continue without it + # TODO(medium): If metadata extraction fails, continue without it metadata = self._get_metadata_from_figure(figure) self.user_prompt = self._update_user_prompt_with_metadata( self.user_prompt, metadata From 1e10a9d41e14eecc9017c8189426836e2591ec2c Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Fri, 27 Jun 2025 16:01:55 -0400 Subject: [PATCH 043/151] refact: renaming prompt file for clarity --- src/earth_reach_agent/core/prompts/{criteria.py => evaluator.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/earth_reach_agent/core/prompts/{criteria.py => evaluator.py} (100%) diff --git a/src/earth_reach_agent/core/prompts/criteria.py b/src/earth_reach_agent/core/prompts/evaluator.py similarity index 100% rename from src/earth_reach_agent/core/prompts/criteria.py rename to src/earth_reach_agent/core/prompts/evaluator.py From 37592553c9b1d8038588e08c8bb64a5dd90767ff Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Fri, 27 Jun 2025 16:02:26 -0400 Subject: [PATCH 044/151] refact: replacing criterion attribute by name in dataclass for clarity --- src/earth_reach_agent/core/evaluator.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach_agent/core/evaluator.py index dd91aac..055191f 100644 --- a/src/earth_reach_agent/core/evaluator.py +++ b/src/earth_reach_agent/core/evaluator.py @@ -9,7 +9,7 @@ from earth_reach_agent.core.generator import FigureMetadata from earth_reach_agent.core.llm import BaseLLM, create_llm -from earth_reach_agent.core.prompts.criteria import ( +from earth_reach_agent.core.prompts.evaluator import ( get_default_criterion_evaluator_user_prompt, ) @@ -24,7 +24,7 @@ class CriterionEvaluatorOutput: reasoning: Optional reasoning for the score, if provided by the LLM. """ - criterion: str + name: str score: int reasoning: str | None = None @@ -147,9 +147,7 @@ def parse_llm_response(self, response: str) -> CriterionEvaluatorOutput: raise Exception(f"Parsing errors occurred: {'; '.join(parsing_errors)}") try: - return CriterionEvaluatorOutput( - criterion=self.criterion, **extracted_values - ) + return CriterionEvaluatorOutput(name=self.criterion, **extracted_values) except Exception as e: raise Exception( f"Failed to create CriterionEvaluatorOutput instance: {str(e)}" From c95114255d94d495dcb849ac59fe61818e15b891 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Fri, 27 Jun 2025 16:02:50 -0400 Subject: [PATCH 045/151] feat: implementing generator method to append user prompt --- src/earth_reach_agent/core/generator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index 597649e..a4860d9 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -284,3 +284,7 @@ def _get_image_from_figure(self, figure: ekp.Figure) -> ImageFile: figure.save(buffer, format="png") buffer.seek(0) return Image.open(buffer) + + def append_user_prompt(self, text: str) -> None: + """Append additional text to the user prompt.""" + self.user_prompt += f"\n\n{text.strip()}" From 65d9b8a49157f0ed46b59f8fcf32c86a48f86a32 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Fri, 27 Jun 2025 16:03:05 -0400 Subject: [PATCH 046/151] feat: implementing orchestrator class --- src/earth_reach_agent/core/orchestrator.py | 132 ++++++++++++++++++++- 1 file changed, 129 insertions(+), 3 deletions(-) diff --git a/src/earth_reach_agent/core/orchestrator.py b/src/earth_reach_agent/core/orchestrator.py index ada2a90..0edd3da 100644 --- a/src/earth_reach_agent/core/orchestrator.py +++ b/src/earth_reach_agent/core/orchestrator.py @@ -1,4 +1,130 @@ -class AgentsOrchestrator: - """Orchestrates multiple agents to process weather figure and generate informative description.""" +import logging +from typing import List - pass +import earthkit.plots as ekp +from PIL.ImageFile import ImageFile + +from earth_reach_agent.core.evaluator import CriterionEvaluatorOutput, EvaluatorAgent +from earth_reach_agent.core.generator import GeneratorAgent +from earth_reach_agent.core.prompts.orchestrator import get_default_feedback_template + +logger = logging.getLogger(__name__) + + +class Orchestrator: + """Orchestrates Generator and Evaluator agents to create weather chart descriptions.""" + + def __init__( + self, + generator_agent: GeneratorAgent, + evaluator_agent: EvaluatorAgent, + max_iterations: int = 3, + criteria_threshold: int = 4, + feedback_template: str | None = None, + ) -> None: + """ + Initialize the orchestrator with generator and evaluator agents. + + Args: + generator_agent: Instance of GeneratorAgent for generating descriptions + evaluator_agent: Instance of EvaluatorAgent for evaluating descriptions + max_iterations: Maximum number of iterations for generating descriptions + """ + self.generator_agent = generator_agent + self.evaluator_agent = evaluator_agent + self.max_iterations = max_iterations + self.criteria_threshold = criteria_threshold + self.feedback_template = feedback_template or get_default_feedback_template() + + def run( + self, figure: ekp.Figure | None = None, image: ImageFile | None = None + ) -> str | None: + """Run the iterative process of generating and evaluating a weather chart description + until quality criteria are met. + + Args: + figure (Figure | None): Optional figure to include in the request. Can't be used with image. + image (ImageFile | None): Optional image to include in the request (will be converted to base64). Can't be used with figure. + + Returns: + str | None: The final weather description if successful, None if max iterations reached without success + """ + if figure is not None and image is not None: + raise ValueError( + "Only one of 'figure' or 'image' can be provided, not both." + ) + + try: + for i in range(self.max_iterations): + description = self.generator_agent.generate(figure=figure, image=image) + if not description: + raise ValueError("Generated description is empty.") + + evaluation = self.evaluator_agent.evaluate( + description, image=image, figure=figure + ) + + if self.verify_evaluation_passes(evaluation): + return description + + self.provide_feedback_to_generator(i + 1, description, evaluation) + + logger.warning( + f"Maximum iterations ({self.max_iterations}) reached without passing evaluation. Stopping." + ) + return None + except Exception as e: + raise RuntimeError("Failed to generate a description") from e + + def verify_evaluation_passes( + self, evaluation: List[CriterionEvaluatorOutput] + ) -> bool: + """ + Verify if the evaluation passes the quality criteria. + + Args: + evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent + + Returns: + bool: True if evaluation passes, False otherwise + """ + return all( + criterion.score >= self.criteria_threshold for criterion in evaluation + ) + + def provide_feedback_to_generator( + self, + evaluation_id: int, + description: str, + evaluation: List[CriterionEvaluatorOutput], + ) -> None: + """ + Provide feedback to the GeneratorAgent based on evaluation results. + + Args: + description (str): The description generated by the GeneratorAgent + evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent + """ + unmet_criteria = [ + criterion + for criterion in evaluation + if criterion.score < self.criteria_threshold + ] + if not unmet_criteria: + logger.warning("No unmet criteria found in evaluation.") + return + + feedback = self.feedback_template.format( + evaluation_id=evaluation_id, + criteria_scores="\n- ".join( + f"- {criterion.name}: {criterion.score}/5" + for criterion in unmet_criteria + ), + criteria_reasoning="\n".join( + f"- {criterion.name}: {criterion.reasoning or 'No reasoning available'}" + for criterion in unmet_criteria + ), + description=description, + ) + + self.generator_agent.append_user_prompt(feedback) From 6255b65eeac459a5c48e9121292690ec622e3ebb Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Fri, 27 Jun 2025 16:03:35 -0400 Subject: [PATCH 047/151] doc: improving docstring with return info --- src/earth_reach_agent/core/prompts/generator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/earth_reach_agent/core/prompts/generator.py b/src/earth_reach_agent/core/prompts/generator.py index 388889b..61850b4 100644 --- a/src/earth_reach_agent/core/prompts/generator.py +++ b/src/earth_reach_agent/core/prompts/generator.py @@ -165,5 +165,9 @@ def get_default_generator_user_prompt() -> str: - """Get the default user prompt for the weather chart description generator.""" + """Get the default user prompt for the weather chart description generator. + + Returns: + str: The default user prompt for the generator agent. + """ return DEFAULT_GENERATOR_USER_PROMPT From 76284fd560134d7f089216d697c7c7faf90eee08 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Fri, 27 Jun 2025 16:03:57 -0400 Subject: [PATCH 048/151] refact: renaming template placeholder name for clarity --- .../core/prompts/orchestrator.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/earth_reach_agent/core/prompts/orchestrator.py diff --git a/src/earth_reach_agent/core/prompts/orchestrator.py b/src/earth_reach_agent/core/prompts/orchestrator.py new file mode 100644 index 0000000..702639c --- /dev/null +++ b/src/earth_reach_agent/core/prompts/orchestrator.py @@ -0,0 +1,28 @@ +DEFAULT_FEEDBACK_TEMPLATE = """## EVALUATOR FEEDBACK + +Evaluation number: {evaluation_id} + +Your last attempt to generate a weather chart description was reviewed by an expert evaluator. + +During their review, they deemed that the description did not meet the following quality criteria: +{criteria_scores} + +They provided the following reasoning for their evaluation: +{criteria_reasoning} + +Here's the description that was evaluated: +{description} + +Your will now attempt to improve the description based on this feedback. To achieve this, you will +go through all the reasoning steps defined above, while taking into account the feedback provided by the evaluator. +""" + + +def get_default_feedback_template() -> str: + """ + Get the default feedback template for the OrchestratorAgent. + + Returns: + str: The default feedback template. + """ + return DEFAULT_FEEDBACK_TEMPLATE From 9ad969b252a7f39f45ea3a9c2d67aca7d9c4195c Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 1 Jul 2025 14:29:05 -0400 Subject: [PATCH 049/151] feat: implementing CLI for evaluator agent --- src/earth_reach_agent/core/evaluator.py | 5 +- src/earth_reach_agent/main.py | 256 +++++++++++++++++++++++- 2 files changed, 257 insertions(+), 4 deletions(-) diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach_agent/core/evaluator.py index 055191f..e50bde5 100644 --- a/src/earth_reach_agent/core/evaluator.py +++ b/src/earth_reach_agent/core/evaluator.py @@ -322,7 +322,7 @@ def create(criterion: str, llm: BaseLLM | None = None) -> CriterionEvaluator: class EvaluatorAgent: """Agent class for evaluating the quality of weather chart descriptions.""" - def __init__(self, criteria: List[str]) -> None: + def __init__(self, criteria: List[str], llm: BaseLLM | None = None) -> None: """ Initialize the EvaluatorAgent. @@ -342,7 +342,8 @@ def __init__(self, criteria: List[str]) -> None: try: self.evaluators = [ - CriterionEvaluatorFactory.create(criterion) for criterion in criteria + CriterionEvaluatorFactory.create(criterion, llm=llm) + for criterion in criteria ] except Exception as e: raise RuntimeError( diff --git a/src/earth_reach_agent/main.py b/src/earth_reach_agent/main.py index 87eb651..d287e76 100644 --- a/src/earth_reach_agent/main.py +++ b/src/earth_reach_agent/main.py @@ -7,11 +7,13 @@ import os import sys from pathlib import Path +from typing import List import fire from dotenv import load_dotenv from PIL import Image +from earth_reach_agent.core.evaluator import EvaluatorAgent from earth_reach_agent.core.generator import GeneratorAgent from earth_reach_agent.core.llm import create_llm from earth_reach_agent.core.prompts.generator import get_default_generator_user_prompt @@ -88,6 +90,58 @@ def resolve_prompt( return DEFAULT_USER_PROMPT +def resolve_description( + description: str | None, + description_file_path: str | None, +) -> str | None: + """ + Resolve description from multiple sources with priority: direct text > file. + + Args: + description (str | None): Direct description text + description_file_path (str | None): Path to description file + + Returns: + str | None: Resolved description text + + Raises: + ValueError: If both description sources are provided or file is invalid + FileNotFoundError: If description file doesn't exist + """ + if description and description_file_path: + raise ValueError( + "Cannot provide both description text and description file path. " + "Please provide only one." + ) + + if description: + return description + + if description_file_path: + if not os.path.exists(description_file_path): + raise FileNotFoundError( + f"Description file not found: {description_file_path}" + ) + + try: + with open(description_file_path, "r", encoding="utf-8") as file: + return file.read().strip() + except Exception as e: + raise ValueError(f"Error reading description file: {e}") + + return None + + +def get_valid_criteria() -> List[str]: + """ + Get list of valid evaluation criteria. + + Returns: + List[str]: List of valid criteria names + """ + return ["coherence", "fluency", "consistency", "relevance"] + + def validate_image_path(image_path: str) -> Path: """ Validate that image path exists and is a supported format. @@ -211,11 +265,209 @@ def main( sys.exit(1) +class CLI: + """ + Command Line Interface for the Earth Reach Agent. + Allows users to generate and evaluate weather chart descriptions from images. + """ + + @staticmethod + def generate( + image_path: str, + system_prompt: str | None = None, + system_prompt_file_path: str | None = None, + user_prompt: str | None = None, + user_prompt_file_path: str | None = None, + verbose: bool = False, + ) -> None: + """ + Generate a scientific description of a weather chart from an image. + + Args: + image_path (str): Path to the weather chart image (JPEG or PNG) + system_prompt (str | None): System prompt text (optional) + system_prompt_file_path (str | None): Path to system prompt file (optional) + user_prompt (str | None): User prompt text (optional) + user_prompt_file_path (str | None): Path to user prompt file (optional) + verbose (bool): Enable verbose output (optional) + + Returns: + None: Prints the generated weather description + + Raises: + FileNotFoundError: If image or prompt files don't exist + ValueError: If arguments are invalid or conflicting + RuntimeError: If description generation fails + """ + try: + if verbose: + logger.info(f"Validating image: {image_path}") + validated_image_path = validate_image_path(image_path) + image = Image.open(validated_image_path) + + if verbose: + logger.info("Resolving prompts...") + system_prompt_text = resolve_prompt( + system_prompt, + system_prompt_file_path, + None, + ) + user_prompt_text = resolve_prompt( + user_prompt, user_prompt_file_path, get_default_generator_user_prompt() + ) + if not user_prompt_text: + raise ValueError( + "User prompt cannot be empty. Please provide a valid prompt." + ) + + if verbose: + if system_prompt_text: + logger.info( + f"System prompt length: {len(system_prompt_text)} characters" + ) + + if user_prompt_text: + logger.info( + f"User prompt length: {len(user_prompt_text)} characters" + ) + + if verbose: + logger.info("Initializing LLM...") + llm = create_llm() + + if verbose: + logger.info("Creating generator agent...") + generator = GeneratorAgent( + llm=llm, system_prompt=system_prompt_text, user_prompt=user_prompt_text + ) + + if verbose: + logger.info(f"Generating description for: {validated_image_path.name}") + description = generator.generate(image=image) + + if verbose: + logger.info("Description generated successfully!") + logger.info(f"Description length: {len(description)} characters") + logger.info("-" * 50) + + print(description) + + return None + + except (FileNotFoundError, ValueError, IOError) as e: + logger.error(f"Could not load image file: {e}", exc_info=True) + sys.exit(1) + except RuntimeError as e: + logger.error(f"Generation failed: {e}", exc_info=True) + sys.exit(1) + except Exception as e: + logger.error(f"Unexpected error: {e}", exc_info=True) + sys.exit(1) + + @staticmethod + def evaluate( + image_path: str, + description: str | None = None, + description_file_path: str | None = None, + criteria: List[str] = ["coherence", "fluency", "consistency", "relevance"], + verbose: bool = False, + ) -> None: + """ + Evaluate a generated weather chart description. + + Args: + image_path (str): Path to the weather chart image (JPEG or PNG) + description (str | None): The description text to evaluate (optional) + description_file_path (str | None): Path to file containing description (optional) + criteria (List[str]): List of evaluation criteria to assess + verbose (bool): Enable verbose output (optional) + + Returns: + None: Prints evaluation results + + Raises: + FileNotFoundError: If image or description files don't exist + ValueError: If arguments are invalid or conflicting + RuntimeError: If evaluation fails + """ + try: + if verbose: + logger.info(f"Validating image: {image_path}") + validated_image_path = validate_image_path(image_path) + image = Image.open(validated_image_path) + + if verbose: + logger.info("Resolving description...") + description_text = resolve_description( + description, + description_file_path, + ) + if not description_text or description_text.strip() == "": + raise ValueError( + "Description cannot be empty. Please provide a valid description." + ) + + if verbose: + logger.info(f"Description length: {len(description_text)} characters") + + if not criteria or len(criteria) == 0: + raise ValueError("Criteria list cannot be empty.") + + valid_criteria = get_valid_criteria() + invalid_criteria = [c for c in criteria if c not in valid_criteria] + if invalid_criteria: + raise ValueError( + f"Invalid criteria: {invalid_criteria}. Valid criteria are: {valid_criteria}" + ) + + if verbose: + logger.info(f"Evaluation criteria: {', '.join(criteria)}") + + if verbose: + logger.info("Initializing LLM...") + llm = create_llm() + + if verbose: + logger.info("Creating evaluator agent...") + evaluator = EvaluatorAgent( + criteria=criteria, + llm=llm, + ) + + if verbose: + logger.info(f"Evaluating description for: {validated_image_path.name}") + evaluation = evaluator.evaluate( + description=description_text, + image=image, + ) + + if verbose: + logger.info("Evaluation completed successfully!") + logger.info(f"Number of criteria evaluated: {len(evaluation)}") + logger.info("-" * 50) + + for eval in evaluation: + print(f"Criterion: {eval.name}") + print(f"Score: {eval.score}/5") + if verbose: + print(f"Reasoning: {eval.reasoning}") + print("-" * 50) + + return None + + except FileNotFoundError as e: + raise FileNotFoundError(f"File not found: {e}") + except ValueError as e: + raise ValueError(f"Invalid input: {e}") + except Exception as e: + raise RuntimeError(f"Evaluation failed: {e}") + + def cli(): """ - CLI entrypoint for generating weather chart descriptions. + CLI entrypoint. """ - fire.Fire(main) + fire.Fire(CLI) if __name__ == "__main__": From 4063114dfadf44504f6f16c92a07bb54fd29090d Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 1 Jul 2025 18:15:43 -0400 Subject: [PATCH 050/151] feat: creating enum as registry for quality criteria --- src/earth_reach_agent/config/criteria.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/earth_reach_agent/config/criteria.py diff --git a/src/earth_reach_agent/config/criteria.py b/src/earth_reach_agent/config/criteria.py new file mode 100644 index 0000000..992ab2a --- /dev/null +++ b/src/earth_reach_agent/config/criteria.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class QualityCriteria(Enum): + COHERENCE = "coherence" + FLUENCY = "fluency" + CONSISTENCY = "consistency" + RELEVANCE = "relevance" + + @classmethod + def list(cls) -> list[str]: + return [criterion.value for criterion in cls] From b3f1c62bb6cde79dce4227b3b0a7f874f6ac627f Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 1 Jul 2025 18:16:10 -0400 Subject: [PATCH 051/151] feat: creating placeholder logic for limits acknowledgement --- src/earth_reach_agent/core/orchestrator.py | 29 ++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/earth_reach_agent/core/orchestrator.py b/src/earth_reach_agent/core/orchestrator.py index 0edd3da..3a6b3a0 100644 --- a/src/earth_reach_agent/core/orchestrator.py +++ b/src/earth_reach_agent/core/orchestrator.py @@ -4,6 +4,7 @@ import earthkit.plots as ekp from PIL.ImageFile import ImageFile +from earth_reach_agent.config.criteria import QualityCriteria from earth_reach_agent.core.evaluator import CriterionEvaluatorOutput, EvaluatorAgent from earth_reach_agent.core.generator import GeneratorAgent from earth_reach_agent.core.prompts.orchestrator import get_default_feedback_template @@ -35,6 +36,12 @@ def __init__( self.max_iterations = max_iterations self.criteria_threshold = criteria_threshold self.feedback_template = feedback_template or get_default_feedback_template() + self.criteria_limits_acknowledgment = { + QualityCriteria.COHERENCE: "Warning: The logical flow and organization of this description may be unclear.", + QualityCriteria.FLUENCY: "Warning: This description may contain linguistic issues, technical terminology errors, or unclear phrasing.", + QualityCriteria.CONSISTENCY: "Warning: This description may contain inaccuracies relative to the source chart or internal contradictions.", + QualityCriteria.RELEVANCE: "Warning: This description may not adequately emphasize the most meteorologically significant patterns.", + } def run( self, figure: ekp.Figure | None = None, image: ImageFile | None = None @@ -70,9 +77,14 @@ def run( self.provide_feedback_to_generator(i + 1, description, evaluation) logger.warning( - f"Maximum iterations ({self.max_iterations}) reached without passing evaluation. Stopping." + f"Maximum iterations ({self.max_iterations}) reached without passing evaluation. Acknowledging limits of description." ) - return None + + description = self.acknowledge_limits_of_description( + description, evaluation + ) + + return description except Exception as e: raise RuntimeError("Failed to generate a description") from e @@ -128,3 +140,16 @@ def provide_feedback_to_generator( ) self.generator_agent.append_user_prompt(feedback) + + def acknowledge_limits_of_description( + self, description: str, evaluation: List[CriterionEvaluatorOutput] + ) -> str: + """ + Add instructions to the GeneratorAgent for acknowledging the limits of the the description. + + Args: + description (str): The description generated by the GeneratorAgent + evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent + """ + # TODO: implement static acknowledgment of limits based on which criteria didn't pass the threshold + return description From ff87c46d838f89e1df8b6d951725acf659339863 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 1 Jul 2025 19:36:34 -0400 Subject: [PATCH 052/151] feat: implementing static acknowledgement of limits --- src/earth_reach_agent/core/orchestrator.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/earth_reach_agent/core/orchestrator.py b/src/earth_reach_agent/core/orchestrator.py index 3a6b3a0..76cdf50 100644 --- a/src/earth_reach_agent/core/orchestrator.py +++ b/src/earth_reach_agent/core/orchestrator.py @@ -4,7 +4,6 @@ import earthkit.plots as ekp from PIL.ImageFile import ImageFile -from earth_reach_agent.config.criteria import QualityCriteria from earth_reach_agent.core.evaluator import CriterionEvaluatorOutput, EvaluatorAgent from earth_reach_agent.core.generator import GeneratorAgent from earth_reach_agent.core.prompts.orchestrator import get_default_feedback_template @@ -37,10 +36,10 @@ def __init__( self.criteria_threshold = criteria_threshold self.feedback_template = feedback_template or get_default_feedback_template() self.criteria_limits_acknowledgment = { - QualityCriteria.COHERENCE: "Warning: The logical flow and organization of this description may be unclear.", - QualityCriteria.FLUENCY: "Warning: This description may contain linguistic issues, technical terminology errors, or unclear phrasing.", - QualityCriteria.CONSISTENCY: "Warning: This description may contain inaccuracies relative to the source chart or internal contradictions.", - QualityCriteria.RELEVANCE: "Warning: This description may not adequately emphasize the most meteorologically significant patterns.", + "coherence": "Warning: The logical flow and organization of this description may be unclear.", + "fluency": "Warning: This description may contain linguistic issues, technical terminology errors, or unclear phrasing.", + "consistency": "Warning: This description may contain inaccuracies relative to the source chart or internal contradictions.", + "relevance": "Warning: This description may not adequately emphasize the most meteorologically significant patterns.", } def run( @@ -145,11 +144,18 @@ def acknowledge_limits_of_description( self, description: str, evaluation: List[CriterionEvaluatorOutput] ) -> str: """ - Add instructions to the GeneratorAgent for acknowledging the limits of the the description. + Acknowledge the limits of the generated description based on evaluation results. Args: description (str): The description generated by the GeneratorAgent evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent + Returns: + str: The description with acknowledgment of its limits added """ - # TODO: implement static acknowledgment of limits based on which criteria didn't pass the threshold + acknowledgment = "\n" + for criterion in evaluation: + if criterion.score < self.criteria_threshold: + crit_ackn = self.criteria_limits_acknowledgment.get(criterion.name, "") + if crit_ackn: + acknowledgment += f"\n{crit_ackn}" return description From 09e30621f8043477f9f9e372fab7221c98218de5 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 1 Jul 2025 19:38:27 -0400 Subject: [PATCH 053/151] refact: removing deprecated main function --- src/earth_reach_agent/main.py | 91 ----------------------------------- 1 file changed, 91 deletions(-) diff --git a/src/earth_reach_agent/main.py b/src/earth_reach_agent/main.py index d287e76..7db9255 100644 --- a/src/earth_reach_agent/main.py +++ b/src/earth_reach_agent/main.py @@ -174,97 +174,6 @@ def validate_image_path(image_path: str) -> Path: return path -def main( - image_path: str, - system_prompt: str | None = None, - system_prompt_file_path: str | None = None, - user_prompt: str | None = None, - user_prompt_file_path: str | None = None, - verbose: bool = False, -) -> None: - """ - Generate a scientific description of a weather chart from an image. - - Args: - image_path (str): Path to the weather chart image (JPEG or PNG) - system_prompt (str | None): System prompt text (optional) - system_prompt_file_path (str | None): Path to system prompt file (optional) - user_prompt (str | None): User prompt text (optional) - user_prompt_file_path (str | None): Path to user prompt file (optional) - verbose (bool): Enable verbose output (optional) - - Returns: - None: Prints the generated weather description - - Raises: - FileNotFoundError: If image or prompt files don't exist - ValueError: If arguments are invalid or conflicting - RuntimeError: If description generation fails - """ - try: - if verbose: - logger.info(f"Validating image: {image_path}") - validated_image_path = validate_image_path(image_path) - image = Image.open(validated_image_path) - - if verbose: - logger.info("Resolving prompts...") - system_prompt_text = resolve_prompt( - system_prompt, - system_prompt_file_path, - None, - ) - user_prompt_text = resolve_prompt( - user_prompt, user_prompt_file_path, get_default_generator_user_prompt() - ) - if not user_prompt_text: - raise ValueError( - "User prompt cannot be empty. Please provide a valid prompt." - ) - - if verbose: - if system_prompt_text: - logger.info( - f"System prompt length: {len(system_prompt_text)} characters" - ) - - if user_prompt_text: - logger.info(f"User prompt length: {len(user_prompt_text)} characters") - - if verbose: - logger.info("Initializing LLM...") - llm = create_llm() - - if verbose: - logger.info("Creating generator agent...") - generator = GeneratorAgent( - llm=llm, system_prompt=system_prompt_text, user_prompt=user_prompt_text - ) - - if verbose: - logger.info(f"Generating description for: {validated_image_path.name}") - description = generator.generate(image=image) - - if verbose: - logger.info("Description generated successfully!") - logger.info(f"Description length: {len(description)} characters") - logger.info("-" * 50) - - print(description) - - return None - - except (FileNotFoundError, ValueError, IOError) as e: - logger.error(f"Could not load image file: {e}", exc_info=True) - sys.exit(1) - except RuntimeError as e: - logger.error(f"Generation failed: {e}", exc_info=True) - sys.exit(1) - except Exception as e: - logger.error(f"Unexpected error: {e}", exc_info=True) - sys.exit(1) - - class CLI: """ Command Line Interface for the Earth Reach Agent. From fcefda60381863003899fba86146ff76cb45afce Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Fri, 4 Jul 2025 16:07:34 -0400 Subject: [PATCH 054/151] feat: using system registry to get valid criteria --- src/earth_reach_agent/config/criteria.py | 3 ++- src/earth_reach_agent/main.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/earth_reach_agent/config/criteria.py b/src/earth_reach_agent/config/criteria.py index 992ab2a..d0aab28 100644 --- a/src/earth_reach_agent/config/criteria.py +++ b/src/earth_reach_agent/config/criteria.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import List class QualityCriteria(Enum): @@ -8,5 +9,5 @@ class QualityCriteria(Enum): RELEVANCE = "relevance" @classmethod - def list(cls) -> list[str]: + def list(cls) -> List[str]: return [criterion.value for criterion in cls] diff --git a/src/earth_reach_agent/main.py b/src/earth_reach_agent/main.py index 7db9255..9c769a6 100644 --- a/src/earth_reach_agent/main.py +++ b/src/earth_reach_agent/main.py @@ -13,6 +13,7 @@ from dotenv import load_dotenv from PIL import Image +from earth_reach_agent.config.criteria import QualityCriteria from earth_reach_agent.core.evaluator import EvaluatorAgent from earth_reach_agent.core.generator import GeneratorAgent from earth_reach_agent.core.llm import create_llm @@ -139,7 +140,7 @@ def get_valid_criteria() -> List[str]: Returns: List[str]: List of valid criteria names """ - return ["coherence", "fluency", "consistency", "relevance"] + return QualityCriteria.list() def validate_image_path(image_path: str) -> Path: From f148bcdffba68deaea9cd2a24eb66605d7eb0450 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Fri, 4 Jul 2025 16:46:09 -0400 Subject: [PATCH 055/151] fix: fixing evaluator parsing bug with wrong required fields --- src/earth_reach_agent/core/evaluator.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach_agent/core/evaluator.py index e50bde5..81c4283 100644 --- a/src/earth_reach_agent/core/evaluator.py +++ b/src/earth_reach_agent/core/evaluator.py @@ -1,3 +1,4 @@ +import logging import re from dataclasses import MISSING, dataclass, fields from io import BytesIO @@ -13,6 +14,8 @@ get_default_criterion_evaluator_user_prompt, ) +logger = logging.getLogger(__name__) + @dataclass class CriterionEvaluatorOutput: @@ -131,10 +134,17 @@ def parse_llm_response(self, response: str) -> CriterionEvaluatorOutput: except Exception as e: parsing_errors.append(f"Failed to parse field '{field_name}': {str(e)}") + logger.warning( + f"Parsing errors encountered: {parsing_errors}" + ) if parsing_errors else None + required_fields = [ f.name for f in dataclass_fields - if f.default == MISSING and f.default_factory == MISSING + if f.default == MISSING + and f.default_factory == MISSING + and f.name + != "name" # 'name' not required since it's set by object attribute ] missing_required = [f for f in required_fields if f not in extracted_values] @@ -143,9 +153,6 @@ def parse_llm_response(self, response: str) -> CriterionEvaluatorOutput: f"Missing required fields in XML response: {missing_required}" ) - if parsing_errors: - raise Exception(f"Parsing errors occurred: {'; '.join(parsing_errors)}") - try: return CriterionEvaluatorOutput(name=self.criterion, **extracted_values) except Exception as e: From 01439969299949f92677f0266d70f3e897742880 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 5 Jul 2025 15:39:50 -0400 Subject: [PATCH 056/151] refact: renaming main script to cli --- pyproject.toml | 4 ++-- src/earth_reach_agent/{main.py => cli.py} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename src/earth_reach_agent/{main.py => cli.py} (100%) diff --git a/pyproject.toml b/pyproject.toml index 0066924..ffdf422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,8 @@ dev = [ ] [project.scripts] -earth-reach-agent = "earth_reach_agent.main:cli" -era = "earth_reach_agent.main:cli" +earth-reach-agent = "earth_reach_agent.cli:cli" +era = "earth_reach_agent.cli:cli" [tool.hatch.build.targets.wheel] packages = ["src/earth_reach_agent"] diff --git a/src/earth_reach_agent/main.py b/src/earth_reach_agent/cli.py similarity index 100% rename from src/earth_reach_agent/main.py rename to src/earth_reach_agent/cli.py From 909335c3911509ed0076b8e339c562d5ee34d94c Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 5 Jul 2025 16:01:02 -0400 Subject: [PATCH 057/151] feat: adding orchestrator to CLI --- src/earth_reach_agent/cli.py | 45 ++++++++++++++++++++-- src/earth_reach_agent/core/orchestrator.py | 6 ++- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/earth_reach_agent/cli.py b/src/earth_reach_agent/cli.py index 9c769a6..25ffdec 100644 --- a/src/earth_reach_agent/cli.py +++ b/src/earth_reach_agent/cli.py @@ -17,6 +17,7 @@ from earth_reach_agent.core.evaluator import EvaluatorAgent from earth_reach_agent.core.generator import GeneratorAgent from earth_reach_agent.core.llm import create_llm +from earth_reach_agent.core.orchestrator import Orchestrator from earth_reach_agent.core.prompts.generator import get_default_generator_user_prompt logging.basicConfig( @@ -188,6 +189,9 @@ def generate( system_prompt_file_path: str | None = None, user_prompt: str | None = None, user_prompt_file_path: str | None = None, + simple: bool = False, + max_iterations: int = 3, + criteria_threshold: int = 4, verbose: bool = False, ) -> None: """ @@ -199,6 +203,9 @@ def generate( system_prompt_file_path (str | None): Path to system prompt file (optional) user_prompt (str | None): User prompt text (optional) user_prompt_file_path (str | None): Path to user prompt file (optional) + simple (bool): Skip orchestrator to only use generator (optional) + max_iterations (int): Orchestrator maximum iterations for description generation (default: 3) + criteria_threshold (int): Minimum score for evaluation criteria to pass (default: 4 verbose (bool): Enable verbose output (optional) Returns: @@ -217,6 +224,7 @@ def generate( if verbose: logger.info("Resolving prompts...") + system_prompt_text = resolve_prompt( system_prompt, system_prompt_file_path, @@ -247,13 +255,37 @@ def generate( if verbose: logger.info("Creating generator agent...") + generator = GeneratorAgent( llm=llm, system_prompt=system_prompt_text, user_prompt=user_prompt_text ) + if not simple: + if verbose: + logger.info("Creating evaluator agent...") + + evaluator = EvaluatorAgent( + criteria=QualityCriteria.list(), + llm=llm, + ) + + if verbose: + logger.info("Creating orchestrator...") + + orchestrator = Orchestrator( + generator_agent=generator, + evaluator_agent=evaluator, + max_iterations=max_iterations, + criteria_threshold=criteria_threshold, + ) + if verbose: logger.info(f"Generating description for: {validated_image_path.name}") - description = generator.generate(image=image) + + if simple: + description = generator.generate(image=image) + else: + description = orchestrator.run(image=image) if verbose: logger.info("Description generated successfully!") @@ -303,11 +335,13 @@ def evaluate( try: if verbose: logger.info(f"Validating image: {image_path}") + validated_image_path = validate_image_path(image_path) image = Image.open(validated_image_path) if verbose: logger.info("Resolving description...") + description_text = resolve_description( description, description_file_path, @@ -366,11 +400,14 @@ def evaluate( return None except FileNotFoundError as e: - raise FileNotFoundError(f"File not found: {e}") + logger.error(f"File not found: {e}", exc_info=True) + sys.exit(1) except ValueError as e: - raise ValueError(f"Invalid input: {e}") + logger.error(f"Invalid input: {e}", exc_info=True) + sys.exit(1) except Exception as e: - raise RuntimeError(f"Evaluation failed: {e}") + logger.error(f"Evaluation failed: {e}", exc_info=True) + sys.exit(1) def cli(): diff --git a/src/earth_reach_agent/core/orchestrator.py b/src/earth_reach_agent/core/orchestrator.py index 76cdf50..096325d 100644 --- a/src/earth_reach_agent/core/orchestrator.py +++ b/src/earth_reach_agent/core/orchestrator.py @@ -29,6 +29,8 @@ def __init__( generator_agent: Instance of GeneratorAgent for generating descriptions evaluator_agent: Instance of EvaluatorAgent for evaluating descriptions max_iterations: Maximum number of iterations for generating descriptions + criteria_threshold: Minimum score for evaluation criteria to pass + feedback_template: Template for feedback to the generator agent (optional) """ self.generator_agent = generator_agent self.evaluator_agent = evaluator_agent @@ -44,7 +46,7 @@ def __init__( def run( self, figure: ekp.Figure | None = None, image: ImageFile | None = None - ) -> str | None: + ) -> str: """Run the iterative process of generating and evaluating a weather chart description until quality criteria are met. @@ -53,7 +55,7 @@ def run( image (ImageFile | None): Optional image to include in the request (will be converted to base64). Can't be used with figure. Returns: - str | None: The final weather description if successful, None if max iterations reached without success + str: The final weather description. """ if figure is not None and image is not None: raise ValueError( From 8e609c8db7c54c0edc04ce5b54df97bc6c45eb7e Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 5 Jul 2025 16:23:29 -0400 Subject: [PATCH 058/151] doc: improving setup and usage readme instructions --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2b827e2..532c646 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,64 @@ EarthReach is a challenge from the 2025 edition dedicated to enhancing the acces ## Installation -### VLLM Inference Server +### Prerequisites + +- Python 3.12 or higher +- [uv](https://docs.astral.sh/uv/) - Python package and project manager + +### Setup + +1. **Clone the repository** + ```sh + git clone https://github.com/ECMWFCode4Earth/EarthReach.git + cd EarthReach + ``` +2. **Create a virtual environment and install dependencies** + ```sh + uv sync + ``` +This command will automatically: +- Create a .venv virtual environment +- Install all project dependencies from pyproject.toml + +3. **Activate the virtual environment** + ```sh + source .venv/bin/activate # On Windows, use: .venv\Scripts\activate + ``` + +You're now ready to use the project. + +## VLLM Inference Server To run this project, you will need to have an openAI-compatible LLM inference server. -We provide instructions on how to set your own secured inference server using [VLLM](./vllm/setup.md). +We provide instructions on how to setup your own secured inference server using [VLLM](./vllm/setup.md). + +## Usage + +The project provides a command-line interface (CLI) accessible through `earth-reach-agent` or its shorter alias `era`. + +### Available Commands + +View all commands and options: +```sh +uv run era --help +``` + +### Generate weather chart descriptions + +Generate a natural language description from a weather chart image: +```sh +uv run era generate --image-path +``` + +### Evaluate descriptions + +Evaluate the accuracy of a description against a weather chart: +```sh +uv run era evaluate --image-path --description "" +``` ## License See [LICENSE](LICENSE) From f9814c34e61f340b818ce1409f999d37823dd446 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 5 Jul 2025 16:29:06 -0400 Subject: [PATCH 059/151] doc: adding project structure section to readme --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 532c646..e126fad 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,20 @@ This command will automatically: You're now ready to use the project. +## Project Structure + +```sh +. +├── docs/ # Project documentation +├── notebooks/ # Tutorials & experiments +├── src/ +│ ├── earth_reach_agent/ # Main package +│ └── tests/ # Unit and integration tests (to come) +├── vllm/ # VLLM inference server setup +├── pyproject.toml # Project dependencies and metadata +└── uv.lock # Locked dependency versions +``` + ## VLLM Inference Server To run this project, you will need to have an openAI-compatible LLM inference server. From e81825f56bf0630ef5812cc8c9d182a8d21f5a92 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 27 Jul 2025 22:48:59 -0400 Subject: [PATCH 060/151] feat: implement generator prompt improvements from labeled descriptions analysis --- src/earth_reach_agent/core/generator.py | 2 + .../core/prompts/generator.py | 480 +++++++++++++----- 2 files changed, 347 insertions(+), 135 deletions(-) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index a4860d9..a1b2ee9 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -51,6 +51,8 @@ class GeneratorOutput: step_2: str | None = None step_3: str | None = None step_4: str | None = None + step_5: str | None = None + step_6: str | None = None final_description: str | None = None def is_complete(self) -> bool: diff --git a/src/earth_reach_agent/core/prompts/generator.py b/src/earth_reach_agent/core/prompts/generator.py index 61850b4..2372954 100644 --- a/src/earth_reach_agent/core/prompts/generator.py +++ b/src/earth_reach_agent/core/prompts/generator.py @@ -1,4 +1,4 @@ -DEFAULT_GENERATOR_USER_PROMPT = """# Enhanced Weather Chart Alt-Text Generation System +DEFAULT_GENERATOR_USER_PROMPT = """# Weather Chart Alt-Text Generation System ## ROLE AND CONTEXT SETTING @@ -12,155 +12,365 @@ - Teaching and educational materials - Operational weather forecasting decisions -## TASK DECOMPOSITION - -### Step 1: Data Extraction and Analysis (200-500 words) -**Objective**: Systematically catalog all quantitative information and meteorological features - -**Actions**: -1. Identify and record the exact value ranges for each variable (temperature, pressure, wind speed, etc.) -2. Note the geographic boundaries (latitude/longitude ranges), map projection, and spatial domain (e.g., "North America", "Europe", "Spain", "Global") -3. List all meteorological systems visible (highs, lows, fronts, convergence zones) -4. Document the time period, date, and any temporal information -5. Record the data source, resolution, and any technical specifications shown - -**Success Check**: Can you list specific numbers for every major feature without using vague terms like "high" or "low"? - -### Step 2: Pattern Recognition and Spatial Analysis (200-500 words) -**Objective**: Identify the dominant meteorological patterns and their spatial relationships - -**Actions**: -1. Determine the primary weather systems and rank them by meteorological significance -2. Map the directional flow of gradients (temperature, pressure) using cardinal directions -3. Identify spatial correlations between different variables -4. Note any unusual or anomalous features that deviate from expected patterns -5. Assess the seasonal/temporal context and typical vs. atypical conditions - -**Success Check**: Can you explain how each major system influences the others spatially? - -### Step 3: Description Planning and Structure Design (100-150 words) -**Objective**: Create a hierarchical outline that prioritizes information by scientific importance - -**Actions**: -1. Rank meteorological features by their significance for understanding weather patterns -2. Plan the logical flow from overview to specific details -3. Determine which quantitative values are essential vs. supplementary -4. Identify the most effective way to convey spatial relationships -5. Plan transitions between sections to maintain scientific coherence - -**Success Check**: Would a meteorologist reading your outline understand the complete weather situation? - -### Step 4: Description Writing with Verification (300-500 words) -**Objective**: Produce the final description following all specifications - -**Actions**: -1. Write a concise overview (2-3 sentences) summarizing the visualization type, geographic region, time period, and primary variables -2. Develop the main body with 4-6 sentences, covering: - - Dominant systems and their characteristics - - Variable distributions (temperature, pressure, wind) - - Spatial relationships and correlations - - Notable features or anomalies -3. Ensure all quantitative values include specific ranges and units -4. Use precise meteorological terminology and avoid visual-dependent language - -**Success Check**: Does your description pass all items in the quality verification checklist? - -## CONCRETE CONSTRAINTS AND SPECIFICATIONS - -### Length and Structure Requirements -- **Total description length**: 300-500 words maximum -- **Overview section**: Exactly 2-3 sentences -- **Main body**: 4-6 sentences - -### Quantitative Requirements -- **Include exact ranges**: Every variable must have minimum and maximum values with units -- **Spatial precision**: Use cardinal directions and geographic references -- **Coordinate references**: Include latitude/longitude for major features (±2 degrees acceptable) -- **Unit consistency**: Use standard meteorological units (hPa, m/s, K or °C, km) - -### Language Specifications -- **Terminology level**: Graduate-level meteorological vocabulary (assume PhD-level audience) -- **Sentence structure**: Maximum 25 words per sentence -- **Accessibility compliance**: Zero visual-dependent phrases (see prohibited terms list below) -- **Objectivity standard**: Separate observations from interpretations using phrases like "The data shows..." vs. "This suggests..." - -## ERROR PREVENTION GUIDELINES - -### Common Pitfalls to Avoid -1. **Vague descriptions**: Never use "high," "low," "warm," "cold" without specific values -2. **Visual dependency**: Prohibited phrases include: "as shown," "visible," "looking at," "clearly," "obviously" -3. **Geographic ambiguity**: Avoid "here," "there," "this area" - use specific location references -4. **Unit omissions**: Every quantitative statement must include appropriate units -5. **Pattern assumptions**: Don't assume readers can visualize spatial relationships - describe explicitly - -## SUCCESS CRITERIA - -### Objective Standards -1. **Completeness Test**: A meteorologist should be able to sketch the general pattern from your description -2. **Quantitative Accuracy**: All numerical values within ±5% of actual data -3. **Accessibility Compliance**: 100% of visual information conveyed through text -4. **Scientific Utility**: Description enables the same analytical conclusions as the visual -5. **Terminology Precision**: All meteorological terms used correctly per AMS Glossary standards - -## EXAMPLES AND REFERENCE PATTERNS - -Not yet available, but will be provided in the future. - -## OUTPUT FORMAT REQUIREMENTS - -### XML Tag Structure -**CRITICAL**: You must wrap the content of each step and the final description in specific XML tags for programmatic parsing. - -**Required XML Tags:** -- `...` - Wrap the entire content of Step 1 (excluding the markdown header) -- `...` - Wrap the entire content of Step 2 (excluding the markdown header) -- `...` - Wrap the entire content of Step 3 (excluding the markdown header) -- `...` - Wrap the entire content of Step 4 (excluding the markdown header) -- `...` - Wrap only the final consolidated description (without headers or meta-commentary) - -### Formatting Rules -1. **Tag Placement**: Place opening and closing tags on their own lines -2. **Content Scope**: Include all step content within tags, but exclude markdown headers (## Step X) -3. **Final Description**: The `` should contain ONLY the final weather description without any meta-commentary about word count or requirements -4. **No Tag Omission**: All five XML tag pairs must be present, even if a step is brief - -### Example Structure: -``` -## Step 1: Data Extraction and Analysis +## METEOROLOGICAL REFERENCE GUIDE + +### Core Atmospheric Circulation Patterns +- **Three-Cell Model**: + - Hadley Cell (0-30°): Rising air at equator, sinking at subtropics + - Ferrel Cell (30-60°): Surface westerlies, opposite of Hadley + - Polar Cell (60-90°): Cold sinking air at poles, surface easterlies + +### Seasonal Pattern Expectations +- **Boreal Winter (DJF)**: Strong Aleutian/Icelandic lows, intense Siberian high, equatorward-shifted jet streams +- **Boreal Summer (JJA)**: Weakened polar lows, strengthened/northward subtropical highs, monsoon patterns +- **Transition Seasons**: Rapid pattern changes, increased variability + +### Pressure-Weather Relationships +- **High Pressure**: Subsidence → clear skies, light winds, stable conditions +- **Low Pressure**: Convergence → clouds, precipitation, stronger winds +- **Pressure Gradients**: Tight spacing = strong winds (>5 hPa/100km = significant) + +### Extreme Value Thresholds +- **Exceptional High**: >1040 hPa (especially >1050 hPa) +- **Deep Low**: <980 hPa (hurricane-strength if <960 hPa) +- **Strong Temperature Gradient**: >10°C/500 km (likely frontal zone) + +### Geostrophic Wind Principles +- **Northern Hemisphere**: Wind flows parallel to isobars, low pressure to the left +- **Southern Hemisphere**: Wind flows parallel to isobars, low pressure to the right +- **Wind Speed**: Proportional to pressure gradient (tighter isobars = stronger wind) + +## SIX-STEP ANALYTICAL PROCESS + +**IMPORTANT**: Steps 1-5 are your analytical working notes. These are your "thinking space" and do NOT count toward the 500-word final description. Only Step 6 produces the final description for the user. + +**Working Notes Format**: Steps 1-5 should contain abbreviated analytical notes showing your thinking process. Use specific values, coordinates, and observations. These notes document your analysis but are NOT included in the final description. + +### Step 1: Pure Data Extraction (200-300 words of working notes) +**Objective**: Observe and record all quantitative information without interpretation + +**Extraction Requirements**: +- **Domain**: Record exact lat/lon boundaries (check all edges - if it's global domain, it's 90°S to 90°N, 180°W to 180°E) +- **Time**: Date, hour, timezone/UTC +- **Variables Present**: List each with complete specifications + +**How to Extract Systematically**: +1. Start at edges, work inward - note domain limits +2. Scan temperature colorbar/legend - record full range AND intervals if applicable +3. Identify isobar contour labels - note values and spacing +4. List visible geographic features for later verification + +**Specific Items to Record**: +- [ ] Temperature: Range, color mapping, contour interval +- [ ] Pressure: Range, contour interval, specific labeled values +- [ ] Geographic features: Continents, major water bodies visible +- [ ] Grid specifications: Lat/lon line intervals + +**Example extraction**: "Temperature colorbar shows -40°C (deep purple) through 40°C (deep red) with approximately 2-3°C color gradations. Pressure contours visible from 1004 hPa to 1032 hPa, labeled at 4 hPa intervals (1004, 1008, 1012...)." + +### Step 2: Spatial Accuracy Verification (100-150 words of working notes) +**Objective**: Verify all spatial information before interpretation + +**How to Verify Spatial Accuracy**: +1. **Pressure Centers**: Find closed contours → locate center → check against geography + - Is that "Mediterranean high" actually over the Mediterranean? +2. **Cross-Reference Method**: Use coastlines and geographic features as anchors + - If a low appears "over UK", verify it's at ~52°N, 0°E +3. **Domain Check**: Confirm complete coverage claimed matches visible data + +**Verification Checklist**: +- [ ] Each pressure center location checked against geography +- [ ] Domain boundaries confirmed (no truncation) +- [ ] Coordinate system consistent throughout +- [ ] No confusion between coastlines and contours + +**Red Flags**: Features over wrong geography, impossible coordinates, domain mismatch + +### Step 3: Multi-Scale Pattern Recognition (200-300 words of working notes) +**Objective**: Identify all meteorological features from global to local scales + +**How to Recognize Patterns Systematically**: +1. **Global Scale First** (if applicable): + - Count major highs and lows + - Note latitude bands of temperature + - Identify any planetary wave patterns + +2. **Regional Scale**: + - Look for temperature gradients >5°C/500km + - Identify regional pressure systems + - Note areas of tight pressure gradients + - **For global charts**: Focus analysis on key populated regions (North America, Europe, East Asia, South America, Southern Africa, Australia) + +3. **Local Features**: + - Terrain influences (if visible) + - Isolated maxima/minima + - Mesoscale circulations + +**How to Rank Meteorological Significance**: +1. **Intensity**: Compare to normal values for location/season +2. **Size**: Larger systems generally more significant +3. **Location**: Systems in unusual positions are notable +4. **Gradients**: Strong gradients indicate active weather + +**Pattern List Format**: "Primary: 1040 hPa high at 45°N, 10°E (exceptional intensity). Secondary: Temperature gradient 15°C/1000km from 40°N to 50°N (potential frontal zone)..." + +### Step 4: Theoretical Validation (150-200 words of working notes) +**Objective**: Validate identified patterns against meteorological theory + +**How to Validate Against Theory**: +1. **Seasonal Check**: + - Is this pattern expected for the date/location? + - Example: "March Siberian high weakening - consistent with spring transition" + +2. **Circulation Consistency**: + - Do patterns align with three-cell model? + - Example: "Subtropical high at 30°N matches Hadley cell subsidence" + +3. **Physical Relationships**: + - Temperature-pressure coupling logical? + - Gradient strengths realistic? + +**Validation Framework**: +- [ ] Major patterns consistent with season? +- [ ] Pressure systems in climatologically reasonable locations? +- [ ] Temperature patterns support pressure analysis? +- [ ] Any features requiring special explanation? + +**How to Note Anomalies**: "The 1052 hPa high is ~12 hPa above normal for this location/date, suggesting exceptional subsidence..." + +### Step 5: Description Architecture Planning (100-150 words of working notes) +**Objective**: Design the structure for the final description + +**Planning Components**: +1. **Information Hierarchy**: List features in order of importance +2. **Scale Integration Strategy**: How to connect global → regional → local +3. **Regional Coverage Plan**: + - Geographic progression (W→E? N→S?) + - For global charts: Ensure coverage of priority regions +4. **Dynamic Process Points**: Where to add circulation/weather implications + +**How to Plan Transitions**: +- Global to regional: "This pattern manifests regionally as..." +- Static to dynamic: "This high pressure system drives..." +- Observed to inferred: "Based on the pressure gradient, inferred winds..." + +**Structural Outline Example**: +1. Context with verified domain +2. Lead with exceptional 1052 hPa high +3. Connect to hemispheric temperature pattern +4. Regional breakdown: Europe → Asia → Americas +5. Synthesis of cold outbreak significance + +### Step 6: Final Scientific Description (450-500 words output) +**Objective**: Synthesize all analysis into a comprehensive description + +**Required Components**: + +**1. Context Paragraph** (60-80 words): +"This weather chart displays [variables] over [verified domain], spanning [exact lat] to [lat] and [exact lon] to [lon] for [date, time, season]. Temperature ranges from [min]°C ([color]) to [max]°C ([color]) with [interval]°C gradations. Pressure contours span [min] to [max] hPa at [interval] hPa intervals." + +**2. Primary Pattern Analysis** (100-150 words): +- Most significant feature with coordinates +- Theoretical validation statement +- Dynamic implications (with inference notation) + +**3. Multi-Scale Integration** (100-150 words): +- Explicit scale connections +- How patterns interact across scales +- Circulation patterns (noted as inferred) + +**4. Regional Analysis** (100-150 words): +- Systematic geographic coverage +- For global charts: Prioritize North America, Europe, East Asia, South America, Southern Africa, and Australia +- For each region provide: + * Dominant pressure system and value + * Temperature range with specific values + * Inferred weather conditions + * Connection to larger-scale patterns +- Integrated temperature-pressure relationships + +**5. Synthesis** (30-50 words): +- Primary significance +- Notable anomalies +- Key inferences acknowledged + +## COMPLETE EXAMPLE: GLOBAL WEATHER PATTERN ANALYSIS + +### Step 1: Pure Data Extraction -The chart displays 2-meter temperature and mean sea level pressure... -[rest of step 1 content] +Chart type: Global weather map showing 2-meter temperature (2t) and mean sea level pressure (mslp) +Domain: 90°S to 90°N, 180°W to 180°E (full global coverage) +Date/Time: March 12, 2011, 12:00 UTC + +Temperature data: +- Color range: -40°C to +40°C +- Blue-green colors = negative temps, yellow-orange-red = positive temps +- Color increments: ~2°C gradations + +Pressure data: +- Contour range: ~970 hPa to 1028 hPa visible +- Contour interval: 4 hPa +- Labeled values seen: 976, 980, 984, 988, 992, 996, 1000, 1004, 1008, 1012, 1016, 1020, 1024, 1028 + +Geographic features: All continents visible, major islands clear (Greenland, Madagascar, Japan, etc.) +Grid: 30° lat/lon intervals -## Step 2: Pattern Recognition and Spatial Analysis +### Step 2: Spatial Accuracy Verification -The dominant feature is a high-pressure system... -[rest of step 2 content] +High pressure centers verified: +- Canada: ~1020 hPa at ~55°N, 100°W ✓ +- Central Europe: ~1024 hPa at ~50°N, 20°E ✓ +- Siberia: strong high ~55°N, 90°E ✓ +- South Atlantic: ~1020 hPa at ~35°S, 10°W ✓ + +Low pressure centers: +- Eastern U.S.: ~996 hPa at ~40°N, 75°W ✓ +- Scandinavia: ~1000 hPa at ~65°N, 15°E ✓ +- East of Japan: ~996 hPa at ~40°N, 170°E ✓ +- Southern Ocean: multiple 976-984 hPa centers between 50-70°S ✓ + +All features align with continental positions - no errors detected. -## Step 3: Description Planning and Structure Design +### Step 3: Multi-Scale Pattern Recognition -To structure the description, we will start with an overview... -[rest of step 3 content] +Global patterns: +- Three-cell circulation evident +- Subtropical highs ~30° both hemispheres +- Strong circumpolar low belt 50-70°S +- Equatorial warm zone continuous +- Polar cooling both ends + +Regional priorities (for populated areas): +NORTH AMERICA: Cold high over Canada (1020 hPa), deep low eastern U.S. (996 hPa) +EUROPE: Blocking high Central Europe (1024 hPa), Scandinavian low (1000 hPa) +EAST ASIA: Siberian high influence north, maritime south, Japan low (996 hPa) +SOUTH AMERICA: Frontal zone 40-55°S between polar low and subtropical ridge +SOUTHERN AFRICA: South Atlantic High (1020 hPa) dominance +AUSTRALIA: Low south of Tasmania (~980 hPa), high to north (1016 hPa) + +Temperature patterns: +- Max temps 35-40°C: Sahara, Arabia, northern India +- Min temps -40°C: Antarctica, -30°C Arctic +- Strong gradients at frontal boundaries -## Step 4: Description Writing with Verification +### Step 4: Theoretical Validation -### Overview -This weather chart displays... -### Main Body -A high-pressure system is centered... -[rest of step 4 content] +March 12 = late NH winter, late SH summer + +Seasonal checks: +- Siberian high present but weakening for March ✓ +- Antarctic lows strengthening toward winter ✓ +- ITCZ slightly north of equator ✓ + +Three-cell model: +- Subtropical highs at ~30° = Hadley subsidence ✓ +- Mid-lat storms ~60° = polar front ✓ +- Warm equator, cold poles ✓ + +Physical consistency: +- Cold air with high pressure (Canada, Siberia) ✓ +- Warm subtropical highs ✓ +- Temperature gradients match frontal zones ✓ + +All patterns consistent with late winter/early spring NH transition. +### Step 5: Description Architecture Planning + +Priority order: +1. Context paragraph with full specs +2. Global three-cell pattern confirmation +3. Regional analysis in order: + - North America (cold high vs eastern low) + - Europe (blocking pattern) + - East Asia (continental vs maritime) + - South America (frontal zone) + - Southern Africa (subtropical high) + - Australia (frontal progression) +4. Seasonal transition synthesis + +Dynamic elements to include: +- High pressure → subsidence → clear/cold or clear/warm +- Low pressure → convergence → clouds/precip +- Gradients → winds (inferred) + +Keep regional temps specific, pressure values exact. + + +### Step 6: Final Scientific Description + -This weather chart visualization shows 2-meter temperature and mean sea level pressure over Western Europe on March 12, 2011, at 12:00 UTC. The region covers latitudes from 40.5°N to 51°N and longitudes from 2.5°W to 10°E. A high-pressure system is centered at 45°N, 7.5°E with a pressure of 102080 Pa. The 2-meter temperature varies from 280 K in the north to 294 K in the southeast, showing a significant north-south temperature gradient. The pressure distribution is dominated by this high-pressure system, with pressure decreasing towards the northwest. The highest temperatures are located southeast of the high-pressure center. - -``` +This global weather chart displays 2-meter temperature and mean sea level pressure for March 12, 2011, at 12:00 UTC, representing late boreal winter conditions. The domain spans 90°S to 90°N and 180°W to 180°E. Temperature ranges from -40°C (deep blue) to +40°C (deep red) with 2°C color increments. Pressure contours extend from 970 to 1028 hPa at 4 hPa intervals. + +The global pressure pattern validates the three-cell circulation model, with subtropical highs positioned near 30° latitude and subpolar lows around 60° in both hemispheres. This late-season configuration shows weakening Northern Hemisphere winter systems contrasting with strengthening Southern Hemisphere features approaching austral winter. + +In North America, a cold high-pressure system (1020 hPa) centered at 55°N, 100°W dominates Canada, maintaining temperatures between -15°C and -40°C through radiational cooling under clear skies (inferred from high pressure). A strong low-pressure system (996 hPa) over the eastern United States at 40°N, 75°W draws warmer, moist Atlantic air northward, creating temperatures of 0°C to 15°C in the southern states. This configuration produces a sharp frontal zone with likely precipitation (inferred from pressure contrast). + +Europe experiences contrasting conditions split by a blocking high-pressure system (1024 hPa) over Central Europe. This high promotes stable conditions with mild temperatures of 10°C to 20°C, particularly across southern regions benefiting from increased March insolation. Conversely, a low-pressure system (1000 hPa) over Scandinavia maintains colder temperatures of -10°C to 5°C with potentially unsettled weather (inferred from low pressure). -**IMPORTANT**: Ensure all XML tags are properly opened and closed. Malformed XML will cause parsing failures. +East Asia shows typical late-winter continental-maritime contrasts. The Siberian high maintains cold conditions (-25°C to 5°C) across northern regions, while southern and eastern China experience warmer temperatures (15°C to 35°C) under high pressure with maritime influence. A developing low-pressure system (996 hPa) positioned at 40°N, 170°E east of Japan indicates active cyclogenesis with strong winds likely (inferred from tight pressure gradients). -**Remember**: Your description is not just text, it's a scientific instrument that must preserve the analytical power of the original visualization while being fully accessible to blind scientists. +South America's weather reflects the transition between tropical and polar influences. A strong circumpolar low south of Patagonia and a subtropical ridge over central Argentina create intense pressure gradients driving westerly winds (inferred from pressure pattern). Southern Chile and Patagonia experience cool temperatures (0°C to 8°C) with likely frontal precipitation, while Buenos Aires remains warm (20°C to 25°C) under stable subtropical conditions. + +Southern Africa sits under the South Atlantic subtropical high (1020 hPa) centered at 35°S, 10°W, bringing warm (25°C to 35°C), dry, stable late-summer conditions across the region, particularly maintaining aridity over the western coastal deserts. + +Australia displays typical autumn transition patterns with a low-pressure system (980 hPa) south of Tasmania and high pressure (1016 hPa) to the north. Western regions remain warm and dry (20°C to 30°C), while the southeastern coast experiences cooler temperatures (15°C to 25°C) with approaching frontal systems bringing cloud and potential precipitation (inferred from pressure configuration). + +This chart captures the global atmosphere during seasonal transition, with each hemisphere displaying characteristic late-season patterns modified by regional geography and ocean-continent thermal contrasts. + + + +## CRITICAL CONSTRAINTS AND SPECIFICATIONS + +### Process vs. Product Distinction +- **Steps 1-5**: Analytical working notes (not included in final description, should be as long as needed) +- **Step 6 only**: Produces the 450-500 word final description +- **Working notes**: Use as much space and words as needed for thorough analysis + +### Quantitative Precision Requirements +- **Spatial accuracy**: All coordinates within ±2° tolerance +- **Systematic frameworks**: Always include intervals, not just ranges in the context paragraph +- **Verification**: Explicitly confirm or correct spatial accuracy in Step 2 +- **Complete specifications**: Every variable with range, units, AND structure + +### Language and Inference Requirements +- **Maximum sentence length**: 25 words +- **Inference notation**: Always mark inferred features (e.g., "inferred from pressure gradients") +- **Prohibited phrases**: "as shown," "visible," "looking at," "clearly," "obviously" +- **Required distinctions**: Observed data vs. theoretical inferences + +### Common Errors to Prevent +1. **Spatial misplacement**: Always verify coordinates in Step 2 +2. **Domain truncation**: Global = 90°S to 90°N, not 60°S to 60°N +3. **False precision**: Don't claim to observe unmarked fronts or air masses +4. **Scale isolation**: Always connect local to regional to global +5. **Static description**: Transform observations into dynamic processes +6. **Generic statements**: Ban "complex pressure systems" without specifics + +## SUCCESS VERIFICATION + +Your analysis succeeds when a blind meteorologist can: +1. Locate all major features within 2° accuracy +2. Understand the theoretical framework validating the patterns +3. Grasp multi-scale interactions and their significance +4. Make informed weather predictions from your description +5. Distinguish clearly between observed and inferred information + +## XML OUTPUT FORMAT + +**Required XML Tags** (place on separate lines): +- `...` - Data extraction notes +- `...` - Verification notes +- `...` - Pattern recognition notes +- `...` - Validation notes +- `...` - Planning notes +- `...` - Contains the final description writing process +- `...` - ONLY the final 450-500 word description + +Remember: You are creating a complete analytical instrument that preserves the full scientific power of visual weather analysis for blind scientists. Precision and systematic methodology are essential for research quality and operational safety. """ From e7e99d4e099df87d5902d6bac0e4e5771bf7be6e Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 27 Jul 2025 23:18:22 -0400 Subject: [PATCH 061/151] feat: adding google gemini as supported LLM provider --- pyproject.toml | 3 +- src/earth_reach_agent/core/generator.py | 10 +- src/earth_reach_agent/core/llm.py | 168 +++++++++++++++++++++--- src/earth_reach_agent/core/utils.py | 18 +++ uv.lock | 117 +++++++++++++++++ 5 files changed, 293 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ffdf422..3e0d9b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,8 @@ dependencies = [ "openai>=1.77.0", "python-dotenv>=1.1.0", "hatchling>=1.27.0", - "fire @ git+https://github.com/google/python-fire.git@2025-03-23-ipython", # Fixes IPython v9.0 compatibility issue + "fire @ git+https://github.com/google/python-fire.git@2025-03-23-ipython", # Fixes IPython v9.0 compatibility issue + "google-genai>=1.27.0", ] [dependency-groups] diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index a1b2ee9..3886e21 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -7,7 +7,7 @@ from PIL import Image from PIL.ImageFile import ImageFile -from earth_reach_agent.core.llm import BaseLLM +from earth_reach_agent.core.llm import LLMInterface @dataclass @@ -111,13 +111,13 @@ class GeneratorAgent: """GeneratorAgent class for generating weather charts scientific descriptions.""" def __init__( - self, llm: BaseLLM, system_prompt: str | None, user_prompt: str + self, llm: LLMInterface, system_prompt: str | None, user_prompt: str ) -> None: """ - Initialize the GeneratorAgent with a BaseLLM instance and prompts. + Initialize the GeneratorAgent with a LLMInterface instance and prompts. Args: - llm (BaseLLM): An instance of a BaseLLM to handle LLM interactions. + llm (LLMInterface): An instance of a LLMInterface to handle LLM interactions. system_prompt (str | None): Optional system prompt to guide the style of the LLM. user_prompt (str): The user prompt containing the instructions for description generation. """ @@ -148,7 +148,7 @@ def generate( "Only one of 'figure' or 'image' can be provided, not both." ) if figure is not None: - # TODO(medium): If metadata extraction failes, continue without it + # TODO(medium): If metadata extraction fails, continue without it metadata = self._get_metadata_from_figure(figure) self.user_prompt = self._update_user_prompt_with_metadata( self.user_prompt, metadata diff --git a/src/earth_reach_agent/core/llm.py b/src/earth_reach_agent/core/llm.py index a9a1e02..5aa5d75 100644 --- a/src/earth_reach_agent/core/llm.py +++ b/src/earth_reach_agent/core/llm.py @@ -1,12 +1,40 @@ import os +from abc import ABC, abstractmethod import openai +from google import genai +from google.genai import types -from earth_reach_agent.core.utils import img_to_base64 +from earth_reach_agent.core.utils import img_to_base64, img_to_bytes -class BaseLLM: - """Base class for a LLM interface.""" +class LLMInterface(ABC): + """Abstract base class defining the interface for all LLM provider implementations.""" + + @abstractmethod + def generate( + self, user_prompt: str, system_prompt: str | None = None, image=None + ) -> str: + """ + Generate a response from the LLM based on the user prompt and optional system prompt. + + Args: + user_prompt (str): The prompt provided by the user to define the task. + system_prompt (str | None): An optional system prompt to guide the model's response. + image: Optional image to include in the request. + + Returns: + str: The generated response content from the LLM. + + Raises: + ValueError: If user_prompt is empty/None or if the API response is empty. + RuntimeError: For other run-time errors. + """ + pass + + +class OpenAICompatibleLLM(LLMInterface): + """Base class for OpenAI-compatible LLM implementations (Groq, OpenAI, etc.).""" def __init__( self, @@ -105,8 +133,8 @@ def __repr__(self) -> str: return f"LLM(model_name={self.model_name}, base_url={self.base_url})" -class GroqLLM(BaseLLM): - """Implementation of the BaseLLM for Groq LLM API Provider.""" +class GroqLLM(OpenAICompatibleLLM): + """Implementation of the LLMInterface for Groq LLM API Provider.""" def __init__(self, model_name: str, api_key: str | None = None) -> None: """Initialize the Groq LLM with a model name and optional API key. @@ -135,8 +163,8 @@ def __init__(self, model_name: str, api_key: str | None = None) -> None: ) -class OpenAILLM(BaseLLM): - """Implementation of the BaseLLM for OpenAI API Provider.""" +class OpenAILLM(OpenAICompatibleLLM): + """Implementation of the LLMInterface for OpenAI API Provider.""" def __init__(self, model_name: str, api_key: str | None = None) -> None: """Initialize the OpenAI LLM with a model name and optional API key. @@ -163,7 +191,99 @@ def __init__(self, model_name: str, api_key: str | None = None) -> None: ) -def create_llm(provider="groq") -> BaseLLM: +class GeminiLLM(LLMInterface): + """Implementation of the LLMInterface for Google Gemini API Provider.""" + + def __init__(self, model_name: str, api_key: str | None = None) -> None: + """Initialize the Gemini LLM with a model name and optional API key. + + Args: + model_name (str): The name of the Gemini model to use. + api_key (str | None): The API key for authentication with the Gemini API. + + Raises: + AssertionError: If the API key is not provided and not found in environment variables. + """ + + if not api_key: + api_key = os.environ.get("GEMINI_API_KEY", None) + if not api_key: + raise AssertionError( + "GEMINI_API_KEY not set. Please set it in your environment variables, or pass it as an argument." + ) + + self.model_name = model_name + self.api_key = api_key + self.client = genai.Client(api_key=api_key) + + def generate( + self, user_prompt: str, system_prompt: str | None = None, image=None + ) -> str: + """ + Generate a response from the Gemini API based on the user prompt and optional system prompt. + + Args: + user_prompt (str): The prompt provided by the user to define the task. + system_prompt (str | None): An optional system prompt to guide the model's response. + image: Optional image to include in the request (PIL Image). + + Returns: + str: The generated response content from the Gemini API. + + Raises: + ValueError: If user_prompt is empty/None or if the API response is empty. + RuntimeError: For other run-time errors. + """ + + if not user_prompt or not user_prompt.strip(): + raise ValueError("user_prompt cannot be empty or None") + + try: + full_prompt = user_prompt.strip() + if system_prompt and system_prompt.strip(): + full_prompt = f"{system_prompt.strip()}\n\n{user_prompt.strip()}" + + contents = [] + if image: + image_bytes = img_to_bytes(image) + if not image_bytes: + raise ValueError("Failed to convert image to bytes") + + contents.append( + types.Part.from_bytes( + data=image_bytes, + mime_type="image/png", + ) + ) + contents.append(full_prompt) + + except Exception as e: + raise ValueError(f"Failed to process input data: {e}") + + try: + response = self.client.models.generate_content( + model=self.model_name, + contents=contents, + ) + + content = response.text + + if not content or not content.strip(): + raise ValueError("The generated response content is empty") + + return content.strip() + + except ValueError: + raise + except Exception as e: + error_msg = f"API call failed: {type(e).__name__}: {e}" + raise RuntimeError(error_msg) from e + + def __repr__(self) -> str: + return f"GeminiLLM(model_name={self.model_name})" + + +def create_llm(provider="groq", model_name: str | None = None) -> LLMInterface: """ Create and return LLM instance. @@ -172,23 +292,37 @@ def create_llm(provider="groq") -> BaseLLM: Args: provider (str): LLM provider name (default: "groq") + model_name (str | None): Specific model name to use (default: None, uses provider's default) Returns: - BaseLLM: Configured LLM instance + LLMInterface: Configured LLM instance """ if provider.lower() == "groq": api_key = os.getenv("GROQ_API_KEY") if not api_key: raise ValueError("GROQ_API_KEY environment variable is not set.") - return GroqLLM( - model_name="meta-llama/llama-4-maverick-17b-128e-instruct", api_key=api_key - ) + + if model_name is None: + model_name = "meta-llama/llama-4-maverick-17b-128e-instruct" + return GroqLLM(model_name=model_name, api_key=api_key) + elif provider.lower() == "openai": api_key = os.getenv("OPENAI_API_KEY") if not api_key: raise ValueError("OPENAI_API_KEY environment variable is not set.") - return OpenAILLM(model_name="o4-mini-2025-04-16", api_key=api_key) - else: - raise ValueError( - f"Unsupported LLM provider: {provider}. Supported providers: 'groq', 'openai'." - ) + if model_name is None: + model_name = "o4-mini-2025-04-16" + return OpenAILLM(model_name=model_name, api_key=api_key) + + elif provider.lower() == "gemini": + api_key = os.getenv("GEMINI_API_KEY") + if not api_key: + raise ValueError("GEMINI_API_KEY environment variable is not set.") + + if model_name is None: + model_name = "gemini-2.5-flash" + return GeminiLLM(model_name=model_name, api_key=api_key) + + raise ValueError( + f"Unsupported LLM provider: {provider}. Supported providers: 'groq', 'openai', 'gemini'." + ) diff --git a/src/earth_reach_agent/core/utils.py b/src/earth_reach_agent/core/utils.py index 98d1f95..021bf42 100644 --- a/src/earth_reach_agent/core/utils.py +++ b/src/earth_reach_agent/core/utils.py @@ -23,3 +23,21 @@ def img_to_base64(image_path: str | None = None, img=None) -> str: with open(image_path, "rb") as img_file: # type: ignore return base64.b64encode(img_file.read()).decode("utf-8") + + +def img_to_bytes(img) -> bytes: + """ + Convert a PIL Image to bytes for Gemini API. + + Args: + img: PIL Image object + + Returns: + bytes: Image as bytes + """ + if img is None: + raise ValueError("Image cannot be None") + + bytes_io = BytesIO() + img.save(bytes_io, format="PNG") + return bytes_io.getvalue() diff --git a/uv.lock b/uv.lock index 8aa5b6a..dc19987 100644 --- a/uv.lock +++ b/uv.lock @@ -84,6 +84,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, ] +[[package]] +name = "cachetools" +version = "5.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 }, +] + [[package]] name = "cartopy" version = "0.24.1" @@ -471,6 +480,7 @@ dependencies = [ { name = "earthkit-data" }, { name = "earthkit-plots" }, { name = "fire" }, + { name = "google-genai" }, { name = "hatchling" }, { name = "openai" }, { name = "python-dotenv" }, @@ -488,6 +498,7 @@ requires-dist = [ { name = "earthkit-data", specifier = ">=0.13.8" }, { name = "earthkit-plots", specifier = ">=0.3.1" }, { name = "fire", git = "https://github.com/google/python-fire.git?rev=2025-03-23-ipython" }, + { name = "google-genai", specifier = ">=1.27.0" }, { name = "hatchling", specifier = ">=1.27.0" }, { name = "openai", specifier = ">=1.77.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, @@ -858,6 +869,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/64/7d344cfcef5efddf9cf32f59af7f855828e9d74b5f862eddf5bfd9f25323/geopandas-1.0.1-py3-none-any.whl", hash = "sha256:01e147d9420cc374d26f51fc23716ac307f32b49406e4bd8462c07e82ed1d3d6", size = 323587 }, ] +[[package]] +name = "google-auth" +version = "2.40.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/9b/e92ef23b84fa10a64ce4831390b7a4c2e53c0132568d99d4ae61d04c8855/google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77", size = 281029 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137 }, +] + +[[package]] +name = "google-genai" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "google-auth" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/37/6c0ececc3a7a629029b5beed2ceb9f28f73292236eb96272355636769b0d/google_genai-1.27.0.tar.gz", hash = "sha256:15a13ffe7b3938da50b9ab77204664d82122617256f55b5ce403d593848ef635", size = 220099 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/12/279afe7357af73f9737a3412b6f0bc1482075b896340eb46a2f9cb0fd791/google_genai-1.27.0-py3-none-any.whl", hash = "sha256:afd6b4efaf8ec1d20a6e6657d768b68d998d60007c6e220e9024e23c913c1833", size = 218489 }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1780,6 +1824,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, +] + [[package]] name = "pycparser" version = "2.22" @@ -2182,6 +2247,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/e5/22865285789f3412ad0c3d7ec4dc0a3e86483b794be8a5d9ed5a19390900/rpds_py-0.24.0-cp313-cp313t-win_amd64.whl", hash = "sha256:675269d407a257b8c00a6b58205b72eec8231656506c56fd429d924ca00bb350", size = 237354 }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 }, +] + [[package]] name = "scipy" version = "1.15.2" @@ -2287,6 +2364,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, ] +[[package]] +name = "tenacity" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165 }, +] + [[package]] name = "termcolor" version = "3.1.0" @@ -2401,6 +2487,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, ] +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, +] + [[package]] name = "xarray" version = "2025.4.0" From 2a044efbe24c5a0d9b6c2073bc4b0903d0d34bb1 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 28 Jul 2025 14:51:15 -0400 Subject: [PATCH 062/151] fix: removing repeated step 6 in generator steps --- src/earth_reach_agent/core/generator.py | 12 +++++++++--- src/earth_reach_agent/core/prompts/generator.py | 3 --- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index 3886e21..814d00f 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -52,7 +52,6 @@ class GeneratorOutput: step_3: str | None = None step_4: str | None = None step_5: str | None = None - step_6: str | None = None final_description: str | None = None def is_complete(self) -> bool: @@ -126,14 +125,18 @@ def __init__( self.user_prompt = user_prompt def generate( - self, figure: ekp.Figure | None = None, image: ImageFile | None = None - ) -> str: + self, + figure: ekp.Figure | None = None, + image: ImageFile | None = None, + return_intermediate_steps: bool = False, + ) -> str | GeneratorOutput: """ Generate a structured weather description using the LLM. Args: figure (Figure | None): Optional figure to include in the request. Can't be used with image. image (ImageFile | None): Optional image to include in the request (will be converted to base64). Can't be used with figure. + return_intermediate_steps (bool): If True, return intermediate steps in the response. Returns: str: The final weather description. @@ -173,6 +176,9 @@ def generate( f"{parsed_output.get_missing_fields()}" ) + if return_intermediate_steps: + return parsed_output + description = parsed_output.final_description except Exception as e: diff --git a/src/earth_reach_agent/core/prompts/generator.py b/src/earth_reach_agent/core/prompts/generator.py index 2372954..0fe4c10 100644 --- a/src/earth_reach_agent/core/prompts/generator.py +++ b/src/earth_reach_agent/core/prompts/generator.py @@ -301,7 +301,6 @@ ### Step 6: Final Scientific Description - This global weather chart displays 2-meter temperature and mean sea level pressure for March 12, 2011, at 12:00 UTC, representing late boreal winter conditions. The domain spans 90°S to 90°N and 180°W to 180°E. Temperature ranges from -40°C (deep blue) to +40°C (deep red) with 2°C color increments. Pressure contours extend from 970 to 1028 hPa at 4 hPa intervals. @@ -321,7 +320,6 @@ This chart captures the global atmosphere during seasonal transition, with each hemisphere displaying characteristic late-season patterns modified by regional geography and ocean-continent thermal contrasts. - ## CRITICAL CONSTRAINTS AND SPECIFICATIONS @@ -367,7 +365,6 @@ - `...` - Pattern recognition notes - `...` - Validation notes - `...` - Planning notes -- `...` - Contains the final description writing process - `...` - ONLY the final 450-500 word description Remember: You are creating a complete analytical instrument that preserves the full scientific power of visual weather analysis for blind scientists. Precision and systematic methodology are essential for research quality and operational safety. From cbbb4ec2fd3805b11a7c4114c40645d7c738951a Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 28 Jul 2025 16:26:24 -0400 Subject: [PATCH 063/151] feat: improving evaluator prompts according to expert labeled descriptions --- .../core/prompts/evaluator.py | 850 ++++++++++-------- 1 file changed, 489 insertions(+), 361 deletions(-) diff --git a/src/earth_reach_agent/core/prompts/evaluator.py b/src/earth_reach_agent/core/prompts/evaluator.py index 364d84e..950cb71 100644 --- a/src/earth_reach_agent/core/prompts/evaluator.py +++ b/src/earth_reach_agent/core/prompts/evaluator.py @@ -6,104 +6,150 @@ **Critical Context**: These descriptions replace visual weather charts for blind meteorologists conducting research, teaching, and operational forecasting. Coherence failures directly impair scientific analysis and decision-making capabilities. +## COHERENCE FUNDAMENTALS FOR METEOROLOGICAL TEXT + +**Core Definition**: Coherence measures whether information flows logically from broadest relevant context → intermediate patterns → finest relevant details, enabling systematic meteorological analysis without visual reference. + +**Scale-Appropriate Hierarchy Principle**: +- **Global charts**: Global circulation → continental patterns → regional systems +- **Regional charts**: Synoptic context → regional systems → local weather +- **Local charts**: Regional context → local systems → specific phenomena +- **Never force inappropriate scale discussions** + +**Essential Components**: +1. **Context Foundation**: Complete technical specification (domain, variables, ranges, intervals) +2. **Analytical Building Logic**: Each section builds systematically upon previous information +3. **Scale-Appropriate Transitions**: Smooth connections between relevant spatial scales +4. **Process Integration Flow**: Static observations → dynamic interpretations → weather implications + ## EVALUATION PROCESS -### Step 1: Structural Analysis (Foundation Assessment) -**Objective**: Examine the overall organizational framework - -**Assessment Actions**: -1. Identify if the description follows a clear information hierarchy (overview → specific details → relationships) -2. Verify logical progression from broad patterns to specific features -3. Check for appropriate sectioning or natural information groupings -4. Assess whether geographic and temporal context is established early -5. Determine if the structure compensates for lack of visual reference points - -**Key Questions**: -- Does the description start with essential context (region, time, variables)? -- Is there a logical sequence from dominant to secondary meteorological features? -- Are spatial relationships clearly established before detailed analysis? - -### Step 2: Transition and Flow Evaluation (Connection Assessment) -**Objective**: Analyze how information elements connect and flow together - -**Assessment Actions**: -1. Examine transitions between different meteorological systems or geographic regions -2. Assess connection quality between quantitative data and qualitative patterns -3. Verify smooth integration of coordinate references and spatial descriptions -4. Check for logical bridges between related phenomena -5. Identify any abrupt topic changes or missing connections - -**Key Questions**: -- Are relationships between weather systems explicitly connected? -- Do quantitative values integrate smoothly with descriptive analysis? -- Are geographic transitions clearly signposted? - -### Step 3: Accessibility-Specific Coherence Check (Adaptation Assessment) -**Objective**: Evaluate coherence from a blind scientist's perspective - -**Assessment Actions**: -1. Assess whether information sequence builds spatial understanding progressively -2. Verify that complex relationships are broken down into logical components -3. Check for consistent reference frameworks (coordinates, directions, system names) -4. Evaluate if the description enables mental model construction -5. Ensure scientific conclusions flow logically from presented evidence - -**Key Questions**: -- Can a blind scientist follow the spatial logic without visual aids? -- Are complex meteorological relationships explained in digestible, sequential steps? -- Does the description build understanding cumulatively? +### Step 1: Information Architecture Assessment (Foundation Analysis) +**Objective**: Evaluate whether the structural organization enables systematic meteorological analysis + +**How to Assess Information Architecture**: +1. **Context Completeness Check**: Verify essential meteorological context appears early +2. **Scale-Appropriate Hierarchy**: Confirm the progression matches the chart's domain (no forced global discussion for regional charts) +3. **Analytical Building Assessment**: Each section should build upon previous information rather than presenting isolated facts +4. **Priority Sequence Logic**: Most meteorologically significant features should be introduced before secondary patterns + +**Scale-Appropriate Examples**: +- **Global Chart**: "Global circulation patterns → Continental manifestations → Regional weather implications" +- **European Chart**: "North Atlantic synoptic context → European pressure systems → National weather patterns" +- **US Regional Chart**: "Continental-scale patterns → Regional systems → State-level conditions" + +**Red Flags**: Missing essential context, inappropriate scale forcing, isolated pattern lists, secondary features before primary systems + +### Step 2: Multi-Scale Flow Integration (Connection Analysis) +**Objective**: Assess how well the description connects meteorological patterns across relevant spatial scales + +**How to Evaluate Multi-Scale Flow**: +1. **Scale Transition Logic**: Verify transitions between scales are necessary and logical (not forced) +2. **Connection Explicitness**: Look for clear linking language that explains relationships between scales +3. **System Interaction Coherence**: Related meteorological systems should be discussed in logical proximity +4. **Geographic Progression Logic**: Geographic transitions should follow meteorological or systematic organizational principles + +**Connection Quality Assessment**: +- **Excellent**: "The North Atlantic subtropical high (1024 hPa) extends a ridge across western Europe, promoting subsidence that maintains clear skies and drives radiational cooling to -5°C across the British Isles." +- **Good**: "High pressure over western Europe maintains clear, cold conditions across the British Isles." +- **Poor**: "High pressure system present. Cold temperatures in Britain." (no explicit causal connection) + +**Geographic Transition Examples**: +- **Strong**: "Moving eastward, this same high pressure influence extends across Scandinavia..." +- **Weak**: "In Scandinavia..." (abrupt geographic jump without connection) + +### Step 3: Analytical Progression Coherence (Process Integration Assessment) +**Objective**: Evaluate whether the description transforms static data into dynamic meteorological understanding through logical progression + +**How to Assess Analytical Progression**: +1. **Evidence-to-Interpretation Flow**: Check that meteorological conclusions follow logically from quantitative observations +2. **Process Integration Sequence**: Verify logical flow from pressure/temperature data → circulation patterns → weather implications +3. **Theoretical Validation Integration**: Assess whether theoretical frameworks (seasonal expectations, circulation models) are woven naturally into the analytical flow +4. **Inference Clarity**: Ensure dynamic interpretations are clearly distinguished from static observations + +**Analytical Flow Quality Examples**: +- **Excellent**: "The tight pressure gradient (8 hPa/200 km) between the Icelandic low (988 hPa) and Azores high (1028 hPa) drives strong geostrophic winds (inferred 40+ m/s), consistent with winter North Atlantic circulation patterns, bringing storm conditions to western Scotland." +- **Adequate**: "Low pressure near Iceland and high pressure near the Azores create strong winds and stormy conditions in Scotland." +- **Poor**: "Low pressure: 988 hPa. High pressure: 1028 hPa. Windy in Scotland." (no analytical connections) + +### Step 4: Accessibility-Optimized Coherence Check (Blind-Scientist Assessment) +**Objective**: Ensure coherence is specifically optimized for non-visual scientific analysis + +**How to Assess Accessibility-Optimized Coherence**: +1. **Spatial Logic Building**: Information sequence must build spatial understanding systematically without visual cues +2. **Reference Framework Consistency**: Coordinate systems, directional references, and system names must remain consistent throughout +3. **Complex Relationship Breakdown**: Multi-system interactions must be explained in digestible, sequential analytical steps +4. **Independent Verification Capability**: Quantitative precision must enable readers to validate described relationships + +**Accessibility Examples**: +- **Strong**: "The Icelandic low, centered at 65°N, 25°W with 988 hPa central pressure, interacts with the Azores high positioned at 38°N, 25°W at 1028 hPa. This 40 hPa pressure difference across 27° latitude creates..." +- **Weak**: "The northern low and southern high create pressure differences..." (vague spatial references, no verification capability) ## SCORING FRAMEWORK **Score 5 - Exceptional Coherence** -- Flawless logical progression from overview to detailed analysis -- Seamless transitions that enhance understanding -- Perfect adaptation for non-visual consumption -- Information builds systematically to enable complete scientific comprehension -- Structure actively facilitates meteorological analysis +- Perfect scale-appropriate information hierarchy that matches chart domain +- Flawless analytical progression from static data → dynamic interpretations → weather implications +- Seamless multi-scale connections with explicit linking language throughout +- Complete accessibility optimization with consistent reference frameworks +- Structure actively enables systematic meteorological analysis equivalent to visual inspection **Score 4 - Strong Coherence** -- Clear logical flow with minor structural imperfections -- Most transitions are smooth and helpful -- Well-adapted for blind scientists with occasional navigation challenges -- Information sequence supports scientific understanding effectively -- Structure enhances rather than hinders analysis +- Clear scale-appropriate progression with minor structural imperfections +- Strong analytical flow with occasional gaps in evidence-to-interpretation logic +- Most multi-scale transitions smooth and meteorologically logical +- Well-adapted for accessibility with minor reference inconsistencies +- Structure effectively supports scientific analysis with minimal navigation challenges **Score 3 - Adequate Coherence** -- Generally logical progression with noticeable structural issues -- Some transitions are unclear or missing -- Adequately accessible but requires extra effort to follow -- Scientific understanding achievable but not optimally supported -- Structure is functional but could be more effective +- Generally appropriate scale hierarchy with some forced or missing transitions +- Adequate analytical progression but noticeable gaps in process integration +- Some scale connections unclear or missing, requiring reader inference +- Basic accessibility adaptation but some spatial relationships unclear +- Structure functional for scientific understanding but requires extra interpretive effort **Score 2 - Poor Coherence** -- Significant logical flow problems that impede understanding -- Frequent unclear or missing transitions -- Accessibility compromised by structural confusion -- Scientific analysis hindered by organizational issues -- Structure creates barriers to comprehension +- Inappropriate scale forcing or significant hierarchy problems +- Weak analytical progression with frequent gaps between observations and interpretations +- Poor multi-scale integration with abrupt transitions or missing connections +- Accessibility compromised by inconsistent references and unclear spatial logic +- Structure creates barriers to systematic meteorological analysis **Score 1 - Very Poor Coherence** -- Major logical sequence problems throughout -- Information poorly connected or randomly ordered -- Severely compromised accessibility -- Scientific understanding significantly impaired -- Structure actively interferes with analysis +- Major scale inappropriate discussions or completely illogical information hierarchy +- Minimal analytical progression with static data poorly connected to dynamic interpretations +- Severely fragmented multi-scale logic with random or missing transitions +- Poor accessibility with inconsistent reference frameworks throughout +- Structure significantly impairs scientific understanding and analysis capability **Score 0 - Incoherent** -- No discernible logical organization -- Unintelligible information sequence -- Completely inaccessible structure -- Scientific analysis impossible due to organizational chaos -- No coherent structure present +- No logical organizational structure present +- Complete absence of analytical progression or process integration +- Unintelligible scale relationships and system connections +- Completely inaccessible for non-visual analysis +- Structure makes meteorological analysis impossible + +## CRITICAL EVALUATION STANDARDS + +**Pass-Fail Thresholds** (Automatic scoring guidance): +- **Missing essential context** (domain, variables, ranges, intervals): Maximum score 2 +- **Inappropriate scale forcing** (global discussion for regional charts): Maximum score 2 +- **No multi-scale connections**: Maximum score 3 +- **Poor accessibility optimization** (inconsistent references, unclear spatial logic): Maximum score 3 + +**Excellence Indicators** (Score 4-5 requirements): +- **Scale-appropriate hierarchy** perfectly matched to chart domain +- **Explicit connection language** throughout multi-scale transitions +- **Systematic analytical progression** from observations to interpretations to implications +- **Complete accessibility optimization** with consistent, precise reference frameworks ## COMMON PITFALLS TO AVOID -1. **Visual Bias**: Penalize descriptions that assume visual understanding (e.g., "as seen in the chart") -2. **Oversimplification**: Remember these are PhD-level scientists requiring sophisticated analysis -3. **Length Confusion**: Coherence isn't about brevity—evaluate logical flow, not word count -4. **Format Fixation**: Focus on logical progression, not rigid template adherence -5. **Missing Context**: Consider that spatial relationships must be explicit without visual cues +1. **Scale Template Bias**: Don't force global→regional→local for non-global charts - assess scale-appropriateness +2. **Visual Assumption Penalties**: Heavily penalize descriptions assuming visual understanding ("as shown," "visible") +3. **Academic Complexity Confusion**: Don't confuse necessary meteorological sophistication with poor coherence +4. **Connection Quality Misjudgment**: Distinguish between explicit linking language and mere topic adjacency +5. **Accessibility Standards**: Remember blind scientists need explicit spatial references and consistent frameworks ## OUTPUT REQUIREMENTS @@ -127,123 +173,152 @@ ## ROLE AND CONTEXT SETTING -You are a scientific communication expert specializing in technical writing assessment. Your task is to evaluate the linguistic quality of meteorological text descriptions, focusing on grammatical correctness, readability, and professional scientific expression. These descriptions serve blind meteorologists who must rely entirely on well-crafted language to understand complex weather patterns. +You are a scientific communication expert specializing in technical writing assessment. Your task is to evaluate the linguistic quality of meteorological text descriptions, focusing on grammatical correctness, meteorological terminology accuracy, readability, and professional scientific expression. These descriptions serve blind meteorologists who must rely entirely on well-crafted language to understand complex weather patterns. -**Critical Context**: Poor fluency directly impairs scientific comprehension for blind researchers. Awkward phrasing, grammatical errors, or unclear expression can render precise meteorological data unusable for research and operational decisions. +**Critical Context**: Poor fluency directly impairs scientific comprehension for blind researchers. Grammatical errors, incorrect meteorological terminology, or unclear expression can render precise meteorological data unusable for research and operational decisions. + +## FLUENCY STANDARDS FOR METEOROLOGICAL TEXT + +**Core Components**: +1. **Grammatical Precision**: Error-free grammar with scientific writing conventions +2. **Meteorological Terminology Accuracy**: Correct usage of technical terms per AMS Glossary standards +3. **Inference Notation Compliance**: Proper distinction between observed data and inferred processes +4. **Accessibility-Optimized Language**: Screen reader compatible with prohibited visual references +5. **Professional Scientific Voice**: Consistent, objective tone appropriate for research use + +**Critical Language Requirements**: +- **Prohibited Visual References**: "visible," "shown," "looking at," "clearly," "obviously," "as seen" +- **Required Inference Notation**: All dynamic interpretations must be marked (e.g., "inferred from pressure gradients") +- **Mandatory Unit Consistency**: All meteorological values must include consistent, appropriate units +- **25-Word Sentence Limit**: Maximum sentence length for accessibility optimization ## EVALUATION PROCESS -### Step 1: Grammar and Syntax Assessment (Foundation Analysis) -**Objective**: Examine basic linguistic correctness and sentence construction - -**Assessment Actions**: -1. Check for grammatical errors (subject-verb agreement, tense consistency, pronoun clarity) -2. Assess sentence structure variety and appropriateness for scientific writing -3. Verify punctuation accuracy, especially with complex quantitative information -4. Examine adherence to 25-word sentence limit while maintaining clarity -5. Identify any awkward or unclear sentence constructions - -**Key Questions**: -- Are all sentences grammatically correct and properly punctuated? -- Does sentence structure support clear communication of complex information? -- Are sentences appropriately varied in structure while maintaining scientific tone? - -### Step 2: Technical Language and Terminology Evaluation (Precision Analysis) -**Objective**: Assess accuracy and consistency of meteorological language use - -**Assessment Actions**: -1. Verify correct usage of meteorological terminology per AMS Glossary standards -2. Check consistency in unit usage (hPa, m/s, K or °C, km) throughout description -3. Assess precision in coordinate and geographic references -4. Evaluate appropriate level of technical vocabulary for PhD-level audience -5. Identify any imprecise or ambiguous technical expressions - -**Key Questions**: -- Are meteorological terms used correctly and consistently? -- Do all quantitative values include appropriate and consistent units? -- Is the technical language precise without being unnecessarily complex? - -### Step 3: Readability and Flow Assessment (Accessibility Analysis) -**Objective**: Evaluate how well the text reads aloud and flows for screen reader users - -**Assessment Actions**: -1. Assess natural reading rhythm and flow when text is read aloud -2. Check for smooth integration of quantitative data within sentences -3. Evaluate transition smoothness between sentences within paragraphs -4. Verify absence of visual-dependent language that disrupts accessibility -5. Assess whether complex spatial relationships are expressed clearly in linear text - -**Key Questions**: -- Does the text flow naturally when read aloud or via screen reader? -- Are numerical values and coordinates integrated smoothly into prose? -- Is the language accessible to blind scientists without sacrificing precision? - -### Step 4: Scientific Voice and Consistency Check (Professional Standards Analysis) -**Objective**: Evaluate maintenance of appropriate scientific tone and style - -**Assessment Actions**: -1. Assess consistency in objective, scientific tone throughout description -2. Verify appropriate separation of observations from interpretations -3. Check for consistent perspective and voice (avoiding shifts between first/third person) -4. Evaluate professional appropriateness of language choices -5. Assess whether the writing maintains scientific credibility - -**Key Questions**: -- Is the scientific tone consistent and appropriate throughout? -- Does the language maintain objectivity while being engaging? -- Are observations clearly distinguished from interpretations? +### Step 1: Grammar and Scientific Writing Standards (Foundation Analysis) +**Objective**: Examine grammatical correctness and adherence to scientific writing conventions + +**How to Assess Grammar and Writing Standards**: +1. **Grammatical Error Detection**: Check subject-verb agreement, tense consistency, pronoun clarity, parallel structure +2. **Scientific Writing Conventions**: Verify appropriate passive/active voice usage, objective tone, precise language +3. **Punctuation Accuracy**: Special attention to complex quantitative information, coordinate lists, unit specifications +4. **Sentence Length Compliance**: Ensure no sentences exceed 25 words while maintaining clarity +5. **Sentence Structure Variety**: Assess appropriate variation in structure for readability without sacrificing precision + +**Grammar Quality Examples**: +- **Excellent**: "The Icelandic low (988 hPa) creates steep pressure gradients across 200 km, driving winds (inferred) exceeding 25 m/s through geostrophic balance." +- **Poor**: "Looking at the chart, you can clearly see that there's a low pressure system that's obviously creating some pretty strong winds over there." (visual references, imprecise language, exceeds word limit) + +### Step 2: Meteorological Terminology and Unit Accuracy (Precision Analysis) +**Objective**: Evaluate accuracy and consistency of meteorological language and quantitative specifications + +**How to Assess Terminology and Unit Accuracy**: +1. **AMS Glossary Compliance**: Verify meteorological terms used correctly per American Meteorological Society standards +2. **Unit Consistency and Appropriateness**: Check all values include proper units (hPa, °C, m/s, km) used consistently throughout +3. **Coordinate Precision Standards**: Assess latitude/longitude references for proper format and precision +4. **Technical Vocabulary Appropriateness**: Evaluate terminology level suitable for PhD-level meteorologists +5. **Quantitative Integration**: Ensure numerical values and units integrate smoothly within sentence structure + +**Terminology Accuracy Examples**: +- **Correct**: "anticyclonic circulation," "geostrophic wind," "baroclinic zone," "subsidence inversion" +- **Incorrect**: "cyclonic high pressure" (contradictory), "windspeed" (should be two words), "temperature gradient" without quantification + +### Step 3: Inference Notation and Accessibility Language (Accessibility Analysis) +**Objective**: Evaluate proper distinction between observations and inferences, plus accessibility-optimized language + +**How to Assess Inference Notation and Accessibility**: +1. **Inference Marking Compliance**: Verify all dynamic interpretations properly marked (winds, weather conditions, circulation patterns) +2. **Prohibited Visual Language Detection**: Check for banned phrases that assume visual chart access +3. **Screen Reader Optimization**: Assess language flow and structure for audio consumption compatibility +4. **Spatial Reference Clarity**: Ensure spatial relationships expressed through explicit coordinate/directional language +5. **Linear Text Logic**: Verify complex spatial relationships understandable in sequential text format + +**Inference Notation Examples**: +- **Correct**: "Strong westerly winds (inferred from 8 hPa/200 km pressure gradient) likely exceed 30 m/s." +- **Incorrect**: "Strong westerly winds are visible across the region." (visual reference, no inference marking) + +**Accessibility Language Examples**: +- **Accessible**: "The low pressure center, positioned at 55°N, 15°W, creates circulation patterns..." +- **Inaccessible**: "The low shown in the upper left creates obvious circulation patterns..." (visual references) + +### Step 4: Professional Scientific Voice and Consistency (Style Analysis) +**Objective**: Evaluate maintenance of appropriate scientific tone and consistent professional voice + +**How to Assess Scientific Voice and Consistency**: +1. **Tone Consistency Assessment**: Verify objective, professional tone maintained throughout description +2. **Voice Perspective Stability**: Check for consistent third-person perspective without inappropriate shifts +3. **Scientific Objectivity**: Ensure language maintains appropriate distance between observations and interpretations +4. **Professional Appropriateness**: Assess language choices suitable for peer-reviewed scientific context +5. **Credibility Maintenance**: Verify writing style supports scientific authority and research applicability + +**Scientific Voice Examples**: +- **Professional**: "The pressure gradient analysis indicates geostrophic wind speeds (inferred) approaching 35 m/s." +- **Unprofessional**: "I can see that the winds are really strong here, probably around 35 m/s." (first person, informal tone, visual reference) ## SCORING FRAMEWORK **Score 5 - Exceptional Fluency** -- Flawless grammar, punctuation, and sentence construction -- Perfect meteorological terminology usage and unit consistency -- Exceptional readability optimized for screen reader accessibility -- Seamless integration of complex quantitative information -- Exemplary scientific voice that enhances comprehension +- Flawless grammar with perfect adherence to scientific writing conventions and 25-word sentence limits +- Perfect meteorological terminology per AMS Glossary standards with complete unit consistency +- All inferred processes properly marked with complete absence of prohibited visual language +- Exceptional screen reader optimization with seamless quantitative integration +- Exemplary professional scientific voice maintaining perfect objectivity and credibility **Score 4 - Strong Fluency** -- Minor grammatical issues that don't impair understanding -- Correct meteorological terminology with possible minor inconsistencies -- Good readability with occasional flow disruptions -- Quantitative information generally well-integrated -- Professional scientific tone with minor voice variations +- Minor grammatical issues that don't impair understanding, good sentence length control +- Correct meteorological terminology with minor unit inconsistencies +- Most inferences properly marked with minimal visual language violations +- Good screen reader compatibility with occasional integration disruptions +- Professional scientific tone with minor voice consistency issues **Score 3 - Adequate Fluency** -- Some grammatical errors or awkward constructions -- Generally correct terminology with noticeable inconsistencies -- Readable but requires effort due to flow issues -- Quantitative integration adequate but sometimes disruptive -- Scientific tone maintained with occasional lapses +- Some grammatical errors or sentence length violations that require extra effort to understand +- Generally correct terminology with noticeable unit inconsistencies or minor term misusage +- Adequate inference marking but some unmarked interpretations or occasional visual references +- Basic screen reader accessibility but some flow disruptions or unclear spatial language +- Scientific tone maintained with occasional unprofessional lapses or voice shifts **Score 2 - Poor Fluency** -- Frequent grammatical errors that impede comprehension -- Inconsistent or incorrect meteorological terminology usage -- Significant readability issues that burden screen reader users -- Poor integration of quantitative information -- Inconsistent or inappropriate scientific tone +- Frequent grammatical errors, sentence length violations, or awkward constructions that impede comprehension +- Significant meteorological terminology errors or widespread unit inconsistencies +- Poor inference marking with frequent visual language violations +- Compromised screen reader accessibility due to poor spatial language or integration issues +- Inconsistent scientific tone with frequent unprofessional expressions **Score 1 - Very Poor Fluency** -- Major grammatical problems throughout -- Substantial meteorological terminology errors -- Severely compromised readability -- Quantitative information poorly expressed -- Unprofessional or highly inconsistent tone +- Major grammatical problems throughout with extensive sentence length violations +- Substantial meteorological terminology errors that compromise scientific accuracy +- Minimal inference marking with pervasive visual language assumptions +- Severely compromised accessibility with poor screen reader optimization +- Unprofessional tone with frequent voice inconsistencies and credibility issues **Score 0 - No Fluency** - Extensive grammatical errors making text barely comprehensible -- Incorrect or inappropriate terminology throughout -- Unreadable for accessibility technology users -- Incomprehensible quantitative expressions -- Completely inappropriate scientific voice +- Incorrect meteorological terminology throughout undermining scientific validity +- Complete absence of inference marking with pervasive visual assumptions +- Completely inaccessible for screen reader users +- Entirely inappropriate scientific voice destroying credibility + +## CRITICAL EVALUATION STANDARDS + +**Pass-Fail Thresholds** (Automatic scoring guidance): +- **Prohibited visual language present** ("visible," "shown," "clearly," etc.): Maximum score 2 +- **Missing inference notation** for dynamic interpretations: Maximum score 2 +- **Major meteorological terminology errors**: Maximum score 2 +- **Frequent sentence length violations** (>25 words): Maximum score 3 + +**Excellence Indicators** (Score 4-5 requirements): +- **Complete absence** of prohibited visual language +- **Perfect inference marking** for all dynamic interpretations +- **AMS Glossary compliance** for all meteorological terminology +- **Flawless accessibility optimization** for screen reader users ## COMMON PITFALLS TO AVOID -1. **Over-penalizing Complexity**: Don't confuse necessary scientific complexity with poor fluency -2. **Visual Bias**: Remember accessibility requirements may create different but valid syntax patterns -3. **Perfectionist Standards**: Minor issues shouldn't overshadow overall linguistic quality -4. **Terminology Assumptions**: Verify meteorological term correctness rather than assuming -5. **Screen Reader Ignorance**: Consider how punctuation and structure affect audio consumption +1. **Complexity vs. Clarity Confusion**: Don't penalize necessary meteorological precision that aids comprehension +2. **Visual Language Tolerance**: Zero tolerance for visual assumptions - these destroy accessibility +3. **Inference Notation Leniency**: Missing inference marking compromises scientific integrity +4. **Terminology Assumption**: Verify AMS Glossary compliance rather than assuming correctness +5. **Screen Reader Ignorance**: Consider audio consumption patterns, punctuation effects, and spatial language clarity ## OUTPUT REQUIREMENTS @@ -270,119 +345,143 @@ **Critical Context**: These descriptions replace visual weather charts for blind meteorologists conducting research and operations. Consistency errors can lead to incorrect scientific conclusions, flawed forecasts, and compromised safety decisions in weather-sensitive operations. +## CONSISTENCY STANDARDS FOR METEOROLOGICAL VALIDATION + +**Core Definition**: Consistency measures factual accuracy between chart and description, internal logical coherence, and meteorological plausibility according to atmospheric physics and climatological expectations. + +**Critical Accuracy Thresholds**: +- **Spatial Accuracy Standard**: All pressure center locations within ±2° tolerance of actual chart positions +- **Domain Completeness**: Full coverage verification (no truncation like 60°N-60°S when global coverage exists) +- **Quantitative Precision**: All values within measurement resolution limits of source chart data +- **Theoretical Consistency**: All patterns validated against seasonal expectations and circulation models + +**ZERO TOLERANCE**: Spatial misplacements render descriptions worse than useless - they become actively misleading. + ## EVALUATION PROCESS -### Step 1: Source-Description Alignment Assessment (Fidelity Analysis) -**Objective**: Verify accuracy between the weather chart and its textual description - -**Assessment Actions**: -1. Compare described pressure values and locations with actual chart readings -2. Verify temperature ranges and spatial distributions against chart data -3. Check coordinate references (latitude/longitude) for major weather systems -4. Validate wind patterns and directions if present in the chart -5. Confirm temporal information (date, time, forecast period) matches chart metadata - -**Key Questions**: -- Do the pressure values and system locations accurately reflect the chart? -- Are temperature ranges and gradients correctly described? -- Do coordinate references precisely match chart features? - -### Step 2: Internal Logical Consistency Check (Coherence Analysis) -**Objective**: Assess whether all described elements align logically within the description - -**Assessment Actions**: -1. Verify spatial relationships consistency (if A is north of B, coordinates should reflect this) -2. Check that pressure gradients align with described system strengths -3. Assess whether temperature-pressure relationships follow meteorological principles -4. Validate that system interactions described are physically plausible -5. Ensure consistent use of coordinate systems and measurement units throughout - -**Key Questions**: -- Are spatial relationships between features internally consistent? -- Do pressure and temperature patterns align with described weather systems? -- Are all coordinate references using the same datum and format? - -### Step 3: Meteorological Plausibility Evaluation (Scientific Validity Analysis) -**Objective**: Determine if described weather patterns follow atmospheric physics principles - -**Assessment Actions**: -1. Assess whether pressure-temperature relationships follow expected atmospheric behavior -2. Verify that wind patterns (if described) align with pressure gradients -3. Check seasonal appropriateness of described patterns for the given date/location -4. Evaluate whether system interactions described are meteorologically realistic -5. Confirm that gradient magnitudes are physically reasonable - -**Key Questions**: -- Do the described meteorological relationships follow atmospheric physics? -- Are pressure gradients and system intensities realistic? -- Do seasonal and geographic contexts match described patterns? - -### Step 4: Quantitative Accuracy Assessment (Precision Analysis) -**Objective**: Evaluate numerical accuracy and measurement consistency - -**Assessment Actions**: -1. Verify all pressure readings are within reasonable atmospheric ranges -2. Check temperature values for geographic and seasonal appropriateness -3. Assess coordinate precision and accuracy for major features -4. Validate unit consistency throughout the description -5. Confirm that value ranges accurately reflect chart data distribution - -**Key Questions**: -- Are all quantitative values within realistic meteorological ranges? -- Is unit usage consistent and appropriate throughout? -- Do numerical ranges accurately represent chart data spread? +### Step 1: Mandatory Spatial Accuracy Verification (Critical Foundation Assessment) +**Objective**: Verify all spatial information within strict accuracy tolerances to prevent misleading descriptions + +**How to Verify Spatial Accuracy**: +1. **Pressure Center Location Verification**: Compare each described pressure system location against actual chart position (±2° maximum tolerance) +2. **Domain Boundary Verification**: Confirm complete domain coverage matches chart extent (no artificial truncations) +3. **Geographic Reference Validation**: Check all coordinate references against actual chart features using coastlines/continents as anchor points +4. **Relative Position Consistency**: Verify all described spatial relationships (north/south/east/west) match actual chart positions +5. **System Extent Accuracy**: Ensure described pressure system sizes and coverage areas match chart representations + +**Spatial Accuracy Examples**: +- **Accurate**: "High pressure center at 45°N, 15°E over the Alps" (when chart shows center at 44°N, 16°E) +- **FAILED**: "High pressure center over the North Sea" (when chart shows center over Scandinavia - >200 km error) + +**Red Flags**: Any pressure center >2° from actual position, domain truncation, geographic anchor misplacement + +### Step 2: Theoretical and Seasonal Consistency Validation (Scientific Plausibility Assessment) +**Objective**: Ensure all described patterns align with meteorological theory and seasonal expectations + +**How to Assess Theoretical Consistency**: +1. **Seasonal Pattern Validation**: Check if described patterns match climatological expectations for the given date/location +2. **Circulation Model Consistency**: Verify patterns align with three-cell circulation model, jet stream positions, typical pressure system locations +3. **Physical Process Verification**: Ensure temperature-pressure relationships follow atmospheric physics principles +4. **Gradient Plausibility**: Confirm pressure gradients and system intensities are meteorologically realistic +5. **System Interaction Logic**: Validate that described system interactions follow known atmospheric dynamics + +**Theoretical Consistency Examples**: +- **Consistent**: "1048 hPa Siberian high in February promotes continental cold air mass" (seasonally appropriate) +- **Inconsistent**: "1048 hPa Siberian high in July" (climatologically implausible intensity/timing) + +### Step 3: Multi-Scale Internal Consistency Verification (Logical Coherence Assessment) +**Objective**: Assess whether all described elements align logically across spatial scales and within the description framework + +**How to Assess Multi-Scale Consistency**: +1. **Cross-Scale Logical Verification**: Ensure local features are consistent with regional patterns, which are consistent with broader atmospheric context +2. **Spatial Relationship Consistency**: Verify all described spatial relationships are internally coherent (if A is north of B, coordinates must reflect this) +3. **Quantitative Cross-Verification**: Check that different quantitative references support each other rather than contradict +4. **System Interaction Consistency**: Ensure described system interactions are logical across all mentioned scales +5. **Reference Framework Consistency**: Verify coordinate systems, units, and measurement frameworks remain consistent throughout + +**Multi-Scale Consistency Examples**: +- **Consistent**: "North Atlantic high (1028 hPa) extends ridge over western Europe, promoting subsidence and clear skies across Britain" +- **Inconsistent**: "High pressure over Britain promotes storms" (contradictory meteorological relationship) + +### Step 4: Quantitative Precision and Chart Fidelity Assessment (Data Accuracy Analysis) +**Objective**: Evaluate numerical accuracy and measurement consistency against source chart capabilities + +**How to Assess Quantitative Precision**: +1. **Chart Resolution Compliance**: Ensure claimed precision doesn't exceed source chart measurement capabilities +2. **Value Range Verification**: Confirm all described ranges accurately reflect chart data distribution +3. **Unit Accuracy and Consistency**: Verify all quantitative values include correct, consistent units throughout +4. **Measurement Interval Consistency**: Check that described measurement intervals match chart specifications +5. **Extreme Value Validation**: Ensure claimed extreme values are actually visible/readable from the source chart + +**Quantitative Precision Examples**: +- **Accurate**: "Pressure contours at 4 hPa intervals from 1004 to 1032 hPa" (matches chart contour labeling) +- **Inaccurate**: "Pressure varies continuously from 1004.3 to 1031.7 hPa" (false precision beyond chart resolution) ## SCORING FRAMEWORK **Score 5 - Perfect Consistency** -- Complete accuracy between chart and description -- Flawless internal logical alignment -- All meteorological relationships scientifically sound -- Perfect quantitative precision with appropriate units -- No contradictions or inconsistencies detected +- ALL pressure centers within ±2° tolerance with perfect spatial accuracy throughout +- Complete theoretical consistency with seasonal expectations and circulation models +- Flawless multi-scale internal logic with no contradictions across any spatial scales +- Perfect quantitative precision matching chart resolution capabilities exactly +- Complete domain coverage accuracy with no truncations or omissions **Score 4 - Strong Consistency** -- Minor discrepancies that don't affect scientific interpretation -- Generally strong internal alignment with occasional minor issues -- Meteorological relationships mostly sound with minor implausibilities -- Quantitative accuracy high with possible minor unit inconsistencies -- Negligible contradictions that don't impair understanding +- ALL pressure centers within ±2° tolerance with minor spatial reference inconsistencies elsewhere +- Strong theoretical consistency with minor seasonal or circulation model deviations +- Generally strong multi-scale logic with occasional minor internal contradictions +- High quantitative accuracy with minor unit inconsistencies or precision issues +- Complete domain coverage with minor boundary specification issues **Score 3 - Adequate Consistency** -- Noticeable but not critical discrepancies with source chart -- Some internal consistency issues that require clarification -- Most meteorological relationships plausible with some questionable aspects -- Generally accurate quantitative data with some unit or precision issues -- Minor contradictions present but don't severely compromise description +- MOST pressure centers within ±2° tolerance but some minor spatial accuracy issues +- Generally appropriate theoretical patterns with some seasonal or theoretical inconsistencies +- Adequate multi-scale consistency but noticeable internal contradictions requiring clarification +- Generally accurate quantitative data with some precision or unit issues +- Adequate domain coverage but some specification problems **Score 2 - Poor Consistency** -- Significant discrepancies between chart and description -- Multiple internal contradictions affecting understanding -- Several meteorologically implausible relationships described -- Quantitative errors that could mislead scientific analysis -- Contradictions that substantially compromise description reliability +- SOME pressure centers exceed ±2° tolerance or significant spatial accuracy problems +- Poor theoretical consistency with multiple seasonal or circulation model violations +- Significant multi-scale contradictions affecting scientific interpretation +- Quantitative errors that could mislead analysis with widespread unit or precision problems +- Incomplete or inaccurate domain coverage affecting interpretation **Score 1 - Very Poor Consistency** -- Major inaccuracies compared to source chart -- Extensive internal contradictions throughout description -- Multiple violations of meteorological principles -- Substantial quantitative errors affecting interpretation -- Pervasive contradictions severely undermining description credibility +- MOST pressure centers exceed ±2° tolerance with major spatial misplacements throughout +- Major theoretical inconsistencies violating seasonal patterns and circulation principles +- Extensive multi-scale contradictions severely undermining description credibility +- Substantial quantitative errors affecting interpretation with pervasive precision/unit problems +- Major domain coverage errors or truncations affecting scientific utility **Score 0 - No Consistency** -- Description bears little resemblance to source chart -- Completely contradictory internal elements -- Described patterns violate basic atmospheric physics -- Quantitative data largely incorrect or nonsensical -- Contradictions make description scientifically unusable +- ALL or most pressure centers grossly misplaced (>5° errors) making description actively misleading +- Complete theoretical implausibility violating basic atmospheric physics +- Pervasive contradictions making description scientifically unusable +- Quantitative data largely incorrect or nonsensical throughout +- Domain coverage completely inaccurate or missing + +## CRITICAL EVALUATION STANDARDS + +**MANDATORY PASS-FAIL THRESHOLDS**: +- **ANY pressure center >2° from actual position**: Maximum score 1 (description becomes actively misleading) +- **Domain truncation when full coverage exists**: Maximum score 2 (incomplete scientific information) +- **Major theoretical violations** (e.g., impossible seasonal patterns): Maximum score 2 +- **Extensive quantitative errors** beyond chart resolution: Maximum score 2 + +**Excellence Requirements** (Score 4-5): +- **Perfect spatial accuracy**: ALL pressure centers within ±2° tolerance +- **Complete theoretical consistency**: Seasonal and circulation model alignment +- **Multi-scale coherence**: No internal contradictions across spatial scales +- **Quantitative precision**: Accuracy matching chart resolution capabilities ## COMMON PITFALLS TO AVOID -1. **Perfectionist Expectations**: Minor measurement variations don't always indicate major consistency problems -2. **Chart Resolution Limits**: Consider that exact numerical precision may be limited by chart resolution -3. **Meteorological Complexity**: Some apparent inconsistencies may reflect complex but real atmospheric phenomena -4. **Temporal Snapshots**: Remember descriptions represent single time moments, not evolving patterns -5. **Accessibility Adaptations**: Some rephrasing for blind users may appear different but remain factually consistent +1. **Spatial Accuracy Tolerance**: ZERO tolerance for >2° pressure center errors - these make descriptions actively misleading +2. **Theoretical Complexity Confusion**: Don't excuse clear seasonal/circulation violations as "atmospheric complexity" +3. **Chart Resolution Expectations**: Don't demand precision beyond chart capabilities, but verify claimed precision is achievable +4. **Multi-Scale Logic**: Ensure local descriptions are consistent with regional and broader patterns mentioned +5. **Domain Coverage Standards**: Verify complete coverage matches chart extent - no artificial truncations acceptable ## OUTPUT REQUIREMENTS @@ -410,119 +509,148 @@ **Critical Context**: These descriptions must distill complex weather charts into the most scientifically valuable information within strict word limits. Poor relevance assessment can render descriptions analytically useless, forcing blind meteorologists to miss critical weather patterns or waste time on insignificant details. +## RELEVANCE STANDARDS FOR METEOROLOGICAL ANALYSIS + +**Core Definition**: Relevance measures whether descriptions prioritize meteorologically significant patterns and enable expert-level analytical conclusions equivalent to visual chart inspection. + +**Expert-Level Requirements** (based on professional meteorological analysis): +- **Multi-Scale Integration Priority**: Most important patterns emphasized across global/regional/local scales as appropriate +- **Dynamic Process Emphasis**: Static data transformed into circulation patterns and weather implications +- **Theoretical Framework Priority**: Seasonal validation and circulation model context for significant patterns +- **Analytical Enablement**: Sufficient information for forecast reasoning, process understanding, and research decisions + +**Information Priority Hierarchy**: +1. **Most Intense Systems**: Strongest pressure centers and steepest gradients receive primary emphasis +2. **Meteorologically Significant Patterns**: Unusual, extreme, or climatologically important features highlighted +3. **Multi-Scale Context**: Pattern significance established through appropriate scale connections +4. **Dynamic Implications**: Weather and circulation consequences of observed patterns + ## EVALUATION PROCESS -### Step 1: Meteorological Significance Assessment (Primary Pattern Analysis) -**Objective**: Evaluate whether the most important weather systems receive appropriate emphasis - -**Assessment Actions**: -1. Identify the dominant meteorological features visible in the chart (strongest systems, major gradients) -2. Assess whether these primary features receive proportional attention in the description -3. Evaluate if system intensities and meteorological significance are accurately prioritized -4. Check that the most analytically important patterns are emphasized early and clearly -5. Verify that dominant weather drivers are given precedence over secondary effects - -**Key Questions**: -- Are the strongest pressure systems and steepest gradients given primary focus? -- Does the description lead with the most meteorologically significant patterns? -- Are weather system intensities ranked appropriately in the text emphasis? - -### Step 2: Information Density Optimization (Efficiency Analysis) -**Objective**: Assess how effectively the description uses its word limit for maximum scientific value - -**Assessment Actions**: -1. Evaluate whether included quantitative details support primary meteorological conclusions -2. Assess if spatial descriptions focus on scientifically important boundaries and gradients -3. Check that coordinate references prioritize major system centers and critical transition zones -4. Verify that secondary details don't overshadow primary patterns -5. Determine if the description enables the same analytical conclusions as the visual chart - -**Key Questions**: -- Do the included details directly support understanding of major weather patterns? -- Are quantitative values focused on the most analytically important features? -- Does the description enable equivalent scientific analysis to visual inspection? - -### Step 3: Analytical Enablement Evaluation (Research Utility Analysis) -**Objective**: Determine if the description supports high-quality meteorological analysis - -**Assessment Actions**: -1. Assess whether described patterns enable forecast reasoning and weather prediction -2. Evaluate if the description supports identification of meteorological processes and mechanisms -3. Check that enough context is provided for understanding system interactions -4. Verify that the description enables comparison with climatological patterns -5. Determine if operational or research decisions could be made based on the description - -**Key Questions**: -- Can a meteorologist make informed forecasting decisions from this description? -- Are the described patterns sufficient for understanding atmospheric processes? -- Does the description support research-quality meteorological analysis? - -### Step 4: Contextual Appropriateness Assessment (Scale and Purpose Analysis) -**Objective**: Evaluate whether information priorities match the chart's scale and meteorological context - -**Assessment Actions**: -1. Assess appropriateness of detail level for the chart's spatial and temporal scale -2. Evaluate whether seasonal and geographic context influences appropriate information priorities -3. Check that described patterns match the meteorological significance for the given region/time -4. Verify that the description addresses the most relevant phenomena for the chart's domain -5. Assess whether the emphasis aligns with typical meteorological analysis priorities - -**Key Questions**: -- Is the detail level appropriate for the chart's geographic and temporal scale? -- Do the emphasized patterns match typical meteorological priorities for this context? -- Are regionally or seasonally important features given appropriate attention? +### Step 1: Meteorological Significance Prioritization Assessment (Primary Pattern Analysis) +**Objective**: Evaluate whether the most meteorologically significant systems receive appropriate emphasis and early attention + +**How to Assess Meteorological Significance Prioritization**: +1. **System Intensity Ranking**: Verify strongest pressure systems (highest/lowest values) receive primary emphasis +2. **Gradient Significance Assessment**: Check that steepest pressure gradients and strongest temperature contrasts are highlighted early +3. **Climatological Importance Evaluation**: Assess whether unusual, extreme, or seasonally significant patterns receive appropriate attention +4. **Early Emphasis Verification**: Confirm most significant patterns appear early in description rather than buried in secondary details +5. **Comparative Intensity Assessment**: Ensure system strength rankings in text match actual meteorological intensity + +**Significance Prioritization Examples**: +- **Excellent**: "The exceptional 1052 hPa high pressure system dominates northern Europe, representing extreme subsidence..." (leads with most intense feature) +- **Poor**: "Various pressure systems exist across Europe, including a 1052 hPa high..." (buries extreme intensity in generic statement) + +### Step 2: Multi-Scale Integration and Dynamic Process Priority (Analytical Depth Assessment) +**Objective**: Assess whether descriptions prioritize multi-scale connections and dynamic process understanding over static data reporting + +**How to Assess Multi-Scale Integration and Dynamic Process Priority**: +1. **Multi-Scale Connection Emphasis**: Verify that scale connections receive adequate emphasis rather than being treated as afterthoughts +2. **Dynamic Process Integration Priority**: Check that circulation patterns and weather implications receive prominent attention +3. **Static-to-Dynamic Transformation**: Assess whether static pressure/temperature data is systematically transformed into process understanding +4. **Process Mechanism Explanation**: Evaluate whether physical mechanisms behind observed patterns receive appropriate attention +5. **Weather Implication Priority**: Verify that weather consequences of pressure systems receive adequate emphasis + +**Dynamic Process Priority Examples**: +- **Excellent**: "The 1028 hPa high drives anticyclonic circulation, promoting subsidence and clear skies (inferred) across western Europe, while creating strong pressure gradients..." +- **Adequate**: "High pressure (1028 hPa) over western Europe creates clear conditions..." +- **Poor**: "Pressure values: 1028 hPa high, 1012 hPa low, 1020 hPa ridge..." (static data listing without process integration) + +### Step 3: Expert-Level Analytical Enablement Assessment (Research Utility Analysis) +**Objective**: Determine whether descriptions enable the same analytical conclusions and research capabilities as expert meteorological analysis + +**How to Assess Expert-Level Analytical Enablement**: +1. **Forecast Reasoning Capability**: Assess whether description provides sufficient information for weather prediction and forecast reasoning +2. **Process Understanding Support**: Evaluate if description enables understanding of atmospheric processes and physical mechanisms +3. **Research Decision Support**: Determine whether operational or research decisions could be made based on the provided information +4. **Comparative Analysis Capability**: Check if description enables comparison with climatological patterns and seasonal expectations +5. **Independent Validation Potential**: Assess whether readers can independently verify and extend the analytical conclusions + +**Analytical Enablement Examples**: +- **Expert-Level**: "The 1052 hPa Scandinavian high, exceptional for February, promotes continental cold air advection through geostrophic balance, creating temperature gradients of 15°C/500 km across central Europe, indicating strong frontal potential..." +- **Limited**: "High pressure over Scandinavia brings cold weather to Europe..." +- **Insufficient**: "Cold temperatures across Europe..." (no analytical framework provided) + +### Step 4: Information Efficiency and Contextual Appropriateness (Optimization Analysis) +**Objective**: Evaluate whether word limit usage maximizes meteorological value and matches chart scale/context appropriately + +**How to Assess Information Efficiency and Contextual Appropriateness**: +1. **Word Limit Optimization**: Verify that every significant meteorological detail serves analytical purposes rather than filling space +2. **Scale-Appropriate Detail Level**: Assess whether detail level matches chart scale (global vs regional vs local analysis priorities) +3. **Seasonal/Geographic Context Alignment**: Check that emphasis matches regional and seasonal meteorological priorities +4. **Analytical Value Density**: Evaluate whether included details directly support meteorological conclusions and understanding +5. **Context-Specific Priority Matching**: Verify that emphasized patterns match typical meteorological analysis priorities for the given domain/season + +**Information Efficiency Examples**: +- **Efficient**: "The 1048 hPa Siberian high, intense for early March, drives continental outflow affecting European temperatures by 10-15°C below normal..." +- **Inefficient**: "The Siberian high pressure system has a central pressure of 1048 hPa and covers a large area of Siberia with generally high pressure conditions..." +- **Inappropriate Scale**: Discussing global circulation for a European regional chart when European synoptic patterns should dominate ## SCORING FRAMEWORK **Score 5 - Exceptional Relevance** -- Perfect identification and emphasis of most meteorologically significant features -- Optimal information density maximizing scientific value within word limits -- Description fully enables equivalent analytical capabilities to visual chart inspection -- Information priorities perfectly matched to meteorological context and scale -- Every included detail directly supports primary meteorological understanding +- Perfect prioritization of strongest systems and most significant patterns with early emphasis +- Exceptional multi-scale integration and dynamic process emphasis throughout description +- Expert-level analytical enablement supporting forecast reasoning, process understanding, and research decisions +- Optimal information efficiency maximizing meteorological value with perfect scale/context appropriateness +- Every detail directly supports primary meteorological conclusions and enables equivalent analysis to visual inspection **Score 4 - Strong Relevance** -- Most important meteorological features appropriately emphasized -- Good information density with minor inclusion of less critical details -- Description enables high-quality meteorological analysis with minor limitations -- Information priorities generally well-matched to context with occasional misalignment -- Most details support primary meteorological conclusions effectively +- Strong prioritization of significant systems with most important patterns emphasized early +- Good multi-scale integration and dynamic process emphasis with minor static data focus +- High-quality analytical enablement supporting most forecast and research needs with minor limitations +- Good information efficiency with occasional less critical details but generally appropriate scale/context matching +- Most details effectively support meteorological conclusions and analytical capabilities **Score 3 - Adequate Relevance** -- Primary meteorological features identified but emphasis could be stronger -- Reasonable information density but some space wasted on less important details -- Description supports basic meteorological analysis but misses some analytical opportunities -- Information priorities generally appropriate but some contextual mismatches +- Generally appropriate prioritization but some significant patterns under-emphasized or buried +- Adequate multi-scale integration but noticeable emphasis on static data over dynamic processes +- Basic analytical enablement supporting fundamental meteorological analysis but missing some research opportunities +- Reasonable information efficiency but some space wasted on less critical details with occasional scale/context mismatches - Mix of relevant and less relevant details affecting overall analytical efficiency **Score 2 - Poor Relevance** -- Important meteorological features under-emphasized or missed -- Poor information density with significant space devoted to minor details -- Description provides limited analytical capability compared to visual chart -- Information priorities poorly matched to meteorological context -- Many included details don't support primary meteorological understanding +- Poor prioritization with important systems under-emphasized and weak systems over-emphasized +- Limited multi-scale integration with heavy focus on static data reporting over process understanding +- Limited analytical enablement providing insufficient information for forecast reasoning or research analysis +- Poor information efficiency with significant space devoted to minor details and poor scale/context matching +- Many included details don't support primary meteorological understanding or analytical conclusions **Score 1 - Very Poor Relevance** -- Major meteorological features largely ignored or severely under-emphasized -- Very poor information density focusing on insignificant details -- Description severely limits meteorological analytical capability -- Information priorities inappropriate for meteorological context and scale -- Most included details irrelevant to primary meteorological understanding +- Major meteorological features largely ignored with inappropriate emphasis on minor systems +- Minimal multi-scale integration with predominantly static data listing and little process understanding +- Severely limited analytical enablement preventing quality meteorological analysis and research application +- Very poor information efficiency focusing on insignificant details with inappropriate scale/context priorities +- Most included details irrelevant to meteorological analysis needs and conclusions **Score 0 - No Relevance** -- Description fails to identify or emphasize any significant meteorological patterns -- Information completely unfocused with no clear meteorological priorities -- Description provides no useful analytical capability -- No appropriate meteorological context or scale consideration -- Content largely irrelevant to meteorological analysis needs +- Complete failure to identify or appropriately emphasize any significant meteorological patterns +- No multi-scale integration or dynamic process emphasis - purely static data reporting +- No analytical enablement capability - description provides no useful meteorological analysis support +- Information completely unfocused with no appropriate meteorological priorities or context consideration +- Content largely irrelevant to meteorological analysis needs with no scientific value + +## CRITICAL EVALUATION STANDARDS + +**Pass-Fail Thresholds** (Automatic scoring guidance): +- **Strongest systems not emphasized early**: Maximum score 2 (defeats primary purpose) +- **No dynamic process integration**: Maximum score 2 (eliminates analytical value) +- **Insufficient detail for basic forecast reasoning**: Maximum score 2 (fails analytical enablement) +- **Inappropriate scale emphasis**: Maximum score 3 (mismatched context priorities) + +**Excellence Indicators** (Score 4-5 requirements): +- **Perfect intensity-based prioritization**: Strongest systems receive primary emphasis early +- **Systematic dynamic process integration**: Static data transformed into circulation and weather understanding +- **Expert-level analytical enablement**: Sufficient detail for forecast reasoning and research decisions +- **Optimal information efficiency**: Every significant detail supports meteorological conclusions ## COMMON PITFALLS TO AVOID -1. **Completeness Confusion**: Don't confuse comprehensive coverage with relevance—focus beats breadth -2. **Detail Fetishism**: Precise quantitative data doesn't automatically mean relevance -3. **Template Thinking**: Different meteorological situations require different information priorities -4. **Academic Bias**: Balance research completeness with analytical efficiency -5. **Scale Mismatching**: Regional charts need different detail emphasis than global analyses +1. **Intensity Ranking Errors**: Don't accept weak system emphasis over strong systems - intensity determines meteorological significance +2. **Static Data Tolerance**: Penalize pure data reporting without process integration - descriptions must enable dynamic understanding +3. **Completeness vs. Relevance Confusion**: Focus on meteorological significance, not comprehensive coverage of all features +4. **Scale Appropriateness**: Assess whether emphasis matches chart scale - don't accept global detail for regional charts or vice versa +5. **Analytical Utility Standards**: Verify descriptions enable the same conclusions as expert meteorological analysis ## OUTPUT REQUIREMENTS From 3f4bb3cdc6a21122f36db337e67bbc12ddd26f34 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 29 Jul 2025 10:40:18 -0400 Subject: [PATCH 064/151] feat: improving fluency prompt --- .../core/prompts/evaluator.py | 104 +++++++++--------- 1 file changed, 49 insertions(+), 55 deletions(-) diff --git a/src/earth_reach_agent/core/prompts/evaluator.py b/src/earth_reach_agent/core/prompts/evaluator.py index 950cb71..4eedb35 100644 --- a/src/earth_reach_agent/core/prompts/evaluator.py +++ b/src/earth_reach_agent/core/prompts/evaluator.py @@ -183,14 +183,14 @@ 1. **Grammatical Precision**: Error-free grammar with scientific writing conventions 2. **Meteorological Terminology Accuracy**: Correct usage of technical terms per AMS Glossary standards 3. **Inference Notation Compliance**: Proper distinction between observed data and inferred processes -4. **Accessibility-Optimized Language**: Screen reader compatible with prohibited visual references +4. **Accessibility Language Standards**: No assumptive visual language that excludes blind users 5. **Professional Scientific Voice**: Consistent, objective tone appropriate for research use **Critical Language Requirements**: -- **Prohibited Visual References**: "visible," "shown," "looking at," "clearly," "obviously," "as seen" +- **Prohibited Assumptive Visual Language**: Phrases that assume the reader has visual access: "as you can see," "if you look at," "clearly visible to the viewer," "obviously shown in the image" +- **Acceptable Descriptive Visual Language**: Objective descriptions of visual elements (colors, patterns, chart features) that help blind users understand the complete picture - **Required Inference Notation**: All dynamic interpretations must be marked (e.g., "inferred from pressure gradients") - **Mandatory Unit Consistency**: All meteorological values must include consistent, appropriate units -- **25-Word Sentence Limit**: Maximum sentence length for accessibility optimization ## EVALUATION PROCESS @@ -201,8 +201,7 @@ 1. **Grammatical Error Detection**: Check subject-verb agreement, tense consistency, pronoun clarity, parallel structure 2. **Scientific Writing Conventions**: Verify appropriate passive/active voice usage, objective tone, precise language 3. **Punctuation Accuracy**: Special attention to complex quantitative information, coordinate lists, unit specifications -4. **Sentence Length Compliance**: Ensure no sentences exceed 25 words while maintaining clarity -5. **Sentence Structure Variety**: Assess appropriate variation in structure for readability without sacrificing precision +4. **Sentence Structure Variety**: Assess appropriate variation in structure for readability without sacrificing precision **Grammar Quality Examples**: - **Excellent**: "The Icelandic low (988 hPa) creates steep pressure gradients across 200 km, driving winds (inferred) exceeding 25 m/s through geostrophic balance." @@ -222,25 +221,41 @@ - **Correct**: "anticyclonic circulation," "geostrophic wind," "baroclinic zone," "subsidence inversion" - **Incorrect**: "cyclonic high pressure" (contradictory), "windspeed" (should be two words), "temperature gradient" without quantification -### Step 3: Inference Notation and Accessibility Language (Accessibility Analysis) -**Objective**: Evaluate proper distinction between observations and inferences, plus accessibility-optimized language +### Step 3: Inference Notation Compliance (Scientific Rigor Analysis) +**Objective**: Evaluate proper distinction between observed data and inferred processes -**How to Assess Inference Notation and Accessibility**: -1. **Inference Marking Compliance**: Verify all dynamic interpretations properly marked (winds, weather conditions, circulation patterns) -2. **Prohibited Visual Language Detection**: Check for banned phrases that assume visual chart access -3. **Screen Reader Optimization**: Assess language flow and structure for audio consumption compatibility -4. **Spatial Reference Clarity**: Ensure spatial relationships expressed through explicit coordinate/directional language -5. **Linear Text Logic**: Verify complex spatial relationships understandable in sequential text format +**How to Assess Inference Notation**: +1. **Dynamic Process Marking**: Verify all interpreted movements, winds, and weather processes are marked as inferences +2. **Observation vs. Interpretation Clarity**: Check clear distinction between measured data and meteorological interpretations +3. **Inference Marking Consistency**: Ensure inference notation applied uniformly throughout description +4. **Appropriate Inference Language**: Verify use of proper qualifying terms (inferred, likely, estimated, suggested by) **Inference Notation Examples**: - **Correct**: "Strong westerly winds (inferred from 8 hPa/200 km pressure gradient) likely exceed 30 m/s." -- **Incorrect**: "Strong westerly winds are visible across the region." (visual reference, no inference marking) - -**Accessibility Language Examples**: -- **Accessible**: "The low pressure center, positioned at 55°N, 15°W, creates circulation patterns..." -- **Inaccessible**: "The low shown in the upper left creates obvious circulation patterns..." (visual references) - -### Step 4: Professional Scientific Voice and Consistency (Style Analysis) +- **Correct**: "The circulation pattern, inferred from isobar configuration, suggests cyclonic rotation." +- **Incorrect**: "Strong westerly winds exceed 30 m/s across the region." (unmarked dynamic interpretation) +- **Incorrect**: "The low pressure system is moving eastward." (unmarked inference about movement) + +### Step 4: Accessibility Language Standards (Inclusivity Analysis) +**Objective**: Evaluate absence of assumptive visual language and quality of spatial descriptions + +**How to Assess Accessibility Language**: +1. **Assumptive Language Detection**: Identify phrases that assume visual access to the chart +2. **Spatial Reference Quality**: Verify spatial relationships use explicit coordinates/directions +3. **Visual Element Description**: Ensure objective description of colors, patterns when mentioned +4. **Navigation Independence**: Check that description doesn't require visual navigation + +**Assumptive Visual Language Examples**: +- **Unacceptable**: "As you can see in the chart," "clearly visible," "if you look at," "obviously shown" +- **Acceptable**: "The chart displays," "The data indicates," "Located at 50°N" + +**Visual Element Description Examples**: +- **Good**: "This weather chart displays temperatures using colors from blue (-30°C) to orange (20°C)" +- **Poor**: "As you can see, the blue areas are cold" (assumes visual access) +- **Good**: "The low pressure center, positioned at 55°N, 15°W, creates circulation patterns..." +- **Poor**: "The low shown in the upper left creates obvious circulation patterns..." (spatial assumption) + +### Step 5: Professional Scientific Voice and Consistency (Style Analysis) **Objective**: Evaluate maintenance of appropriate scientific tone and consistent professional voice **How to Assess Scientific Voice and Consistency**: @@ -259,67 +274,45 @@ **Score 5 - Exceptional Fluency** - Flawless grammar with perfect adherence to scientific writing conventions and 25-word sentence limits - Perfect meteorological terminology per AMS Glossary standards with complete unit consistency -- All inferred processes properly marked with complete absence of prohibited visual language -- Exceptional screen reader optimization with seamless quantitative integration +- All inferred processes properly marked with consistent inference notation +- Complete absence of assumptive visual language with clear spatial descriptions - Exemplary professional scientific voice maintaining perfect objectivity and credibility **Score 4 - Strong Fluency** - Minor grammatical issues that don't impair understanding, good sentence length control - Correct meteorological terminology with minor unit inconsistencies -- Most inferences properly marked with minimal visual language violations -- Good screen reader compatibility with occasional integration disruptions +- Most inferences properly marked with good observation/interpretation distinction +- Minimal assumptive language with generally accessible descriptions - Professional scientific tone with minor voice consistency issues **Score 3 - Adequate Fluency** -- Some grammatical errors or sentence length violations that require extra effort to understand +- Some grammatical errors that require extra effort to understand - Generally correct terminology with noticeable unit inconsistencies or minor term misusage -- Adequate inference marking but some unmarked interpretations or occasional visual references -- Basic screen reader accessibility but some flow disruptions or unclear spatial language +- Adequate inference marking but some unmarked interpretations +- Occasional assumptive visual language or unclear spatial references - Scientific tone maintained with occasional unprofessional lapses or voice shifts **Score 2 - Poor Fluency** - Frequent grammatical errors, sentence length violations, or awkward constructions that impede comprehension - Significant meteorological terminology errors or widespread unit inconsistencies -- Poor inference marking with frequent visual language violations -- Compromised screen reader accessibility due to poor spatial language or integration issues +- Poor inference marking with many unmarked dynamic processes +- Frequent assumptive visual language compromising accessibility - Inconsistent scientific tone with frequent unprofessional expressions **Score 1 - Very Poor Fluency** - Major grammatical problems throughout with extensive sentence length violations - Substantial meteorological terminology errors that compromise scientific accuracy -- Minimal inference marking with pervasive visual language assumptions -- Severely compromised accessibility with poor screen reader optimization +- Minimal inference marking throughout description +- Pervasive assumptive visual language making text inaccessible - Unprofessional tone with frequent voice inconsistencies and credibility issues **Score 0 - No Fluency** - Extensive grammatical errors making text barely comprehensible - Incorrect meteorological terminology throughout undermining scientific validity -- Complete absence of inference marking with pervasive visual assumptions -- Completely inaccessible for screen reader users +- Complete absence of inference marking for dynamic processes +- Pervasive visual assumptions making description unusable for blind users - Entirely inappropriate scientific voice destroying credibility -## CRITICAL EVALUATION STANDARDS - -**Pass-Fail Thresholds** (Automatic scoring guidance): -- **Prohibited visual language present** ("visible," "shown," "clearly," etc.): Maximum score 2 -- **Missing inference notation** for dynamic interpretations: Maximum score 2 -- **Major meteorological terminology errors**: Maximum score 2 -- **Frequent sentence length violations** (>25 words): Maximum score 3 - -**Excellence Indicators** (Score 4-5 requirements): -- **Complete absence** of prohibited visual language -- **Perfect inference marking** for all dynamic interpretations -- **AMS Glossary compliance** for all meteorological terminology -- **Flawless accessibility optimization** for screen reader users - -## COMMON PITFALLS TO AVOID - -1. **Complexity vs. Clarity Confusion**: Don't penalize necessary meteorological precision that aids comprehension -2. **Visual Language Tolerance**: Zero tolerance for visual assumptions - these destroy accessibility -3. **Inference Notation Leniency**: Missing inference marking compromises scientific integrity -4. **Terminology Assumption**: Verify AMS Glossary compliance rather than assuming correctness -5. **Screen Reader Ignorance**: Consider audio consumption patterns, punctuation effects, and spatial language clarity - ## OUTPUT REQUIREMENTS Provide your evaluation in the following XML format: @@ -337,6 +330,7 @@ **Success Check**: Your evaluation should enable a developer to understand exactly what linguistic strengths or weaknesses exist in the description and provide actionable guidance for improvement. """ + DEFAULT_CONSISTENCY_CRITERIA_EVALUATOR_USER_PROMPT = """# "Consistency" Criteria Evaluation Prompt ## ROLE AND CONTEXT SETTING From 8935b057f98c15803c49fe40d7708d15583c6d7f Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 29 Jul 2025 10:40:49 -0400 Subject: [PATCH 065/151] fix: fixing LLMInterface name error --- src/earth_reach_agent/core/evaluator.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach_agent/core/evaluator.py index 81c4283..f598601 100644 --- a/src/earth_reach_agent/core/evaluator.py +++ b/src/earth_reach_agent/core/evaluator.py @@ -9,7 +9,7 @@ from PIL.ImageFile import ImageFile from earth_reach_agent.core.generator import FigureMetadata -from earth_reach_agent.core.llm import BaseLLM, create_llm +from earth_reach_agent.core.llm import LLMInterface, create_llm from earth_reach_agent.core.prompts.evaluator import ( get_default_criterion_evaluator_user_prompt, ) @@ -49,7 +49,11 @@ class CriterionEvaluator: """Evaluator class for evaluating the quality of text based on a specified criterion.""" def __init__( - self, criterion: str, llm: BaseLLM, system_prompt: str | None, user_prompt: str + self, + criterion: str, + llm: LLMInterface, + system_prompt: str | None, + user_prompt: str, ) -> None: if criterion not in ["coherence", "fluency", "consistency", "relevance"]: raise ValueError(f"Unsupported criterion: {criterion}") @@ -302,13 +306,13 @@ class CriterionEvaluatorFactory: """Factory class for creating single criterion evaluator agents.""" @staticmethod - def create(criterion: str, llm: BaseLLM | None = None) -> CriterionEvaluator: + def create(criterion: str, llm: LLMInterface | None = None) -> CriterionEvaluator: """ Create a CriterionEvaluator instance based on the provided criterion. Args: criterion (str): Criterion name to create evaluators for. - llm (BaseLLM | None): Optional LLM instance to use for evaluation. + llm (LLMInterface | None): Optional LLM instance to use for evaluation. Returns: CriterionEvaluator: CriterionEvaluator instance. @@ -329,13 +333,14 @@ def create(criterion: str, llm: BaseLLM | None = None) -> CriterionEvaluator: class EvaluatorAgent: """Agent class for evaluating the quality of weather chart descriptions.""" - def __init__(self, criteria: List[str], llm: BaseLLM | None = None) -> None: + def __init__(self, criteria: List[str], llm: LLMInterface | None = None) -> None: """ Initialize the EvaluatorAgent. Args: criteria (List[str]): List of criteria to evaluate against. Supported criteria: "coherence", "fluency", "consistency", "relevance". + llm (LLMInterface | None): Optional LLM instance to use for evaluation. Raises: ValueError: If an unsupported criterion is provided. From 64d75592a860bb60f98acd1dfafdaba6f2ad6be8 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 29 Jul 2025 10:41:15 -0400 Subject: [PATCH 066/151] feat: improving type safety in cli --- src/earth_reach_agent/cli.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/earth_reach_agent/cli.py b/src/earth_reach_agent/cli.py index 25ffdec..a69b303 100644 --- a/src/earth_reach_agent/cli.py +++ b/src/earth_reach_agent/cli.py @@ -283,11 +283,13 @@ def generate( logger.info(f"Generating description for: {validated_image_path.name}") if simple: - description = generator.generate(image=image) + description = generator.generate( + image=image, return_intermediate_steps=False + ) else: description = orchestrator.run(image=image) - if verbose: + if verbose and isinstance(description, str): logger.info("Description generated successfully!") logger.info(f"Description length: {len(description)} characters") logger.info("-" * 50) From 7b4bc0182de5e1de1cb9d543e194fe9327f44db4 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 29 Jul 2025 10:41:34 -0400 Subject: [PATCH 067/151] feat: improving type safety in orchestrator --- src/earth_reach_agent/core/orchestrator.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/earth_reach_agent/core/orchestrator.py b/src/earth_reach_agent/core/orchestrator.py index 096325d..852e927 100644 --- a/src/earth_reach_agent/core/orchestrator.py +++ b/src/earth_reach_agent/core/orchestrator.py @@ -64,7 +64,14 @@ def run( try: for i in range(self.max_iterations): - description = self.generator_agent.generate(figure=figure, image=image) + description = self.generator_agent.generate( + figure=figure, image=image, return_intermediate_steps=False + ) + if not isinstance(description, str): + raise TypeError( + f"Expected description to be a string, got {type(description)}" + ) + if not description: raise ValueError("Generated description is empty.") From 19a44e7c0f233525a4d8b92560e5a0e658569187 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 29 Jul 2025 11:02:01 -0400 Subject: [PATCH 068/151] feat: implementing first version of pressure center extractor --- src/earth_reach_agent/core/data_extractor.py | 401 +++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 src/earth_reach_agent/core/data_extractor.py diff --git a/src/earth_reach_agent/core/data_extractor.py b/src/earth_reach_agent/core/data_extractor.py new file mode 100644 index 0000000..b2ae37c --- /dev/null +++ b/src/earth_reach_agent/core/data_extractor.py @@ -0,0 +1,401 @@ +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +from scipy.ndimage import gaussian_filter, maximum_filter, minimum_filter + + +@dataclass +class PressureCenter: + """Data structure for a pressure center.""" + + center_type: str + latitude: float + longitude: float + pressure_hPa: float + intensity: float + timestamp: datetime + confidence: float + grid_indices: Tuple[int, int] + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + "type": self.center_type, + "lat": self.latitude, + "lon": self.longitude, + "pressure_hPa": self.pressure_hPa, + "intensity": self.intensity, + "timestamp": self.timestamp.isoformat(), + "confidence": self.confidence, + "grid_indices": self.grid_indices, + } + + +class DataExtractorInterface(ABC): + """ + Abstract base class for weather data extractors. + + Provides common functionality and interface for extracting + meteorological features from various data sources. + """ + + def __init__(self, logger: Optional[logging.Logger] = None, verbose: bool = True): + """ + Initialize the data extractor. + + Args: + logger: Optional logger instance + verbose: Whether to print progress messages + """ + self.logger = logger or logging.getLogger(self.__class__.__name__) + self.verbose = verbose + self._data = None + self._metadata = {} + + @abstractmethod + def validate_data(self, data: Any) -> bool: + """ + Validate that the input data contains required variables. + + Args: + data: Input data to validate + + Returns: + bool: True if validation passes + + Raises: + ValueError: If validation fails + """ + pass + + @abstractmethod + def extract(self, data: Any, **kwargs) -> List[Any]: + """ + Extract features from the input data. + + Args: + data: Input data + **kwargs: Additional extraction parameters + + Returns: + List of extracted features + """ + pass + + @abstractmethod + def format_output(self, features: List[Any]) -> Dict[str, Any]: + """ + Format extracted features for output. + + Args: + features: List of extracted features + + Returns: + Formatted output dictionary + """ + pass + + def _log(self, message: str, level: str = "info"): + """Internal logging helper.""" + if self.verbose: + print(f"[{self.__class__.__name__}] {message}") + + log_func = getattr(self.logger, level, self.logger.info) + log_func(message) + + +class PressureCenterDataExtractor(DataExtractorInterface): + """ + Concrete implementation for extracting pressure centers from GRIB data. + + Uses local extrema detection with Gaussian smoothing to identify + high and low pressure centers in mean sea level pressure fields. + """ + + def __init__( + self, + sigma: float = 1.0, + min_distance: float = 500.0, # km + min_intensity: float = 1.0, # hPa + neighborhood: int = 8, # 4 or 8 connected + pressure_var_name: str = "msl", + **kwargs, + ): + """ + Initialize the pressure center extractor. + + Args: + sigma: Gaussian smoothing parameter (grid points) + min_distance: Minimum distance between centers (km) + min_intensity: Minimum pressure difference to be considered a center (hPa) + neighborhood: 4 or 8 connected neighborhood for extrema detection + pressure_var_name: Name of pressure variable in GRIB data + **kwargs: Additional arguments passed to parent class + """ + super().__init__(**kwargs) + + self.sigma = sigma + self.min_distance = min_distance + self.min_intensity = min_intensity + self.neighborhood = neighborhood + self.pressure_var_name = pressure_var_name + + if neighborhood not in [4, 8]: + raise ValueError("neighborhood must be 4 or 8") + + def validate_data(self, data: Any) -> bool: + """ + Validate GRIB data contains mean sea level pressure. + + Args: + data: earthkit-data GRIB dataset + + Returns: + bool: True if validation passes + + Raises: + ValueError: If required pressure variable not found + """ + try: + available_vars = [str(var) for var in data.metadata("shortName")] + + if self.pressure_var_name not in available_vars: + raise ValueError( + f"Required variable '{self.pressure_var_name}' not found. " + f"Available variables: {', '.join(available_vars)}" + ) + + lats = data.to_numpy(latitude=True) + lons = data.to_numpy(longitude=True) + + if lats is None or lons is None: + raise ValueError("Could not extract latitude/longitude coordinates") + + self._log(f"Validation passed. Found {self.pressure_var_name} variable.") + return True + + except Exception as e: + self._log(f"Validation failed: {str(e)}", level="error") + raise + + def extract( + self, data: Any, smoothing: bool = True, return_all: bool = False + ) -> List[PressureCenter]: + """ + Extract pressure centers from GRIB data. + + Args: + data: earthkit-data GRIB dataset + smoothing: Whether to apply Gaussian smoothing + return_all: If True, return all extrema without filtering + + Returns: + List of PressureCenter objects + """ + self.validate_data(data) + + pressure_data = data.sel(shortName=self.pressure_var_name) + pressure_array = pressure_data.to_numpy() + + lats = pressure_data.to_numpy(latitude=True) + lons = pressure_data.to_numpy(longitude=True) + + if smoothing and self.sigma > 0: + pressure_smoothed = gaussian_filter(pressure_array, sigma=self.sigma) + self._log(f"Applied Gaussian smoothing with sigma={self.sigma}") + else: + pressure_smoothed = pressure_array.copy() + + timestamp = data.datetime() + + high_centers = self._find_extrema( + pressure_smoothed, lats, lons, timestamp, extrema_type="high" + ) + low_centers = self._find_extrema( + pressure_smoothed, lats, lons, timestamp, extrema_type="low" + ) + + all_centers = high_centers + low_centers + + if return_all: + return all_centers + + filtered_centers = [c for c in all_centers if c.intensity >= self.min_intensity] + + final_centers = self._filter_by_distance(filtered_centers, lats, lons) + + self._log( + f"Extracted {len(final_centers)} pressure centers " + f"({sum(1 for c in final_centers if c.center_type == 'high')} high, " + f"{sum(1 for c in final_centers if c.center_type == 'low')} low)" + ) + + return final_centers + + def _find_extrema( + self, + pressure: np.ndarray, + lats: np.ndarray, + lons: np.ndarray, + timestamp: datetime, + extrema_type: str, + ) -> List[PressureCenter]: + """Find local extrema in pressure field.""" + + if self.neighborhood == 4: + struct = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]) + else: # 8-connected + struct = np.ones((3, 3)) + + if extrema_type == "high": + extrema = pressure == maximum_filter(pressure, footprint=struct) + else: # low + extrema = pressure == minimum_filter(pressure, footprint=struct) + + extrema[0, :] = False + extrema[-1, :] = False + extrema[:, 0] = False + extrema[:, -1] = False + + centers = [] + indices = np.where(extrema) + + for i, j in zip(indices[0], indices[1]): + local_region = pressure[ + max(0, i - 2) : min(pressure.shape[0], i + 3), + max(0, j - 2) : min(pressure.shape[1], j + 3), + ] + + if extrema_type == "high": + intensity = pressure[i, j] - np.mean( + local_region[local_region != pressure[i, j]] + ) + else: + intensity = ( + np.mean(local_region[local_region != pressure[i, j]]) + - pressure[i, j] + ) + + grad_y, grad_x = np.gradient( + pressure[ + max(0, i - 1) : min(pressure.shape[0], i + 2), + max(0, j - 1) : min(pressure.shape[1], j + 2), + ] + ) + gradient_magnitude = np.sqrt(grad_y**2 + grad_x**2).mean() + confidence = 1.0 - np.exp(-gradient_magnitude) + + center = PressureCenter( + center_type=extrema_type, + latitude=float(lats[i, j]) if lats.ndim > 1 else float(lats[i]), + longitude=float(lons[i, j]) if lons.ndim > 1 else float(lons[j]), + pressure_hPa=float(pressure[i, j]), + intensity=abs(float(intensity)), + timestamp=timestamp, + confidence=float(confidence), + grid_indices=(int(i), int(j)), + ) + centers.append(center) + + return centers + + def _filter_by_distance( + self, centers: List[PressureCenter], lats: np.ndarray, lons: np.ndarray + ) -> List[PressureCenter]: + """Filter centers to ensure minimum distance between them.""" + + if not centers or self.min_distance <= 0: + return centers + + sorted_centers = sorted(centers, key=lambda c: c.intensity, reverse=True) + + kept_centers = [] + for center in sorted_centers: + too_close = False + for kept in kept_centers: + distance = self._haversine_distance( + center.latitude, center.longitude, kept.latitude, kept.longitude + ) + if distance < self.min_distance: + too_close = True + break + + if not too_close: + kept_centers.append(center) + + return kept_centers + + def _haversine_distance( + self, lat1: float, lon1: float, lat2: float, lon2: float + ) -> float: + """Calculate distance between two points on Earth (km).""" + R = 6371 # Earth radius in km + + lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2]) + dlat = lat2 - lat1 + dlon = lon2 - lon1 + + a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2 + c = 2 * np.arcsin(np.sqrt(a)) + + return R * c + + def format_output(self, features: List[PressureCenter]) -> Dict[str, Any]: + """ + Format pressure centers for output. + + Returns a dictionary suitable for JSON serialization or + further processing for verbal descriptions. + """ + output = { + "extraction_type": "pressure_centers", + "timestamp": datetime.now().isoformat(), + "parameters": { + "sigma": self.sigma, + "min_distance_km": self.min_distance, + "min_intensity_hPa": self.min_intensity, + "neighborhood": self.neighborhood, + }, + "summary": { + "total_centers": len(features), + "high_pressure_centers": sum( + 1 for f in features if f.center_type == "high" + ), + "low_pressure_centers": sum( + 1 for f in features if f.center_type == "low" + ), + }, + "centers": [f.to_dict() for f in features], + } + + if features: + high_centers = [f for f in features if f.center_type == "high"] + low_centers = [f for f in features if f.center_type == "low"] + + description_parts = [] + + if high_centers: + strongest_high = max(high_centers, key=lambda c: c.pressure_hPa) + description_parts.append( + f"{len(high_centers)} high pressure center(s), " + f"strongest at {strongest_high.pressure_hPa:.1f} hPa " + f"near {strongest_high.latitude:.1f}°N, {strongest_high.longitude:.1f}°E" + ) + + if low_centers: + strongest_low = min(low_centers, key=lambda c: c.pressure_hPa) + description_parts.append( + f"{len(low_centers)} low pressure center(s), " + f"deepest at {strongest_low.pressure_hPa:.1f} hPa " + f"near {strongest_low.latitude:.1f}°N, {strongest_low.longitude:.1f}°E" + ) + + output["verbal_summary"] = ". ".join(description_parts) + else: + output["verbal_summary"] = "No significant pressure centers detected" + + return output From a892ef0a30b3a6fc9ebf0279590198beb6f5e959 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Wed, 30 Jul 2025 20:29:11 -0400 Subject: [PATCH 069/151] feat: creating centralized logging config --- src/earth_reach_agent/cli.py | 8 +--- src/earth_reach_agent/config/logging.py | 40 ++++++++++++++++++++ src/earth_reach_agent/core/data_extractor.py | 27 +++++-------- src/earth_reach_agent/core/evaluator.py | 4 +- src/earth_reach_agent/core/orchestrator.py | 4 +- 5 files changed, 56 insertions(+), 27 deletions(-) create mode 100644 src/earth_reach_agent/config/logging.py diff --git a/src/earth_reach_agent/cli.py b/src/earth_reach_agent/cli.py index a69b303..20765cf 100644 --- a/src/earth_reach_agent/cli.py +++ b/src/earth_reach_agent/cli.py @@ -3,7 +3,6 @@ CLI entrypoint for generating weather chart descriptions. """ -import logging import os import sys from pathlib import Path @@ -14,17 +13,14 @@ from PIL import Image from earth_reach_agent.config.criteria import QualityCriteria +from earth_reach_agent.config.logging import get_logger from earth_reach_agent.core.evaluator import EvaluatorAgent from earth_reach_agent.core.generator import GeneratorAgent from earth_reach_agent.core.llm import create_llm from earth_reach_agent.core.orchestrator import Orchestrator from earth_reach_agent.core.prompts.generator import get_default_generator_user_prompt -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) +logger = get_logger(__name__) load_dotenv() diff --git a/src/earth_reach_agent/config/logging.py b/src/earth_reach_agent/config/logging.py new file mode 100644 index 0000000..e7c259e --- /dev/null +++ b/src/earth_reach_agent/config/logging.py @@ -0,0 +1,40 @@ +""" +Centralized logging configuration. +""" + +import logging +import os +from typing import Optional + + +def setup_logging(level: Optional[str] = None) -> None: + """ + Configure logging for the entire application. + + Args: + level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). + If None, uses INFO level or LOG_LEVEL environment variable. + """ + if level is None: + level = os.getenv("LOG_LEVEL", "INFO").upper() + + logging.basicConfig( + level=getattr(logging, level, logging.INFO), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + +def get_logger(name: str) -> logging.Logger: + """ + Get a logger instance with the specified name. + + Args: + name: Logger name, typically __name__ from the calling module. + + Returns: + Configured logger instance. + """ + return logging.getLogger(name) + + +setup_logging() diff --git a/src/earth_reach_agent/core/data_extractor.py b/src/earth_reach_agent/core/data_extractor.py index b2ae37c..b6eb8ab 100644 --- a/src/earth_reach_agent/core/data_extractor.py +++ b/src/earth_reach_agent/core/data_extractor.py @@ -1,12 +1,15 @@ -import logging from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Tuple import numpy as np from scipy.ndimage import gaussian_filter, maximum_filter, minimum_filter +from earth_reach_agent.config.logging import get_logger + +logger = get_logger(__name__) + @dataclass class PressureCenter: @@ -43,15 +46,13 @@ class DataExtractorInterface(ABC): meteorological features from various data sources. """ - def __init__(self, logger: Optional[logging.Logger] = None, verbose: bool = True): + def __init__(self, verbose: bool = True): """ Initialize the data extractor. Args: - logger: Optional logger instance verbose: Whether to print progress messages """ - self.logger = logger or logging.getLogger(self.__class__.__name__) self.verbose = verbose self._data = None self._metadata = {} @@ -99,14 +100,6 @@ def format_output(self, features: List[Any]) -> Dict[str, Any]: """ pass - def _log(self, message: str, level: str = "info"): - """Internal logging helper.""" - if self.verbose: - print(f"[{self.__class__.__name__}] {message}") - - log_func = getattr(self.logger, level, self.logger.info) - log_func(message) - class PressureCenterDataExtractor(DataExtractorInterface): """ @@ -175,11 +168,11 @@ def validate_data(self, data: Any) -> bool: if lats is None or lons is None: raise ValueError("Could not extract latitude/longitude coordinates") - self._log(f"Validation passed. Found {self.pressure_var_name} variable.") + logger.info(f"Validation passed. Found {self.pressure_var_name} variable.") return True except Exception as e: - self._log(f"Validation failed: {str(e)}", level="error") + logger.error(f"Validation failed: {str(e)}") raise def extract( @@ -206,7 +199,7 @@ def extract( if smoothing and self.sigma > 0: pressure_smoothed = gaussian_filter(pressure_array, sigma=self.sigma) - self._log(f"Applied Gaussian smoothing with sigma={self.sigma}") + logger.info(f"Applied Gaussian smoothing with sigma={self.sigma}") else: pressure_smoothed = pressure_array.copy() @@ -228,7 +221,7 @@ def extract( final_centers = self._filter_by_distance(filtered_centers, lats, lons) - self._log( + logger.info( f"Extracted {len(final_centers)} pressure centers " f"({sum(1 for c in final_centers if c.center_type == 'high')} high, " f"{sum(1 for c in final_centers if c.center_type == 'low')} low)" diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach_agent/core/evaluator.py index f598601..366ba60 100644 --- a/src/earth_reach_agent/core/evaluator.py +++ b/src/earth_reach_agent/core/evaluator.py @@ -1,4 +1,3 @@ -import logging import re from dataclasses import MISSING, dataclass, fields from io import BytesIO @@ -8,13 +7,14 @@ from PIL import Image from PIL.ImageFile import ImageFile +from earth_reach_agent.config.logging import get_logger from earth_reach_agent.core.generator import FigureMetadata from earth_reach_agent.core.llm import LLMInterface, create_llm from earth_reach_agent.core.prompts.evaluator import ( get_default_criterion_evaluator_user_prompt, ) -logger = logging.getLogger(__name__) +logger = get_logger(__name__) @dataclass diff --git a/src/earth_reach_agent/core/orchestrator.py b/src/earth_reach_agent/core/orchestrator.py index 852e927..7304808 100644 --- a/src/earth_reach_agent/core/orchestrator.py +++ b/src/earth_reach_agent/core/orchestrator.py @@ -1,14 +1,14 @@ -import logging from typing import List import earthkit.plots as ekp from PIL.ImageFile import ImageFile +from earth_reach_agent.config.logging import get_logger from earth_reach_agent.core.evaluator import CriterionEvaluatorOutput, EvaluatorAgent from earth_reach_agent.core.generator import GeneratorAgent from earth_reach_agent.core.prompts.orchestrator import get_default_feedback_template -logger = logging.getLogger(__name__) +logger = get_logger(__name__) class Orchestrator: From 732c1cdb67c85ab09094f93ccaab7add56ef6b1c Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Wed, 30 Jul 2025 20:31:01 -0400 Subject: [PATCH 070/151] fix: fixing typo --- src/earth_reach_agent/config/logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/earth_reach_agent/config/logging.py b/src/earth_reach_agent/config/logging.py index e7c259e..d816037 100644 --- a/src/earth_reach_agent/config/logging.py +++ b/src/earth_reach_agent/config/logging.py @@ -9,7 +9,7 @@ def setup_logging(level: Optional[str] = None) -> None: """ - Configure logging for the entire application. + Configure logging for the application. Args: level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). From cee9b314a45ceb896369773a798614dfa25f37ce Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Wed, 30 Jul 2025 21:29:54 -0400 Subject: [PATCH 071/151] modifying data extractor interface --- src/earth_reach_agent/core/data_extractor.py | 66 +++----------------- 1 file changed, 8 insertions(+), 58 deletions(-) diff --git a/src/earth_reach_agent/core/data_extractor.py b/src/earth_reach_agent/core/data_extractor.py index b6eb8ab..e3efc9d 100644 --- a/src/earth_reach_agent/core/data_extractor.py +++ b/src/earth_reach_agent/core/data_extractor.py @@ -88,15 +88,16 @@ def extract(self, data: Any, **kwargs) -> List[Any]: pass @abstractmethod - def format_output(self, features: List[Any]) -> Dict[str, Any]: + def add_data_to_prompt(self, prompt: str, features: List[Any]) -> str: """ - Format extracted features for output. + Format data and add them to the prompt. Args: + prompt: Prompt string to modify features: List of extracted features Returns: - Formatted output dictionary + str: Updated prompt with formatted data """ pass @@ -337,58 +338,7 @@ def _haversine_distance( return R * c - def format_output(self, features: List[PressureCenter]) -> Dict[str, Any]: - """ - Format pressure centers for output. - - Returns a dictionary suitable for JSON serialization or - further processing for verbal descriptions. - """ - output = { - "extraction_type": "pressure_centers", - "timestamp": datetime.now().isoformat(), - "parameters": { - "sigma": self.sigma, - "min_distance_km": self.min_distance, - "min_intensity_hPa": self.min_intensity, - "neighborhood": self.neighborhood, - }, - "summary": { - "total_centers": len(features), - "high_pressure_centers": sum( - 1 for f in features if f.center_type == "high" - ), - "low_pressure_centers": sum( - 1 for f in features if f.center_type == "low" - ), - }, - "centers": [f.to_dict() for f in features], - } - - if features: - high_centers = [f for f in features if f.center_type == "high"] - low_centers = [f for f in features if f.center_type == "low"] - - description_parts = [] - - if high_centers: - strongest_high = max(high_centers, key=lambda c: c.pressure_hPa) - description_parts.append( - f"{len(high_centers)} high pressure center(s), " - f"strongest at {strongest_high.pressure_hPa:.1f} hPa " - f"near {strongest_high.latitude:.1f}°N, {strongest_high.longitude:.1f}°E" - ) - - if low_centers: - strongest_low = min(low_centers, key=lambda c: c.pressure_hPa) - description_parts.append( - f"{len(low_centers)} low pressure center(s), " - f"deepest at {strongest_low.pressure_hPa:.1f} hPa " - f"near {strongest_low.latitude:.1f}°N, {strongest_low.longitude:.1f}°E" - ) - - output["verbal_summary"] = ". ".join(description_parts) - else: - output["verbal_summary"] = "No significant pressure centers detected" - - return output + def format_output(self, prompt: str, features: List[PressureCenter]) -> str: + raise NotImplementedError( + "Should implement this method before use with DataExtractor" + ) From 5544ac16db6529c4345f75f3709ecbde2a9eb5c6 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Wed, 30 Jul 2025 21:31:13 -0400 Subject: [PATCH 072/151] doc: adding todo to prepare data extraction from fieldlist object --- src/earth_reach_agent/core/generator.py | 2 +- src/earth_reach_agent/core/orchestrator.py | 24 +++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index 814d00f..974ad08 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -151,7 +151,7 @@ def generate( "Only one of 'figure' or 'image' can be provided, not both." ) if figure is not None: - # TODO(medium): If metadata extraction fails, continue without it + # TODO(medium): If metadata extraction fails at any point, generate image and continue without figure metadata metadata = self._get_metadata_from_figure(figure) self.user_prompt = self._update_user_prompt_with_metadata( self.user_prompt, metadata diff --git a/src/earth_reach_agent/core/orchestrator.py b/src/earth_reach_agent/core/orchestrator.py index 7304808..f3da12a 100644 --- a/src/earth_reach_agent/core/orchestrator.py +++ b/src/earth_reach_agent/core/orchestrator.py @@ -1,9 +1,11 @@ from typing import List import earthkit.plots as ekp +from earthkit.data import FieldList from PIL.ImageFile import ImageFile from earth_reach_agent.config.logging import get_logger +from earth_reach_agent.core.data_extractor import DataExtractorInterface from earth_reach_agent.core.evaluator import CriterionEvaluatorOutput, EvaluatorAgent from earth_reach_agent.core.generator import GeneratorAgent from earth_reach_agent.core.prompts.orchestrator import get_default_feedback_template @@ -18,6 +20,7 @@ def __init__( self, generator_agent: GeneratorAgent, evaluator_agent: EvaluatorAgent, + data_extractors: List[DataExtractorInterface] = [], max_iterations: int = 3, criteria_threshold: int = 4, feedback_template: str | None = None, @@ -28,12 +31,14 @@ def __init__( Args: generator_agent: Instance of GeneratorAgent for generating descriptions evaluator_agent: Instance of EvaluatorAgent for evaluating descriptions + data_extractors: List of DataExtractorInterface instances for extracting features max_iterations: Maximum number of iterations for generating descriptions criteria_threshold: Minimum score for evaluation criteria to pass feedback_template: Template for feedback to the generator agent (optional) """ self.generator_agent = generator_agent self.evaluator_agent = evaluator_agent + self.data_extractors = data_extractors self.max_iterations = max_iterations self.criteria_threshold = criteria_threshold self.feedback_template = feedback_template or get_default_feedback_template() @@ -45,14 +50,18 @@ def __init__( } def run( - self, figure: ekp.Figure | None = None, image: ImageFile | None = None + self, + figure: ekp.Figure | None = None, + image: ImageFile | None = None, + data: FieldList | None = None, ) -> str: - """Run the iterative process of generating and evaluating a weather chart description - until quality criteria are met. + """ + Run the iterative process of generating and evaluating a weather chart description until quality criteria are met. Args: - figure (Figure | None): Optional figure to include in the request. Can't be used with image. - image (ImageFile | None): Optional image to include in the request (will be converted to base64). Can't be used with figure. + figure (Figure | None): Optional figure to use to generate a description. Can't be used with image. + image (ImageFile | None): Optional image to use to generate a description (will be converted to base64). Can't be used with figure. + data (FieldList | None): Optional data to use to generate a description. Returns: str: The final weather description. @@ -62,6 +71,11 @@ def run( "Only one of 'figure' or 'image' can be provided, not both." ) + # TODO(high): to integrate with the list of data extractors, verify that pressure and temperature fields are present + # if one of them is not present, log a warning + # if present then try to extract features from the data + # then add the features to the generator user prompt and evaluator prompt using a method similar to `provide_feedback_to_generator` that uses `append_user_prompt` + try: for i in range(self.max_iterations): description = self.generator_agent.generate( From 154d6d7d185206f77bc358359f49a2f88f838e9b Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Wed, 30 Jul 2025 21:33:47 -0400 Subject: [PATCH 073/151] fix: fixing typo in metadata prompt update from figure object --- src/earth_reach_agent/core/generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py index 974ad08..2e66b4c 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach_agent/core/generator.py @@ -272,7 +272,7 @@ def _update_user_prompt_with_metadata( if not metadata_items: return user_prompt - metadata_str = "# FIGURE METADATA\n\n" + metadata_str = "## FIGURE METADATA\n\n" metadata_str += "The following metadata was extracted from the figure:\n\n" metadata_str += "\n".join(metadata_items) From 1b4f0d764b70dca71f0d7b0d695110c1e296dd45 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 2 Aug 2025 15:44:50 -0400 Subject: [PATCH 074/151] refact: renaming package as earth reach --- README.md | 2 +- notebooks/earthkit_plots_exploration.py | 522 +++++++++++++++++- pyproject.toml | 10 +- .../__init__.py | 0 src/{earth_reach_agent => earth_reach}/cli.py | 15 +- .../config/__init__.py | 0 .../config/criteria.py | 0 .../config/logging.py | 0 .../core/__init__.py | 0 .../core/data_extractor.py | 3 +- .../core/evaluator.py | 13 +- .../core/generator.py | 3 +- .../core/llm.py | 3 +- .../core/orchestrator.py | 11 +- .../core/prompts/__init__.py | 0 .../core/prompts/evaluator.py | 0 .../core/prompts/generator.py | 0 .../core/prompts/orchestrator.py | 0 .../core/utils.py | 0 uv.lock | 31 +- 20 files changed, 551 insertions(+), 62 deletions(-) rename src/{earth_reach_agent => earth_reach}/__init__.py (100%) rename src/{earth_reach_agent => earth_reach}/cli.py (96%) rename src/{earth_reach_agent => earth_reach}/config/__init__.py (100%) rename src/{earth_reach_agent => earth_reach}/config/criteria.py (100%) rename src/{earth_reach_agent => earth_reach}/config/logging.py (100%) rename src/{earth_reach_agent => earth_reach}/core/__init__.py (100%) rename src/{earth_reach_agent => earth_reach}/core/data_extractor.py (99%) rename src/{earth_reach_agent => earth_reach}/core/evaluator.py (98%) rename src/{earth_reach_agent => earth_reach}/core/generator.py (99%) rename src/{earth_reach_agent => earth_reach}/core/llm.py (99%) rename src/{earth_reach_agent => earth_reach}/core/orchestrator.py (95%) rename src/{earth_reach_agent => earth_reach}/core/prompts/__init__.py (100%) rename src/{earth_reach_agent => earth_reach}/core/prompts/evaluator.py (100%) rename src/{earth_reach_agent => earth_reach}/core/prompts/generator.py (100%) rename src/{earth_reach_agent => earth_reach}/core/prompts/orchestrator.py (100%) rename src/{earth_reach_agent => earth_reach}/core/utils.py (100%) diff --git a/README.md b/README.md index e126fad..2b6e953 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ You're now ready to use the project. ├── docs/ # Project documentation ├── notebooks/ # Tutorials & experiments ├── src/ -│ ├── earth_reach_agent/ # Main package +│ ├── earth_reach/ # Main package │ └── tests/ # Unit and integration tests (to come) ├── vllm/ # VLLM inference server setup ├── pyproject.toml # Project dependencies and metadata diff --git a/notebooks/earthkit_plots_exploration.py b/notebooks/earthkit_plots_exploration.py index 42ba1a0..e5409e7 100644 --- a/notebooks/earthkit_plots_exploration.py +++ b/notebooks/earthkit_plots_exploration.py @@ -9,7 +9,7 @@ # format_version: '1.3' # jupytext_version: 1.17.1 # kernelspec: -# display_name: .venv +# display_name: EarthReach # language: python # name: python3 # --- @@ -23,18 +23,24 @@ # %% import base64 import os +import time import warnings from io import BytesIO from pathlib import Path -import pandas as pd +from typing import Dict, List -from earthkit.data import cache, config import earthkit as ek import openai -from PIL import Image -from typing import List, Dict +import pandas as pd from dotenv import load_dotenv +from earthkit.data import cache, config from IPython.display import Markdown, display +from PIL import Image +from tqdm import tqdm + +from earth_reach.core.generator import GeneratorAgent +from earth_reach.core.llm import GroqLLM +from earth_reach.core.prompts.generator import get_default_generator_user_prompt load_dotenv(Path().cwd().parent / ".env") @@ -47,6 +53,32 @@ # %% [markdown] # ## Definitions + +# %% +def get_weather_data( + date: str, + times: List[str] = ["12:00"], + variables: List[str] = ["2m_temperature", "mean_sea_level_pressure"], + dataset: str = "reanalysis-era5-single-levels", +): + date_list = date.split("/") + + request = { + "product_type": ["reanalysis"], + "variable": variables, + "day": [date_list[0]], + "month": [date_list[1]], + "year": [date_list[2]], + "time": times, + "data_format": "grib", + "download_format": "unarchived", + } + + data = ek.data.from_source("cds", dataset, request) + + return data + + # %% def display_markdown(text: str) -> None: """ @@ -58,7 +90,7 @@ def display_markdown(text: str) -> None: display(Markdown(text)) -def img_to_base64(image_path: str | None = None, img = None) -> str: +def img_to_base64(image_path: str | None = None, img=None) -> str: """ Convert an image to a base64 string. @@ -138,29 +170,8 @@ def call_llm_api(user_prompt: str, image) -> str: # %% # Copernicus datastore, requires API key set -dataset = "reanalysis-era5-single-levels" - - -def get_weather_data(date: str): - date_list = date.split("/") - - request = { - "product_type": ["reanalysis"], - "variable": ["2m_temperature", "mean_sea_level_pressure"], - "day": [date_list[0]], - "month": [date_list[1]], - "year": [date_list[2]], - "time": ["12:00"], - "data_format": "grib", - "download_format": "unarchived", - } - - data = ek.data.from_source("cds", dataset, request) - - return data - - event_date = "12/03/2011" + data = get_weather_data(event_date) data.ls() @@ -213,7 +224,7 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): sub_data, units=["celsius", "hPa"], mode="overlay", - **kwargs, # type: ignore + **kwargs, # type: ignore ) figure.save(buffer, format="png") @@ -244,8 +255,85 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): return metadata_df +# %% metadata_df = save_weather_event_images(weather_events) +# %% [markdown] +# Chart to generate again with wind vectors: 13, cold_wave, Europe, 27/02/2018 + +# %% +output_dir = str(Path().cwd().parent / "data" / "weather_images") + +figure_id = 13 +date_str = "27/02/2018" +domain_type = "Europe" +variables = [ + "10m_u_component_of_wind", + "10m_v_component_of_wind", + "2m_temperature", + "mean_sea_level_pressure", +] + +data = get_weather_data(date_str, variables=variables) + +data.ls() + +# %% +f = ek.plots.Map(domain=["Europe"]) + +temperature = data.sel(short_name="2t") +pressure = data.sel(short_name="msl") + +f.quickplot(temperature, units="celsius") +f.quickplot(pressure, units="hPa") + +f.title( + "2m Temperature (C), Mean Sea Level Pressure (hPa), and Wind Vectors on 27/02/2018 in Europe", +) + +f.coastlines(color="white", linewidth=2) +f.gridlines() + +f.quiver( + u=data.sel(short_name="10u"), + v=data.sel(short_name="10v"), +) + +f.legend() + +f.show() + +# %% +sub_data = data.sel(param=["2t", "msl", "10u", "10v"], typeOfLevel="surface") + +kwargs: Dict[str, List] = ( + {"domain": [domain_type]} if domain_type.lower() != "global" else {} +) + +buffer = BytesIO() +figure = ek.plots.quickplot( + sub_data, + # units=["celsius", "hPa"], + mode="overlay", + **kwargs, # type: ignore +) + +# TODO(high): quiver doesn't exist for a classic figure, so I need to figure out +# how to make a figure and a Map work together to plot the wind vectors +# and the temperature/pressure overlay. +# figure.quiver( +# u=data.sel(shortName="10u"), +# v=data.sel(shortName="10v"), +# resample=Subsample(1, mode="stride"), +# ) +figure.save(buffer, format="png") + +image_filename = f"figure_{figure_id}_wind.png" +image_path = os.path.join(output_dir, image_filename) +buffer.seek(0) +img = Image.open(buffer) +img.save(image_path) + # %% [markdown] # ## Exploring GRIB data @@ -580,3 +668,379 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): description = call_llm_api(enhanced_prompt, img) display_markdown(description) + +# %% [markdown] +# ## Generating Summaries for All Charts with Generator Agent + +# %% +images_dir_path = Path().cwd().parent / "data" / "weather_images" +csv_path = images_dir_path / "figure_metadata.csv" + +llm = GroqLLM( + model_name="meta-llama/llama-4-maverick-17b-128e-instruct", + api_key=os.environ.get("GROQ_API_KEY"), +) + +generator = GeneratorAgent( + llm=llm, + user_prompt=DEFAULT_USER_PROMPT, + system_prompt=None, +) + +image_files = list(images_dir_path.glob("figure_*.png")) +df = pd.read_csv(csv_path) + +if "description" not in df.columns: + df["description"] = None + +DELAY_SECONDS = 2 +SAVE_EVERY = 1 + +processed_count = 0 +for idx, row in tqdm(df.iterrows(), desc="Generating descriptions"): + if pd.isna(row["description"]): + figure_id = row["figure_id"] + image_path = images_dir_path / f"figure_{figure_id}.png" + + if image_path.exists(): + try: + image = Image.open(image_path) + description = generator.generate(image) + df.at[idx, "description"] = description + print(f"✓ Generated description for figure_{figure_id}") + + except Exception as e: + df.at[idx, "description"] = f"FAILED: {str(e)}" + print(f"✗ Failed figure_{figure_id}: {e}") + + processed_count += 1 + + if processed_count % SAVE_EVERY == 0: + df.to_csv(csv_path, index=False) + print(f"💾 Progress saved ({processed_count} processed)") + + time.sleep(DELAY_SECONDS) + +df.to_csv(csv_path, index=False) +print(f"Results saved to: {csv_path}") + +# %% [markdown] +# ## Reviewing Generated Summaries + +# %% +images_dir_path = Path().cwd().parent / "data" / "weather_images" +csv_path = images_dir_path / "figure_metadata.csv" + +# %% +image_files = list(images_dir_path.glob("figure_*.png")) +df = pd.read_csv(csv_path) +df + +# %% +from textwrap import fill + + +def show_image_with_description(image_path: Path, description: str): + """ + Display an image with its description. + + Args: + image_path (Path): Path to the image file. + description (str): Description of the image. + """ + if image_path.exists(): + try: + image = Image.open(image_path) + print( + fill(description, width=80) + ) # Format description for better readability + return image + except Exception as e: + print(f"Error displaying image {image_path}: {e}") + + +# %% +figure_id = 13 +figure_path = images_dir_path / f"figure_{figure_id}.png" +description = df.loc[df["figure_id"] == figure_id, "description"].values[0] + +show_image_with_description(figure_path, description) + +# %% [markdown] +# ## Testing Improved Generator Prompt + +# %% +from pathlib import Path + +from dotenv import load_dotenv +from PIL import Image + +from earth_reach.core.generator import GeneratorAgent +from earth_reach.core.llm import create_llm + +load_dotenv(Path().cwd().parent / ".env") + +# %% +figure_id = 13 +images_dir_path = Path().cwd().parent / "data" / "weather_images" +figure_path = images_dir_path / f"figure_{figure_id}.png" +img = Image.open(figure_path) +img + +# %% +llm = create_llm( + provider="gemini", + model_name="gemini-2.5-pro", +) + +# %% +generator = GeneratorAgent( + llm=llm, + user_prompt=get_default_generator_user_prompt(), + system_prompt=None, +) + +# %% +output = generator.generate( + image=img, + return_intermediate_steps=True, +) +print(output) + +# %% +print( + fill( + 'This weather chart displays 2-meter temperature and mean sea level pressure over Europe and North Africa for 12:00 UTC on February 27, 2018. The map domain extends from approximately 25°N to 70°N and 10°W to 45°E. Temperatures range from below -30°C (blue/purple) to over 20°C (orange), with green tones near 0°C. Isobars are drawn at 4 hPa intervals.\n\nThe synoptic situation is dominated by an exceptionally intense and large high-pressure system centered over Scandinavia and northwestern Russia. The central pressure of this anticyclone exceeds 1052 hPa. This system governs the weather pattern across the entire continent, representing a significant anomaly for late boreal winter. Its presence establishes a powerful blocking pattern, preventing milder Atlantic air from reaching Europe.\n\nThe primary consequence of this high is a severe cold air outbreak. Inferred from the anticyclonic (clockwise) circulation, strong easterly winds advect frigid continental air from Siberia westward across Europe. This results in extremely low temperatures across a vast area. Scandinavia, the Baltic states, and northern Russia experience temperatures between -15°C and -30°C. The cold air penetrates deep into Western and Central Europe, with temperatures in Germany, Poland, and the UK falling between -5°C and -15°C.\n\nA sharp and powerful frontal boundary forms across Southern Europe where the arctic air mass collides with milder air. This front extends from the Black Sea, across the Balkan Peninsula and northern Italy, into southern France. Along this zone, temperatures increase dramatically from sub-zero readings in the north to over 10°C in the south. A low-pressure system, with a central pressure near 1004 hPa, is established in the central Mediterranean Sea, south of Italy.\n\nThe interaction between these features creates active and severe weather. The tight pressure gradient between the northern high and southern low generates strong to gale-force easterly winds, particularly across Central and Eastern Europe. These winds contribute to dangerously low wind chill values. The uplift associated with the Mediterranean low, combined with the influx of cold air, would lead to heavy snowfall, especially across the mountains of Italy and the Balkans. In contrast, North Africa and the far southeastern Mediterranean remain mild, with temperatures between 15°C and 25°C.\n\nIn summary, this chart captures a classic but extreme "Beast from the East" event, a severe winter cold wave driven by an anomalously strong Scandinavian high-pressure system, resulting in widespread record-breaking cold and disruptive weather across Europe.', + 80, + ) +) + +# %% [markdown] +# ## Testing Improved Evaluator Prompt + +# %% +from pathlib import Path +from textwrap import fill + +from dotenv import load_dotenv +from PIL import Image + +from earth_reach.core.evaluator import EvaluatorAgent +from earth_reach.core.llm import create_llm + +load_dotenv(Path().cwd().parent / ".env") + +# %% +figure_id = 13 +images_dir_path = Path().cwd().parent / "data" / "weather_images" +figure_path = images_dir_path / f"figure_{figure_id}.png" +img = Image.open(figure_path) +img + +# %% +llm = create_llm( + provider="gemini", + model_name="gemini-2.5-pro", +) + +evaluator = EvaluatorAgent( + criteria=["fluency"], + llm=llm, +) + +# %% +description = 'This weather chart displays 2-meter temperature and mean sea level pressure over Europe and North Africa for 12:00 UTC on February 27, 2018. The map domain extends from approximately 25°N to 70°N and 10°W to 45°E. Temperatures range from below -30°C (blue/purple) to over 20°C (orange), with green tones near 0°C. Isobars are drawn at 4 hPa intervals.\n\nThe synoptic situation is dominated by an exceptionally intense and large high-pressure system centered over Scandinavia and northwestern Russia. The central pressure of this anticyclone exceeds 1052 hPa. This system governs the weather pattern across the entire continent, representing a significant anomaly for late boreal winter. Its presence establishes a powerful blocking pattern, preventing milder Atlantic air from reaching Europe.\n\nThe primary consequence of this high is a severe cold air outbreak. Inferred from the anticyclonic (clockwise) circulation, strong easterly winds advect frigid continental air from Siberia westward across Europe. This results in extremely low temperatures across a vast area. Scandinavia, the Baltic states, and northern Russia experience temperatures between -15°C and -30°C. The cold air penetrates deep into Western and Central Europe, with temperatures in Germany, Poland, and the UK falling between -5°C and -15°C.\n\nA sharp and powerful frontal boundary forms across Southern Europe where the arctic air mass collides with milder air. This front extends from the Black Sea, across the Balkan Peninsula and northern Italy, into southern France. Along this zone, temperatures increase dramatically from sub-zero readings in the north to over 10°C in the south. A low-pressure system, with a central pressure near 1004 hPa, is established in the central Mediterranean Sea, south of Italy.\n\nThe interaction between these features creates active and severe weather. The tight pressure gradient between the northern high and southern low generates strong to gale-force easterly winds, particularly across Central and Eastern Europe. These winds contribute to dangerously low wind chill values. The uplift associated with the Mediterranean low, combined with the influx of cold air, would lead to heavy snowfall, especially across the mountains of Italy and the Balkans. In contrast, North Africa and the far southeastern Mediterranean remain mild, with temperatures between 15°C and 25°C.\n\nIn summary, this chart captures a classic but extreme "Beast from the East" event, a severe winter cold wave driven by an anomalously strong Scandinavian high-pressure system, resulting in widespread record-breaking cold and disruptive weather across Europe.' + +# %% +output = evaluator.evaluate( + description=description, + image=img, +) +print(output) + +# %% +for eval in output: + print(f"Criterion: {eval.name}, Score: {eval.score}") + if eval.name == "fluency": + print(f"Fluency Feedback:\n\n{fill(eval.reasoning, 80)}") + +# %% [markdown] +# ## Testing Improved Complete System + +# %% +from pathlib import Path +from textwrap import fill + +from dotenv import load_dotenv +from PIL import Image + +from earth_reach.core.evaluator import EvaluatorAgent +from earth_reach.core.generator import GeneratorAgent +from earth_reach.core.llm import create_llm +from earth_reach.core.orchestrator import Orchestrator +from earth_reach.core.prompts.generator import get_default_generator_user_prompt + +load_dotenv(Path().cwd().parent / ".env") + +# %% +figure_id = 13 +images_dir_path = Path().cwd().parent / "data" / "weather_images" +figure_path = images_dir_path / f"figure_{figure_id}.png" +img = Image.open(figure_path) +img + +# %% +llm = create_llm( + provider="gemini", + model_name="gemini-2.5-pro", +) + +evaluator = EvaluatorAgent( + criteria=["coherence", "fluency", "consistency", "relevance"], + llm=llm, +) + +generator = GeneratorAgent( + llm=llm, + user_prompt=get_default_generator_user_prompt(), + system_prompt=None, +) + +orchestrator = Orchestrator( + generator_agent=generator, + evaluator_agent=evaluator, +) + +# %% +description = orchestrator.run( + image=img, +) +print(fill(description, 80)) + +# %% +print(description.replace("\n", "")) + +# %% [markdown] +# ## Generate Descriptions for All Figures with Improved System + +# %% +import time +from datetime import datetime +from pathlib import Path +from typing import Optional + +import pandas as pd +from dotenv import load_dotenv +from PIL import Image +from tqdm import tqdm + +from earth_reach.core.evaluator import EvaluatorAgent +from earth_reach.core.generator import GeneratorAgent +from earth_reach.core.llm import create_llm +from earth_reach.core.orchestrator import Orchestrator +from earth_reach.core.prompts.generator import get_default_generator_user_prompt + +load_dotenv(Path().cwd().parent / ".env") + +# %% +images_dir_path = Path().cwd().parent / "data" / "weather_images" +csv_path = images_dir_path / "figure_metadata.csv" + + +def generate_descriptions_to_text( + orchestrator, + images_dir_path: Path, + model_name: str, + output_filename: str = "figure_descriptions.txt", + delay_seconds: int = 2, + delay: bool = True, + verbose: bool = False, +): + output_path = images_dir_path / output_filename + figure_files = sorted(images_dir_path.glob("figure_*.png")) + + if not figure_files: + print("No figure files found!") + return + + print(f"Found {len(figure_files)} figures to process") + + for i, image_path in enumerate(tqdm(figure_files, desc="Generating descriptions")): + figure_id = image_path.stem.replace("figure_", "") + + try: + image = Image.open(image_path) + + start_time = time.time() + description = orchestrator.run(image=image) + computation_time = time.time() - start_time + + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + with open(output_path, "a", encoding="utf-8") as f: + f.write(f"{'=' * 80}\n") + f.write(f"Figure ID: {figure_id}\n") + f.write(f"Model: {model_name}\n") + f.write(f"Computation Time: {computation_time:.2f} seconds\n") + f.write(f"Generated At: {timestamp}\n") + f.write(f"{'=' * 80}\n\n") + f.write(f"{description}\n\n") + + print(f"✓ Processed {image_path.name} ({computation_time:.2f}s)") + + if verbose: + print(f"\nFigure ID: {figure_id}") + print(f"Description: {description}\n") + + if delay and i < len(figure_files) - 1: + time.sleep(delay_seconds) + + except Exception as e: + print(f"✗ Failed {image_path.name}: {str(e)}") + + with open(output_path, "a", encoding="utf-8") as f: + f.write(f"{'=' * 80}\n") + f.write(f"Figure ID: {figure_id}\n") + f.write(f"Model: {model_name}\n") + f.write("Computation Time: 0.00 seconds\n") + f.write( + f"Generated At: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" + ) + f.write(f"{'=' * 80}\n\n") + f.write(f"ERROR: Failed to generate description\nReason: {str(e)}\n\n") + + print(f"\n✅ Results saved to: {output_path}") + + +# %% +llm = create_llm( + provider="gemini", + model_name="gemini-2.5-pro", +) + +evaluator = EvaluatorAgent( + criteria=["coherence", "fluency", "consistency", "relevance"], + llm=llm, +) + +generator = GeneratorAgent( + llm=llm, + user_prompt=get_default_generator_user_prompt(), + system_prompt=None, +) + +orchestrator = Orchestrator( + generator_agent=generator, + evaluator_agent=evaluator, +) + +# %% +generate_descriptions_to_text( + orchestrator=orchestrator, + images_dir_path=images_dir_path, + output_filename="figure_descriptions.txt", + model_name="gemini-2.5-pro", + delay_seconds=2, + delay=True, + verbose=True, +) diff --git a/pyproject.toml b/pyproject.toml index 3e0d9b6..97168ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "earth-reach-agent" +name = "earth-reach" version = "0.1.0" description = "EarthReach Agent: Dual-LLM Framework for Validated Meteorological Chart Descriptions" readme = "README.md" @@ -23,14 +23,16 @@ dependencies = [ dev = [ "jupytext>=1.17.1", "ipykernel>=6.29.5", + "ruff>=0.12.7", + "pytest>=8.3.5", ] [project.scripts] -earth-reach-agent = "earth_reach_agent.cli:cli" -era = "earth_reach_agent.cli:cli" +earth-reach-agent = "earth_reach.cli:cli" +era = "earth_reach.cli:cli" [tool.hatch.build.targets.wheel] -packages = ["src/earth_reach_agent"] +packages = ["src/earth_reach"] [tool.hatch.metadata] allow-direct-references = true diff --git a/src/earth_reach_agent/__init__.py b/src/earth_reach/__init__.py similarity index 100% rename from src/earth_reach_agent/__init__.py rename to src/earth_reach/__init__.py diff --git a/src/earth_reach_agent/cli.py b/src/earth_reach/cli.py similarity index 96% rename from src/earth_reach_agent/cli.py rename to src/earth_reach/cli.py index 20765cf..ec135b1 100644 --- a/src/earth_reach_agent/cli.py +++ b/src/earth_reach/cli.py @@ -10,16 +10,15 @@ import fire from dotenv import load_dotenv +from earth_reach.config.criteria import QualityCriteria +from earth_reach.config.logging import get_logger +from earth_reach.core.evaluator import EvaluatorAgent +from earth_reach.core.generator import GeneratorAgent +from earth_reach.core.llm import create_llm +from earth_reach.core.orchestrator import Orchestrator +from earth_reach.core.prompts.generator import get_default_generator_user_prompt from PIL import Image -from earth_reach_agent.config.criteria import QualityCriteria -from earth_reach_agent.config.logging import get_logger -from earth_reach_agent.core.evaluator import EvaluatorAgent -from earth_reach_agent.core.generator import GeneratorAgent -from earth_reach_agent.core.llm import create_llm -from earth_reach_agent.core.orchestrator import Orchestrator -from earth_reach_agent.core.prompts.generator import get_default_generator_user_prompt - logger = get_logger(__name__) load_dotenv() diff --git a/src/earth_reach_agent/config/__init__.py b/src/earth_reach/config/__init__.py similarity index 100% rename from src/earth_reach_agent/config/__init__.py rename to src/earth_reach/config/__init__.py diff --git a/src/earth_reach_agent/config/criteria.py b/src/earth_reach/config/criteria.py similarity index 100% rename from src/earth_reach_agent/config/criteria.py rename to src/earth_reach/config/criteria.py diff --git a/src/earth_reach_agent/config/logging.py b/src/earth_reach/config/logging.py similarity index 100% rename from src/earth_reach_agent/config/logging.py rename to src/earth_reach/config/logging.py diff --git a/src/earth_reach_agent/core/__init__.py b/src/earth_reach/core/__init__.py similarity index 100% rename from src/earth_reach_agent/core/__init__.py rename to src/earth_reach/core/__init__.py diff --git a/src/earth_reach_agent/core/data_extractor.py b/src/earth_reach/core/data_extractor.py similarity index 99% rename from src/earth_reach_agent/core/data_extractor.py rename to src/earth_reach/core/data_extractor.py index e3efc9d..8e017fb 100644 --- a/src/earth_reach_agent/core/data_extractor.py +++ b/src/earth_reach/core/data_extractor.py @@ -4,10 +4,9 @@ from typing import Any, Dict, List, Tuple import numpy as np +from earth_reach.config.logging import get_logger from scipy.ndimage import gaussian_filter, maximum_filter, minimum_filter -from earth_reach_agent.config.logging import get_logger - logger = get_logger(__name__) diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach/core/evaluator.py similarity index 98% rename from src/earth_reach_agent/core/evaluator.py rename to src/earth_reach/core/evaluator.py index 366ba60..2e29c52 100644 --- a/src/earth_reach_agent/core/evaluator.py +++ b/src/earth_reach/core/evaluator.py @@ -4,15 +4,14 @@ from typing import Any, List, Union, get_args, get_origin import earthkit.plots as ekp -from PIL import Image -from PIL.ImageFile import ImageFile - -from earth_reach_agent.config.logging import get_logger -from earth_reach_agent.core.generator import FigureMetadata -from earth_reach_agent.core.llm import LLMInterface, create_llm -from earth_reach_agent.core.prompts.evaluator import ( +from earth_reach.config.logging import get_logger +from earth_reach.core.generator import FigureMetadata +from earth_reach.core.llm import LLMInterface, create_llm +from earth_reach.core.prompts.evaluator import ( get_default_criterion_evaluator_user_prompt, ) +from PIL import Image +from PIL.ImageFile import ImageFile logger = get_logger(__name__) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach/core/generator.py similarity index 99% rename from src/earth_reach_agent/core/generator.py rename to src/earth_reach/core/generator.py index 2e66b4c..be8a44b 100644 --- a/src/earth_reach_agent/core/generator.py +++ b/src/earth_reach/core/generator.py @@ -4,11 +4,10 @@ from typing import List import earthkit.plots as ekp +from earth_reach.core.llm import LLMInterface from PIL import Image from PIL.ImageFile import ImageFile -from earth_reach_agent.core.llm import LLMInterface - @dataclass class FigureMetadata: diff --git a/src/earth_reach_agent/core/llm.py b/src/earth_reach/core/llm.py similarity index 99% rename from src/earth_reach_agent/core/llm.py rename to src/earth_reach/core/llm.py index 5aa5d75..2ad735b 100644 --- a/src/earth_reach_agent/core/llm.py +++ b/src/earth_reach/core/llm.py @@ -2,11 +2,10 @@ from abc import ABC, abstractmethod import openai +from earth_reach.core.utils import img_to_base64, img_to_bytes from google import genai from google.genai import types -from earth_reach_agent.core.utils import img_to_base64, img_to_bytes - class LLMInterface(ABC): """Abstract base class defining the interface for all LLM provider implementations.""" diff --git a/src/earth_reach_agent/core/orchestrator.py b/src/earth_reach/core/orchestrator.py similarity index 95% rename from src/earth_reach_agent/core/orchestrator.py rename to src/earth_reach/core/orchestrator.py index f3da12a..c17bfd5 100644 --- a/src/earth_reach_agent/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -1,15 +1,14 @@ from typing import List import earthkit.plots as ekp +from earth_reach.config.logging import get_logger +from earth_reach.core.data_extractor import DataExtractorInterface +from earth_reach.core.evaluator import CriterionEvaluatorOutput, EvaluatorAgent +from earth_reach.core.generator import GeneratorAgent +from earth_reach.core.prompts.orchestrator import get_default_feedback_template from earthkit.data import FieldList from PIL.ImageFile import ImageFile -from earth_reach_agent.config.logging import get_logger -from earth_reach_agent.core.data_extractor import DataExtractorInterface -from earth_reach_agent.core.evaluator import CriterionEvaluatorOutput, EvaluatorAgent -from earth_reach_agent.core.generator import GeneratorAgent -from earth_reach_agent.core.prompts.orchestrator import get_default_feedback_template - logger = get_logger(__name__) diff --git a/src/earth_reach_agent/core/prompts/__init__.py b/src/earth_reach/core/prompts/__init__.py similarity index 100% rename from src/earth_reach_agent/core/prompts/__init__.py rename to src/earth_reach/core/prompts/__init__.py diff --git a/src/earth_reach_agent/core/prompts/evaluator.py b/src/earth_reach/core/prompts/evaluator.py similarity index 100% rename from src/earth_reach_agent/core/prompts/evaluator.py rename to src/earth_reach/core/prompts/evaluator.py diff --git a/src/earth_reach_agent/core/prompts/generator.py b/src/earth_reach/core/prompts/generator.py similarity index 100% rename from src/earth_reach_agent/core/prompts/generator.py rename to src/earth_reach/core/prompts/generator.py diff --git a/src/earth_reach_agent/core/prompts/orchestrator.py b/src/earth_reach/core/prompts/orchestrator.py similarity index 100% rename from src/earth_reach_agent/core/prompts/orchestrator.py rename to src/earth_reach/core/prompts/orchestrator.py diff --git a/src/earth_reach_agent/core/utils.py b/src/earth_reach/core/utils.py similarity index 100% rename from src/earth_reach_agent/core/utils.py rename to src/earth_reach/core/utils.py diff --git a/uv.lock b/uv.lock index dc19987..e4c9880 100644 --- a/uv.lock +++ b/uv.lock @@ -472,7 +472,7 @@ wheels = [ ] [[package]] -name = "earth-reach-agent" +name = "earth-reach" version = "0.1.0" source = { editable = "." } dependencies = [ @@ -490,6 +490,8 @@ dependencies = [ dev = [ { name = "ipykernel" }, { name = "jupytext" }, + { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] @@ -508,6 +510,8 @@ requires-dist = [ dev = [ { name = "ipykernel", specifier = ">=6.29.5" }, { name = "jupytext", specifier = ">=1.17.1" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "ruff", specifier = ">=0.12.7" }, ] [[package]] @@ -2259,6 +2263,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 }, ] +[[package]] +name = "ruff" +version = "0.12.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/81/0bd3594fa0f690466e41bd033bdcdf86cba8288345ac77ad4afbe5ec743a/ruff-0.12.7.tar.gz", hash = "sha256:1fc3193f238bc2d7968772c82831a4ff69252f673be371fb49663f0068b7ec71", size = 5197814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/d2/6cb35e9c85e7a91e8d22ab32ae07ac39cc34a71f1009a6f9e4a2a019e602/ruff-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:76e4f31529899b8c434c3c1dede98c4483b89590e15fb49f2d46183801565303", size = 11852189 }, + { url = "https://files.pythonhosted.org/packages/63/5b/a4136b9921aa84638f1a6be7fb086f8cad0fde538ba76bda3682f2599a2f/ruff-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:789b7a03e72507c54fb3ba6209e4bb36517b90f1a3569ea17084e3fd295500fb", size = 12519389 }, + { url = "https://files.pythonhosted.org/packages/a8/c9/3e24a8472484269b6b1821794141f879c54645a111ded4b6f58f9ab0705f/ruff-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e1c2a3b8626339bb6369116e7030a4cf194ea48f49b64bb505732a7fce4f4e3", size = 11743384 }, + { url = "https://files.pythonhosted.org/packages/26/7c/458dd25deeb3452c43eaee853c0b17a1e84169f8021a26d500ead77964fd/ruff-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32dec41817623d388e645612ec70d5757a6d9c035f3744a52c7b195a57e03860", size = 11943759 }, + { url = "https://files.pythonhosted.org/packages/7f/8b/658798472ef260ca050e400ab96ef7e85c366c39cf3dfbef4d0a46a528b6/ruff-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47ef751f722053a5df5fa48d412dbb54d41ab9b17875c6840a58ec63ff0c247c", size = 11654028 }, + { url = "https://files.pythonhosted.org/packages/a8/86/9c2336f13b2a3326d06d39178fd3448dcc7025f82514d1b15816fe42bfe8/ruff-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a828a5fc25a3efd3e1ff7b241fd392686c9386f20e5ac90aa9234a5faa12c423", size = 13225209 }, + { url = "https://files.pythonhosted.org/packages/76/69/df73f65f53d6c463b19b6b312fd2391dc36425d926ec237a7ed028a90fc1/ruff-0.12.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5726f59b171111fa6a69d82aef48f00b56598b03a22f0f4170664ff4d8298efb", size = 14182353 }, + { url = "https://files.pythonhosted.org/packages/58/1e/de6cda406d99fea84b66811c189b5ea139814b98125b052424b55d28a41c/ruff-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74e6f5c04c4dd4aba223f4fe6e7104f79e0eebf7d307e4f9b18c18362124bccd", size = 13631555 }, + { url = "https://files.pythonhosted.org/packages/6f/ae/625d46d5164a6cc9261945a5e89df24457dc8262539ace3ac36c40f0b51e/ruff-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0bfe4e77fba61bf2ccadf8cf005d6133e3ce08793bbe870dd1c734f2699a3e", size = 12667556 }, + { url = "https://files.pythonhosted.org/packages/55/bf/9cb1ea5e3066779e42ade8d0cd3d3b0582a5720a814ae1586f85014656b6/ruff-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06bfb01e1623bf7f59ea749a841da56f8f653d641bfd046edee32ede7ff6c606", size = 12939784 }, + { url = "https://files.pythonhosted.org/packages/55/7f/7ead2663be5627c04be83754c4f3096603bf5e99ed856c7cd29618c691bd/ruff-0.12.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e41df94a957d50083fd09b916d6e89e497246698c3f3d5c681c8b3e7b9bb4ac8", size = 11771356 }, + { url = "https://files.pythonhosted.org/packages/17/40/a95352ea16edf78cd3a938085dccc55df692a4d8ba1b3af7accbe2c806b0/ruff-0.12.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4000623300563c709458d0ce170c3d0d788c23a058912f28bbadc6f905d67afa", size = 11612124 }, + { url = "https://files.pythonhosted.org/packages/4d/74/633b04871c669e23b8917877e812376827c06df866e1677f15abfadc95cb/ruff-0.12.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:69ffe0e5f9b2cf2b8e289a3f8945b402a1b19eff24ec389f45f23c42a3dd6fb5", size = 12479945 }, + { url = "https://files.pythonhosted.org/packages/be/34/c3ef2d7799c9778b835a76189c6f53c179d3bdebc8c65288c29032e03613/ruff-0.12.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a07a5c8ffa2611a52732bdc67bf88e243abd84fe2d7f6daef3826b59abbfeda4", size = 12998677 }, + { url = "https://files.pythonhosted.org/packages/77/ab/aca2e756ad7b09b3d662a41773f3edcbd262872a4fc81f920dc1ffa44541/ruff-0.12.7-py3-none-win32.whl", hash = "sha256:c928f1b2ec59fb77dfdf70e0419408898b63998789cc98197e15f560b9e77f77", size = 11756687 }, + { url = "https://files.pythonhosted.org/packages/b4/71/26d45a5042bc71db22ddd8252ca9d01e9ca454f230e2996bb04f16d72799/ruff-0.12.7-py3-none-win_amd64.whl", hash = "sha256:9c18f3d707ee9edf89da76131956aba1270c6348bfee8f6c647de841eac7194f", size = 12912365 }, + { url = "https://files.pythonhosted.org/packages/4c/9b/0b8aa09817b63e78d94b4977f18b1fcaead3165a5ee49251c5d5c245bb2d/ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69", size = 11982083 }, +] + [[package]] name = "scipy" version = "1.15.2" From 687e069fb05f1a06ed37720cc15e515c5c94ffaf Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 2 Aug 2025 16:50:09 -0400 Subject: [PATCH 075/151] feat: adding gemini and claude models as optional dependencies --- pyproject.toml | 13 ++++++++++++- uv.lock | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 97168ec..9b45958 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,6 @@ dependencies = [ "python-dotenv>=1.1.0", "hatchling>=1.27.0", "fire @ git+https://github.com/google/python-fire.git@2025-03-23-ipython", # Fixes IPython v9.0 compatibility issue - "google-genai>=1.27.0", ] [dependency-groups] @@ -27,6 +26,18 @@ dev = [ "pytest>=8.3.5", ] +[project.optional-dependencies] +gemini = [ + "google-genai>=1.27.0", +] +claude = [ + "anthropic>=0.60.0", +] +all-models = [ + "google-genai>=1.27.0", + "anthropic>=0.60.0" +] + [project.scripts] earth-reach-agent = "earth_reach.cli:cli" era = "earth_reach.cli:cli" diff --git a/uv.lock b/uv.lock index e4c9880..30cfcc2 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] +[[package]] +name = "anthropic" +version = "0.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/03/3334921dc54ed822b3dd993ae72d823a7402588521bbba3e024b3333a1fd/anthropic-0.60.0.tar.gz", hash = "sha256:a22ba187c6f4fd5afecb2fc913b960feccf72bc0d25c1b7ce0345e87caede577", size = 425983 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/bb/d84f287fb1c217b30c328af987cf8bbe3897edf0518dcc5fa39412f794ec/anthropic-0.60.0-py3-none-any.whl", hash = "sha256:65ad1f088a960217aaf82ba91ff743d6c89e9d811c6d64275b9a7c59ee9ac3c6", size = 293116 }, +] + [[package]] name = "anyio" version = "4.9.0" @@ -480,12 +498,23 @@ dependencies = [ { name = "earthkit-data" }, { name = "earthkit-plots" }, { name = "fire" }, - { name = "google-genai" }, { name = "hatchling" }, { name = "openai" }, { name = "python-dotenv" }, ] +[package.optional-dependencies] +all-models = [ + { name = "anthropic" }, + { name = "google-genai" }, +] +claude = [ + { name = "anthropic" }, +] +gemini = [ + { name = "google-genai" }, +] + [package.dev-dependencies] dev = [ { name = "ipykernel" }, @@ -496,15 +525,19 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic", marker = "extra == 'all-models'", specifier = ">=0.60.0" }, + { name = "anthropic", marker = "extra == 'claude'", specifier = ">=0.60.0" }, { name = "earthkit", specifier = ">=0.10.3" }, { name = "earthkit-data", specifier = ">=0.13.8" }, { name = "earthkit-plots", specifier = ">=0.3.1" }, { name = "fire", git = "https://github.com/google/python-fire.git?rev=2025-03-23-ipython" }, - { name = "google-genai", specifier = ">=1.27.0" }, + { name = "google-genai", marker = "extra == 'all-models'", specifier = ">=1.27.0" }, + { name = "google-genai", marker = "extra == 'gemini'", specifier = ">=1.27.0" }, { name = "hatchling", specifier = ">=1.27.0" }, { name = "openai", specifier = ">=1.77.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, ] +provides-extras = ["gemini", "claude", "all-models"] [package.metadata.requires-dev] dev = [ From 4cee2ee3907a1b710277ac70f89b68ede91e5a1a Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 2 Aug 2025 16:50:42 -0400 Subject: [PATCH 076/151] feat: creating earth reach agent template class for future main integration point with earthkit-plots lib --- src/earth_reach/main.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/earth_reach/main.py diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py new file mode 100644 index 0000000..7965c15 --- /dev/null +++ b/src/earth_reach/main.py @@ -0,0 +1,2 @@ +class EarthReachAgent: + pass From d372ebf463266bd5a26a9f0b9e2d9f08e66cea08 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 3 Aug 2025 11:22:15 -0400 Subject: [PATCH 077/151] feat: adding pre-commit hooks with ruff configured to increase code quality --- .pre-commit-config.yaml | 18 +++++++++++ .python-version | 1 + README.md | 54 +++++++++++++++++++++++++++++--- pyproject.toml | 29 ++++++++++++++++-- uv.lock | 68 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 .python-version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f59240a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + - id: check-toml + - id: debug-statements + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.7 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md index 2b6e953..eb3a82d 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,7 @@ EarthReach is a challenge from the 2025 edition dedicated to enhancing the acces ### Prerequisites -- Python 3.12 or higher -- [uv](https://docs.astral.sh/uv/) - Python package and project manager +- [uv](https://docs.astral.sh/uv/) - Python package and project manager (will automatically install Python 3.12+ if needed) ### Setup @@ -32,23 +31,29 @@ EarthReach is a challenge from the 2025 edition dedicated to enhancing the acces ``` 2. **Create a virtual environment and install dependencies** ```sh - uv sync + uv sync --group dev ``` This command will automatically: - Create a .venv virtual environment - Install all project dependencies from pyproject.toml +- Install development dependencies 3. **Activate the virtual environment** ```sh source .venv/bin/activate # On Windows, use: .venv\Scripts\activate ``` +4. **Set up pre-commit hooks (recommended for development)** + ```sh + uv run pre-commit install + ``` + You're now ready to use the project. ## Project Structure ```sh -. +. ├── docs/ # Project documentation ├── notebooks/ # Tutorials & experiments ├── src/ @@ -61,7 +66,7 @@ You're now ready to use the project. ## VLLM Inference Server -To run this project, you will need to have an openAI-compatible LLM inference server. +To run this project, you will need to have an openAI-compatible LLM inference server. We provide instructions on how to setup your own secured inference server using [VLLM](./vllm/setup.md). @@ -90,6 +95,45 @@ Evaluate the accuracy of a description against a weather chart: ```sh uv run era evaluate --image-path --description "" ``` + +## Development + +### Development Tooling + +This project uses the following development tools: + +- **Ruff**: Fast Python linter and formatter +- **Pre-commit**: Git hooks for code quality checks +- **Pytest**: Testing framework + +### Code Quality + +The project is configured with pre-commit hooks that run automatically before each commit to ensure code quality: + +- **Ruff linting**: Checks for common Python issues and enforces coding standards +- **Ruff formatting**: Automatically formats code for consistency +- **Basic checks**: Trailing whitespace, file endings, YAML/TOML syntax, merge conflicts + +### Running Tools Manually + +You can run the development tools manually: + +```sh +# Run ruff linter +uv run ruff check . + +# Run ruff formatter +uv run ruff format . + +# Run pre-commit hooks on all files +pre-commit run --all-files +``` + +### Configuration + +- Ruff configuration is in `pyproject.toml` under `[tool.ruff]` +- Pre-commit configuration is in `.pre-commit-config.yaml` + ## License See [LICENSE](LICENSE) diff --git a/pyproject.toml b/pyproject.toml index 9b45958..2081308 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dev = [ "ipykernel>=6.29.5", "ruff>=0.12.7", "pytest>=8.3.5", + "pre-commit>=4.0.1", ] [project.optional-dependencies] @@ -33,9 +34,9 @@ gemini = [ claude = [ "anthropic>=0.60.0", ] -all-models = [ - "google-genai>=1.27.0", - "anthropic>=0.60.0" +all-models = [ + "google-genai>=1.27.0", + "anthropic>=0.60.0" ] [project.scripts] @@ -47,3 +48,25 @@ packages = ["src/earth_reach"] [tool.hatch.metadata] allow-direct-references = true + +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long, handled by formatter +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/uv.lock b/uv.lock index 30cfcc2..dbb5fbd 100644 --- a/uv.lock +++ b/uv.lock @@ -206,6 +206,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/d7/96b4209c99f1fd6c19f502cebe8c91983c23331c380f3f521250f268ae8c/cfgrib-0.9.15.0-py3-none-any.whl", hash = "sha256:469cfd25dc173863795e596263b3b6b5ea1402b1715f2b7b1d4b995b40b32c18", size = 48908 }, ] +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, +] + [[package]] name = "cftime" version = "1.6.4.post1" @@ -471,6 +480,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178 }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, +] + [[package]] name = "distro" version = "1.9.0" @@ -519,6 +537,7 @@ gemini = [ dev = [ { name = "ipykernel" }, { name = "jupytext" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "ruff" }, ] @@ -543,6 +562,7 @@ provides-extras = ["gemini", "claude", "all-models"] dev = [ { name = "ipykernel", specifier = ">=6.29.5" }, { name = "jupytext", specifier = ">=1.17.1" }, + { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "ruff", specifier = ">=0.12.7" }, ] @@ -1004,6 +1024,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] +[[package]] +name = "identify" +version = "2.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145 }, +] + [[package]] name = "idna" version = "3.10" @@ -1501,6 +1530,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/b5/e04550fd53de57001dbd5a87242da7ff784c80790adc48897977b6ccf891/netCDF4-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:5c5fbee6134ee1246c397e1508e5297d825aa19221fdf3fa8dc9727ad824d7a5", size = 6990521 }, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + [[package]] name = "numpy" version = "2.2.5" @@ -1816,6 +1854,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/75/e3fd211c9b273df86ff387b989c794b6bd44ddc01dd00c720a18bfd01cde/polytope_client-0.7.4-py3-none-any.whl", hash = "sha256:fc038b41c6e0c6ad11efc8953f58e6399ef65e66dfa3953edfbb84510f49a823", size = 49680 }, ] +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 }, +] + [[package]] name = "prompt-toolkit" version = "3.0.51" @@ -2540,6 +2594,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 }, ] +[[package]] +name = "virtualenv" +version = "20.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2e/8a70dcbe8bf15213a08f9b0325ede04faca5d362922ae0d62ef0fa4b069d/virtualenv-20.33.0.tar.gz", hash = "sha256:47e0c0d2ef1801fce721708ccdf2a28b9403fa2307c3268aebd03225976f61d2", size = 6082069 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/87/b22cf40cdf7e2b2bf83f38a94d2c90c5ad6c304896e5a12d0c08a602eb59/virtualenv-20.33.0-py3-none-any.whl", hash = "sha256:106b6baa8ab1b526d5a9b71165c85c456fbd49b16976c88e2bc9352ee3bc5d3f", size = 6060205 }, +] + [[package]] name = "wcwidth" version = "0.2.13" From 1ee83cfc2b9cd08e3e9c77db0f75c5457307ddb6 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 3 Aug 2025 11:50:11 -0400 Subject: [PATCH 078/151] feat: adding more ruff linting rules to increase code quality, adding auto fix when available --- pyproject.toml | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2081308..db3b9de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,21 +52,37 @@ allow-direct-references = true [tool.ruff] line-length = 88 target-version = "py312" +exclude = ["notebooks/"] [tool.ruff.lint] select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "COM", # trailing commas + "Q", # quotes + "SIM", # simplify + "RUF", # Ruff-specific rules + "PIE", # flake8-pie + "RET", # flake8-return + "TCH", # type-checking ] ignore = [ "E501", # line too long, handled by formatter + "COM812", # trailing comma conflicts with formatter ] +fixable = ["ALL"] [tool.ruff.format] quote-style = "double" indent-style = "space" + +[tool.ruff.lint.isort] +known-first-party = ["earth_reach"] +force-single-line = false +lines-between-types = 1 +split-on-trailing-comma = true From d753e518f6ea8ba51a15a28a6244fa0a5d6d7b83 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 3 Aug 2025 11:50:49 -0400 Subject: [PATCH 079/151] fix: fixing trailing whitespaces with pre-commit hooks --- .gitignore | 2 +- LICENSE | 2 +- vllm/.env.example | 2 +- vllm/docker-compose.yaml | 2 +- vllm/setup.md | 12 ++++++------ vllm/setup.sh | 10 +++++----- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 2c54a30..6971f4b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,4 @@ weather_images/ # Images *.png *.jpg -*.jpeg \ No newline at end of file +*.jpeg diff --git a/LICENSE b/LICENSE index 2ffd622..e47c543 100644 --- a/LICENSE +++ b/LICENSE @@ -199,4 +199,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file + limitations under the License. diff --git a/vllm/.env.example b/vllm/.env.example index 5a08d0f..8731f4f 100644 --- a/vllm/.env.example +++ b/vllm/.env.example @@ -12,4 +12,4 @@ HF_CACHE_DIR=/dev/shm/hf-cache MODEL_NAME="google/gemma-3-4b-it" HF_HUB_TOKEN= VLLM_SERVER_API_KEY= -VLLM_PORT=8000 \ No newline at end of file +VLLM_PORT=8000 diff --git a/vllm/docker-compose.yaml b/vllm/docker-compose.yaml index d40891f..caef703 100644 --- a/vllm/docker-compose.yaml +++ b/vllm/docker-compose.yaml @@ -31,7 +31,7 @@ services: # - "traefik.http.routers.vllm-server.tls.domains[0].main=${DOMAIN}" # - "traefik.http.routers.vllm-server.tls.domains[0].sans=*.${DOMAIN}" # - "traefik.http.services.vllm-server.loadbalancer.server.port=${VLLM_PORT}" - + traefik: image: traefik:v2.9 container_name: traefik diff --git a/vllm/setup.md b/vllm/setup.md index 42e0988..7492f6a 100644 --- a/vllm/setup.md +++ b/vllm/setup.md @@ -25,18 +25,18 @@ Start by making sure your server is up to date: ```sh # Search for updates and apply them -sudo dnf upgrade +sudo dnf upgrade ``` ### Firewall Let's allow HTTP, HTTPS connections and deny the other types by default. -TODO(high): update to only accept requests from cloudflare servers for requests other than SSH +TODO(high): update to only accept requests from cloudflare servers for requests other than SSH ```sh -# Install firewalld -sudo dnf install firewalld +# Install firewalld +sudo dnf install firewalld # Start and enable firewalld sudo systemctl start firewalld @@ -71,7 +71,7 @@ curl -s -L https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-contai # Install nvidia container toolkit sudo dnf install -y nvidia-container-toolkit -# Configure the nvidia runtime for docker containers +# Configure the nvidia runtime for docker containers sudo nvidia-ctk runtime configure --runtime=docker # Restart docker @@ -118,4 +118,4 @@ curl https://$DOMAIN/v1/completions \ "max_tokens": 20, "temperature": 0.1 }' -``` \ No newline at end of file +``` diff --git a/vllm/setup.sh b/vllm/setup.sh index 4784daa..faad957 100644 --- a/vllm/setup.sh +++ b/vllm/setup.sh @@ -11,7 +11,7 @@ sudo systemctl start firewalld sudo systemctl enable firewalld # Add SSH, HTTP and HTTPS services -# TODO(high): update to only accept requests from cloudflare servers for requests other than SSH +# TODO(high): update to only accept requests from cloudflare servers for requests other than SSH sudo firewall-cmd --permanent --add-service=ssh sudo firewall-cmd --permanent --add-service=http sudo firewall-cmd --permanent --add-service=https @@ -39,7 +39,7 @@ curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia- # Install nvidia container toolkit sudo dnf install -y nvidia-container-toolkit -# Configure the nvidia runtime for docker containers +# Configure the nvidia runtime for docker containers sudo nvidia-ctk runtime configure --runtime=docker # Restart docker @@ -51,7 +51,7 @@ mkdir -p $CADDY_DATA_DIR $CADDY_CONFIG_DIR # Copy the Caddyfile to the config directory cp ./Caddyfile $CADDY_FILE_PATH - + # Install uv python manager curl -LsSf https://astral.sh/uv/install.sh | sh @@ -60,6 +60,6 @@ mkdir -p $HF_CACHE_DIR # Install HF-CLI and download the model to the cache directory uv run --with "huggingface_hub[cli]" huggingface-cli login --token $HF_HUB_TOKEN -uv run --with "huggingface_hub[cli]" huggingface-cli download $MODEL_NAME --cache-dir=$HF_CACHE_DIR +uv run --with "huggingface_hub[cli]" huggingface-cli download $MODEL_NAME --cache-dir=$HF_CACHE_DIR -echo "Setup complete! You can now run 'sudo docker compose up -d'" \ No newline at end of file +echo "Setup complete! You can now run 'sudo docker compose up -d'" From 8c61b56fee91ea81051272bdc62429cac9ce9ecf Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 3 Aug 2025 11:51:26 -0400 Subject: [PATCH 080/151] feat: creating dedicated extractors subpackage --- .../core/extractors/base_extractor.py | 71 +++++++++ .../pressure_extractor.py} | 141 +++++++----------- .../core/extractors/temperature_extractor.py | 5 + 3 files changed, 130 insertions(+), 87 deletions(-) create mode 100644 src/earth_reach/core/extractors/base_extractor.py rename src/earth_reach/core/{data_extractor.py => extractors/pressure_extractor.py} (77%) create mode 100644 src/earth_reach/core/extractors/temperature_extractor.py diff --git a/src/earth_reach/core/extractors/base_extractor.py b/src/earth_reach/core/extractors/base_extractor.py new file mode 100644 index 0000000..9152f01 --- /dev/null +++ b/src/earth_reach/core/extractors/base_extractor.py @@ -0,0 +1,71 @@ +""" +Base Data Extractor module. + +This module defines the abstract base class for data extractors in the EarthReach project. +It provides a common interface and functionality for extracting meteorological features +from GRIB files. +""" + +from abc import ABC, abstractmethod +from typing import Any + + +class BaseDataExtractor(ABC): + """ + Abstract base class for weather data extractors. + + Provides common functionality and interface for extracting + meteorological features from various data sources. + """ + + def __init__(self, verbose: bool = False): + """ + Initialize the data extractor. + + Args: + verbose: Whether to print progress messages + """ + self.verbose = verbose + self._data = None + self._metadata = {} + + @abstractmethod + def validate_data(self, data: Any) -> bool: + """ + Validate that the input data contains required variables. + + Args: + data: Input data to validate + + Returns: + bool: True if validation passes + + Raises: + ValueError: If validation fails + """ + + @abstractmethod + def extract(self, data: Any, **kwargs) -> list[Any]: + """ + Extract features from the input data. + + Args: + data: Input data + **kwargs: Additional extraction parameters + + Returns: + List of extracted features + """ + + @abstractmethod + def add_data_to_prompt(self, prompt: str, features: list[Any]) -> str: + """ + Format data and add them to the prompt. + + Args: + prompt: Prompt string to modify + features: List of extracted features + + Returns: + str: Updated prompt with formatted data + """ diff --git a/src/earth_reach/core/data_extractor.py b/src/earth_reach/core/extractors/pressure_extractor.py similarity index 77% rename from src/earth_reach/core/data_extractor.py rename to src/earth_reach/core/extractors/pressure_extractor.py index 8e017fb..6af84a4 100644 --- a/src/earth_reach/core/data_extractor.py +++ b/src/earth_reach/core/extractors/pressure_extractor.py @@ -1,12 +1,22 @@ -from abc import ABC, abstractmethod +""" +Pressure-Center Data Extractor module. + +This module provides functionality to extract pressure centers (high and low pressure systems) +from meteorological data, specifically mean sea level pressure fields in GRIB format. +It uses local extrema detection with Gaussian smoothing to identify these centers. +""" + from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, List, Tuple +from typing import Any import numpy as np -from earth_reach.config.logging import get_logger + from scipy.ndimage import gaussian_filter, maximum_filter, minimum_filter +from earth_reach.config.logging import get_logger +from earth_reach.core.extractors.base_extractor import BaseDataExtractor + logger = get_logger(__name__) @@ -21,9 +31,9 @@ class PressureCenter: intensity: float timestamp: datetime confidence: float - grid_indices: Tuple[int, int] + grid_indices: tuple[int, int] - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to dictionary for serialization.""" return { "type": self.center_type, @@ -37,71 +47,7 @@ def to_dict(self) -> Dict[str, Any]: } -class DataExtractorInterface(ABC): - """ - Abstract base class for weather data extractors. - - Provides common functionality and interface for extracting - meteorological features from various data sources. - """ - - def __init__(self, verbose: bool = True): - """ - Initialize the data extractor. - - Args: - verbose: Whether to print progress messages - """ - self.verbose = verbose - self._data = None - self._metadata = {} - - @abstractmethod - def validate_data(self, data: Any) -> bool: - """ - Validate that the input data contains required variables. - - Args: - data: Input data to validate - - Returns: - bool: True if validation passes - - Raises: - ValueError: If validation fails - """ - pass - - @abstractmethod - def extract(self, data: Any, **kwargs) -> List[Any]: - """ - Extract features from the input data. - - Args: - data: Input data - **kwargs: Additional extraction parameters - - Returns: - List of extracted features - """ - pass - - @abstractmethod - def add_data_to_prompt(self, prompt: str, features: List[Any]) -> str: - """ - Format data and add them to the prompt. - - Args: - prompt: Prompt string to modify - features: List of extracted features - - Returns: - str: Updated prompt with formatted data - """ - pass - - -class PressureCenterDataExtractor(DataExtractorInterface): +class PressureCenterDataExtractor(BaseDataExtractor): """ Concrete implementation for extracting pressure centers from GRIB data. @@ -159,7 +105,7 @@ def validate_data(self, data: Any) -> bool: if self.pressure_var_name not in available_vars: raise ValueError( f"Required variable '{self.pressure_var_name}' not found. " - f"Available variables: {', '.join(available_vars)}" + f"Available variables: {', '.join(available_vars)}", ) lats = data.to_numpy(latitude=True) @@ -172,12 +118,15 @@ def validate_data(self, data: Any) -> bool: return True except Exception as e: - logger.error(f"Validation failed: {str(e)}") + logger.error(f"Validation failed: {e!s}") raise def extract( - self, data: Any, smoothing: bool = True, return_all: bool = False - ) -> List[PressureCenter]: + self, + data: Any, + smoothing: bool = True, + return_all: bool = False, + ) -> list[PressureCenter]: """ Extract pressure centers from GRIB data. @@ -206,10 +155,18 @@ def extract( timestamp = data.datetime() high_centers = self._find_extrema( - pressure_smoothed, lats, lons, timestamp, extrema_type="high" + pressure_smoothed, + lats, + lons, + timestamp, + extrema_type="high", ) low_centers = self._find_extrema( - pressure_smoothed, lats, lons, timestamp, extrema_type="low" + pressure_smoothed, + lats, + lons, + timestamp, + extrema_type="low", ) all_centers = high_centers + low_centers @@ -224,7 +181,7 @@ def extract( logger.info( f"Extracted {len(final_centers)} pressure centers " f"({sum(1 for c in final_centers if c.center_type == 'high')} high, " - f"{sum(1 for c in final_centers if c.center_type == 'low')} low)" + f"{sum(1 for c in final_centers if c.center_type == 'low')} low)", ) return final_centers @@ -236,7 +193,7 @@ def _find_extrema( lons: np.ndarray, timestamp: datetime, extrema_type: str, - ) -> List[PressureCenter]: + ) -> list[PressureCenter]: """Find local extrema in pressure field.""" if self.neighborhood == 4: @@ -257,7 +214,7 @@ def _find_extrema( centers = [] indices = np.where(extrema) - for i, j in zip(indices[0], indices[1]): + for i, j in zip(indices[0], indices[1], strict=False): local_region = pressure[ max(0, i - 2) : min(pressure.shape[0], i + 3), max(0, j - 2) : min(pressure.shape[1], j + 3), @@ -265,7 +222,7 @@ def _find_extrema( if extrema_type == "high": intensity = pressure[i, j] - np.mean( - local_region[local_region != pressure[i, j]] + local_region[local_region != pressure[i, j]], ) else: intensity = ( @@ -277,7 +234,7 @@ def _find_extrema( pressure[ max(0, i - 1) : min(pressure.shape[0], i + 2), max(0, j - 1) : min(pressure.shape[1], j + 2), - ] + ], ) gradient_magnitude = np.sqrt(grad_y**2 + grad_x**2).mean() confidence = 1.0 - np.exp(-gradient_magnitude) @@ -297,8 +254,11 @@ def _find_extrema( return centers def _filter_by_distance( - self, centers: List[PressureCenter], lats: np.ndarray, lons: np.ndarray - ) -> List[PressureCenter]: + self, + centers: list[PressureCenter], + lats: np.ndarray, + lons: np.ndarray, + ) -> list[PressureCenter]: """Filter centers to ensure minimum distance between them.""" if not centers or self.min_distance <= 0: @@ -311,7 +271,10 @@ def _filter_by_distance( too_close = False for kept in kept_centers: distance = self._haversine_distance( - center.latitude, center.longitude, kept.latitude, kept.longitude + center.latitude, + center.longitude, + kept.latitude, + kept.longitude, ) if distance < self.min_distance: too_close = True @@ -323,7 +286,11 @@ def _filter_by_distance( return kept_centers def _haversine_distance( - self, lat1: float, lon1: float, lat2: float, lon2: float + self, + lat1: float, + lon1: float, + lat2: float, + lon2: float, ) -> float: """Calculate distance between two points on Earth (km).""" R = 6371 # Earth radius in km @@ -337,7 +304,7 @@ def _haversine_distance( return R * c - def format_output(self, prompt: str, features: List[PressureCenter]) -> str: + def format_output(self, prompt: str, features: list[PressureCenter]) -> str: raise NotImplementedError( - "Should implement this method before use with DataExtractor" + "Should implement this method before use with DataExtractor", ) diff --git a/src/earth_reach/core/extractors/temperature_extractor.py b/src/earth_reach/core/extractors/temperature_extractor.py new file mode 100644 index 0000000..1b5d071 --- /dev/null +++ b/src/earth_reach/core/extractors/temperature_extractor.py @@ -0,0 +1,5 @@ +""" +Temperature Data Extractor module. + + +""" From 3ea5e1d4bfdfe222c21cbbf614a1db4211eab3d2 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 3 Aug 2025 11:51:38 -0400 Subject: [PATCH 081/151] fix: fixing ruff linting rules --- notebooks/earthkit_plots_exploration.py | 50 ++++++------ src/earth_reach/cli.py | 56 ++++++++------ src/earth_reach/config/criteria.py | 3 +- src/earth_reach/config/logging.py | 3 +- src/earth_reach/core/evaluator.py | 84 ++++++++++++--------- src/earth_reach/core/extractors/__init__.py | 0 src/earth_reach/core/generator.py | 37 +++++---- src/earth_reach/core/llm.py | 43 +++++++---- src/earth_reach/core/orchestrator.py | 42 ++++++----- src/earth_reach/core/prompts/evaluator.py | 23 +++--- src/earth_reach/core/prompts/generator.py | 14 ++-- src/earth_reach/core/utils.py | 1 + 12 files changed, 206 insertions(+), 150 deletions(-) create mode 100644 src/earth_reach/core/extractors/__init__.py diff --git a/notebooks/earthkit_plots_exploration.py b/notebooks/earthkit_plots_exploration.py index e5409e7..24502ae 100644 --- a/notebooks/earthkit_plots_exploration.py +++ b/notebooks/earthkit_plots_exploration.py @@ -25,13 +25,14 @@ import os import time import warnings + from io import BytesIO from pathlib import Path -from typing import Dict, List import earthkit as ek import openai import pandas as pd + from dotenv import load_dotenv from earthkit.data import cache, config from IPython.display import Markdown, display @@ -57,8 +58,8 @@ # %% def get_weather_data( date: str, - times: List[str] = ["12:00"], - variables: List[str] = ["2m_temperature", "mean_sea_level_pressure"], + times: list[str] = ["12:00"], + variables: list[str] = ["2m_temperature", "mean_sea_level_pressure"], dataset: str = "reanalysis-era5-single-levels", ): date_list = date.split("/") @@ -145,7 +146,7 @@ def call_llm_api(user_prompt: str, image) -> str: }, }, ], - } + }, ], ) description = response.choices[0].message.content @@ -213,7 +214,7 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): sub_data = data.sel(param=["2t", "msl"], typeOfLevel="surface") - kwargs: Dict[str, List] = ( + kwargs: dict[str, list] = ( {"domain": [domain_type]} if domain_type.lower() != "global" else {} @@ -240,7 +241,7 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): "event_type": event_type, "domain_type": domain_type, "date": date_str, - } + }, ) figure_id += 1 @@ -306,7 +307,7 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): # %% sub_data = data.sel(param=["2t", "msl", "10u", "10v"], typeOfLevel="surface") -kwargs: Dict[str, List] = ( +kwargs: dict[str, list] = ( {"domain": [domain_type]} if domain_type.lower() != "global" else {} ) @@ -403,22 +404,22 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): # %% task_prompt = """# Instructions for Describing Meteorological Visualizations for Blind Scientists -Your task is to create a comprehensive, scientifically accurate description of meteorological visualizations that will be accessible to blind scientists. +Your task is to create a comprehensive, scientifically accurate description of meteorological visualizations that will be accessible to blind scientists. """ method_prompt = """## Method -To achieve this task, you will work in two three steps. +To achieve this task, you will work in two three steps. 1. Understanding the information of the visualization 2. Planning your description 3. Writing your description First,reflect on the meteorological visualizations and the metadata provided, step by step, critically, and without omitting any detail, to make sure you understand them and the key information they convey. -Once you've completed your full breakdown of the visualization and the metadata, you should be able to write a comprehensive, scientifically accurate description of the visualization. +Once you've completed your full breakdown of the visualization and the metadata, you should be able to write a comprehensive, scientifically accurate description of the visualization. -So you will once again start a reflection process, to plan what you will convey and how you will convey it in the desciption, in order to respect the requirements established previously +So you will once again start a reflection process, to plan what you will convey and how you will convey it in the desciption, in order to respect the requirements established previously """ @@ -569,11 +570,11 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): ### Length and Structure Requirements - **Total description length**: 300-500 words maximum - **Overview section**: Exactly 2-3 sentences -- **Main body**: 4-6 sentences +- **Main body**: 4-6 sentences ### Quantitative Requirements - **Include exact ranges**: Every variable must have minimum and maximum values with units -- **Spatial precision**: Use cardinal directions and geographic references +- **Spatial precision**: Use cardinal directions and geographic references - **Coordinate references**: Include latitude/longitude for major features (±2 degrees acceptable) - **Unit consistency**: Use standard meteorological units (hPa, m/s, K or °C, km) @@ -612,7 +613,7 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): **Required XML Tags:** - `...` - Wrap the entire content of Step 1 (excluding the markdown header) -- `...` - Wrap the entire content of Step 2 (excluding the markdown header) +- `...` - Wrap the entire content of Step 2 (excluding the markdown header) - `...` - Wrap the entire content of Step 3 (excluding the markdown header) - `...` - Wrap the entire content of Step 4 (excluding the markdown header) - `...` - Wrap only the final consolidated description (without headers or meta-commentary) @@ -631,7 +632,7 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): [rest of step 1 content] -## Step 2: Pattern Recognition and Spatial Analysis +## Step 2: Pattern Recognition and Spatial Analysis The dominant feature is a high-pressure system... [rest of step 2 content] @@ -640,14 +641,14 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): ## Step 3: Description Planning and Structure Design To structure the description, we will start with an overview... -[rest of step 3 content] +[rest of step 3 content] ## Step 4: Description Writing with Verification ### Overview This weather chart displays... -### Main Body +### Main Body A high-pressure system is centered... [rest of step 4 content] @@ -710,7 +711,7 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): print(f"✓ Generated description for figure_{figure_id}") except Exception as e: - df.at[idx, "description"] = f"FAILED: {str(e)}" + df.at[idx, "description"] = f"FAILED: {e!s}" print(f"✗ Failed figure_{figure_id}: {e}") processed_count += 1 @@ -752,7 +753,7 @@ def show_image_with_description(image_path: Path, description: str): try: image = Image.open(image_path) print( - fill(description, width=80) + fill(description, width=80), ) # Format description for better readability return image except Exception as e: @@ -812,7 +813,7 @@ def show_image_with_description(image_path: Path, description: str): fill( 'This weather chart displays 2-meter temperature and mean sea level pressure over Europe and North Africa for 12:00 UTC on February 27, 2018. The map domain extends from approximately 25°N to 70°N and 10°W to 45°E. Temperatures range from below -30°C (blue/purple) to over 20°C (orange), with green tones near 0°C. Isobars are drawn at 4 hPa intervals.\n\nThe synoptic situation is dominated by an exceptionally intense and large high-pressure system centered over Scandinavia and northwestern Russia. The central pressure of this anticyclone exceeds 1052 hPa. This system governs the weather pattern across the entire continent, representing a significant anomaly for late boreal winter. Its presence establishes a powerful blocking pattern, preventing milder Atlantic air from reaching Europe.\n\nThe primary consequence of this high is a severe cold air outbreak. Inferred from the anticyclonic (clockwise) circulation, strong easterly winds advect frigid continental air from Siberia westward across Europe. This results in extremely low temperatures across a vast area. Scandinavia, the Baltic states, and northern Russia experience temperatures between -15°C and -30°C. The cold air penetrates deep into Western and Central Europe, with temperatures in Germany, Poland, and the UK falling between -5°C and -15°C.\n\nA sharp and powerful frontal boundary forms across Southern Europe where the arctic air mass collides with milder air. This front extends from the Black Sea, across the Balkan Peninsula and northern Italy, into southern France. Along this zone, temperatures increase dramatically from sub-zero readings in the north to over 10°C in the south. A low-pressure system, with a central pressure near 1004 hPa, is established in the central Mediterranean Sea, south of Italy.\n\nThe interaction between these features creates active and severe weather. The tight pressure gradient between the northern high and southern low generates strong to gale-force easterly winds, particularly across Central and Eastern Europe. These winds contribute to dangerously low wind chill values. The uplift associated with the Mediterranean low, combined with the influx of cold air, would lead to heavy snowfall, especially across the mountains of Italy and the Balkans. In contrast, North Africa and the far southeastern Mediterranean remain mild, with temperatures between 15°C and 25°C.\n\nIn summary, this chart captures a classic but extreme "Beast from the East" event, a severe winter cold wave driven by an anomalously strong Scandinavian high-pressure system, resulting in widespread record-breaking cold and disruptive weather across Europe.', 80, - ) + ), ) # %% [markdown] @@ -925,11 +926,12 @@ def show_image_with_description(image_path: Path, description: str): # %% import time + from datetime import datetime from pathlib import Path -from typing import Optional import pandas as pd + from dotenv import load_dotenv from PIL import Image from tqdm import tqdm @@ -996,7 +998,7 @@ def generate_descriptions_to_text( time.sleep(delay_seconds) except Exception as e: - print(f"✗ Failed {image_path.name}: {str(e)}") + print(f"✗ Failed {image_path.name}: {e!s}") with open(output_path, "a", encoding="utf-8") as f: f.write(f"{'=' * 80}\n") @@ -1004,10 +1006,10 @@ def generate_descriptions_to_text( f.write(f"Model: {model_name}\n") f.write("Computation Time: 0.00 seconds\n") f.write( - f"Generated At: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" + f"Generated At: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n", ) f.write(f"{'=' * 80}\n\n") - f.write(f"ERROR: Failed to generate description\nReason: {str(e)}\n\n") + f.write(f"ERROR: Failed to generate description\nReason: {e!s}\n\n") print(f"\n✅ Results saved to: {output_path}") diff --git a/src/earth_reach/cli.py b/src/earth_reach/cli.py index ec135b1..45f93ed 100644 --- a/src/earth_reach/cli.py +++ b/src/earth_reach/cli.py @@ -5,11 +5,14 @@ import os import sys + from pathlib import Path -from typing import List import fire + from dotenv import load_dotenv +from PIL import Image + from earth_reach.config.criteria import QualityCriteria from earth_reach.config.logging import get_logger from earth_reach.core.evaluator import EvaluatorAgent @@ -17,7 +20,6 @@ from earth_reach.core.llm import create_llm from earth_reach.core.orchestrator import Orchestrator from earth_reach.core.prompts.generator import get_default_generator_user_prompt -from PIL import Image logger = get_logger(__name__) @@ -43,13 +45,13 @@ def load_prompt_from_file(file_path: str) -> str: raise FileNotFoundError(f"Prompt file not found: {file_path}") try: - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: content = f.read().strip() if not content: raise ValueError(f"Prompt file is empty: {file_path}") return content except Exception as e: - raise IOError(f"Failed to read prompt file '{file_path}': {e}") + raise OSError(f"Failed to read prompt file '{file_path}': {e}") from e def resolve_prompt( @@ -73,7 +75,7 @@ def resolve_prompt( """ if direct_prompt is not None and file_path is not None: raise ValueError( - "Cannot specify both prompt file and prompt text. Please use only one." + "Cannot specify both prompt file and prompt text. Please use only one.", ) if direct_prompt is not None: @@ -108,7 +110,7 @@ def resolve_description( if description and description_file_path: raise ValueError( "Cannot provide both description text and description file path. " - "Please provide only one." + "Please provide only one.", ) if description: @@ -117,19 +119,19 @@ def resolve_description( if description_file_path: if not os.path.exists(description_file_path): raise FileNotFoundError( - f"Description file not found: {description_file_path}" + f"Description file not found: {description_file_path}", ) try: - with open(description_file_path, "r", encoding="utf-8") as file: + with open(description_file_path, encoding="utf-8") as file: return file.read().strip() except Exception as e: - raise ValueError(f"Error reading description file: {e}") + raise ValueError(f"Error reading description file: {e}") from e return None -def get_valid_criteria() -> List[str]: +def get_valid_criteria() -> list[str]: """ Get list of valid evaluation criteria. @@ -165,7 +167,7 @@ def validate_image_path(image_path: str) -> Path: if path.suffix not in valid_extensions: raise ValueError( f"Unsupported image format: {path.suffix}. " - f"Supported formats: {', '.join(sorted(valid_extensions))}" + f"Supported formats: {', '.join(sorted(valid_extensions))}", ) return path @@ -226,22 +228,24 @@ def generate( None, ) user_prompt_text = resolve_prompt( - user_prompt, user_prompt_file_path, get_default_generator_user_prompt() + user_prompt, + user_prompt_file_path, + get_default_generator_user_prompt(), ) if not user_prompt_text: raise ValueError( - "User prompt cannot be empty. Please provide a valid prompt." + "User prompt cannot be empty. Please provide a valid prompt.", ) if verbose: if system_prompt_text: logger.info( - f"System prompt length: {len(system_prompt_text)} characters" + f"System prompt length: {len(system_prompt_text)} characters", ) if user_prompt_text: logger.info( - f"User prompt length: {len(user_prompt_text)} characters" + f"User prompt length: {len(user_prompt_text)} characters", ) if verbose: @@ -252,7 +256,9 @@ def generate( logger.info("Creating generator agent...") generator = GeneratorAgent( - llm=llm, system_prompt=system_prompt_text, user_prompt=user_prompt_text + llm=llm, + system_prompt=system_prompt_text, + user_prompt=user_prompt_text, ) if not simple: @@ -279,7 +285,8 @@ def generate( if simple: description = generator.generate( - image=image, return_intermediate_steps=False + image=image, + return_intermediate_steps=False, ) else: description = orchestrator.run(image=image) @@ -291,9 +298,9 @@ def generate( print(description) - return None + return - except (FileNotFoundError, ValueError, IOError) as e: + except (OSError, FileNotFoundError, ValueError) as e: logger.error(f"Could not load image file: {e}", exc_info=True) sys.exit(1) except RuntimeError as e: @@ -308,7 +315,7 @@ def evaluate( image_path: str, description: str | None = None, description_file_path: str | None = None, - criteria: List[str] = ["coherence", "fluency", "consistency", "relevance"], + criteria: list[str] | None = None, verbose: bool = False, ) -> None: """ @@ -329,6 +336,9 @@ def evaluate( ValueError: If arguments are invalid or conflicting RuntimeError: If evaluation fails """ + if criteria is None: + criteria = ["coherence", "fluency", "consistency", "relevance"] + try: if verbose: logger.info(f"Validating image: {image_path}") @@ -345,7 +355,7 @@ def evaluate( ) if not description_text or description_text.strip() == "": raise ValueError( - "Description cannot be empty. Please provide a valid description." + "Description cannot be empty. Please provide a valid description.", ) if verbose: @@ -358,7 +368,7 @@ def evaluate( invalid_criteria = [c for c in criteria if c not in valid_criteria] if invalid_criteria: raise ValueError( - f"Invalid criteria: {invalid_criteria}. Valid criteria are: {valid_criteria}" + f"Invalid criteria: {invalid_criteria}. Valid criteria are: {valid_criteria}", ) if verbose: @@ -394,7 +404,7 @@ def evaluate( print(f"Reasoning: {eval.reasoning}") print("-" * 50) - return None + return except FileNotFoundError as e: logger.error(f"File not found: {e}", exc_info=True) diff --git a/src/earth_reach/config/criteria.py b/src/earth_reach/config/criteria.py index d0aab28..992ab2a 100644 --- a/src/earth_reach/config/criteria.py +++ b/src/earth_reach/config/criteria.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import List class QualityCriteria(Enum): @@ -9,5 +8,5 @@ class QualityCriteria(Enum): RELEVANCE = "relevance" @classmethod - def list(cls) -> List[str]: + def list(cls) -> list[str]: return [criterion.value for criterion in cls] diff --git a/src/earth_reach/config/logging.py b/src/earth_reach/config/logging.py index d816037..15791c8 100644 --- a/src/earth_reach/config/logging.py +++ b/src/earth_reach/config/logging.py @@ -4,10 +4,9 @@ import logging import os -from typing import Optional -def setup_logging(level: Optional[str] = None) -> None: +def setup_logging(level: str | None = None) -> None: """ Configure logging for the application. diff --git a/src/earth_reach/core/evaluator.py b/src/earth_reach/core/evaluator.py index 2e29c52..ce33c28 100644 --- a/src/earth_reach/core/evaluator.py +++ b/src/earth_reach/core/evaluator.py @@ -1,17 +1,20 @@ import re + from dataclasses import MISSING, dataclass, fields from io import BytesIO -from typing import Any, List, Union, get_args, get_origin +from typing import Any, Union, get_args, get_origin import earthkit.plots as ekp + +from PIL import Image +from PIL.ImageFile import ImageFile + from earth_reach.config.logging import get_logger from earth_reach.core.generator import FigureMetadata from earth_reach.core.llm import LLMInterface, create_llm from earth_reach.core.prompts.evaluator import ( get_default_criterion_evaluator_user_prompt, ) -from PIL import Image -from PIL.ImageFile import ImageFile logger = get_logger(__name__) @@ -70,18 +73,19 @@ def evaluate( ) -> CriterionEvaluatorOutput: if figure is not None and image is not None: raise ValueError( - "Only one of 'figure' or 'image' can be provided, not both." + "Only one of 'figure' or 'image' can be provided, not both.", ) if figure is not None: # TODO(medium): If metadata extraction fails, continue without it metadata = self._get_metadata_from_figure(figure) self.user_prompt = self._update_user_prompt_with_metadata( - self.user_prompt, metadata + self.user_prompt, + metadata, ) image = self._get_image_from_figure(figure) elif image is None and figure is None: raise ValueError( - "Either 'figure' or 'image' must be provided to generate a description." + "Either 'figure' or 'image' must be provided to generate a description.", ) try: user_prompt = ( @@ -131,14 +135,16 @@ def parse_llm_response(self, response: str) -> CriterionEvaluatorOutput: content = match.group(1).strip() if content: converted_value = self.convert_to_field_type( - content, field_name, field_type + content, + field_name, + field_type, ) extracted_values[field_name] = converted_value except Exception as e: - parsing_errors.append(f"Failed to parse field '{field_name}': {str(e)}") + parsing_errors.append(f"Failed to parse field '{field_name}': {e!s}") logger.warning( - f"Parsing errors encountered: {parsing_errors}" + f"Parsing errors encountered: {parsing_errors}", ) if parsing_errors else None required_fields = [ @@ -153,18 +159,21 @@ def parse_llm_response(self, response: str) -> CriterionEvaluatorOutput: missing_required = [f for f in required_fields if f not in extracted_values] if missing_required: raise ValueError( - f"Missing required fields in XML response: {missing_required}" + f"Missing required fields in XML response: {missing_required}", ) try: return CriterionEvaluatorOutput(name=self.criterion, **extracted_values) except Exception as e: raise Exception( - f"Failed to create CriterionEvaluatorOutput instance: {str(e)}" - ) + f"Failed to create CriterionEvaluatorOutput instance: {e!s}", + ) from e def convert_to_field_type( - self, content: str, field_name: str, field_type: Any + self, + content: str, + field_name: str, + field_type: Any, ) -> Any: """ Convert string content to the appropriate type based on field type annotation. @@ -193,36 +202,35 @@ def convert_to_field_type( if field_type is int: try: return int(content) - except ValueError: + except ValueError as e: raise ValueError( - f"Cannot convert '{content}' to integer for field '{field_name}'" - ) + f"Cannot convert '{content}' to integer for field '{field_name}'", + ) from e elif field_type is float: try: return float(content) - except ValueError: + except ValueError as e: raise ValueError( - f"Cannot convert '{content}' to float for field '{field_name}'" - ) + f"Cannot convert '{content}' to float for field '{field_name}'", + ) from e elif field_type is bool: lower_content = content.lower().strip() if lower_content in ("true", "1", "yes", "on", "y"): return True - elif lower_content in ("false", "0", "no", "off", "n"): + if lower_content in ("false", "0", "no", "off", "n"): return False - else: - raise ValueError( - f"Cannot convert '{content}' to boolean for field '{field_name}'. " - f"Expected: true/false, 1/0, yes/no, on/off, y/n" - ) + raise ValueError( + f"Cannot convert '{content}' to boolean for field '{field_name}'. " + f"Expected: true/false, 1/0, yes/no, on/off, y/n", + ) elif field_type is str: return content else: - if hasattr(field_type, "__call__"): + if callable(field_type): try: return field_type(content) except Exception: @@ -255,7 +263,9 @@ def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata: return metadata def _update_user_prompt_with_metadata( - self, user_prompt: str, metadata: FigureMetadata + self, + user_prompt: str, + metadata: FigureMetadata, ) -> str: """ Update the user prompt with metadata extracted from the figure. @@ -272,7 +282,8 @@ def _update_user_prompt_with_metadata( value = getattr(metadata, field_info.name) if value is not None: description = field_info.metadata.get( - "description", "No description available" + "description", + "No description available", ) metadata_items.append(f"- {field_info.name} ({description}): {value}") @@ -325,14 +336,17 @@ def create(criterion: str, llm: LLMInterface | None = None) -> CriterionEvaluato user_prompt = get_default_criterion_evaluator_user_prompt(criterion) return CriterionEvaluator( - criterion=criterion, llm=llm, system_prompt=None, user_prompt=user_prompt + criterion=criterion, + llm=llm, + system_prompt=None, + user_prompt=user_prompt, ) class EvaluatorAgent: """Agent class for evaluating the quality of weather chart descriptions.""" - def __init__(self, criteria: List[str], llm: LLMInterface | None = None) -> None: + def __init__(self, criteria: list[str], llm: LLMInterface | None = None) -> None: """ Initialize the EvaluatorAgent. @@ -358,7 +372,7 @@ def __init__(self, criteria: List[str], llm: LLMInterface | None = None) -> None ] except Exception as e: raise RuntimeError( - f"Failed to create evaluators for criteria {criteria}: {e}" + f"Failed to create evaluators for criteria {criteria}: {e}", ) from e def evaluate( @@ -366,7 +380,7 @@ def evaluate( description: str, figure: ekp.Figure | None = None, image: ImageFile | None = None, - ) -> List[CriterionEvaluatorOutput]: + ) -> list[CriterionEvaluatorOutput]: """ Evaluate the given text against the specified criteria. @@ -378,14 +392,16 @@ def evaluate( """ if figure is not None and image is not None: raise ValueError( - "Only one of 'figure' or 'image' can be provided, not both." + "Only one of 'figure' or 'image' can be provided, not both.", ) try: evaluations = [] for evaluator in self.evaluators: result = evaluator.evaluate( - description=description, figure=figure, image=image + description=description, + figure=figure, + image=image, ) evaluations.append(result) diff --git a/src/earth_reach/core/extractors/__init__.py b/src/earth_reach/core/extractors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/earth_reach/core/generator.py b/src/earth_reach/core/generator.py index be8a44b..c612952 100644 --- a/src/earth_reach/core/generator.py +++ b/src/earth_reach/core/generator.py @@ -1,20 +1,23 @@ import re + from dataclasses import dataclass, field, fields from io import BytesIO -from typing import List import earthkit.plots as ekp -from earth_reach.core.llm import LLMInterface + from PIL import Image from PIL.ImageFile import ImageFile +from earth_reach.core.llm import LLMInterface + @dataclass class FigureMetadata: """Metadata extracted from a figure.""" title: str | None = field( - default=None, metadata={"description": "Figure title or heading"} + default=None, + metadata={"description": "Figure title or heading"}, ) xlabel: str | None = field( default=None, @@ -25,9 +28,10 @@ class FigureMetadata: metadata={"description": "Y-axis label describing the vertical dimension"}, ) domain: str | None = field( - default=None, metadata={"description": "Geographic domain of the figure"} + default=None, + metadata={"description": "Geographic domain of the figure"}, ) - variables: List[str] | None = field( + variables: list[str] | None = field( default=None, metadata={"description": "Key variables shown in the figure"}, ) @@ -65,7 +69,7 @@ def is_complete(self) -> bool: for field in fields(self) ) - def get_missing_fields(self) -> List[str]: + def get_missing_fields(self) -> list[str]: """ Return list of field names that were not successfully parsed. @@ -109,7 +113,10 @@ class GeneratorAgent: """GeneratorAgent class for generating weather charts scientific descriptions.""" def __init__( - self, llm: LLMInterface, system_prompt: str | None, user_prompt: str + self, + llm: LLMInterface, + system_prompt: str | None, + user_prompt: str, ) -> None: """ Initialize the GeneratorAgent with a LLMInterface instance and prompts. @@ -147,18 +154,19 @@ def generate( """ if figure is not None and image is not None: raise ValueError( - "Only one of 'figure' or 'image' can be provided, not both." + "Only one of 'figure' or 'image' can be provided, not both.", ) if figure is not None: # TODO(medium): If metadata extraction fails at any point, generate image and continue without figure metadata metadata = self._get_metadata_from_figure(figure) self.user_prompt = self._update_user_prompt_with_metadata( - self.user_prompt, metadata + self.user_prompt, + metadata, ) image = self._get_image_from_figure(figure) elif image is None and figure is None: raise ValueError( - "Either 'figure' or 'image' must be provided to generate a description." + "Either 'figure' or 'image' must be provided to generate a description.", ) try: @@ -172,7 +180,7 @@ def generate( if not parsed_output.is_complete(): raise ValueError( "Parsed output is incomplete. Missing fields: " - f"{parsed_output.get_missing_fields()}" + f"{parsed_output.get_missing_fields()}", ) if return_intermediate_steps: @@ -247,7 +255,9 @@ def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata: return metadata def _update_user_prompt_with_metadata( - self, user_prompt: str, metadata: FigureMetadata + self, + user_prompt: str, + metadata: FigureMetadata, ) -> str: """ Update the user prompt with metadata extracted from the figure. @@ -264,7 +274,8 @@ def _update_user_prompt_with_metadata( value = getattr(metadata, field_info.name) if value is not None: description = field_info.metadata.get( - "description", "No description available" + "description", + "No description available", ) metadata_items.append(f"- {field_info.name} ({description}): {value}") diff --git a/src/earth_reach/core/llm.py b/src/earth_reach/core/llm.py index 2ad735b..367d21e 100644 --- a/src/earth_reach/core/llm.py +++ b/src/earth_reach/core/llm.py @@ -1,18 +1,24 @@ import os + from abc import ABC, abstractmethod import openai -from earth_reach.core.utils import img_to_base64, img_to_bytes + from google import genai from google.genai import types +from earth_reach.core.utils import img_to_base64, img_to_bytes + class LLMInterface(ABC): """Abstract base class defining the interface for all LLM provider implementations.""" @abstractmethod def generate( - self, user_prompt: str, system_prompt: str | None = None, image=None + self, + user_prompt: str, + system_prompt: str | None = None, + image=None, ) -> str: """ Generate a response from the LLM based on the user prompt and optional system prompt. @@ -29,7 +35,6 @@ def generate( ValueError: If user_prompt is empty/None or if the API response is empty. RuntimeError: For other run-time errors. """ - pass class OpenAICompatibleLLM(LLMInterface): @@ -60,7 +65,10 @@ def __init__( ) def generate( - self, user_prompt: str, system_prompt: str | None = None, image=None + self, + user_prompt: str, + system_prompt: str | None = None, + image=None, ) -> str: """ Generate a response from the LLM API based on the user prompt and optional system prompt. @@ -107,7 +115,7 @@ def generate( messages.append({"role": "user", "content": user_content}) except Exception as e: - raise ValueError(f"Failed to process input data: {e}") + raise ValueError(f"Failed to process input data: {e}") from e try: response = self.client.chat.completions.create( @@ -152,7 +160,7 @@ def __init__(self, model_name: str, api_key: str | None = None) -> None: api_key = os.environ.get("GROQ_API_KEY", None) if not api_key: raise AssertionError( - "GROQ_API_KEY not set. Please set it in your environment variables, or pass it as an argument." + "GROQ_API_KEY not set. Please set it in your environment variables, or pass it as an argument.", ) super().__init__( @@ -182,11 +190,13 @@ def __init__(self, model_name: str, api_key: str | None = None) -> None: api_key = os.environ.get("OPENAI_API_KEY", None) if not api_key: raise AssertionError( - "OPENAI_API_KEY not set. Please set it in your environment variables, or pass it as an argument." + "OPENAI_API_KEY not set. Please set it in your environment variables, or pass it as an argument.", ) super().__init__( - model_name=model_name, api_key=api_key, base_url="https://api.openai.com/v1" + model_name=model_name, + api_key=api_key, + base_url="https://api.openai.com/v1", ) @@ -208,7 +218,7 @@ def __init__(self, model_name: str, api_key: str | None = None) -> None: api_key = os.environ.get("GEMINI_API_KEY", None) if not api_key: raise AssertionError( - "GEMINI_API_KEY not set. Please set it in your environment variables, or pass it as an argument." + "GEMINI_API_KEY not set. Please set it in your environment variables, or pass it as an argument.", ) self.model_name = model_name @@ -216,7 +226,10 @@ def __init__(self, model_name: str, api_key: str | None = None) -> None: self.client = genai.Client(api_key=api_key) def generate( - self, user_prompt: str, system_prompt: str | None = None, image=None + self, + user_prompt: str, + system_prompt: str | None = None, + image=None, ) -> str: """ Generate a response from the Gemini API based on the user prompt and optional system prompt. @@ -252,12 +265,12 @@ def generate( types.Part.from_bytes( data=image_bytes, mime_type="image/png", - ) + ), ) contents.append(full_prompt) except Exception as e: - raise ValueError(f"Failed to process input data: {e}") + raise ValueError(f"Failed to process input data: {e}") from e try: response = self.client.models.generate_content( @@ -305,7 +318,7 @@ def create_llm(provider="groq", model_name: str | None = None) -> LLMInterface: model_name = "meta-llama/llama-4-maverick-17b-128e-instruct" return GroqLLM(model_name=model_name, api_key=api_key) - elif provider.lower() == "openai": + if provider.lower() == "openai": api_key = os.getenv("OPENAI_API_KEY") if not api_key: raise ValueError("OPENAI_API_KEY environment variable is not set.") @@ -313,7 +326,7 @@ def create_llm(provider="groq", model_name: str | None = None) -> LLMInterface: model_name = "o4-mini-2025-04-16" return OpenAILLM(model_name=model_name, api_key=api_key) - elif provider.lower() == "gemini": + if provider.lower() == "gemini": api_key = os.getenv("GEMINI_API_KEY") if not api_key: raise ValueError("GEMINI_API_KEY environment variable is not set.") @@ -323,5 +336,5 @@ def create_llm(provider="groq", model_name: str | None = None) -> LLMInterface: return GeminiLLM(model_name=model_name, api_key=api_key) raise ValueError( - f"Unsupported LLM provider: {provider}. Supported providers: 'groq', 'openai', 'gemini'." + f"Unsupported LLM provider: {provider}. Supported providers: 'groq', 'openai', 'gemini'.", ) diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py index c17bfd5..b8d2445 100644 --- a/src/earth_reach/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -1,13 +1,13 @@ -from typing import List - import earthkit.plots as ekp + +from earthkit.data import FieldList +from PIL.ImageFile import ImageFile + from earth_reach.config.logging import get_logger from earth_reach.core.data_extractor import DataExtractorInterface from earth_reach.core.evaluator import CriterionEvaluatorOutput, EvaluatorAgent from earth_reach.core.generator import GeneratorAgent from earth_reach.core.prompts.orchestrator import get_default_feedback_template -from earthkit.data import FieldList -from PIL.ImageFile import ImageFile logger = get_logger(__name__) @@ -19,7 +19,7 @@ def __init__( self, generator_agent: GeneratorAgent, evaluator_agent: EvaluatorAgent, - data_extractors: List[DataExtractorInterface] = [], + data_extractors: list[DataExtractorInterface] | None = None, max_iterations: int = 3, criteria_threshold: int = 4, feedback_template: str | None = None, @@ -37,7 +37,7 @@ def __init__( """ self.generator_agent = generator_agent self.evaluator_agent = evaluator_agent - self.data_extractors = data_extractors + self.data_extractors = data_extractors if data_extractors is not None else [] self.max_iterations = max_iterations self.criteria_threshold = criteria_threshold self.feedback_template = feedback_template or get_default_feedback_template() @@ -67,7 +67,7 @@ def run( """ if figure is not None and image is not None: raise ValueError( - "Only one of 'figure' or 'image' can be provided, not both." + "Only one of 'figure' or 'image' can be provided, not both.", ) # TODO(high): to integrate with the list of data extractors, verify that pressure and temperature fields are present @@ -78,18 +78,22 @@ def run( try: for i in range(self.max_iterations): description = self.generator_agent.generate( - figure=figure, image=image, return_intermediate_steps=False + figure=figure, + image=image, + return_intermediate_steps=False, ) if not isinstance(description, str): raise TypeError( - f"Expected description to be a string, got {type(description)}" + f"Expected description to be a string, got {type(description)}", ) if not description: raise ValueError("Generated description is empty.") evaluation = self.evaluator_agent.evaluate( - description, image=image, figure=figure + description, + image=image, + figure=figure, ) if self.verify_evaluation_passes(evaluation): @@ -98,19 +102,19 @@ def run( self.provide_feedback_to_generator(i + 1, description, evaluation) logger.warning( - f"Maximum iterations ({self.max_iterations}) reached without passing evaluation. Acknowledging limits of description." + f"Maximum iterations ({self.max_iterations}) reached without passing evaluation. Acknowledging limits of description.", ) - description = self.acknowledge_limits_of_description( - description, evaluation + return self.acknowledge_limits_of_description( + description, + evaluation, ) - - return description except Exception as e: raise RuntimeError("Failed to generate a description") from e def verify_evaluation_passes( - self, evaluation: List[CriterionEvaluatorOutput] + self, + evaluation: list[CriterionEvaluatorOutput], ) -> bool: """ Verify if the evaluation passes the quality criteria. @@ -129,7 +133,7 @@ def provide_feedback_to_generator( self, evaluation_id: int, description: str, - evaluation: List[CriterionEvaluatorOutput], + evaluation: list[CriterionEvaluatorOutput], ) -> None: """ Provide feedback to the GeneratorAgent based on evaluation results. @@ -163,7 +167,9 @@ def provide_feedback_to_generator( self.generator_agent.append_user_prompt(feedback) def acknowledge_limits_of_description( - self, description: str, evaluation: List[CriterionEvaluatorOutput] + self, + description: str, + evaluation: list[CriterionEvaluatorOutput], ) -> str: """ Acknowledge the limits of the generated description based on evaluation results. diff --git a/src/earth_reach/core/prompts/evaluator.py b/src/earth_reach/core/prompts/evaluator.py index 4eedb35..e617158 100644 --- a/src/earth_reach/core/prompts/evaluator.py +++ b/src/earth_reach/core/prompts/evaluator.py @@ -10,7 +10,7 @@ **Core Definition**: Coherence measures whether information flows logically from broadest relevant context → intermediate patterns → finest relevant details, enabling systematic meteorological analysis without visual reference. -**Scale-Appropriate Hierarchy Principle**: +**Scale-Appropriate Hierarchy Principle**: - **Global charts**: Global circulation → continental patterns → regional systems - **Regional charts**: Synoptic context → regional systems → local weather - **Local charts**: Regional context → local systems → specific phenomena @@ -28,7 +28,7 @@ **Objective**: Evaluate whether the structural organization enables systematic meteorological analysis **How to Assess Information Architecture**: -1. **Context Completeness Check**: Verify essential meteorological context appears early +1. **Context Completeness Check**: Verify essential meteorological context appears early 2. **Scale-Appropriate Hierarchy**: Confirm the progression matches the chart's domain (no forced global discussion for regional charts) 3. **Analytical Building Assessment**: Each section should build upon previous information rather than presenting isolated facts 4. **Priority Sequence Logic**: Most meteorologically significant features should be introduced before secondary patterns @@ -110,7 +110,7 @@ **Score 2 - Poor Coherence** - Inappropriate scale forcing or significant hierarchy problems -- Weak analytical progression with frequent gaps between observations and interpretations +- Weak analytical progression with frequent gaps between observations and interpretations - Poor multi-scale integration with abrupt transitions or missing connections - Accessibility compromised by inconsistent references and unclear spatial logic - Structure creates barriers to systematic meteorological analysis @@ -133,7 +133,7 @@ **Pass-Fail Thresholds** (Automatic scoring guidance): - **Missing essential context** (domain, variables, ranges, intervals): Maximum score 2 -- **Inappropriate scale forcing** (global discussion for regional charts): Maximum score 2 +- **Inappropriate scale forcing** (global discussion for regional charts): Maximum score 2 - **No multi-scale connections**: Maximum score 3 - **Poor accessibility optimization** (inconsistent references, unclear spatial logic): Maximum score 3 @@ -472,7 +472,7 @@ ## COMMON PITFALLS TO AVOID 1. **Spatial Accuracy Tolerance**: ZERO tolerance for >2° pressure center errors - these make descriptions actively misleading -2. **Theoretical Complexity Confusion**: Don't excuse clear seasonal/circulation violations as "atmospheric complexity" +2. **Theoretical Complexity Confusion**: Don't excuse clear seasonal/circulation violations as "atmospheric complexity" 3. **Chart Resolution Expectations**: Don't demand precision beyond chart capabilities, but verify claimed precision is achievable 4. **Multi-Scale Logic**: Ensure local descriptions are consistent with regional and broader patterns mentioned 5. **Domain Coverage Standards**: Verify complete coverage matches chart extent - no artificial truncations acceptable @@ -677,13 +677,12 @@ def get_default_criterion_evaluator_user_prompt(criterion: str) -> str: """ if criterion == "coherence": return DEFAULT_COHERENCE_CRITERIA_EVALUATOR_USER_PROMPT - elif criterion == "fluency": + if criterion == "fluency": return DEFAULT_FLUENCY_CRITERIA_EVALUATOR_USER_PROMPT - elif criterion == "consistency": + if criterion == "consistency": return DEFAULT_CONSISTENCY_CRITERIA_EVALUATOR_USER_PROMPT - elif criterion == "relevance": + if criterion == "relevance": return DEFAULT_RELEVANCE_CRITERIA_EVALUATOR_USER_PROMPT - else: - raise ValueError( - f"Unknown criterion: {criterion}. Valid options are: coherence, fluency, consistency, relevance." - ) + raise ValueError( + f"Unknown criterion: {criterion}. Valid options are: coherence, fluency, consistency, relevance.", + ) diff --git a/src/earth_reach/core/prompts/generator.py b/src/earth_reach/core/prompts/generator.py index 0fe4c10..129bd8e 100644 --- a/src/earth_reach/core/prompts/generator.py +++ b/src/earth_reach/core/prompts/generator.py @@ -15,7 +15,7 @@ ## METEOROLOGICAL REFERENCE GUIDE ### Core Atmospheric Circulation Patterns -- **Three-Cell Model**: +- **Three-Cell Model**: - Hadley Cell (0-30°): Rising air at equator, sinking at subtropics - Ferrel Cell (30-60°): Surface westerlies, opposite of Hadley - Polar Cell (60-90°): Cold sinking air at poles, surface easterlies @@ -94,13 +94,13 @@ - Count major highs and lows - Note latitude bands of temperature - Identify any planetary wave patterns - + 2. **Regional Scale**: - Look for temperature gradients >5°C/500km - Identify regional pressure systems - Note areas of tight pressure gradients - **For global charts**: Focus analysis on key populated regions (North America, Europe, East Asia, South America, Southern Africa, Australia) - + 3. **Local Features**: - Terrain influences (if visible) - Isolated maxima/minima @@ -118,14 +118,14 @@ **Objective**: Validate identified patterns against meteorological theory **How to Validate Against Theory**: -1. **Seasonal Check**: +1. **Seasonal Check**: - Is this pattern expected for the date/location? - Example: "March Siberian high weakening - consistent with spring transition" - + 2. **Circulation Consistency**: - Do patterns align with three-cell model? - Example: "Subtropical high at 30°N matches Hadley cell subsidence" - + 3. **Physical Relationships**: - Temperature-pressure coupling logical? - Gradient strengths realistic? @@ -144,7 +144,7 @@ **Planning Components**: 1. **Information Hierarchy**: List features in order of importance 2. **Scale Integration Strategy**: How to connect global → regional → local -3. **Regional Coverage Plan**: +3. **Regional Coverage Plan**: - Geographic progression (W→E? N→S?) - For global charts: Ensure coverage of priority regions 4. **Dynamic Process Points**: Where to add circulation/weather implications diff --git a/src/earth_reach/core/utils.py b/src/earth_reach/core/utils.py index 021bf42..f9fb4fc 100644 --- a/src/earth_reach/core/utils.py +++ b/src/earth_reach/core/utils.py @@ -1,4 +1,5 @@ import base64 + from io import BytesIO From 6e4890803747fa227b30d5ffa5a8d472e70ba332 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 3 Aug 2025 11:54:49 -0400 Subject: [PATCH 082/151] doc: updating todo items --- notebooks/earthkit_plots_exploration.py | 5 +---- vllm/setup.md | 2 +- vllm/setup.sh | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/notebooks/earthkit_plots_exploration.py b/notebooks/earthkit_plots_exploration.py index 24502ae..b2fbd73 100644 --- a/notebooks/earthkit_plots_exploration.py +++ b/notebooks/earthkit_plots_exploration.py @@ -319,9 +319,6 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): **kwargs, # type: ignore ) -# TODO(high): quiver doesn't exist for a classic figure, so I need to figure out -# how to make a figure and a Map work together to plot the wind vectors -# and the temperature/pressure overlay. # figure.quiver( # u=data.sel(shortName="10u"), # v=data.sel(shortName="10v"), @@ -388,7 +385,7 @@ def save_weather_event_images(weather_events, output_dir="weather_images"): img = Image.open(buffer) # %% -figure.title() # TODO: extract the string title +figure.title() # %% [markdown] # Observations: diff --git a/vllm/setup.md b/vllm/setup.md index 7492f6a..41e9dd4 100644 --- a/vllm/setup.md +++ b/vllm/setup.md @@ -94,7 +94,7 @@ sudo dnf install -y docker-ce \ sudo systemctl enable --now docker ``` -TODO: replace caddy configuration with traefik configuration instructions +TODO(medium): replace caddy configuration with traefik configuration instructions ### Caddy ```sh diff --git a/vllm/setup.sh b/vllm/setup.sh index faad957..255620b 100644 --- a/vllm/setup.sh +++ b/vllm/setup.sh @@ -45,7 +45,7 @@ sudo nvidia-ctk runtime configure --runtime=docker # Restart docker sudo systemctl docker restart -# TODO: replace caddy automated configuration with traefik +# TODO(medium): replace caddy automated configuration with traefik # Create necessary Caddy directories mkdir -p $CADDY_DATA_DIR $CADDY_CONFIG_DIR From 3b4f41768a66b1ede7a4df23d400f3410037aa64 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 3 Aug 2025 12:00:15 -0400 Subject: [PATCH 083/151] fix: fixing base extractor import statement --- src/earth_reach/core/orchestrator.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py index b8d2445..0d3aa9b 100644 --- a/src/earth_reach/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -4,8 +4,10 @@ from PIL.ImageFile import ImageFile from earth_reach.config.logging import get_logger -from earth_reach.core.data_extractor import DataExtractorInterface from earth_reach.core.evaluator import CriterionEvaluatorOutput, EvaluatorAgent +from earth_reach.core.extractors.base_extractor import ( + BaseDataExtractor, +) from earth_reach.core.generator import GeneratorAgent from earth_reach.core.prompts.orchestrator import get_default_feedback_template @@ -19,7 +21,7 @@ def __init__( self, generator_agent: GeneratorAgent, evaluator_agent: EvaluatorAgent, - data_extractors: list[DataExtractorInterface] | None = None, + data_extractors: list[BaseDataExtractor] | None = None, max_iterations: int = 3, criteria_threshold: int = 4, feedback_template: str | None = None, @@ -30,7 +32,7 @@ def __init__( Args: generator_agent: Instance of GeneratorAgent for generating descriptions evaluator_agent: Instance of EvaluatorAgent for evaluating descriptions - data_extractors: List of DataExtractorInterface instances for extracting features + data_extractors: List of BaseDataExtractor instances for extracting features max_iterations: Maximum number of iterations for generating descriptions criteria_threshold: Minimum score for evaluation criteria to pass feedback_template: Template for feedback to the generator agent (optional) From eafb63bd8913202ba7220ac0ade7f2aeb5a0df91 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 3 Aug 2025 15:35:47 -0400 Subject: [PATCH 084/151] feat: adding mypy configuration to increase code quality with type checking --- .pre-commit-config.yaml | 6 ++++++ README.md | 8 +++++++- pyproject.toml | 27 ++++++++++++++++++++++++++ uv.lock | 43 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f59240a..0c1297b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,3 +16,9 @@ repos: - id: ruff-check args: [--fix] - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + files: ^src/earth_reach/ diff --git a/README.md b/README.md index eb3a82d..c5c6a94 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ uv run era evaluate --image-path --description "=0.12.7", "pytest>=8.3.5", "pre-commit>=4.0.1", + "mypy>=1.8.0", ] [project.optional-dependencies] @@ -86,3 +87,29 @@ known-first-party = ["earth_reach"] force-single-line = false lines-between-types = 1 split-on-trailing-comma = true + +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +files = ["src/earth_reach"] + +# Ignore missing imports for external libraries +[[tool.mypy.overrides]] +module = [ + "earthkit.*", + "scipy.*", + "fire.*", + "openai.*", + "google.*", + "PIL.*", + "dotenv.*", +] +ignore_missing_imports = true + +# Allow more flexible typing for complex API interactions +[[tool.mypy.overrides]] +module = "earth_reach.core.llm" +warn_return_any = false +disallow_untyped_defs = false diff --git a/uv.lock b/uv.lock index dbb5fbd..311a398 100644 --- a/uv.lock +++ b/uv.lock @@ -537,6 +537,7 @@ gemini = [ dev = [ { name = "ipykernel" }, { name = "jupytext" }, + { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, { name = "ruff" }, @@ -562,6 +563,7 @@ provides-extras = ["gemini", "claude", "all-models"] dev = [ { name = "ipykernel", specifier = ">=6.29.5" }, { name = "jupytext", specifier = ">=1.17.1" }, + { name = "mypy", specifier = ">=1.8.0" }, { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "ruff", specifier = ">=0.12.7" }, @@ -1474,6 +1476,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/b4/4dd3f8f8bdb79bf65a1882fbf769a27e3ce27e9566faa0aeaa295ed755d7/multiurl-0.3.5-py3-none-any.whl", hash = "sha256:37b920c3116861198ec5b24080fed5344514006021eec969784dabc76fcf3d63", size = 21323 }, ] +[[package]] +name = "mypy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295 }, + { url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355 }, + { url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285 }, + { url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895 }, + { url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025 }, + { url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664 }, + { url = "https://files.pythonhosted.org/packages/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338 }, + { url = "https://files.pythonhosted.org/packages/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066 }, + { url = "https://files.pythonhosted.org/packages/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473 }, + { url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296 }, + { url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657 }, + { url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320 }, + { url = "https://files.pythonhosted.org/packages/38/56/79c2fac86da57c7d8c48622a05873eaab40b905096c33597462713f5af90/mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733", size = 11040037 }, + { url = "https://files.pythonhosted.org/packages/4d/c3/adabe6ff53638e3cad19e3547268482408323b1e68bf082c9119000cd049/mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd", size = 10131550 }, + { url = "https://files.pythonhosted.org/packages/b8/c5/2e234c22c3bdeb23a7817af57a58865a39753bde52c74e2c661ee0cfc640/mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0", size = 11872963 }, + { url = "https://files.pythonhosted.org/packages/ab/26/c13c130f35ca8caa5f2ceab68a247775648fdcd6c9a18f158825f2bc2410/mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a", size = 12710189 }, + { url = "https://files.pythonhosted.org/packages/82/df/c7d79d09f6de8383fe800521d066d877e54d30b4fb94281c262be2df84ef/mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91", size = 12900322 }, + { url = "https://files.pythonhosted.org/packages/b8/98/3d5a48978b4f708c55ae832619addc66d677f6dc59f3ebad71bae8285ca6/mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed", size = 9751879 }, + { url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + [[package]] name = "narwhals" version = "1.37.1" From c793ae9bd5525404785aa9053ed7eeb19b3b623f Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 3 Aug 2025 15:36:36 -0400 Subject: [PATCH 085/151] fix: fixing minor type checking issues --- src/earth_reach/cli.py | 2 +- src/earth_reach/core/evaluator.py | 2 +- .../core/extractors/base_extractor.py | 4 ++-- src/earth_reach/core/llm.py | 7 ++++--- src/earth_reach/core/orchestrator.py | 18 +++++++++++++++++- src/earth_reach/core/utils.py | 5 +++-- 6 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/earth_reach/cli.py b/src/earth_reach/cli.py index 45f93ed..6e6f806 100644 --- a/src/earth_reach/cli.py +++ b/src/earth_reach/cli.py @@ -417,7 +417,7 @@ def evaluate( sys.exit(1) -def cli(): +def cli() -> None: """ CLI entrypoint. """ diff --git a/src/earth_reach/core/evaluator.py b/src/earth_reach/core/evaluator.py index ce33c28..04690fd 100644 --- a/src/earth_reach/core/evaluator.py +++ b/src/earth_reach/core/evaluator.py @@ -42,7 +42,7 @@ def is_score_valid(self) -> bool: """ return 0 <= self.score <= 5 - def __post_init__(self): + def __post_init__(self) -> None: if not self.is_score_valid(): raise ValueError("Score must be between 0 and 5.") diff --git a/src/earth_reach/core/extractors/base_extractor.py b/src/earth_reach/core/extractors/base_extractor.py index 9152f01..f7b4013 100644 --- a/src/earth_reach/core/extractors/base_extractor.py +++ b/src/earth_reach/core/extractors/base_extractor.py @@ -27,7 +27,7 @@ def __init__(self, verbose: bool = False): """ self.verbose = verbose self._data = None - self._metadata = {} + self._metadata: dict[str, Any] = {} @abstractmethod def validate_data(self, data: Any) -> bool: @@ -45,7 +45,7 @@ def validate_data(self, data: Any) -> bool: """ @abstractmethod - def extract(self, data: Any, **kwargs) -> list[Any]: + def extract(self, data: Any, **kwargs: Any) -> list[Any]: """ Extract features from the input data. diff --git a/src/earth_reach/core/llm.py b/src/earth_reach/core/llm.py index 367d21e..904aa7f 100644 --- a/src/earth_reach/core/llm.py +++ b/src/earth_reach/core/llm.py @@ -1,6 +1,7 @@ import os from abc import ABC, abstractmethod +from typing import Any import openai @@ -89,7 +90,7 @@ def generate( if not user_prompt or not user_prompt.strip(): raise ValueError("user_prompt cannot be empty or None") - messages = [] + messages: list[Any] = [] if system_prompt and system_prompt.strip(): messages.append({"role": "system", "content": system_prompt.strip()}) @@ -100,7 +101,7 @@ def generate( if not base64_image: raise ValueError("Failed to convert image to base64") - user_content = [ + user_content: Any = [ {"type": "text", "text": user_prompt.strip()}, { "type": "image_url", @@ -255,7 +256,7 @@ def generate( if system_prompt and system_prompt.strip(): full_prompt = f"{system_prompt.strip()}\n\n{user_prompt.strip()}" - contents = [] + contents: list[Any] = [] if image: image_bytes = img_to_bytes(image) if not image_bytes: diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py index 0d3aa9b..0e74057 100644 --- a/src/earth_reach/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -8,7 +8,7 @@ from earth_reach.core.extractors.base_extractor import ( BaseDataExtractor, ) -from earth_reach.core.generator import GeneratorAgent +from earth_reach.core.generator import GeneratorAgent, GeneratorOutput from earth_reach.core.prompts.orchestrator import get_default_feedback_template logger = get_logger(__name__) @@ -40,8 +40,15 @@ def __init__( self.generator_agent = generator_agent self.evaluator_agent = evaluator_agent self.data_extractors = data_extractors if data_extractors is not None else [] + self.max_iterations = max_iterations + if self.max_iterations <= 0: + raise ValueError("max_iterations must be greater than 0") + self.criteria_threshold = criteria_threshold + if self.criteria_threshold < 0 or self.criteria_threshold > 5: + raise ValueError("criteria_threshold must be between 0 and 5") + self.feedback_template = feedback_template or get_default_feedback_template() self.criteria_limits_acknowledgment = { "coherence": "Warning: The logical flow and organization of this description may be unclear.", @@ -78,6 +85,8 @@ def run( # then add the features to the generator user prompt and evaluator prompt using a method similar to `provide_feedback_to_generator` that uses `append_user_prompt` try: + description: str | GeneratorOutput = "" + evaluation: list[CriterionEvaluatorOutput] = [] for i in range(self.max_iterations): description = self.generator_agent.generate( figure=figure, @@ -107,6 +116,13 @@ def run( f"Maximum iterations ({self.max_iterations}) reached without passing evaluation. Acknowledging limits of description.", ) + if not isinstance(description, str): + raise TypeError( + f"Expected description to be a string, got {type(description)}", + ) + if not description: + raise ValueError("Final generated description is empty.") + return self.acknowledge_limits_of_description( description, evaluation, diff --git a/src/earth_reach/core/utils.py b/src/earth_reach/core/utils.py index f9fb4fc..6423458 100644 --- a/src/earth_reach/core/utils.py +++ b/src/earth_reach/core/utils.py @@ -1,9 +1,10 @@ import base64 from io import BytesIO +from typing import Any -def img_to_base64(image_path: str | None = None, img=None) -> str: +def img_to_base64(image_path: str | None = None, img: Any = None) -> str: """ Convert an image to a base64 string. @@ -26,7 +27,7 @@ def img_to_base64(image_path: str | None = None, img=None) -> str: return base64.b64encode(img_file.read()).decode("utf-8") -def img_to_bytes(img) -> bytes: +def img_to_bytes(img: Any) -> bytes: """ Convert a PIL Image to bytes for Gemini API. From 34c523a5841046a0a07a563dc582bd2891373734 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 3 Aug 2025 18:05:10 -0400 Subject: [PATCH 086/151] feat: implementing unit test structure --- src/tests/__init__.py | 1 + src/tests/unit/__init__.py | 1 + 2 files changed, 2 insertions(+) create mode 100644 src/tests/__init__.py create mode 100644 src/tests/unit/__init__.py diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..73b4114 --- /dev/null +++ b/src/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for EarthReach project diff --git a/src/tests/unit/__init__.py b/src/tests/unit/__init__.py new file mode 100644 index 0000000..4a5d263 --- /dev/null +++ b/src/tests/unit/__init__.py @@ -0,0 +1 @@ +# Unit tests package From b4fcfae6e289fdf975d596121610ec7f7c2571f3 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sat, 9 Aug 2025 12:51:00 -0400 Subject: [PATCH 087/151] feat: improving type coherence for images --- .pre-commit-config.yaml | 6 -- pyproject.toml | 23 ++--- src/earth_reach/core/generator.py | 4 +- src/earth_reach/core/llm.py | 23 +++-- src/earth_reach/core/utils.py | 11 +-- uv.lock | 139 ++++++++++++++++++++++-------- 6 files changed, 134 insertions(+), 72 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c1297b..f59240a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,9 +16,3 @@ repos: - id: ruff-check args: [--fix] - id: ruff-format - - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 - hooks: - - id: mypy - files: ^src/earth_reach/ diff --git a/pyproject.toml b/pyproject.toml index f1948b5..f6359be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "earthkit>=0.10.3", "earthkit-data>=0.13.8", "earthkit-plots>=0.3.1", + "scipy>=1.15.2", "openai>=1.77.0", "python-dotenv>=1.1.0", "hatchling>=1.27.0", @@ -26,6 +27,7 @@ dev = [ "pytest>=8.3.5", "pre-commit>=4.0.1", "mypy>=1.8.0", + "scipy-stubs>=1.16.1.0", ] [project.optional-dependencies] @@ -95,21 +97,8 @@ warn_unused_configs = true disallow_untyped_defs = true files = ["src/earth_reach"] -# Ignore missing imports for external libraries +# Auto type checking for untyped packages [[tool.mypy.overrides]] -module = [ - "earthkit.*", - "scipy.*", - "fire.*", - "openai.*", - "google.*", - "PIL.*", - "dotenv.*", -] -ignore_missing_imports = true - -# Allow more flexible typing for complex API interactions -[[tool.mypy.overrides]] -module = "earth_reach.core.llm" -warn_return_any = false -disallow_untyped_defs = false +module = ["earthkit.*", "fire.*"] +follow_untyped_imports = true +warn_return_any = true diff --git a/src/earth_reach/core/generator.py b/src/earth_reach/core/generator.py index c612952..b48389e 100644 --- a/src/earth_reach/core/generator.py +++ b/src/earth_reach/core/generator.py @@ -187,11 +187,13 @@ def generate( return parsed_output description = parsed_output.final_description + if not description or not description.strip(): + raise ValueError("Final description is empty or None.") except Exception as e: raise RuntimeError(f"Failed to generate response: {e}") from e - return description # type: ignore[return-value] + return description def parse_llm_response(self, response: str) -> GeneratorOutput: """ diff --git a/src/earth_reach/core/llm.py b/src/earth_reach/core/llm.py index 904aa7f..ff23aa8 100644 --- a/src/earth_reach/core/llm.py +++ b/src/earth_reach/core/llm.py @@ -7,6 +7,7 @@ from google import genai from google.genai import types +from PIL.ImageFile import ImageFile from earth_reach.core.utils import img_to_base64, img_to_bytes @@ -19,7 +20,7 @@ def generate( self, user_prompt: str, system_prompt: str | None = None, - image=None, + image: ImageFile | None = None, ) -> str: """ Generate a response from the LLM based on the user prompt and optional system prompt. @@ -69,7 +70,7 @@ def generate( self, user_prompt: str, system_prompt: str | None = None, - image=None, + image: ImageFile | None = None, ) -> str: """ Generate a response from the LLM API based on the user prompt and optional system prompt. @@ -126,8 +127,10 @@ def generate( content = response.choices[0].message.content - if not content or not content.strip(): - raise ValueError("The generated response content is empty") + if not content or not isinstance(content, str) or not content.strip(): + raise ValueError( + "The generated response content is empty or not a string" + ) return content.strip() @@ -230,7 +233,7 @@ def generate( self, user_prompt: str, system_prompt: str | None = None, - image=None, + image: ImageFile | None = None, ) -> str: """ Generate a response from the Gemini API based on the user prompt and optional system prompt. @@ -238,7 +241,7 @@ def generate( Args: user_prompt (str): The prompt provided by the user to define the task. system_prompt (str | None): An optional system prompt to guide the model's response. - image: Optional image to include in the request (PIL Image). + image: Optional image to include in the request (ImageFile). Returns: str: The generated response content from the Gemini API. @@ -281,8 +284,10 @@ def generate( content = response.text - if not content or not content.strip(): - raise ValueError("The generated response content is empty") + if not content or not isinstance(content, str) or not content.strip(): + raise ValueError( + "The generated response content is empty or not a string" + ) return content.strip() @@ -296,7 +301,7 @@ def __repr__(self) -> str: return f"GeminiLLM(model_name={self.model_name})" -def create_llm(provider="groq", model_name: str | None = None) -> LLMInterface: +def create_llm(provider: str = "groq", model_name: str | None = None) -> LLMInterface: """ Create and return LLM instance. diff --git a/src/earth_reach/core/utils.py b/src/earth_reach/core/utils.py index 6423458..ad2962a 100644 --- a/src/earth_reach/core/utils.py +++ b/src/earth_reach/core/utils.py @@ -1,16 +1,17 @@ import base64 from io import BytesIO -from typing import Any +from PIL.ImageFile import ImageFile -def img_to_base64(image_path: str | None = None, img: Any = None) -> str: + +def img_to_base64(image_path: str | None = None, img: ImageFile | None = None) -> str: """ Convert an image to a base64 string. Args: image_path (str): The path to the image file. Either this or img must be provided. - img (PIL.Image): The image object. Either this or image_path must be provided. + img (ImageFile | None): The image object. Either this or image_path must be provided. Returns: str: The base64 string representation of the image. @@ -27,12 +28,12 @@ def img_to_base64(image_path: str | None = None, img: Any = None) -> str: return base64.b64encode(img_file.read()).decode("utf-8") -def img_to_bytes(img: Any) -> bytes: +def img_to_bytes(img: ImageFile) -> bytes: """ Convert a PIL Image to bytes for Gemini API. Args: - img: PIL Image object + img: ImageFile object Returns: bytes: Image as bytes diff --git a/uv.lock b/uv.lock index 311a398..9206f4f 100644 --- a/uv.lock +++ b/uv.lock @@ -519,6 +519,7 @@ dependencies = [ { name = "hatchling" }, { name = "openai" }, { name = "python-dotenv" }, + { name = "scipy" }, ] [package.optional-dependencies] @@ -541,6 +542,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "ruff" }, + { name = "scipy-stubs" }, ] [package.metadata] @@ -556,6 +558,7 @@ requires-dist = [ { name = "hatchling", specifier = ">=1.27.0" }, { name = "openai", specifier = ">=1.77.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, + { name = "scipy", specifier = ">=1.15.2" }, ] provides-extras = ["gemini", "claude", "all-models"] @@ -567,6 +570,7 @@ dev = [ { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "ruff", specifier = ">=0.12.7" }, + { name = "scipy-stubs", specifier = ">=1.16.1.0" }, ] [[package]] @@ -1584,40 +1588,77 @@ wheels = [ [[package]] name = "numpy" -version = "2.2.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/b2/ce4b867d8cd9c0ee84938ae1e6a6f7926ebf928c9090d036fc3c6a04f946/numpy-2.2.5.tar.gz", hash = "sha256:a9c0d994680cd991b1cb772e8b297340085466a6fe964bc9d4e80f5e2f43c291", size = 20273920 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/f7/1fd4ff108cd9d7ef929b8882692e23665dc9c23feecafbb9c6b80f4ec583/numpy-2.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ee461a4eaab4f165b68780a6a1af95fb23a29932be7569b9fab666c407969051", size = 20948633 }, - { url = "https://files.pythonhosted.org/packages/12/03/d443c278348371b20d830af155ff2079acad6a9e60279fac2b41dbbb73d8/numpy-2.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ec31367fd6a255dc8de4772bd1658c3e926d8e860a0b6e922b615e532d320ddc", size = 14176123 }, - { url = "https://files.pythonhosted.org/packages/2b/0b/5ca264641d0e7b14393313304da48b225d15d471250376f3fbdb1a2be603/numpy-2.2.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:47834cde750d3c9f4e52c6ca28a7361859fcaf52695c7dc3cc1a720b8922683e", size = 5163817 }, - { url = "https://files.pythonhosted.org/packages/04/b3/d522672b9e3d28e26e1613de7675b441bbd1eaca75db95680635dd158c67/numpy-2.2.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2c1a1c6ccce4022383583a6ded7bbcda22fc635eb4eb1e0a053336425ed36dfa", size = 6698066 }, - { url = "https://files.pythonhosted.org/packages/a0/93/0f7a75c1ff02d4b76df35079676b3b2719fcdfb39abdf44c8b33f43ef37d/numpy-2.2.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d75f338f5f79ee23548b03d801d28a505198297534f62416391857ea0479571", size = 14087277 }, - { url = "https://files.pythonhosted.org/packages/b0/d9/7c338b923c53d431bc837b5b787052fef9ae68a56fe91e325aac0d48226e/numpy-2.2.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a801fef99668f309b88640e28d261991bfad9617c27beda4a3aec4f217ea073", size = 16135742 }, - { url = "https://files.pythonhosted.org/packages/2d/10/4dec9184a5d74ba9867c6f7d1e9f2e0fb5fe96ff2bf50bb6f342d64f2003/numpy-2.2.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:abe38cd8381245a7f49967a6010e77dbf3680bd3627c0fe4362dd693b404c7f8", size = 15581825 }, - { url = "https://files.pythonhosted.org/packages/80/1f/2b6fcd636e848053f5b57712a7d1880b1565eec35a637fdfd0a30d5e738d/numpy-2.2.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5a0ac90e46fdb5649ab6369d1ab6104bfe5854ab19b645bf5cda0127a13034ae", size = 17899600 }, - { url = "https://files.pythonhosted.org/packages/ec/87/36801f4dc2623d76a0a3835975524a84bd2b18fe0f8835d45c8eae2f9ff2/numpy-2.2.5-cp312-cp312-win32.whl", hash = "sha256:0cd48122a6b7eab8f06404805b1bd5856200e3ed6f8a1b9a194f9d9054631beb", size = 6312626 }, - { url = "https://files.pythonhosted.org/packages/8b/09/4ffb4d6cfe7ca6707336187951992bd8a8b9142cf345d87ab858d2d7636a/numpy-2.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:ced69262a8278547e63409b2653b372bf4baff0870c57efa76c5703fd6543282", size = 12645715 }, - { url = "https://files.pythonhosted.org/packages/e2/a0/0aa7f0f4509a2e07bd7a509042967c2fab635690d4f48c6c7b3afd4f448c/numpy-2.2.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:059b51b658f4414fff78c6d7b1b4e18283ab5fa56d270ff212d5ba0c561846f4", size = 20935102 }, - { url = "https://files.pythonhosted.org/packages/7e/e4/a6a9f4537542912ec513185396fce52cdd45bdcf3e9d921ab02a93ca5aa9/numpy-2.2.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:47f9ed103af0bc63182609044b0490747e03bd20a67e391192dde119bf43d52f", size = 14191709 }, - { url = "https://files.pythonhosted.org/packages/be/65/72f3186b6050bbfe9c43cb81f9df59ae63603491d36179cf7a7c8d216758/numpy-2.2.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:261a1ef047751bb02f29dfe337230b5882b54521ca121fc7f62668133cb119c9", size = 5149173 }, - { url = "https://files.pythonhosted.org/packages/e5/e9/83e7a9432378dde5802651307ae5e9ea07bb72b416728202218cd4da2801/numpy-2.2.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4520caa3807c1ceb005d125a75e715567806fed67e315cea619d5ec6e75a4191", size = 6684502 }, - { url = "https://files.pythonhosted.org/packages/ea/27/b80da6c762394c8ee516b74c1f686fcd16c8f23b14de57ba0cad7349d1d2/numpy-2.2.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d14b17b9be5f9c9301f43d2e2a4886a33b53f4e6fdf9ca2f4cc60aeeee76372", size = 14084417 }, - { url = "https://files.pythonhosted.org/packages/aa/fc/ebfd32c3e124e6a1043e19c0ab0769818aa69050ce5589b63d05ff185526/numpy-2.2.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ba321813a00e508d5421104464510cc962a6f791aa2fca1c97b1e65027da80d", size = 16133807 }, - { url = "https://files.pythonhosted.org/packages/bf/9b/4cc171a0acbe4666f7775cfd21d4eb6bb1d36d3a0431f48a73e9212d2278/numpy-2.2.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4cbdef3ddf777423060c6f81b5694bad2dc9675f110c4b2a60dc0181543fac7", size = 15575611 }, - { url = "https://files.pythonhosted.org/packages/a3/45/40f4135341850df48f8edcf949cf47b523c404b712774f8855a64c96ef29/numpy-2.2.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54088a5a147ab71a8e7fdfd8c3601972751ded0739c6b696ad9cb0343e21ab73", size = 17895747 }, - { url = "https://files.pythonhosted.org/packages/f8/4c/b32a17a46f0ffbde8cc82df6d3daeaf4f552e346df143e1b188a701a8f09/numpy-2.2.5-cp313-cp313-win32.whl", hash = "sha256:c8b82a55ef86a2d8e81b63da85e55f5537d2157165be1cb2ce7cfa57b6aef38b", size = 6309594 }, - { url = "https://files.pythonhosted.org/packages/13/ae/72e6276feb9ef06787365b05915bfdb057d01fceb4a43cb80978e518d79b/numpy-2.2.5-cp313-cp313-win_amd64.whl", hash = "sha256:d8882a829fd779f0f43998e931c466802a77ca1ee0fe25a3abe50278616b1471", size = 12638356 }, - { url = "https://files.pythonhosted.org/packages/79/56/be8b85a9f2adb688e7ded6324e20149a03541d2b3297c3ffc1a73f46dedb/numpy-2.2.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e8b025c351b9f0e8b5436cf28a07fa4ac0204d67b38f01433ac7f9b870fa38c6", size = 20963778 }, - { url = "https://files.pythonhosted.org/packages/ff/77/19c5e62d55bff507a18c3cdff82e94fe174957bad25860a991cac719d3ab/numpy-2.2.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dfa94b6a4374e7851bbb6f35e6ded2120b752b063e6acdd3157e4d2bb922eba", size = 14207279 }, - { url = "https://files.pythonhosted.org/packages/75/22/aa11f22dc11ff4ffe4e849d9b63bbe8d4ac6d5fae85ddaa67dfe43be3e76/numpy-2.2.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:97c8425d4e26437e65e1d189d22dff4a079b747ff9c2788057bfb8114ce1e133", size = 5199247 }, - { url = "https://files.pythonhosted.org/packages/4f/6c/12d5e760fc62c08eded0394f62039f5a9857f758312bf01632a81d841459/numpy-2.2.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:352d330048c055ea6db701130abc48a21bec690a8d38f8284e00fab256dc1376", size = 6711087 }, - { url = "https://files.pythonhosted.org/packages/ef/94/ece8280cf4218b2bee5cec9567629e61e51b4be501e5c6840ceb593db945/numpy-2.2.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b4c0773b6ada798f51f0f8e30c054d32304ccc6e9c5d93d46cb26f3d385ab19", size = 14059964 }, - { url = "https://files.pythonhosted.org/packages/39/41/c5377dac0514aaeec69115830a39d905b1882819c8e65d97fc60e177e19e/numpy-2.2.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55f09e00d4dccd76b179c0f18a44f041e5332fd0e022886ba1c0bbf3ea4a18d0", size = 16121214 }, - { url = "https://files.pythonhosted.org/packages/db/54/3b9f89a943257bc8e187145c6bc0eb8e3d615655f7b14e9b490b053e8149/numpy-2.2.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:02f226baeefa68f7d579e213d0f3493496397d8f1cff5e2b222af274c86a552a", size = 15575788 }, - { url = "https://files.pythonhosted.org/packages/b1/c4/2e407e85df35b29f79945751b8f8e671057a13a376497d7fb2151ba0d290/numpy-2.2.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c26843fd58f65da9491165072da2cccc372530681de481ef670dcc8e27cfb066", size = 17893672 }, - { url = "https://files.pythonhosted.org/packages/29/7e/d0b44e129d038dba453f00d0e29ebd6eaf2f06055d72b95b9947998aca14/numpy-2.2.5-cp313-cp313t-win32.whl", hash = "sha256:1a161c2c79ab30fe4501d5a2bbfe8b162490757cf90b7f05be8b80bc02f7bb8e", size = 6377102 }, - { url = "https://files.pythonhosted.org/packages/63/be/b85e4aa4bf42c6502851b971f1c326d583fcc68227385f92089cf50a7b45/numpy-2.2.5-cp313-cp313t-win_amd64.whl", hash = "sha256:d403c84991b5ad291d3809bace5e85f4bbf44a04bdc9a88ed2bb1807b3360bb8", size = 12750096 }, +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/6d/745dd1c1c5c284d17725e5c802ca4d45cfc6803519d777f087b71c9f4069/numpy-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b", size = 20956420 }, + { url = "https://files.pythonhosted.org/packages/bc/96/e7b533ea5740641dd62b07a790af5d9d8fec36000b8e2d0472bd7574105f/numpy-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f", size = 14184660 }, + { url = "https://files.pythonhosted.org/packages/2b/53/102c6122db45a62aa20d1b18c9986f67e6b97e0d6fbc1ae13e3e4c84430c/numpy-2.3.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0", size = 5113382 }, + { url = "https://files.pythonhosted.org/packages/2b/21/376257efcbf63e624250717e82b4fae93d60178f09eb03ed766dbb48ec9c/numpy-2.3.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b", size = 6647258 }, + { url = "https://files.pythonhosted.org/packages/91/ba/f4ebf257f08affa464fe6036e13f2bf9d4642a40228781dc1235da81be9f/numpy-2.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370", size = 14281409 }, + { url = "https://files.pythonhosted.org/packages/59/ef/f96536f1df42c668cbacb727a8c6da7afc9c05ece6d558927fb1722693e1/numpy-2.3.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73", size = 16641317 }, + { url = "https://files.pythonhosted.org/packages/f6/a7/af813a7b4f9a42f498dde8a4c6fcbff8100eed00182cc91dbaf095645f38/numpy-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc", size = 16056262 }, + { url = "https://files.pythonhosted.org/packages/8b/5d/41c4ef8404caaa7f05ed1cfb06afe16a25895260eacbd29b4d84dff2920b/numpy-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be", size = 18579342 }, + { url = "https://files.pythonhosted.org/packages/a1/4f/9950e44c5a11636f4a3af6e825ec23003475cc9a466edb7a759ed3ea63bd/numpy-2.3.2-cp312-cp312-win32.whl", hash = "sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036", size = 6320610 }, + { url = "https://files.pythonhosted.org/packages/7c/2f/244643a5ce54a94f0a9a2ab578189c061e4a87c002e037b0829dd77293b6/numpy-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f", size = 12786292 }, + { url = "https://files.pythonhosted.org/packages/54/cd/7b5f49d5d78db7badab22d8323c1b6ae458fbf86c4fdfa194ab3cd4eb39b/numpy-2.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07", size = 10194071 }, + { url = "https://files.pythonhosted.org/packages/1c/c0/c6bb172c916b00700ed3bf71cb56175fd1f7dbecebf8353545d0b5519f6c/numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3", size = 20949074 }, + { url = "https://files.pythonhosted.org/packages/20/4e/c116466d22acaf4573e58421c956c6076dc526e24a6be0903219775d862e/numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b", size = 14177311 }, + { url = "https://files.pythonhosted.org/packages/78/45/d4698c182895af189c463fc91d70805d455a227261d950e4e0f1310c2550/numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6", size = 5106022 }, + { url = "https://files.pythonhosted.org/packages/9f/76/3e6880fef4420179309dba72a8c11f6166c431cf6dee54c577af8906f914/numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089", size = 6640135 }, + { url = "https://files.pythonhosted.org/packages/34/fa/87ff7f25b3c4ce9085a62554460b7db686fef1e0207e8977795c7b7d7ba1/numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2", size = 14278147 }, + { url = "https://files.pythonhosted.org/packages/1d/0f/571b2c7a3833ae419fe69ff7b479a78d313581785203cc70a8db90121b9a/numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f", size = 16635989 }, + { url = "https://files.pythonhosted.org/packages/24/5a/84ae8dca9c9a4c592fe11340b36a86ffa9fd3e40513198daf8a97839345c/numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee", size = 16053052 }, + { url = "https://files.pythonhosted.org/packages/57/7c/e5725d99a9133b9813fcf148d3f858df98511686e853169dbaf63aec6097/numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6", size = 18577955 }, + { url = "https://files.pythonhosted.org/packages/ae/11/7c546fcf42145f29b71e4d6f429e96d8d68e5a7ba1830b2e68d7418f0bbd/numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b", size = 6311843 }, + { url = "https://files.pythonhosted.org/packages/aa/6f/a428fd1cb7ed39b4280d057720fed5121b0d7754fd2a9768640160f5517b/numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56", size = 12782876 }, + { url = "https://files.pythonhosted.org/packages/65/85/4ea455c9040a12595fb6c43f2c217257c7b52dd0ba332c6a6c1d28b289fe/numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2", size = 10192786 }, + { url = "https://files.pythonhosted.org/packages/80/23/8278f40282d10c3f258ec3ff1b103d4994bcad78b0cba9208317f6bb73da/numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab", size = 21047395 }, + { url = "https://files.pythonhosted.org/packages/1f/2d/624f2ce4a5df52628b4ccd16a4f9437b37c35f4f8a50d00e962aae6efd7a/numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2", size = 14300374 }, + { url = "https://files.pythonhosted.org/packages/f6/62/ff1e512cdbb829b80a6bd08318a58698867bca0ca2499d101b4af063ee97/numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a", size = 5228864 }, + { url = "https://files.pythonhosted.org/packages/7d/8e/74bc18078fff03192d4032cfa99d5a5ca937807136d6f5790ce07ca53515/numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286", size = 6737533 }, + { url = "https://files.pythonhosted.org/packages/19/ea/0731efe2c9073ccca5698ef6a8c3667c4cf4eea53fcdcd0b50140aba03bc/numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8", size = 14352007 }, + { url = "https://files.pythonhosted.org/packages/cf/90/36be0865f16dfed20f4bc7f75235b963d5939707d4b591f086777412ff7b/numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a", size = 16701914 }, + { url = "https://files.pythonhosted.org/packages/94/30/06cd055e24cb6c38e5989a9e747042b4e723535758e6153f11afea88c01b/numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91", size = 16132708 }, + { url = "https://files.pythonhosted.org/packages/9a/14/ecede608ea73e58267fd7cb78f42341b3b37ba576e778a1a06baffbe585c/numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5", size = 18651678 }, + { url = "https://files.pythonhosted.org/packages/40/f3/2fe6066b8d07c3685509bc24d56386534c008b462a488b7f503ba82b8923/numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5", size = 6441832 }, + { url = "https://files.pythonhosted.org/packages/0b/ba/0937d66d05204d8f28630c9c60bc3eda68824abde4cf756c4d6aad03b0c6/numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450", size = 12927049 }, + { url = "https://files.pythonhosted.org/packages/e9/ed/13542dd59c104d5e654dfa2ac282c199ba64846a74c2c4bcdbc3a0f75df1/numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a", size = 10262935 }, + { url = "https://files.pythonhosted.org/packages/c9/7c/7659048aaf498f7611b783e000c7268fcc4dcf0ce21cd10aad7b2e8f9591/numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a", size = 20950906 }, + { url = "https://files.pythonhosted.org/packages/80/db/984bea9d4ddf7112a04cfdfb22b1050af5757864cfffe8e09e44b7f11a10/numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b", size = 14185607 }, + { url = "https://files.pythonhosted.org/packages/e4/76/b3d6f414f4eca568f469ac112a3b510938d892bc5a6c190cb883af080b77/numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125", size = 5114110 }, + { url = "https://files.pythonhosted.org/packages/9e/d2/6f5e6826abd6bca52392ed88fe44a4b52aacb60567ac3bc86c67834c3a56/numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19", size = 6642050 }, + { url = "https://files.pythonhosted.org/packages/c4/43/f12b2ade99199e39c73ad182f103f9d9791f48d885c600c8e05927865baf/numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f", size = 14296292 }, + { url = "https://files.pythonhosted.org/packages/5d/f9/77c07d94bf110a916b17210fac38680ed8734c236bfed9982fd8524a7b47/numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5", size = 16638913 }, + { url = "https://files.pythonhosted.org/packages/9b/d1/9d9f2c8ea399cc05cfff8a7437453bd4e7d894373a93cdc46361bbb49a7d/numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58", size = 16071180 }, + { url = "https://files.pythonhosted.org/packages/4c/41/82e2c68aff2a0c9bf315e47d61951099fed65d8cb2c8d9dc388cb87e947e/numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0", size = 18576809 }, + { url = "https://files.pythonhosted.org/packages/14/14/4b4fd3efb0837ed252d0f583c5c35a75121038a8c4e065f2c259be06d2d8/numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2", size = 6366410 }, + { url = "https://files.pythonhosted.org/packages/11/9e/b4c24a6b8467b61aced5c8dc7dcfce23621baa2e17f661edb2444a418040/numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b", size = 12918821 }, + { url = "https://files.pythonhosted.org/packages/0e/0f/0dc44007c70b1007c1cef86b06986a3812dd7106d8f946c09cfa75782556/numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910", size = 10477303 }, + { url = "https://files.pythonhosted.org/packages/8b/3e/075752b79140b78ddfc9c0a1634d234cfdbc6f9bbbfa6b7504e445ad7d19/numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e", size = 21047524 }, + { url = "https://files.pythonhosted.org/packages/fe/6d/60e8247564a72426570d0e0ea1151b95ce5bd2f1597bb878a18d32aec855/numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45", size = 14300519 }, + { url = "https://files.pythonhosted.org/packages/4d/73/d8326c442cd428d47a067070c3ac6cc3b651a6e53613a1668342a12d4479/numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b", size = 5228972 }, + { url = "https://files.pythonhosted.org/packages/34/2e/e71b2d6dad075271e7079db776196829019b90ce3ece5c69639e4f6fdc44/numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2", size = 6737439 }, + { url = "https://files.pythonhosted.org/packages/15/b0/d004bcd56c2c5e0500ffc65385eb6d569ffd3363cb5e593ae742749b2daa/numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0", size = 14352479 }, + { url = "https://files.pythonhosted.org/packages/11/e3/285142fcff8721e0c99b51686426165059874c150ea9ab898e12a492e291/numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0", size = 16702805 }, + { url = "https://files.pythonhosted.org/packages/33/c3/33b56b0e47e604af2c7cd065edca892d180f5899599b76830652875249a3/numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2", size = 16133830 }, + { url = "https://files.pythonhosted.org/packages/6e/ae/7b1476a1f4d6a48bc669b8deb09939c56dd2a439db1ab03017844374fb67/numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf", size = 18652665 }, + { url = "https://files.pythonhosted.org/packages/14/ba/5b5c9978c4bb161034148ade2de9db44ec316fab89ce8c400db0e0c81f86/numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1", size = 6514777 }, + { url = "https://files.pythonhosted.org/packages/eb/46/3dbaf0ae7c17cdc46b9f662c56da2054887b8d9e737c1476f335c83d33db/numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b", size = 13111856 }, + { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226 }, +] + +[[package]] +name = "numpy-typing-compat" +version = "2.3.20250730" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/20/f2a7b68572d1e011a13bdaf0452de9cc6c53b0685371134db6c78d4c824b/numpy_typing_compat-2.3.20250730.tar.gz", hash = "sha256:eb30717dc7390a038c2247be1a7e8bf1a48b8a4701a01960804830a231631bef", size = 4707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/47/ca494f6f2366f17bd2a79b6c1468f6c441a447b017fbdf600aa40b5d27ac/numpy_typing_compat-2.3.20250730-py3-none-any.whl", hash = "sha256:9ab0cd4bb1b4c31debf0bd745554c735a07a7bb3cc3801dc934b2b5856549612", size = 6057 }, ] [[package]] @@ -1655,6 +1696,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/58/37ae3ca75936b824a0a5ca30491c968192007857319d6836764b548b9d9b/openai-1.77.0-py3-none-any.whl", hash = "sha256:07706e91eb71631234996989a8ea991d5ee56f0744ef694c961e0824d4f39218", size = 662031 }, ] +[[package]] +name = "optype" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/b5/c295280c848a622e402d1f4c329d0f4d8055e281a140d3ca52aa8eda462d/optype-0.13.1.tar.gz", hash = "sha256:9b1d590597b0c093faf5253e38238c5a5e1a1f9afb04530836c38ac94fdd5cf6", size = 99030 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/0f/35fd26222a48cb4601c62616f992586b39fdcb963bd362bbeec435f81355/optype-0.13.1-py3-none-any.whl", hash = "sha256:811c6b4c3d40e10b6602e33a546b30b5e595e9ec7c59e050b5e328332ed2659c", size = 87632 }, +] + +[package.optional-dependencies] +numpy = [ + { name = "numpy" }, + { name = "numpy-typing-compat" }, +] + [[package]] name = "orjson" version = "3.10.18" @@ -2456,6 +2515,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/c8/b3f566db71461cabd4b2d5b39bcc24a7e1c119535c8361f81426be39bb47/scipy-1.15.2-cp313-cp313t-win_amd64.whl", hash = "sha256:fe8a9eb875d430d81755472c5ba75e84acc980e4a8f6204d402849234d3017db", size = 40477705 }, ] +[[package]] +name = "scipy-stubs" +version = "1.16.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "optype", extra = ["numpy"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/27/bc9a81cabc1cb24aca77d221d70e9d62677b984da9a20292511b80183895/scipy_stubs-1.16.1.0.tar.gz", hash = "sha256:4251c10d0a0a47b54916c49f62cc7b411ab0f7b78ea3946a156990110982d6d9", size = 344964 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/1b/9afd7b859c3067845631c65bff8a1ffa59de122e0af121ff17c476f625cd/scipy_stubs-1.16.1.0-py3-none-any.whl", hash = "sha256:6ce677a485391012cbcc0e28897b5dd6031a1b5dda40b2ceecbbc5d4f9cc03d5", size = 551168 }, +] + [[package]] name = "shapely" version = "2.1.0" From 9e5da6cac3fa0ca70056b84913ff27d55b8dfd12 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 10 Aug 2025 15:52:07 -0400 Subject: [PATCH 088/151] feat: implementing simple local-extrema pressure center extractor --- .../core/extractors/base_extractor.py | 39 +-- .../core/extractors/pressure_extractor.py | 312 ++++++------------ 2 files changed, 113 insertions(+), 238 deletions(-) diff --git a/src/earth_reach/core/extractors/base_extractor.py b/src/earth_reach/core/extractors/base_extractor.py index f7b4013..c6fd77a 100644 --- a/src/earth_reach/core/extractors/base_extractor.py +++ b/src/earth_reach/core/extractors/base_extractor.py @@ -1,7 +1,7 @@ """ Base Data Extractor module. -This module defines the abstract base class for data extractors in the EarthReach project. +This module defines the abstract base class for data extractors. It provides a common interface and functionality for extracting meteorological features from GRIB files. """ @@ -9,43 +9,29 @@ from abc import ABC, abstractmethod from typing import Any +import earthkit.data as ekd -class BaseDataExtractor(ABC): - """ - Abstract base class for weather data extractors. - - Provides common functionality and interface for extracting - meteorological features from various data sources. - """ - - def __init__(self, verbose: bool = False): - """ - Initialize the data extractor. - Args: - verbose: Whether to print progress messages - """ - self.verbose = verbose - self._data = None - self._metadata: dict[str, Any] = {} +class BaseDataExtractor(ABC): + """Abstract base class for weather data extractors.""" @abstractmethod - def validate_data(self, data: Any) -> bool: + def validate_data(self, data: ekd.FieldList) -> Any: """ - Validate that the input data contains required variables. + Parse, validate and return GRIB data. Args: - data: Input data to validate + data (ekd.FieldList): Input data to validate Returns: - bool: True if validation passes + Any: Parsed and validated data Raises: ValueError: If validation fails """ @abstractmethod - def extract(self, data: Any, **kwargs: Any) -> list[Any]: + def extract(self, data: ekd.FieldList, **kwargs: Any) -> list[Any]: """ Extract features from the input data. @@ -58,14 +44,13 @@ def extract(self, data: Any, **kwargs: Any) -> list[Any]: """ @abstractmethod - def add_data_to_prompt(self, prompt: str, features: list[Any]) -> str: + def format_features_to_str(self, features: list[Any]) -> str: """ - Format data and add them to the prompt. + Format extracted features into a prompt-friendly string. Args: - prompt: Prompt string to modify features: List of extracted features Returns: - str: Updated prompt with formatted data + str: Formatted string for prompt update """ diff --git a/src/earth_reach/core/extractors/pressure_extractor.py b/src/earth_reach/core/extractors/pressure_extractor.py index 6af84a4..36f27d3 100644 --- a/src/earth_reach/core/extractors/pressure_extractor.py +++ b/src/earth_reach/core/extractors/pressure_extractor.py @@ -3,16 +3,16 @@ This module provides functionality to extract pressure centers (high and low pressure systems) from meteorological data, specifically mean sea level pressure fields in GRIB format. -It uses local extrema detection with Gaussian smoothing to identify these centers. +It uses simple local extrema detection to identify these centers. """ from dataclasses import dataclass -from datetime import datetime from typing import Any +import earthkit.data as ekd import numpy as np -from scipy.ndimage import gaussian_filter, maximum_filter, minimum_filter +from scipy.ndimage import maximum_filter, minimum_filter from earth_reach.config.logging import get_logger from earth_reach.core.extractors.base_extractor import BaseDataExtractor @@ -27,22 +27,16 @@ class PressureCenter: center_type: str latitude: float longitude: float - pressure_hPa: float - intensity: float - timestamp: datetime - confidence: float + center_value_hPa: float grid_indices: tuple[int, int] def to_dict(self) -> dict[str, Any]: """Convert to dictionary for serialization.""" return { - "type": self.center_type, - "lat": self.latitude, - "lon": self.longitude, - "pressure_hPa": self.pressure_hPa, - "intensity": self.intensity, - "timestamp": self.timestamp.isoformat(), - "confidence": self.confidence, + "center_type": self.center_type, + "latitude": self.latitude, + "longitude": self.longitude, + "center_value_hPa": self.center_value_hPa, "grid_indices": self.grid_indices, } @@ -51,71 +45,67 @@ class PressureCenterDataExtractor(BaseDataExtractor): """ Concrete implementation for extracting pressure centers from GRIB data. - Uses local extrema detection with Gaussian smoothing to identify - high and low pressure centers in mean sea level pressure fields. + Uses local extrema detection to identify high and low pressure centers + in mean sea level pressure fields. """ def __init__( self, - sigma: float = 1.0, - min_distance: float = 500.0, # km - min_intensity: float = 1.0, # hPa - neighborhood: int = 8, # 4 or 8 connected pressure_var_name: str = "msl", - **kwargs, + neighborhood_size: int = 200, ): """ Initialize the pressure center extractor. Args: - sigma: Gaussian smoothing parameter (grid points) - min_distance: Minimum distance between centers (km) - min_intensity: Minimum pressure difference to be considered a center (hPa) - neighborhood: 4 or 8 connected neighborhood for extrema detection pressure_var_name: Name of pressure variable in GRIB data - **kwargs: Additional arguments passed to parent class + neighborhood_size: Size of neighborhood for local extrema detection """ - super().__init__(**kwargs) - self.sigma = sigma - self.min_distance = min_distance - self.min_intensity = min_intensity - self.neighborhood = neighborhood + self.neighborhood_size = neighborhood_size self.pressure_var_name = pressure_var_name - if neighborhood not in [4, 8]: - raise ValueError("neighborhood must be 4 or 8") - - def validate_data(self, data: Any) -> bool: + def validate_data( + self, data: ekd.FieldList + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ - Validate GRIB data contains mean sea level pressure. + Validate and return GRIB data pressure field. Args: - data: earthkit-data GRIB dataset + data (ekd.FieldList): GRIB data to validate Returns: - bool: True if validation passes + Tuple of (data array, latitudes, longitudes) Raises: ValueError: If required pressure variable not found """ try: available_vars = [str(var) for var in data.metadata("shortName")] - if self.pressure_var_name not in available_vars: raise ValueError( f"Required variable '{self.pressure_var_name}' not found. " f"Available variables: {', '.join(available_vars)}", ) - lats = data.to_numpy(latitude=True) - lons = data.to_numpy(longitude=True) + data_field = data.sel(shortName=self.pressure_var_name)[0] + if not isinstance(data_field, ekd.Field): + raise ValueError( + "Could not extract a valid pressure field from data.", + ) + data_arr = data_field.to_numpy() + if data_arr is None or not isinstance(data_arr, np.ndarray): + raise ValueError("Data array is empty or not a valid numpy array.") + + latlons = data_field.to_latlon() + lats = latlons["lat"] + lons = latlons["lon"] if lats is None or lons is None: raise ValueError("Could not extract latitude/longitude coordinates") - logger.info(f"Validation passed. Found {self.pressure_var_name} variable.") - return True + logger.info("Validation passed.") + return data_arr, lats, lons except Exception as e: logger.error(f"Validation failed: {e!s}") @@ -123,188 +113,88 @@ def validate_data(self, data: Any) -> bool: def extract( self, - data: Any, - smoothing: bool = True, - return_all: bool = False, + data: ekd.FieldList, + **kwargs: Any, ) -> list[PressureCenter]: """ Extract pressure centers from GRIB data. Args: - data: earthkit-data GRIB dataset - smoothing: Whether to apply Gaussian smoothing - return_all: If True, return all extrema without filtering + data (ekd.FieldList): Input GRIB data + **kwargs: Additional extraction parameters Returns: List of PressureCenter objects """ - self.validate_data(data) - - pressure_data = data.sel(shortName=self.pressure_var_name) - pressure_array = pressure_data.to_numpy() - - lats = pressure_data.to_numpy(latitude=True) - lons = pressure_data.to_numpy(longitude=True) - - if smoothing and self.sigma > 0: - pressure_smoothed = gaussian_filter(pressure_array, sigma=self.sigma) - logger.info(f"Applied Gaussian smoothing with sigma={self.sigma}") - else: - pressure_smoothed = pressure_array.copy() - - timestamp = data.datetime() - - high_centers = self._find_extrema( - pressure_smoothed, - lats, - lons, - timestamp, - extrema_type="high", - ) - low_centers = self._find_extrema( - pressure_smoothed, - lats, - lons, - timestamp, - extrema_type="low", - ) - - all_centers = high_centers + low_centers - - if return_all: - return all_centers - - filtered_centers = [c for c in all_centers if c.intensity >= self.min_intensity] - - final_centers = self._filter_by_distance(filtered_centers, lats, lons) - - logger.info( - f"Extracted {len(final_centers)} pressure centers " - f"({sum(1 for c in final_centers if c.center_type == 'high')} high, " - f"{sum(1 for c in final_centers if c.center_type == 'low')} low)", - ) + pressure_centers = [] + try: + data_arr, lats, lons = self.validate_data(data) + local_min = data == minimum_filter(data_arr, size=self.neighborhood_size) + local_max = data == maximum_filter(data_arr, size=self.neighborhood_size) - return final_centers + if local_max is None or local_min is None: + raise ValueError( + "Could not find local extrema in the data.", + ) - def _find_extrema( - self, - pressure: np.ndarray, - lats: np.ndarray, - lons: np.ndarray, - timestamp: datetime, - extrema_type: str, - ) -> list[PressureCenter]: - """Find local extrema in pressure field.""" - - if self.neighborhood == 4: - struct = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]) - else: # 8-connected - struct = np.ones((3, 3)) - - if extrema_type == "high": - extrema = pressure == maximum_filter(pressure, footprint=struct) - else: # low - extrema = pressure == minimum_filter(pressure, footprint=struct) - - extrema[0, :] = False - extrema[-1, :] = False - extrema[:, 0] = False - extrema[:, -1] = False - - centers = [] - indices = np.where(extrema) - - for i, j in zip(indices[0], indices[1], strict=False): - local_region = pressure[ - max(0, i - 2) : min(pressure.shape[0], i + 3), - max(0, j - 2) : min(pressure.shape[1], j + 3), - ] - - if extrema_type == "high": - intensity = pressure[i, j] - np.mean( - local_region[local_region != pressure[i, j]], + min_indices = np.where(local_min) + max_indices = np.where(local_max) + for i in range(len(min_indices[0])): + row, col = min_indices[0][i], min_indices[1][i] + pressure_centers.append( + PressureCenter( + center_type="low", + latitude=lats[row, col], + longitude=lons[row, col], + center_value_hPa=data_arr[row, col], + grid_indices=(row, col), + ), ) - else: - intensity = ( - np.mean(local_region[local_region != pressure[i, j]]) - - pressure[i, j] + for i in range(len(max_indices[0])): + row, col = max_indices[0][i], max_indices[1][i] + pressure_centers.append( + PressureCenter( + center_type="high", + latitude=lats[row, col], + longitude=lons[row, col], + center_value_hPa=data_arr[row, col], + grid_indices=(row, col), + ), ) - grad_y, grad_x = np.gradient( - pressure[ - max(0, i - 1) : min(pressure.shape[0], i + 2), - max(0, j - 1) : min(pressure.shape[1], j + 2), - ], + return pressure_centers + except Exception as e: + logger.error(f"Extraction failed: {e!s}") + return [] + + def format_features_to_str(self, features: list[PressureCenter]) -> str: + output_str = "## Presure Center Extractor Output\n\n" + if not features: + output_str += "No pressure centers found.\n" + return output_str + + low_pressure_centers = sorted( + [center for center in features if center.center_type == "low"], + key=lambda x: x.center_value_hPa, + ) + high_pressure_centers = sorted( + [center for center in features if center.center_type == "high"], + key=lambda x: x.center_value_hPa, + reverse=True, + ) + output_str += "**Low Pressure Centers**:\n" + for center in low_pressure_centers: + output_str += ( + f"- {center.center_type.capitalize()} pressure center at " + f"({center.latitude:.2f}°N, {center.longitude:.2f}°E) " + f"with value {center.center_value_hPa:.2f} hPa.\n" ) - gradient_magnitude = np.sqrt(grad_y**2 + grad_x**2).mean() - confidence = 1.0 - np.exp(-gradient_magnitude) - - center = PressureCenter( - center_type=extrema_type, - latitude=float(lats[i, j]) if lats.ndim > 1 else float(lats[i]), - longitude=float(lons[i, j]) if lons.ndim > 1 else float(lons[j]), - pressure_hPa=float(pressure[i, j]), - intensity=abs(float(intensity)), - timestamp=timestamp, - confidence=float(confidence), - grid_indices=(int(i), int(j)), + output_str += "\n**High Pressure Centers**:\n" + for center in high_pressure_centers: + output_str += ( + f"- {center.center_type.capitalize()} pressure center at " + f"({center.latitude:.2f}°N, {center.longitude:.2f}°E) " + f"with value {center.center_value_hPa:.2f} hPa.\n" ) - centers.append(center) - - return centers - def _filter_by_distance( - self, - centers: list[PressureCenter], - lats: np.ndarray, - lons: np.ndarray, - ) -> list[PressureCenter]: - """Filter centers to ensure minimum distance between them.""" - - if not centers or self.min_distance <= 0: - return centers - - sorted_centers = sorted(centers, key=lambda c: c.intensity, reverse=True) - - kept_centers = [] - for center in sorted_centers: - too_close = False - for kept in kept_centers: - distance = self._haversine_distance( - center.latitude, - center.longitude, - kept.latitude, - kept.longitude, - ) - if distance < self.min_distance: - too_close = True - break - - if not too_close: - kept_centers.append(center) - - return kept_centers - - def _haversine_distance( - self, - lat1: float, - lon1: float, - lat2: float, - lon2: float, - ) -> float: - """Calculate distance between two points on Earth (km).""" - R = 6371 # Earth radius in km - - lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2]) - dlat = lat2 - lat1 - dlon = lon2 - lon1 - - a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2 - c = 2 * np.arcsin(np.sqrt(a)) - - return R * c - - def format_output(self, prompt: str, features: list[PressureCenter]) -> str: - raise NotImplementedError( - "Should implement this method before use with DataExtractor", - ) + return output_str From 77cca191de66a52d41f6e60c2f94cfae08dbdf68 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 10 Aug 2025 15:53:39 -0400 Subject: [PATCH 089/151] feat: updating output string if not pressure centers detected --- src/earth_reach/core/extractors/pressure_extractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/earth_reach/core/extractors/pressure_extractor.py b/src/earth_reach/core/extractors/pressure_extractor.py index 36f27d3..28790f7 100644 --- a/src/earth_reach/core/extractors/pressure_extractor.py +++ b/src/earth_reach/core/extractors/pressure_extractor.py @@ -170,7 +170,7 @@ def extract( def format_features_to_str(self, features: list[PressureCenter]) -> str: output_str = "## Presure Center Extractor Output\n\n" if not features: - output_str += "No pressure centers found.\n" + output_str += "No pressure centers could be extracted.\n" return output_str low_pressure_centers = sorted( From a667a1318bb0860f4e32efb1850469bfd63b8feb Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 11 Aug 2025 21:48:16 -0400 Subject: [PATCH 090/151] feat: preprocessing average seasonal regional temperature data --- .gitignore | 3 ++ pyproject.toml | 1 + uv.lock | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/.gitignore b/.gitignore index 6971f4b..e2724d3 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ weather_images/ *.png *.jpg *.jpeg + +# Data +data/monthly_average.zarr diff --git a/pyproject.toml b/pyproject.toml index f6359be..d672319 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "python-dotenv>=1.1.0", "hatchling>=1.27.0", "fire @ git+https://github.com/google/python-fire.git@2025-03-23-ipython", # Fixes IPython v9.0 compatibility issue + "zarr>=3.1.1", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 9206f4f..9762399 100644 --- a/uv.lock +++ b/uv.lock @@ -400,6 +400,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/03/3bf7c3646135433a2e7de2866132d15e08594f44b9c21c443adb735adf43/covjsonkit-0.1.11-py3-none-any.whl", hash = "sha256:774a8fead55342c0f4435b2afb0d210dc49fbfe3da972811903ad7b28beca8d1", size = 477387 }, ] +[[package]] +name = "crc32c" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/4c/4e40cc26347ac8254d3f25b9f94710b8e8df24ee4dddc1ba41907a88a94d/crc32c-2.7.1.tar.gz", hash = "sha256:f91b144a21eef834d64178e01982bb9179c354b3e9e5f4c803b0e5096384968c", size = 45712 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/02/998dc21333413ce63fe4c1ca70eafe61ca26afc7eb353f20cecdb77d614e/crc32c-2.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f7d1c4e761fe42bf856130daf8b2658df33fe0ced3c43dadafdfeaa42b57b950", size = 49568 }, + { url = "https://files.pythonhosted.org/packages/9c/3e/e3656bfa76e50ef87b7136fef2dbf3c46e225629432fc9184fdd7fd187ff/crc32c-2.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:73361c79a6e4605204457f19fda18b042a94508a52e53d10a4239da5fb0f6a34", size = 37019 }, + { url = "https://files.pythonhosted.org/packages/0b/7d/5ff9904046ad15a08772515db19df43107bf5e3901a89c36a577b5f40ba0/crc32c-2.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:afd778fc8ac0ed2ffbfb122a9aa6a0e409a8019b894a1799cda12c01534493e0", size = 35373 }, + { url = "https://files.pythonhosted.org/packages/4d/41/4aedc961893f26858ab89fc772d0eaba91f9870f19eaa933999dcacb94ec/crc32c-2.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56ef661b34e9f25991fface7f9ad85e81bbc1b3fe3b916fd58c893eabe2fa0b8", size = 54675 }, + { url = "https://files.pythonhosted.org/packages/d6/63/8cabf09b7e39b9fec8f7010646c8b33057fc8d67e6093b3cc15563d23533/crc32c-2.7.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:571aa4429444b5d7f588e4377663592145d2d25eb1635abb530f1281794fc7c9", size = 52386 }, + { url = "https://files.pythonhosted.org/packages/79/13/13576941bf7cf95026abae43d8427c812c0054408212bf8ed490eda846b0/crc32c-2.7.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c02a3bd67dea95cdb25844aaf44ca2e1b0c1fd70b287ad08c874a95ef4bb38db", size = 53495 }, + { url = "https://files.pythonhosted.org/packages/3d/b6/55ffb26d0517d2d6c6f430ce2ad36ae7647c995c5bfd7abce7f32bb2bad1/crc32c-2.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99d17637c4867672cb8adeea007294e3c3df9d43964369516cfe2c1f47ce500a", size = 54456 }, + { url = "https://files.pythonhosted.org/packages/c2/1a/5562e54cb629ecc5543d3604dba86ddfc7c7b7bf31d64005b38a00d31d31/crc32c-2.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f4a400ac3c69a32e180d8753fd7ec7bccb80ade7ab0812855dce8a208e72495f", size = 52647 }, + { url = "https://files.pythonhosted.org/packages/48/ec/ce4138eaf356cd9aae60bbe931755e5e0151b3eca5f491fce6c01b97fd59/crc32c-2.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:588587772e55624dd9c7a906ec9e8773ae0b6ac5e270fc0bc84ee2758eba90d5", size = 53332 }, + { url = "https://files.pythonhosted.org/packages/5e/b5/144b42cd838a901175a916078781cb2c3c9f977151c9ba085aebd6d15b22/crc32c-2.7.1-cp312-cp312-win32.whl", hash = "sha256:9f14b60e5a14206e8173dd617fa0c4df35e098a305594082f930dae5488da428", size = 38371 }, + { url = "https://files.pythonhosted.org/packages/ae/c4/7929dcd5d9b57db0cce4fe6f6c191049380fc6d8c9b9f5581967f4ec018e/crc32c-2.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:7c810a246660a24dc818047dc5f89c7ce7b2814e1e08a8e99993f4103f7219e8", size = 39805 }, + { url = "https://files.pythonhosted.org/packages/bf/98/1a6d60d5b3b5edc8382777b64100343cb4aa6a7e172fae4a6cfcb8ebbbd9/crc32c-2.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:24949bffb06fc411cc18188d33357923cb935273642164d0bb37a5f375654169", size = 49567 }, + { url = "https://files.pythonhosted.org/packages/4f/56/0dd652d4e950e6348bbf16b964b3325e4ad8220470774128fc0b0dd069cb/crc32c-2.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d5d326e7e118d4fa60187770d86b66af2fdfc63ce9eeb265f0d3e7d49bebe0b", size = 37018 }, + { url = "https://files.pythonhosted.org/packages/47/02/2bd65fdef10139b6a802d83a7f966b7750fe5ffb1042f7cbe5dbb6403869/crc32c-2.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba110df60c64c8e2d77a9425b982a520ccdb7abe42f06604f4d98a45bb1fff62", size = 35374 }, + { url = "https://files.pythonhosted.org/packages/a9/0d/3e797d1ed92d357a6a4c5b41cea15a538b27a8fdf18c7863747eb50b73ad/crc32c-2.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c277f9d16a3283e064d54854af0976b72abaa89824955579b2b3f37444f89aae", size = 54641 }, + { url = "https://files.pythonhosted.org/packages/a7/d3/4ddeef755caaa75680c559562b6c71f5910fee4c4f3a2eb5ea8b57f0e48c/crc32c-2.7.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:881af0478a01331244e27197356929edbdeaef6a9f81b5c6bacfea18d2139289", size = 52338 }, + { url = "https://files.pythonhosted.org/packages/01/cf/32f019be5de9f6e180926a50ee5f08648e686c7d9a59f2c5d0806a77b1c7/crc32c-2.7.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:724d5ff4d29ff093a983ae656be3307093706d850ea2a233bf29fcacc335d945", size = 53447 }, + { url = "https://files.pythonhosted.org/packages/b2/8b/92f3f62f3bafe8f7ab4af7bfb7246dc683fd11ec0d6dfb73f91e09079f69/crc32c-2.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2416c4d88696ac322632555c0f81ab35e15f154bc96055da6cf110d642dbc10", size = 54484 }, + { url = "https://files.pythonhosted.org/packages/98/b2/113a50f8781f76af5ac65ffdb907e72bddbe974de8e02247f0d58bc48040/crc32c-2.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:60254251b88ec9b9795215f0f9ec015a6b5eef8b2c5fba1267c672d83c78fc02", size = 52703 }, + { url = "https://files.pythonhosted.org/packages/b4/6c/309229e9acda8cf36a8ff4061d70b54d905f79b7037e16883ce6590a24ab/crc32c-2.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:edefc0e46f3c37372183f70338e5bdee42f6789b62fcd36ec53aa933e9dfbeaf", size = 53367 }, + { url = "https://files.pythonhosted.org/packages/b5/2a/6c6324d920396e1bd9f3efbe8753da071be0ca52bd22d6c82d446b8d6975/crc32c-2.7.1-cp313-cp313-win32.whl", hash = "sha256:813af8111218970fe2adb833c5e5239f091b9c9e76f03b4dd91aaba86e99b499", size = 38377 }, + { url = "https://files.pythonhosted.org/packages/db/a0/f01ccfab538db07ef3f6b4ede46357ff147a81dd4f3c59ca6a34c791a549/crc32c-2.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:7d9ede7be8e4ec1c9e90aaf6884decbeef10e3473e6ddac032706d710cab5888", size = 39803 }, + { url = "https://files.pythonhosted.org/packages/1b/80/61dcae7568b33acfde70c9d651c7d891c0c578c39cc049107c1cf61f1367/crc32c-2.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db9ac92294284b22521356715784b91cc9094eee42a5282ab281b872510d1831", size = 49386 }, + { url = "https://files.pythonhosted.org/packages/1e/f1/80f17c089799ab2b4c247443bdd101d6ceda30c46d7f193e16b5ca29c5a0/crc32c-2.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8fcd7f2f29a30dc92af64a9ee3d38bde0c82bd20ad939999427aac94bbd87373", size = 36937 }, + { url = "https://files.pythonhosted.org/packages/63/42/5fcfc71a3de493d920fd2590843762a2749981ea56b802b380e5df82309d/crc32c-2.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5c056ef043393085523e149276a7ce0cb534b872e04f3e20d74d9a94a75c0ad7", size = 35292 }, + { url = "https://files.pythonhosted.org/packages/03/de/fef962e898a953558fe1c55141644553e84ef4190693a31244c59a0856c7/crc32c-2.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03a92551a343702629af91f78d205801219692b6909f8fa126b830e332bfb0e0", size = 54223 }, + { url = "https://files.pythonhosted.org/packages/21/14/fceca1a6f45c0a1814fe8602a65657b75c27425162445925ba87438cad6b/crc32c-2.7.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb9424ec1a8ca54763155a703e763bcede82e6569fe94762614bb2de1412d4e1", size = 51588 }, + { url = "https://files.pythonhosted.org/packages/13/3b/13d40a7dfbf9ef05c84a0da45544ee72080dca4ce090679e5105689984bd/crc32c-2.7.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88732070f6175530db04e0bb36880ac45c33d49f8ac43fa0e50cfb1830049d23", size = 52678 }, + { url = "https://files.pythonhosted.org/packages/36/09/65ffc4fb9fa60ff6714eeb50a92284a4525e5943f0b040b572c0c76368c1/crc32c-2.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:57a20dfc27995f568f64775eea2bbb58ae269f1a1144561df5e4a4955f79db32", size = 53847 }, + { url = "https://files.pythonhosted.org/packages/24/71/938e926085b7288da052db7c84416f3ce25e71baf7ab5b63824c7bcb6f22/crc32c-2.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f7186d098bfd2cff25eac6880b7c7ad80431b90610036131c1c7dd0eab42a332", size = 51860 }, + { url = "https://files.pythonhosted.org/packages/3c/d8/4526d5380189d6f2fa27256c204100f30214fe402f47cf6e9fb9a91ab890/crc32c-2.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:55a77e29a265418fa34bef15bd0f2c60afae5348988aaf35ed163b4bbf93cf37", size = 52508 }, + { url = "https://files.pythonhosted.org/packages/19/30/15f7e35176488b77e5b88751947d321d603fccac273099ace27c7b2d50a6/crc32c-2.7.1-cp313-cp313t-win32.whl", hash = "sha256:ae38a4b6aa361595d81cab441405fbee905c72273e80a1c010fb878ae77ac769", size = 38319 }, + { url = "https://files.pythonhosted.org/packages/19/c4/0b3eee04dac195f4730d102d7a9fbea894ae7a32ce075f84336df96a385d/crc32c-2.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:eee2a43b663feb6c79a6c1c6e5eae339c2b72cfac31ee54ec0209fa736cf7ee5", size = 39781 }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -507,6 +548,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533 }, ] +[[package]] +name = "donfig" +version = "0.8.1.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/71/80cc718ff6d7abfbabacb1f57aaa42e9c1552bfdd01e64ddd704e4a03638/donfig-0.8.1.post1.tar.gz", hash = "sha256:3bef3413a4c1c601b585e8d297256d0c1470ea012afa6e8461dc28bfb7c23f52", size = 19506 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592 }, +] + [[package]] name = "earth-reach" version = "0.1.0" @@ -520,6 +573,7 @@ dependencies = [ { name = "openai" }, { name = "python-dotenv" }, { name = "scipy" }, + { name = "zarr" }, ] [package.optional-dependencies] @@ -559,6 +613,7 @@ requires-dist = [ { name = "openai", specifier = ">=1.77.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "scipy", specifier = ">=1.15.2" }, + { name = "zarr", specifier = ">=3.1.1" }, ] provides-extras = ["gemini", "claude", "all-models"] @@ -1586,6 +1641,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, ] +[[package]] +name = "numcodecs" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/35/49da850ce5371da3930d099da364a73ce9ae4fc64075e521674b48f4804d/numcodecs-0.16.1.tar.gz", hash = "sha256:c47f20d656454568c6b4697ce02081e6bbb512f198738c6a56fafe8029c97fb1", size = 6268134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ee/e2a903c88fed347dc74c70bbd7a8dab9aa22bb0dac68c5bc6393c2e9373b/numcodecs-0.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1abe0651ecb6f207656ebfc802effa55c4ae3136cf172c295a067749a2699122", size = 1663434 }, + { url = "https://files.pythonhosted.org/packages/f2/f0/37819d4f6896b1ac43a164ffd3ab99d7cbf63bf63cb375fef97aedaef4f0/numcodecs-0.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:abb39b7102d0816c8563669cdddca40392d34d0cbf31e3e996706b244586a458", size = 1150402 }, + { url = "https://files.pythonhosted.org/packages/60/3c/5059a29750305b80b7428b1e6695878dea9ea3b537d7fba57875e4bbc2c7/numcodecs-0.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3359a951f8b23317f12736a7ad1e7375ec3d735465f92049c76d032ebca4c40", size = 8237455 }, + { url = "https://files.pythonhosted.org/packages/1b/f5/515f98d659ab0cbe3738da153eddae22186fd38f05a808511e10f04cf679/numcodecs-0.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82cc70592ec18060786b1bfa0da23afd2a7807d7975d766e626954d6628ec609", size = 8770711 }, + { url = "https://files.pythonhosted.org/packages/a2/3a/9fc6104f888af11bad804ebd32dffe0bcb83337f4525b4fe5b379942fefd/numcodecs-0.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:4b48ddc8a7d132b7808bc53eb2705342de5c1e39289d725f988bd143c0fd86df", size = 788701 }, + { url = "https://files.pythonhosted.org/packages/5e/1e/73ffb1074f03d52cb1c4f4deaba26a2008ca45262f3622ed26dbec7a7362/numcodecs-0.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ad8ee940315f59188accfc3f2d39726a4ca0d76b49bf8d0018e121f01c49028", size = 1659453 }, + { url = "https://files.pythonhosted.org/packages/42/72/5affb1ce92b7a6becee17921de7c6b521a48fa61fc3d36d9f1eea2cf83f5/numcodecs-0.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:179ca7bf3525a0f7379df7767d87dd495253de44597cb7e511198b28b09da633", size = 1143932 }, + { url = "https://files.pythonhosted.org/packages/e3/f1/b092679d84c67c6ed62e4df5781d89bbb089f24a0df4187cbab9db51cf6b/numcodecs-0.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e2babbb50bf348ae982818d5560af330eab0dcd925fb0e49509785ad57d11db", size = 8187716 }, + { url = "https://files.pythonhosted.org/packages/a8/e8/86e7741adb43261aff409b53c53c8bac2797bfca055d64dd65dc731d5141/numcodecs-0.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4b29d8d3284b72bfad4fb83d672a17f497ae86ee1ef8087bac7222b620d3d91", size = 8728650 }, + { url = "https://files.pythonhosted.org/packages/21/03/87c5c217232aa3515d350728c6dcefca252fa582246100ef68a51fbda456/numcodecs-0.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:06489635f43e1a959aea73cb830d78cf3adb07ac5f34daccb92091e4d9ac6b07", size = 785553 }, +] + +[package.optional-dependencies] +crc32c = [ + { name = "crc32c" }, +] + [[package]] name = "numpy" version = "2.3.2" @@ -2775,3 +2857,19 @@ sdist = { url = "https://files.pythonhosted.org/packages/9b/29/37761364e137db138 wheels = [ { url = "https://files.pythonhosted.org/packages/a4/1e/96fd96419fec1a37da998a1ca3d558f2cae2f6f3cd5015170371b05a2b6b/xarray-2025.4.0-py3-none-any.whl", hash = "sha256:b27defd082c5cb85d32c695708de6bb05c2838fb7caaf3f952982e602a35b9b8", size = 1290171 }, ] + +[[package]] +name = "zarr" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "donfig" }, + { name = "numcodecs", extra = ["crc32c"] }, + { name = "numpy" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/a9/29fe1800380092ae03ac6207d757f3e5affaf1fcd2e5ef074cf4fc68f0fa/zarr-3.1.1.tar.gz", hash = "sha256:17db72f37f2489452d2137ac891c4133b8f976f9189d8efd3e75f3b3add84e8c", size = 314075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/48/bde2f58cfbc9fd6ab844e2f2fd79d5e54195c12a17aa9b47c0b0e701a421/zarr-3.1.1-py3-none-any.whl", hash = "sha256:9a0b7e7c27bf62965b8eef6b8b8fdb9b47381f0738be35e40f37be6479b546be", size = 255373 }, +] From 6e03b99079235520c89a9ba16e1c93584429598c Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 11 Aug 2025 21:48:37 -0400 Subject: [PATCH 091/151] feat: minor improvements --- src/earth_reach/core/extractors/pressure_extractor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/earth_reach/core/extractors/pressure_extractor.py b/src/earth_reach/core/extractors/pressure_extractor.py index 28790f7..725e0c2 100644 --- a/src/earth_reach/core/extractors/pressure_extractor.py +++ b/src/earth_reach/core/extractors/pressure_extractor.py @@ -107,7 +107,7 @@ def validate_data( logger.info("Validation passed.") return data_arr, lats, lons - except Exception as e: + except ValueError as e: logger.error(f"Validation failed: {e!s}") raise @@ -124,7 +124,7 @@ def extract( **kwargs: Additional extraction parameters Returns: - List of PressureCenter objects + List[PressureCenter]: List of PressureCenter objects """ pressure_centers = [] try: @@ -168,6 +168,8 @@ def extract( return [] def format_features_to_str(self, features: list[PressureCenter]) -> str: + """Format extracted temperature features into a prompt-friendly string.""" + output_str = "## Presure Center Extractor Output\n\n" if not features: output_str += "No pressure centers could be extracted.\n" From bedbf561f9d0e65dec2dc8db697da45bbf49c6af Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 11 Aug 2025 21:50:34 -0400 Subject: [PATCH 092/151] feat: implementing template for regional temperature feature extractor --- .../core/extractors/temperature_extractor.py | 132 +++++++++++++++++- src/earth_reach/core/utils.py | 7 + 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/earth_reach/core/extractors/temperature_extractor.py b/src/earth_reach/core/extractors/temperature_extractor.py index 1b5d071..3161350 100644 --- a/src/earth_reach/core/extractors/temperature_extractor.py +++ b/src/earth_reach/core/extractors/temperature_extractor.py @@ -1,5 +1,135 @@ """ Temperature Data Extractor module. - +This module provides functionality to extract temperature data +from meteorological datasets, specifically 2-meter temperature fields in GRIB format. +It includes data validation and formatting for prompt updates. """ + +from dataclasses import dataclass +from typing import Any + +import earthkit.data as ekd +import numpy as np +import xarray as xr + +from earth_reach.config.logging import get_logger +from earth_reach.core.extractors.base_extractor import BaseDataExtractor +from earth_reach.core.utils import get_root_dir_path + +logger = get_logger(__name__) + + +@dataclass +class TemperatureData: + """Data structure for temperature data.""" + + domain: str + month: str + temperature_C: float + average_temperature_C: float + latitude: float + longitude: float + grid_indices: tuple[int, int] + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + "domain": self.domain, + "month": self.month, + "temperature_C": self.temperature_C, + "average_temperature_C": self.average_temperature_C, + "latitude": self.latitude, + "longitude": self.longitude, + "grid_indices": self.grid_indices, + } + + +class TemperatureDataExtractor(BaseDataExtractor): + """Concrete implementation for extracting temperature data from GRIB data.""" + + def __init__(self, temperature_var_name: str = "2t"): + """ + Initialize the temperature data extractor. + + Args: + temperature_var_name (str): Name of the temperature variable in GRIB data. + """ + self.temperature_var_name = temperature_var_name + self.average_temp_data = xr.open_dataset( + get_root_dir_path() / "data" / "average_monthly_regional_temperature.nc" + ) + + def validate_data(self, data: ekd.FieldList) -> ekd.FieldList: + """ + Parse, validate and return GRIB data. + + Args: + data (ekd.FieldList): Input data to validate + + Returns: + ekd.FieldList: Parsed and validated data + + Raises: + ValueError: If validation fails + """ + try: + available_vars = [str(var) for var in data.metadata("shortName")] + if self.temperature_var_name not in available_vars: + raise ValueError( + f"Required variable '{self.temperature_var_name}' not found. " + f"Available variables: {', '.join(available_vars)}", + ) + + data_field = data.sel(shortName=self.temperature_var_name)[0] + if not isinstance(data_field, ekd.Field): + raise ValueError( + "Could not extract a valid temperature field from data.", + ) + data_arr = data_field.to_numpy() + if data_arr is None or not isinstance(data_arr, np.ndarray): + raise ValueError("Data array is empty or not a valid numpy array.") + + latlons = data_field.to_latlon() + lats = latlons["lat"] + lons = latlons["lon"] + + if lats is None or lons is None: + raise ValueError("Could not extract latitude/longitude coordinates") + + logger.info("Validation passed.") + return data_arr, lats, lons + except ValueError as e: + logger.error(f"Validation failed: {e!s}") + raise + + def extract(self, data: ekd.FieldList, **kwargs: Any) -> list[TemperatureData]: + """ + Extract temperature features from the input data. + + Args: + data (ekd.FieldList): Input data + **kwargs: Additional extraction parameters + + Returns: + List[TemperatureData]: List of extracted temperature features + """ + temperature_data = [] + try: + temperature_arr, lats, lons = self.validate_data(data) + # 0. Possible to split in sub-regions ? Should extract regions around hotest / coldest points ? + # 1. Get domain and month from kwargs + # 2. If domain or month is not provided, try to infer from field + # 3. Convert domain to longitude and latitude bounds + # 4. Compute average temperature for the domain and month on field data + # 5. Compare with average temperature from precomputed data, assume normal distribution + # to find anomalies according to std deviation or z-score + # 6. Build TemperatureData object + return temperature_data + except IndexError as e: + raise ValueError( + f"Temperature variable '{self.temperature_var_name}' not found in data" + ) from e + + def format_features_to_str(self, features: list[TemperatureData]) -> str: + """Format extracted temperature features into a prompt-friendly string.""" diff --git a/src/earth_reach/core/utils.py b/src/earth_reach/core/utils.py index ad2962a..070190e 100644 --- a/src/earth_reach/core/utils.py +++ b/src/earth_reach/core/utils.py @@ -1,6 +1,7 @@ import base64 from io import BytesIO +from pathlib import Path from PIL.ImageFile import ImageFile @@ -44,3 +45,9 @@ def img_to_bytes(img: ImageFile) -> bytes: bytes_io = BytesIO() img.save(bytes_io, format="PNG") return bytes_io.getvalue() + + +def get_root_dir_path() -> Path: + """Get the root directory path of the project.""" + + return Path(__file__).parent.parent.parent.resolve() From d3bf38657c6880e48031ee93fabd0741d9e1455f Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 18:27:47 -0400 Subject: [PATCH 093/151] chore: ignoring data file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e2724d3..85721ae 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ weather_images/ # Data data/monthly_average.zarr +data/*.nc From 0d5e0fc9dff6b19599c5146b86fbb7ff35783035 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 19:13:10 -0400 Subject: [PATCH 094/151] feat: removing unused evaluation heuristics --- src/earth_reach/core/evaluator.py | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/src/earth_reach/core/evaluator.py b/src/earth_reach/core/evaluator.py index 04690fd..0454348 100644 --- a/src/earth_reach/core/evaluator.py +++ b/src/earth_reach/core/evaluator.py @@ -48,7 +48,7 @@ def __post_init__(self) -> None: class CriterionEvaluator: - """Evaluator class for evaluating the quality of text based on a specified criterion.""" + """Evaluator class for evaluating the quality of weather descriptions based on a specified criterion.""" def __init__( self, @@ -408,29 +408,3 @@ def evaluate( return evaluations except Exception as e: raise RuntimeError(f"Failed to evaluate description: {e}") from e - - def is_text_length_lesser_than_max(self, text: str, max_length: int = 1000) -> bool: - """ - Check if the length of the text is lesser than the specified maximum length. - - Args: - text (str): The text to check. - max_length (int): The maximum length of the text. - - Returns: - bool: True if the text length is valid, False otherwise. - """ - return len(text) <= max_length - - def is_text_length_greater_than_min(self, text: str, min_length: int = 100) -> bool: - """ - Check if the length of the text is greater than the specified minimum length. - - Args: - text (str): The text to check. - min_length (int): The minimum length of the text. - - Returns: - bool: True if the text length is more than the minimum, False otherwise. - """ - return min_length <= len(text) From 4d9b4811b5671ea46e4b6657fc074355106bf437 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 19:13:35 -0400 Subject: [PATCH 095/151] doc: creating structure for sphinx doc --- docs/Makefile | 21 +++++ docs/make.bat | 35 ++++++++ docs/source/api.rst | 0 docs/source/conf.py | 36 ++++++++ docs/source/index.rst | 29 ++++++ docs/source/installation.rst | 0 docs/source/usage.rst | 0 pyproject.toml | 2 + uv.lock | 166 +++++++++++++++++++++++++++++++++++ 9 files changed, 289 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/api.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst create mode 100644 docs/source/installation.rst create mode 100644 docs/source/usage.rst diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..b3a5199 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,21 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + sphinx-apidoc -o ${SOURCEDIR}/api/ ../your_project_name/ + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..e952740 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,36 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +import os +import sys + +sys.path.insert(0, os.path.abspath("../../src/earthreach")) + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "EarthReach" +copyright = "2025, Romain Bazin" +author = "Romain Bazin" +release = "1" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "sphinx.ext.napoleon", +] + +templates_path = ["_templates"] +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..35be917 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,29 @@ +EarthReach documentation +======================== + +|License: Apache 2.0| + +.. |License: Apache 2.0| image:: https://img.shields.io/badge/License-Apache%202.0-blue.svg + :target: https://opensource.org/licenses/apache-2-0 + + +EarthReach is an accessibility-focused python library. It's goal is to enable blind and low-vision earth scientists to access information present in weather-charts. + +This library is designed to extend the **earthkit-plots** library, by providing an optional easy to use integration point. It leverages the rich data and metadata contained in GRIB files and a framework of dual LLMs, a generator, and an evaluator, with access to the data and the image of the weather chart, to generate a scientific description. + + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + installation + usage + example + api + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/installation.rst b/docs/source/installation.rst new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/usage.rst b/docs/source/usage.rst new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index d672319..b0f52c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ dev = [ "pre-commit>=4.0.1", "mypy>=1.8.0", "scipy-stubs>=1.16.1.0", + "sphinx>=8.2.3", + "sphinx-rtd-theme>=3.0.2", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 9762399..c94c88e 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/1c/8feedd607cc14c5df9aef74fe3af9a99bf660743b842a9b5b1865326b4aa/adjustText-1.3.0-py3-none-any.whl", hash = "sha256:da23d7b24b6db5ffa039bb136bfa556207365e32f48ac74b07ad26dd485bc691", size = 13154 }, ] +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929 }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -102,6 +111,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, ] +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, +] + [[package]] name = "cachetools" version = "5.5.2" @@ -548,6 +566,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533 }, ] +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408 }, +] + [[package]] name = "donfig" version = "0.8.1.post1" @@ -597,6 +624,8 @@ dev = [ { name = "pytest" }, { name = "ruff" }, { name = "scipy-stubs" }, + { name = "sphinx" }, + { name = "sphinx-rtd-theme" }, ] [package.metadata] @@ -626,6 +655,8 @@ dev = [ { name = "pytest", specifier = ">=8.3.5" }, { name = "ruff", specifier = ">=0.12.7" }, { name = "scipy-stubs", specifier = ">=1.16.1.0" }, + { name = "sphinx", specifier = ">=8.2.3" }, + { name = "sphinx-rtd-theme", specifier = ">=3.0.2" }, ] [[package]] @@ -1103,6 +1134,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -2475,6 +2515,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bb/9c/dc7cbeb99a7b7422392ed7f327efdbb958bc0faf424aef5f130309320bda/rich_argparse-1.7.0-py3-none-any.whl", hash = "sha256:b8ec8943588e9731967f4f97b735b03dc127c416f480a083060433a97baf2fd3", size = 25339 }, ] +[[package]] +name = "roman-numerals-py" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742 }, +] + [[package]] name = "rpds-py" version = "0.24.0" @@ -2662,6 +2711,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274 }, +] + +[[package]] +name = "sphinx" +version = "8.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals-py" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741 }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/44/c97faec644d29a5ceddd3020ae2edffa69e7d00054a8c7a6021e82f20335/sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85", size = 7620463 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/77/46e3bac77b82b4df5bb5b61f2de98637724f246b4966cfc34bc5895d852a/sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13", size = 7655561 }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300 }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530 }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705 }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104 }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071 }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743 }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072 }, +] + [[package]] name = "stack-data" version = "0.6.3" From 7c41b3a14bd07c1fc97b65d3d36deadc9ba2d36e Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 19:19:08 -0400 Subject: [PATCH 096/151] doc: adding module-level docstring --- src/earth_reach/core/evaluator.py | 7 +++++++ src/earth_reach/core/generator.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/earth_reach/core/evaluator.py b/src/earth_reach/core/evaluator.py index 0454348..b2d041f 100644 --- a/src/earth_reach/core/evaluator.py +++ b/src/earth_reach/core/evaluator.py @@ -1,3 +1,10 @@ +""" +Evaluator Agent module. + +This module provides the main structure and classes for evaluating weather chart descriptions +automatically against a set of quality criteria. +""" + import re from dataclasses import MISSING, dataclass, fields diff --git a/src/earth_reach/core/generator.py b/src/earth_reach/core/generator.py index b48389e..f416de1 100644 --- a/src/earth_reach/core/generator.py +++ b/src/earth_reach/core/generator.py @@ -1,3 +1,10 @@ +""" +Generator Agent module. + +This module provides the main structures and classes for generating descriptions from +weather chart images or eathkit-plots figures. +""" + import re from dataclasses import dataclass, field, fields From 3ffea27cc25c461646f0f609fc6ff3e850466120 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 19:21:31 -0400 Subject: [PATCH 097/151] doc: writing docstring for orchestrator --- src/earth_reach/core/orchestrator.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py index 0e74057..ba10c86 100644 --- a/src/earth_reach/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -1,3 +1,10 @@ +""" +Orchestrator module. + +This module provides the orchestrator class, driving the generator and evaluator successive +interactions to generate high-quality weather chart descriptions. +""" + import earthkit.plots as ekp from earthkit.data import FieldList From e26e390cac5216d3d838b00b05f5def20e3f646b Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 19:47:08 -0400 Subject: [PATCH 098/151] feat: adding model name as env variable --- vllm/test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vllm/test.sh b/vllm/test.sh index e2494de..3a1f0c7 100644 --- a/vllm/test.sh +++ b/vllm/test.sh @@ -2,7 +2,7 @@ curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${VLLM_SERVER_API_KEY}" \ -d '{ - "model": "google/gemma-3-4b-it", + "model": "${MODEL_NAME}", "messages": [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Who won the world series in 2020?"} From 9ab708d13e49b08052039244b8ad8c2e46ccc363 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 19:47:31 -0400 Subject: [PATCH 099/151] feat: removing unused dotenv env loading --- src/earth_reach/cli.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/earth_reach/cli.py b/src/earth_reach/cli.py index 6e6f806..351513e 100644 --- a/src/earth_reach/cli.py +++ b/src/earth_reach/cli.py @@ -10,7 +10,6 @@ import fire -from dotenv import load_dotenv from PIL import Image from earth_reach.config.criteria import QualityCriteria @@ -23,8 +22,6 @@ logger = get_logger(__name__) -load_dotenv() - def load_prompt_from_file(file_path: str) -> str: """ From 9fac17c56fe9ad6ca6f79d7ffac605cca21508d0 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 19:51:31 -0400 Subject: [PATCH 100/151] feat: adding automatic api doc build to makefile --- docs/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Makefile b/docs/Makefile index b3a5199..1878ffd 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -17,5 +17,5 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - sphinx-apidoc -o ${SOURCEDIR}/api/ ../your_project_name/ + sphinx-apidoc -o ${SOURCEDIR}/api/ ../src/earth_reach/ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) From 6567bf42038762f53d0d9fa4e844790b688fdc27 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 19:51:49 -0400 Subject: [PATCH 101/151] doc: improving introduction paragraphs in index --- docs/source/index.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 35be917..f91ec75 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,9 +7,9 @@ EarthReach documentation :target: https://opensource.org/licenses/apache-2-0 -EarthReach is an accessibility-focused python library. It's goal is to enable blind and low-vision earth scientists to access information present in weather-charts. +EarthReach is a Python library for generating natural language descriptions of meteorological data visualizations. The library extends **earthkit-plots** by providing automated text generation capabilities for weather charts, enabling programmatic conversion of visual data representations into structured textual descriptions. -This library is designed to extend the **earthkit-plots** library, by providing an optional easy to use integration point. It leverages the rich data and metadata contained in GRIB files and a framework of dual LLMs, a generator, and an evaluator, with access to the data and the image of the weather chart, to generate a scientific description. +The system implements a dual-LLM architecture consisting of a generator agent and an evaluator agent. The generator creates initial descriptions from chart images and associated GRIB file metadata, while the evaluator assesses output quality across multiple criteria including scientific accuracy, coherence, and meteorological relevance. This iterative process continues until quality thresholds are met or maximum iterations are reached. .. toctree:: @@ -18,7 +18,6 @@ This library is designed to extend the **earthkit-plots** library, by providing installation usage - example api Indices and tables From 4589331c645ad6bf6d4eec28494d9ab60a38bd78 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 19:52:05 -0400 Subject: [PATCH 102/151] doc: writing installation page --- docs/source/installation.rst | 75 ++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/docs/source/installation.rst b/docs/source/installation.rst index e69de29..a6c240f 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -0,0 +1,75 @@ +Installation +============ + +Prerequisites +------------- + +- `uv `: Python package and project manager (will automatically install Python 3.12+ if needed) +- API key for a supported LLM provider (OpenAI, Google Gemini, Anthropic Claude, or Groq) + +Dependencies +----- + +1. **Clone the repository** + + .. code-block:: bash + + git clone https://github.com/ECMWFCode4Earth/EarthReach.git + cd EarthReach + +2. **Create a virtual environment and install dependencies** + + .. code-block:: bash + + uv sync + + This command will automatically: + + - Create a .venv virtual environment + - Install all project dependencies from pyproject.toml + +3. **Activate the virtual environment** + + .. code-block:: bash + + source .venv/bin/activate # On Windows: .venv\Scripts\activate + +LLM Provider Configuration +-------------------------- + +EarthReach requires an external LLM provider to function. The system supports multiple providers in a bring-your-own-key fashion. Set the appropriate environment variable for your chosen provider: + +**OpenAI** + +.. code-block:: bash + + export OPENAI_API_KEY="your-openai-api-key" + +**Google Gemini** + +.. code-block:: bash + + export GEMINI_API_KEY="your-gemini-api-key" + +**Anthropic Claude** + +.. code-block:: bash + + export ANTHROPIC_API_KEY="your-claude-api-key" + +**Groq** + +.. code-block:: bash + + export GROQ_API_KEY="your-groq-api-key" + +.. note:: + You only need to configure one provider. + +Self-hosted LLM Server (Advanced) +---------------------------------- + +For users who prefer to host their own LLM inference server, EarthReach can work with any OpenAI-compatible API endpoint. An example setup using VLLM on Rocky Linux/RHEL is available in the ``vllm/`` directory of this repository. + +.. warning:: + Setting up a self-hosted inference server requires advanced system administration knowledge and significant computational resources. This approach is recommended only for users with experience in server deployment and GPU management. From 824ad28c4f8f4774fff34c93dc3060104e97e8f8 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 20:20:30 -0400 Subject: [PATCH 103/151] fix: fixing source file path in docs --- docs/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index e952740..d51f7f9 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,7 +6,7 @@ import os import sys -sys.path.insert(0, os.path.abspath("../../src/earthreach")) +sys.path.insert(0, os.path.abspath("../../src/earth_reach")) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information From f5aa534ae4174320a27d7869b80ddc3677a03226 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 20:20:41 -0400 Subject: [PATCH 104/151] doc: writing installation doc --- docs/source/installation.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/installation.rst b/docs/source/installation.rst index a6c240f..e7e2dad 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -4,11 +4,11 @@ Installation Prerequisites ------------- -- `uv `: Python package and project manager (will automatically install Python 3.12+ if needed) +- `uv `_: Python package and project manager (will automatically install Python 3.12+ if needed) - API key for a supported LLM provider (OpenAI, Google Gemini, Anthropic Claude, or Groq) Dependencies ------ +------------ 1. **Clone the repository** From 0366fc2ba69008ce8bc1ba39d74feccb5e13d25b Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 20:21:05 -0400 Subject: [PATCH 105/151] doc: writing draft of API reference --- docs/source/api.rst | 100 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/docs/source/api.rst b/docs/source/api.rst index e69de29..ea17112 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -0,0 +1,100 @@ +API Reference +============= + +This section provides detailed documentation of the EarthReach API. + +Core Components +--------------- + +These are the main classes that implement the dual-LLM framework: + +GeneratorAgent +~~~~~~~~~~~~~~ + +.. autoclass:: earth_reach.core.generator.GeneratorAgent + :members: + :show-inheritance: + +.. autoclass:: earth_reach.core.generator.GeneratorOutput + :members: + :show-inheritance: + +EvaluatorAgent +~~~~~~~~~~~~~~ + +.. autoclass:: earth_reach.core.evaluator.EvaluatorAgent + :members: + :show-inheritance: + +.. autoclass:: earth_reach.core.evaluator.CriterionEvaluatorOutput + :members: + :show-inheritance: + +Orchestrator +~~~~~~~~~~~~ + +.. autoclass:: earth_reach.core.orchestrator.Orchestrator + :members: + :show-inheritance: + +LLM Interface +~~~~~~~~~~~~~ + +.. autoclass:: earth_reach.core.llm.LLMInterface + :members: + :show-inheritance: + +Data Extractors +--------------- + +Base Extractor +~~~~~~~~~~~~~~ + +.. automodule:: earth_reach.core.extractors.base_extractor + :members: + :show-inheritance: + +Temperature Extractor +~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: earth_reach.core.extractors.temperature_extractor + :members: + :show-inheritance: + +Pressure Extractor +~~~~~~~~~~~~~~~~~~ + +.. automodule:: earth_reach.core.extractors.pressure_extractor + :members: + :show-inheritance: + +Configuration +------------- + +Evaluation Criteria +~~~~~~~~~~~~~~~~~~~ + +.. automodule:: earth_reach.config.criteria + :members: + :show-inheritance: + +Logging Configuration +~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: earth_reach.config.logging + :members: + :show-inheritance: + +Command Line Interface +---------------------- + +.. automodule:: earth_reach.cli + :members: + :show-inheritance: + +Utilities +--------- + +.. automodule:: earth_reach.core.utils + :members: + :show-inheritance: From 413e82f1b1c241e5bdc57c056c6612b290759f64 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 20:21:23 -0400 Subject: [PATCH 106/151] fix: removing auto doc build from makefile --- docs/Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/Makefile b/docs/Makefile index 1878ffd..d0c3cbf 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -17,5 +17,4 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - sphinx-apidoc -o ${SOURCEDIR}/api/ ../src/earth_reach/ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) From 6fa06859e68c7cf8a80b8ec4372e4902d7c48eaa Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 20:21:41 -0400 Subject: [PATCH 107/151] doc: writing CLI usage doc --- docs/source/usage.rst | 70 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index e69de29..694b12f 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -0,0 +1,70 @@ +Usage +===== + +EarthReach provides a command-line interface for generating and evaluating weather chart descriptions. + +Getting Started +--------------- + +The CLI is accessible through the ``era`` command (short for earth-reach-agent). View all available commands: + +.. code-block:: bash + + uv run era --help + +Generate Descriptions +--------------------- + +Generate a natural language description from a weather chart image: + +.. code-block:: bash + + uv run era generate --image-path + +**Options:** + +- ``--image-path``: Path to the weather chart image file +- ``--simple``: Use simple mode (generator only, no evaluation loop) +- ``--prompt-path``: Path to a custom prompt file (optional) + +**Example:** + +.. code-block:: bash + + uv run era generate --image-path ./charts/temperature_map.png + +Evaluate Descriptions +--------------------- + +Evaluate the quality of a description against a weather chart: + +.. code-block:: bash + + uv run era evaluate --image-path --description "" + +**Options:** + +- ``--image-path``: Path to the weather chart image file +- ``--description``: The description text to evaluate +- ``--prompt-path``: Path to a custom prompt file (optional) + +**Example:** + +.. code-block:: bash + + uv run era evaluate --image-path ./charts/temperature_map.png --description "Temperature ranges from 10C to 25C across the region" + +Output Format +------------- + +The CLI outputs structured information including: + +- Generated descriptions with metadata +- Evaluation scores across multiple criteria (coherence, fluency, consistency, relevance) +- Processing time and iteration counts +- Quality metrics and feedback + +Environment Variables +--------------------- + +Ensure you have configured the appropriate LLM provider as described in the :doc:`installation` section. From 95b176292f9188a7e56ea827381ff9d26508e8d3 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 20:37:31 -0400 Subject: [PATCH 108/151] doc: writing section descriptions --- docs/source/api.rst | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/source/api.rst b/docs/source/api.rst index ea17112..66f025e 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -6,7 +6,10 @@ This section provides detailed documentation of the EarthReach API. Core Components --------------- -These are the main classes that implement the dual-LLM framework: +The dual-LLM framework comprises three main components: +- The GeneratorAgent: creating weather chart descriptions +- The EvaluatorAgent: evaluating the generated chart descriptions +- The Orchestrator: driving the successive interactions between the generator and evaluator GeneratorAgent ~~~~~~~~~~~~~~ @@ -37,16 +40,11 @@ Orchestrator :members: :show-inheritance: -LLM Interface -~~~~~~~~~~~~~ - -.. autoclass:: earth_reach.core.llm.LLMInterface - :members: - :show-inheritance: - Data Extractors --------------- +Data extractors analyze GRIB file metadata to extract relevant meteorological information, which is then used to enhance prompts for both the generator and evaluator agents. + Base Extractor ~~~~~~~~~~~~~~ @@ -71,6 +69,13 @@ Pressure Extractor Configuration ------------- +LLM Interface +~~~~~~~~~~~~~ + +.. autoclass:: earth_reach.core.llm.LLMInterface + :members: + :show-inheritance: + Evaluation Criteria ~~~~~~~~~~~~~~~~~~~ @@ -88,6 +93,8 @@ Logging Configuration Command Line Interface ---------------------- +The CLI provides convenient access to EarthReach's core functionality through command-line commands. However, as it operates solely on image files without access to underlying GRIB metadata, the CLI generates less detailed descriptions compared to the full *earthkit-plots* library integration. + .. automodule:: earth_reach.cli :members: :show-inheritance: From 564cd0ab89fceff7de067291d2bc12194a23c41a Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 20:51:17 -0400 Subject: [PATCH 109/151] doc: improving docstrings in source code --- src/earth_reach/__init__.py | 6 ++++++ src/earth_reach/config/__init__.py | 6 ++++++ src/earth_reach/config/criteria.py | 7 +++++++ src/earth_reach/core/__init__.py | 6 ++++++ src/earth_reach/core/extractors/__init__.py | 6 ++++++ src/earth_reach/core/llm.py | 7 +++++++ src/earth_reach/core/prompts/__init__.py | 6 ++++++ src/earth_reach/core/prompts/evaluator.py | 7 +++++++ src/earth_reach/core/prompts/generator.py | 7 +++++++ src/earth_reach/core/prompts/orchestrator.py | 7 +++++++ src/earth_reach/core/utils.py | 7 +++++++ src/earth_reach/main.py | 8 ++++++++ 12 files changed, 80 insertions(+) diff --git a/src/earth_reach/__init__.py b/src/earth_reach/__init__.py index e69de29..c53f4ac 100644 --- a/src/earth_reach/__init__.py +++ b/src/earth_reach/__init__.py @@ -0,0 +1,6 @@ +""" +Main package. + +Dual-LLM framework for generating natural language descriptions of meteorological +data visualizations, making weather charts accessible to blind and low-vision scientists. +""" diff --git a/src/earth_reach/config/__init__.py b/src/earth_reach/config/__init__.py index e69de29..0b7eb79 100644 --- a/src/earth_reach/config/__init__.py +++ b/src/earth_reach/config/__init__.py @@ -0,0 +1,6 @@ +""" +Configuration package. + +Contains configuration modules for evaluation criteria, logging setup, +and other system-wide settings. +""" diff --git a/src/earth_reach/config/criteria.py b/src/earth_reach/config/criteria.py index 992ab2a..282b053 100644 --- a/src/earth_reach/config/criteria.py +++ b/src/earth_reach/config/criteria.py @@ -1,3 +1,10 @@ +""" +Quality criteria module. + +Defines the evaluation criteria used to assess the quality of generated +weather chart descriptions across multiple dimensions including coherence and accuracy. +""" + from enum import Enum diff --git a/src/earth_reach/core/__init__.py b/src/earth_reach/core/__init__.py index e69de29..92c644e 100644 --- a/src/earth_reach/core/__init__.py +++ b/src/earth_reach/core/__init__.py @@ -0,0 +1,6 @@ +""" +Core package. + +Contains the core components of the dual-LLM framework: generator and evaluator +agents, orchestrator, and data extraction utilities. +""" diff --git a/src/earth_reach/core/extractors/__init__.py b/src/earth_reach/core/extractors/__init__.py index e69de29..9fb17bf 100644 --- a/src/earth_reach/core/extractors/__init__.py +++ b/src/earth_reach/core/extractors/__init__.py @@ -0,0 +1,6 @@ +""" +Data extractors package. + +Contains modules for extracting meteorological information from GRIB files, +including temperature and pressure data extractors that enhance prompt generation. +""" diff --git a/src/earth_reach/core/llm.py b/src/earth_reach/core/llm.py index ff23aa8..2472fdd 100644 --- a/src/earth_reach/core/llm.py +++ b/src/earth_reach/core/llm.py @@ -1,3 +1,10 @@ +""" +LLM Interface module. + +Provides an abstract interface and concrete implementations for various +Large Language Model providers including OpenAI, Google Gemini, and Anthropic Claude. +""" + import os from abc import ABC, abstractmethod diff --git a/src/earth_reach/core/prompts/__init__.py b/src/earth_reach/core/prompts/__init__.py index e69de29..5fedd18 100644 --- a/src/earth_reach/core/prompts/__init__.py +++ b/src/earth_reach/core/prompts/__init__.py @@ -0,0 +1,6 @@ +""" +Prompts package. + +Contains default prompt templates and configurations for the generator, +evaluator, and orchestrator components. +""" diff --git a/src/earth_reach/core/prompts/evaluator.py b/src/earth_reach/core/prompts/evaluator.py index e617158..f565513 100644 --- a/src/earth_reach/core/prompts/evaluator.py +++ b/src/earth_reach/core/prompts/evaluator.py @@ -1,3 +1,10 @@ +""" +Evaluator prompts module. + +Contains default prompt templates used by the evaluator component +to assess the quality of generated weather chart descriptions across multiple criteria. +""" + DEFAULT_COHERENCE_CRITERIA_EVALUATOR_USER_PROMPT = """# "Coherence" Quality Criteria Evaluation Instructions ## ROLE AND CONTEXT SETTING diff --git a/src/earth_reach/core/prompts/generator.py b/src/earth_reach/core/prompts/generator.py index 129bd8e..df0802b 100644 --- a/src/earth_reach/core/prompts/generator.py +++ b/src/earth_reach/core/prompts/generator.py @@ -1,3 +1,10 @@ +""" +Generator prompts module. + +Contains default prompt templates used by the generator component +to create detailed weather chart descriptions from meteorological visualizations. +""" + DEFAULT_GENERATOR_USER_PROMPT = """# Weather Chart Alt-Text Generation System ## ROLE AND CONTEXT SETTING diff --git a/src/earth_reach/core/prompts/orchestrator.py b/src/earth_reach/core/prompts/orchestrator.py index 702639c..abcad04 100644 --- a/src/earth_reach/core/prompts/orchestrator.py +++ b/src/earth_reach/core/prompts/orchestrator.py @@ -1,3 +1,10 @@ +""" +Orchestrator prompts module. + +Contains default prompt templates used by the orchestrator component +to provide feedback between generator and evaluator iterations. +""" + DEFAULT_FEEDBACK_TEMPLATE = """## EVALUATOR FEEDBACK Evaluation number: {evaluation_id} diff --git a/src/earth_reach/core/utils.py b/src/earth_reach/core/utils.py index 070190e..1f6b08c 100644 --- a/src/earth_reach/core/utils.py +++ b/src/earth_reach/core/utils.py @@ -1,3 +1,10 @@ +""" +Utility functions module. + +Provides utility functions for image processing, file path operations, +and data format conversions. +""" + import base64 from io import BytesIO diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py index 7965c15..ba90008 100644 --- a/src/earth_reach/main.py +++ b/src/earth_reach/main.py @@ -1,2 +1,10 @@ +""" +Main module. + +Contains the main EarthReachAgent class that serves as the primary +entry point for the dual-LLM weather chart description framework. +""" + + class EarthReachAgent: pass From cde152bfb60c41cefafaf62df6718ad765c060a9 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 20:57:26 -0400 Subject: [PATCH 110/151] doc: update module imports and exports in __init__.py --- src/earth_reach/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/earth_reach/__init__.py b/src/earth_reach/__init__.py index c53f4ac..676ee89 100644 --- a/src/earth_reach/__init__.py +++ b/src/earth_reach/__init__.py @@ -4,3 +4,10 @@ Dual-LLM framework for generating natural language descriptions of meteorological data visualizations, making weather charts accessible to blind and low-vision scientists. """ + +from earth_reach.core.evaluator import EvaluatorAgent +from earth_reach.core.generator import GeneratorAgent +from earth_reach.core.llm import GeminiLLM, GroqLLM, OpenAILLM +from earth_reach.core.orchestrator import Orchestrator + +__all__ = [Orchestrator, GeneratorAgent, EvaluatorAgent, OpenAILLM, GeminiLLM, GroqLLM] From de54c1924e0fcdf8c3249e644dc142f6565a3dbb Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 21:27:44 -0400 Subject: [PATCH 111/151] feat: implementing quickplot main integration point --- src/earth_reach/main.py | 215 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 214 insertions(+), 1 deletion(-) diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py index ba90008..676c6c9 100644 --- a/src/earth_reach/main.py +++ b/src/earth_reach/main.py @@ -5,6 +5,219 @@ entry point for the dual-LLM weather chart description framework. """ +from typing import Any + +import earthkit.data as ekd +import earthkit.plots as ekp + +from earth_reach.config.criteria import QualityCriteria +from earth_reach.config.logging import get_logger +from earth_reach.core.evaluator import EvaluatorAgent +from earth_reach.core.extractors.base_extractor import BaseDataExtractor +from earth_reach.core.extractors.pressure_extractor import PressureCenterDataExtractor +from earth_reach.core.extractors.temperature_extractor import TemperatureDataExtractor +from earth_reach.core.generator import GeneratorAgent +from earth_reach.core.llm import create_llm +from earth_reach.core.orchestrator import Orchestrator +from earth_reach.core.prompts.generator import get_default_generator_user_prompt + +logger = get_logger(__name__) + class EarthReachAgent: - pass + """ + Main agent class for generating weather chart descriptions. + + Provides a high-level interface for the dual-LLM framework, handling + data validation, component initialization, and orchestrated generation. + """ + + def __init__( + self, + max_iterations: int = 3, + criteria_threshold: int = 4, + ) -> None: + """ + Initialize the EarthReachAgent with configuration parameters. + + Args: + max_iterations: Maximum number of iterations for the orchestrator + criteria_threshold: Minimum score for evaluation criteria to pass + """ + self.required_vars = ("2t", "msl") + self.max_iterations = max_iterations + self.criteria_threshold = criteria_threshold + logger.info( + "EarthReachAgent initialized with max_iterations=%d, criteria_threshold=%d", + max_iterations, + criteria_threshold, + ) + + def _validate_inputs(self, figure: Any, data: Any) -> None: + """ + Validate that inputs are of the correct types and contain required data. + + Args: + figure: Should be an earthkit.plots.Figure object + data: Should be an earthkit.data.FieldList object + + Raises: + TypeError: If inputs are not of the expected types + ValueError: If required variables are missing from data + """ + logger.debug("Validating inputs...") + + if not isinstance(figure, ekp.Figure): + raise TypeError(f"Expected earthkit.plots.Figure, got {type(figure)}") + + if not isinstance(data, ekd.FieldList): + raise TypeError(f"Expected earthkit.data.FieldList, got {type(data)}") + + available_vars = set() + try: + for field in data: + param = field.metadata("param") + if param: + available_vars.add(param) + except Exception as e: + logger.warning("Could not extract parameter metadata from data: %s", e) + return + + missing_vars = self.required_vars - available_vars + if missing_vars: + raise ValueError(f"Required variables missing from data: {missing_vars}") + + logger.debug("Input validation passed. Available variables: %s", available_vars) + + def _create_data_extractors(self, data: ekd.FieldList) -> list[BaseDataExtractor]: + """ + Create appropriate data extractors based on available variables in the data. + + Args: + data: FieldList containing GRIB data + + Returns: + List of data extractor instances + + Raises: + RuntimeError: If extractor creation fails + """ + logger.info("Creating data extractors...") + extractors = [] + + try: + available_vars = set() + for field in data: + param = field.metadata("param") + if param: + available_vars.add(param) + + if "2t" in available_vars: + logger.debug("Creating TemperatureDataExtractor for 2t variable") + extractors.append(TemperatureDataExtractor()) + + if "msl" in available_vars: + logger.debug("Creating PressureCenterDataExtractor for msl variable") + extractors.append(PressureCenterDataExtractor()) + + logger.info("Created %d data extractors", len(extractors)) + return extractors + + except Exception as e: + raise RuntimeError(f"Failed to create data extractors: {e}") from e + + def _setup_components( + self, data_extractors: list[BaseDataExtractor] + ) -> Orchestrator: + """ + Initialize LLM, agents, and orchestrator with the provided data extractors. + + Args: + data_extractors: List of data extractor instances + + Returns: + Configured orchestrator instance + + Raises: + RuntimeError: If component setup fails + """ + try: + logger.debug("Initializing components...") + llm = create_llm() + + generator = GeneratorAgent( + llm=llm, + system_prompt=None, + user_prompt=get_default_generator_user_prompt(), + ) + + evaluator = EvaluatorAgent( + criteria=QualityCriteria.list(), + llm=llm, + ) + + orchestrator = Orchestrator( + generator_agent=generator, + evaluator_agent=evaluator, + data_extractors=data_extractors, + max_iterations=self.max_iterations, + criteria_threshold=self.criteria_threshold, + ) + + logger.info("Component setup completed successfully") + return orchestrator + + except Exception as e: + raise RuntimeError(f"Failed to setup components: {e}") from e + + def generate_alt_description(self, figure: ekp.Figure, data: ekd.FieldList) -> str: + """ + Generate alternative text description for a weather chart. + + This is the main method called by earthkit-plots integration. + + Args: + figure: earthkit.plots Figure object containing the weather chart + data: earthkit.data FieldList containing GRIB meteorological data + + Returns: + String description of the weather chart + + Raises: + TypeError: If inputs are not of the expected types + ValueError: If required data is missing or invalid + RuntimeError: If description generation fails + """ + try: + logger.info("Starting weather chart description generation") + + self._validate_inputs(figure, data) + + data_extractors = self._create_data_extractors(data) + + orchestrator = self._setup_components(data_extractors) + + logger.info("Running orchestrated description generation...") + description = orchestrator.run(figure=figure, data=data) + + if not isinstance(description, str): + raise TypeError(f"Expected string description, got {type(description)}") + + if not description.strip(): + raise ValueError("Generated description is empty") + + logger.info( + "Description generation completed successfully (length: %d characters)", + len(description), + ) + return description.strip() + + except (TypeError, ValueError) as e: + logger.error("Input validation or data error: %s", e) + raise + except RuntimeError as e: + logger.error("Runtime error during generation: %s", e) + raise + except Exception as e: + logger.error("Unexpected error during description generation: %s", e) + raise RuntimeError(f"Failed to generate description: {e}") from e From b790d6f0d13d5fefd87576b6d02850b774abbb9c Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 21:28:01 -0400 Subject: [PATCH 112/151] doc: adding raised error to orchestrator --- src/earth_reach/core/orchestrator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py index ba10c86..80b3e3d 100644 --- a/src/earth_reach/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -80,6 +80,9 @@ def run( Returns: str: The final weather description. + + Raises: + RuntimeError: if an error occurs during description generation """ if figure is not None and image is not None: raise ValueError( From 699203ad574fa5adfedd997d85d83b68931c70fc Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 21:29:35 -0400 Subject: [PATCH 113/151] feat: adding main components to package level import --- src/earth_reach/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/earth_reach/__init__.py b/src/earth_reach/__init__.py index 676ee89..b8c4beb 100644 --- a/src/earth_reach/__init__.py +++ b/src/earth_reach/__init__.py @@ -9,5 +9,14 @@ from earth_reach.core.generator import GeneratorAgent from earth_reach.core.llm import GeminiLLM, GroqLLM, OpenAILLM from earth_reach.core.orchestrator import Orchestrator +from earth_reach.main import EarthReachAgent -__all__ = [Orchestrator, GeneratorAgent, EvaluatorAgent, OpenAILLM, GeminiLLM, GroqLLM] +__all__ = [ + "EarthReachAgent", + "EvaluatorAgent", + "GeminiLLM", + "GeneratorAgent", + "GroqLLM", + "OpenAILLM", + "Orchestrator", +] From 70048b5d3ddf744b9152bc7c130c40f8022b95cd Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 21:40:23 -0400 Subject: [PATCH 114/151] chore: upadting earthkit data import statement for consistency --- src/earth_reach/core/orchestrator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py index 80b3e3d..b869f8d 100644 --- a/src/earth_reach/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -5,9 +5,9 @@ interactions to generate high-quality weather chart descriptions. """ +import earthkit.data as ekd import earthkit.plots as ekp -from earthkit.data import FieldList from PIL.ImageFile import ImageFile from earth_reach.config.logging import get_logger @@ -68,7 +68,7 @@ def run( self, figure: ekp.Figure | None = None, image: ImageFile | None = None, - data: FieldList | None = None, + data: ekd.FieldList | None = None, ) -> str: """ Run the iterative process of generating and evaluating a weather chart description until quality criteria are met. From 53daa7d9c7ee48310fa091217c29bbdde7f59c8f Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 21:40:49 -0400 Subject: [PATCH 115/151] doc: writing first version of library API usage --- docs/source/usage.rst | 55 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 694b12f..4e9d6fe 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -1,7 +1,60 @@ Usage ===== -EarthReach provides a command-line interface for generating and evaluating weather chart descriptions. +Library Integration +------------------- + +The primary way to use EarthReach is through the ``EarthReachAgent`` class, designed for integration with earthkit-plots and other Python applications. + +Basic Usage +~~~~~~~~~~~ + +.. code-block:: python + + from earth_reach import EarthReachAgent + import earthkit.plots as ekp + import earthkit.data as ekd + + # Load your data with earthkit-data + data = ekd.from_source("file", "your_data.grib") + + # Create a weather chart with earthkit-plots + figure = ekp.quickplot(data, mode="overlay") + + # Generate description + agent = EarthReachAgent() + description = agent.generate_alt_description(figure, data) + print(description) + +Input Requirements +~~~~~~~~~~~~~~~~~~ + +The ``generate_alt_description`` method requires: + +- **figure**: An ``earthkit.plots.Figure`` object containing the weather chart +- **data**: An ``earthkit.data.FieldList`` containing GRIB meteorological data + +**Required Variables**: The data must contain both: + +- ``2t``: 2-meter temperature fields +- ``msl``: Mean sea level pressure fields + +Configuration Options +~~~~~~~~~~~~~~~~~~~~~ + +Customize the agent behavior with initialization parameters: + +.. code-block:: python + + agent = EarthReachAgent( + max_iterations=5, # Maximum evaluation iterations (default: 3) + criteria_threshold=3 # Minimum quality score to pass (default: 4) + ) + +Command Line Interface +---------------------- + +EarthReach also provides a command-line interface for standalone usage. Getting Started --------------- From 637fa9792b639aed0c5eee099a5817a462dcfe2e Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 21:50:20 -0400 Subject: [PATCH 116/151] feat: adding llm provider and model name as arguments to earthreachagent class --- src/earth_reach/main.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py index 676c6c9..9de2992 100644 --- a/src/earth_reach/main.py +++ b/src/earth_reach/main.py @@ -34,21 +34,29 @@ class EarthReachAgent: def __init__( self, + provider: str, + model_name: str | None = None, max_iterations: int = 3, criteria_threshold: int = 4, ) -> None: """ - Initialize the EarthReachAgent with configuration parameters. + Initialize the EarthReachAgent with LLM provider and configuration parameters. Args: + provider: LLM provider name (e.g., "openai", "gemini", "groq", "anthropic") + model_name: Specific model name to use (optional, uses provider default) max_iterations: Maximum number of iterations for the orchestrator criteria_threshold: Minimum score for evaluation criteria to pass """ - self.required_vars = ("2t", "msl") + self.provider = provider + self.model_name = model_name + self.required_vars = {"2t", "msl"} self.max_iterations = max_iterations self.criteria_threshold = criteria_threshold logger.info( - "EarthReachAgent initialized with max_iterations=%d, criteria_threshold=%d", + "EarthReachAgent initialized with provider=%s, model=%s, max_iterations=%d, criteria_threshold=%d", + provider, + model_name or "default", max_iterations, criteria_threshold, ) @@ -143,7 +151,7 @@ def _setup_components( """ try: logger.debug("Initializing components...") - llm = create_llm() + llm = create_llm(provider=self.provider, model_name=self.model_name) generator = GeneratorAgent( llm=llm, From dc5c41245599a2ace8c05e5b5089c776b07e955d Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 21:50:48 -0400 Subject: [PATCH 117/151] doc: updating with provider information --- docs/source/usage.rst | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 4e9d6fe..7e71857 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -22,7 +22,7 @@ Basic Usage figure = ekp.quickplot(data, mode="overlay") # Generate description - agent = EarthReachAgent() + agent = EarthReachAgent(provider="openai") description = agent.generate_alt_description(figure, data) print(description) @@ -47,10 +47,22 @@ Customize the agent behavior with initialization parameters: .. code-block:: python agent = EarthReachAgent( - max_iterations=5, # Maximum evaluation iterations (default: 3) - criteria_threshold=3 # Minimum quality score to pass (default: 4) + provider="openai", # Required: LLM provider + model_name="gpt-4", # Optional: specific model (uses provider default) + max_iterations=5, # Maximum evaluation iterations (default: 3) + criteria_threshold=3 # Minimum quality score to pass (default: 4) ) +**Supported Providers**: + +- ``"openai"``: OpenAI models (GPT-3.5, GPT-4, etc.) +- ``"gemini"``: Google Gemini models +- ``"anthropic"``: Anthropic Claude models +- ``"groq"``: Groq models + +.. note:: + Ensure you have configured the API key for your chosen provider as an environment variable (e.g., ``OPENAI_API_KEY``, ``GEMINI_API_KEY``, etc.). See the :doc:`installation` section for details. + Command Line Interface ---------------------- From faa7265e252405c13128d58a13a1fb49b0e35c98 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 21:56:11 -0400 Subject: [PATCH 118/151] fix: fixing wrong spacing between bullet points --- docs/source/api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/api.rst b/docs/source/api.rst index 66f025e..13fa4f5 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -7,6 +7,7 @@ Core Components --------------- The dual-LLM framework comprises three main components: + - The GeneratorAgent: creating weather chart descriptions - The EvaluatorAgent: evaluating the generated chart descriptions - The Orchestrator: driving the successive interactions between the generator and evaluator From 773b4e8e9b9f558bc88c3f9c481c110d5a1e62ea Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 21:56:25 -0400 Subject: [PATCH 119/151] doc: clarifying CLI limit in doc --- docs/source/usage.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 7e71857..d0cfcef 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -66,7 +66,7 @@ Customize the agent behavior with initialization parameters: Command Line Interface ---------------------- -EarthReach also provides a command-line interface for standalone usage. +EarthReach also provides a command-line interface for convenient standalone usage. However, as it operates solely on image files without access to underlying GRIB metadata, the CLI generates less detailed descriptions compared to the full earthkit-plots library integration. Getting Started --------------- From 7482f89c4d8a91c5409165e4f6ab1f8bc3657edc Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 18 Aug 2025 21:57:38 -0400 Subject: [PATCH 120/151] doc: removing not working extensions --- docs/source/index.rst | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index f91ec75..787c388 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -19,10 +19,3 @@ The system implements a dual-LLM architecture consisting of a generator agent an installation usage api - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` From 290d33b7421d41aab18bad608821a537ad7dd6cc Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 10:20:54 -0400 Subject: [PATCH 121/151] feat: improving error handling with data extractors --- .../core/extractors/pressure_extractor.py | 4 +-- .../core/extractors/temperature_extractor.py | 13 +++++--- src/earth_reach/main.py | 32 ++++++++++--------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/earth_reach/core/extractors/pressure_extractor.py b/src/earth_reach/core/extractors/pressure_extractor.py index 725e0c2..1f9bdcd 100644 --- a/src/earth_reach/core/extractors/pressure_extractor.py +++ b/src/earth_reach/core/extractors/pressure_extractor.py @@ -108,7 +108,7 @@ def validate_data( return data_arr, lats, lons except ValueError as e: - logger.error(f"Validation failed: {e!s}") + logger.error("Pressure centers data extractor validation failed: %s", e) raise def extract( @@ -164,7 +164,7 @@ def extract( return pressure_centers except Exception as e: - logger.error(f"Extraction failed: {e!s}") + logger.error("Pressure center data extraction failed: %s", e) return [] def format_features_to_str(self, features: list[PressureCenter]) -> str: diff --git a/src/earth_reach/core/extractors/temperature_extractor.py b/src/earth_reach/core/extractors/temperature_extractor.py index 3161350..281aea2 100644 --- a/src/earth_reach/core/extractors/temperature_extractor.py +++ b/src/earth_reach/core/extractors/temperature_extractor.py @@ -56,9 +56,13 @@ def __init__(self, temperature_var_name: str = "2t"): temperature_var_name (str): Name of the temperature variable in GRIB data. """ self.temperature_var_name = temperature_var_name - self.average_temp_data = xr.open_dataset( - get_root_dir_path() / "data" / "average_monthly_regional_temperature.nc" - ) + try: + self.average_temp_data = xr.open_dataset( + get_root_dir_path() / "data" / "average_monthly_regional_temperature.nc" + ) + except Exception as e: + logger.error("Could not load cached average temperature data file: %s", e) + raise def validate_data(self, data: ekd.FieldList) -> ekd.FieldList: """ @@ -97,10 +101,9 @@ def validate_data(self, data: ekd.FieldList) -> ekd.FieldList: if lats is None or lons is None: raise ValueError("Could not extract latitude/longitude coordinates") - logger.info("Validation passed.") return data_arr, lats, lons except ValueError as e: - logger.error(f"Validation failed: {e!s}") + logger.error("Temperature data extractor validation failed: %s", e) raise def extract(self, data: ekd.FieldList, **kwargs: Any) -> list[TemperatureData]: diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py index 9de2992..6be9867 100644 --- a/src/earth_reach/main.py +++ b/src/earth_reach/main.py @@ -113,26 +113,28 @@ def _create_data_extractors(self, data: ekd.FieldList) -> list[BaseDataExtractor logger.info("Creating data extractors...") extractors = [] - try: - available_vars = set() - for field in data: - param = field.metadata("param") - if param: - available_vars.add(param) + available_vars = set() + for field in data: + param = field.metadata("param") + if param: + available_vars.add(param) - if "2t" in available_vars: - logger.debug("Creating TemperatureDataExtractor for 2t variable") + if "2t" in available_vars: + try: extractors.append(TemperatureDataExtractor()) + logger.debug("Temperature data extractor created") + except Exception as e: + logger.debug("Could not create temperature data extractor: %s", e) - if "msl" in available_vars: - logger.debug("Creating PressureCenterDataExtractor for msl variable") + if "msl" in available_vars: + try: extractors.append(PressureCenterDataExtractor()) + logger.debug("Pressure centers data extractor created") + except Exception as e: + logger.debug("Could not create pressure centers data extractor: %s", e) - logger.info("Created %d data extractors", len(extractors)) - return extractors - - except Exception as e: - raise RuntimeError(f"Failed to create data extractors: {e}") from e + logger.info("Created %d data extractors", len(extractors)) + return extractors def _setup_components( self, data_extractors: list[BaseDataExtractor] From f403216f636d98a279c24f04c88fa7f02d3e4244 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 10:36:29 -0400 Subject: [PATCH 122/151] feat: improving logging in orchestrator --- src/earth_reach/core/orchestrator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py index b869f8d..5d6af1d 100644 --- a/src/earth_reach/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -123,7 +123,8 @@ def run( self.provide_feedback_to_generator(i + 1, description, evaluation) logger.warning( - f"Maximum iterations ({self.max_iterations}) reached without passing evaluation. Acknowledging limits of description.", + "Maximum iterations %d reached without passing evaluation. Acknowledging limits of description.", + self.max_iterations, ) if not isinstance(description, str): From 5a9f230b925a5de37c79280136709586b73c1c20 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 10:37:07 -0400 Subject: [PATCH 123/151] fix: fixing logging level --- src/earth_reach/core/orchestrator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py index 5d6af1d..6dce622 100644 --- a/src/earth_reach/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -122,7 +122,7 @@ def run( self.provide_feedback_to_generator(i + 1, description, evaluation) - logger.warning( + logger.info( "Maximum iterations %d reached without passing evaluation. Acknowledging limits of description.", self.max_iterations, ) From 40153f5befb03c01e926a3b328b2d7c3161bc3aa Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 10:59:11 -0400 Subject: [PATCH 124/151] doc: using real model in example --- docs/source/usage.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index d0cfcef..008976e 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -47,8 +47,8 @@ Customize the agent behavior with initialization parameters: .. code-block:: python agent = EarthReachAgent( - provider="openai", # Required: LLM provider - model_name="gpt-4", # Optional: specific model (uses provider default) + provider="gemini", # Required: LLM provider + model_name="gemini-2.5-pro", # Optional: specific model (uses provider default) max_iterations=5, # Maximum evaluation iterations (default: 3) criteria_threshold=3 # Minimum quality score to pass (default: 4) ) From 5e8505dfbabc66d7ff8001d90f83d7fb06638d3c Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 15:18:05 -0400 Subject: [PATCH 125/151] feat: adding extracted data information to generator and evaluator prompt --- src/earth_reach/core/evaluator.py | 9 ++++++ src/earth_reach/core/orchestrator.py | 41 +++++++++++++++++++++------- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/earth_reach/core/evaluator.py b/src/earth_reach/core/evaluator.py index b2d041f..f6c9cc2 100644 --- a/src/earth_reach/core/evaluator.py +++ b/src/earth_reach/core/evaluator.py @@ -318,6 +318,10 @@ def _get_image_from_figure(self, figure: ekp.Figure) -> ImageFile: buffer.seek(0) return Image.open(buffer) + def append_user_prompt(self, text: str) -> None: + """Append additional text to the user prompt.""" + self.user_prompt += f"\n\n{text.strip()}" + class CriterionEvaluatorFactory: """Factory class for creating single criterion evaluator agents.""" @@ -415,3 +419,8 @@ def evaluate( return evaluations except Exception as e: raise RuntimeError(f"Failed to evaluate description: {e}") from e + + def append_user_prompt(self, text: str) -> None: + """Append additional text to the user prompt of each criterion evaluator.""" + for evaluator in self.evaluators: + evaluator.user_prompt += f"\n\n{text.strip()}" diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py index 6dce622..58c9dbf 100644 --- a/src/earth_reach/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -89,10 +89,14 @@ def run( "Only one of 'figure' or 'image' can be provided, not both.", ) - # TODO(high): to integrate with the list of data extractors, verify that pressure and temperature fields are present - # if one of them is not present, log a warning - # if present then try to extract features from the data - # then add the features to the generator user prompt and evaluator prompt using a method similar to `provide_feedback_to_generator` that uses `append_user_prompt` + for extractor in self.data_extractors: + try: + features = extractor.extract(data) + features_str = extractor.format_features_to_str(features) + self._add_data_features_to_agent_prompt(features_str, agent="generator") + self._add_data_features_to_agent_prompt(features, agent="evaluator") + except Exception: + continue try: description: str | GeneratorOutput = "" @@ -117,10 +121,10 @@ def run( figure=figure, ) - if self.verify_evaluation_passes(evaluation): + if self._verify_evaluation_passes(evaluation): return description - self.provide_feedback_to_generator(i + 1, description, evaluation) + self._provide_feedback_to_generator(i + 1, description, evaluation) logger.info( "Maximum iterations %d reached without passing evaluation. Acknowledging limits of description.", @@ -134,14 +138,31 @@ def run( if not description: raise ValueError("Final generated description is empty.") - return self.acknowledge_limits_of_description( + return self._acknowledge_limits_of_description( description, evaluation, ) except Exception as e: raise RuntimeError("Failed to generate a description") from e - def verify_evaluation_passes( + def _add_data_features_to_agent_prompt(self, features: str, agent: str) -> None: + """Add extracted data features to end of agent prompt + + Args: + features (str): extracted and string formatted data features + agent (str): agent to add the prompt to + """ + if agent not in ["generator", "evaluator"]: + raise ValueError( + f"agent parameter should be one of ['generator', 'evaluator'], found {agent}" + ) + + if agent == "generator": + self.generator_agent.append_user_prompt(features) + + self.evaluator_agent.append_user_prompt(features) + + def _verify_evaluation_passes( self, evaluation: list[CriterionEvaluatorOutput], ) -> bool: @@ -158,7 +179,7 @@ def verify_evaluation_passes( criterion.score >= self.criteria_threshold for criterion in evaluation ) - def provide_feedback_to_generator( + def _provide_feedback_to_generator( self, evaluation_id: int, description: str, @@ -195,7 +216,7 @@ def provide_feedback_to_generator( self.generator_agent.append_user_prompt(feedback) - def acknowledge_limits_of_description( + def _acknowledge_limits_of_description( self, description: str, evaluation: list[CriterionEvaluatorOutput], From 8ea83f5e19dd1ba343dff663d5737f7d8c282903 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 15:18:28 -0400 Subject: [PATCH 126/151] feat: improving formatted features string from pressure data extractor --- src/earth_reach/core/extractors/pressure_extractor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/earth_reach/core/extractors/pressure_extractor.py b/src/earth_reach/core/extractors/pressure_extractor.py index 1f9bdcd..d99e994 100644 --- a/src/earth_reach/core/extractors/pressure_extractor.py +++ b/src/earth_reach/core/extractors/pressure_extractor.py @@ -175,6 +175,10 @@ def format_features_to_str(self, features: list[PressureCenter]) -> str: output_str += "No pressure centers could be extracted.\n" return output_str + output_str += ( + "Information extracted about the pressure centers present on the map:\n\n" + ) + low_pressure_centers = sorted( [center for center in features if center.center_type == "low"], key=lambda x: x.center_value_hPa, From 49dd237682d0a1650c79ad48cedc03cae96b5f4d Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 15:43:32 -0400 Subject: [PATCH 127/151] feat: removing logging in pressure extractor validation --- src/earth_reach/core/extractors/pressure_extractor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/earth_reach/core/extractors/pressure_extractor.py b/src/earth_reach/core/extractors/pressure_extractor.py index d99e994..507edcc 100644 --- a/src/earth_reach/core/extractors/pressure_extractor.py +++ b/src/earth_reach/core/extractors/pressure_extractor.py @@ -104,7 +104,6 @@ def validate_data( if lats is None or lons is None: raise ValueError("Could not extract latitude/longitude coordinates") - logger.info("Validation passed.") return data_arr, lats, lons except ValueError as e: From efc4f46c4719d9c359e6094c97695e4115091855 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 15:44:14 -0400 Subject: [PATCH 128/151] feat: removing success validation in main agent --- src/earth_reach/main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py index 6be9867..79db6bb 100644 --- a/src/earth_reach/main.py +++ b/src/earth_reach/main.py @@ -95,8 +95,6 @@ def _validate_inputs(self, figure: Any, data: Any) -> None: if missing_vars: raise ValueError(f"Required variables missing from data: {missing_vars}") - logger.debug("Input validation passed. Available variables: %s", available_vars) - def _create_data_extractors(self, data: ekd.FieldList) -> list[BaseDataExtractor]: """ Create appropriate data extractors based on available variables in the data. From b5e16f981bfb0c49c0f8ecb9ce6da4b6bf4faea3 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 16:14:00 -0400 Subject: [PATCH 129/151] doc: updating docstring --- src/earth_reach/main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py index 79db6bb..5ba85be 100644 --- a/src/earth_reach/main.py +++ b/src/earth_reach/main.py @@ -182,8 +182,6 @@ def generate_alt_description(self, figure: ekp.Figure, data: ekd.FieldList) -> s """ Generate alternative text description for a weather chart. - This is the main method called by earthkit-plots integration. - Args: figure: earthkit.plots Figure object containing the weather chart data: earthkit.data FieldList containing GRIB meteorological data From ab1e141445f9a13444ed3ed3caa6a536dbcca2e2 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 16:20:12 -0400 Subject: [PATCH 130/151] feat: removing dev notebook --- notebooks/earthkit_plots_exploration.py | 1045 ----------------------- 1 file changed, 1045 deletions(-) delete mode 100644 notebooks/earthkit_plots_exploration.py diff --git a/notebooks/earthkit_plots_exploration.py b/notebooks/earthkit_plots_exploration.py deleted file mode 100644 index b2fbd73..0000000 --- a/notebooks/earthkit_plots_exploration.py +++ /dev/null @@ -1,1045 +0,0 @@ -# --- -# jupyter: -# jupytext: -# cell_metadata_filter: -all -# formats: ipynb,py:percent -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.17.1 -# kernelspec: -# display_name: EarthReach -# language: python -# name: python3 -# --- - -# %% [markdown] -# # Earthkit Plots Exploration - -# %% [markdown] -# ## Configuration & Imports - -# %% -import base64 -import os -import time -import warnings - -from io import BytesIO -from pathlib import Path - -import earthkit as ek -import openai -import pandas as pd - -from dotenv import load_dotenv -from earthkit.data import cache, config -from IPython.display import Markdown, display -from PIL import Image -from tqdm import tqdm - -from earth_reach.core.generator import GeneratorAgent -from earth_reach.core.llm import GroqLLM -from earth_reach.core.prompts.generator import get_default_generator_user_prompt - -load_dotenv(Path().cwd().parent / ".env") - -warnings.filterwarnings("ignore") - -config.set("cache-policy", "user") -print("cache:", cache.directory()) - - -# %% [markdown] -# ## Definitions - - -# %% -def get_weather_data( - date: str, - times: list[str] = ["12:00"], - variables: list[str] = ["2m_temperature", "mean_sea_level_pressure"], - dataset: str = "reanalysis-era5-single-levels", -): - date_list = date.split("/") - - request = { - "product_type": ["reanalysis"], - "variable": variables, - "day": [date_list[0]], - "month": [date_list[1]], - "year": [date_list[2]], - "time": times, - "data_format": "grib", - "download_format": "unarchived", - } - - data = ek.data.from_source("cds", dataset, request) - - return data - - -# %% -def display_markdown(text: str) -> None: - """ - Display a string as Markdown in a Jupyter notebook. - - Args: - text (str): The text to display as Markdown. - """ - display(Markdown(text)) - - -def img_to_base64(image_path: str | None = None, img=None) -> str: - """ - Convert an image to a base64 string. - - Args: - image_path (str): The path to the image file. Either this or img must be provided. - img (PIL.Image): The image object. Either this or image_path must be provided. - - Returns: - str: The base64 string representation of the image. - """ - if image_path is None and img is None: - raise ValueError("Either image_path or img must be provided.") - - if img is not None: - bytes_io = BytesIO() - img.save(bytes_io, format="PNG") - return base64.b64encode(bytes_io.getvalue()).decode("utf-8") - - with open(image_path, "rb") as img_file: # type: ignore - return base64.b64encode(img_file.read()).decode("utf-8") - - -# %% -assert os.environ.get("GROQ_API_KEY"), ( - "GROQ_API_KEY not set. Please set it in your environment variables." -) -client = openai.OpenAI( - base_url="https://api.groq.com/openai/v1", - api_key=os.environ.get("GROQ_API_KEY"), # Using GROQ free API provider -) - -MODEL_NAME = ( - "meta-llama/llama-4-maverick-17b-128e-instruct" # vision-enabled chat model -) - - -def call_llm_api(user_prompt: str, image) -> str: - """Call the LLM generation API""" - base64_image = img_to_base64(img=image) - try: - response = client.chat.completions.create( - model=MODEL_NAME, - messages=[ - { - "role": "user", - "content": [ - {"type": "text", "text": user_prompt}, - { - "type": "image_url", - "image_url": { - "url": f"data:image/jpeg;base64,{base64_image}", - }, - }, - ], - }, - ], - ) - description = response.choices[0].message.content - - if not description: - raise ValueError("The description generated is empty") - - return description - - except Exception as e: - print("Encountered unexpected error when calling the api: {e}") - raise e - - -# %% [markdown] -# ## Loading Data - -# %% -# Example, quickly accessible data -data = ek.data.from_source("sample", "era5-2t-msl-1985122512.grib") -data.ls() - -# %% -# Copernicus datastore, requires API key set -event_date = "12/03/2011" - -data = get_weather_data(event_date) - -data.ls() - -# %% [markdown] -# ## Generating Plots from Interesting Weather Events - -# %% -# NOTE: those dates are probably most relevant for temperature but are they also for pressure ? -# Maybe we should consider events that have two interesting events ? -# Possible configurations: interesting event present / not present, parameter shown / not shown -weather_events = { - "average_days": {"global": ["12/03/2011", "20/07/2010", "05/11/2003"]}, - "cold_days": {"global": ["13/03/1976", "21/07/1943", "05/11/1975"]}, - "hot_days": {"global": ["14/03/2024", "22/07/2024", "08/11/2023"]}, - "heatwave": {"France": ["30/04/2025"], "Arctic": ["17/04/2015"]}, - "cold_wave": {"United States": ["31/01/2019"], "Europe": ["27/02/2018"]}, -} - - -# %% -def save_weather_event_images(weather_events, output_dir="weather_images"): - """ - Save weather event images and create a CSV mapping file. - - Args: - weather_events: Dictionary of weather events - output_dir: Directory to save the images and CSV file - """ - os.makedirs(output_dir, exist_ok=True) - - figure_metadata = [] - figure_id = 1 - try: - for event_type, domains in weather_events.items(): - for domain_type, dates in domains.items(): - for date_str in dates: - data = get_weather_data(date_str) - - sub_data = data.sel(param=["2t", "msl"], typeOfLevel="surface") - - kwargs: dict[str, list] = ( - {"domain": [domain_type]} - if domain_type.lower() != "global" - else {} - ) - - buffer = BytesIO() - figure = ek.plots.quickplot( - sub_data, - units=["celsius", "hPa"], - mode="overlay", - **kwargs, # type: ignore - ) - figure.save(buffer, format="png") - - image_filename = f"figure_{figure_id}.png" - image_path = os.path.join(output_dir, image_filename) - buffer.seek(0) - img = Image.open(buffer) - img.save(image_path) - - figure_metadata.append( - { - "figure_id": figure_id, - "event_type": event_type, - "domain_type": domain_type, - "date": date_str, - }, - ) - - figure_id += 1 - except: - pass - - metadata_df = pd.DataFrame(figure_metadata) - csv_path = os.path.join(output_dir, "figure_metadata.csv") - metadata_df.to_csv(csv_path, index=False) - - print(f"Saved {figure_id - 1} figures and metadata to {output_dir}") - return metadata_df - - -# %% -metadata_df = save_weather_event_images(weather_events) - -# %% [markdown] -# Chart to generate again with wind vectors: 13, cold_wave, Europe, 27/02/2018 - -# %% -output_dir = str(Path().cwd().parent / "data" / "weather_images") - -figure_id = 13 -date_str = "27/02/2018" -domain_type = "Europe" -variables = [ - "10m_u_component_of_wind", - "10m_v_component_of_wind", - "2m_temperature", - "mean_sea_level_pressure", -] - -data = get_weather_data(date_str, variables=variables) - -data.ls() - -# %% -f = ek.plots.Map(domain=["Europe"]) - -temperature = data.sel(short_name="2t") -pressure = data.sel(short_name="msl") - -f.quickplot(temperature, units="celsius") -f.quickplot(pressure, units="hPa") - -f.title( - "2m Temperature (C), Mean Sea Level Pressure (hPa), and Wind Vectors on 27/02/2018 in Europe", -) - -f.coastlines(color="white", linewidth=2) -f.gridlines() - -f.quiver( - u=data.sel(short_name="10u"), - v=data.sel(short_name="10v"), -) - -f.legend() - -f.show() - -# %% -sub_data = data.sel(param=["2t", "msl", "10u", "10v"], typeOfLevel="surface") - -kwargs: dict[str, list] = ( - {"domain": [domain_type]} if domain_type.lower() != "global" else {} -) - -buffer = BytesIO() -figure = ek.plots.quickplot( - sub_data, - # units=["celsius", "hPa"], - mode="overlay", - **kwargs, # type: ignore -) - -# figure.quiver( -# u=data.sel(shortName="10u"), -# v=data.sel(shortName="10v"), -# resample=Subsample(1, mode="stride"), -# ) -figure.save(buffer, format="png") - -image_filename = f"figure_{figure_id}_wind.png" -image_path = os.path.join(output_dir, image_filename) -buffer.seek(0) -img = Image.open(buffer) -img.save(image_path) - -# %% [markdown] -# ## Exploring GRIB data - -# %% [markdown] -# When loaded through the `.from_source()` method, a GRIB file will yield a `GribFielList` object. Documentation available [here](https://earthkit-data.readthedocs.io/en/latest/guide/data_format/grib.html) -# -# Some information about fields and GRIB data: -# - The fields can be iterated through with for loops -# - A field represents a single meteorological phenomenon (like temperature), measured at a certain time, at a certain height, from a certain center - -# %% -len(data) # Number of fields - -# %% -data.head() # List first 5 fields and "some" metadata for each field - -# %% -data.ls() # List all fields and *some* metadata for each field - -# %% -data.describe() # Describe the fields - -# %% -data[0].dump() # Access different metadata namespaces - -# %% -data[0].metadata(namespaces="statistics") - -# %% -data.indices() # List of the metadata and the values they can take - -# %% [markdown] -# ## Earthkit-plot Quickplot API - -# %% -data.head() - -# %% -# Sub-select the data -sub_data = data.sel(param=["2t", "msl"], typeOfLevel="surface") -sub_data.head() - -# %% -buffer = BytesIO() -figure = ek.plots.quickplot(sub_data, domain=["France"], mode="overlay") - -figure.save(buffer, format="png") - -buffer.seek(0) - -img = Image.open(buffer) - -# %% -figure.title() - -# %% [markdown] -# Observations: -# - Geographic information is represented almost purely visually (there are latitudes and longitudes information on the axes but they are hardly interpretable), so a model should recognize the domains, or have access to them in the forms of variable valuess, default being the whole world -# - Pressure values are only represented as visual isobars, which might be the information the hardest to understand visually. A better approach could be to use the data values. -# - Temperature values are visually represented with a gradient of colors, and a clear legend. I think it would be quite easily interpreted by a model. -# - Title provides important context about the information represented and metadata such as the date. It should probably be transmitted to an end user. It can be accessed visually but also through the figure attributes. -# - In the end, the goal of interpreting this type of images could be phrased as "understanding the spatial distribution of meteorological variables." - -# %% [markdown] -# ## Trying a Simple VLLM Summary Generation - -# %% -task_prompt = """# Instructions for Describing Meteorological Visualizations for Blind Scientists - -Your task is to create a comprehensive, scientifically accurate description of meteorological visualizations that will be accessible to blind scientists. - -""" - -method_prompt = """## Method - -To achieve this task, you will work in two three steps. -1. Understanding the information of the visualization -2. Planning your description -3. Writing your description - -First,reflect on the meteorological visualizations and the metadata provided, step by step, critically, and without omitting any detail, to make sure you understand them and the key information they convey. - -Once you've completed your full breakdown of the visualization and the metadata, you should be able to write a comprehensive, scientifically accurate description of the visualization. - -So you will once again start a reflection process, to plan what you will convey and how you will convey it in the desciption, in order to respect the requirements established previously - -""" - -requirements_prompt = """## Requirements - -Here are requirements that you will have to follow rigorously when writing your description. - -### 1. Structural Organization -- Begin with a concise overview (2-3 sentences) stating the visualization type, geographic region, time period, and primary variables displayed. -- Organize your description hierarchically from most to least scientifically significant features. -- Structure your description in clearly defined sections with standardized headings. - -### 2. Scientific Content Requirements -- Use precise meteorological terminology consistent with scientific publications. -- Always include quantitative values with appropriate units (hPa for pressure, K/°C for temperature). -- Describe patterns and gradients rather than individual data points. -- Explicitly state the range of values shown for each variable. -- Identify and describe major meteorological features (high/low pressure systems, fronts, temperature gradients). -- Include geographic context using cardinal directions and recognized regional references. - -### 3. Spatial Relationship Guidelines -- Systematically describe the geographic distribution of data using cardinal directions (N, S, E, W, NE, etc.). -- Reference latitude and longitude coordinates when describing specific features. -- Describe gradients and transitions using directional language (e.g., "increasing from south to north"). -- Use recognized geographic features (countries, seas, mountain ranges) as reference points. - -### 4. Pattern Description Protocol -- Identify dominant patterns for each variable (e.g., pressure systems, temperature fronts). -- Describe the spatial relationships between different variables (e.g., how temperature aligns with pressure systems). -- Communicate the intensity of gradients, using terms like "sharp gradient," "gentle slope," or "uniform distribution." -- Note any anomalies or unusual features in the data pattern. - -### 5. Technical Elements -- Describe the scale and units prominently displayed in the visualization. -- Explain the visualization technique used for each variable (e.g., color gradient for temperature, contour lines for pressure). -- Note the resolution and any apparent limitations of the data. - -### 6. Scientific Context -- Include relevant seasonal context (e.g., winter conditions for December visualization). -- Provide brief meteorological context for the significance of the date, if applicable. -- Relate the patterns shown to typical or expected meteorological conditions for the region and season. - -### 7. Accessibility-Specific Considerations -- Use clear, unambiguous language that doesn't rely on visual references. -- Avoid phrases like "as you can see" or "looking at the map." -- Use directional and relative terms that don't require visual understanding. -- Ensure all information conveyed by color is also expressed verbally in terms of values and patterns. - -### 8. Language Style -- Maintain a formal, scientific tone throughout. -- Use precise, concise language without unnecessary elaboration. -- Be objective in descriptions, clearly differentiating between observed data and interpretations. - -""" - -format_prompt = """""" - -examples_prompt = """""" - -user_prompt = ( - task_prompt + method_prompt + requirements_prompt + format_prompt + examples_prompt -) - -# %% -description = call_llm_api(user_prompt, img) - -display_markdown(description) - -# %% [markdown] -# Observations (from a very simple prompt): -# - Good structured and objective documentation of visual elements -# - Can accurately identify technical components of scientific visualizations -# - Remains cautious about making interpretive claims beyond what's explicitly shown -# - Prioritizes factual accuracy over speculative analysis -# -# These first observations are promising. We now need to try a generation with a more specialized prompt, including best prompt-engineering techniques from the start. - -# %% -enhanced_prompt = """# Enhanced Weather Chart Alt-Text Generation System - -## ROLE AND CONTEXT SETTING - -You are a specialist scientific communication assistant working with meteorological researchers who are blind or visually impaired. Your expertise lies in converting complex weather visualizations into precise, scientifically accurate text descriptions that preserve all critical meteorological information while being fully accessible. - -**Your Mission**: Transform weather charts and maps into comprehensive text descriptions that enable blind scientists to conduct the same quality of meteorological analysis as their sighted colleagues. - -**Critical Context**: Your descriptions will be used for: -- Research analysis and data interpretation -- Scientific paper writing and peer review -- Teaching and educational materials -- Operational weather forecasting decisions - -## TASK DECOMPOSITION - -### Step 1: Data Extraction and Analysis (200-500 words) -**Objective**: Systematically catalog all quantitative information and meteorological features - -**Actions**: -1. Identify and record the exact value ranges for each variable (temperature, pressure, wind speed, etc.) -2. Note the geographic boundaries (latitude/longitude ranges), map projection, and spatial domain (e.g., "North America", "Europe", "Spain", "Global") -3. List all meteorological systems visible (highs, lows, fronts, convergence zones) -4. Document the time period, date, and any temporal information -5. Record the data source, resolution, and any technical specifications shown - -**Success Check**: Can you list specific numbers for every major feature without using vague terms like "high" or "low"? - -### Step 2: Pattern Recognition and Spatial Analysis (200-500 words) -**Objective**: Identify the dominant meteorological patterns and their spatial relationships - -**Actions**: -1. Determine the primary weather systems and rank them by meteorological significance -2. Map the directional flow of gradients (temperature, pressure) using cardinal directions -3. Identify spatial correlations between different variables -4. Note any unusual or anomalous features that deviate from expected patterns -5. Assess the seasonal/temporal context and typical vs. atypical conditions - -**Success Check**: Can you explain how each major system influences the others spatially? - -### Step 3: Description Planning and Structure Design (100-150 words) -**Objective**: Create a hierarchical outline that prioritizes information by scientific importance - -**Actions**: -1. Rank meteorological features by their significance for understanding weather patterns -2. Plan the logical flow from overview to specific details -3. Determine which quantitative values are essential vs. supplementary -4. Identify the most effective way to convey spatial relationships -5. Plan transitions between sections to maintain scientific coherence - -**Success Check**: Would a meteorologist reading your outline understand the complete weather situation? - -### Step 4: Description Writing with Verification (300-500 words) -**Objective**: Produce the final description following all specifications - -**Actions**: -1. Write a concise overview (2-3 sentences) summarizing the visualization type, geographic region, time period, and primary variables -2. Develop the main body with 4-6 sentences, covering: - - Dominant systems and their characteristics - - Variable distributions (temperature, pressure, wind) - - Spatial relationships and correlations - - Notable features or anomalies -3. Ensure all quantitative values include specific ranges and units -4. Use precise meteorological terminology and avoid visual-dependent language - -**Success Check**: Does your description pass all items in the quality verification checklist? - -## CONCRETE CONSTRAINTS AND SPECIFICATIONS - -### Length and Structure Requirements -- **Total description length**: 300-500 words maximum -- **Overview section**: Exactly 2-3 sentences -- **Main body**: 4-6 sentences - -### Quantitative Requirements -- **Include exact ranges**: Every variable must have minimum and maximum values with units -- **Spatial precision**: Use cardinal directions and geographic references -- **Coordinate references**: Include latitude/longitude for major features (±2 degrees acceptable) -- **Unit consistency**: Use standard meteorological units (hPa, m/s, K or °C, km) - -### Language Specifications -- **Terminology level**: Graduate-level meteorological vocabulary (assume PhD-level audience) -- **Sentence structure**: Maximum 25 words per sentence -- **Accessibility compliance**: Zero visual-dependent phrases (see prohibited terms list below) -- **Objectivity standard**: Separate observations from interpretations using phrases like "The data shows..." vs. "This suggests..." - -## ERROR PREVENTION GUIDELINES - -### Common Pitfalls to Avoid -1. **Vague descriptions**: Never use "high," "low," "warm," "cold" without specific values -2. **Visual dependency**: Prohibited phrases include: "as shown," "visible," "looking at," "clearly," "obviously" -3. **Geographic ambiguity**: Avoid "here," "there," "this area" - use specific location references -4. **Unit omissions**: Every quantitative statement must include appropriate units -5. **Pattern assumptions**: Don't assume readers can visualize spatial relationships - describe explicitly - -## SUCCESS CRITERIA - -### Objective Standards -1. **Completeness Test**: A meteorologist should be able to sketch the general pattern from your description -2. **Quantitative Accuracy**: All numerical values within ±5% of actual data -3. **Accessibility Compliance**: 100% of visual information conveyed through text -4. **Scientific Utility**: Description enables the same analytical conclusions as the visual -5. **Terminology Precision**: All meteorological terms used correctly per AMS Glossary standards - -## EXAMPLES AND REFERENCE PATTERNS - -Not yet available, but will be provided in the future. - -## OUTPUT FORMAT REQUIREMENTS - -### XML Tag Structure -**CRITICAL**: You must wrap the content of each step and the final description in specific XML tags for programmatic parsing. - -**Required XML Tags:** -- `...` - Wrap the entire content of Step 1 (excluding the markdown header) -- `...` - Wrap the entire content of Step 2 (excluding the markdown header) -- `...` - Wrap the entire content of Step 3 (excluding the markdown header) -- `...` - Wrap the entire content of Step 4 (excluding the markdown header) -- `...` - Wrap only the final consolidated description (without headers or meta-commentary) - -### Formatting Rules -1. **Tag Placement**: Place opening and closing tags on their own lines -2. **Content Scope**: Include all step content within tags, but exclude markdown headers (## Step X) -3. **Final Description**: The `` should contain ONLY the final weather description without any meta-commentary about word count or requirements -4. **No Tag Omission**: All five XML tag pairs must be present, even if a step is brief - -### Example Structure: -``` -## Step 1: Data Extraction and Analysis - -The chart displays 2-meter temperature and mean sea level pressure... -[rest of step 1 content] - - -## Step 2: Pattern Recognition and Spatial Analysis - -The dominant feature is a high-pressure system... -[rest of step 2 content] - - -## Step 3: Description Planning and Structure Design - -To structure the description, we will start with an overview... -[rest of step 3 content] - - -## Step 4: Description Writing with Verification - -### Overview -This weather chart displays... -### Main Body -A high-pressure system is centered... -[rest of step 4 content] - - - -This weather chart visualization shows 2-meter temperature and mean sea level pressure over Western Europe on March 12, 2011, at 12:00 UTC. The region covers latitudes from 40.5°N to 51°N and longitudes from 2.5°W to 10°E. A high-pressure system is centered at 45°N, 7.5°E with a pressure of 102080 Pa. The 2-meter temperature varies from 280 K in the north to 294 K in the southeast, showing a significant north-south temperature gradient. The pressure distribution is dominated by this high-pressure system, with pressure decreasing towards the northwest. The highest temperatures are located southeast of the high-pressure center. - -``` - -**IMPORTANT**: Ensure all XML tags are properly opened and closed. Malformed XML will cause parsing failures. - -**Remember**: Your description is not just text, it's a scientific instrument that must preserve the analytical power of the original visualization while being fully accessible to blind scientists. -""" - -# display_markdown(enhanced_prompt) - -# %% -description = call_llm_api(enhanced_prompt, img) - -display_markdown(description) - -# %% [markdown] -# ## Generating Summaries for All Charts with Generator Agent - -# %% -images_dir_path = Path().cwd().parent / "data" / "weather_images" -csv_path = images_dir_path / "figure_metadata.csv" - -llm = GroqLLM( - model_name="meta-llama/llama-4-maverick-17b-128e-instruct", - api_key=os.environ.get("GROQ_API_KEY"), -) - -generator = GeneratorAgent( - llm=llm, - user_prompt=DEFAULT_USER_PROMPT, - system_prompt=None, -) - -image_files = list(images_dir_path.glob("figure_*.png")) -df = pd.read_csv(csv_path) - -if "description" not in df.columns: - df["description"] = None - -DELAY_SECONDS = 2 -SAVE_EVERY = 1 - -processed_count = 0 -for idx, row in tqdm(df.iterrows(), desc="Generating descriptions"): - if pd.isna(row["description"]): - figure_id = row["figure_id"] - image_path = images_dir_path / f"figure_{figure_id}.png" - - if image_path.exists(): - try: - image = Image.open(image_path) - description = generator.generate(image) - df.at[idx, "description"] = description - print(f"✓ Generated description for figure_{figure_id}") - - except Exception as e: - df.at[idx, "description"] = f"FAILED: {e!s}" - print(f"✗ Failed figure_{figure_id}: {e}") - - processed_count += 1 - - if processed_count % SAVE_EVERY == 0: - df.to_csv(csv_path, index=False) - print(f"💾 Progress saved ({processed_count} processed)") - - time.sleep(DELAY_SECONDS) - -df.to_csv(csv_path, index=False) -print(f"Results saved to: {csv_path}") - -# %% [markdown] -# ## Reviewing Generated Summaries - -# %% -images_dir_path = Path().cwd().parent / "data" / "weather_images" -csv_path = images_dir_path / "figure_metadata.csv" - -# %% -image_files = list(images_dir_path.glob("figure_*.png")) -df = pd.read_csv(csv_path) -df - -# %% -from textwrap import fill - - -def show_image_with_description(image_path: Path, description: str): - """ - Display an image with its description. - - Args: - image_path (Path): Path to the image file. - description (str): Description of the image. - """ - if image_path.exists(): - try: - image = Image.open(image_path) - print( - fill(description, width=80), - ) # Format description for better readability - return image - except Exception as e: - print(f"Error displaying image {image_path}: {e}") - - -# %% -figure_id = 13 -figure_path = images_dir_path / f"figure_{figure_id}.png" -description = df.loc[df["figure_id"] == figure_id, "description"].values[0] - -show_image_with_description(figure_path, description) - -# %% [markdown] -# ## Testing Improved Generator Prompt - -# %% -from pathlib import Path - -from dotenv import load_dotenv -from PIL import Image - -from earth_reach.core.generator import GeneratorAgent -from earth_reach.core.llm import create_llm - -load_dotenv(Path().cwd().parent / ".env") - -# %% -figure_id = 13 -images_dir_path = Path().cwd().parent / "data" / "weather_images" -figure_path = images_dir_path / f"figure_{figure_id}.png" -img = Image.open(figure_path) -img - -# %% -llm = create_llm( - provider="gemini", - model_name="gemini-2.5-pro", -) - -# %% -generator = GeneratorAgent( - llm=llm, - user_prompt=get_default_generator_user_prompt(), - system_prompt=None, -) - -# %% -output = generator.generate( - image=img, - return_intermediate_steps=True, -) -print(output) - -# %% -print( - fill( - 'This weather chart displays 2-meter temperature and mean sea level pressure over Europe and North Africa for 12:00 UTC on February 27, 2018. The map domain extends from approximately 25°N to 70°N and 10°W to 45°E. Temperatures range from below -30°C (blue/purple) to over 20°C (orange), with green tones near 0°C. Isobars are drawn at 4 hPa intervals.\n\nThe synoptic situation is dominated by an exceptionally intense and large high-pressure system centered over Scandinavia and northwestern Russia. The central pressure of this anticyclone exceeds 1052 hPa. This system governs the weather pattern across the entire continent, representing a significant anomaly for late boreal winter. Its presence establishes a powerful blocking pattern, preventing milder Atlantic air from reaching Europe.\n\nThe primary consequence of this high is a severe cold air outbreak. Inferred from the anticyclonic (clockwise) circulation, strong easterly winds advect frigid continental air from Siberia westward across Europe. This results in extremely low temperatures across a vast area. Scandinavia, the Baltic states, and northern Russia experience temperatures between -15°C and -30°C. The cold air penetrates deep into Western and Central Europe, with temperatures in Germany, Poland, and the UK falling between -5°C and -15°C.\n\nA sharp and powerful frontal boundary forms across Southern Europe where the arctic air mass collides with milder air. This front extends from the Black Sea, across the Balkan Peninsula and northern Italy, into southern France. Along this zone, temperatures increase dramatically from sub-zero readings in the north to over 10°C in the south. A low-pressure system, with a central pressure near 1004 hPa, is established in the central Mediterranean Sea, south of Italy.\n\nThe interaction between these features creates active and severe weather. The tight pressure gradient between the northern high and southern low generates strong to gale-force easterly winds, particularly across Central and Eastern Europe. These winds contribute to dangerously low wind chill values. The uplift associated with the Mediterranean low, combined with the influx of cold air, would lead to heavy snowfall, especially across the mountains of Italy and the Balkans. In contrast, North Africa and the far southeastern Mediterranean remain mild, with temperatures between 15°C and 25°C.\n\nIn summary, this chart captures a classic but extreme "Beast from the East" event, a severe winter cold wave driven by an anomalously strong Scandinavian high-pressure system, resulting in widespread record-breaking cold and disruptive weather across Europe.', - 80, - ), -) - -# %% [markdown] -# ## Testing Improved Evaluator Prompt - -# %% -from pathlib import Path -from textwrap import fill - -from dotenv import load_dotenv -from PIL import Image - -from earth_reach.core.evaluator import EvaluatorAgent -from earth_reach.core.llm import create_llm - -load_dotenv(Path().cwd().parent / ".env") - -# %% -figure_id = 13 -images_dir_path = Path().cwd().parent / "data" / "weather_images" -figure_path = images_dir_path / f"figure_{figure_id}.png" -img = Image.open(figure_path) -img - -# %% -llm = create_llm( - provider="gemini", - model_name="gemini-2.5-pro", -) - -evaluator = EvaluatorAgent( - criteria=["fluency"], - llm=llm, -) - -# %% -description = 'This weather chart displays 2-meter temperature and mean sea level pressure over Europe and North Africa for 12:00 UTC on February 27, 2018. The map domain extends from approximately 25°N to 70°N and 10°W to 45°E. Temperatures range from below -30°C (blue/purple) to over 20°C (orange), with green tones near 0°C. Isobars are drawn at 4 hPa intervals.\n\nThe synoptic situation is dominated by an exceptionally intense and large high-pressure system centered over Scandinavia and northwestern Russia. The central pressure of this anticyclone exceeds 1052 hPa. This system governs the weather pattern across the entire continent, representing a significant anomaly for late boreal winter. Its presence establishes a powerful blocking pattern, preventing milder Atlantic air from reaching Europe.\n\nThe primary consequence of this high is a severe cold air outbreak. Inferred from the anticyclonic (clockwise) circulation, strong easterly winds advect frigid continental air from Siberia westward across Europe. This results in extremely low temperatures across a vast area. Scandinavia, the Baltic states, and northern Russia experience temperatures between -15°C and -30°C. The cold air penetrates deep into Western and Central Europe, with temperatures in Germany, Poland, and the UK falling between -5°C and -15°C.\n\nA sharp and powerful frontal boundary forms across Southern Europe where the arctic air mass collides with milder air. This front extends from the Black Sea, across the Balkan Peninsula and northern Italy, into southern France. Along this zone, temperatures increase dramatically from sub-zero readings in the north to over 10°C in the south. A low-pressure system, with a central pressure near 1004 hPa, is established in the central Mediterranean Sea, south of Italy.\n\nThe interaction between these features creates active and severe weather. The tight pressure gradient between the northern high and southern low generates strong to gale-force easterly winds, particularly across Central and Eastern Europe. These winds contribute to dangerously low wind chill values. The uplift associated with the Mediterranean low, combined with the influx of cold air, would lead to heavy snowfall, especially across the mountains of Italy and the Balkans. In contrast, North Africa and the far southeastern Mediterranean remain mild, with temperatures between 15°C and 25°C.\n\nIn summary, this chart captures a classic but extreme "Beast from the East" event, a severe winter cold wave driven by an anomalously strong Scandinavian high-pressure system, resulting in widespread record-breaking cold and disruptive weather across Europe.' - -# %% -output = evaluator.evaluate( - description=description, - image=img, -) -print(output) - -# %% -for eval in output: - print(f"Criterion: {eval.name}, Score: {eval.score}") - if eval.name == "fluency": - print(f"Fluency Feedback:\n\n{fill(eval.reasoning, 80)}") - -# %% [markdown] -# ## Testing Improved Complete System - -# %% -from pathlib import Path -from textwrap import fill - -from dotenv import load_dotenv -from PIL import Image - -from earth_reach.core.evaluator import EvaluatorAgent -from earth_reach.core.generator import GeneratorAgent -from earth_reach.core.llm import create_llm -from earth_reach.core.orchestrator import Orchestrator -from earth_reach.core.prompts.generator import get_default_generator_user_prompt - -load_dotenv(Path().cwd().parent / ".env") - -# %% -figure_id = 13 -images_dir_path = Path().cwd().parent / "data" / "weather_images" -figure_path = images_dir_path / f"figure_{figure_id}.png" -img = Image.open(figure_path) -img - -# %% -llm = create_llm( - provider="gemini", - model_name="gemini-2.5-pro", -) - -evaluator = EvaluatorAgent( - criteria=["coherence", "fluency", "consistency", "relevance"], - llm=llm, -) - -generator = GeneratorAgent( - llm=llm, - user_prompt=get_default_generator_user_prompt(), - system_prompt=None, -) - -orchestrator = Orchestrator( - generator_agent=generator, - evaluator_agent=evaluator, -) - -# %% -description = orchestrator.run( - image=img, -) -print(fill(description, 80)) - -# %% -print(description.replace("\n", "")) - -# %% [markdown] -# ## Generate Descriptions for All Figures with Improved System - -# %% -import time - -from datetime import datetime -from pathlib import Path - -import pandas as pd - -from dotenv import load_dotenv -from PIL import Image -from tqdm import tqdm - -from earth_reach.core.evaluator import EvaluatorAgent -from earth_reach.core.generator import GeneratorAgent -from earth_reach.core.llm import create_llm -from earth_reach.core.orchestrator import Orchestrator -from earth_reach.core.prompts.generator import get_default_generator_user_prompt - -load_dotenv(Path().cwd().parent / ".env") - -# %% -images_dir_path = Path().cwd().parent / "data" / "weather_images" -csv_path = images_dir_path / "figure_metadata.csv" - - -def generate_descriptions_to_text( - orchestrator, - images_dir_path: Path, - model_name: str, - output_filename: str = "figure_descriptions.txt", - delay_seconds: int = 2, - delay: bool = True, - verbose: bool = False, -): - output_path = images_dir_path / output_filename - figure_files = sorted(images_dir_path.glob("figure_*.png")) - - if not figure_files: - print("No figure files found!") - return - - print(f"Found {len(figure_files)} figures to process") - - for i, image_path in enumerate(tqdm(figure_files, desc="Generating descriptions")): - figure_id = image_path.stem.replace("figure_", "") - - try: - image = Image.open(image_path) - - start_time = time.time() - description = orchestrator.run(image=image) - computation_time = time.time() - start_time - - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - with open(output_path, "a", encoding="utf-8") as f: - f.write(f"{'=' * 80}\n") - f.write(f"Figure ID: {figure_id}\n") - f.write(f"Model: {model_name}\n") - f.write(f"Computation Time: {computation_time:.2f} seconds\n") - f.write(f"Generated At: {timestamp}\n") - f.write(f"{'=' * 80}\n\n") - f.write(f"{description}\n\n") - - print(f"✓ Processed {image_path.name} ({computation_time:.2f}s)") - - if verbose: - print(f"\nFigure ID: {figure_id}") - print(f"Description: {description}\n") - - if delay and i < len(figure_files) - 1: - time.sleep(delay_seconds) - - except Exception as e: - print(f"✗ Failed {image_path.name}: {e!s}") - - with open(output_path, "a", encoding="utf-8") as f: - f.write(f"{'=' * 80}\n") - f.write(f"Figure ID: {figure_id}\n") - f.write(f"Model: {model_name}\n") - f.write("Computation Time: 0.00 seconds\n") - f.write( - f"Generated At: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n", - ) - f.write(f"{'=' * 80}\n\n") - f.write(f"ERROR: Failed to generate description\nReason: {e!s}\n\n") - - print(f"\n✅ Results saved to: {output_path}") - - -# %% -llm = create_llm( - provider="gemini", - model_name="gemini-2.5-pro", -) - -evaluator = EvaluatorAgent( - criteria=["coherence", "fluency", "consistency", "relevance"], - llm=llm, -) - -generator = GeneratorAgent( - llm=llm, - user_prompt=get_default_generator_user_prompt(), - system_prompt=None, -) - -orchestrator = Orchestrator( - generator_agent=generator, - evaluator_agent=evaluator, -) - -# %% -generate_descriptions_to_text( - orchestrator=orchestrator, - images_dir_path=images_dir_path, - output_filename="figure_descriptions.txt", - model_name="gemini-2.5-pro", - delay_seconds=2, - delay=True, - verbose=True, -) From fbd11322839e44a109752a7c093f73954f67231f Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 16:20:25 -0400 Subject: [PATCH 131/151] feat: creating demo notebook --- .gitignore | 4 -- notebooks/example.ipynb | 114 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 notebooks/example.ipynb diff --git a/.gitignore b/.gitignore index 85721ae..7836703 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,6 @@ wheels/ # Virtual environments .venv -# Notebooks -.ipynb_checkpoints/ -*.ipynb - # Data weather_images/ diff --git a/notebooks/example.ipynb b/notebooks/example.ipynb new file mode 100644 index 0000000..e0ad883 --- /dev/null +++ b/notebooks/example.ipynb @@ -0,0 +1,114 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cb4f99c1", + "metadata": {}, + "source": [ + "# EarthReach - Weather Chart Description Generation Example " + ] + }, + { + "cell_type": "markdown", + "id": "7918fff6", + "metadata": {}, + "source": [ + "### Introduction\n", + "\n", + "EarthReach is a Python library for generating natural language descriptions of meteorological data visualizations. The library extends **earthkit-plots** by providing automated text generation capabilities for weather charts, enabling programmatic conversion of visual data representations into structured textual descriptions.\n", + "\n", + "The system implements a dual-LLM architecture consisting of a generator agent and an evaluator agent. The generator creates initial descriptions from chart images and associated GRIB file metadata, while the evaluator assesses output quality across multiple criteria including scientific accuracy, coherence, and meteorological relevance. This iterative process continues until quality thresholds are met or maximum iterations are reached.\n", + "\n", + "This notebook demonstrates the workflow for using the EarthReachAgent API to generate descriptions of weather charts.\n", + "\n", + "### Prerequisites\n", + "\n", + "Before running this notebook, ensure you have:\n", + "\n", + "- **Python 3.12+** with all project dependencies installed\n", + "- **[Climate Data Store](https://cds.climate.copernicus.eu/how-to-api) API key** configured for accessing meteorological data\n", + "- **LLM provider API key** set as an environment variable (supports OpenAI, Anthropic, or compatible endpoints)\n", + "\n", + "For more detailed setup instructions, refer to the [installation documentation](../docs/source/installation.rst)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8791358b", + "metadata": {}, + "outputs": [], + "source": [ + "import earthkit.plots as ekp\n", + "import earthkit.data as ekd\n", + "from earth_reach import EarthReachAgent\n", + "from textwrap import fill\n", + "import warnings\n", + "import logging\n", + "\n", + "logging.getLogger('htpx').setLevel(logging.WARNING)\n", + "logging.getLogger('google_genai').setLevel(logging.WARNING)\n", + "\n", + "warnings.filterwarnings('ignore')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d22b505a", + "metadata": {}, + "outputs": [], + "source": [ + "# Load your data with earthkit-data\n", + "request = {\n", + " \"product_type\": [\"reanalysis\"],\n", + " \"variable\": [\"2m_temperature\", \"mean_sea_level_pressure\"],\n", + " \"day\": ['30'],\n", + " \"month\": ['04'],\n", + " \"year\": ['2025'],\n", + " \"time\": ['12:00'],\n", + " \"data_format\": \"grib\",\n", + " \"download_format\": \"unarchived\",\n", + "}\n", + "dataset = \"reanalysis-era5-single-levels\"\n", + "data = ekd.from_source(\"cds\", dataset, request)\n", + "\n", + "# Preprocess the data as you like, but make sure you have these two variables present in the dataset\n", + "sub_data = data.sel(param=[\"2t\", \"msl\"], typeOfLevel=\"surface\")\n", + "\n", + "# Create a weather chart with earthkit-plots\n", + "figure = ekp.quickplot(sub_data, domain='France', units=['celsius', 'hPa'], mode=\"overlay\")\n", + "\n", + "# Generate description\n", + "agent = EarthReachAgent(provider=\"gemini\", model_name='gemini-2.5-pro') \n", + "description = agent.generate_alt_description(figure, sub_data)\n", + "\n", + "# Visualize description\n", + "print('\\nGenerated Description:')\n", + "print('-' * 22)\n", + "print(fill(description, 88))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "EarthReach", + "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.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 5aeef2149787560ed6deac371322abcafcf01a99 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 16:21:17 -0400 Subject: [PATCH 132/151] chore: improving comment phrasing --- notebooks/example.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notebooks/example.ipynb b/notebooks/example.ipynb index e0ad883..6cf220c 100644 --- a/notebooks/example.ipynb +++ b/notebooks/example.ipynb @@ -73,14 +73,14 @@ "dataset = \"reanalysis-era5-single-levels\"\n", "data = ekd.from_source(\"cds\", dataset, request)\n", "\n", - "# Preprocess the data as you like, but make sure you have these two variables present in the dataset\n", + "# Preprocess the data as you like, but make sure these two variables are present in the dataset\n", "sub_data = data.sel(param=[\"2t\", \"msl\"], typeOfLevel=\"surface\")\n", "\n", "# Create a weather chart with earthkit-plots\n", "figure = ekp.quickplot(sub_data, domain='France', units=['celsius', 'hPa'], mode=\"overlay\")\n", "\n", "# Generate description\n", - "agent = EarthReachAgent(provider=\"gemini\", model_name='gemini-2.5-pro') \n", + "agent = EarthReachAgent(provider=\"gemini\", model_name='gemini-2.5-pr') \n", "description = agent.generate_alt_description(figure, sub_data)\n", "\n", "# Visualize description\n", From 3f4ff895d4010aa648c81c776b2d5c4eacb462e6 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 16:23:36 -0400 Subject: [PATCH 133/151] fix: fixing wrong root dir path --- src/earth_reach/core/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/earth_reach/core/utils.py b/src/earth_reach/core/utils.py index 1f6b08c..6efea4a 100644 --- a/src/earth_reach/core/utils.py +++ b/src/earth_reach/core/utils.py @@ -57,4 +57,4 @@ def img_to_bytes(img: ImageFile) -> bytes: def get_root_dir_path() -> Path: """Get the root directory path of the project.""" - return Path(__file__).parent.parent.parent.resolve() + return Path(__file__).parent.parent.parent.parent.resolve() From dce4800c17d3446ab4c589280356391dc75eefa7 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 16:26:38 -0400 Subject: [PATCH 134/151] fix: fixing variable typo --- src/earth_reach/core/orchestrator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py index 58c9dbf..ca3a1ab 100644 --- a/src/earth_reach/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -94,7 +94,7 @@ def run( features = extractor.extract(data) features_str = extractor.format_features_to_str(features) self._add_data_features_to_agent_prompt(features_str, agent="generator") - self._add_data_features_to_agent_prompt(features, agent="evaluator") + self._add_data_features_to_agent_prompt(features_str, agent="evaluator") except Exception: continue From 20e8c8d85fc71b138399cd38157acced671d7cdb Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 16:35:33 -0400 Subject: [PATCH 135/151] fix: verifying data is not none in orchestrator --- src/earth_reach/core/orchestrator.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py index ca3a1ab..387f53f 100644 --- a/src/earth_reach/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -89,14 +89,19 @@ def run( "Only one of 'figure' or 'image' can be provided, not both.", ) - for extractor in self.data_extractors: - try: - features = extractor.extract(data) - features_str = extractor.format_features_to_str(features) - self._add_data_features_to_agent_prompt(features_str, agent="generator") - self._add_data_features_to_agent_prompt(features_str, agent="evaluator") - except Exception: - continue + if data is not None: + for extractor in self.data_extractors: + try: + features = extractor.extract(data) + features_str = extractor.format_features_to_str(features) + self._add_data_features_to_agent_prompt( + features_str, agent="generator" + ) + self._add_data_features_to_agent_prompt( + features_str, agent="evaluator" + ) + except Exception: + continue try: description: str | GeneratorOutput = "" From 71d49e55ee4b325b08d0fcae54fddc218ada75fb Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 16:35:51 -0400 Subject: [PATCH 136/151] doc: clarifying main entrypoint usage --- src/earth_reach/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py index 5ba85be..a4426f6 100644 --- a/src/earth_reach/main.py +++ b/src/earth_reach/main.py @@ -24,10 +24,14 @@ logger = get_logger(__name__) +# TODO(high): update so that generation continues if data is none, but just don't instantiate the data extractors and probably log it class EarthReachAgent: """ Main agent class for generating weather chart descriptions. + This class is meant to be the main entrypoint for the earthkit.plots integration. + To generate descriptions from images, instead of figure and data objects, use the provided CLI. + Provides a high-level interface for the dual-LLM framework, handling data validation, component initialization, and orchestrated generation. """ From 6f924e3a98f284c2958a6f76b9a771dce603e1f4 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 16:43:42 -0400 Subject: [PATCH 137/151] doc: adding pointer to example notebook --- docs/source/usage.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 008976e..cb79d36 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -26,6 +26,8 @@ Basic Usage description = agent.generate_alt_description(figure, data) print(description) +See `notebooks/example.ipynb` for a practical usage example. + Input Requirements ~~~~~~~~~~~~~~~~~~ From 3a39c4ffa8b615c6aaa7788288103e8d69a4f901 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 16:45:44 -0400 Subject: [PATCH 138/151] doc: adding pointer for CDS api key --- docs/source/installation.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/installation.rst b/docs/source/installation.rst index e7e2dad..4a287d0 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -5,6 +5,7 @@ Prerequisites ------------- - `uv `_: Python package and project manager (will automatically install Python 3.12+ if needed) +- `Climate Data Store `_ API key configured for accessing meteorological data - API key for a supported LLM provider (OpenAI, Google Gemini, Anthropic Claude, or Groq) Dependencies From c1f624b713342aba178b9006dd47769f635bfc89 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 17:10:49 -0400 Subject: [PATCH 139/151] doc: adding example of supported charts --- .gitignore | 5 --- docs/.gitkeep | 0 docs/source/_static/example_global_chart.png | Bin 0 -> 450608 bytes .../source/_static/example_regional_chart.png | Bin 0 -> 121318 bytes docs/source/index.rst | 31 ++++++++++++++++-- 5 files changed, 29 insertions(+), 7 deletions(-) delete mode 100644 docs/.gitkeep create mode 100644 docs/source/_static/example_global_chart.png create mode 100644 docs/source/_static/example_regional_chart.png diff --git a/.gitignore b/.gitignore index 7836703..8bb6629 100644 --- a/.gitignore +++ b/.gitignore @@ -18,11 +18,6 @@ weather_images/ # Common *.DS_Store -# Images -*.png -*.jpg -*.jpeg - # Data data/monthly_average.zarr data/*.nc diff --git a/docs/.gitkeep b/docs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/source/_static/example_global_chart.png b/docs/source/_static/example_global_chart.png new file mode 100644 index 0000000000000000000000000000000000000000..2449a838b237605efa9bf1728094a5b5ff7451c3 GIT binary patch literal 450608 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

+ + Python 3.12 + License: Apache 2.0 @@ -20,7 +23,9 @@ EarthReach is a challenge from the 2025 edition dedicated to enhancing the acces ### Prerequisites -- [uv](https://docs.astral.sh/uv/) - Python package and project manager (will automatically install Python 3.12+ if needed) +- [uv](https://docs.astral.sh/uv/): Python package and project manager (will automatically install Python 3.12+ if needed) +- [Climate Data Store ](https://cds.climate.copernicus.eu/how-to-api): API key configured for accessing meteorological data +- API key for a supported LLM provider (OpenAI, Google Gemini, Anthropic Claude, Groq, or any OpenAI-compatible API provider) ### Setup @@ -70,9 +75,30 @@ To run this project, you will need to have an openAI-compatible LLM inference se We provide instructions on how to setup your own secured inference server using [VLLM](./vllm/setup.md). -## Usage +## Basic Usage + +```python +from earth_reach import EarthReachAgent +import earthkit.plots as ekp +import earthkit.data as ekd + +# Load your data with earthkit-data +data = ekd.from_source("file", "your_data.grib") + +# Create a weather chart with earthkit-plots +figure = ekp.quickplot(data, mode="overlay") + +# Generate description +agent = EarthReachAgent(provider="openai") +description = agent.generate_alt_description(figure, data) +print(description) +``` + +See `notebooks/example.ipynb` for a practical usage example. + +## CLI Interface -The project provides a command-line interface (CLI) accessible through `earth-reach-agent` or its shorter alias `era`. +EarthReach includes a standalone CLI that works on image files only, producing less detailed descriptions than the full library integration. ### Available Commands From edae6222b58188f17afffe66b32d09d12d75f978 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 17:28:24 -0400 Subject: [PATCH 141/151] doc: updating readme vllm instructions --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 039e84a..627ee04 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,6 @@ You're now ready to use the project. └── uv.lock # Locked dependency versions ``` -## VLLM Inference Server - -To run this project, you will need to have an openAI-compatible LLM inference server. - -We provide instructions on how to setup your own secured inference server using [VLLM](./vllm/setup.md). - ## Basic Usage ```python @@ -121,6 +115,11 @@ Evaluate the accuracy of a description against a weather chart: ```sh uv run era evaluate --image-path --description "" ``` +## VLLM Inference Server + +EarthReach supports any OpenAI-compatible API endpoint for self-hosted LLMs. See `vllm/` directory for a VLLM setup example. + +**Warning**: Self-hosting requires advanced system administration skills and significant GPU resources. Recommended only for experienced users. ## Development From 94e644f6b0d66c370301b2366beecf6f0b2f4942 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 17:29:26 -0400 Subject: [PATCH 142/151] chore: adding todo --- src/earth_reach/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py index a4426f6..e2181de 100644 --- a/src/earth_reach/main.py +++ b/src/earth_reach/main.py @@ -25,6 +25,7 @@ # TODO(high): update so that generation continues if data is none, but just don't instantiate the data extractors and probably log it +# TODO(high): add parameter to use or not data extractors class EarthReachAgent: """ Main agent class for generating weather chart descriptions. From c7d6c5d88d1fa681428015d1d5d345dcb54fea36 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 17:49:16 -0400 Subject: [PATCH 143/151] doc: improving readme header --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 627ee04..f18fd74 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,11 @@ ## EarthReach -EarthReach is a challenge from the 2025 edition dedicated to enhancing the accessibility of meteorological data visualisations produced by Earthkit, by equipping the plots module with LLM-powered alternative text generation capabilities. +EarthReach is a Python library for generating natural language descriptions of meteorological data visualizations. The library extends [earthkit-plots](https://github.com/ecmwf/earthkit-plots) by providing automated text generation capabilities for weather charts, enabling programmatic conversion of visual data representations into structured textual descriptions. + +The system implements a dual-LLM architecture consisting of a generator agent and an evaluator agent. The generator creates initial descriptions from chart images and associated GRIB file metadata, while the evaluator assesses output quality across multiple criteria including scientific accuracy, coherence, and meteorological relevance. This iterative process continues until quality thresholds are met or maximum iterations are reached. + +![Global temperature and mean sea level map](./docs/source/_static/example_global_chart.png) ## Installation From a50e7ee5a72e9408e8ec52c889fa70c4a62273ee Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 17:50:40 -0400 Subject: [PATCH 144/151] doc: removing subsections --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index f18fd74..a8ba3f4 100644 --- a/README.md +++ b/README.md @@ -105,15 +105,11 @@ View all commands and options: uv run era --help ``` -### Generate weather chart descriptions - Generate a natural language description from a weather chart image: ```sh uv run era generate --image-path ``` -### Evaluate descriptions - Evaluate the accuracy of a description against a weather chart: ```sh From 2605fac53375dc0868e17cf2eb64d2f6d4fb8eeb Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 19 Aug 2025 20:36:38 -0400 Subject: [PATCH 145/151] ci: implementing code quality analysis CI --- .github/workflows/ci.yaml | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..092c452 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [ main, develop ] + paths-ignore: + - '**.md' + - 'docs/**' + - 'README*' + - '.gitignore' + pull_request: + branches: [ main, develop ] + paths-ignore: + - '**.md' + - 'docs/**' + - 'README*' + - '.gitignore' + workflow_dispatch: + +jobs: + code-analysis: + name: Code Quality Analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "latest" + + - name: Install Python + run: uv python install + + - name: Install dependencies + run: uv sync --locked --all-extras --dev + + - name: Check formatting + run: uv run ruff format --check . + + - name: Check linting + run: uv run ruff check . From b804e41a2d91a8820ddf3c4bc62eac484cf9319c Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Wed, 20 Aug 2025 09:31:31 -0400 Subject: [PATCH 146/151] fix: fixing typo in model name --- notebooks/example.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks/example.ipynb b/notebooks/example.ipynb index 6cf220c..74a38e6 100644 --- a/notebooks/example.ipynb +++ b/notebooks/example.ipynb @@ -80,7 +80,7 @@ "figure = ekp.quickplot(sub_data, domain='France', units=['celsius', 'hPa'], mode=\"overlay\")\n", "\n", "# Generate description\n", - "agent = EarthReachAgent(provider=\"gemini\", model_name='gemini-2.5-pr') \n", + "agent = EarthReachAgent(provider=\"gemini\", model_name='gemini-2.5-pro') \n", "description = agent.generate_alt_description(figure, sub_data)\n", "\n", "# Visualize description\n", From 43c66e7c67e7e470fbaf9bab64bbbed7b7d5c70d Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Tue, 26 Aug 2025 10:51:58 -0400 Subject: [PATCH 147/151] feat: improving logging coverage at main decision points --- src/earth_reach/cli.py | 62 +++++++++++++------------ src/earth_reach/core/evaluator.py | 11 ++++- src/earth_reach/core/generator.py | 24 +++++++++- src/earth_reach/core/llm.py | 68 ++++++++++++++++++++++++++-- src/earth_reach/core/orchestrator.py | 29 +++++++++++- src/earth_reach/main.py | 15 ++++-- 6 files changed, 170 insertions(+), 39 deletions(-) diff --git a/src/earth_reach/cli.py b/src/earth_reach/cli.py index 351513e..acb8e3c 100644 --- a/src/earth_reach/cli.py +++ b/src/earth_reach/cli.py @@ -210,15 +210,11 @@ def generate( ValueError: If arguments are invalid or conflicting RuntimeError: If description generation fails """ + logger.info("Starting description generation...") try: - if verbose: - logger.info(f"Validating image: {image_path}") validated_image_path = validate_image_path(image_path) image = Image.open(validated_image_path) - if verbose: - logger.info("Resolving prompts...") - system_prompt_text = resolve_prompt( system_prompt, system_prompt_file_path, @@ -237,16 +233,23 @@ def generate( if verbose: if system_prompt_text: logger.info( - f"System prompt length: {len(system_prompt_text)} characters", + "System prompt length: %d characters", len(system_prompt_text) ) if user_prompt_text: logger.info( - f"User prompt length: {len(user_prompt_text)} characters", + "User prompt length: %d characters", len(user_prompt_text) ) - if verbose: - logger.info("Initializing LLM...") + logger.debug( + "CLI configuration for generation", + extra={ + "provider": os.getenv("LLM_PROVIDER", "groq"), + "simple_mode": simple, + "max_iterations": max_iterations, + "criteria_threshold": criteria_threshold, + }, + ) llm = create_llm() if verbose: @@ -278,7 +281,7 @@ def generate( ) if verbose: - logger.info(f"Generating description for: {validated_image_path.name}") + logger.info("Generating description for: %s", validated_image_path.name) if simple: description = generator.generate( @@ -290,7 +293,7 @@ def generate( if verbose and isinstance(description, str): logger.info("Description generated successfully!") - logger.info(f"Description length: {len(description)} characters") + logger.info("Description length: %d characters", len(description)) logger.info("-" * 50) print(description) @@ -298,13 +301,13 @@ def generate( return except (OSError, FileNotFoundError, ValueError) as e: - logger.error(f"Could not load image file: {e}", exc_info=True) + logger.error("Could not load image file: %s", e, exc_info=True) sys.exit(1) except RuntimeError as e: - logger.error(f"Generation failed: {e}", exc_info=True) + logger.error("Generation failed: %s", e, exc_info=True) sys.exit(1) except Exception as e: - logger.error(f"Unexpected error: {e}", exc_info=True) + logger.error("Unexpected error: %s", e, exc_info=True) sys.exit(1) @staticmethod @@ -333,19 +336,14 @@ def evaluate( ValueError: If arguments are invalid or conflicting RuntimeError: If evaluation fails """ + logger.info("Starting description evaluation...") + if criteria is None: criteria = ["coherence", "fluency", "consistency", "relevance"] - try: - if verbose: - logger.info(f"Validating image: {image_path}") - validated_image_path = validate_image_path(image_path) image = Image.open(validated_image_path) - if verbose: - logger.info("Resolving description...") - description_text = resolve_description( description, description_file_path, @@ -356,7 +354,7 @@ def evaluate( ) if verbose: - logger.info(f"Description length: {len(description_text)} characters") + logger.info("Description length: %d characters", len(description_text)) if not criteria or len(criteria) == 0: raise ValueError("Criteria list cannot be empty.") @@ -369,10 +367,18 @@ def evaluate( ) if verbose: - logger.info(f"Evaluation criteria: {', '.join(criteria)}") + logger.info("Evaluation criteria: %s", ", ".join(criteria)) if verbose: logger.info("Initializing LLM...") + + logger.debug( + "CLI configuration for evaluation", + extra={ + "provider": os.getenv("LLM_PROVIDER", "groq"), + "criteria": criteria, + }, + ) llm = create_llm() if verbose: @@ -383,7 +389,7 @@ def evaluate( ) if verbose: - logger.info(f"Evaluating description for: {validated_image_path.name}") + logger.info("Evaluating description for: %s", validated_image_path.name) evaluation = evaluator.evaluate( description=description_text, image=image, @@ -391,7 +397,7 @@ def evaluate( if verbose: logger.info("Evaluation completed successfully!") - logger.info(f"Number of criteria evaluated: {len(evaluation)}") + logger.info("Number of criteria evaluated: %d", len(evaluation)) logger.info("-" * 50) for eval in evaluation: @@ -404,13 +410,13 @@ def evaluate( return except FileNotFoundError as e: - logger.error(f"File not found: {e}", exc_info=True) + logger.error("File not found: %s", e, exc_info=True) sys.exit(1) except ValueError as e: - logger.error(f"Invalid input: {e}", exc_info=True) + logger.error("Invalid input: %s", e, exc_info=True) sys.exit(1) except Exception as e: - logger.error(f"Evaluation failed: {e}", exc_info=True) + logger.error("Evaluation failed: %s", e, exc_info=True) sys.exit(1) diff --git a/src/earth_reach/core/evaluator.py b/src/earth_reach/core/evaluator.py index f6c9cc2..b744dee 100644 --- a/src/earth_reach/core/evaluator.py +++ b/src/earth_reach/core/evaluator.py @@ -83,7 +83,6 @@ def evaluate( "Only one of 'figure' or 'image' can be provided, not both.", ) if figure is not None: - # TODO(medium): If metadata extraction fails, continue without it metadata = self._get_metadata_from_figure(figure) self.user_prompt = self._update_user_prompt_with_metadata( self.user_prompt, @@ -409,13 +408,23 @@ def evaluate( try: evaluations = [] for evaluator in self.evaluators: + logger.debug("Evaluating criterion: %s", evaluator.criterion) result = evaluator.evaluate( description=description, figure=figure, image=image, ) + logger.debug( + "Criterion evaluation completed", + extra={ + "criterion": result.name, + "score": result.score, + "max_score": 5, + }, + ) evaluations.append(result) + logger.info("Evaluator successfully evaluated the description") return evaluations except Exception as e: raise RuntimeError(f"Failed to evaluate description: {e}") from e diff --git a/src/earth_reach/core/generator.py b/src/earth_reach/core/generator.py index f416de1..3622760 100644 --- a/src/earth_reach/core/generator.py +++ b/src/earth_reach/core/generator.py @@ -15,8 +15,11 @@ from PIL import Image from PIL.ImageFile import ImageFile +from earth_reach.config.logging import get_logger from earth_reach.core.llm import LLMInterface +logger = get_logger(__name__) + @dataclass class FigureMetadata: @@ -164,7 +167,6 @@ def generate( "Only one of 'figure' or 'image' can be provided, not both.", ) if figure is not None: - # TODO(medium): If metadata extraction fails at any point, generate image and continue without figure metadata metadata = self._get_metadata_from_figure(figure) self.user_prompt = self._update_user_prompt_with_metadata( self.user_prompt, @@ -183,8 +185,16 @@ def generate( image=image, ) + logger.debug("Parsing LLM response for structured output") parsed_output = self.parse_llm_response(response) if not parsed_output.is_complete(): + logger.warning( + "LLM response parsing incomplete", + extra={ + "missing_fields": parsed_output.get_missing_fields(), + "total_fields": len(list(fields(parsed_output))), + }, + ) raise ValueError( "Parsed output is incomplete. Missing fields: " f"{parsed_output.get_missing_fields()}", @@ -200,6 +210,7 @@ def generate( except Exception as e: raise RuntimeError(f"Failed to generate response: {e}") from e + logger.info("Generator successfully generated a description") return description def parse_llm_response(self, response: str) -> GeneratorOutput: @@ -261,6 +272,17 @@ def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata: metadata.xlabel = axes[0].get_xlabel() metadata.ylabel = axes[0].get_ylabel() metadata.domain = figure._domain + + logger.debug( + "Extracted metadata from figure", + extra={ + "title": metadata.title or "None", + "domain": metadata.domain or "None", + "xlabel": metadata.xlabel or "None", + "ylabel": metadata.ylabel or "None", + }, + ) + return metadata def _update_user_prompt_with_metadata( diff --git a/src/earth_reach/core/llm.py b/src/earth_reach/core/llm.py index 2472fdd..559120b 100644 --- a/src/earth_reach/core/llm.py +++ b/src/earth_reach/core/llm.py @@ -16,12 +16,20 @@ from google.genai import types from PIL.ImageFile import ImageFile +from earth_reach.config.logging import get_logger from earth_reach.core.utils import img_to_base64, img_to_bytes +logger = get_logger(__name__) + class LLMInterface(ABC): """Abstract base class defining the interface for all LLM provider implementations.""" + @property + @abstractmethod + def provider_name(self) -> str: + """Must be implemented by subclasses to return the provider name.""" + @abstractmethod def generate( self, @@ -73,6 +81,10 @@ def __init__( api_key=api_key, ) + @property + def provider_name(self): + return "openAICompatible" + def generate( self, user_prompt: str, @@ -139,13 +151,31 @@ def generate( "The generated response content is empty or not a string" ) + logger.info( + "LLM API call completed successfully", + extra={ + "provider": self.provider_name, + "model": self.model_name, + "input_length": len(user_prompt), + "output_length": len(content), + "has_image": image is not None, + }, + ) + return content.strip() except ValueError: raise except Exception as e: - error_msg = f"API call failed: {type(e).__name__}: {e}" - raise RuntimeError(error_msg) from e + logger.error( + "LLM API call failed", + extra={ + "provider": self.provider_name, + "model": self.model_name, + }, + exc_info=True, + ) + raise RuntimeError("LLM API call failed") from e def __repr__(self) -> str: return f"LLM(model_name={self.model_name}, base_url={self.base_url})" @@ -180,6 +210,10 @@ def __init__(self, model_name: str, api_key: str | None = None) -> None: base_url="https://api.groq.com/openai/v1", ) + @property + def provider_name(self): + return "groq" + class OpenAILLM(OpenAICompatibleLLM): """Implementation of the LLMInterface for OpenAI API Provider.""" @@ -210,6 +244,10 @@ def __init__(self, model_name: str, api_key: str | None = None) -> None: base_url="https://api.openai.com/v1", ) + @property + def provider_name(self): + return "openAI" + class GeminiLLM(LLMInterface): """Implementation of the LLMInterface for Google Gemini API Provider.""" @@ -236,6 +274,10 @@ def __init__(self, model_name: str, api_key: str | None = None) -> None: self.api_key = api_key self.client = genai.Client(api_key=api_key) + @property + def provider_name(self): + return "gemini" + def generate( self, user_prompt: str, @@ -296,13 +338,31 @@ def generate( "The generated response content is empty or not a string" ) + logger.info( + "LLM API call completed successfully", + extra={ + "provider": self.provider_name, + "model": self.model_name, + "input_length": len(user_prompt), + "output_length": len(content), + "has_image": image is not None, + }, + ) + return content.strip() except ValueError: raise except Exception as e: - error_msg = f"API call failed: {type(e).__name__}: {e}" - raise RuntimeError(error_msg) from e + logger.error( + "LLM API call failed", + extra={ + "provider": self.provider_name, + "model": self.model_name, + }, + exc_info=True, + ) + raise RuntimeError("LLM API call failed") from e def __repr__(self) -> str: return f"GeminiLLM(model_name={self.model_name})" diff --git a/src/earth_reach/core/orchestrator.py b/src/earth_reach/core/orchestrator.py index 387f53f..5936f39 100644 --- a/src/earth_reach/core/orchestrator.py +++ b/src/earth_reach/core/orchestrator.py @@ -89,7 +89,8 @@ def run( "Only one of 'figure' or 'image' can be provided, not both.", ) - if data is not None: + if data is not None and self.data_extractors: + logger.info("Enriching prompts with data extractors...") for extractor in self.data_extractors: try: features = extractor.extract(data) @@ -107,6 +108,9 @@ def run( description: str | GeneratorOutput = "" evaluation: list[CriterionEvaluatorOutput] = [] for i in range(self.max_iterations): + logger.info( + "Starting orchestration iteration %d/%d", i + 1, self.max_iterations + ) description = self.generator_agent.generate( figure=figure, image=image, @@ -127,8 +131,31 @@ def run( ) if self._verify_evaluation_passes(evaluation): + logger.info( + "All criteria met, orchestration completed successfully", + extra={ + "description_length": len(description), + "iteration": i + 1, + "final_scores": { + eval_result.name: eval_result.score + for eval_result in evaluation + }, + }, + ) return description + unmet_criteria = [ + c for c in evaluation if c.score < self.criteria_threshold + ] + logger.debug( + "Providing feedback for iteration %d", + i + 1, + extra={ + "iteration": i + 1, + "unmet_criteria": [c.name for c in unmet_criteria], + "unmet_scores": {c.name: c.score for c in unmet_criteria}, + }, + ) self._provide_feedback_to_generator(i + 1, description, evaluation) logger.info( diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py index e2181de..b4e0a54 100644 --- a/src/earth_reach/main.py +++ b/src/earth_reach/main.py @@ -113,7 +113,7 @@ def _create_data_extractors(self, data: ekd.FieldList) -> list[BaseDataExtractor Raises: RuntimeError: If extractor creation fails """ - logger.info("Creating data extractors...") + logger.debug("Creating data extractors...") extractors = [] available_vars = set() @@ -183,13 +183,16 @@ def _setup_components( except Exception as e: raise RuntimeError(f"Failed to setup components: {e}") from e - def generate_alt_description(self, figure: ekp.Figure, data: ekd.FieldList) -> str: + def generate_alt_description( + self, figure: ekp.Figure, data: ekd.FieldList, use_extractors: bool = False + ) -> str: """ Generate alternative text description for a weather chart. Args: figure: earthkit.plots Figure object containing the weather chart data: earthkit.data FieldList containing GRIB meteorological data + use_extractors: Whether to use data extractors to enrich prompt or not. Default to False Returns: String description of the weather chart @@ -200,11 +203,15 @@ def generate_alt_description(self, figure: ekp.Figure, data: ekd.FieldList) -> s RuntimeError: If description generation fails """ try: - logger.info("Starting weather chart description generation") + logger.info("Starting description generation...") self._validate_inputs(figure, data) - data_extractors = self._create_data_extractors(data) + if use_extractors: + logger.info("Creating data extractors to enrich prompts") + data_extractors = self._create_data_extractors(data) + else: + data_extractors = [] orchestrator = self._setup_components(data_extractors) From e114a55fc0c82d26d39119373e44215f02ee3dc9 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 31 Aug 2025 21:50:28 +0200 Subject: [PATCH 148/151] fix: removing todo items --- src/earth_reach/main.py | 2 -- vllm/setup.md | 3 --- vllm/setup.sh | 2 -- 3 files changed, 7 deletions(-) diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py index b4e0a54..d2cd440 100644 --- a/src/earth_reach/main.py +++ b/src/earth_reach/main.py @@ -24,8 +24,6 @@ logger = get_logger(__name__) -# TODO(high): update so that generation continues if data is none, but just don't instantiate the data extractors and probably log it -# TODO(high): add parameter to use or not data extractors class EarthReachAgent: """ Main agent class for generating weather chart descriptions. diff --git a/vllm/setup.md b/vllm/setup.md index 41e9dd4..e206f73 100644 --- a/vllm/setup.md +++ b/vllm/setup.md @@ -32,8 +32,6 @@ sudo dnf upgrade Let's allow HTTP, HTTPS connections and deny the other types by default. -TODO(high): update to only accept requests from cloudflare servers for requests other than SSH - ```sh # Install firewalld sudo dnf install firewalld @@ -94,7 +92,6 @@ sudo dnf install -y docker-ce \ sudo systemctl enable --now docker ``` -TODO(medium): replace caddy configuration with traefik configuration instructions ### Caddy ```sh diff --git a/vllm/setup.sh b/vllm/setup.sh index 255620b..521c8ac 100644 --- a/vllm/setup.sh +++ b/vllm/setup.sh @@ -11,7 +11,6 @@ sudo systemctl start firewalld sudo systemctl enable firewalld # Add SSH, HTTP and HTTPS services -# TODO(high): update to only accept requests from cloudflare servers for requests other than SSH sudo firewall-cmd --permanent --add-service=ssh sudo firewall-cmd --permanent --add-service=http sudo firewall-cmd --permanent --add-service=https @@ -45,7 +44,6 @@ sudo nvidia-ctk runtime configure --runtime=docker # Restart docker sudo systemctl docker restart -# TODO(medium): replace caddy automated configuration with traefik # Create necessary Caddy directories mkdir -p $CADDY_DATA_DIR $CADDY_CONFIG_DIR From 32af211d79ee27235b5cd8ca6ec49214624cbb87 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 31 Aug 2025 21:57:54 +0200 Subject: [PATCH 149/151] feat: updating instructions and setup script with traefik specific information --- vllm/.env.example | 1 + vllm/setup.md | 41 ++++++++++++++++++++++++++++++++++------- vllm/setup.sh | 11 +++++++---- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/vllm/.env.example b/vllm/.env.example index 8731f4f..e7a6846 100644 --- a/vllm/.env.example +++ b/vllm/.env.example @@ -10,6 +10,7 @@ HF_CACHE_DIR=/dev/shm/hf-cache # VLLM configuration MODEL_NAME="google/gemma-3-4b-it" +MODEL_DIR_PATH= HF_HUB_TOKEN= VLLM_SERVER_API_KEY= VLLM_PORT=8000 diff --git a/vllm/setup.md b/vllm/setup.md index e206f73..d9838a5 100644 --- a/vllm/setup.md +++ b/vllm/setup.md @@ -4,13 +4,26 @@ To setup this private LLM inference server, you will need: - To have access to a linux machine - Docker and docker compose installed -- A domain name pointing towards your linux macine's public IP address +- A domain name pointing towards your linux machine's public IP address (configured with Cloudflare for DNS and SSL) +- A Cloudflare account with API access for DNS challenges ## Installation We provide a helper script `setup.sh` to facilitate the configuration. Please review it before using it. -Please set the right values for the environment variables in a `vllm/.env` file (see `.env.example`). +Please set the right values for the environment variables in a `vllm/.env` file (see `.env.example`): + +- `DOMAIN`: Your domain name (e.g., api.yourdomain.com) +- `CF_DNS_TOKEN`: Cloudflare API token with DNS permissions +- `CF_ACME_EMAIL`: Email address for Let's Encrypt certificates +- `CF_IPS`: Cloudflare IP ranges for proxy protocol +- `TRAEFIK_ROOT_DIR`: Directory for Traefik configuration and certificates +- `HF_CACHE_DIR`: Directory for Hugging Face model cache +- `MODEL_NAME`: The model to serve (e.g., "google/gemma-3-4b-it") +- `MODEL_DIR_PATH`: Full path to the downloaded model directory +- `HF_HUB_TOKEN`: Hugging Face Hub token for model access +- `VLLM_SERVER_API_KEY`: API key for accessing the VLLM server +- `VLLM_PORT`: Port for the VLLM server (default: 8000) ```sh chmod +x ./setup.sh @@ -92,16 +105,27 @@ sudo dnf install -y docker-ce \ sudo systemctl enable --now docker ``` -### Caddy +### Traefik ```sh -# Create necessary Caddy directories -mkdir -p $CADDY_DATA_DIR $CADDY_CONFIG_DIR +# Create necessary Traefik directories +mkdir -p $TRAEFIK_ROOT_DIR/traefik/cert -# Copy the Caddyfile to the config directory -cp Caddyfile $CADDY_FILE_PATH +# Set proper permissions for the certificate storage +chmod 600 $TRAEFIK_ROOT_DIR/traefik/cert ``` +### Cloudflare Configuration + +This setup uses Cloudflare for DNS management and SSL certificate provisioning via DNS challenges. You'll need: + +1. **Domain Configuration**: Your domain must be managed by Cloudflare +2. **API Token**: Create a Cloudflare API token with the following permissions: + - Zone:Zone:Read + - Zone:DNS:Edit + - Zone:Zone Settings:Read +3. **Trusted IPs**: Configure Cloudflare's IP ranges for proxy protocol (set in CF_IPS environment variable) + ## Usage Try requesting the server with: @@ -109,6 +133,7 @@ Try requesting the server with: ```sh curl https://$DOMAIN/v1/completions \ -H "Content-Type: application/json" \ + -H "Authorization: Bearer $VLLM_SERVER_API_KEY" \ -d '{ "model": "$MODEL_NAME", "prompt": "What's the weather like in Bologna ?", @@ -116,3 +141,5 @@ curl https://$DOMAIN/v1/completions \ "temperature": 0.1 }' ``` + +Note: Make sure to enable the Traefik labels in your docker-compose.yaml by uncommenting the labels section for the vllm-server service before running `docker compose up -d`. diff --git a/vllm/setup.sh b/vllm/setup.sh index 521c8ac..ece6d9b 100644 --- a/vllm/setup.sh +++ b/vllm/setup.sh @@ -44,11 +44,11 @@ sudo nvidia-ctk runtime configure --runtime=docker # Restart docker sudo systemctl docker restart -# Create necessary Caddy directories -mkdir -p $CADDY_DATA_DIR $CADDY_CONFIG_DIR +# Create necessary Traefik directories +mkdir -p $TRAEFIK_ROOT_DIR/traefik/cert -# Copy the Caddyfile to the config directory -cp ./Caddyfile $CADDY_FILE_PATH +# Set proper permissions for the certificate storage +chmod 600 $TRAEFIK_ROOT_DIR/traefik/cert # Install uv python manager curl -LsSf https://astral.sh/uv/install.sh | sh @@ -60,4 +60,7 @@ mkdir -p $HF_CACHE_DIR uv run --with "huggingface_hub[cli]" huggingface-cli login --token $HF_HUB_TOKEN uv run --with "huggingface_hub[cli]" huggingface-cli download $MODEL_NAME --cache-dir=$HF_CACHE_DIR +# Set MODEL_DIR_PATH environment variable to point to the downloaded model +export MODEL_DIR_PATH="$HF_CACHE_DIR/models--$(echo $MODEL_NAME | tr '/' '-')/snapshots/$(ls $HF_CACHE_DIR/models--$(echo $MODEL_NAME | tr '/' '-')/snapshots/ | head -1)" + echo "Setup complete! You can now run 'sudo docker compose up -d'" From cf0008c16a259120b526a014f564ae67f2bb74ae Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Sun, 31 Aug 2025 22:12:32 +0200 Subject: [PATCH 150/151] refact: removing temperature extractor from project --- docs/source/api.rst | 7 - src/earth_reach/core/extractors/__init__.py | 6 +- .../core/extractors/temperature_extractor.py | 138 ------------------ src/earth_reach/main.py | 8 - 4 files changed, 5 insertions(+), 154 deletions(-) delete mode 100644 src/earth_reach/core/extractors/temperature_extractor.py diff --git a/docs/source/api.rst b/docs/source/api.rst index 13fa4f5..5112ad9 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -53,13 +53,6 @@ Base Extractor :members: :show-inheritance: -Temperature Extractor -~~~~~~~~~~~~~~~~~~~~~ - -.. automodule:: earth_reach.core.extractors.temperature_extractor - :members: - :show-inheritance: - Pressure Extractor ~~~~~~~~~~~~~~~~~~ diff --git a/src/earth_reach/core/extractors/__init__.py b/src/earth_reach/core/extractors/__init__.py index 9fb17bf..d9de6d0 100644 --- a/src/earth_reach/core/extractors/__init__.py +++ b/src/earth_reach/core/extractors/__init__.py @@ -2,5 +2,9 @@ Data extractors package. Contains modules for extracting meteorological information from GRIB files, -including temperature and pressure data extractors that enhance prompt generation. +including pressure data extractors that enhance prompt generation. + +Data extractors are optional experimental components that can be created and +integrated with the system to improve the relevance and consistency of the +descriptions, but they are not mandatory to the system's core functionality. """ diff --git a/src/earth_reach/core/extractors/temperature_extractor.py b/src/earth_reach/core/extractors/temperature_extractor.py deleted file mode 100644 index 281aea2..0000000 --- a/src/earth_reach/core/extractors/temperature_extractor.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -Temperature Data Extractor module. - -This module provides functionality to extract temperature data -from meteorological datasets, specifically 2-meter temperature fields in GRIB format. -It includes data validation and formatting for prompt updates. -""" - -from dataclasses import dataclass -from typing import Any - -import earthkit.data as ekd -import numpy as np -import xarray as xr - -from earth_reach.config.logging import get_logger -from earth_reach.core.extractors.base_extractor import BaseDataExtractor -from earth_reach.core.utils import get_root_dir_path - -logger = get_logger(__name__) - - -@dataclass -class TemperatureData: - """Data structure for temperature data.""" - - domain: str - month: str - temperature_C: float - average_temperature_C: float - latitude: float - longitude: float - grid_indices: tuple[int, int] - - def to_dict(self) -> dict[str, Any]: - """Convert to dictionary for serialization.""" - return { - "domain": self.domain, - "month": self.month, - "temperature_C": self.temperature_C, - "average_temperature_C": self.average_temperature_C, - "latitude": self.latitude, - "longitude": self.longitude, - "grid_indices": self.grid_indices, - } - - -class TemperatureDataExtractor(BaseDataExtractor): - """Concrete implementation for extracting temperature data from GRIB data.""" - - def __init__(self, temperature_var_name: str = "2t"): - """ - Initialize the temperature data extractor. - - Args: - temperature_var_name (str): Name of the temperature variable in GRIB data. - """ - self.temperature_var_name = temperature_var_name - try: - self.average_temp_data = xr.open_dataset( - get_root_dir_path() / "data" / "average_monthly_regional_temperature.nc" - ) - except Exception as e: - logger.error("Could not load cached average temperature data file: %s", e) - raise - - def validate_data(self, data: ekd.FieldList) -> ekd.FieldList: - """ - Parse, validate and return GRIB data. - - Args: - data (ekd.FieldList): Input data to validate - - Returns: - ekd.FieldList: Parsed and validated data - - Raises: - ValueError: If validation fails - """ - try: - available_vars = [str(var) for var in data.metadata("shortName")] - if self.temperature_var_name not in available_vars: - raise ValueError( - f"Required variable '{self.temperature_var_name}' not found. " - f"Available variables: {', '.join(available_vars)}", - ) - - data_field = data.sel(shortName=self.temperature_var_name)[0] - if not isinstance(data_field, ekd.Field): - raise ValueError( - "Could not extract a valid temperature field from data.", - ) - data_arr = data_field.to_numpy() - if data_arr is None or not isinstance(data_arr, np.ndarray): - raise ValueError("Data array is empty or not a valid numpy array.") - - latlons = data_field.to_latlon() - lats = latlons["lat"] - lons = latlons["lon"] - - if lats is None or lons is None: - raise ValueError("Could not extract latitude/longitude coordinates") - - return data_arr, lats, lons - except ValueError as e: - logger.error("Temperature data extractor validation failed: %s", e) - raise - - def extract(self, data: ekd.FieldList, **kwargs: Any) -> list[TemperatureData]: - """ - Extract temperature features from the input data. - - Args: - data (ekd.FieldList): Input data - **kwargs: Additional extraction parameters - - Returns: - List[TemperatureData]: List of extracted temperature features - """ - temperature_data = [] - try: - temperature_arr, lats, lons = self.validate_data(data) - # 0. Possible to split in sub-regions ? Should extract regions around hotest / coldest points ? - # 1. Get domain and month from kwargs - # 2. If domain or month is not provided, try to infer from field - # 3. Convert domain to longitude and latitude bounds - # 4. Compute average temperature for the domain and month on field data - # 5. Compare with average temperature from precomputed data, assume normal distribution - # to find anomalies according to std deviation or z-score - # 6. Build TemperatureData object - return temperature_data - except IndexError as e: - raise ValueError( - f"Temperature variable '{self.temperature_var_name}' not found in data" - ) from e - - def format_features_to_str(self, features: list[TemperatureData]) -> str: - """Format extracted temperature features into a prompt-friendly string.""" diff --git a/src/earth_reach/main.py b/src/earth_reach/main.py index d2cd440..da127cd 100644 --- a/src/earth_reach/main.py +++ b/src/earth_reach/main.py @@ -15,7 +15,6 @@ from earth_reach.core.evaluator import EvaluatorAgent from earth_reach.core.extractors.base_extractor import BaseDataExtractor from earth_reach.core.extractors.pressure_extractor import PressureCenterDataExtractor -from earth_reach.core.extractors.temperature_extractor import TemperatureDataExtractor from earth_reach.core.generator import GeneratorAgent from earth_reach.core.llm import create_llm from earth_reach.core.orchestrator import Orchestrator @@ -120,13 +119,6 @@ def _create_data_extractors(self, data: ekd.FieldList) -> list[BaseDataExtractor if param: available_vars.add(param) - if "2t" in available_vars: - try: - extractors.append(TemperatureDataExtractor()) - logger.debug("Temperature data extractor created") - except Exception as e: - logger.debug("Could not create temperature data extractor: %s", e) - if "msl" in available_vars: try: extractors.append(PressureCenterDataExtractor()) From bef0daf9617f98443ba4cc39d113915ef099f377 Mon Sep 17 00:00:00 2001 From: Romain Bazin Date: Mon, 1 Sep 2025 08:08:37 +0200 Subject: [PATCH 151/151] Revert "Merge branch 'main' into develop" This reverts commit 0f3647b19c013e58748d247842e5de09ac7ebeab, reversing changes made to cf0008c16a259120b526a014f564ae67f2bb74ae. --- .gitignore | 4 + README.md | 74 --- docs/.gitkeep | 0 notebooks/earthkit_plots_exploration.py | 582 ------------------ src/earth_reach_agent/__init__.py | 0 src/earth_reach_agent/cli.py | 421 ------------- src/earth_reach_agent/config/__init__.py | 0 src/earth_reach_agent/config/criteria.py | 13 - src/earth_reach_agent/core/__init__.py | 0 src/earth_reach_agent/core/evaluator.py | 416 ------------- src/earth_reach_agent/core/generator.py | 290 --------- src/earth_reach_agent/core/llm.py | 194 ------ src/earth_reach_agent/core/orchestrator.py | 163 ----- .../core/prompts/__init__.py | 0 .../core/prompts/evaluator.py | 567 ----------------- .../core/prompts/generator.py | 173 ------ .../core/prompts/orchestrator.py | 28 - src/earth_reach_agent/core/utils.py | 25 - vllm/docker-compose.yaml | 1 + vllm/setup.sh | 1 - vllm/test.sh | 2 +- 21 files changed, 6 insertions(+), 2948 deletions(-) delete mode 100644 docs/.gitkeep delete mode 100644 notebooks/earthkit_plots_exploration.py delete mode 100644 src/earth_reach_agent/__init__.py delete mode 100644 src/earth_reach_agent/cli.py delete mode 100644 src/earth_reach_agent/config/__init__.py delete mode 100644 src/earth_reach_agent/config/criteria.py delete mode 100644 src/earth_reach_agent/core/__init__.py delete mode 100644 src/earth_reach_agent/core/evaluator.py delete mode 100644 src/earth_reach_agent/core/generator.py delete mode 100644 src/earth_reach_agent/core/llm.py delete mode 100644 src/earth_reach_agent/core/orchestrator.py delete mode 100644 src/earth_reach_agent/core/prompts/__init__.py delete mode 100644 src/earth_reach_agent/core/prompts/evaluator.py delete mode 100644 src/earth_reach_agent/core/prompts/generator.py delete mode 100644 src/earth_reach_agent/core/prompts/orchestrator.py delete mode 100644 src/earth_reach_agent/core/utils.py diff --git a/.gitignore b/.gitignore index fc5a6a5..8bb6629 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ weather_images/ # Common *.DS_Store + +# Data +data/monthly_average.zarr +data/*.nc diff --git a/README.md b/README.md index 1a1da7e..a8ba3f4 100644 --- a/README.md +++ b/README.md @@ -165,80 +165,6 @@ uv run pre-commit run --all-files - mypy configuration is in `pyproject.toml` under `[tool.mypy]` - Pre-commit configuration is in `.pre-commit-config.yaml` -## Installation - -### Prerequisites - -- Python 3.12 or higher -- [uv](https://docs.astral.sh/uv/) - Python package and project manager - -### Setup - -1. **Clone the repository** - ```sh - git clone https://github.com/ECMWFCode4Earth/EarthReach.git - cd EarthReach - ``` -2. **Create a virtual environment and install dependencies** - ```sh - uv sync - ``` -This command will automatically: -- Create a .venv virtual environment -- Install all project dependencies from pyproject.toml - -3. **Activate the virtual environment** - ```sh - source .venv/bin/activate # On Windows, use: .venv\Scripts\activate - ``` - -You're now ready to use the project. - -## Project Structure - -```sh -. -├── docs/ # Project documentation -├── notebooks/ # Tutorials & experiments -├── src/ -│ ├── earth_reach_agent/ # Main package -│ └── tests/ # Unit and integration tests (to come) -├── vllm/ # VLLM inference server setup -├── pyproject.toml # Project dependencies and metadata -└── uv.lock # Locked dependency versions -``` - -## VLLM Inference Server - -To run this project, you will need to have an openAI-compatible LLM inference server. - -We provide instructions on how to setup your own secured inference server using [VLLM](./vllm/setup.md). - -## Usage - -The project provides a command-line interface (CLI) accessible through `earth-reach-agent` or its shorter alias `era`. - -### Available Commands - -View all commands and options: -```sh -uv run era --help -``` - -### Generate weather chart descriptions - -Generate a natural language description from a weather chart image: -```sh -uv run era generate --image-path -``` - -### Evaluate descriptions - -Evaluate the accuracy of a description against a weather chart: - -```sh -uv run era evaluate --image-path --description "" -``` ## License See [LICENSE](LICENSE) diff --git a/docs/.gitkeep b/docs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/notebooks/earthkit_plots_exploration.py b/notebooks/earthkit_plots_exploration.py deleted file mode 100644 index 42ba1a0..0000000 --- a/notebooks/earthkit_plots_exploration.py +++ /dev/null @@ -1,582 +0,0 @@ -# --- -# jupyter: -# jupytext: -# cell_metadata_filter: -all -# formats: ipynb,py:percent -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.17.1 -# kernelspec: -# display_name: .venv -# language: python -# name: python3 -# --- - -# %% [markdown] -# # Earthkit Plots Exploration - -# %% [markdown] -# ## Configuration & Imports - -# %% -import base64 -import os -import warnings -from io import BytesIO -from pathlib import Path -import pandas as pd - -from earthkit.data import cache, config -import earthkit as ek -import openai -from PIL import Image -from typing import List, Dict -from dotenv import load_dotenv -from IPython.display import Markdown, display - -load_dotenv(Path().cwd().parent / ".env") - -warnings.filterwarnings("ignore") - -config.set("cache-policy", "user") -print("cache:", cache.directory()) - - -# %% [markdown] -# ## Definitions - -# %% -def display_markdown(text: str) -> None: - """ - Display a string as Markdown in a Jupyter notebook. - - Args: - text (str): The text to display as Markdown. - """ - display(Markdown(text)) - - -def img_to_base64(image_path: str | None = None, img = None) -> str: - """ - Convert an image to a base64 string. - - Args: - image_path (str): The path to the image file. Either this or img must be provided. - img (PIL.Image): The image object. Either this or image_path must be provided. - - Returns: - str: The base64 string representation of the image. - """ - if image_path is None and img is None: - raise ValueError("Either image_path or img must be provided.") - - if img is not None: - bytes_io = BytesIO() - img.save(bytes_io, format="PNG") - return base64.b64encode(bytes_io.getvalue()).decode("utf-8") - - with open(image_path, "rb") as img_file: # type: ignore - return base64.b64encode(img_file.read()).decode("utf-8") - - -# %% -assert os.environ.get("GROQ_API_KEY"), ( - "GROQ_API_KEY not set. Please set it in your environment variables." -) -client = openai.OpenAI( - base_url="https://api.groq.com/openai/v1", - api_key=os.environ.get("GROQ_API_KEY"), # Using GROQ free API provider -) - -MODEL_NAME = ( - "meta-llama/llama-4-maverick-17b-128e-instruct" # vision-enabled chat model -) - - -def call_llm_api(user_prompt: str, image) -> str: - """Call the LLM generation API""" - base64_image = img_to_base64(img=image) - try: - response = client.chat.completions.create( - model=MODEL_NAME, - messages=[ - { - "role": "user", - "content": [ - {"type": "text", "text": user_prompt}, - { - "type": "image_url", - "image_url": { - "url": f"data:image/jpeg;base64,{base64_image}", - }, - }, - ], - } - ], - ) - description = response.choices[0].message.content - - if not description: - raise ValueError("The description generated is empty") - - return description - - except Exception as e: - print("Encountered unexpected error when calling the api: {e}") - raise e - - -# %% [markdown] -# ## Loading Data - -# %% -# Example, quickly accessible data -data = ek.data.from_source("sample", "era5-2t-msl-1985122512.grib") -data.ls() - -# %% -# Copernicus datastore, requires API key set -dataset = "reanalysis-era5-single-levels" - - -def get_weather_data(date: str): - date_list = date.split("/") - - request = { - "product_type": ["reanalysis"], - "variable": ["2m_temperature", "mean_sea_level_pressure"], - "day": [date_list[0]], - "month": [date_list[1]], - "year": [date_list[2]], - "time": ["12:00"], - "data_format": "grib", - "download_format": "unarchived", - } - - data = ek.data.from_source("cds", dataset, request) - - return data - - -event_date = "12/03/2011" -data = get_weather_data(event_date) - -data.ls() - -# %% [markdown] -# ## Generating Plots from Interesting Weather Events - -# %% -# NOTE: those dates are probably most relevant for temperature but are they also for pressure ? -# Maybe we should consider events that have two interesting events ? -# Possible configurations: interesting event present / not present, parameter shown / not shown -weather_events = { - "average_days": {"global": ["12/03/2011", "20/07/2010", "05/11/2003"]}, - "cold_days": {"global": ["13/03/1976", "21/07/1943", "05/11/1975"]}, - "hot_days": {"global": ["14/03/2024", "22/07/2024", "08/11/2023"]}, - "heatwave": {"France": ["30/04/2025"], "Arctic": ["17/04/2015"]}, - "cold_wave": {"United States": ["31/01/2019"], "Europe": ["27/02/2018"]}, -} - - -# %% -def save_weather_event_images(weather_events, output_dir="weather_images"): - """ - Save weather event images and create a CSV mapping file. - - Args: - weather_events: Dictionary of weather events - output_dir: Directory to save the images and CSV file - """ - os.makedirs(output_dir, exist_ok=True) - - figure_metadata = [] - figure_id = 1 - try: - for event_type, domains in weather_events.items(): - for domain_type, dates in domains.items(): - for date_str in dates: - data = get_weather_data(date_str) - - sub_data = data.sel(param=["2t", "msl"], typeOfLevel="surface") - - kwargs: Dict[str, List] = ( - {"domain": [domain_type]} - if domain_type.lower() != "global" - else {} - ) - - buffer = BytesIO() - figure = ek.plots.quickplot( - sub_data, - units=["celsius", "hPa"], - mode="overlay", - **kwargs, # type: ignore - ) - figure.save(buffer, format="png") - - image_filename = f"figure_{figure_id}.png" - image_path = os.path.join(output_dir, image_filename) - buffer.seek(0) - img = Image.open(buffer) - img.save(image_path) - - figure_metadata.append( - { - "figure_id": figure_id, - "event_type": event_type, - "domain_type": domain_type, - "date": date_str, - } - ) - - figure_id += 1 - except: - pass - - metadata_df = pd.DataFrame(figure_metadata) - csv_path = os.path.join(output_dir, "figure_metadata.csv") - metadata_df.to_csv(csv_path, index=False) - - print(f"Saved {figure_id - 1} figures and metadata to {output_dir}") - return metadata_df - - -metadata_df = save_weather_event_images(weather_events) - -# %% [markdown] -# ## Exploring GRIB data - -# %% [markdown] -# When loaded through the `.from_source()` method, a GRIB file will yield a `GribFielList` object. Documentation available [here](https://earthkit-data.readthedocs.io/en/latest/guide/data_format/grib.html) -# -# Some information about fields and GRIB data: -# - The fields can be iterated through with for loops -# - A field represents a single meteorological phenomenon (like temperature), measured at a certain time, at a certain height, from a certain center - -# %% -len(data) # Number of fields - -# %% -data.head() # List first 5 fields and "some" metadata for each field - -# %% -data.ls() # List all fields and *some* metadata for each field - -# %% -data.describe() # Describe the fields - -# %% -data[0].dump() # Access different metadata namespaces - -# %% -data[0].metadata(namespaces="statistics") - -# %% -data.indices() # List of the metadata and the values they can take - -# %% [markdown] -# ## Earthkit-plot Quickplot API - -# %% -data.head() - -# %% -# Sub-select the data -sub_data = data.sel(param=["2t", "msl"], typeOfLevel="surface") -sub_data.head() - -# %% -buffer = BytesIO() -figure = ek.plots.quickplot(sub_data, domain=["France"], mode="overlay") - -figure.save(buffer, format="png") - -buffer.seek(0) - -img = Image.open(buffer) - -# %% -figure.title() # TODO: extract the string title - -# %% [markdown] -# Observations: -# - Geographic information is represented almost purely visually (there are latitudes and longitudes information on the axes but they are hardly interpretable), so a model should recognize the domains, or have access to them in the forms of variable valuess, default being the whole world -# - Pressure values are only represented as visual isobars, which might be the information the hardest to understand visually. A better approach could be to use the data values. -# - Temperature values are visually represented with a gradient of colors, and a clear legend. I think it would be quite easily interpreted by a model. -# - Title provides important context about the information represented and metadata such as the date. It should probably be transmitted to an end user. It can be accessed visually but also through the figure attributes. -# - In the end, the goal of interpreting this type of images could be phrased as "understanding the spatial distribution of meteorological variables." - -# %% [markdown] -# ## Trying a Simple VLLM Summary Generation - -# %% -task_prompt = """# Instructions for Describing Meteorological Visualizations for Blind Scientists - -Your task is to create a comprehensive, scientifically accurate description of meteorological visualizations that will be accessible to blind scientists. - -""" - -method_prompt = """## Method - -To achieve this task, you will work in two three steps. -1. Understanding the information of the visualization -2. Planning your description -3. Writing your description - -First,reflect on the meteorological visualizations and the metadata provided, step by step, critically, and without omitting any detail, to make sure you understand them and the key information they convey. - -Once you've completed your full breakdown of the visualization and the metadata, you should be able to write a comprehensive, scientifically accurate description of the visualization. - -So you will once again start a reflection process, to plan what you will convey and how you will convey it in the desciption, in order to respect the requirements established previously - -""" - -requirements_prompt = """## Requirements - -Here are requirements that you will have to follow rigorously when writing your description. - -### 1. Structural Organization -- Begin with a concise overview (2-3 sentences) stating the visualization type, geographic region, time period, and primary variables displayed. -- Organize your description hierarchically from most to least scientifically significant features. -- Structure your description in clearly defined sections with standardized headings. - -### 2. Scientific Content Requirements -- Use precise meteorological terminology consistent with scientific publications. -- Always include quantitative values with appropriate units (hPa for pressure, K/°C for temperature). -- Describe patterns and gradients rather than individual data points. -- Explicitly state the range of values shown for each variable. -- Identify and describe major meteorological features (high/low pressure systems, fronts, temperature gradients). -- Include geographic context using cardinal directions and recognized regional references. - -### 3. Spatial Relationship Guidelines -- Systematically describe the geographic distribution of data using cardinal directions (N, S, E, W, NE, etc.). -- Reference latitude and longitude coordinates when describing specific features. -- Describe gradients and transitions using directional language (e.g., "increasing from south to north"). -- Use recognized geographic features (countries, seas, mountain ranges) as reference points. - -### 4. Pattern Description Protocol -- Identify dominant patterns for each variable (e.g., pressure systems, temperature fronts). -- Describe the spatial relationships between different variables (e.g., how temperature aligns with pressure systems). -- Communicate the intensity of gradients, using terms like "sharp gradient," "gentle slope," or "uniform distribution." -- Note any anomalies or unusual features in the data pattern. - -### 5. Technical Elements -- Describe the scale and units prominently displayed in the visualization. -- Explain the visualization technique used for each variable (e.g., color gradient for temperature, contour lines for pressure). -- Note the resolution and any apparent limitations of the data. - -### 6. Scientific Context -- Include relevant seasonal context (e.g., winter conditions for December visualization). -- Provide brief meteorological context for the significance of the date, if applicable. -- Relate the patterns shown to typical or expected meteorological conditions for the region and season. - -### 7. Accessibility-Specific Considerations -- Use clear, unambiguous language that doesn't rely on visual references. -- Avoid phrases like "as you can see" or "looking at the map." -- Use directional and relative terms that don't require visual understanding. -- Ensure all information conveyed by color is also expressed verbally in terms of values and patterns. - -### 8. Language Style -- Maintain a formal, scientific tone throughout. -- Use precise, concise language without unnecessary elaboration. -- Be objective in descriptions, clearly differentiating between observed data and interpretations. - -""" - -format_prompt = """""" - -examples_prompt = """""" - -user_prompt = ( - task_prompt + method_prompt + requirements_prompt + format_prompt + examples_prompt -) - -# %% -description = call_llm_api(user_prompt, img) - -display_markdown(description) - -# %% [markdown] -# Observations (from a very simple prompt): -# - Good structured and objective documentation of visual elements -# - Can accurately identify technical components of scientific visualizations -# - Remains cautious about making interpretive claims beyond what's explicitly shown -# - Prioritizes factual accuracy over speculative analysis -# -# These first observations are promising. We now need to try a generation with a more specialized prompt, including best prompt-engineering techniques from the start. - -# %% -enhanced_prompt = """# Enhanced Weather Chart Alt-Text Generation System - -## ROLE AND CONTEXT SETTING - -You are a specialist scientific communication assistant working with meteorological researchers who are blind or visually impaired. Your expertise lies in converting complex weather visualizations into precise, scientifically accurate text descriptions that preserve all critical meteorological information while being fully accessible. - -**Your Mission**: Transform weather charts and maps into comprehensive text descriptions that enable blind scientists to conduct the same quality of meteorological analysis as their sighted colleagues. - -**Critical Context**: Your descriptions will be used for: -- Research analysis and data interpretation -- Scientific paper writing and peer review -- Teaching and educational materials -- Operational weather forecasting decisions - -## TASK DECOMPOSITION - -### Step 1: Data Extraction and Analysis (200-500 words) -**Objective**: Systematically catalog all quantitative information and meteorological features - -**Actions**: -1. Identify and record the exact value ranges for each variable (temperature, pressure, wind speed, etc.) -2. Note the geographic boundaries (latitude/longitude ranges), map projection, and spatial domain (e.g., "North America", "Europe", "Spain", "Global") -3. List all meteorological systems visible (highs, lows, fronts, convergence zones) -4. Document the time period, date, and any temporal information -5. Record the data source, resolution, and any technical specifications shown - -**Success Check**: Can you list specific numbers for every major feature without using vague terms like "high" or "low"? - -### Step 2: Pattern Recognition and Spatial Analysis (200-500 words) -**Objective**: Identify the dominant meteorological patterns and their spatial relationships - -**Actions**: -1. Determine the primary weather systems and rank them by meteorological significance -2. Map the directional flow of gradients (temperature, pressure) using cardinal directions -3. Identify spatial correlations between different variables -4. Note any unusual or anomalous features that deviate from expected patterns -5. Assess the seasonal/temporal context and typical vs. atypical conditions - -**Success Check**: Can you explain how each major system influences the others spatially? - -### Step 3: Description Planning and Structure Design (100-150 words) -**Objective**: Create a hierarchical outline that prioritizes information by scientific importance - -**Actions**: -1. Rank meteorological features by their significance for understanding weather patterns -2. Plan the logical flow from overview to specific details -3. Determine which quantitative values are essential vs. supplementary -4. Identify the most effective way to convey spatial relationships -5. Plan transitions between sections to maintain scientific coherence - -**Success Check**: Would a meteorologist reading your outline understand the complete weather situation? - -### Step 4: Description Writing with Verification (300-500 words) -**Objective**: Produce the final description following all specifications - -**Actions**: -1. Write a concise overview (2-3 sentences) summarizing the visualization type, geographic region, time period, and primary variables -2. Develop the main body with 4-6 sentences, covering: - - Dominant systems and their characteristics - - Variable distributions (temperature, pressure, wind) - - Spatial relationships and correlations - - Notable features or anomalies -3. Ensure all quantitative values include specific ranges and units -4. Use precise meteorological terminology and avoid visual-dependent language - -**Success Check**: Does your description pass all items in the quality verification checklist? - -## CONCRETE CONSTRAINTS AND SPECIFICATIONS - -### Length and Structure Requirements -- **Total description length**: 300-500 words maximum -- **Overview section**: Exactly 2-3 sentences -- **Main body**: 4-6 sentences - -### Quantitative Requirements -- **Include exact ranges**: Every variable must have minimum and maximum values with units -- **Spatial precision**: Use cardinal directions and geographic references -- **Coordinate references**: Include latitude/longitude for major features (±2 degrees acceptable) -- **Unit consistency**: Use standard meteorological units (hPa, m/s, K or °C, km) - -### Language Specifications -- **Terminology level**: Graduate-level meteorological vocabulary (assume PhD-level audience) -- **Sentence structure**: Maximum 25 words per sentence -- **Accessibility compliance**: Zero visual-dependent phrases (see prohibited terms list below) -- **Objectivity standard**: Separate observations from interpretations using phrases like "The data shows..." vs. "This suggests..." - -## ERROR PREVENTION GUIDELINES - -### Common Pitfalls to Avoid -1. **Vague descriptions**: Never use "high," "low," "warm," "cold" without specific values -2. **Visual dependency**: Prohibited phrases include: "as shown," "visible," "looking at," "clearly," "obviously" -3. **Geographic ambiguity**: Avoid "here," "there," "this area" - use specific location references -4. **Unit omissions**: Every quantitative statement must include appropriate units -5. **Pattern assumptions**: Don't assume readers can visualize spatial relationships - describe explicitly - -## SUCCESS CRITERIA - -### Objective Standards -1. **Completeness Test**: A meteorologist should be able to sketch the general pattern from your description -2. **Quantitative Accuracy**: All numerical values within ±5% of actual data -3. **Accessibility Compliance**: 100% of visual information conveyed through text -4. **Scientific Utility**: Description enables the same analytical conclusions as the visual -5. **Terminology Precision**: All meteorological terms used correctly per AMS Glossary standards - -## EXAMPLES AND REFERENCE PATTERNS - -Not yet available, but will be provided in the future. - -## OUTPUT FORMAT REQUIREMENTS - -### XML Tag Structure -**CRITICAL**: You must wrap the content of each step and the final description in specific XML tags for programmatic parsing. - -**Required XML Tags:** -- `...` - Wrap the entire content of Step 1 (excluding the markdown header) -- `...` - Wrap the entire content of Step 2 (excluding the markdown header) -- `...` - Wrap the entire content of Step 3 (excluding the markdown header) -- `...` - Wrap the entire content of Step 4 (excluding the markdown header) -- `...` - Wrap only the final consolidated description (without headers or meta-commentary) - -### Formatting Rules -1. **Tag Placement**: Place opening and closing tags on their own lines -2. **Content Scope**: Include all step content within tags, but exclude markdown headers (## Step X) -3. **Final Description**: The `` should contain ONLY the final weather description without any meta-commentary about word count or requirements -4. **No Tag Omission**: All five XML tag pairs must be present, even if a step is brief - -### Example Structure: -``` -## Step 1: Data Extraction and Analysis - -The chart displays 2-meter temperature and mean sea level pressure... -[rest of step 1 content] - - -## Step 2: Pattern Recognition and Spatial Analysis - -The dominant feature is a high-pressure system... -[rest of step 2 content] - - -## Step 3: Description Planning and Structure Design - -To structure the description, we will start with an overview... -[rest of step 3 content] - - -## Step 4: Description Writing with Verification - -### Overview -This weather chart displays... -### Main Body -A high-pressure system is centered... -[rest of step 4 content] - - - -This weather chart visualization shows 2-meter temperature and mean sea level pressure over Western Europe on March 12, 2011, at 12:00 UTC. The region covers latitudes from 40.5°N to 51°N and longitudes from 2.5°W to 10°E. A high-pressure system is centered at 45°N, 7.5°E with a pressure of 102080 Pa. The 2-meter temperature varies from 280 K in the north to 294 K in the southeast, showing a significant north-south temperature gradient. The pressure distribution is dominated by this high-pressure system, with pressure decreasing towards the northwest. The highest temperatures are located southeast of the high-pressure center. - -``` - -**IMPORTANT**: Ensure all XML tags are properly opened and closed. Malformed XML will cause parsing failures. - -**Remember**: Your description is not just text, it's a scientific instrument that must preserve the analytical power of the original visualization while being fully accessible to blind scientists. -""" - -# display_markdown(enhanced_prompt) - -# %% -description = call_llm_api(enhanced_prompt, img) - -display_markdown(description) diff --git a/src/earth_reach_agent/__init__.py b/src/earth_reach_agent/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/earth_reach_agent/cli.py b/src/earth_reach_agent/cli.py deleted file mode 100644 index 25ffdec..0000000 --- a/src/earth_reach_agent/cli.py +++ /dev/null @@ -1,421 +0,0 @@ -#!/usr/bin/env python3 -""" -CLI entrypoint for generating weather chart descriptions. -""" - -import logging -import os -import sys -from pathlib import Path -from typing import List - -import fire -from dotenv import load_dotenv -from PIL import Image - -from earth_reach_agent.config.criteria import QualityCriteria -from earth_reach_agent.core.evaluator import EvaluatorAgent -from earth_reach_agent.core.generator import GeneratorAgent -from earth_reach_agent.core.llm import create_llm -from earth_reach_agent.core.orchestrator import Orchestrator -from earth_reach_agent.core.prompts.generator import get_default_generator_user_prompt - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - -load_dotenv() - - -def load_prompt_from_file(file_path: str) -> str: - """ - Load prompt text from a file. - - Args: - file_path (str): Path to the text file containing the prompt - - Returns: - str: The loaded prompt text - - Raises: - FileNotFoundError: If file doesn't exist - IOError: If file can't be read - ValueError: If file is empty - """ - if not os.path.isfile(file_path): - raise FileNotFoundError(f"Prompt file not found: {file_path}") - - try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read().strip() - if not content: - raise ValueError(f"Prompt file is empty: {file_path}") - return content - except Exception as e: - raise IOError(f"Failed to read prompt file '{file_path}': {e}") - - -def resolve_prompt( - direct_prompt: str | None, - file_path: str | None, - DEFAULT_USER_PROMPT: str | None, -) -> str | None: - """ - Resolve final prompt from direct string, file path, or default. - - Args: - direct_prompt (str | None): Direct prompt text - file_path (str | None): Path to prompt file - DEFAULT_USER_PROMPT (str | None): Default prompt to use if neither is provided - - Returns: - str | None: The resolved prompt text - - Raises: - ValueError: If both direct_prompt and file_path are provided - """ - if direct_prompt is not None and file_path is not None: - raise ValueError( - "Cannot specify both prompt file and prompt text. Please use only one." - ) - - if direct_prompt is not None: - if not direct_prompt.strip(): - raise ValueError("Prompt text is an empty string.") - return direct_prompt.strip() - - if file_path is not None: - return load_prompt_from_file(file_path) - - return DEFAULT_USER_PROMPT - - -def resolve_description( - description: str | None, - description_file_path: str | None, -) -> str | None: - """ - Resolve description from multiple sources with priority: direct text > file. - - Args: - description (str | None): Direct description text - description_file_path (str | None): Path to description file - - Returns: - str | None: Resolved description text - - Raises: - ValueError: If both description sources are provided or file is invalid - FileNotFoundError: If description file doesn't exist - """ - if description and description_file_path: - raise ValueError( - "Cannot provide both description text and description file path. " - "Please provide only one." - ) - - if description: - return description - - if description_file_path: - if not os.path.exists(description_file_path): - raise FileNotFoundError( - f"Description file not found: {description_file_path}" - ) - - try: - with open(description_file_path, "r", encoding="utf-8") as file: - return file.read().strip() - except Exception as e: - raise ValueError(f"Error reading description file: {e}") - - return None - - -def get_valid_criteria() -> List[str]: - """ - Get list of valid evaluation criteria. - - Returns: - List[str]: List of valid criteria names - """ - return QualityCriteria.list() - - -def validate_image_path(image_path: str) -> Path: - """ - Validate that image path exists and is a supported format. - - Args: - image_path (str): Path to the image file - - Returns: - Path: Validated Path object - - Raises: - FileNotFoundError: If image file doesn't exist - ValueError: If image format is not supported - """ - path = Path(image_path) - - if not path.exists(): - raise FileNotFoundError(f"Image file not found: {image_path}") - - if not path.is_file(): - raise ValueError(f"Path is not a file: {image_path}") - - valid_extensions = {".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG"} - if path.suffix not in valid_extensions: - raise ValueError( - f"Unsupported image format: {path.suffix}. " - f"Supported formats: {', '.join(sorted(valid_extensions))}" - ) - - return path - - -class CLI: - """ - Command Line Interface for the Earth Reach Agent. - Allows users to generate and evaluate weather chart descriptions from images. - """ - - @staticmethod - def generate( - image_path: str, - system_prompt: str | None = None, - system_prompt_file_path: str | None = None, - user_prompt: str | None = None, - user_prompt_file_path: str | None = None, - simple: bool = False, - max_iterations: int = 3, - criteria_threshold: int = 4, - verbose: bool = False, - ) -> None: - """ - Generate a scientific description of a weather chart from an image. - - Args: - image_path (str): Path to the weather chart image (JPEG or PNG) - system_prompt (str | None): System prompt text (optional) - system_prompt_file_path (str | None): Path to system prompt file (optional) - user_prompt (str | None): User prompt text (optional) - user_prompt_file_path (str | None): Path to user prompt file (optional) - simple (bool): Skip orchestrator to only use generator (optional) - max_iterations (int): Orchestrator maximum iterations for description generation (default: 3) - criteria_threshold (int): Minimum score for evaluation criteria to pass (default: 4 - verbose (bool): Enable verbose output (optional) - - Returns: - None: Prints the generated weather description - - Raises: - FileNotFoundError: If image or prompt files don't exist - ValueError: If arguments are invalid or conflicting - RuntimeError: If description generation fails - """ - try: - if verbose: - logger.info(f"Validating image: {image_path}") - validated_image_path = validate_image_path(image_path) - image = Image.open(validated_image_path) - - if verbose: - logger.info("Resolving prompts...") - - system_prompt_text = resolve_prompt( - system_prompt, - system_prompt_file_path, - None, - ) - user_prompt_text = resolve_prompt( - user_prompt, user_prompt_file_path, get_default_generator_user_prompt() - ) - if not user_prompt_text: - raise ValueError( - "User prompt cannot be empty. Please provide a valid prompt." - ) - - if verbose: - if system_prompt_text: - logger.info( - f"System prompt length: {len(system_prompt_text)} characters" - ) - - if user_prompt_text: - logger.info( - f"User prompt length: {len(user_prompt_text)} characters" - ) - - if verbose: - logger.info("Initializing LLM...") - llm = create_llm() - - if verbose: - logger.info("Creating generator agent...") - - generator = GeneratorAgent( - llm=llm, system_prompt=system_prompt_text, user_prompt=user_prompt_text - ) - - if not simple: - if verbose: - logger.info("Creating evaluator agent...") - - evaluator = EvaluatorAgent( - criteria=QualityCriteria.list(), - llm=llm, - ) - - if verbose: - logger.info("Creating orchestrator...") - - orchestrator = Orchestrator( - generator_agent=generator, - evaluator_agent=evaluator, - max_iterations=max_iterations, - criteria_threshold=criteria_threshold, - ) - - if verbose: - logger.info(f"Generating description for: {validated_image_path.name}") - - if simple: - description = generator.generate(image=image) - else: - description = orchestrator.run(image=image) - - if verbose: - logger.info("Description generated successfully!") - logger.info(f"Description length: {len(description)} characters") - logger.info("-" * 50) - - print(description) - - return None - - except (FileNotFoundError, ValueError, IOError) as e: - logger.error(f"Could not load image file: {e}", exc_info=True) - sys.exit(1) - except RuntimeError as e: - logger.error(f"Generation failed: {e}", exc_info=True) - sys.exit(1) - except Exception as e: - logger.error(f"Unexpected error: {e}", exc_info=True) - sys.exit(1) - - @staticmethod - def evaluate( - image_path: str, - description: str | None = None, - description_file_path: str | None = None, - criteria: List[str] = ["coherence", "fluency", "consistency", "relevance"], - verbose: bool = False, - ) -> None: - """ - Evaluate a generated weather chart description. - - Args: - image_path (str): Path to the weather chart image (JPEG or PNG) - description (str | None): The description text to evaluate (optional) - description_file_path (str | None): Path to file containing description (optional) - criteria (List[str]): List of evaluation criteria to assess - verbose (bool): Enable verbose output (optional) - - Returns: - None: Prints evaluation results - - Raises: - FileNotFoundError: If image or description files don't exist - ValueError: If arguments are invalid or conflicting - RuntimeError: If evaluation fails - """ - try: - if verbose: - logger.info(f"Validating image: {image_path}") - - validated_image_path = validate_image_path(image_path) - image = Image.open(validated_image_path) - - if verbose: - logger.info("Resolving description...") - - description_text = resolve_description( - description, - description_file_path, - ) - if not description_text or description_text.strip() == "": - raise ValueError( - "Description cannot be empty. Please provide a valid description." - ) - - if verbose: - logger.info(f"Description length: {len(description_text)} characters") - - if not criteria or len(criteria) == 0: - raise ValueError("Criteria list cannot be empty.") - - valid_criteria = get_valid_criteria() - invalid_criteria = [c for c in criteria if c not in valid_criteria] - if invalid_criteria: - raise ValueError( - f"Invalid criteria: {invalid_criteria}. Valid criteria are: {valid_criteria}" - ) - - if verbose: - logger.info(f"Evaluation criteria: {', '.join(criteria)}") - - if verbose: - logger.info("Initializing LLM...") - llm = create_llm() - - if verbose: - logger.info("Creating evaluator agent...") - evaluator = EvaluatorAgent( - criteria=criteria, - llm=llm, - ) - - if verbose: - logger.info(f"Evaluating description for: {validated_image_path.name}") - evaluation = evaluator.evaluate( - description=description_text, - image=image, - ) - - if verbose: - logger.info("Evaluation completed successfully!") - logger.info(f"Number of criteria evaluated: {len(evaluation)}") - logger.info("-" * 50) - - for eval in evaluation: - print(f"Criterion: {eval.name}") - print(f"Score: {eval.score}/5") - if verbose: - print(f"Reasoning: {eval.reasoning}") - print("-" * 50) - - return None - - except FileNotFoundError as e: - logger.error(f"File not found: {e}", exc_info=True) - sys.exit(1) - except ValueError as e: - logger.error(f"Invalid input: {e}", exc_info=True) - sys.exit(1) - except Exception as e: - logger.error(f"Evaluation failed: {e}", exc_info=True) - sys.exit(1) - - -def cli(): - """ - CLI entrypoint. - """ - fire.Fire(CLI) - - -if __name__ == "__main__": - cli() diff --git a/src/earth_reach_agent/config/__init__.py b/src/earth_reach_agent/config/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/earth_reach_agent/config/criteria.py b/src/earth_reach_agent/config/criteria.py deleted file mode 100644 index d0aab28..0000000 --- a/src/earth_reach_agent/config/criteria.py +++ /dev/null @@ -1,13 +0,0 @@ -from enum import Enum -from typing import List - - -class QualityCriteria(Enum): - COHERENCE = "coherence" - FLUENCY = "fluency" - CONSISTENCY = "consistency" - RELEVANCE = "relevance" - - @classmethod - def list(cls) -> List[str]: - return [criterion.value for criterion in cls] diff --git a/src/earth_reach_agent/core/__init__.py b/src/earth_reach_agent/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/earth_reach_agent/core/evaluator.py b/src/earth_reach_agent/core/evaluator.py deleted file mode 100644 index 81c4283..0000000 --- a/src/earth_reach_agent/core/evaluator.py +++ /dev/null @@ -1,416 +0,0 @@ -import logging -import re -from dataclasses import MISSING, dataclass, fields -from io import BytesIO -from typing import Any, List, Union, get_args, get_origin - -import earthkit.plots as ekp -from PIL import Image -from PIL.ImageFile import ImageFile - -from earth_reach_agent.core.generator import FigureMetadata -from earth_reach_agent.core.llm import BaseLLM, create_llm -from earth_reach_agent.core.prompts.evaluator import ( - get_default_criterion_evaluator_user_prompt, -) - -logger = logging.getLogger(__name__) - - -@dataclass -class CriterionEvaluatorOutput: - """Structured representation of the criterion evaluator's output. - - Attributes: - criterion: The criterion against which the text was evaluated. - score: The score assigned to the text based on the criterion (0 to 5). - reasoning: Optional reasoning for the score, if provided by the LLM. - """ - - name: str - score: int - reasoning: str | None = None - - def is_score_valid(self) -> bool: - """ - Check if the score is within a valid range (0 to 5). - - Returns: - bool: True if the score is valid, False otherwise. - """ - return 0 <= self.score <= 5 - - def __post_init__(self): - if not self.is_score_valid(): - raise ValueError("Score must be between 0 and 5.") - - -class CriterionEvaluator: - """Evaluator class for evaluating the quality of text based on a specified criterion.""" - - def __init__( - self, criterion: str, llm: BaseLLM, system_prompt: str | None, user_prompt: str - ) -> None: - if criterion not in ["coherence", "fluency", "consistency", "relevance"]: - raise ValueError(f"Unsupported criterion: {criterion}") - - self.criterion = criterion - self.llm = llm - self.system_prompt = system_prompt - self.user_prompt = user_prompt - - def evaluate( - self, - description: str, - figure: ekp.Figure | None = None, - image: ImageFile | None = None, - ) -> CriterionEvaluatorOutput: - if figure is not None and image is not None: - raise ValueError( - "Only one of 'figure' or 'image' can be provided, not both." - ) - if figure is not None: - # TODO(medium): If metadata extraction fails, continue without it - metadata = self._get_metadata_from_figure(figure) - self.user_prompt = self._update_user_prompt_with_metadata( - self.user_prompt, metadata - ) - image = self._get_image_from_figure(figure) - elif image is None and figure is None: - raise ValueError( - "Either 'figure' or 'image' must be provided to generate a description." - ) - try: - user_prompt = ( - self.user_prompt - + f"\n\n# Description to evaluate\n\n{description}" - + "\n\nPlease provide your evaluation of the description against the criteria." - ) - response = self.llm.generate( - user_prompt=user_prompt, - system_prompt=self.system_prompt, - ) - return self.parse_llm_response(response) - except Exception as e: - raise RuntimeError(f"Failed to generate response: {e}") from e - - def parse_llm_response(self, response: str) -> CriterionEvaluatorOutput: - """ - Parse the XML-tagged response from the CriterionEvaluator Agent into structured data. - - Args: - response (str): The full llm response string containing XML tags - - Returns: - CriterionEvaluatorOutput: Parsed evaluation content - - Raises: - ValueError: If the response string is empty or None, or if required fields are missing - Exception: If a parsing error occurs for any field - """ - if not response or not response.strip(): - raise ValueError("Response string is empty or None") - - dataclass_fields = fields(CriterionEvaluatorOutput) - - extracted_values = {} - parsing_errors = [] - - for field in dataclass_fields: - field_name = field.name - field_type = field.type - - pattern = rf"<{field_name}>(.*?)" - - try: - match = re.search(pattern, response, re.DOTALL) - if match: - content = match.group(1).strip() - if content: - converted_value = self.convert_to_field_type( - content, field_name, field_type - ) - extracted_values[field_name] = converted_value - except Exception as e: - parsing_errors.append(f"Failed to parse field '{field_name}': {str(e)}") - - logger.warning( - f"Parsing errors encountered: {parsing_errors}" - ) if parsing_errors else None - - required_fields = [ - f.name - for f in dataclass_fields - if f.default == MISSING - and f.default_factory == MISSING - and f.name - != "name" # 'name' not required since it's set by object attribute - ] - - missing_required = [f for f in required_fields if f not in extracted_values] - if missing_required: - raise ValueError( - f"Missing required fields in XML response: {missing_required}" - ) - - try: - return CriterionEvaluatorOutput(name=self.criterion, **extracted_values) - except Exception as e: - raise Exception( - f"Failed to create CriterionEvaluatorOutput instance: {str(e)}" - ) - - def convert_to_field_type( - self, content: str, field_name: str, field_type: Any - ) -> Any: - """ - Convert string content to the appropriate type based on field type annotation. - - Args: - content (str): The extracted string content - field_name (str): Name of the field (for error reporting) - field_type: The type annotation of the field - - Returns: - The converted value with the correct type - - Raises: - ValueError: If type conversion fails - """ - origin = get_origin(field_type) - - if origin is Union: - args = get_args(field_type) - non_none_types = [arg for arg in args if arg is not type(None)] - if non_none_types: - field_type = non_none_types[0] - else: - return content - - if field_type is int: - try: - return int(content) - except ValueError: - raise ValueError( - f"Cannot convert '{content}' to integer for field '{field_name}'" - ) - - elif field_type is float: - try: - return float(content) - except ValueError: - raise ValueError( - f"Cannot convert '{content}' to float for field '{field_name}'" - ) - - elif field_type is bool: - lower_content = content.lower().strip() - if lower_content in ("true", "1", "yes", "on", "y"): - return True - elif lower_content in ("false", "0", "no", "off", "n"): - return False - else: - raise ValueError( - f"Cannot convert '{content}' to boolean for field '{field_name}'. " - f"Expected: true/false, 1/0, yes/no, on/off, y/n" - ) - - elif field_type is str: - return content - - else: - if hasattr(field_type, "__call__"): - try: - return field_type(content) - except Exception: - return content - else: - return content - - def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata: - """ - Extract metadata from the given figure. - - Args: - figure (ekp.Figure): The figure to extract metadata from. - - Returns: - FigureMetadata: An object containing the extracted metadata. - """ - metadata = FigureMetadata() - - plt_fig = figure.fig - if plt_fig is None: - raise ValueError("Matplotlib figure is None, cannot extract metadata.") - - axes = plt_fig.get_axes() - - metadata.title = axes[0].get_title() - metadata.xlabel = axes[0].get_xlabel() - metadata.ylabel = axes[0].get_ylabel() - metadata.domain = figure._domain - return metadata - - def _update_user_prompt_with_metadata( - self, user_prompt: str, metadata: FigureMetadata - ) -> str: - """ - Update the user prompt with metadata extracted from the figure. - - Args: - user_prompt (str): The original user prompt. - metadata (FigureMetadata): The metadata to include in the prompt. - - Returns: - str: The updated user prompt with metadata included. - """ - metadata_items = [] - for field_info in fields(metadata): - value = getattr(metadata, field_info.name) - if value is not None: - description = field_info.metadata.get( - "description", "No description available" - ) - metadata_items.append(f"- {field_info.name} ({description}): {value}") - - if not metadata_items: - return user_prompt - - metadata_str = "# FIGURE METADATA\n\n" - metadata_str += "The following metadata was extracted from the figure:\n\n" - metadata_str += "\n".join(metadata_items) - - return f"{user_prompt}\n\n{metadata_str}" - - def _get_image_from_figure(self, figure: ekp.Figure) -> ImageFile: - """ - Convert the given figure to an image file. - - Args: - figure (ekp.Figure): The figure to convert. - - Returns: - ImageFile: The converted image file. - """ - buffer = BytesIO() - figure.save(buffer, format="png") - buffer.seek(0) - return Image.open(buffer) - - -class CriterionEvaluatorFactory: - """Factory class for creating single criterion evaluator agents.""" - - @staticmethod - def create(criterion: str, llm: BaseLLM | None = None) -> CriterionEvaluator: - """ - Create a CriterionEvaluator instance based on the provided criterion. - - Args: - criterion (str): Criterion name to create evaluators for. - llm (BaseLLM | None): Optional LLM instance to use for evaluation. - - Returns: - CriterionEvaluator: CriterionEvaluator instance. - """ - if criterion not in ["coherence", "fluency", "consistency", "relevance"]: - raise ValueError(f"Unsupported criterion: {criterion}") - - if not llm: - llm = create_llm() - - user_prompt = get_default_criterion_evaluator_user_prompt(criterion) - - return CriterionEvaluator( - criterion=criterion, llm=llm, system_prompt=None, user_prompt=user_prompt - ) - - -class EvaluatorAgent: - """Agent class for evaluating the quality of weather chart descriptions.""" - - def __init__(self, criteria: List[str], llm: BaseLLM | None = None) -> None: - """ - Initialize the EvaluatorAgent. - - Args: - criteria (List[str]): List of criteria to evaluate against. - Supported criteria: "coherence", "fluency", "consistency", "relevance". - - Raises: - ValueError: If an unsupported criterion is provided. - RuntimeError: If the evaluator creation fails. - """ - for criterion in criteria: - if criterion not in ["coherence", "fluency", "consistency", "relevance"]: - raise ValueError(f"Unsupported criterion: {criterion}") - - self.criteria = criteria - - try: - self.evaluators = [ - CriterionEvaluatorFactory.create(criterion, llm=llm) - for criterion in criteria - ] - except Exception as e: - raise RuntimeError( - f"Failed to create evaluators for criteria {criteria}: {e}" - ) from e - - def evaluate( - self, - description: str, - figure: ekp.Figure | None = None, - image: ImageFile | None = None, - ) -> List[CriterionEvaluatorOutput]: - """ - Evaluate the given text against the specified criteria. - - Args: - text (str): The text to evaluate. - - Returns: - List[CriterionEvaluatorOutput]: A list of evaluation results for each criterion. - """ - if figure is not None and image is not None: - raise ValueError( - "Only one of 'figure' or 'image' can be provided, not both." - ) - - try: - evaluations = [] - for evaluator in self.evaluators: - result = evaluator.evaluate( - description=description, figure=figure, image=image - ) - evaluations.append(result) - - return evaluations - except Exception as e: - raise RuntimeError(f"Failed to evaluate description: {e}") from e - - def is_text_length_lesser_than_max(self, text: str, max_length: int = 1000) -> bool: - """ - Check if the length of the text is lesser than the specified maximum length. - - Args: - text (str): The text to check. - max_length (int): The maximum length of the text. - - Returns: - bool: True if the text length is valid, False otherwise. - """ - return len(text) <= max_length - - def is_text_length_greater_than_min(self, text: str, min_length: int = 100) -> bool: - """ - Check if the length of the text is greater than the specified minimum length. - - Args: - text (str): The text to check. - min_length (int): The minimum length of the text. - - Returns: - bool: True if the text length is more than the minimum, False otherwise. - """ - return min_length <= len(text) diff --git a/src/earth_reach_agent/core/generator.py b/src/earth_reach_agent/core/generator.py deleted file mode 100644 index a4860d9..0000000 --- a/src/earth_reach_agent/core/generator.py +++ /dev/null @@ -1,290 +0,0 @@ -import re -from dataclasses import dataclass, field, fields -from io import BytesIO -from typing import List - -import earthkit.plots as ekp -from PIL import Image -from PIL.ImageFile import ImageFile - -from earth_reach_agent.core.llm import BaseLLM - - -@dataclass -class FigureMetadata: - """Metadata extracted from a figure.""" - - title: str | None = field( - default=None, metadata={"description": "Figure title or heading"} - ) - xlabel: str | None = field( - default=None, - metadata={"description": "X-axis label describing the horizontal dimension"}, - ) - ylabel: str | None = field( - default=None, - metadata={"description": "Y-axis label describing the vertical dimension"}, - ) - domain: str | None = field( - default=None, metadata={"description": "Geographic domain of the figure"} - ) - variables: List[str] | None = field( - default=None, - metadata={"description": "Key variables shown in the figure"}, - ) - - -@dataclass -class GeneratorOutput: - """ - Structured representation of the generator agent's output. - - Attributes: - step_1: Data extraction and analysis content - step_2: Pattern recognition and spatial analysis content - step_3: Description planning and structure design content - step_4: Description writing with verification content - final_description: The final consolidated weather description - """ - - step_1: str | None = None - step_2: str | None = None - step_3: str | None = None - step_4: str | None = None - final_description: str | None = None - - def is_complete(self) -> bool: - """ - Check if all required fields were successfully parsed. - - Returns: - bool: True if all fields contain content, False otherwise - """ - return all( - getattr(self, field.name) is not None and getattr(self, field.name).strip() - for field in fields(self) - ) - - def get_missing_fields(self) -> List[str]: - """ - Return list of field names that were not successfully parsed. - - Returns: - List[str]: Names of fields that are None or empty - """ - return [ - field.name - for field in fields(self) - if not getattr(self, field.name) or not getattr(self, field.name).strip() - ] - - def get_step_word_count(self, step_name: str) -> int: - """ - Get word count for a specific step. - - Args: - step_name: Name of the step ('step_1', 'step_2', etc.) - - Returns: - int: Word count for the specified step, 0 if step is None/empty - """ - content = getattr(self, step_name, None) - if not content: - return 0 - return len(content.split()) - - def get_final_description_word_count(self) -> int: - """ - Get word count for the final description. - - Returns: - int: Word count for final description, 0 if None/empty - """ - if not self.final_description: - return 0 - return len(self.final_description.split()) - - -class GeneratorAgent: - """GeneratorAgent class for generating weather charts scientific descriptions.""" - - def __init__( - self, llm: BaseLLM, system_prompt: str | None, user_prompt: str - ) -> None: - """ - Initialize the GeneratorAgent with a BaseLLM instance and prompts. - - Args: - llm (BaseLLM): An instance of a BaseLLM to handle LLM interactions. - system_prompt (str | None): Optional system prompt to guide the style of the LLM. - user_prompt (str): The user prompt containing the instructions for description generation. - """ - self.llm = llm - self.system_prompt = system_prompt - self.user_prompt = user_prompt - - def generate( - self, figure: ekp.Figure | None = None, image: ImageFile | None = None - ) -> str: - """ - Generate a structured weather description using the LLM. - - Args: - figure (Figure | None): Optional figure to include in the request. Can't be used with image. - image (ImageFile | None): Optional image to include in the request (will be converted to base64). Can't be used with figure. - - Returns: - str: The final weather description. - - Raises: - ValueError: If the output string is empty or None. - RuntimeError: For other run-time errors. - Exception: If the LLM response is incomplete or parsing fails. - """ - if figure is not None and image is not None: - raise ValueError( - "Only one of 'figure' or 'image' can be provided, not both." - ) - if figure is not None: - # TODO(medium): If metadata extraction failes, continue without it - metadata = self._get_metadata_from_figure(figure) - self.user_prompt = self._update_user_prompt_with_metadata( - self.user_prompt, metadata - ) - image = self._get_image_from_figure(figure) - elif image is None and figure is None: - raise ValueError( - "Either 'figure' or 'image' must be provided to generate a description." - ) - - try: - response = self.llm.generate( - user_prompt=self.user_prompt, - system_prompt=self.system_prompt, - image=image, - ) - - parsed_output = self.parse_llm_response(response) - if not parsed_output.is_complete(): - raise ValueError( - "Parsed output is incomplete. Missing fields: " - f"{parsed_output.get_missing_fields()}" - ) - - description = parsed_output.final_description - - except Exception as e: - raise RuntimeError(f"Failed to generate response: {e}") from e - - return description # type: ignore[return-value] - - def parse_llm_response(self, response: str) -> GeneratorOutput: - """ - Parse the XML-tagged response from the generator agent into structured data. - - Args: - response (str): The full llm response string containing XML tags - - Returns: - GeneratorOutput: Parsed content with individual step results - - Raises: - ValueError: If the response string is empty or None - Exception: If parsing fails for any step - """ - if not response or not response.strip(): - raise ValueError("Response string is empty or None") - - result = GeneratorOutput() - - patterns = { - field.name: rf"<{field.name}>(.*?)" - for field in fields(GeneratorOutput) - } - - for field_name, pattern in patterns.items(): - try: - match = re.search(pattern, response, re.DOTALL) - if match: - content = match.group(1).strip() - if content: - setattr(result, field_name, content) - except Exception as e: - print(f"Warning: Failed to parse {field_name}: {e}") - continue - - return result - - def _get_metadata_from_figure(self, figure: ekp.Figure) -> FigureMetadata: - """ - Extract metadata from the given figure. - - Args: - figure (ekp.Figure): The figure to extract metadata from. - - Returns: - FigureMetadata: An object containing the extracted metadata. - """ - metadata = FigureMetadata() - - plt_fig = figure.fig - if plt_fig is None: - raise ValueError("Matplotlib figure is None, cannot extract metadata.") - - axes = plt_fig.get_axes() - - metadata.title = axes[0].get_title() - metadata.xlabel = axes[0].get_xlabel() - metadata.ylabel = axes[0].get_ylabel() - metadata.domain = figure._domain - return metadata - - def _update_user_prompt_with_metadata( - self, user_prompt: str, metadata: FigureMetadata - ) -> str: - """ - Update the user prompt with metadata extracted from the figure. - - Args: - user_prompt (str): The original user prompt. - metadata (FigureMetadata): The metadata to include in the prompt. - - Returns: - str: The updated user prompt with metadata included. - """ - metadata_items = [] - for field_info in fields(metadata): - value = getattr(metadata, field_info.name) - if value is not None: - description = field_info.metadata.get( - "description", "No description available" - ) - metadata_items.append(f"- {field_info.name} ({description}): {value}") - - if not metadata_items: - return user_prompt - - metadata_str = "# FIGURE METADATA\n\n" - metadata_str += "The following metadata was extracted from the figure:\n\n" - metadata_str += "\n".join(metadata_items) - - return f"{user_prompt}\n\n{metadata_str}" - - def _get_image_from_figure(self, figure: ekp.Figure) -> ImageFile: - """ - Convert the given figure to an image file. - - Args: - figure (ekp.Figure): The figure to convert. - - Returns: - ImageFile: The converted image file. - """ - buffer = BytesIO() - figure.save(buffer, format="png") - buffer.seek(0) - return Image.open(buffer) - - def append_user_prompt(self, text: str) -> None: - """Append additional text to the user prompt.""" - self.user_prompt += f"\n\n{text.strip()}" diff --git a/src/earth_reach_agent/core/llm.py b/src/earth_reach_agent/core/llm.py deleted file mode 100644 index a9a1e02..0000000 --- a/src/earth_reach_agent/core/llm.py +++ /dev/null @@ -1,194 +0,0 @@ -import os - -import openai - -from earth_reach_agent.core.utils import img_to_base64 - - -class BaseLLM: - """Base class for a LLM interface.""" - - def __init__( - self, - model_name: str, - base_url: str, - api_key: str | None = None, - ) -> None: - """ - Initialize the LLM with a model name and optional keyword arguments. - - Args: - model_name (str): The name of the model to use. - base_url (str): The base URL for the LLM API. - api_key (str | None): The API key for authentication with the LLM provider. - **kwargs: Additional keyword arguments for the LLM configuration. - """ - self.model_name = model_name - self.base_url = base_url - self.api_key = api_key - - self.client = openai.OpenAI( - base_url=base_url, - api_key=api_key, - ) - - def generate( - self, user_prompt: str, system_prompt: str | None = None, image=None - ) -> str: - """ - Generate a response from the LLM API based on the user prompt and optional system prompt. - - Args: - user_prompt (str): The prompt provided by the user to define the task. - system_prompt (str | None): An optional system prompt to guide the model's response. - image: Optional image to include in the request (will be converted to base64). - - Returns: - str: The generated response content from the LLM. - - Raises: - ValueError: If user_prompt is empty/None or if the API response is empty. - RuntimeError: For other run-time errors. - """ - - if not user_prompt or not user_prompt.strip(): - raise ValueError("user_prompt cannot be empty or None") - - messages = [] - - if system_prompt and system_prompt.strip(): - messages.append({"role": "system", "content": system_prompt.strip()}) - - try: - if image: - base64_image = img_to_base64(img=image) - if not base64_image: - raise ValueError("Failed to convert image to base64") - - user_content = [ - {"type": "text", "text": user_prompt.strip()}, - { - "type": "image_url", - "image_url": { - "url": f"data:image/jpeg;base64,{base64_image}", - }, - }, - ] - else: - user_content = user_prompt.strip() - - messages.append({"role": "user", "content": user_content}) - - except Exception as e: - raise ValueError(f"Failed to process input data: {e}") - - try: - response = self.client.chat.completions.create( - model=self.model_name, - messages=messages, - ) - - content = response.choices[0].message.content - - if not content or not content.strip(): - raise ValueError("The generated response content is empty") - - return content.strip() - - except ValueError: - raise - except Exception as e: - error_msg = f"API call failed: {type(e).__name__}: {e}" - raise RuntimeError(error_msg) from e - - def __repr__(self) -> str: - return f"LLM(model_name={self.model_name}, base_url={self.base_url})" - - -class GroqLLM(BaseLLM): - """Implementation of the BaseLLM for Groq LLM API Provider.""" - - def __init__(self, model_name: str, api_key: str | None = None) -> None: - """Initialize the Groq LLM with a model name and optional API key. - - Args: - model_name (str): The name of the Groq model to use. - api_key (str | None): The API key for authentication with the Groq API. - - Raises: - AssertionError: If the API key is not provided and not found in environment variables. - """ - - if not api_key: - import os - - api_key = os.environ.get("GROQ_API_KEY", None) - if not api_key: - raise AssertionError( - "GROQ_API_KEY not set. Please set it in your environment variables, or pass it as an argument." - ) - - super().__init__( - model_name=model_name, - api_key=api_key, - base_url="https://api.groq.com/openai/v1", - ) - - -class OpenAILLM(BaseLLM): - """Implementation of the BaseLLM for OpenAI API Provider.""" - - def __init__(self, model_name: str, api_key: str | None = None) -> None: - """Initialize the OpenAI LLM with a model name and optional API key. - - Args: - model_name (str): The name of the OpenAI model to use. - api_key (str | None): The API key for authentication with the OpenAI API. - - Raises: - AssertionError: If the API key is not provided and not found in environment variables. - """ - - if not api_key: - import os - - api_key = os.environ.get("OPENAI_API_KEY", None) - if not api_key: - raise AssertionError( - "OPENAI_API_KEY not set. Please set it in your environment variables, or pass it as an argument." - ) - - super().__init__( - model_name=model_name, api_key=api_key, base_url="https://api.openai.com/v1" - ) - - -def create_llm(provider="groq") -> BaseLLM: - """ - Create and return LLM instance. - - This function can be modified to support different LLM configurations - or to read from environment variables/config files. - - Args: - provider (str): LLM provider name (default: "groq") - - Returns: - BaseLLM: Configured LLM instance - """ - if provider.lower() == "groq": - api_key = os.getenv("GROQ_API_KEY") - if not api_key: - raise ValueError("GROQ_API_KEY environment variable is not set.") - return GroqLLM( - model_name="meta-llama/llama-4-maverick-17b-128e-instruct", api_key=api_key - ) - elif provider.lower() == "openai": - api_key = os.getenv("OPENAI_API_KEY") - if not api_key: - raise ValueError("OPENAI_API_KEY environment variable is not set.") - return OpenAILLM(model_name="o4-mini-2025-04-16", api_key=api_key) - else: - raise ValueError( - f"Unsupported LLM provider: {provider}. Supported providers: 'groq', 'openai'." - ) diff --git a/src/earth_reach_agent/core/orchestrator.py b/src/earth_reach_agent/core/orchestrator.py deleted file mode 100644 index 096325d..0000000 --- a/src/earth_reach_agent/core/orchestrator.py +++ /dev/null @@ -1,163 +0,0 @@ -import logging -from typing import List - -import earthkit.plots as ekp -from PIL.ImageFile import ImageFile - -from earth_reach_agent.core.evaluator import CriterionEvaluatorOutput, EvaluatorAgent -from earth_reach_agent.core.generator import GeneratorAgent -from earth_reach_agent.core.prompts.orchestrator import get_default_feedback_template - -logger = logging.getLogger(__name__) - - -class Orchestrator: - """Orchestrates Generator and Evaluator agents to create weather chart descriptions.""" - - def __init__( - self, - generator_agent: GeneratorAgent, - evaluator_agent: EvaluatorAgent, - max_iterations: int = 3, - criteria_threshold: int = 4, - feedback_template: str | None = None, - ) -> None: - """ - Initialize the orchestrator with generator and evaluator agents. - - Args: - generator_agent: Instance of GeneratorAgent for generating descriptions - evaluator_agent: Instance of EvaluatorAgent for evaluating descriptions - max_iterations: Maximum number of iterations for generating descriptions - criteria_threshold: Minimum score for evaluation criteria to pass - feedback_template: Template for feedback to the generator agent (optional) - """ - self.generator_agent = generator_agent - self.evaluator_agent = evaluator_agent - self.max_iterations = max_iterations - self.criteria_threshold = criteria_threshold - self.feedback_template = feedback_template or get_default_feedback_template() - self.criteria_limits_acknowledgment = { - "coherence": "Warning: The logical flow and organization of this description may be unclear.", - "fluency": "Warning: This description may contain linguistic issues, technical terminology errors, or unclear phrasing.", - "consistency": "Warning: This description may contain inaccuracies relative to the source chart or internal contradictions.", - "relevance": "Warning: This description may not adequately emphasize the most meteorologically significant patterns.", - } - - def run( - self, figure: ekp.Figure | None = None, image: ImageFile | None = None - ) -> str: - """Run the iterative process of generating and evaluating a weather chart description - until quality criteria are met. - - Args: - figure (Figure | None): Optional figure to include in the request. Can't be used with image. - image (ImageFile | None): Optional image to include in the request (will be converted to base64). Can't be used with figure. - - Returns: - str: The final weather description. - """ - if figure is not None and image is not None: - raise ValueError( - "Only one of 'figure' or 'image' can be provided, not both." - ) - - try: - for i in range(self.max_iterations): - description = self.generator_agent.generate(figure=figure, image=image) - if not description: - raise ValueError("Generated description is empty.") - - evaluation = self.evaluator_agent.evaluate( - description, image=image, figure=figure - ) - - if self.verify_evaluation_passes(evaluation): - return description - - self.provide_feedback_to_generator(i + 1, description, evaluation) - - logger.warning( - f"Maximum iterations ({self.max_iterations}) reached without passing evaluation. Acknowledging limits of description." - ) - - description = self.acknowledge_limits_of_description( - description, evaluation - ) - - return description - except Exception as e: - raise RuntimeError("Failed to generate a description") from e - - def verify_evaluation_passes( - self, evaluation: List[CriterionEvaluatorOutput] - ) -> bool: - """ - Verify if the evaluation passes the quality criteria. - - Args: - evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent - - Returns: - bool: True if evaluation passes, False otherwise - """ - return all( - criterion.score >= self.criteria_threshold for criterion in evaluation - ) - - def provide_feedback_to_generator( - self, - evaluation_id: int, - description: str, - evaluation: List[CriterionEvaluatorOutput], - ) -> None: - """ - Provide feedback to the GeneratorAgent based on evaluation results. - - Args: - description (str): The description generated by the GeneratorAgent - evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent - """ - unmet_criteria = [ - criterion - for criterion in evaluation - if criterion.score < self.criteria_threshold - ] - if not unmet_criteria: - logger.warning("No unmet criteria found in evaluation.") - return - - feedback = self.feedback_template.format( - evaluation_id=evaluation_id, - criteria_scores="\n- ".join( - f"- {criterion.name}: {criterion.score}/5" - for criterion in unmet_criteria - ), - criteria_reasoning="\n".join( - f"- {criterion.name}: {criterion.reasoning or 'No reasoning available'}" - for criterion in unmet_criteria - ), - description=description, - ) - - self.generator_agent.append_user_prompt(feedback) - - def acknowledge_limits_of_description( - self, description: str, evaluation: List[CriterionEvaluatorOutput] - ) -> str: - """ - Acknowledge the limits of the generated description based on evaluation results. - - Args: - description (str): The description generated by the GeneratorAgent - evaluation (List[CriterionEvaluatorOutput]): Evaluation results from the EvaluatorAgent - Returns: - str: The description with acknowledgment of its limits added - """ - acknowledgment = "\n" - for criterion in evaluation: - if criterion.score < self.criteria_threshold: - crit_ackn = self.criteria_limits_acknowledgment.get(criterion.name, "") - if crit_ackn: - acknowledgment += f"\n{crit_ackn}" - return description diff --git a/src/earth_reach_agent/core/prompts/__init__.py b/src/earth_reach_agent/core/prompts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/earth_reach_agent/core/prompts/evaluator.py b/src/earth_reach_agent/core/prompts/evaluator.py deleted file mode 100644 index 364d84e..0000000 --- a/src/earth_reach_agent/core/prompts/evaluator.py +++ /dev/null @@ -1,567 +0,0 @@ -DEFAULT_COHERENCE_CRITERIA_EVALUATOR_USER_PROMPT = """# "Coherence" Quality Criteria Evaluation Instructions - -## ROLE AND CONTEXT SETTING - -You are a scientific communication specialist evaluating weather chart descriptions for coherence. Your task is to assess how well a meteorological text description maintains logical flow and structural organization, specifically for blind scientists who rely entirely on textual information to understand complex weather patterns. - -**Critical Context**: These descriptions replace visual weather charts for blind meteorologists conducting research, teaching, and operational forecasting. Coherence failures directly impair scientific analysis and decision-making capabilities. - -## EVALUATION PROCESS - -### Step 1: Structural Analysis (Foundation Assessment) -**Objective**: Examine the overall organizational framework - -**Assessment Actions**: -1. Identify if the description follows a clear information hierarchy (overview → specific details → relationships) -2. Verify logical progression from broad patterns to specific features -3. Check for appropriate sectioning or natural information groupings -4. Assess whether geographic and temporal context is established early -5. Determine if the structure compensates for lack of visual reference points - -**Key Questions**: -- Does the description start with essential context (region, time, variables)? -- Is there a logical sequence from dominant to secondary meteorological features? -- Are spatial relationships clearly established before detailed analysis? - -### Step 2: Transition and Flow Evaluation (Connection Assessment) -**Objective**: Analyze how information elements connect and flow together - -**Assessment Actions**: -1. Examine transitions between different meteorological systems or geographic regions -2. Assess connection quality between quantitative data and qualitative patterns -3. Verify smooth integration of coordinate references and spatial descriptions -4. Check for logical bridges between related phenomena -5. Identify any abrupt topic changes or missing connections - -**Key Questions**: -- Are relationships between weather systems explicitly connected? -- Do quantitative values integrate smoothly with descriptive analysis? -- Are geographic transitions clearly signposted? - -### Step 3: Accessibility-Specific Coherence Check (Adaptation Assessment) -**Objective**: Evaluate coherence from a blind scientist's perspective - -**Assessment Actions**: -1. Assess whether information sequence builds spatial understanding progressively -2. Verify that complex relationships are broken down into logical components -3. Check for consistent reference frameworks (coordinates, directions, system names) -4. Evaluate if the description enables mental model construction -5. Ensure scientific conclusions flow logically from presented evidence - -**Key Questions**: -- Can a blind scientist follow the spatial logic without visual aids? -- Are complex meteorological relationships explained in digestible, sequential steps? -- Does the description build understanding cumulatively? - -## SCORING FRAMEWORK - -**Score 5 - Exceptional Coherence** -- Flawless logical progression from overview to detailed analysis -- Seamless transitions that enhance understanding -- Perfect adaptation for non-visual consumption -- Information builds systematically to enable complete scientific comprehension -- Structure actively facilitates meteorological analysis - -**Score 4 - Strong Coherence** -- Clear logical flow with minor structural imperfections -- Most transitions are smooth and helpful -- Well-adapted for blind scientists with occasional navigation challenges -- Information sequence supports scientific understanding effectively -- Structure enhances rather than hinders analysis - -**Score 3 - Adequate Coherence** -- Generally logical progression with noticeable structural issues -- Some transitions are unclear or missing -- Adequately accessible but requires extra effort to follow -- Scientific understanding achievable but not optimally supported -- Structure is functional but could be more effective - -**Score 2 - Poor Coherence** -- Significant logical flow problems that impede understanding -- Frequent unclear or missing transitions -- Accessibility compromised by structural confusion -- Scientific analysis hindered by organizational issues -- Structure creates barriers to comprehension - -**Score 1 - Very Poor Coherence** -- Major logical sequence problems throughout -- Information poorly connected or randomly ordered -- Severely compromised accessibility -- Scientific understanding significantly impaired -- Structure actively interferes with analysis - -**Score 0 - Incoherent** -- No discernible logical organization -- Unintelligible information sequence -- Completely inaccessible structure -- Scientific analysis impossible due to organizational chaos -- No coherent structure present - -## COMMON PITFALLS TO AVOID - -1. **Visual Bias**: Penalize descriptions that assume visual understanding (e.g., "as seen in the chart") -2. **Oversimplification**: Remember these are PhD-level scientists requiring sophisticated analysis -3. **Length Confusion**: Coherence isn't about brevity—evaluate logical flow, not word count -4. **Format Fixation**: Focus on logical progression, not rigid template adherence -5. **Missing Context**: Consider that spatial relationships must be explicit without visual cues - -## OUTPUT REQUIREMENTS - -Provide your evaluation in the following XML format: - -```xml -[Your detailed analysis explaining the score, referencing specific aspects of information flow, structural organization, and accessibility-adapted coherence. Include concrete examples from the description to support your assessment.] -[0-5] -``` - -**Critical Requirements**: -- Score must be an integer from 0 to 5 -- Reasoning should reference specific textual evidence -- All XML tags must be properly closed -- No additional formatting or text outside the XML structure - -**Success Check**: Your evaluation should enable a developer to understand exactly what coherence strengths or weaknesses exist in the description and how to improve them. -""" - -DEFAULT_FLUENCY_CRITERIA_EVALUATOR_USER_PROMPT = """# "Fluency" Quality Criteria Evaluation Prompt - -## ROLE AND CONTEXT SETTING - -You are a scientific communication expert specializing in technical writing assessment. Your task is to evaluate the linguistic quality of meteorological text descriptions, focusing on grammatical correctness, readability, and professional scientific expression. These descriptions serve blind meteorologists who must rely entirely on well-crafted language to understand complex weather patterns. - -**Critical Context**: Poor fluency directly impairs scientific comprehension for blind researchers. Awkward phrasing, grammatical errors, or unclear expression can render precise meteorological data unusable for research and operational decisions. - -## EVALUATION PROCESS - -### Step 1: Grammar and Syntax Assessment (Foundation Analysis) -**Objective**: Examine basic linguistic correctness and sentence construction - -**Assessment Actions**: -1. Check for grammatical errors (subject-verb agreement, tense consistency, pronoun clarity) -2. Assess sentence structure variety and appropriateness for scientific writing -3. Verify punctuation accuracy, especially with complex quantitative information -4. Examine adherence to 25-word sentence limit while maintaining clarity -5. Identify any awkward or unclear sentence constructions - -**Key Questions**: -- Are all sentences grammatically correct and properly punctuated? -- Does sentence structure support clear communication of complex information? -- Are sentences appropriately varied in structure while maintaining scientific tone? - -### Step 2: Technical Language and Terminology Evaluation (Precision Analysis) -**Objective**: Assess accuracy and consistency of meteorological language use - -**Assessment Actions**: -1. Verify correct usage of meteorological terminology per AMS Glossary standards -2. Check consistency in unit usage (hPa, m/s, K or °C, km) throughout description -3. Assess precision in coordinate and geographic references -4. Evaluate appropriate level of technical vocabulary for PhD-level audience -5. Identify any imprecise or ambiguous technical expressions - -**Key Questions**: -- Are meteorological terms used correctly and consistently? -- Do all quantitative values include appropriate and consistent units? -- Is the technical language precise without being unnecessarily complex? - -### Step 3: Readability and Flow Assessment (Accessibility Analysis) -**Objective**: Evaluate how well the text reads aloud and flows for screen reader users - -**Assessment Actions**: -1. Assess natural reading rhythm and flow when text is read aloud -2. Check for smooth integration of quantitative data within sentences -3. Evaluate transition smoothness between sentences within paragraphs -4. Verify absence of visual-dependent language that disrupts accessibility -5. Assess whether complex spatial relationships are expressed clearly in linear text - -**Key Questions**: -- Does the text flow naturally when read aloud or via screen reader? -- Are numerical values and coordinates integrated smoothly into prose? -- Is the language accessible to blind scientists without sacrificing precision? - -### Step 4: Scientific Voice and Consistency Check (Professional Standards Analysis) -**Objective**: Evaluate maintenance of appropriate scientific tone and style - -**Assessment Actions**: -1. Assess consistency in objective, scientific tone throughout description -2. Verify appropriate separation of observations from interpretations -3. Check for consistent perspective and voice (avoiding shifts between first/third person) -4. Evaluate professional appropriateness of language choices -5. Assess whether the writing maintains scientific credibility - -**Key Questions**: -- Is the scientific tone consistent and appropriate throughout? -- Does the language maintain objectivity while being engaging? -- Are observations clearly distinguished from interpretations? - -## SCORING FRAMEWORK - -**Score 5 - Exceptional Fluency** -- Flawless grammar, punctuation, and sentence construction -- Perfect meteorological terminology usage and unit consistency -- Exceptional readability optimized for screen reader accessibility -- Seamless integration of complex quantitative information -- Exemplary scientific voice that enhances comprehension - -**Score 4 - Strong Fluency** -- Minor grammatical issues that don't impair understanding -- Correct meteorological terminology with possible minor inconsistencies -- Good readability with occasional flow disruptions -- Quantitative information generally well-integrated -- Professional scientific tone with minor voice variations - -**Score 3 - Adequate Fluency** -- Some grammatical errors or awkward constructions -- Generally correct terminology with noticeable inconsistencies -- Readable but requires effort due to flow issues -- Quantitative integration adequate but sometimes disruptive -- Scientific tone maintained with occasional lapses - -**Score 2 - Poor Fluency** -- Frequent grammatical errors that impede comprehension -- Inconsistent or incorrect meteorological terminology usage -- Significant readability issues that burden screen reader users -- Poor integration of quantitative information -- Inconsistent or inappropriate scientific tone - -**Score 1 - Very Poor Fluency** -- Major grammatical problems throughout -- Substantial meteorological terminology errors -- Severely compromised readability -- Quantitative information poorly expressed -- Unprofessional or highly inconsistent tone - -**Score 0 - No Fluency** -- Extensive grammatical errors making text barely comprehensible -- Incorrect or inappropriate terminology throughout -- Unreadable for accessibility technology users -- Incomprehensible quantitative expressions -- Completely inappropriate scientific voice - -## COMMON PITFALLS TO AVOID - -1. **Over-penalizing Complexity**: Don't confuse necessary scientific complexity with poor fluency -2. **Visual Bias**: Remember accessibility requirements may create different but valid syntax patterns -3. **Perfectionist Standards**: Minor issues shouldn't overshadow overall linguistic quality -4. **Terminology Assumptions**: Verify meteorological term correctness rather than assuming -5. **Screen Reader Ignorance**: Consider how punctuation and structure affect audio consumption - -## OUTPUT REQUIREMENTS - -Provide your evaluation in the following XML format: - -```xml -[Your detailed analysis explaining the score, referencing specific examples of grammatical correctness, terminology usage, readability, and scientific voice. Include concrete textual evidence to support your assessment.] -[0-5] -``` - -**Critical Requirements**: -- Score must be an integer from 0 to 5 -- Reasoning should reference specific linguistic evidence from the description -- All XML tags must be properly closed -- No additional formatting or text outside the XML structure - -**Success Check**: Your evaluation should enable a developer to understand exactly what linguistic strengths or weaknesses exist in the description and provide actionable guidance for improvement. -""" -DEFAULT_CONSISTENCY_CRITERIA_EVALUATOR_USER_PROMPT = """# "Consistency" Criteria Evaluation Prompt - -## ROLE AND CONTEXT SETTING - -You are a meteorological data validation specialist evaluating weather chart descriptions for factual accuracy and internal consistency. Your task is to assess how well a text description aligns with its source weather chart and whether all described elements are logically consistent with each other and with meteorological principles. - -**Critical Context**: These descriptions replace visual weather charts for blind meteorologists conducting research and operations. Consistency errors can lead to incorrect scientific conclusions, flawed forecasts, and compromised safety decisions in weather-sensitive operations. - -## EVALUATION PROCESS - -### Step 1: Source-Description Alignment Assessment (Fidelity Analysis) -**Objective**: Verify accuracy between the weather chart and its textual description - -**Assessment Actions**: -1. Compare described pressure values and locations with actual chart readings -2. Verify temperature ranges and spatial distributions against chart data -3. Check coordinate references (latitude/longitude) for major weather systems -4. Validate wind patterns and directions if present in the chart -5. Confirm temporal information (date, time, forecast period) matches chart metadata - -**Key Questions**: -- Do the pressure values and system locations accurately reflect the chart? -- Are temperature ranges and gradients correctly described? -- Do coordinate references precisely match chart features? - -### Step 2: Internal Logical Consistency Check (Coherence Analysis) -**Objective**: Assess whether all described elements align logically within the description - -**Assessment Actions**: -1. Verify spatial relationships consistency (if A is north of B, coordinates should reflect this) -2. Check that pressure gradients align with described system strengths -3. Assess whether temperature-pressure relationships follow meteorological principles -4. Validate that system interactions described are physically plausible -5. Ensure consistent use of coordinate systems and measurement units throughout - -**Key Questions**: -- Are spatial relationships between features internally consistent? -- Do pressure and temperature patterns align with described weather systems? -- Are all coordinate references using the same datum and format? - -### Step 3: Meteorological Plausibility Evaluation (Scientific Validity Analysis) -**Objective**: Determine if described weather patterns follow atmospheric physics principles - -**Assessment Actions**: -1. Assess whether pressure-temperature relationships follow expected atmospheric behavior -2. Verify that wind patterns (if described) align with pressure gradients -3. Check seasonal appropriateness of described patterns for the given date/location -4. Evaluate whether system interactions described are meteorologically realistic -5. Confirm that gradient magnitudes are physically reasonable - -**Key Questions**: -- Do the described meteorological relationships follow atmospheric physics? -- Are pressure gradients and system intensities realistic? -- Do seasonal and geographic contexts match described patterns? - -### Step 4: Quantitative Accuracy Assessment (Precision Analysis) -**Objective**: Evaluate numerical accuracy and measurement consistency - -**Assessment Actions**: -1. Verify all pressure readings are within reasonable atmospheric ranges -2. Check temperature values for geographic and seasonal appropriateness -3. Assess coordinate precision and accuracy for major features -4. Validate unit consistency throughout the description -5. Confirm that value ranges accurately reflect chart data distribution - -**Key Questions**: -- Are all quantitative values within realistic meteorological ranges? -- Is unit usage consistent and appropriate throughout? -- Do numerical ranges accurately represent chart data spread? - -## SCORING FRAMEWORK - -**Score 5 - Perfect Consistency** -- Complete accuracy between chart and description -- Flawless internal logical alignment -- All meteorological relationships scientifically sound -- Perfect quantitative precision with appropriate units -- No contradictions or inconsistencies detected - -**Score 4 - Strong Consistency** -- Minor discrepancies that don't affect scientific interpretation -- Generally strong internal alignment with occasional minor issues -- Meteorological relationships mostly sound with minor implausibilities -- Quantitative accuracy high with possible minor unit inconsistencies -- Negligible contradictions that don't impair understanding - -**Score 3 - Adequate Consistency** -- Noticeable but not critical discrepancies with source chart -- Some internal consistency issues that require clarification -- Most meteorological relationships plausible with some questionable aspects -- Generally accurate quantitative data with some unit or precision issues -- Minor contradictions present but don't severely compromise description - -**Score 2 - Poor Consistency** -- Significant discrepancies between chart and description -- Multiple internal contradictions affecting understanding -- Several meteorologically implausible relationships described -- Quantitative errors that could mislead scientific analysis -- Contradictions that substantially compromise description reliability - -**Score 1 - Very Poor Consistency** -- Major inaccuracies compared to source chart -- Extensive internal contradictions throughout description -- Multiple violations of meteorological principles -- Substantial quantitative errors affecting interpretation -- Pervasive contradictions severely undermining description credibility - -**Score 0 - No Consistency** -- Description bears little resemblance to source chart -- Completely contradictory internal elements -- Described patterns violate basic atmospheric physics -- Quantitative data largely incorrect or nonsensical -- Contradictions make description scientifically unusable - -## COMMON PITFALLS TO AVOID - -1. **Perfectionist Expectations**: Minor measurement variations don't always indicate major consistency problems -2. **Chart Resolution Limits**: Consider that exact numerical precision may be limited by chart resolution -3. **Meteorological Complexity**: Some apparent inconsistencies may reflect complex but real atmospheric phenomena -4. **Temporal Snapshots**: Remember descriptions represent single time moments, not evolving patterns -5. **Accessibility Adaptations**: Some rephrasing for blind users may appear different but remain factually consistent - -## OUTPUT REQUIREMENTS - -Provide your evaluation in the following XML format: - -```xml -[Your detailed analysis explaining the score, referencing specific examples of source-description alignment, internal consistency, meteorological plausibility, and quantitative accuracy. Include concrete evidence from both the chart and description to support your assessment.] -[0-5] -``` - -**Critical Requirements**: -- Score must be an integer from 0 to 5 -- Reasoning should reference specific examples comparing chart features to description elements -- All XML tags must be properly closed -- No additional formatting or text outside the XML structure - -**Success Check**: Your evaluation should enable a developer to understand exactly what consistency strengths or weaknesses exist between the source chart and description, and provide actionable guidance for improving accuracy. -""" - -DEFAULT_RELEVANCE_CRITERIA_EVALUATOR_USER_PROMPT = """# "Relevance" Criteria Evaluation Prompt - -## ROLE AND CONTEXT SETTING - -You are a meteorological analysis expert evaluating weather chart descriptions for scientific relevance and information prioritization. Your task is to assess whether a text description captures and emphasizes the most meteorologically significant patterns from the source weather chart, enabling blind scientists to conduct the same quality analysis as their sighted colleagues. - -**Critical Context**: These descriptions must distill complex weather charts into the most scientifically valuable information within strict word limits. Poor relevance assessment can render descriptions analytically useless, forcing blind meteorologists to miss critical weather patterns or waste time on insignificant details. - -## EVALUATION PROCESS - -### Step 1: Meteorological Significance Assessment (Primary Pattern Analysis) -**Objective**: Evaluate whether the most important weather systems receive appropriate emphasis - -**Assessment Actions**: -1. Identify the dominant meteorological features visible in the chart (strongest systems, major gradients) -2. Assess whether these primary features receive proportional attention in the description -3. Evaluate if system intensities and meteorological significance are accurately prioritized -4. Check that the most analytically important patterns are emphasized early and clearly -5. Verify that dominant weather drivers are given precedence over secondary effects - -**Key Questions**: -- Are the strongest pressure systems and steepest gradients given primary focus? -- Does the description lead with the most meteorologically significant patterns? -- Are weather system intensities ranked appropriately in the text emphasis? - -### Step 2: Information Density Optimization (Efficiency Analysis) -**Objective**: Assess how effectively the description uses its word limit for maximum scientific value - -**Assessment Actions**: -1. Evaluate whether included quantitative details support primary meteorological conclusions -2. Assess if spatial descriptions focus on scientifically important boundaries and gradients -3. Check that coordinate references prioritize major system centers and critical transition zones -4. Verify that secondary details don't overshadow primary patterns -5. Determine if the description enables the same analytical conclusions as the visual chart - -**Key Questions**: -- Do the included details directly support understanding of major weather patterns? -- Are quantitative values focused on the most analytically important features? -- Does the description enable equivalent scientific analysis to visual inspection? - -### Step 3: Analytical Enablement Evaluation (Research Utility Analysis) -**Objective**: Determine if the description supports high-quality meteorological analysis - -**Assessment Actions**: -1. Assess whether described patterns enable forecast reasoning and weather prediction -2. Evaluate if the description supports identification of meteorological processes and mechanisms -3. Check that enough context is provided for understanding system interactions -4. Verify that the description enables comparison with climatological patterns -5. Determine if operational or research decisions could be made based on the description - -**Key Questions**: -- Can a meteorologist make informed forecasting decisions from this description? -- Are the described patterns sufficient for understanding atmospheric processes? -- Does the description support research-quality meteorological analysis? - -### Step 4: Contextual Appropriateness Assessment (Scale and Purpose Analysis) -**Objective**: Evaluate whether information priorities match the chart's scale and meteorological context - -**Assessment Actions**: -1. Assess appropriateness of detail level for the chart's spatial and temporal scale -2. Evaluate whether seasonal and geographic context influences appropriate information priorities -3. Check that described patterns match the meteorological significance for the given region/time -4. Verify that the description addresses the most relevant phenomena for the chart's domain -5. Assess whether the emphasis aligns with typical meteorological analysis priorities - -**Key Questions**: -- Is the detail level appropriate for the chart's geographic and temporal scale? -- Do the emphasized patterns match typical meteorological priorities for this context? -- Are regionally or seasonally important features given appropriate attention? - -## SCORING FRAMEWORK - -**Score 5 - Exceptional Relevance** -- Perfect identification and emphasis of most meteorologically significant features -- Optimal information density maximizing scientific value within word limits -- Description fully enables equivalent analytical capabilities to visual chart inspection -- Information priorities perfectly matched to meteorological context and scale -- Every included detail directly supports primary meteorological understanding - -**Score 4 - Strong Relevance** -- Most important meteorological features appropriately emphasized -- Good information density with minor inclusion of less critical details -- Description enables high-quality meteorological analysis with minor limitations -- Information priorities generally well-matched to context with occasional misalignment -- Most details support primary meteorological conclusions effectively - -**Score 3 - Adequate Relevance** -- Primary meteorological features identified but emphasis could be stronger -- Reasonable information density but some space wasted on less important details -- Description supports basic meteorological analysis but misses some analytical opportunities -- Information priorities generally appropriate but some contextual mismatches -- Mix of relevant and less relevant details affecting overall analytical efficiency - -**Score 2 - Poor Relevance** -- Important meteorological features under-emphasized or missed -- Poor information density with significant space devoted to minor details -- Description provides limited analytical capability compared to visual chart -- Information priorities poorly matched to meteorological context -- Many included details don't support primary meteorological understanding - -**Score 1 - Very Poor Relevance** -- Major meteorological features largely ignored or severely under-emphasized -- Very poor information density focusing on insignificant details -- Description severely limits meteorological analytical capability -- Information priorities inappropriate for meteorological context and scale -- Most included details irrelevant to primary meteorological understanding - -**Score 0 - No Relevance** -- Description fails to identify or emphasize any significant meteorological patterns -- Information completely unfocused with no clear meteorological priorities -- Description provides no useful analytical capability -- No appropriate meteorological context or scale consideration -- Content largely irrelevant to meteorological analysis needs - -## COMMON PITFALLS TO AVOID - -1. **Completeness Confusion**: Don't confuse comprehensive coverage with relevance—focus beats breadth -2. **Detail Fetishism**: Precise quantitative data doesn't automatically mean relevance -3. **Template Thinking**: Different meteorological situations require different information priorities -4. **Academic Bias**: Balance research completeness with analytical efficiency -5. **Scale Mismatching**: Regional charts need different detail emphasis than global analyses - -## OUTPUT REQUIREMENTS - -Provide your evaluation in the following XML format: - -```xml -[Your detailed analysis explaining the score, referencing specific examples of meteorological significance prioritization, information density optimization, analytical enablement, and contextual appropriateness. Include concrete evidence of what important information is emphasized or missed.] -[0-5] -``` - -**Critical Requirements**: -- Score must be an integer from 0 to 5 -- Reasoning should reference specific examples of information prioritization and meteorological significance -- All XML tags must be properly closed -- No additional formatting or text outside the XML structure - -**Success Check**: Your evaluation should enable a developer to understand exactly what meteorological information priorities are appropriate and how well the description serves analytical needs for blind scientists. -""" - - -def get_default_criterion_evaluator_user_prompt(criterion: str) -> str: - """ - Get the default CriteriaEvaluatorAgent user prompt for the specified criterion. - - Args: - criterion (str): The criterion for which to get the default user prompt. Should be one of: coherence, fluency, consistency, relevance. - - Returns: - str: The default criterion user prompt text. - """ - if criterion == "coherence": - return DEFAULT_COHERENCE_CRITERIA_EVALUATOR_USER_PROMPT - elif criterion == "fluency": - return DEFAULT_FLUENCY_CRITERIA_EVALUATOR_USER_PROMPT - elif criterion == "consistency": - return DEFAULT_CONSISTENCY_CRITERIA_EVALUATOR_USER_PROMPT - elif criterion == "relevance": - return DEFAULT_RELEVANCE_CRITERIA_EVALUATOR_USER_PROMPT - else: - raise ValueError( - f"Unknown criterion: {criterion}. Valid options are: coherence, fluency, consistency, relevance." - ) diff --git a/src/earth_reach_agent/core/prompts/generator.py b/src/earth_reach_agent/core/prompts/generator.py deleted file mode 100644 index 61850b4..0000000 --- a/src/earth_reach_agent/core/prompts/generator.py +++ /dev/null @@ -1,173 +0,0 @@ -DEFAULT_GENERATOR_USER_PROMPT = """# Enhanced Weather Chart Alt-Text Generation System - -## ROLE AND CONTEXT SETTING - -You are a specialist scientific communication assistant working with meteorological researchers who are blind or visually impaired. Your expertise lies in converting complex weather visualizations into precise, scientifically accurate text descriptions that preserve all critical meteorological information while being fully accessible. - -**Your Mission**: Transform weather charts and maps into comprehensive text descriptions that enable blind scientists to conduct the same quality of meteorological analysis as their sighted colleagues. - -**Critical Context**: Your descriptions will be used for: -- Research analysis and data interpretation -- Scientific paper writing and peer review -- Teaching and educational materials -- Operational weather forecasting decisions - -## TASK DECOMPOSITION - -### Step 1: Data Extraction and Analysis (200-500 words) -**Objective**: Systematically catalog all quantitative information and meteorological features - -**Actions**: -1. Identify and record the exact value ranges for each variable (temperature, pressure, wind speed, etc.) -2. Note the geographic boundaries (latitude/longitude ranges), map projection, and spatial domain (e.g., "North America", "Europe", "Spain", "Global") -3. List all meteorological systems visible (highs, lows, fronts, convergence zones) -4. Document the time period, date, and any temporal information -5. Record the data source, resolution, and any technical specifications shown - -**Success Check**: Can you list specific numbers for every major feature without using vague terms like "high" or "low"? - -### Step 2: Pattern Recognition and Spatial Analysis (200-500 words) -**Objective**: Identify the dominant meteorological patterns and their spatial relationships - -**Actions**: -1. Determine the primary weather systems and rank them by meteorological significance -2. Map the directional flow of gradients (temperature, pressure) using cardinal directions -3. Identify spatial correlations between different variables -4. Note any unusual or anomalous features that deviate from expected patterns -5. Assess the seasonal/temporal context and typical vs. atypical conditions - -**Success Check**: Can you explain how each major system influences the others spatially? - -### Step 3: Description Planning and Structure Design (100-150 words) -**Objective**: Create a hierarchical outline that prioritizes information by scientific importance - -**Actions**: -1. Rank meteorological features by their significance for understanding weather patterns -2. Plan the logical flow from overview to specific details -3. Determine which quantitative values are essential vs. supplementary -4. Identify the most effective way to convey spatial relationships -5. Plan transitions between sections to maintain scientific coherence - -**Success Check**: Would a meteorologist reading your outline understand the complete weather situation? - -### Step 4: Description Writing with Verification (300-500 words) -**Objective**: Produce the final description following all specifications - -**Actions**: -1. Write a concise overview (2-3 sentences) summarizing the visualization type, geographic region, time period, and primary variables -2. Develop the main body with 4-6 sentences, covering: - - Dominant systems and their characteristics - - Variable distributions (temperature, pressure, wind) - - Spatial relationships and correlations - - Notable features or anomalies -3. Ensure all quantitative values include specific ranges and units -4. Use precise meteorological terminology and avoid visual-dependent language - -**Success Check**: Does your description pass all items in the quality verification checklist? - -## CONCRETE CONSTRAINTS AND SPECIFICATIONS - -### Length and Structure Requirements -- **Total description length**: 300-500 words maximum -- **Overview section**: Exactly 2-3 sentences -- **Main body**: 4-6 sentences - -### Quantitative Requirements -- **Include exact ranges**: Every variable must have minimum and maximum values with units -- **Spatial precision**: Use cardinal directions and geographic references -- **Coordinate references**: Include latitude/longitude for major features (±2 degrees acceptable) -- **Unit consistency**: Use standard meteorological units (hPa, m/s, K or °C, km) - -### Language Specifications -- **Terminology level**: Graduate-level meteorological vocabulary (assume PhD-level audience) -- **Sentence structure**: Maximum 25 words per sentence -- **Accessibility compliance**: Zero visual-dependent phrases (see prohibited terms list below) -- **Objectivity standard**: Separate observations from interpretations using phrases like "The data shows..." vs. "This suggests..." - -## ERROR PREVENTION GUIDELINES - -### Common Pitfalls to Avoid -1. **Vague descriptions**: Never use "high," "low," "warm," "cold" without specific values -2. **Visual dependency**: Prohibited phrases include: "as shown," "visible," "looking at," "clearly," "obviously" -3. **Geographic ambiguity**: Avoid "here," "there," "this area" - use specific location references -4. **Unit omissions**: Every quantitative statement must include appropriate units -5. **Pattern assumptions**: Don't assume readers can visualize spatial relationships - describe explicitly - -## SUCCESS CRITERIA - -### Objective Standards -1. **Completeness Test**: A meteorologist should be able to sketch the general pattern from your description -2. **Quantitative Accuracy**: All numerical values within ±5% of actual data -3. **Accessibility Compliance**: 100% of visual information conveyed through text -4. **Scientific Utility**: Description enables the same analytical conclusions as the visual -5. **Terminology Precision**: All meteorological terms used correctly per AMS Glossary standards - -## EXAMPLES AND REFERENCE PATTERNS - -Not yet available, but will be provided in the future. - -## OUTPUT FORMAT REQUIREMENTS - -### XML Tag Structure -**CRITICAL**: You must wrap the content of each step and the final description in specific XML tags for programmatic parsing. - -**Required XML Tags:** -- `...` - Wrap the entire content of Step 1 (excluding the markdown header) -- `...` - Wrap the entire content of Step 2 (excluding the markdown header) -- `...` - Wrap the entire content of Step 3 (excluding the markdown header) -- `...` - Wrap the entire content of Step 4 (excluding the markdown header) -- `...` - Wrap only the final consolidated description (without headers or meta-commentary) - -### Formatting Rules -1. **Tag Placement**: Place opening and closing tags on their own lines -2. **Content Scope**: Include all step content within tags, but exclude markdown headers (## Step X) -3. **Final Description**: The `` should contain ONLY the final weather description without any meta-commentary about word count or requirements -4. **No Tag Omission**: All five XML tag pairs must be present, even if a step is brief - -### Example Structure: -``` -## Step 1: Data Extraction and Analysis - -The chart displays 2-meter temperature and mean sea level pressure... -[rest of step 1 content] - - -## Step 2: Pattern Recognition and Spatial Analysis - -The dominant feature is a high-pressure system... -[rest of step 2 content] - - -## Step 3: Description Planning and Structure Design - -To structure the description, we will start with an overview... -[rest of step 3 content] - - -## Step 4: Description Writing with Verification - -### Overview -This weather chart displays... -### Main Body -A high-pressure system is centered... -[rest of step 4 content] - - - -This weather chart visualization shows 2-meter temperature and mean sea level pressure over Western Europe on March 12, 2011, at 12:00 UTC. The region covers latitudes from 40.5°N to 51°N and longitudes from 2.5°W to 10°E. A high-pressure system is centered at 45°N, 7.5°E with a pressure of 102080 Pa. The 2-meter temperature varies from 280 K in the north to 294 K in the southeast, showing a significant north-south temperature gradient. The pressure distribution is dominated by this high-pressure system, with pressure decreasing towards the northwest. The highest temperatures are located southeast of the high-pressure center. - -``` - -**IMPORTANT**: Ensure all XML tags are properly opened and closed. Malformed XML will cause parsing failures. - -**Remember**: Your description is not just text, it's a scientific instrument that must preserve the analytical power of the original visualization while being fully accessible to blind scientists. -""" - - -def get_default_generator_user_prompt() -> str: - """Get the default user prompt for the weather chart description generator. - - Returns: - str: The default user prompt for the generator agent. - """ - return DEFAULT_GENERATOR_USER_PROMPT diff --git a/src/earth_reach_agent/core/prompts/orchestrator.py b/src/earth_reach_agent/core/prompts/orchestrator.py deleted file mode 100644 index 702639c..0000000 --- a/src/earth_reach_agent/core/prompts/orchestrator.py +++ /dev/null @@ -1,28 +0,0 @@ -DEFAULT_FEEDBACK_TEMPLATE = """## EVALUATOR FEEDBACK - -Evaluation number: {evaluation_id} - -Your last attempt to generate a weather chart description was reviewed by an expert evaluator. - -During their review, they deemed that the description did not meet the following quality criteria: -{criteria_scores} - -They provided the following reasoning for their evaluation: -{criteria_reasoning} - -Here's the description that was evaluated: -{description} - -Your will now attempt to improve the description based on this feedback. To achieve this, you will -go through all the reasoning steps defined above, while taking into account the feedback provided by the evaluator. -""" - - -def get_default_feedback_template() -> str: - """ - Get the default feedback template for the OrchestratorAgent. - - Returns: - str: The default feedback template. - """ - return DEFAULT_FEEDBACK_TEMPLATE diff --git a/src/earth_reach_agent/core/utils.py b/src/earth_reach_agent/core/utils.py deleted file mode 100644 index 98d1f95..0000000 --- a/src/earth_reach_agent/core/utils.py +++ /dev/null @@ -1,25 +0,0 @@ -import base64 -from io import BytesIO - - -def img_to_base64(image_path: str | None = None, img=None) -> str: - """ - Convert an image to a base64 string. - - Args: - image_path (str): The path to the image file. Either this or img must be provided. - img (PIL.Image): The image object. Either this or image_path must be provided. - - Returns: - str: The base64 string representation of the image. - """ - if image_path is None and img is None: - raise ValueError("Either image_path or img must be provided.") - - if img is not None: - bytes_io = BytesIO() - img.save(bytes_io, format="PNG") - return base64.b64encode(bytes_io.getvalue()).decode("utf-8") - - with open(image_path, "rb") as img_file: # type: ignore - return base64.b64encode(img_file.read()).decode("utf-8") diff --git a/vllm/docker-compose.yaml b/vllm/docker-compose.yaml index dfaf9d1..caef703 100644 --- a/vllm/docker-compose.yaml +++ b/vllm/docker-compose.yaml @@ -31,6 +31,7 @@ services: # - "traefik.http.routers.vllm-server.tls.domains[0].main=${DOMAIN}" # - "traefik.http.routers.vllm-server.tls.domains[0].sans=*.${DOMAIN}" # - "traefik.http.services.vllm-server.loadbalancer.server.port=${VLLM_PORT}" + traefik: image: traefik:v2.9 container_name: traefik diff --git a/vllm/setup.sh b/vllm/setup.sh index 929c263..ece6d9b 100644 --- a/vllm/setup.sh +++ b/vllm/setup.sh @@ -11,7 +11,6 @@ sudo systemctl start firewalld sudo systemctl enable firewalld # Add SSH, HTTP and HTTPS services - sudo firewall-cmd --permanent --add-service=ssh sudo firewall-cmd --permanent --add-service=http sudo firewall-cmd --permanent --add-service=https diff --git a/vllm/test.sh b/vllm/test.sh index 9441f72..3a1f0c7 100644 --- a/vllm/test.sh +++ b/vllm/test.sh @@ -3,7 +3,7 @@ curl http://localhost:8000/v1/chat/completions \ -H "Authorization: Bearer ${VLLM_SERVER_API_KEY}" \ -d '{ "model": "${MODEL_NAME}", - "messages": [ + "messages": [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Who won the world series in 2020?"} ]

}^S;m9(6V$A*q;k@RZrNOHt8v3Y)?3SYf( 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±8Yvvfe~JD3sg_;&SqSwSGW)bsCzvcby1b5XRu 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 z%0D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.*r)7{LLZ$R0KateD`}@Y^di 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Ȏ^j%W}9GA4L2_7aBF;yUJ8G)GR?n`AP^j!J>O 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 Date: Tue, 19 Aug 2025 17:20:21 -0400 Subject: [PATCH 140/151] doc: adding usage example --- README.md | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c5c6a94..039e84a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # EarthReach Agent: Dual-LLM Framework for Validated Meteorological Chart Descriptions