From a7871368f10f320e7a095097177acc150db8941a Mon Sep 17 00:00:00 2001 From: taddyb Date: Tue, 23 Dec 2025 10:54:09 -0500 Subject: [PATCH 01/16] setup: setting up repo, license, base folders --- .gitignore | 241 ++++++++++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 30 +++++ LICENSE | 12 ++ README.md | 0 pyproject.toml | 142 +++++++++++++++++++++++ 5 files changed, 425 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c152544 --- /dev/null +++ b/.gitignore @@ -0,0 +1,241 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +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 + +# 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 +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# 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 +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# 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 +*.iml +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Data +data/* + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +_version.py + +*.zip +*.dbf +*.shp +*.prj +*.shx +*.gpkg +*.gpkg-shm +*.gpkg-wal +*.csv +*.parquet +*.tif +*.tfw +*.aux +*.htm +*.ovr +*.aux.xml +*.nc +*.vrt + +Pipfile +.vscode +.DS_Store + +# configs +configs/ + +# logs +logs/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a970a7f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + exclude: LICENSE|\.csv$ + - id: end-of-file-fixer + exclude: LICENSE|\.csv$ + - id: check-yaml + - id: debug-statements + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.0 + hooks: + - id: ruff-check + types_or: [ python, pyi ] + args: [ --fix ] + - id: ruff-format + types_or: [ python, pyi ] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.17.0 + hooks: + - id: mypy + additional_dependencies: ["types-PyYAML==6.0.12.20250516"] + + - repo: https://github.com/kynan/nbstripout + rev: 0.8.1 + hooks: + - id: nbstripout diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..41e23ca --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +Copyright 2025 Raytheon Company + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +Licensed under: https://opensource.org/license/bsd-2-clause + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +All rights reserved. Based on Government sponsored work under contract GS-35F-204GA. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1070436 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,142 @@ +[project] +name = "reference-builds" +version = "0.1.0" +description = "Building Reference Datasets + Processing Ancillary Data" +readme = "README.md" +requires-python = ">=3.12" +license = { file = "LICENSE" } +authors = [ + { name = "Tadd Bindas", email = "tadd.bindas@ertcorp.com" }, + { name = "Daniel Cumpton", email = "dcumpton@rtx.com" }, + { name = "Quercus Hamlin", email = "qhamlin@asrcfederal.com" }, + { name = "Brock Hinkson", email = "brock.w.hinkson@rtx.com" }, + { name = "Farshid Rahmani", email = "Farshid.Rahmani@rtx.com" }, +] +maintainers = [ + { name = "Tadd Bindas", email = "tadd.bindas@ertcorp.com" }, + { name = "Daniel Cumpton", email = "dcumpton@rtx.com" }, + { name = "Quercus Hamlin", email = "qhamlin@asrcfederal.com" }, + { name = "Brock Hinkson", email = "brock.w.hinkson@rtx.com" }, + { name = "Farshid Rahmani", email = "Farshid.Rahmani@rtx.com" }, +] + +dependencies = [ + "python-dotenv==1.1.0", + "geopandas==1.1.1", + "boto3==1.40.45", + "pyarrow==20.0.0", + "pyiceberg[s3fs,glue,sql-sqlite]==0.9.1", + "pyprojroot==0.3.0", + "tqdm==4.67.1", + "folium==0.20.0", + "matplotlib==3.10.5", + "mapclassify==2.10.0", + "numpy==2.2.6", + "netCDF4==1.7.2", + "pandas==2.3.0", + "polars==1.34.0", + "shapely==2.1.1", + "xarray==2025.7.1", + "rioxarray==0.19.0", + "rasterio==1.4.3", + "rustworkx==0.17.1", + "exactextract==0.2.2", + "rasterstats>=0.20.0", +] + +[dependency-groups] +dev = [ + "pre-commit==3.8.0", + "ruff==0.11.13", + "mypy==1.15.0", + "nbstripout==0.8.1", + "types-PyYaml", + "types-requests==2.32.4.20250611", +] +examples = [ + "ipykernel==6.29.5", + "jupyterlab==4.4.3" +] +tests = [ + "pytest==8.4.1", + "pytest-cov==6.1.1", + "astropy==7.1.0" +] + +[tool.uv] +default-groups = ["dev", "examples", "tests"] + +[tool.ruff] +line-length = 110 +exclude = [".csv", "LICENSE", ".tf", ".tfvars"] +lint.select = [ + "F", # Errors detected by Pyflakes + "E", # Error detected by Pycodestyle + "W", # Warning detected by Pycodestyle + "I", # isort + "D", # pydocstyle + "B", # flake8-bugbear + "Q", # flake8-quotes + "TID", # flake8-tidy-imports + "C4", # flake8-comprehensions + "BLE", # flake8-blind-except + "UP", # pyupgrade + "RUF100", # Report unused noqa directives +] +lint.ignore = [ + # line too long -> we accept long comment lines; black gets rid of long code lines + "E501", + # Do not assign a lambda expression, use a def -> lambda expression assignments are convenient + "E731", + # allow I, O, l as variable names -> I is the identity matrix + "E741", + # Missing docstring in public package + "D104", + # Missing docstring in public module + "D100", + # Missing docstring in __init__ + "D107", + # Errors from function calls in argument defaults. These are fine when the result is immutable. + "B008", + # __magic__ methods are are often self-explanatory, allow missing docstrings + "D105", + # first line should end with a period [Bug: doesn't work with single-line docstrings] + "D400", + # First line should be in imperative mood; try rephrasing + "D401", + ## Disable one in each pair of mutually incompatible rules + # We don't want a blank line before a class docstring + "D203", + # We want docstrings to start immediately after the opening triple quote + "D213", + # Bare except okay for passing + "E722", +] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.ruff.lint.per-file-ignores] +"docs/*" = ["I"] +"tests/*" = ["D"] +"*/__init__.py" = ["F401"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = false +disallow_any_unimported = false +warn_unused_configs = true +strict_optional = true +ignore_missing_imports = true +check_untyped_defs = true +disallow_untyped_defs = true +no_implicit_optional = true +show_error_codes = true +warn_unused_ignores = true + +[tool.pytest.ini_options] +filterwarnings = [ + "ignore::DeprecationWarning:pyogrio", + "ignore:The 'shapely.geos' module is deprecated:DeprecationWarning", + "ignore:The behavior of DataFrame concatenation with empty or all-NA entries is deprecated:FutureWarning", +] From e49a5e5d9998a1142b6c559de01fcee4af9d25d8 Mon Sep 17 00:00:00 2001 From: Tadd Bindas Date: Wed, 24 Dec 2025 11:25:10 -0600 Subject: [PATCH 02/16] Merge pull request #3 from NGWPC/feat/prvi Base PRVI --- .gitignore | 2 +- NOTICE.txt | 7 + README.md | 3 + builds/prvi_reference.py | 177 ++++++++++++++++++++++ config/example_prvi.yaml | 2 + pyproject.toml | 13 +- src/reference_builds/__init__.py | 3 + src/reference_builds/configs/__init__.py | 3 + src/reference_builds/configs/prvi.py | 58 +++++++ src/reference_builds/graph/__init__.py | 3 + src/reference_builds/graph/v22_graph.py | 108 +++++++++++++ src/reference_builds/logs.py | 31 ++++ src/reference_builds/pipeline/download.py | 47 ++++++ src/reference_builds/task_instance.py | 42 +++++ 14 files changed, 497 insertions(+), 2 deletions(-) create mode 100644 NOTICE.txt create mode 100644 builds/prvi_reference.py create mode 100644 config/example_prvi.yaml create mode 100644 src/reference_builds/__init__.py create mode 100644 src/reference_builds/configs/__init__.py create mode 100644 src/reference_builds/configs/prvi.py create mode 100644 src/reference_builds/graph/__init__.py create mode 100644 src/reference_builds/graph/v22_graph.py create mode 100644 src/reference_builds/logs.py create mode 100644 src/reference_builds/pipeline/download.py create mode 100644 src/reference_builds/task_instance.py diff --git a/.gitignore b/.gitignore index c152544..993db7e 100644 --- a/.gitignore +++ b/.gitignore @@ -235,7 +235,7 @@ Pipfile .DS_Store # configs -configs/ +config/ # logs logs/ diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 0000000..546a10d --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,7 @@ +-- + +Inspiration and assistance in the creation, and storage of network graphs comes from the following repos. Credit to the authors: +- https://github.com/DeepGroundwater/ddr/blob/ab4c3962c2c119e6a9182a77f2a9faceec19f2e0/engine/adjacency.py +- https://github.com/CIROH-UA/NGIAB_data_preprocess/blob/main/modules/data_processing/graph_utils.py + +-- diff --git a/README.md b/README.md index e69de29..f44e6b6 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,3 @@ +# Reference-Builds + +This repo is meant to take OCONUS reference data products and convert them into reference-files that can be used in the [NGWPC/nhf-builds](https://github.com/NGWPC/nhf-builds/) Repository diff --git a/builds/prvi_reference.py b/builds/prvi_reference.py new file mode 100644 index 0000000..891c0e0 --- /dev/null +++ b/builds/prvi_reference.py @@ -0,0 +1,177 @@ +"""An end-to-end build file that will take the v2.2 Hydrofabric for PRVI and turn it into the reference fabric""" + +"""Local runner for building the NGWPC hydrofabric""" + +import argparse +from collections.abc import Callable +from datetime import datetime +from typing import Any, Self + +from pydantic import ValidationError + +from reference_builds.configs import PRVI +from reference_builds.logs import setup_logging +from reference_builds.task_instance import TaskInstance + +logger = setup_logging() + + +class LocalRunner: + """Execute pipeline tasks locally with Airflow-like interface. + + Parameters + ---------- + config : HFConfig + Pipeline configuration containing build settings and parameters. + run_id : str or None, default=None + Unique identifier for this pipeline run. If None, generated from + current timestamp in format 'YYYYMMDD_HHMMSS'. + + Attributes + ---------- + config : HFConfig + The pipeline configuration. + run_id : str + Unique identifier for this run. + ti : TaskInstance + TaskInstance for XCom operations. + results : dict[str, dict[str, Any]] + Execution results for each task, keyed by task_id. + """ + + def __init__( + self, + config: PRVI, + run_id: str | None = None, + ) -> None: + """Initialize the LocalRunner. + + Parameters + ---------- + config : HFConfig + Pipeline configuration. + run_id : str or None, default=None + Optional run identifier. Auto-generated if not provided. + """ + self.config: PRVI = config + self.run_id: str = run_id or datetime.now().strftime("%Y%m%d_%H%M%S") + self.ti: TaskInstance = TaskInstance() + self.results: dict[str, dict[str, Any]] = {} + + def cleanup(self) -> None: + """Clean up resources""" + logger.info("runner: Closing processes") + + def __enter__(self: Self) -> Self: + """Context manager entry.""" + return self + + def __exit__(self: Self, *args: str, **kwargs: str) -> None: + """Context manager exit - ensures cleanup.""" + self.cleanup() + + def run_task( + self, + task_id: str, + python_callable: Callable[..., Any], + op_kwargs: dict[str, Any] | None = None, + ) -> Any: + """Execute a single task. + + Parameters + ---------- + task_id : str + Unique identifier for this task. Used in XCom keys and result tracking. + python_callable : Callable[..., Any] + The function to execute. Must accept **kwargs to receive context. + op_kwargs : dict[str, Any] or None, default=None + Additional keyword arguments to pass to the callable. + + Returns + ------- + Any + The return value from the callable. + """ + logger.info(f"Running task: {task_id}") + + context: dict[str, Any] = { + "ti": self.ti, + "task_id": task_id, + "run_id": self.run_id, + "ds": datetime.now().strftime("%Y-%m-%d"), + "execution_date": datetime.now(), + "config": self.config, + } + + kwargs = {**(op_kwargs or {}), **context} + + result = python_callable(**kwargs) + + for k, v in result.items(): + self.ti.xcom_push(f"{task_id}.{k}", v) + self.results[task_id] = {"status": "success", "result": result} + + logger.info(f"✓ Task {task_id} completed") + return result + + def get_result(self, task_id: str) -> dict[str, Any]: + """Retrieve execution results for a specific task. + + Parameters + ---------- + task_id : str + The identifier of the task to get results for. + + Returns + ------- + dict[str, Any] or None + Dictionary containing 'status' and either 'result' (on success) + or 'error' (on failure). Returns None if task_id not found. + """ + result = self.results.get(task_id) + if result is None: + raise ValueError("Cannot find result from task") + return result + + +def main() -> int: + """Main entry point for the hydrofabric-build pipeline CLI. + + Returns + ------- + int + Exit code: 0 for success, 1 for failure. + """ + parser = argparse.ArgumentParser(description="A local runner for hydrofabric data processing") + parser.add_argument("--config", required=False, help="Config file") + args = parser.parse_args() + + try: + config = PRVI.from_yaml(args.config) + except ValidationError as e: + print("Configuration validation failed:") + for error in e.errors(): + print(f" {error['loc']}: {error['msg']}") + return 1 + except FileNotFoundError: + logger.error(f"Config file not found: {args.config}") + return 1 + except TypeError as e: + logger.error("Config file not specified.") + raise TypeError("Config file not specified.") from e + + with LocalRunner(config) as runner: + runner.run_task(task_id="download", python_callable=download_reference_data, op_kwargs={}) + + print("Pipeline completed") + print("=" * 60) + for task_id, info in runner.results.items(): + status = "✓" if info["status"] == "success" else "✗" + print(f" {status} {task_id}: {info['status']}") + print("=" * 60) + + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/config/example_prvi.yaml b/config/example_prvi.yaml new file mode 100644 index 0000000..379dae3 --- /dev/null +++ b/config/example_prvi.yaml @@ -0,0 +1,2 @@ +output_dir: data/ +crs: EPSG:6566 diff --git a/pyproject.toml b/pyproject.toml index 1070436..1730ec2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,17 @@ +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling", "hatch-vcs"] + +[tool.hatch] +version.source = "vcs" +build.hooks.vcs.version-file = "src/reference_builds/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/reference_builds"] + [project] name = "reference-builds" -version = "0.1.0" +dynamic = ["version"] description = "Building Reference Datasets + Processing Ancillary Data" readme = "README.md" requires-python = ">=3.12" diff --git a/src/reference_builds/__init__.py b/src/reference_builds/__init__.py new file mode 100644 index 0000000..26d23ba --- /dev/null +++ b/src/reference_builds/__init__.py @@ -0,0 +1,3 @@ +from ._version import __version__ + +__all__ = ["__version__"] diff --git a/src/reference_builds/configs/__init__.py b/src/reference_builds/configs/__init__.py new file mode 100644 index 0000000..1c35d07 --- /dev/null +++ b/src/reference_builds/configs/__init__.py @@ -0,0 +1,3 @@ +from .prvi import PRVI + +__all__ = ["PRVI"] diff --git a/src/reference_builds/configs/prvi.py b/src/reference_builds/configs/prvi.py new file mode 100644 index 0000000..991dc0f --- /dev/null +++ b/src/reference_builds/configs/prvi.py @@ -0,0 +1,58 @@ +"""A file to host all Hydrofabric Schemas""" + +from pathlib import Path +from typing import Self + +import yaml +from pydantic import BaseModel, Field +from pyprojroot import here + +from reference_builds import __version__ + + +class PRVI(BaseModel): + """Configs for building the PRVI reference""" + + output_dir: Path = Field( + default=here() / "data/", + description="The directory for output files to be saved from Hydrofabric builds", + ) + + input_file_regex: Path = Field( + default_factory=lambda data: data["output_dir"] / "PRVI/NHDPLUS_H_2101_HU4_GPKG.gpkg", + description="input file to be converted into a reference product", + ) + + crs: str = Field( + default="EPSG:6566", + description="Coordinate Reference System for the PRVI reference builds. Defaults to https://epsg.io/6566", + ) + + output_reference_divides_path: Path = Field( + default_factory=lambda data: data["output_dir"] / f"prvi_{__version__}_reference_divides.parquet", + description="Save directory for the PRVI reference divides", + ) + + output_reference_flowpaths_path: Path = Field( + default_factory=lambda data: data["output_dir"] / f"prvi_{__version__}_reference_flowpaths.parquet", + description="Save directory for the PRVI reference flowpaths", + ) + + @classmethod + def from_yaml(cls, path: str | Path) -> Self: + """An internal method to read a config from a YAML file + + Parameters + ---------- + path : str | Path + The path to the provided YAML file + + Returns + ------- + HFConfig + A configuration object validated + """ + with open(path) as f: + data = yaml.safe_load(f) + + return cls(**data) diff --git a/src/reference_builds/graph/__init__.py b/src/reference_builds/graph/__init__.py new file mode 100644 index 0000000..1db1a33 --- /dev/null +++ b/src/reference_builds/graph/__init__.py @@ -0,0 +1,3 @@ +from .v22_graph import build_v22_graph + +__all__ = ["v22_graph"] diff --git a/src/reference_builds/graph/v22_graph.py b/src/reference_builds/graph/v22_graph.py new file mode 100644 index 0000000..87badf3 --- /dev/null +++ b/src/reference_builds/graph/v22_graph.py @@ -0,0 +1,108 @@ +""" +Builds a graph object from the v2.2 Hydrofabric + +Kudos to Nels Fraizer for assistance in making the graph building code: +https://github.com/DeepGroundwater/ddr/blob/ab4c3962c2c119e6a9182a77f2a9faceec19f2e0/engine/adjacency.py +""" + +import sqlite3 +from pathlib import Path + +import polars as pl +import rustworkx as rx +from tqdm import tqdm + + +def _find_outlets_by_hydroseq(reference_flowpaths: pl.DataFrame) -> list[str]: + """Find outlets for the river using hydroseq. + + Parameters + ---------- + reference_flowpaths : pl.DataFrame + The flowpath reference + + Returns + ------- + list[str] + All outlets from the reference + """ + df_pl = reference_flowpaths.select(pl.col(["flowpath_id", "hydroseq", "dnhydroseq", "totdasqkm"])) + + df_with_str_id = df_pl.with_columns( + pl.col("flowpath_id").cast(pl.Float64).cast(pl.Int64).cast(pl.Utf8).alias("flowpath_id_str") + ) + + hydroseq_set: set[Any] = set(df_pl["hydroseq"].to_list()) + + outlets_df = df_with_str_id.filter( + (pl.col("dnhydroseq") == 0) | ~pl.col("dnhydroseq").is_in(hydroseq_set) + ).sort("flowpath_id_str") # dnhydroseq is 0, or doesn't exist in hydroseq + + # outlets_sorted = outlets_df.sort("totdasqkm", descending=True) # Commenting out until production + outlets: list[str] = outlets_df["flowpath_id_str"].to_list() + + return outlets + + +def create_matrix(fp: pl.LazyFrame, network: pl.LazyFrame) -> tuple[rx.PyDiGraph, dict[str, int]]: + """ + Create a directed graph from flowpaths and network dataframes. + + Parameters + ---------- + fp : pl.LazyFrame + Flowpaths dataframe with 'toid' column indicating downstream nexus IDs. + network : pl.LazyFrame + Network dataframe with 'toid' column indicating downstream flowpath IDs. + + Returns + ------- + tuple[rx.PyDiGraph, dict[str, int]] + tuple[0]: A rustworkx directed graph + tuple[1]: Mapping of flowpath IDs to graph node indices + """ + fp = fp.with_row_index(name="idx").collect() + network = network.collect().unique(subset=["id"]) + _values = zip(fp["idx"], fp["toid"], strict=False) + fp = dict(zip(fp["id"], _values, strict=True)) + network = dict(zip(network["id"], network["toid"], strict=True)) + + graph = rx.PyDiGraph(check_cycle=False, node_count_hint=len(fp), edge_count_hint=len(fp)) + gidx = graph.add_nodes_from(fp.keys()) + + # Create mapping from flowpath ID to graph node index + node_index = {graph.get_node_data(idx): idx for idx in gidx} + + for idx in tqdm(gidx, desc="Building network graph"): + id = graph.get_node_data(idx) + nex = fp[id][1] # the downstream nexus id + ds_wb = network.get(nex) + if ds_wb is not None: + graph.add_edge(idx, node_index[ds_wb], nex) + + return graph, node_index + + +def build_v22_graph(file_path: Path) -> tuple[rx.PyDiGraph, dict[str, int]]: + """Builds a graph from the v2.2 hydrofabric + + Parameters + ---------- + file_path : Path + The path to the v2.2 geopackage + + Returns + ------- + tuple[rx.PyDiGraph, dict[str, int]] + tuple[0]: A rustworkx directed graph + tuple[1]: Mapping of flowpath IDs to graph node indices + """ + # Read hydrofabric geopackage using sqlite + query = "SELECT id,toid FROM flowpaths" + conn = sqlite3.connect(file_path) + fp = pl.read_database(query=query, connection=conn) + fp = fp.extend(pl.DataFrame({"id": ["wb-0"], "toid": [None]})).lazy() + query = "SELECT id,toid FROM network" + network = pl.read_database(query=query, connection=conn).lazy() + network = network.filter(pl.col("id").str.starts_with("wb-").not_()) + return create_matrix(fp, network) diff --git a/src/reference_builds/logs.py b/src/reference_builds/logs.py new file mode 100644 index 0000000..c7fefe7 --- /dev/null +++ b/src/reference_builds/logs.py @@ -0,0 +1,31 @@ +import logging +import logging.handlers +import os + +from dotenv import load_dotenv +from pyprojroot import here + + +def setup_logging() -> logging.Logger: + """Configures the reference builds logging""" + load_dotenv(here() / ".env") + + logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") + + logger = logging.getLogger(__name__) + logging.getLogger("rasterio").setLevel(logging.WARNING) # turning off rasterio INFO logging + + log_file_path = here() / "logs/" + log_file_path.mkdir(exist_ok=True) + max_bytes = int(os.getenv("LOG_MAX_BYTES", 10485760)) + backup_count = int(os.getenv("LOG_BACKUP_COUNT", 5)) + + file_handler = logging.handlers.RotatingFileHandler( + log_file_path / "reference_builds.log", maxBytes=max_bytes, backupCount=backup_count + ) + + formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") + file_handler.setFormatter(formatter) + file_handler.setLevel(logging.DEBUG) + logging.getLogger().addHandler(file_handler) + return logger diff --git a/src/reference_builds/pipeline/download.py b/src/reference_builds/pipeline/download.py new file mode 100644 index 0000000..00c3d97 --- /dev/null +++ b/src/reference_builds/pipeline/download.py @@ -0,0 +1,47 @@ +"""Contains all code for downloading hydrofabric data""" + +import logging +from typing import Any, cast + +import geopandas as gpd +import polars as pl +from hydrofabric_builds.config import HFConfig +from hydrofabric_builds.hydrofabric.graph import _validate_and_fix_geometries + +logger = logging.getLogger(__name__) + + +def download_reference_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: + """Opens local / downloads reference materials for the hydrofabric build process + + Parameters + ---------- + **context : dict + Airflow-compatible context containing: + - ti : TaskInstance for XCom operations + - config : HFConfig with pipeline configuration + - task_id : str identifier for this task + - run_id : str identifier for this pipeline run + - ds : str execution date + - execution_date : datetime object + + Returns + ------- + dict[str, gpd.GeoDataFrame] + The reference flowpath and divides references in memory + """ + cfg = cast(HFConfig, context["config"]) + + _reference_divides = gpd.read_parquet(cfg.build.reference_divides_path) + _reference_divides["divide_id"] = _reference_divides["divide_id"].astype("int").astype("str") + _reference_divides = _validate_and_fix_geometries(_reference_divides, geom_type="divides") + reference_divides = pl.from_pandas(_reference_divides.to_wkb()) + logger.info(f"download Task: Ingested Reference Divides from: {cfg.build.reference_divides_path}") + + _reference_flowpaths = gpd.read_parquet(cfg.build.reference_flowpaths_path) + _reference_flowpaths["flowpath_id"] = _reference_flowpaths["flowpath_id"].astype("int").astype("str") + _reference_flowpaths = _validate_and_fix_geometries(_reference_flowpaths, geom_type="flowpaths") + reference_flowpaths = pl.from_pandas(_reference_flowpaths.to_wkb()) + logger.info(f"download Task: Ingested Reference Flowpaths from: {cfg.build.reference_flowpaths_path}") + + return {"reference_flowpaths": reference_flowpaths, "reference_divides": reference_divides} diff --git a/src/reference_builds/task_instance.py b/src/reference_builds/task_instance.py new file mode 100644 index 0000000..9d3f795 --- /dev/null +++ b/src/reference_builds/task_instance.py @@ -0,0 +1,42 @@ +"""Mocks a local instance for building runners""" + +from typing import Any + + +class TaskInstance: + """A Mock TaskInstance for local runners similar to Apache Airflow.""" + + def __init__(self) -> None: + """Initialize the TaskInstance with empty XCom storage.""" + self.xcom_storage: dict[str, Any] = {} + + def xcom_push(self, key: str, value: Any) -> None: + """ + Store a value in XCom for retrieval by downstream tasks. + + Parameters + ---------- + key : str + Unique identifier for the stored value. Convention is to use '{task_id}.{key_name}' format for namespacing. + value : Any + The data to store. Can be any Python object. + """ + self.xcom_storage[key] = value + + def xcom_pull(self, task_id: str, key: str = "return_value") -> Any: + """ + Retrieve a value from XCom that was pushed by an upstream task. + + Parameters + ---------- + task_id : str + The task_id of the task that pushed the value. + key : str, default='return_value' + The key used when the value was pushed. Default 'return_value' is used for values returned from task functions. + + Returns + ------- + Any + The stored value, or None if the key doesn't exist. + """ + return self.xcom_storage.get(f"{task_id}.{key}") From 090254917493508fc4c027ca616700b1fa61b633 Mon Sep 17 00:00:00 2001 From: Tadd Bindas Date: Mon, 29 Dec 2025 11:22:25 -0600 Subject: [PATCH 03/16] Feat/prvi: started setting up PRVI (#4) * setup: created builds and src dirs * tmp: added configs and graph building for v2.2 * tmp: adding code updates for reference-builds * feat: added download pipeline * Merge pull request #3 from NGWPC/feat/prvi Base PRVI * tmp: added configs and graph building for v2.2 * tmp: adding code updates for reference-builds * patch: renamed download function * abstraction: moved LocalRunner into its own file --- builds/prvi_reference.py | 134 +--------------------- src/reference_builds/configs/prvi.py | 16 ++- src/reference_builds/graph/__init__.py | 3 +- src/reference_builds/graph/utils.py | 42 +++++++ src/reference_builds/graph/v22_graph.py | 1 + src/reference_builds/local_runner.py | 128 +++++++++++++++++++++ src/reference_builds/pipeline/__init__.py | 5 + src/reference_builds/pipeline/download.py | 57 ++++++--- 8 files changed, 240 insertions(+), 146 deletions(-) create mode 100644 src/reference_builds/graph/utils.py create mode 100644 src/reference_builds/local_runner.py create mode 100644 src/reference_builds/pipeline/__init__.py diff --git a/builds/prvi_reference.py b/builds/prvi_reference.py index 891c0e0..2d70b10 100644 --- a/builds/prvi_reference.py +++ b/builds/prvi_reference.py @@ -1,137 +1,15 @@ -"""An end-to-end build file that will take the v2.2 Hydrofabric for PRVI and turn it into the reference fabric""" - -"""Local runner for building the NGWPC hydrofabric""" +"""An end-to-end build file that will take the NHD PRVI and turn it into a reference fabric""" import argparse -from collections.abc import Callable -from datetime import datetime -from typing import Any, Self +import logging from pydantic import ValidationError from reference_builds.configs import PRVI -from reference_builds.logs import setup_logging -from reference_builds.task_instance import TaskInstance - -logger = setup_logging() - - -class LocalRunner: - """Execute pipeline tasks locally with Airflow-like interface. - - Parameters - ---------- - config : HFConfig - Pipeline configuration containing build settings and parameters. - run_id : str or None, default=None - Unique identifier for this pipeline run. If None, generated from - current timestamp in format 'YYYYMMDD_HHMMSS'. - - Attributes - ---------- - config : HFConfig - The pipeline configuration. - run_id : str - Unique identifier for this run. - ti : TaskInstance - TaskInstance for XCom operations. - results : dict[str, dict[str, Any]] - Execution results for each task, keyed by task_id. - """ - - def __init__( - self, - config: PRVI, - run_id: str | None = None, - ) -> None: - """Initialize the LocalRunner. - - Parameters - ---------- - config : HFConfig - Pipeline configuration. - run_id : str or None, default=None - Optional run identifier. Auto-generated if not provided. - """ - self.config: PRVI = config - self.run_id: str = run_id or datetime.now().strftime("%Y%m%d_%H%M%S") - self.ti: TaskInstance = TaskInstance() - self.results: dict[str, dict[str, Any]] = {} - - def cleanup(self) -> None: - """Clean up resources""" - logger.info("runner: Closing processes") - - def __enter__(self: Self) -> Self: - """Context manager entry.""" - return self - - def __exit__(self: Self, *args: str, **kwargs: str) -> None: - """Context manager exit - ensures cleanup.""" - self.cleanup() - - def run_task( - self, - task_id: str, - python_callable: Callable[..., Any], - op_kwargs: dict[str, Any] | None = None, - ) -> Any: - """Execute a single task. - - Parameters - ---------- - task_id : str - Unique identifier for this task. Used in XCom keys and result tracking. - python_callable : Callable[..., Any] - The function to execute. Must accept **kwargs to receive context. - op_kwargs : dict[str, Any] or None, default=None - Additional keyword arguments to pass to the callable. - - Returns - ------- - Any - The return value from the callable. - """ - logger.info(f"Running task: {task_id}") - - context: dict[str, Any] = { - "ti": self.ti, - "task_id": task_id, - "run_id": self.run_id, - "ds": datetime.now().strftime("%Y-%m-%d"), - "execution_date": datetime.now(), - "config": self.config, - } - - kwargs = {**(op_kwargs or {}), **context} - - result = python_callable(**kwargs) - - for k, v in result.items(): - self.ti.xcom_push(f"{task_id}.{k}", v) - self.results[task_id] = {"status": "success", "result": result} - - logger.info(f"✓ Task {task_id} completed") - return result - - def get_result(self, task_id: str) -> dict[str, Any]: - """Retrieve execution results for a specific task. - - Parameters - ---------- - task_id : str - The identifier of the task to get results for. +from reference_builds.local_runner import LocalRunner +from reference_builds.pipeline import download_nhd_data - Returns - ------- - dict[str, Any] or None - Dictionary containing 'status' and either 'result' (on success) - or 'error' (on failure). Returns None if task_id not found. - """ - result = self.results.get(task_id) - if result is None: - raise ValueError("Cannot find result from task") - return result +logger = logging.getLogger(__name__) def main() -> int: @@ -161,7 +39,7 @@ def main() -> int: raise TypeError("Config file not specified.") from e with LocalRunner(config) as runner: - runner.run_task(task_id="download", python_callable=download_reference_data, op_kwargs={}) + runner.run_task(task_id="download", python_callable=download_nhd_data, op_kwargs={}) print("Pipeline completed") print("=" * 60) diff --git a/src/reference_builds/configs/prvi.py b/src/reference_builds/configs/prvi.py index 991dc0f..02fbb6a 100644 --- a/src/reference_builds/configs/prvi.py +++ b/src/reference_builds/configs/prvi.py @@ -18,8 +18,8 @@ class PRVI(BaseModel): description="The directory for output files to be saved from Hydrofabric builds", ) - input_file_regex: Path = Field( - default_factory=lambda data: data["output_dir"] / "PRVI/NHDPLUS_H_2101_HU4_GPKG.gpkg", + input_file_regex: str = Field( + default="PRVI/NHDPLUS_H_21*_HU4_GPKG", description="input file to be converted into a reference product", ) @@ -28,6 +28,18 @@ class PRVI(BaseModel): description="Coordinate Reference System for the PRVI reference builds. Defaults to https://epsg.io/6566", ) + permitted_fcodes: list[str] = Field( + default_factory=lambda: [ + "Stream/River: Hydrographic Category = Intermittent", + "Artificial Path", + "Connector", + "Stream/River: Hydrographic Category = Perennial", + "Canal/Ditch", + "Canal Ditch: Canal Ditch Type = Stormwater", + ], + description="The permitted fcode descriptions for the reference", + ) + output_reference_divides_path: Path = Field( default_factory=lambda data: data["output_dir"] / f"prvi_{__version__}_reference_divides.parquet", description="Save directory for the PRVI reference divides", diff --git a/src/reference_builds/graph/__init__.py b/src/reference_builds/graph/__init__.py index 1db1a33..82ef625 100644 --- a/src/reference_builds/graph/__init__.py +++ b/src/reference_builds/graph/__init__.py @@ -1,3 +1,4 @@ +from .utils import _validate_and_fix_geometries from .v22_graph import build_v22_graph -__all__ = ["v22_graph"] +__all__ = ["_validate_and_fix_geometries", "v22_graph"] diff --git a/src/reference_builds/graph/utils.py b/src/reference_builds/graph/utils.py new file mode 100644 index 0000000..3cad09f --- /dev/null +++ b/src/reference_builds/graph/utils.py @@ -0,0 +1,42 @@ +"""A file for all graph related internal functions""" + +import geopandas as gpd + + +def _validate_and_fix_geometries(gdf: gpd.GeoDataFrame, geom_type: str) -> gpd.GeoDataFrame: + """Validate and fix invalid geometries in a GeoDataFrame. + + Parameters + ---------- + gdf : gpd.GeoDataFrame + GeoDataFrame to validate + geom_type : str + Description for logging (e.g., "flowpaths", "divides") + + Returns + ------- + gpd.GeoDataFrame + GeoDataFrame with fixed geometries + + Raises + ------ + ValueError + If geometries cannot be fixed or invalid geometries remain + """ + invalid_mask = ~gdf.geometry.is_valid + invalid_count = invalid_mask.sum() + + if invalid_count == 0: + return gdf # No invalid geometries + + geometries = gdf[invalid_mask].geometry + gdf.loc[invalid_mask, "geometry"] = geometries.make_valid() + + if len(gdf[~gdf.geometry.is_valid]) > 0: + raise ValueError(f"Could not fix invalid geometries in {geom_type}") + + still_invalid = (~gdf.geometry.is_valid).sum() + if still_invalid > 0: + raise ValueError(f"Invalid Geometries remain: {gdf[~gdf.geometry.is_valid]}") + + return gdf diff --git a/src/reference_builds/graph/v22_graph.py b/src/reference_builds/graph/v22_graph.py index 87badf3..d77f3fb 100644 --- a/src/reference_builds/graph/v22_graph.py +++ b/src/reference_builds/graph/v22_graph.py @@ -7,6 +7,7 @@ import sqlite3 from pathlib import Path +from typing import Any import polars as pl import rustworkx as rx diff --git a/src/reference_builds/local_runner.py b/src/reference_builds/local_runner.py new file mode 100644 index 0000000..291b1eb --- /dev/null +++ b/src/reference_builds/local_runner.py @@ -0,0 +1,128 @@ +"""A file to hold the LocalRunner class""" + +from collections.abc import Callable +from datetime import datetime +from typing import Any, Self + +from reference_builds.configs import PRVI +from reference_builds.logs import setup_logging +from reference_builds.task_instance import TaskInstance + + +class LocalRunner: + """Execute pipeline tasks locally with Airflow-like interface. + + Parameters + ---------- + config : HFConfig + Pipeline configuration containing build settings and parameters. + run_id : str or None, default=None + Unique identifier for this pipeline run. If None, generated from + current timestamp in format 'YYYYMMDD_HHMMSS'. + + Attributes + ---------- + config : HFConfig + The pipeline configuration. + run_id : str + Unique identifier for this run. + ti : TaskInstance + TaskInstance for XCom operations. + results : dict[str, dict[str, Any]] + Execution results for each task, keyed by task_id. + """ + + def __init__( + self, + config: PRVI, + run_id: str | None = None, + ) -> None: + """Initialize the LocalRunner. + + Parameters + ---------- + config : HFConfig + Pipeline configuration. + run_id : str or None, default=None + Optional run identifier. Auto-generated if not provided. + """ + self.config: PRVI = config + self.run_id: str = run_id or datetime.now().strftime("%Y%m%d_%H%M%S") + self.ti: TaskInstance = TaskInstance() + self.results: dict[str, dict[str, Any]] = {} + self.logger = setup_logging() + + def cleanup(self) -> None: + """Clean up resources""" + self.logger.info("runner: Closing processes") + + def __enter__(self: Self) -> Self: + """Context manager entry.""" + return self + + def __exit__(self: Self, *args: str, **kwargs: str) -> None: + """Context manager exit - ensures cleanup.""" + self.cleanup() + + def run_task( + self, + task_id: str, + python_callable: Callable[..., Any], + op_kwargs: dict[str, Any] | None = None, + ) -> Any: + """Execute a single task. + + Parameters + ---------- + task_id : str + Unique identifier for this task. Used in XCom keys and result tracking. + python_callable : Callable[..., Any] + The function to execute. Must accept **kwargs to receive context. + op_kwargs : dict[str, Any] or None, default=None + Additional keyword arguments to pass to the callable. + + Returns + ------- + Any + The return value from the callable. + """ + self.logger.info(f"Running task: {task_id}") + + context: dict[str, Any] = { + "ti": self.ti, + "task_id": task_id, + "run_id": self.run_id, + "ds": datetime.now().strftime("%Y-%m-%d"), + "execution_date": datetime.now(), + "config": self.config, + } + + kwargs = {**(op_kwargs or {}), **context} + + result = python_callable(**kwargs) + + for k, v in result.items(): + self.ti.xcom_push(f"{task_id}.{k}", v) + self.results[task_id] = {"status": "success", "result": result} + + self.logger.info(f"✓ Task {task_id} completed") + return result + + def get_result(self, task_id: str) -> dict[str, Any]: + """Retrieve execution results for a specific task. + + Parameters + ---------- + task_id : str + The identifier of the task to get results for. + + Returns + ------- + dict[str, Any] or None + Dictionary containing 'status' and either 'result' (on success) + or 'error' (on failure). Returns None if task_id not found. + """ + result = self.results.get(task_id) + if result is None: + raise ValueError("Cannot find result from task") + return result diff --git a/src/reference_builds/pipeline/__init__.py b/src/reference_builds/pipeline/__init__.py new file mode 100644 index 0000000..1af3347 --- /dev/null +++ b/src/reference_builds/pipeline/__init__.py @@ -0,0 +1,5 @@ +from .download import download_nhd_data + +__all__ = [ + "download_nhd_data", +] diff --git a/src/reference_builds/pipeline/download.py b/src/reference_builds/pipeline/download.py index 00c3d97..0e4a867 100644 --- a/src/reference_builds/pipeline/download.py +++ b/src/reference_builds/pipeline/download.py @@ -1,17 +1,29 @@ """Contains all code for downloading hydrofabric data""" import logging +from pathlib import Path from typing import Any, cast import geopandas as gpd +import pandas as pd import polars as pl -from hydrofabric_builds.config import HFConfig -from hydrofabric_builds.hydrofabric.graph import _validate_and_fix_geometries + +from reference_builds.configs import PRVI +from reference_builds.graph import _validate_and_fix_geometries logger = logging.getLogger(__name__) -def download_reference_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: +def _load_and_concat_layers(gpkg_files: list[Path], layer_name: str) -> gpd.GeoDataFrame: + """Load a specific layer from all gpkg files and concatenate.""" + gdfs = [] + for gpkg_path in gpkg_files: + gdf = gpd.read_file(gpkg_path, layer=layer_name) + gdfs.append(gdf) + return pd.concat(gdfs, ignore_index=True) + + +def download_nhd_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: """Opens local / downloads reference materials for the hydrofabric build process Parameters @@ -30,18 +42,33 @@ def download_reference_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame dict[str, gpd.GeoDataFrame] The reference flowpath and divides references in memory """ - cfg = cast(HFConfig, context["config"]) + cfg = cast(PRVI, context["config"]) + + # find the gpkg files from the ScienceBase NHD folders + matching_folders = list(cfg.output_dir.glob(cfg.input_file_regex)) + gpkg_files: list[Path] = [] + for folder in matching_folders: + if folder.is_dir(): + gpkg_files.extend(folder.glob("*.gpkg")) - _reference_divides = gpd.read_parquet(cfg.build.reference_divides_path) - _reference_divides["divide_id"] = _reference_divides["divide_id"].astype("int").astype("str") - _reference_divides = _validate_and_fix_geometries(_reference_divides, geom_type="divides") - reference_divides = pl.from_pandas(_reference_divides.to_wkb()) - logger.info(f"download Task: Ingested Reference Divides from: {cfg.build.reference_divides_path}") + # load layers + layers = [ + "NHDFlowline", + "NHDPlusCatchment", + "NHDPlusFlowlineVAA", + ] + data = {layer: _load_and_concat_layers(gpkg_files, layer) for layer in layers} - _reference_flowpaths = gpd.read_parquet(cfg.build.reference_flowpaths_path) - _reference_flowpaths["flowpath_id"] = _reference_flowpaths["flowpath_id"].astype("int").astype("str") - _reference_flowpaths = _validate_and_fix_geometries(_reference_flowpaths, geom_type="flowpaths") - reference_flowpaths = pl.from_pandas(_reference_flowpaths.to_wkb()) - logger.info(f"download Task: Ingested Reference Flowpaths from: {cfg.build.reference_flowpaths_path}") + # filter/validate layers + _flowpaths = _validate_and_fix_geometries(data["NHDFlowline"], geom_type="flowpaths") + catchments = _validate_and_fix_geometries(data["NHDPlusCatchment"], geom_type="divides") + _flowpaths_with_catchments = _flowpaths[_flowpaths["NHDPlusID"].isin(catchments["NHDPlusID"])] + flowpaths = _flowpaths_with_catchments[ + _flowpaths_with_catchments["fcode_description"].isin(cfg.permitted_fcodes) + ] - return {"reference_flowpaths": reference_flowpaths, "reference_divides": reference_divides} + return { + "nhd_flowpaths": pl.from_pandas(flowpaths.to_wkb()), + "nhd_divides": pl.from_pandas(catchments.to_wkb()), + "nhd_connectivity": pl.from_pandas(data["NHDPlusFlowlineVAA"]), + } From e009200f70d8a8bd9703116cef23916af65d4917 Mon Sep 17 00:00:00 2001 From: Tadd Bindas Date: Mon, 29 Dec 2025 13:16:02 -0600 Subject: [PATCH 04/16] feat: processing the NHD into a graph object (#5) --- builds/prvi_reference.py | 3 +- src/reference_builds/pipeline/__init__.py | 2 + src/reference_builds/pipeline/download.py | 2 +- src/reference_builds/pipeline/processing.py | 125 ++++++++++++++++++++ 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 src/reference_builds/pipeline/processing.py diff --git a/builds/prvi_reference.py b/builds/prvi_reference.py index 2d70b10..0112267 100644 --- a/builds/prvi_reference.py +++ b/builds/prvi_reference.py @@ -7,7 +7,7 @@ from reference_builds.configs import PRVI from reference_builds.local_runner import LocalRunner -from reference_builds.pipeline import download_nhd_data +from reference_builds.pipeline import build_graphs, download_nhd_data logger = logging.getLogger(__name__) @@ -40,6 +40,7 @@ def main() -> int: with LocalRunner(config) as runner: runner.run_task(task_id="download", python_callable=download_nhd_data, op_kwargs={}) + runner.run_task(task_id="build_graphs", python_callable=build_graphs, op_kwargs={}) print("Pipeline completed") print("=" * 60) diff --git a/src/reference_builds/pipeline/__init__.py b/src/reference_builds/pipeline/__init__.py index 1af3347..02b1cce 100644 --- a/src/reference_builds/pipeline/__init__.py +++ b/src/reference_builds/pipeline/__init__.py @@ -1,5 +1,7 @@ from .download import download_nhd_data +from .processing import build_graphs __all__ = [ + "build_graphs", "download_nhd_data", ] diff --git a/src/reference_builds/pipeline/download.py b/src/reference_builds/pipeline/download.py index 0e4a867..edb2a63 100644 --- a/src/reference_builds/pipeline/download.py +++ b/src/reference_builds/pipeline/download.py @@ -24,7 +24,7 @@ def _load_and_concat_layers(gpkg_files: list[Path], layer_name: str) -> gpd.GeoD def download_nhd_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: - """Opens local / downloads reference materials for the hydrofabric build process + """Opens local / downloads for the reference-build process Parameters ---------- diff --git a/src/reference_builds/pipeline/processing.py b/src/reference_builds/pipeline/processing.py new file mode 100644 index 0000000..49b9937 --- /dev/null +++ b/src/reference_builds/pipeline/processing.py @@ -0,0 +1,125 @@ +"""Contains all code for downloading hydrofabric data""" + +import logging +from typing import Any, cast + +import polars as pl +import rustworkx as rx + +from reference_builds.task_instance import TaskInstance + +logger = logging.getLogger(__name__) + + +def _build_graph(connectivity: pl.DataFrame, flowpaths: pl.DataFrame) -> dict[str, list[str]]: + """Build a graph of upstream flowpath connections. + + Parameters + ---------- + connectivity : pl.DataFrame + The connectivity/flow table with FromNode and ToNode columns + flowpaths : pl.DataFrame + The reference flowpaths to filter to + + Returns + ------- + dict[str, list[str]] + The upstream dictionary containing upstream and downstream connections + Key is the downstream flowpath ID, values are the upstream flowpath IDs + """ + valid_ids = flowpaths.select(pl.col("NHDPlusID").cast(pl.Int64))["NHDPlusID"] + + filtered_connectivity = connectivity.select( + [ + pl.col("NHDPlusID").cast(pl.Int64), + pl.col("FromNode").cast(pl.Int64), + pl.col("ToNode").cast(pl.Int64), + ] + ).filter(pl.col("NHDPlusID").is_in(valid_ids)) + + tonode_lookup = filtered_connectivity.select( + [ + pl.col("ToNode"), + pl.col("NHDPlusID").cast(pl.Utf8).alias("upstream_id"), + ] + ) + + fromnode_lookup = filtered_connectivity.select( + [ + pl.col("FromNode"), + pl.col("NHDPlusID").cast(pl.Utf8).alias("downstream_id"), + ] + ) + + merged = tonode_lookup.join(fromnode_lookup, left_on="ToNode", right_on="FromNode", how="inner").select( + ["upstream_id", "downstream_id"] + ) + + upstream_network_df = merged.group_by("downstream_id").agg(pl.col("upstream_id").alias("upstream_list")) + + upstream_dict: dict[str, list[str]] = dict( + zip( + upstream_network_df["downstream_id"].to_list(), + upstream_network_df["upstream_list"].to_list(), + strict=False, + ) + ) + + return upstream_dict + + +def _build_rustworkx_object( + upstream_network: dict[str, list[str]] | dict[int, list[int]], +) -> tuple[rx.PyDiGraph, dict[str, int] | dict[int, int]]: + """Build a RustWorkX directed graph from upstream network dictionary. + + Parameters + ---------- + upstream_network : dict[str, list[str]] | dict[int, list[int]] + Dictionary mapping downstream flowpath IDs to lists of upstream flowpath IDs + + Returns + ------- + tuple[rx.PyDiGraph, dict[str, int] | dict[int, int]] + The flowpaths object in graph form and node indices for each object in the graph + """ + graph = rx.PyDiGraph(check_cycle=True) + node_indices: dict[Any, int] = {} + for to_edge in sorted(upstream_network.keys()): + from_edges = upstream_network[to_edge] # type: ignore + if to_edge not in node_indices: + node_indices[to_edge] = graph.add_node(to_edge) + for from_edge in from_edges: + if from_edge not in node_indices: + node_indices[from_edge] = graph.add_node(from_edge) + for to_edge, from_edges in upstream_network.items(): + for from_edge in from_edges: + graph.add_edge(node_indices[from_edge], node_indices[to_edge], None) + return graph, node_indices + + +def build_graphs(**context: dict[str, Any]) -> dict[str, Any]: + """Builds and processes graphs from NHD data + + Parameters + ---------- + **context : dict + Airflow-compatible context containing: + - ti : TaskInstance for XCom operations + - config : HFConfig with pipeline configuration + - task_id : str identifier for this task + - run_id : str identifier for this pipeline run + - ds : str execution date + - execution_date : datetime object + + Returns + ------- + dict[str, Any] + The rustworkx graph object and node_indices for the NHD + """ + ti = cast(TaskInstance, context["ti"]) + flowpaths: pl.DataFrame = ti.xcom_pull(task_id="download", key="nhd_flowpaths") + connectivity: pl.DataFrame = ti.xcom_pull(task_id="download", key="nhd_connectivity") + upstream_network = _build_graph(connectivity, flowpaths) + graph, node_indices = _build_rustworkx_object(upstream_network) + return {"graph": graph, "node_indices": node_indices} From f008078e808ef3e93e9efcdb0254eac114a7b2e2 Mon Sep 17 00:00:00 2001 From: Tadd Bindas Date: Tue, 30 Dec 2025 13:13:24 -0600 Subject: [PATCH 05/16] feat: finished create_reference and write_reference (#6) * feat: finished create_reference and write_reference * tests: added tests and github workflows --- .github/ISSUE_TEMPLATE/capability.md | 38 ++ .github/PULL_REQUEST_TEMPLATE.md | 31 ++ .github/workflows/cicd.yaml | 56 +++ builds/prvi_reference.py | 4 +- src/reference_builds/configs/prvi.py | 11 + src/reference_builds/pipeline/__init__.py | 4 + .../pipeline/build_reference.py | 319 +++++++++++++++ src/reference_builds/pipeline/download.py | 4 +- src/reference_builds/pipeline/processing.py | 2 +- src/reference_builds/pipeline/write.py | 50 +++ .../{graph => utils}/__init__.py | 2 +- .../{graph/utils.py => utils/geometries.py} | 0 .../{graph => utils}/v22_graph.py | 0 tests/conftest.py | 125 ++++++ tests/test_builds.py | 384 ++++++++++++++++++ 15 files changed, 1025 insertions(+), 5 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/capability.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/cicd.yaml create mode 100644 src/reference_builds/pipeline/build_reference.py create mode 100644 src/reference_builds/pipeline/write.py rename src/reference_builds/{graph => utils}/__init__.py (64%) rename src/reference_builds/{graph/utils.py => utils/geometries.py} (100%) rename src/reference_builds/{graph => utils}/v22_graph.py (100%) create mode 100644 tests/conftest.py create mode 100644 tests/test_builds.py diff --git a/.github/ISSUE_TEMPLATE/capability.md b/.github/ISSUE_TEMPLATE/capability.md new file mode 100644 index 0000000..41378f6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/capability.md @@ -0,0 +1,38 @@ +--- +name: JIRA Story/Capability +about: The structure for outlining work being done on a JIRA story +labels: JIRA Story +--- + +# Capability + + + +## Task + + + +## Plan/Outline + + + + +### TODOS + + +- [ ] + +### Additional components / Context + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d73c01d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,31 @@ +## Issue Addressed + + + +Fixes # (issue number) + +## Description + + + +## Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Code cleanup/refactor +- [ ] Documentation update + +Other (please specify): + +## Checklist + +- [ ] Branch is up to date with master +- [ ] Updated tests or added new tests +- [ ] Tests & pre-commit hooks pass +- [ ] Updated documentation (if applicable) +- [ ] Code follows established style and conventions diff --git a/.github/workflows/cicd.yaml b/.github/workflows/cicd.yaml new file mode 100644 index 0000000..0250fc4 --- /dev/null +++ b/.github/workflows/cicd.yaml @@ -0,0 +1,56 @@ +name: CI/CD + +on: + push: + branches: + - main + pull_request: + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: uv sync + + - name: Run ruff check + run: | + uv run ruff check --config pyproject.toml --output-format=github + + - name: Run ruff format check + run: | + uv run ruff format --config pyproject.toml + + pytests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.12', '3.13'] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run tests + run: uv run pytest tests diff --git a/builds/prvi_reference.py b/builds/prvi_reference.py index 0112267..e8d628b 100644 --- a/builds/prvi_reference.py +++ b/builds/prvi_reference.py @@ -7,7 +7,7 @@ from reference_builds.configs import PRVI from reference_builds.local_runner import LocalRunner -from reference_builds.pipeline import build_graphs, download_nhd_data +from reference_builds.pipeline import build_graphs, build_reference, download_nhd_data, write_reference logger = logging.getLogger(__name__) @@ -41,6 +41,8 @@ def main() -> int: with LocalRunner(config) as runner: runner.run_task(task_id="download", python_callable=download_nhd_data, op_kwargs={}) runner.run_task(task_id="build_graphs", python_callable=build_graphs, op_kwargs={}) + runner.run_task(task_id="build_reference", python_callable=build_reference, op_kwargs={}) + runner.run_task(task_id="write_reference", python_callable=write_reference, op_kwargs={}) print("Pipeline completed") print("=" * 60) diff --git a/src/reference_builds/configs/prvi.py b/src/reference_builds/configs/prvi.py index 02fbb6a..add6c96 100644 --- a/src/reference_builds/configs/prvi.py +++ b/src/reference_builds/configs/prvi.py @@ -28,6 +28,12 @@ class PRVI(BaseModel): description="Coordinate Reference System for the PRVI reference builds. Defaults to https://epsg.io/6566", ) + vpu_id: str = Field(default="21", description="The VPUID for PRVI") + + write_gpkg: bool = Field( + default=True, description="Writes a geopackage in addition to parquet files for output" + ) + permitted_fcodes: list[str] = Field( default_factory=lambda: [ "Stream/River: Hydrographic Category = Intermittent", @@ -40,6 +46,11 @@ class PRVI(BaseModel): description="The permitted fcode descriptions for the reference", ) + output_reference_gpkg_path: Path = Field( + default_factory=lambda data: data["output_dir"] / f"prvi_{__version__}_reference.gpkg", + description="Save directory for the PRVI reference (in .gpkg form)", + ) + output_reference_divides_path: Path = Field( default_factory=lambda data: data["output_dir"] / f"prvi_{__version__}_reference_divides.parquet", description="Save directory for the PRVI reference divides", diff --git a/src/reference_builds/pipeline/__init__.py b/src/reference_builds/pipeline/__init__.py index 02b1cce..6042b6b 100644 --- a/src/reference_builds/pipeline/__init__.py +++ b/src/reference_builds/pipeline/__init__.py @@ -1,7 +1,11 @@ +from .build_reference import build_reference from .download import download_nhd_data from .processing import build_graphs +from .write import write_reference __all__ = [ "build_graphs", + "build_reference", "download_nhd_data", + "write_reference", ] diff --git a/src/reference_builds/pipeline/build_reference.py b/src/reference_builds/pipeline/build_reference.py new file mode 100644 index 0000000..b63bb8b --- /dev/null +++ b/src/reference_builds/pipeline/build_reference.py @@ -0,0 +1,319 @@ +"""Contains all code for building a reference fabric from the NHD graph object""" + +import logging +from typing import Any, cast + +import geopandas as gpd +import pandas as pd +import polars as pl +import rustworkx as rx + +from reference_builds.configs import PRVI +from reference_builds.task_instance import TaskInstance + +logger = logging.getLogger(__name__) + + +def _trace_attributes( + graph: rx.PyDiGraph, + node_indices: dict[str, int], + flowpaths: gpd.GeoDataFrame, + divides: gpd.GeoDataFrame, + vpu_id: str, +) -> gpd.GeoDataFrame: + """Trace flowpath attributes for the entire graph. + + Parameters + ---------- + graph : rx.PyDiGraph + The rustworkx directed graph (may contain multiple disconnected subgraphs) + node_indices : dict[str, int] + Mapping from NHDPlusID (as string) to node index + flowpaths : gpd.GeoDataFrame + The flowpaths GeoDataFrame with LengthKM + divides : gpd.GeoDataFrame + The divides GeoDataFrame with AreaSqKm + + Returns + ------- + pl.DataFrame + Traced attributes: totdasqkm, mainstemlp, pathlength, dnhydroseq, hydroseq, stream_order + """ + flowpaths_lookup = flowpaths.set_index("NHDPlusID")["LengthKM"].to_dict() + divides_lookup = divides.set_index("NHDPlusID")["AreaSqKm"].to_dict() + fp_geom_lookup = flowpaths.set_index("NHDPlusID")["geometry"].to_dict() + fp_fcode_lookup = flowpaths.set_index("NHDPlusID")["fcode_description"].to_dict() + for node_idx in graph.node_indices(): + flowpath_id = str(graph[node_idx]) + nhd_id = int(flowpath_id) + + graph[node_idx] = { + "flowpath_id": flowpath_id, + "areasqkm": divides_lookup.get(nhd_id, 0.0), + "lengthkm": flowpaths_lookup.get(nhd_id, 0.0), + "totdasqkm": 0.0, + "mainstemlp": None, + "pathlength": 0.0, + "dnhydroseq": None, + "hydroseq": None, + "streamorder": None, + "fcode_description": fp_fcode_lookup[nhd_id], + "geometry": fp_geom_lookup[nhd_id], + } + + # Find all outlets (nodes with no downstream connections) + outlets = [idx for idx in graph.node_indices() if graph.out_degree(idx) == 0] + logger.info(f"build_reference task: Found {len(outlets)} outlets (disconnected subgraphs)") + + # Get topological order for entire graph + try: + topo_order = rx.topological_sort(graph) + except rx.DAGHasCycle as e: + raise AssertionError("Graph contains cycles") from e + + # PASS 1: Calculate pathlength and hydroseq (reverse topo order - upstream from outlets) + current_hydroseq = 1 + + # Initialize outlets + for outlet_idx in outlets: + graph[outlet_idx]["pathlength"] = 0.0 + graph[outlet_idx]["dnhydroseq"] = 0 + + # Traverse in reverse topo order + for node_idx in reversed(topo_order): + # Assign hydroseq + graph[node_idx]["hydroseq"] = current_hydroseq + current_hydroseq += 1 + + # Calculate pathlength based on downstream node + out_edges = graph.out_edges(node_idx) + if out_edges: + downstream_nodes = [tgt_idx for _, tgt_idx, _ in out_edges] + if downstream_nodes: + downstream_idx = max(downstream_nodes, key=lambda idx: graph[idx]["pathlength"]) + graph[node_idx]["pathlength"] = ( + graph[downstream_idx]["pathlength"] + graph[downstream_idx]["lengthkm"] + ) + + # Trace mainstems for each outlet's basin + current_mainstem_id = 1 + processed: set[int] = set() + + for outlet_idx in outlets: + # Trace main mainstem (longest path from outlet to headwater) + current_idx = outlet_idx + mainstem_nodes = [] + + while current_idx not in processed: + mainstem_nodes.append(current_idx) + graph[current_idx]["mainstemlp"] = current_mainstem_id + processed.add(current_idx) + + in_edges = list(graph.in_edges(current_idx)) + if not in_edges: + break + + upstream_candidates = [src_idx for src_idx, _, _ in in_edges if src_idx not in processed] + if not upstream_candidates: + break + + current_idx = max( + upstream_candidates, + key=lambda idx: (graph[idx]["pathlength"], graph[idx]["totdasqkm"]), + ) + + current_mainstem_id += 1 + + # Assign tributary mainstems for remaining nodes + for node_idx in graph.node_indices(): + if node_idx not in processed: + tributary_id = current_mainstem_id + current_mainstem_id += 1 + + trib_current = node_idx + while trib_current not in processed: + graph[trib_current]["mainstemlp"] = tributary_id + processed.add(trib_current) + + in_edges = list(graph.in_edges(trib_current)) + upstream_in_basin = [src_idx for src_idx, _, _ in in_edges if src_idx not in processed] + + if not upstream_in_basin: + break + + trib_current = max( + upstream_in_basin, + key=lambda idx: (graph[idx]["pathlength"], graph[idx]["totdasqkm"]), + ) + + # Assign dnhydroseq based on graph edges + for node_idx in graph.node_indices(): + out_edges = graph.out_edges(node_idx) + downstream_nodes = [tgt_idx for _, tgt_idx, _ in out_edges] + + if downstream_nodes: + downstream_idx = downstream_nodes[0] + graph[node_idx]["dnhydroseq"] = graph[downstream_idx]["hydroseq"] + else: + graph[node_idx]["dnhydroseq"] = 0 + + # PASS 2: Calculate totdasqkm and stream_order (forward topo order - downstream from headwaters) + for node_idx in topo_order: + in_edges = list(graph.in_edges(node_idx)) + + # Accumulate upstream drainage area + upstream_total = sum(graph[src_idx]["totdasqkm"] for src_idx, _, _ in in_edges) + graph[node_idx]["totdasqkm"] = upstream_total + graph[node_idx]["areasqkm"] + + # Calculate Strahler stream order + if not in_edges: + graph[node_idx]["streamorder"] = 1 + else: + upstream_orders = [graph[src_idx]["streamorder"] for src_idx, _, _ in in_edges] + max_order = max(upstream_orders) + count_max = upstream_orders.count(max_order) + + if count_max >= 2: + graph[node_idx]["streamorder"] = max_order + 1 + else: + graph[node_idx]["streamorder"] = max_order + + # Extract results + flowpath_ids = [] + vpu_ids = [] + das = [] + total_das = [] + mainstems = [] + pathlengths = [] + dnhydroseqs = [] + hydroseqs = [] + streamorders = [] + fcodes = [] + geometries = [] + + for node_idx in graph.node_indices(): + node_data = graph[node_idx] + flowpath_ids.append(node_data["flowpath_id"]) + vpu_ids.append(vpu_id) + das.append(node_data["areasqkm"]) + total_das.append(node_data["totdasqkm"]) + mainstems.append(node_data["mainstemlp"]) + pathlengths.append(node_data["pathlength"]) + dnhydroseqs.append(node_data["dnhydroseq"]) + hydroseqs.append(node_data["hydroseq"]) + streamorders.append(node_data["streamorder"]) + fcodes.append(node_data["fcode_description"]) + geometries.append(node_data["geometry"]) + + return gpd.GeoDataFrame( + { + "flowpath_id": flowpath_ids, + "VPUID": vpu_ids, + "areasqkm": das, + "totdasqkm": total_das, + "mainstemlp": mainstems, + "pathlength": pathlengths, + "dnhydroseq": dnhydroseqs, + "hydroseq": hydroseqs, + "stream_order": streamorders, + "fcode_description": fcodes, + }, + geometry=geometries, + crs="EPSG:4269", + ) + + +def _create_reference_divides( + divides_df: gpd.GeoDataFrame, reference_flowpaths: gpd.GeoDataFrame, vpu_id: str +) -> gpd.GeoDataFrame: + """A function to create the reference divides table + + Parameters + ---------- + divides_df : gpd.GeoDataFrame + the NHDCatchments table + reference_flowpaths : gpd.GeoDataFrame + The reference flowpaths + vpu_id : str + the VPUID we're working in + + Returns + ------- + gpd.GeoDataFrame + the outputted reference_divides + """ + reference_divides = divides_df.rename( + columns={"NHDPlusID": "divide_id", "VPUID": "vpuid", "AreaSqKm": "areasqkm"} + ) + reference_divides["divide_id"] = reference_divides["divide_id"].astype(int).astype(str) + reference_divides["vpuid"] = vpu_id + mask = reference_divides["divide_id"].isin(reference_flowpaths["flowpath_id"]) + reference_divides["has_flowpath"] = mask + reference_divides["flowpath_id"] = pd.NA + reference_divides.loc[mask, "flowpath_id"] = reference_divides.loc[mask, "divide_id"] + return reference_divides + + +def build_reference(**context: dict[str, Any]) -> dict[str, Any]: + """Opens local / downloads for the reference-build process + + Parameters + ---------- + **context : dict + Airflow-compatible context containing: + - ti : TaskInstance for XCom operations + - config : HFConfig with pipeline configuration + - task_id : str identifier for this task + - run_id : str identifier for this pipeline run + - ds : str execution date + - execution_date : datetime object + + Returns + ------- + dict[str, Any] + The reference flowpath and divides references in memory + """ + ti = cast(TaskInstance, context["ti"]) + cfg = cast(PRVI, context["config"]) + graph: rx.PyDiGraph = ti.xcom_pull(task_id="build_graphs", key="graph") + node_indices: dict[str, int] = ti.xcom_pull(task_id="build_graphs", key="node_indices") + _flowpaths: pl.DataFrame = ti.xcom_pull(task_id="download", key="nhd_flowpaths") + _divides: pl.DataFrame = ti.xcom_pull(task_id="download", key="nhd_divides") + cycles_iter = rx.simple_cycles(graph) + cycles: list[list[str]] = [] + cycle_ids: set[str] = set() + for cycle in cycles_iter: + _ids: list[Any] = [graph.get_node_data(node_idx) for node_idx in cycle] + cycles.append(_ids) + cycle_ids.update(_ids) + if cycle_ids: + raise NotImplementedError("Cycle Detected. Please create method for removing") + + _flowpaths_df = gpd.GeoDataFrame( + _flowpaths.select( + [ + pl.col("NHDPlusID"), + pl.col("VPUID"), + pl.col("LengthKM"), + pl.col("fcode_description"), + ] + ).to_pandas(), + geometry=gpd.GeoSeries.from_wkb(_flowpaths["geometry"]), + crs="EPSG:4269", + ) + + _divides_df = gpd.GeoDataFrame( + _divides.select( + [ + pl.col("NHDPlusID"), + pl.col("VPUID"), + pl.col("AreaSqKm"), + ] + ).to_pandas(), + geometry=gpd.GeoSeries.from_wkb(_divides["geometry"]), + crs="EPSG:4269", + ) + reference_flowpaths = _trace_attributes(graph, node_indices, _flowpaths_df, _divides_df, cfg.vpu_id) + reference_divides = _create_reference_divides(_divides_df, reference_flowpaths, cfg.vpu_id) + + return {"reference_flowpaths": reference_flowpaths, "reference_divides": reference_divides} diff --git a/src/reference_builds/pipeline/download.py b/src/reference_builds/pipeline/download.py index edb2a63..cc9be03 100644 --- a/src/reference_builds/pipeline/download.py +++ b/src/reference_builds/pipeline/download.py @@ -9,7 +9,7 @@ import polars as pl from reference_builds.configs import PRVI -from reference_builds.graph import _validate_and_fix_geometries +from reference_builds.utils import _validate_and_fix_geometries logger = logging.getLogger(__name__) @@ -39,7 +39,7 @@ def download_nhd_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: Returns ------- - dict[str, gpd.GeoDataFrame] + dict[str, pl.DataFrame] The reference flowpath and divides references in memory """ cfg = cast(PRVI, context["config"]) diff --git a/src/reference_builds/pipeline/processing.py b/src/reference_builds/pipeline/processing.py index 49b9937..fdc56e6 100644 --- a/src/reference_builds/pipeline/processing.py +++ b/src/reference_builds/pipeline/processing.py @@ -1,4 +1,4 @@ -"""Contains all code for downloading hydrofabric data""" +"""Contains all code for processing nhd data""" import logging from typing import Any, cast diff --git a/src/reference_builds/pipeline/write.py b/src/reference_builds/pipeline/write.py new file mode 100644 index 0000000..8c976d1 --- /dev/null +++ b/src/reference_builds/pipeline/write.py @@ -0,0 +1,50 @@ +"""Contains all code for downloading hydrofabric data""" + +import logging +from typing import Any, cast + +from reference_builds.configs import PRVI +from reference_builds.task_instance import TaskInstance + +logger = logging.getLogger(__name__) + + +def write_reference(**context: dict[str, Any]) -> dict[str, Any]: + """Opens local / downloads for the reference-build process + + Parameters + ---------- + **context : dict + Airflow-compatible context containing: + - ti : TaskInstance for XCom operations + - config : HFConfig with pipeline configuration + - task_id : str identifier for this task + - run_id : str identifier for this pipeline run + - ds : str execution date + - execution_date : datetime object + + Returns + ------- + dict[str, Any] + The reference flowpath and divides references in memory + """ + cfg = cast(PRVI, context["config"]) + ti = cast(TaskInstance, context["ti"]) + cfg.output_reference_flowpaths_path.unlink(missing_ok=True) # deletes files that exist with the same name + cfg.output_reference_divides_path.unlink(missing_ok=True) # deletes files that exist with the same name + + final_flowpaths = ti.xcom_pull(task_id="build_reference", key="reference_flowpaths") + final_divides = ti.xcom_pull(task_id="build_reference", key="reference_divides") + + final_flowpaths = final_flowpaths.to_crs(cfg.crs) + final_divides = final_divides.to_crs(cfg.crs) + + if cfg.write_gpkg: + cfg.output_reference_gpkg_path.unlink(missing_ok=True) + final_flowpaths.to_file(cfg.output_reference_gpkg_path, layer="reference_flowpaths", driver="GPKG") + final_divides.to_file(cfg.output_reference_gpkg_path, layer="reference_divides", driver="GPKG") + logger.info(f"write_nhd_data task: wrote geopackage layers to {cfg.output_reference_gpkg_path}") + + final_flowpaths.to_parquet(cfg.output_reference_flowpaths_path) + final_flowpaths.to_parquet(cfg.output_reference_divides_path) + return {} diff --git a/src/reference_builds/graph/__init__.py b/src/reference_builds/utils/__init__.py similarity index 64% rename from src/reference_builds/graph/__init__.py rename to src/reference_builds/utils/__init__.py index 82ef625..7a6e1e2 100644 --- a/src/reference_builds/graph/__init__.py +++ b/src/reference_builds/utils/__init__.py @@ -1,4 +1,4 @@ -from .utils import _validate_and_fix_geometries +from .geometries import _validate_and_fix_geometries from .v22_graph import build_v22_graph __all__ = ["_validate_and_fix_geometries", "v22_graph"] diff --git a/src/reference_builds/graph/utils.py b/src/reference_builds/utils/geometries.py similarity index 100% rename from src/reference_builds/graph/utils.py rename to src/reference_builds/utils/geometries.py diff --git a/src/reference_builds/graph/v22_graph.py b/src/reference_builds/utils/v22_graph.py similarity index 100% rename from src/reference_builds/graph/v22_graph.py rename to src/reference_builds/utils/v22_graph.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0311222 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,125 @@ +"""Conftests for Pytest Suite""" + +import geopandas as gpd +import pytest +import rustworkx as rx +from shapely.geometry import LineString, MultiLineString, MultiPolygon, Polygon + + +@pytest.fixture +def sample_graph() -> tuple[rx.PyDiGraph, dict[str, int]]: + """Create a simple directed graph for testing. + + Graph structure: + 1 -> 3 + 2 -> 3 + 3 -> 4 + 4 -> 5 (outlet) + """ + graph = rx.PyDiGraph() + + # Add nodes (using NHDPlusID as string) + node_data = [ + "85000100000001", + "85000100000002", + "85000100000003", + "85000100000004", + "85000100000005", + ] + + node_indices = {} + for fp_id in node_data: + idx = graph.add_node(fp_id) + node_indices[fp_id] = idx + + # Add edges (upstream -> downstream) + graph.add_edge(node_indices["85000100000001"], node_indices["85000100000003"], None) + graph.add_edge(node_indices["85000100000002"], node_indices["85000100000003"], None) + graph.add_edge(node_indices["85000100000003"], node_indices["85000100000004"], None) + graph.add_edge(node_indices["85000100000004"], node_indices["85000100000005"], None) + + return graph, node_indices + + +@pytest.fixture +def sample_flowpaths() -> gpd.GeoDataFrame: + """Create sample flowpaths GeoDataFrame.""" + data = { + "NHDPlusID": [ + 85000100000001, + 85000100000002, + 85000100000003, + 85000100000004, + 85000100000005, + ], + "VPUID": ["2101", "2101", "2101", "2101", "2101"], + "LengthKM": [0.333, 0.5, 1.201, 0.182, 0.387], + "fcode_description": [ + "Stream/River: Hydrographic Category = Intermittent", + "Artificial Path", + "Stream/River: Hydrographic Category = Intermittent", + "Artificial Path", + "Stream/River: Hydrographic Category = Intermittent", + ], + } + + # Create simple linestring geometries + geometries = [MultiLineString([LineString([(0, i), (1, i)])]) for i in range(5)] + + return gpd.GeoDataFrame(data, geometry=geometries, crs="EPSG:4269") + + +@pytest.fixture +def sample_divides() -> gpd.GeoDataFrame: + """Create sample divides GeoDataFrame.""" + data = { + "NHDPlusID": [ + 85000100000001, + 85000100000002, + 85000100000003, + 85000100000004, + 85000100000005, + ], + "VPUID": ["2101", "2101", "2101", "2101", "2101"], + "AreaSqKm": [0.1602, 0.6949, 0.0248, 0.1413, 0.9395], + } + + # Create simple polygon geometries + geometries = [MultiPolygon([Polygon([(i, 0), (i + 1, 0), (i + 1, 1), (i, 1)])]) for i in range(5)] + + return gpd.GeoDataFrame(data, geometry=geometries, crs="EPSG:4269") + + +@pytest.fixture +def disconnected_graph() -> tuple[rx.PyDiGraph, dict[str, int]]: + """Create a graph with multiple disconnected subgraphs (multiple outlets). + + Subgraph 1: + 1 -> 2 (outlet) + + Subgraph 2: + 3 -> 4 -> 5 (outlet) + """ + graph = rx.PyDiGraph() + + node_data = [ + "85000100000001", + "85000100000002", + "85000100000003", + "85000100000004", + "85000100000005", + ] + + node_indices = {} + for fp_id in node_data: + idx = graph.add_node(fp_id) + node_indices[fp_id] = idx + + # Subgraph 1 + graph.add_edge(node_indices["85000100000001"], node_indices["85000100000002"], None) + + # Subgraph 2 + graph.add_edge(node_indices["85000100000003"], node_indices["85000100000004"], None) + graph.add_edge(node_indices["85000100000004"], node_indices["85000100000005"], None) + + return graph, node_indices diff --git a/tests/test_builds.py b/tests/test_builds.py new file mode 100644 index 0000000..6d6022c --- /dev/null +++ b/tests/test_builds.py @@ -0,0 +1,384 @@ +"""Tests for build_reference module""" + +import geopandas as gpd +import numpy as np +import pytest +import rustworkx as rx +from shapely.geometry import LineString, MultiLineString, MultiPolygon, Polygon + +from reference_builds.pipeline.build_reference import ( + _create_reference_divides, + _trace_attributes, +) + + +class TestTraceAttributes: + """Tests for _trace_attributes function.""" + + def test_output_columns( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that output has expected columns.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + expected_columns = { + "flowpath_id", + "VPUID", + "areasqkm", + "totdasqkm", + "mainstemlp", + "pathlength", + "dnhydroseq", + "hydroseq", + "stream_order", + "fcode_description", + "geometry", + } + + assert set(result.columns) == expected_columns + + def test_all_flowpaths_traced( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that all flowpaths in graph are traced.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + assert len(result) == graph.num_nodes() + + def test_hydroseq_unique( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that hydroseq values are unique.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + assert result["hydroseq"].nunique() == len(result) + + def test_outlet_has_zero_dnhydroseq( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that outlet node has dnhydroseq of 0.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + # Outlet is 85000100000005 + outlet_row = result[result["flowpath_id"] == "85000100000005"] + assert outlet_row["dnhydroseq"].iloc[0] == 0 + + def test_outlet_has_zero_pathlength( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that outlet node has pathlength of 0.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + outlet_row = result[result["flowpath_id"] == "85000100000005"] + assert outlet_row["pathlength"].iloc[0] == 0.0 + + def test_headwaters_have_stream_order_1( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that headwater nodes have stream order 1.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + # Headwaters are 85000100000001 and 85000100000002 + headwater_rows = result[result["flowpath_id"].isin(["85000100000001", "85000100000002"])] + assert (headwater_rows["stream_order"] == 1).all() + + def test_strahler_order_increases_at_confluence( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that stream order increases when two streams of same order meet.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + # Node 3 is confluence of two order-1 streams, should be order 2 + confluence_row = result[result["flowpath_id"] == "85000100000003"] + assert confluence_row["stream_order"].iloc[0] == 2 + + def test_totdasqkm_accumulates_downstream( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that total drainage area accumulates downstream.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + # Outlet should have largest totdasqkm + outlet_row = result[result["flowpath_id"] == "85000100000005"] + max_da = result["totdasqkm"].max() + + assert outlet_row["totdasqkm"].iloc[0] == max_da + + def test_totdasqkm_equals_sum_of_all_areas_at_outlet( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that outlet totdasqkm equals sum of all upstream areas.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + outlet_row = result[result["flowpath_id"] == "85000100000005"] + total_area = sample_divides["AreaSqKm"].sum() + + assert np.isclose(outlet_row["totdasqkm"].iloc[0], total_area) + + def test_pathlength_increases_upstream( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that pathlength increases going upstream.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + # Headwaters should have larger pathlength than outlet + headwater_row = result[result["flowpath_id"] == "85000100000001"] + outlet_row = result[result["flowpath_id"] == "85000100000005"] + + assert headwater_row["pathlength"].iloc[0] > outlet_row["pathlength"].iloc[0] + + def test_mainstemlp_assigned_to_all_nodes( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that all nodes have a mainstem level path assigned.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + assert result["mainstemlp"].notna().all() + + def test_multiple_outlets_handled( + self, + disconnected_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that disconnected subgraphs with multiple outlets are handled.""" + graph, node_indices = disconnected_graph + result = _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + # Should have two outlets with dnhydroseq = 0 + outlets = result[result["dnhydroseq"] == 0] + assert len(outlets) == 2 + + +class TestCreateReferenceDivides: + """Tests for _create_reference_divides function.""" + + @pytest.fixture + def sample_reference_flowpaths(self, sample_flowpaths: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + """Create sample reference flowpaths (subset of divides).""" + # Only include some flowpaths (simulating filtered network) + return gpd.GeoDataFrame( + { + "flowpath_id": ["85000100000001", "85000100000003", "85000100000005"], + "VPUID": ["21", "21", "21"], + }, + geometry=sample_flowpaths.geometry.iloc[:3].values, + crs="EPSG:4269", + ) + + def test_returns_geodataframe( + self, + sample_divides: gpd.GeoDataFrame, + sample_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that _create_reference_divides returns a GeoDataFrame.""" + result = _create_reference_divides(sample_divides, sample_reference_flowpaths, "21") + + assert isinstance(result, gpd.GeoDataFrame) + + def test_all_divides_included( + self, + sample_divides: gpd.GeoDataFrame, + sample_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that all divides are included in output.""" + result = _create_reference_divides(sample_divides, sample_reference_flowpaths, "21") + + assert len(result) == len(sample_divides) + + def test_has_flowpath_flag_correct( + self, + sample_divides: gpd.GeoDataFrame, + sample_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that has_flowpath flag is set correctly.""" + result = _create_reference_divides(sample_divides, sample_reference_flowpaths, "21") + + # 3 divides should have flowpaths + assert result["has_flowpath"].sum() == 3 + + def test_flowpath_id_assigned_when_has_flowpath( + self, + sample_divides: gpd.GeoDataFrame, + sample_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that flowpath_id is assigned when has_flowpath is True.""" + result = _create_reference_divides(sample_divides, sample_reference_flowpaths, "21") + + with_flowpath = result[result["has_flowpath"]] + assert with_flowpath["flowpath_id"].notna().all() + assert (with_flowpath["flowpath_id"] == with_flowpath["divide_id"]).all() + + def test_flowpath_id_na_when_no_flowpath( + self, + sample_divides: gpd.GeoDataFrame, + sample_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that flowpath_id is NA when has_flowpath is False.""" + result = _create_reference_divides(sample_divides, sample_reference_flowpaths, "21") + + without_flowpath = result[~result["has_flowpath"]] + assert without_flowpath["flowpath_id"].isna().all() + + +class TestGraphWithCycles: + """Tests for handling graphs with cycles.""" + + @pytest.fixture + def cyclic_graph(self) -> tuple[rx.PyDiGraph, dict[str, int]]: + """Create a graph with a cycle. + + 1 -> 2 -> 3 -> 1 (cycle) + """ + graph = rx.PyDiGraph() + + node_data = ["85000100000001", "85000100000002", "85000100000003"] + + node_indices = {} + for fp_id in node_data: + idx = graph.add_node(fp_id) + node_indices[fp_id] = idx + + graph.add_edge(node_indices["85000100000001"], node_indices["85000100000002"], None) + graph.add_edge(node_indices["85000100000002"], node_indices["85000100000003"], None) + graph.add_edge(node_indices["85000100000003"], node_indices["85000100000001"], None) + + return graph, node_indices + + def test_trace_attributes_raises_on_cycle( + self, + cyclic_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that _trace_attributes raises AssertionError on cyclic graph.""" + graph, node_indices = cyclic_graph + + with pytest.raises(AssertionError, match="Graph contains cycles"): + _trace_attributes(graph, node_indices, sample_flowpaths, sample_divides, "21") + + +class TestEdgeCases: + """Tests for edge cases.""" + + @pytest.fixture + def single_node_graph(self) -> tuple[rx.PyDiGraph, dict[str, int]]: + """Create a graph with a single node (headwater that is also outlet).""" + graph = rx.PyDiGraph() + idx = graph.add_node("85000100000001") + return graph, {"85000100000001": idx} + + @pytest.fixture + def single_flowpath(self) -> gpd.GeoDataFrame: + """Create single flowpath GeoDataFrame.""" + return gpd.GeoDataFrame( + { + "NHDPlusID": [85000100000001], + "VPUID": ["2101"], + "LengthKM": [0.5], + "fcode_description": ["Stream/River"], + }, + geometry=[MultiLineString([LineString([(0, 0), (1, 1)])])], + crs="EPSG:4269", + ) + + @pytest.fixture + def single_divide(self) -> gpd.GeoDataFrame: + """Create single divide GeoDataFrame.""" + return gpd.GeoDataFrame( + { + "NHDPlusID": [85000100000001], + "VPUID": ["2101"], + "AreaSqKm": [1.0], + }, + geometry=[MultiPolygon([Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])])], + crs="EPSG:4269", + ) + + def test_single_node_graph( + self, + single_node_graph: tuple[rx.PyDiGraph, dict[str, int]], + single_flowpath: gpd.GeoDataFrame, + single_divide: gpd.GeoDataFrame, + ) -> None: + """Test that single node graph is handled correctly.""" + graph, node_indices = single_node_graph + result = _trace_attributes(graph, node_indices, single_flowpath, single_divide, "21") + + assert len(result) == 1 + assert result["stream_order"].iloc[0] == 1 + assert result["dnhydroseq"].iloc[0] == 0 + assert result["pathlength"].iloc[0] == 0.0 + assert result["totdasqkm"].iloc[0] == 1.0 + + def test_missing_divide_defaults_to_zero_area( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that missing divides default to zero area.""" + graph, node_indices = sample_graph + + # Create divides missing one entry + partial_divides = gpd.GeoDataFrame( + { + "NHDPlusID": [85000100000001, 85000100000002], + "VPUID": ["2101", "2101"], + "AreaSqKm": [0.5, 0.5], + }, + geometry=[ + MultiPolygon([Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])]), + MultiPolygon([Polygon([(1, 0), (2, 0), (2, 1), (1, 1)])]), + ], + crs="EPSG:4269", + ) + + result = _trace_attributes(graph, node_indices, sample_flowpaths, partial_divides, "21") + + # Nodes without divides should have areasqkm = 0 + missing_divide_rows = result[~result["flowpath_id"].isin(["85000100000001", "85000100000002"])] + assert (missing_divide_rows["areasqkm"] == 0.0).all() From a16dfdd42f913e2effd4ad4b924538c8361708e6 Mon Sep 17 00:00:00 2001 From: Tadd Bindas Date: Tue, 30 Dec 2025 14:23:56 -0600 Subject: [PATCH 06/16] patch: fixed incorrect parquet read (#7) --- src/reference_builds/pipeline/write.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reference_builds/pipeline/write.py b/src/reference_builds/pipeline/write.py index 8c976d1..0a3a7c5 100644 --- a/src/reference_builds/pipeline/write.py +++ b/src/reference_builds/pipeline/write.py @@ -46,5 +46,5 @@ def write_reference(**context: dict[str, Any]) -> dict[str, Any]: logger.info(f"write_nhd_data task: wrote geopackage layers to {cfg.output_reference_gpkg_path}") final_flowpaths.to_parquet(cfg.output_reference_flowpaths_path) - final_flowpaths.to_parquet(cfg.output_reference_divides_path) + final_divides.to_parquet(cfg.output_reference_divides_path) return {} From 32f3d72e05c432ca60c27c609ce5f1bde0942995 Mon Sep 17 00:00:00 2001 From: Tadd Bindas Date: Tue, 30 Dec 2025 14:36:23 -0600 Subject: [PATCH 07/16] update: streamorder name incorrect (#8) --- src/reference_builds/pipeline/build_reference.py | 2 +- src/reference_builds/pipeline/write.py | 2 +- tests/test_builds.py | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/reference_builds/pipeline/build_reference.py b/src/reference_builds/pipeline/build_reference.py index b63bb8b..c8195d2 100644 --- a/src/reference_builds/pipeline/build_reference.py +++ b/src/reference_builds/pipeline/build_reference.py @@ -215,7 +215,7 @@ def _trace_attributes( "pathlength": pathlengths, "dnhydroseq": dnhydroseqs, "hydroseq": hydroseqs, - "stream_order": streamorders, + "streamorder": streamorders, "fcode_description": fcodes, }, geometry=geometries, diff --git a/src/reference_builds/pipeline/write.py b/src/reference_builds/pipeline/write.py index 0a3a7c5..a0aad69 100644 --- a/src/reference_builds/pipeline/write.py +++ b/src/reference_builds/pipeline/write.py @@ -43,7 +43,7 @@ def write_reference(**context: dict[str, Any]) -> dict[str, Any]: cfg.output_reference_gpkg_path.unlink(missing_ok=True) final_flowpaths.to_file(cfg.output_reference_gpkg_path, layer="reference_flowpaths", driver="GPKG") final_divides.to_file(cfg.output_reference_gpkg_path, layer="reference_divides", driver="GPKG") - logger.info(f"write_nhd_data task: wrote geopackage layers to {cfg.output_reference_gpkg_path}") + logger.info(f"write_reference task: wrote geopackage layers to {cfg.output_reference_gpkg_path}") final_flowpaths.to_parquet(cfg.output_reference_flowpaths_path) final_divides.to_parquet(cfg.output_reference_divides_path) diff --git a/tests/test_builds.py b/tests/test_builds.py index 6d6022c..c85c8b2 100644 --- a/tests/test_builds.py +++ b/tests/test_builds.py @@ -34,7 +34,7 @@ def test_output_columns( "pathlength", "dnhydroseq", "hydroseq", - "stream_order", + "streamorder", "fcode_description", "geometry", } @@ -104,7 +104,7 @@ def test_headwaters_have_stream_order_1( # Headwaters are 85000100000001 and 85000100000002 headwater_rows = result[result["flowpath_id"].isin(["85000100000001", "85000100000002"])] - assert (headwater_rows["stream_order"] == 1).all() + assert (headwater_rows["streamorder"] == 1).all() def test_strahler_order_increases_at_confluence( self, @@ -118,7 +118,7 @@ def test_strahler_order_increases_at_confluence( # Node 3 is confluence of two order-1 streams, should be order 2 confluence_row = result[result["flowpath_id"] == "85000100000003"] - assert confluence_row["stream_order"].iloc[0] == 2 + assert confluence_row["streamorder"].iloc[0] == 2 def test_totdasqkm_accumulates_downstream( self, @@ -350,7 +350,7 @@ def test_single_node_graph( result = _trace_attributes(graph, node_indices, single_flowpath, single_divide, "21") assert len(result) == 1 - assert result["stream_order"].iloc[0] == 1 + assert result["streamorder"].iloc[0] == 1 assert result["dnhydroseq"].iloc[0] == 0 assert result["pathlength"].iloc[0] == 0.0 assert result["totdasqkm"].iloc[0] == 1.0 From 0d7bcfd3aae77784afc361616d9b0c1f14313ca6 Mon Sep 17 00:00:00 2001 From: Tadd Bindas Date: Tue, 30 Dec 2025 14:58:39 -0600 Subject: [PATCH 08/16] feat: added remaining code for reference to work with nhf-builds (#9) --- src/reference_builds/pipeline/build_reference.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/reference_builds/pipeline/build_reference.py b/src/reference_builds/pipeline/build_reference.py index c8195d2..a999d1a 100644 --- a/src/reference_builds/pipeline/build_reference.py +++ b/src/reference_builds/pipeline/build_reference.py @@ -146,7 +146,7 @@ def _trace_attributes( key=lambda idx: (graph[idx]["pathlength"], graph[idx]["totdasqkm"]), ) - # Assign dnhydroseq based on graph edges + # Assign dnhydroseq and flowpath_toid based on graph edges for node_idx in graph.node_indices(): out_edges = graph.out_edges(node_idx) downstream_nodes = [tgt_idx for _, tgt_idx, _ in out_edges] @@ -154,8 +154,10 @@ def _trace_attributes( if downstream_nodes: downstream_idx = downstream_nodes[0] graph[node_idx]["dnhydroseq"] = graph[downstream_idx]["hydroseq"] + graph[node_idx]["flowpath_toid"] = graph[downstream_idx]["flowpath_id"] else: graph[node_idx]["dnhydroseq"] = 0 + graph[node_idx]["flowpath_toid"] = "0" # PASS 2: Calculate totdasqkm and stream_order (forward topo order - downstream from headwaters) for node_idx in topo_order: @@ -180,8 +182,10 @@ def _trace_attributes( # Extract results flowpath_ids = [] + flowpath_toids = [] vpu_ids = [] das = [] + lengthkms = [] total_das = [] mainstems = [] pathlengths = [] @@ -194,8 +198,10 @@ def _trace_attributes( for node_idx in graph.node_indices(): node_data = graph[node_idx] flowpath_ids.append(node_data["flowpath_id"]) + flowpath_toids.append(node_data["flowpath_toid"]) vpu_ids.append(vpu_id) das.append(node_data["areasqkm"]) + lengthkms.append(node_data["lengthkm"]) total_das.append(node_data["totdasqkm"]) mainstems.append(node_data["mainstemlp"]) pathlengths.append(node_data["pathlength"]) @@ -208,7 +214,9 @@ def _trace_attributes( return gpd.GeoDataFrame( { "flowpath_id": flowpath_ids, + "flowpath_toid": flowpath_toids, "VPUID": vpu_ids, + "lengthkm": lengthkms, "areasqkm": das, "totdasqkm": total_das, "mainstemlp": mainstems, From 5d281c3fb9eead8bf4ec2633fda63d07cf291486 Mon Sep 17 00:00:00 2001 From: Tadd Bindas Date: Wed, 31 Dec 2025 13:50:55 -0600 Subject: [PATCH 09/16] refactor: made configs abstract to account for more reference domains (#10) * refactor: made configs abstract to account for more reference domains * patch: added single flowpaths to output * tests: fixed tests --- .../{prvi_reference.py => build_reference.py} | 6 ++--- config/example_hi.yaml | 4 +++ config/example_prvi.yaml | 4 ++- src/reference_builds/configs/__init__.py | 4 +-- .../configs/{prvi.py => reference_config.py} | 27 ++++++++++--------- src/reference_builds/local_runner.py | 6 ++--- .../pipeline/build_reference.py | 4 +-- src/reference_builds/pipeline/download.py | 4 +-- src/reference_builds/pipeline/processing.py | 8 ++++++ src/reference_builds/pipeline/write.py | 4 +-- tests/test_builds.py | 2 ++ 11 files changed, 46 insertions(+), 27 deletions(-) rename builds/{prvi_reference.py => build_reference.py} (89%) create mode 100644 config/example_hi.yaml rename src/reference_builds/configs/{prvi.py => reference_config.py} (63%) diff --git a/builds/prvi_reference.py b/builds/build_reference.py similarity index 89% rename from builds/prvi_reference.py rename to builds/build_reference.py index e8d628b..64a92a2 100644 --- a/builds/prvi_reference.py +++ b/builds/build_reference.py @@ -1,11 +1,11 @@ -"""An end-to-end build file that will take the NHD PRVI and turn it into a reference fabric""" +"""An end-to-end build file that will take the NHD ReferenceConfig and turn it into a reference fabric""" import argparse import logging from pydantic import ValidationError -from reference_builds.configs import PRVI +from reference_builds.configs import ReferenceConfig from reference_builds.local_runner import LocalRunner from reference_builds.pipeline import build_graphs, build_reference, download_nhd_data, write_reference @@ -25,7 +25,7 @@ def main() -> int: args = parser.parse_args() try: - config = PRVI.from_yaml(args.config) + config = ReferenceConfig.from_yaml(args.config) except ValidationError as e: print("Configuration validation failed:") for error in e.errors(): diff --git a/config/example_hi.yaml b/config/example_hi.yaml new file mode 100644 index 0000000..6905704 --- /dev/null +++ b/config/example_hi.yaml @@ -0,0 +1,4 @@ +domain: hi +input_file_regex: HI/NHDPLUS_H_20*_HU4_GPKG +vpu_id: "20" +crs: EPSG:32604 diff --git a/config/example_prvi.yaml b/config/example_prvi.yaml index 379dae3..099bfd9 100644 --- a/config/example_prvi.yaml +++ b/config/example_prvi.yaml @@ -1,2 +1,4 @@ -output_dir: data/ +domain: prvi +input_file_regex: PRVI/NHDPLUS_H_21*_HU4_GPKG +vpu_id: "21" crs: EPSG:6566 diff --git a/src/reference_builds/configs/__init__.py b/src/reference_builds/configs/__init__.py index 1c35d07..6ecdcb3 100644 --- a/src/reference_builds/configs/__init__.py +++ b/src/reference_builds/configs/__init__.py @@ -1,3 +1,3 @@ -from .prvi import PRVI +from .reference_config import ReferenceConfig -__all__ = ["PRVI"] +__all__ = ["ReferenceConfig"] diff --git a/src/reference_builds/configs/prvi.py b/src/reference_builds/configs/reference_config.py similarity index 63% rename from src/reference_builds/configs/prvi.py rename to src/reference_builds/configs/reference_config.py index add6c96..8da7f73 100644 --- a/src/reference_builds/configs/prvi.py +++ b/src/reference_builds/configs/reference_config.py @@ -10,25 +10,26 @@ from reference_builds import __version__ -class PRVI(BaseModel): - """Configs for building the PRVI reference""" +class ReferenceConfig(BaseModel): + """Configs for building the ReferenceConfig reference""" output_dir: Path = Field( default=here() / "data/", description="The directory for output files to be saved from Hydrofabric builds", ) + domain: str = Field(description="The domain used for the building your reference") + input_file_regex: str = Field( - default="PRVI/NHDPLUS_H_21*_HU4_GPKG", description="input file to be converted into a reference product", ) crs: str = Field( - default="EPSG:6566", - description="Coordinate Reference System for the PRVI reference builds. Defaults to https://epsg.io/6566", + default="EPSG:4326", + description="Coordinate Reference System for the domain reference builds. Defaults to https://epsg.io/4326", ) - vpu_id: str = Field(default="21", description="The VPUID for PRVI") + vpu_id: str = Field(description="The VPUID for the domain") write_gpkg: bool = Field( default=True, description="Writes a geopackage in addition to parquet files for output" @@ -47,18 +48,20 @@ class PRVI(BaseModel): ) output_reference_gpkg_path: Path = Field( - default_factory=lambda data: data["output_dir"] / f"prvi_{__version__}_reference.gpkg", - description="Save directory for the PRVI reference (in .gpkg form)", + default_factory=lambda data: data["output_dir"] / f"{data['domain']}_{__version__}_reference.gpkg", + description="Save directory for the domain's reference (in .gpkg form)", ) output_reference_divides_path: Path = Field( - default_factory=lambda data: data["output_dir"] / f"prvi_{__version__}_reference_divides.parquet", - description="Save directory for the PRVI reference divides", + default_factory=lambda data: data["output_dir"] + / f"{data['domain']}_{__version__}_reference_divides.parquet", + description="Save directory for the domain's reference divides", ) output_reference_flowpaths_path: Path = Field( - default_factory=lambda data: data["output_dir"] / f"prvi_{__version__}_reference_flowpaths.parquet", - description="Save directory for the PRVI reference flowpaths", + default_factory=lambda data: data["output_dir"] + / f"{data['domain']}_{__version__}_reference_flowpaths.parquet", + description="Save directory for the domain's reference flowpaths", ) @classmethod diff --git a/src/reference_builds/local_runner.py b/src/reference_builds/local_runner.py index 291b1eb..c864189 100644 --- a/src/reference_builds/local_runner.py +++ b/src/reference_builds/local_runner.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Any, Self -from reference_builds.configs import PRVI +from reference_builds.configs import ReferenceConfig from reference_builds.logs import setup_logging from reference_builds.task_instance import TaskInstance @@ -34,7 +34,7 @@ class LocalRunner: def __init__( self, - config: PRVI, + config: ReferenceConfig, run_id: str | None = None, ) -> None: """Initialize the LocalRunner. @@ -46,7 +46,7 @@ def __init__( run_id : str or None, default=None Optional run identifier. Auto-generated if not provided. """ - self.config: PRVI = config + self.config: ReferenceConfig = config self.run_id: str = run_id or datetime.now().strftime("%Y%m%d_%H%M%S") self.ti: TaskInstance = TaskInstance() self.results: dict[str, dict[str, Any]] = {} diff --git a/src/reference_builds/pipeline/build_reference.py b/src/reference_builds/pipeline/build_reference.py index a999d1a..ca22e88 100644 --- a/src/reference_builds/pipeline/build_reference.py +++ b/src/reference_builds/pipeline/build_reference.py @@ -8,7 +8,7 @@ import polars as pl import rustworkx as rx -from reference_builds.configs import PRVI +from reference_builds.configs import ReferenceConfig from reference_builds.task_instance import TaskInstance logger = logging.getLogger(__name__) @@ -282,7 +282,7 @@ def build_reference(**context: dict[str, Any]) -> dict[str, Any]: The reference flowpath and divides references in memory """ ti = cast(TaskInstance, context["ti"]) - cfg = cast(PRVI, context["config"]) + cfg = cast(ReferenceConfig, context["config"]) graph: rx.PyDiGraph = ti.xcom_pull(task_id="build_graphs", key="graph") node_indices: dict[str, int] = ti.xcom_pull(task_id="build_graphs", key="node_indices") _flowpaths: pl.DataFrame = ti.xcom_pull(task_id="download", key="nhd_flowpaths") diff --git a/src/reference_builds/pipeline/download.py b/src/reference_builds/pipeline/download.py index cc9be03..c848d97 100644 --- a/src/reference_builds/pipeline/download.py +++ b/src/reference_builds/pipeline/download.py @@ -8,7 +8,7 @@ import pandas as pd import polars as pl -from reference_builds.configs import PRVI +from reference_builds.configs import ReferenceConfig from reference_builds.utils import _validate_and_fix_geometries logger = logging.getLogger(__name__) @@ -42,7 +42,7 @@ def download_nhd_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: dict[str, pl.DataFrame] The reference flowpath and divides references in memory """ - cfg = cast(PRVI, context["config"]) + cfg = cast(ReferenceConfig, context["config"]) # find the gpkg files from the ScienceBase NHD folders matching_folders = list(cfg.output_dir.glob(cfg.input_file_regex)) diff --git a/src/reference_builds/pipeline/processing.py b/src/reference_builds/pipeline/processing.py index fdc56e6..39ef942 100644 --- a/src/reference_builds/pipeline/processing.py +++ b/src/reference_builds/pipeline/processing.py @@ -65,6 +65,14 @@ def _build_graph(connectivity: pl.DataFrame, flowpaths: pl.DataFrame) -> dict[st ) ) + all_flowpath_ids = flowpaths.select(pl.col("NHDPlusID").cast(pl.Int64).cast(pl.Utf8))[ + "NHDPlusID" + ].to_list() + + for fp_id in all_flowpath_ids: + if fp_id not in upstream_dict: + upstream_dict[fp_id] = [] + return upstream_dict diff --git a/src/reference_builds/pipeline/write.py b/src/reference_builds/pipeline/write.py index a0aad69..7152e31 100644 --- a/src/reference_builds/pipeline/write.py +++ b/src/reference_builds/pipeline/write.py @@ -3,7 +3,7 @@ import logging from typing import Any, cast -from reference_builds.configs import PRVI +from reference_builds.configs import ReferenceConfig from reference_builds.task_instance import TaskInstance logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ def write_reference(**context: dict[str, Any]) -> dict[str, Any]: dict[str, Any] The reference flowpath and divides references in memory """ - cfg = cast(PRVI, context["config"]) + cfg = cast(ReferenceConfig, context["config"]) ti = cast(TaskInstance, context["ti"]) cfg.output_reference_flowpaths_path.unlink(missing_ok=True) # deletes files that exist with the same name cfg.output_reference_divides_path.unlink(missing_ok=True) # deletes files that exist with the same name diff --git a/tests/test_builds.py b/tests/test_builds.py index c85c8b2..c36a7e1 100644 --- a/tests/test_builds.py +++ b/tests/test_builds.py @@ -27,7 +27,9 @@ def test_output_columns( expected_columns = { "flowpath_id", + "flowpath_toid", "VPUID", + "lengthkm", "areasqkm", "totdasqkm", "mainstemlp", From 7e5e1f08a3b2f86f2c9c1c2a158787c4cdfec8d0 Mon Sep 17 00:00:00 2001 From: Tadd Bindas Date: Tue, 6 Jan 2026 15:43:47 -0500 Subject: [PATCH 10/16] Alaska reference: working with Geoglows (#11) * feat: added ak builds in base reference script * reference: finished the alaska reference * patch: updated regex * patch: updated tests * patch: added reference tests to be able to confirm linestrings are oriented correctly --- builds/build_reference.py | 30 +- config/example_ak.yaml | 6 + licenses.md | 60 ++ src/reference_builds/configs/__init__.py | 4 +- .../configs/reference_config.py | 19 +- src/reference_builds/pipeline/__init__.py | 13 +- .../pipeline/build_reference.py | 394 +++++++++++- src/reference_builds/pipeline/download.py | 55 +- src/reference_builds/pipeline/processing.py | 99 +-- src/reference_builds/utils/geoglows_graph.py | 52 ++ src/reference_builds/utils/geometries.py | 84 ++- src/reference_builds/utils/nhd_graph.py | 72 +++ tests/conftest.py | 179 ++++++ tests/test_builds.py | 564 +++++++++++++++++- tests/test_geometry_utils.py | 266 +++++++++ 15 files changed, 1806 insertions(+), 91 deletions(-) create mode 100644 config/example_ak.yaml create mode 100644 licenses.md create mode 100644 src/reference_builds/utils/geoglows_graph.py create mode 100644 src/reference_builds/utils/nhd_graph.py create mode 100644 tests/test_geometry_utils.py diff --git a/builds/build_reference.py b/builds/build_reference.py index 64a92a2..bb62418 100644 --- a/builds/build_reference.py +++ b/builds/build_reference.py @@ -5,9 +5,17 @@ from pydantic import ValidationError -from reference_builds.configs import ReferenceConfig +from reference_builds.configs import BaseDataset, ReferenceConfig from reference_builds.local_runner import LocalRunner -from reference_builds.pipeline import build_graphs, build_reference, download_nhd_data, write_reference +from reference_builds.pipeline import ( + build_geoglows_graphs, + build_geoglows_reference, + build_nhd_graphs, + build_nhd_reference, + download_geoglows_data, + download_nhd_data, + write_reference, +) logger = logging.getLogger(__name__) @@ -39,10 +47,20 @@ def main() -> int: raise TypeError("Config file not specified.") from e with LocalRunner(config) as runner: - runner.run_task(task_id="download", python_callable=download_nhd_data, op_kwargs={}) - runner.run_task(task_id="build_graphs", python_callable=build_graphs, op_kwargs={}) - runner.run_task(task_id="build_reference", python_callable=build_reference, op_kwargs={}) - runner.run_task(task_id="write_reference", python_callable=write_reference, op_kwargs={}) + if config.base_dataset == BaseDataset.NHD: + runner.run_task(task_id="download", python_callable=download_nhd_data, op_kwargs={}) + runner.run_task(task_id="build_nhd_graphs", python_callable=build_nhd_graphs, op_kwargs={}) + runner.run_task(task_id="build_reference", python_callable=build_nhd_reference, op_kwargs={}) + runner.run_task(task_id="write_reference", python_callable=write_reference, op_kwargs={}) + elif config.base_dataset == BaseDataset.GEOGLOWS: + runner.run_task(task_id="download", python_callable=download_geoglows_data, op_kwargs={}) + runner.run_task( + task_id="build_geoglows_graphs", python_callable=build_geoglows_graphs, op_kwargs={} + ) + runner.run_task(task_id="build_reference", python_callable=build_geoglows_reference, op_kwargs={}) + runner.run_task(task_id="write_reference", python_callable=write_reference, op_kwargs={}) + else: + raise NotImplementedError("Base Dataset not implemented") print("Pipeline completed") print("=" * 60) diff --git a/config/example_ak.yaml b/config/example_ak.yaml new file mode 100644 index 0000000..9946aab --- /dev/null +++ b/config/example_ak.yaml @@ -0,0 +1,6 @@ +domain: ak +base_dataset: geoglows +input_file_regex: AK/streams_mapping_[78]*.gpkg +geoglows_catchment_regex: AK/catchments_[78]*.parquet +vpu_id: "19" +crs: EPSG:3338 diff --git a/licenses.md b/licenses.md new file mode 100644 index 0000000..5f1f570 --- /dev/null +++ b/licenses.md @@ -0,0 +1,60 @@ +# Licenses + +## CC BY 4.0 License + +The GEOGloWS Hydrologic Model Version 2.0 (GEOGloWS) is licensed under the Creative Commons Attribution 4.0 +International License. To view a copy of this license, visit [http://creativecommons.org/licenses/by/4.0/](http://creativecommons.org/licenses/by/4.0/). + +You are free to: + +Share — copy and redistribute the material in any medium or format for any purpose, even commercially. + +Adapt — remix, transform, and build upon the material for any purpose, even commercially. The licensor cannot revoke these freedoms as long as you follow the license terms. + +Under the following terms: + +Attribution - You must give appropriate credit , provide a link to the license, and indicate if changes were made . You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. + +No additional restrictions - You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits. + +Notices: + +You do not have to comply with the license for elements of the material in the public domain or where your use is permitted by an applicable exception or limitation . + +No warranties are given. The license may not give you all of the permissions necessary for your intended use. For example, other rights such as publicity, privacy, or moral rights may limit how you use the material. + + +## Licenses of Open Datasets +GEOGloWS uses several licensed open source datasets. The following table summarizes the licenses for these data. + +| Dataset | Purpose | License | +|---------------------------------------------------|--------------------------------------------------------|-----------------------------------------------------------------| +| [TDX-Hydro](https://nga.mil) | Geospatial stream center line and catchment boundaries | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) | +| [ECMWF IFS](https://doi.org/10.21957/fv6k37c49h) | Gridded runoff forecast data | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | +| [ERA5](https://doi.org/10.24381/cds.143582cf) | Gridded runoff retrospective data | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | + +### Licensing statements required by ECMWF Terms of Use + +The following statements are required by ECMWF in compliance with their [terms of use](https://doi.org/10.21957/open-data). + +The following wording shall be attached to the use of this ECMWF data product: +1. Copyright statement: Copyright "© [2023] European Centre for Medium-Range Weather Forecasts (ECMWF)". +2. Source www.ecmwf.int +3. Licence Statement: This data is published under a Creative Commons Attribution 4.0 International (CC BY 4.0). https://creativecommons.org/licenses/by/4.0/ +4. Disclaimer: ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use. +5. Where applicable, an indication if the material has been modified and an indication of previous modifications. GEOGloWS makes no modifications to datasets provided by ECMWF. + +## Acknowledgement of Open Source Code +Several open source code projects are used to build GEOGloWS. They do not require license statements or redistribution but we acknowledge their role. +1. [GDAL](https://gdal.org/) - Geospatial Data Abstraction Library +2. [PROJ](https://proj.org/) - Cartographic Projections Library +3. [Geopackage](https://www.geopackage.org/) - GeoPackage is an OGC open source, standards-based, platform-independent, portable, self-describing, compact format for transferring geospatial information. +4. [NetCDF](https://www.unidata.ucar.edu/software/netcdf/) - Network Common Data Form +5. [Zarr](https://zarr.readthedocs.io/en/stable/) - Zarr is a format for the storage of chunked, compressed, N-dimensional arrays +6. [NCO](http://nco.sourceforge.net/) - NetCDF Operators (NCO) are a suite of tools for manipulation of NetCDF files +7. [Xarray](http://xarray.pydata.org/en/stable/) - N-D labeled arrays and datasets in Python +8. [Dask](https://dask.org/) - Dask is a flexible library for parallel computing in Python +9. [Numpy](https://numpy.org/) - The fundamental package for scientific computing with Python +10. [Pandas](https://pandas.pydata.org/) - Powerful data structures for data analysis, time series, and statistics +11. [GeoPandas](https://geopandas.org/) - GeoPandas is an open source project to make working with geospatial data in python easier +12. [RAPID](https://rapid-hub.org/) - RAPID is an implementation of the musking river routing method in fortran optimized for large river networks. diff --git a/src/reference_builds/configs/__init__.py b/src/reference_builds/configs/__init__.py index 6ecdcb3..1116bc1 100644 --- a/src/reference_builds/configs/__init__.py +++ b/src/reference_builds/configs/__init__.py @@ -1,3 +1,3 @@ -from .reference_config import ReferenceConfig +from .reference_config import BaseDataset, ReferenceConfig -__all__ = ["ReferenceConfig"] +__all__ = ["BaseDataset", "ReferenceConfig"] diff --git a/src/reference_builds/configs/reference_config.py b/src/reference_builds/configs/reference_config.py index 8da7f73..f2d1545 100644 --- a/src/reference_builds/configs/reference_config.py +++ b/src/reference_builds/configs/reference_config.py @@ -1,5 +1,6 @@ """A file to host all Hydrofabric Schemas""" +from enum import Enum from pathlib import Path from typing import Self @@ -10,6 +11,13 @@ from reference_builds import __version__ +class BaseDataset(str, Enum): + """Enum for the base dataset used in reference builds""" + + NHD = "nhd" + GEOGLOWS = "geoglows" + + class ReferenceConfig(BaseModel): """Configs for building the ReferenceConfig reference""" @@ -18,10 +26,19 @@ class ReferenceConfig(BaseModel): description="The directory for output files to be saved from Hydrofabric builds", ) + base_dataset: BaseDataset = Field( + default=BaseDataset.NHD, + description="The base dataset to use for the reference build", + ) + domain: str = Field(description="The domain used for the building your reference") input_file_regex: str = Field( - description="input file to be converted into a reference product", + description="regex to find input files to be converted into a reference product", + ) + geoglows_catchment_regex: str | None = Field( + default=None, + description="regex to file catchment files from geoglows", ) crs: str = Field( diff --git a/src/reference_builds/pipeline/__init__.py b/src/reference_builds/pipeline/__init__.py index 6042b6b..de3a7e2 100644 --- a/src/reference_builds/pipeline/__init__.py +++ b/src/reference_builds/pipeline/__init__.py @@ -1,11 +1,14 @@ -from .build_reference import build_reference -from .download import download_nhd_data -from .processing import build_graphs +from .build_reference import build_geoglows_reference, build_nhd_reference +from .download import download_geoglows_data, download_nhd_data +from .processing import build_geoglows_graphs, build_nhd_graphs from .write import write_reference __all__ = [ - "build_graphs", - "build_reference", + "build_geoglows_graphs", + "build_nhd_graphs", + "download_geoglows_data", + "build_nhd_reference", + "build_geoglows_reference", "download_nhd_data", "write_reference", ] diff --git a/src/reference_builds/pipeline/build_reference.py b/src/reference_builds/pipeline/build_reference.py index ca22e88..26c6fe4 100644 --- a/src/reference_builds/pipeline/build_reference.py +++ b/src/reference_builds/pipeline/build_reference.py @@ -1,4 +1,4 @@ -"""Contains all code for building a reference fabric from the NHD graph object""" +"""Contains all code for building a reference fabric from the reference graph object""" import logging from typing import Any, cast @@ -10,6 +10,7 @@ from reference_builds.configs import ReferenceConfig from reference_builds.task_instance import TaskInstance +from reference_builds.utils.geometries import _orient_flowpath_downstream logger = logging.getLogger(__name__) @@ -63,7 +64,7 @@ def _trace_attributes( # Find all outlets (nodes with no downstream connections) outlets = [idx for idx in graph.node_indices() if graph.out_degree(idx) == 0] - logger.info(f"build_reference task: Found {len(outlets)} outlets (disconnected subgraphs)") + logger.info(f"build_nhd_reference task: Found {len(outlets)} outlets (disconnected subgraphs)") # Get topological order for entire graph try: @@ -180,6 +181,35 @@ def _trace_attributes( else: graph[node_idx]["streamorder"] = max_order + # PASS 3: Orient all flowpath geometries so they flow upstream -> downstream + for node_idx in graph.node_indices(): + in_edges = graph.in_edges(node_idx) + upstream_nodes = [src_idx for src_idx, _, _ in in_edges] + + # Check if this is an outlet (flowpath_toid == "0" means no downstream) + is_outlet = graph[node_idx]["flowpath_toid"] == "0" + + if is_outlet and upstream_nodes: + # Outlet: use upstream geometry + upstream_idx = upstream_nodes[0] + us_geom = graph[upstream_idx]["geometry"] + graph[node_idx]["geometry"] = _orient_flowpath_downstream( + graph[node_idx]["geometry"], ds_geom=None, us_geom=us_geom + ) + else: + # Normal case: use downstream geometry + out_edges = graph.out_edges(node_idx) + downstream_nodes = [tgt_idx for _, tgt_idx, _ in out_edges] + + ds_geom = None + if downstream_nodes: + downstream_idx = downstream_nodes[0] + ds_geom = graph[downstream_idx]["geometry"] + + graph[node_idx]["geometry"] = _orient_flowpath_downstream( + graph[node_idx]["geometry"], ds_geom=ds_geom, us_geom=None + ) + # Extract results flowpath_ids = [] flowpath_toids = [] @@ -231,6 +261,250 @@ def _trace_attributes( ) +def _trace_geoglows_attributes( + graph: rx.PyDiGraph, + node_indices: dict[str, int], + flowpaths: gpd.GeoDataFrame, + catchments: gpd.GeoDataFrame, + vpu_id: str, +) -> gpd.GeoDataFrame: + """Trace flowpath attributes for the entire GeoGLOWS graph. + + Parameters + ---------- + graph : rx.PyDiGraph + The rustworkx directed graph (may contain multiple disconnected subgraphs) + node_indices : dict[str, int] + Mapping from LINKNO (as string) to node index + flowpaths : gpd.GeoDataFrame + The GeoGLOWS flowpaths GeoDataFrame with LengthKM, strmOrder + catchments : gpd.GeoDataFrame + The GeoGLOWS catchments GeoDataFrame with linkno, areasqkm, and geometry + vpu_id : str + The VPUID for the domain + + Returns + ------- + gpd.GeoDataFrame + Traced attributes: totdasqkm, mainstemlp, pathlength, dnhydroseq, hydroseq, stream_order + """ + length_lookup = flowpaths.set_index("LINKNO")["LengthKM"].to_dict() + order_lookup = flowpaths.set_index("LINKNO")["strmOrder"].to_dict() + fp_geom_lookup = flowpaths.set_index("LINKNO")["geometry"].to_dict() + + # Build catchment lookups (area and geometry keyed by linkno) + catchment_area_lookup = catchments.set_index("linkno")["areasqkm"].to_dict() + catchment_geom_lookup = catchments.set_index("linkno")["geometry"].to_dict() + + for node_idx in graph.node_indices(): + flowpath_id = str(graph[node_idx]) + link_id = int(flowpath_id) + + # Get length in km (already calculated from geometry) + length_km = length_lookup.get(link_id, 0.0) + + # Get local catchment area (from catchments, keyed by linkno) + local_areasqkm = catchment_area_lookup.get(link_id, 0.0) + + # Get geometries + catchment_geometry = catchment_geom_lookup.get(link_id) + flowpath_geometry = fp_geom_lookup.get(link_id) + + graph[node_idx] = { + "flowpath_id": flowpath_id, + "areasqkm": local_areasqkm, + "lengthkm": length_km, + "totdasqkm": 0.0, # Will be accumulated in PASS 2 + "mainstemlp": None, + "pathlength": 0.0, + "dnhydroseq": None, + "hydroseq": None, + "streamorder": order_lookup.get(link_id, 1), + "flowpath_geometry": flowpath_geometry, + "catchment_geometry": catchment_geometry, + } + + # Find all outlets (nodes with no downstream connections) + outlets = [idx for idx in graph.node_indices() if graph.out_degree(idx) == 0] + logger.info(f"build_geoglows_reference task: Found {len(outlets)} outlets (disconnected subgraphs)") + + # Get topological order for entire graph + try: + topo_order = rx.topological_sort(graph) + except rx.DAGHasCycle as e: + raise AssertionError("Graph contains cycles") from e + + # PASS 1: Calculate pathlength and hydroseq (reverse topo order - upstream from outlets) + current_hydroseq = 1 + + # Initialize outlets + for outlet_idx in outlets: + graph[outlet_idx]["pathlength"] = 0.0 + graph[outlet_idx]["dnhydroseq"] = 0 + + # Traverse in reverse topo order + for node_idx in reversed(topo_order): + # Assign hydroseq + graph[node_idx]["hydroseq"] = current_hydroseq + current_hydroseq += 1 + + # Calculate pathlength based on downstream node + out_edges = graph.out_edges(node_idx) + if out_edges: + downstream_nodes = [tgt_idx for _, tgt_idx, _ in out_edges] + if downstream_nodes: + downstream_idx = max(downstream_nodes, key=lambda idx: graph[idx]["pathlength"]) + graph[node_idx]["pathlength"] = ( + graph[downstream_idx]["pathlength"] + graph[downstream_idx]["lengthkm"] + ) + + # PASS 2: Calculate totdasqkm and stream_order (forward topo order - downstream from headwaters) + for node_idx in topo_order: + in_edges = list(graph.in_edges(node_idx)) + + # Accumulate upstream drainage area + upstream_total = sum(graph[src_idx]["totdasqkm"] for src_idx, _, _ in in_edges) + graph[node_idx]["totdasqkm"] = upstream_total + graph[node_idx]["areasqkm"] + + # Trace mainstems for each outlet's basin + current_mainstem_id = 1 + processed: set[int] = set() + + for outlet_idx in outlets: + # Trace main mainstem (longest path from outlet to headwater) + current_idx = outlet_idx + + while current_idx not in processed: + graph[current_idx]["mainstemlp"] = current_mainstem_id + processed.add(current_idx) + + in_edges = list(graph.in_edges(current_idx)) + if not in_edges: + break + + upstream_candidates = [src_idx for src_idx, _, _ in in_edges if src_idx not in processed] + if not upstream_candidates: + break + + current_idx = max( + upstream_candidates, + key=lambda idx: (graph[idx]["pathlength"], graph[idx]["totdasqkm"]), + ) + + current_mainstem_id += 1 + + # Assign tributary mainstems for remaining nodes + for node_idx in graph.node_indices(): + if node_idx not in processed: + tributary_id = current_mainstem_id + current_mainstem_id += 1 + + trib_current = node_idx + while trib_current not in processed: + graph[trib_current]["mainstemlp"] = tributary_id + processed.add(trib_current) + + in_edges = list(graph.in_edges(trib_current)) + upstream_in_basin = [src_idx for src_idx, _, _ in in_edges if src_idx not in processed] + + if not upstream_in_basin: + break + + trib_current = max( + upstream_in_basin, + key=lambda idx: (graph[idx]["pathlength"], graph[idx]["totdasqkm"]), + ) + + # Assign dnhydroseq and flowpath_toid based on graph edges + for node_idx in graph.node_indices(): + out_edges = graph.out_edges(node_idx) + downstream_nodes = [tgt_idx for _, tgt_idx, _ in out_edges] + + if downstream_nodes: + downstream_idx = downstream_nodes[0] + graph[node_idx]["dnhydroseq"] = graph[downstream_idx]["hydroseq"] + graph[node_idx]["flowpath_toid"] = graph[downstream_idx]["flowpath_id"] + else: + graph[node_idx]["dnhydroseq"] = 0 + graph[node_idx]["flowpath_toid"] = "0" + + # PASS 3: Orient all flowpath geometries so they flow upstream -> downstream + for node_idx in graph.node_indices(): + in_edges = graph.in_edges(node_idx) + upstream_nodes = [src_idx for src_idx, _, _ in in_edges] + + # Check if this is an outlet (flowpath_toid == "0" means no downstream) + is_outlet = graph[node_idx]["flowpath_toid"] == "0" + + if is_outlet and upstream_nodes: + # Outlet: use upstream geometry + upstream_idx = upstream_nodes[0] + us_geom = graph[upstream_idx]["flowpath_geometry"] + graph[node_idx]["flowpath_geometry"] = _orient_flowpath_downstream( + graph[node_idx]["flowpath_geometry"], ds_geom=None, us_geom=us_geom + ) + else: + # Normal case: use downstream geometry + out_edges = graph.out_edges(node_idx) + downstream_nodes = [tgt_idx for _, tgt_idx, _ in out_edges] + + ds_geom = None + if downstream_nodes: + downstream_idx = downstream_nodes[0] + ds_geom = graph[downstream_idx]["flowpath_geometry"] + + graph[node_idx]["flowpath_geometry"] = _orient_flowpath_downstream( + graph[node_idx]["flowpath_geometry"], ds_geom=ds_geom, us_geom=None + ) + + # Extract results for flowpaths + flowpath_ids = [] + flowpath_toids = [] + vpu_ids = [] + das = [] + lengthkms = [] + total_das = [] + mainstems = [] + pathlengths = [] + dnhydroseqs = [] + hydroseqs = [] + streamorders = [] + flowpath_geometries = [] + + for node_idx in graph.node_indices(): + node_data = graph[node_idx] + flowpath_ids.append(node_data["flowpath_id"]) + flowpath_toids.append(node_data["flowpath_toid"]) + vpu_ids.append(vpu_id) + das.append(node_data["areasqkm"]) + lengthkms.append(node_data["lengthkm"]) + total_das.append(node_data["totdasqkm"]) + mainstems.append(node_data["mainstemlp"]) + pathlengths.append(node_data["pathlength"]) + dnhydroseqs.append(node_data["dnhydroseq"]) + hydroseqs.append(node_data["hydroseq"]) + streamorders.append(node_data["streamorder"]) + flowpath_geometries.append(node_data["flowpath_geometry"]) + + return gpd.GeoDataFrame( + { + "flowpath_id": flowpath_ids, + "flowpath_toid": flowpath_toids, + "VPUID": vpu_ids, + "lengthkm": lengthkms, + "areasqkm": das, + "totdasqkm": total_das, + "mainstemlp": mainstems, + "pathlength": pathlengths, + "dnhydroseq": dnhydroseqs, + "hydroseq": hydroseqs, + "streamorder": streamorders, + }, + geometry=flowpath_geometries, + crs="EPSG:3857", + ) + + def _create_reference_divides( divides_df: gpd.GeoDataFrame, reference_flowpaths: gpd.GeoDataFrame, vpu_id: str ) -> gpd.GeoDataFrame: @@ -262,7 +536,40 @@ def _create_reference_divides( return reference_divides -def build_reference(**context: dict[str, Any]) -> dict[str, Any]: +def _create_geoglows_reference_divides( + catchments_df: gpd.GeoDataFrame, reference_flowpaths: gpd.GeoDataFrame, vpu_id: str +) -> gpd.GeoDataFrame: + """A function to create the reference divides table from GeoGLOWS catchments + + Parameters + ---------- + catchments_df : gpd.GeoDataFrame + The GeoGLOWS catchments table with linkno, areasqkm, and geometry + reference_flowpaths : gpd.GeoDataFrame + The reference flowpaths + vpu_id : str + The VPUID we're working in + + Returns + ------- + gpd.GeoDataFrame + The outputted reference_divides with catchment geometries + """ + reference_divides = catchments_df.copy() + reference_divides = reference_divides.rename(columns={"linkno": "divide_id"}) + reference_divides["divide_id"] = reference_divides["divide_id"].astype(int).astype(str) + reference_divides["vpuid"] = vpu_id + + # Filter to only include catchments that have a corresponding flowpath + mask = reference_divides["divide_id"].isin(reference_flowpaths["flowpath_id"]) + reference_divides["has_flowpath"] = mask + reference_divides["flowpath_id"] = pd.NA + reference_divides.loc[mask, "flowpath_id"] = reference_divides.loc[mask, "divide_id"] + + return reference_divides + + +def build_nhd_reference(**context: dict[str, Any]) -> dict[str, Any]: """Opens local / downloads for the reference-build process Parameters @@ -283,8 +590,8 @@ def build_reference(**context: dict[str, Any]) -> dict[str, Any]: """ ti = cast(TaskInstance, context["ti"]) cfg = cast(ReferenceConfig, context["config"]) - graph: rx.PyDiGraph = ti.xcom_pull(task_id="build_graphs", key="graph") - node_indices: dict[str, int] = ti.xcom_pull(task_id="build_graphs", key="node_indices") + graph: rx.PyDiGraph = ti.xcom_pull(task_id="build_nhd_graphs", key="graph") + node_indices: dict[str, int] = ti.xcom_pull(task_id="build_nhd_graphs", key="node_indices") _flowpaths: pl.DataFrame = ti.xcom_pull(task_id="download", key="nhd_flowpaths") _divides: pl.DataFrame = ti.xcom_pull(task_id="download", key="nhd_divides") cycles_iter = rx.simple_cycles(graph) @@ -325,3 +632,80 @@ def build_reference(**context: dict[str, Any]) -> dict[str, Any]: reference_divides = _create_reference_divides(_divides_df, reference_flowpaths, cfg.vpu_id) return {"reference_flowpaths": reference_flowpaths, "reference_divides": reference_divides} + + +def build_geoglows_reference(**context: dict[str, Any]) -> dict[str, Any]: + """Builds reference fabric from GeoGLOWS data + + Parameters + ---------- + **context : dict + Airflow-compatible context containing: + - ti : TaskInstance for XCom operations + - config : HFConfig with pipeline configuration + - task_id : str identifier for this task + - run_id : str identifier for this pipeline run + - ds : str execution date + - execution_date : datetime object + + Returns + ------- + dict[str, Any] + The reference flowpaths and divides in memory + """ + ti = cast(TaskInstance, context["ti"]) + cfg = cast(ReferenceConfig, context["config"]) + graph: rx.PyDiGraph = ti.xcom_pull(task_id="build_geoglows_graphs", key="graph") + node_indices: dict[str, int] = ti.xcom_pull(task_id="build_geoglows_graphs", key="node_indices") + _flowpaths: pl.DataFrame = ti.xcom_pull(task_id="download", key="geoglows_flowpaths") + _catchments: pl.DataFrame = ti.xcom_pull(task_id="download", key="geoglows_divides") + + # Check for cycles + cycles_iter = rx.simple_cycles(graph) + cycles: list[list[str]] = [] + cycle_ids: set[str] = set() + for cycle in cycles_iter: + _ids: list[Any] = [graph.get_node_data(node_idx) for node_idx in cycle] + cycles.append(_ids) + cycle_ids.update(_ids) + if cycle_ids: + raise NotImplementedError("Cycle Detected. Please create method for removing") + + _flowpaths_df = gpd.GeoDataFrame( + _flowpaths.select( + [ + pl.col("LINKNO"), + pl.col("DSLINKNO"), + pl.col("strmOrder"), + ] + ).to_pandas(), + geometry=gpd.GeoSeries.from_wkb(_flowpaths["geometry"]), + crs="EPSG:3857", + ) + + _flowpaths_df_projected = _flowpaths_df.to_crs(cfg.crs) + _flowpaths_df["LengthKM"] = _flowpaths_df_projected.geometry.length / 1000 + + _catchments_df = gpd.GeoDataFrame( + _catchments.select([pl.col("linkno")]).to_pandas(), + geometry=gpd.GeoSeries.from_wkb(_catchments["geometry"]), + crs="EPSG:4326", + ) + _catchments_df_projected = _catchments_df.to_crs(cfg.crs) + _catchments_df["areasqkm"] = _catchments_df_projected.geometry.area / 1e6 + + # Log any flowpaths without matching catchments + flowpath_linkno_set = set(_flowpaths_df["LINKNO"].tolist()) + catchment_linkno_set = set(_catchments_df["linkno"].tolist()) + missing_catchments = flowpath_linkno_set - catchment_linkno_set + if missing_catchments: + logger.warning( + f"build_geoglows_reference task: {len(missing_catchments)} flowpaths have no matching catchment" + ) + + reference_flowpaths = _trace_geoglows_attributes( + graph, node_indices, _flowpaths_df, _catchments_df, cfg.vpu_id + ) + reference_divides = _create_geoglows_reference_divides(_catchments_df, reference_flowpaths, cfg.vpu_id) + + return {"reference_flowpaths": reference_flowpaths, "reference_divides": reference_divides} diff --git a/src/reference_builds/pipeline/download.py b/src/reference_builds/pipeline/download.py index c848d97..6c1025d 100644 --- a/src/reference_builds/pipeline/download.py +++ b/src/reference_builds/pipeline/download.py @@ -14,15 +14,66 @@ logger = logging.getLogger(__name__) -def _load_and_concat_layers(gpkg_files: list[Path], layer_name: str) -> gpd.GeoDataFrame: +def _load_and_concat_layers(gpkg_files: list[Path], layer_name: str | None) -> gpd.GeoDataFrame: """Load a specific layer from all gpkg files and concatenate.""" gdfs = [] for gpkg_path in gpkg_files: - gdf = gpd.read_file(gpkg_path, layer=layer_name) + if layer_name is None: + gdf = gpd.read_file(gpkg_path, driver="GPKG") + else: + gdf = gpd.read_file(gpkg_path, layer=layer_name) gdfs.append(gdf) return pd.concat(gdfs, ignore_index=True) +def _load_and_concat_parquet(parquet_files: list[Path]) -> gpd.GeoDataFrame: + """Load a specific layer from all parquet files and concatenate.""" + gdfs = [] + for parquet_path in parquet_files: + gdf = gpd.read_parquet(parquet_path) + gdfs.append(gdf) + return pd.concat(gdfs, ignore_index=True) + + +def download_geoglows_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: + """Opens local / downloads for the reference-build process + + Parameters + ---------- + **context : dict + Airflow-compatible context containing: + - ti : TaskInstance for XCom operations + - config : HFConfig with pipeline configuration + - task_id : str identifier for this task + - run_id : str identifier for this pipeline run + - ds : str execution date + - execution_date : datetime object + + Returns + ------- + dict[str, pl.DataFrame] + The reference flowpath and divides references in memory + """ + cfg = cast(ReferenceConfig, context["config"]) + + # find the gpkg files from the ScienceBase NHD folders + gpkg_files = list(cfg.output_dir.glob(cfg.input_file_regex)) + + assert cfg.geoglows_catchment_regex is not None, "Need to specify where the catchment parquet files are" + parquet_files = list(cfg.output_dir.glob(cfg.geoglows_catchment_regex)) + # load layers + __flowpaths = _load_and_concat_layers(gpkg_files, layer_name=None) + __catchments = _load_and_concat_parquet(parquet_files) + # filter/validate layers + flowpaths = _validate_and_fix_geometries(__flowpaths, geom_type="flowpaths") + catchments = _validate_and_fix_geometries(__catchments, geom_type="divides") + + return { + "geoglows_flowpaths": pl.from_pandas(flowpaths.to_wkb()), + "geoglows_divides": pl.from_pandas(catchments.to_wkb()), + } + + def download_nhd_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: """Opens local / downloads for the reference-build process diff --git a/src/reference_builds/pipeline/processing.py b/src/reference_builds/pipeline/processing.py index 39ef942..1851a74 100644 --- a/src/reference_builds/pipeline/processing.py +++ b/src/reference_builds/pipeline/processing.py @@ -1,4 +1,4 @@ -"""Contains all code for processing nhd data""" +"""Contains all pipeline code for processing reference data""" import logging from typing import Any, cast @@ -7,75 +7,12 @@ import rustworkx as rx from reference_builds.task_instance import TaskInstance +from reference_builds.utils.geoglows_graph import _build_geoglows_graph +from reference_builds.utils.nhd_graph import _build_graph logger = logging.getLogger(__name__) -def _build_graph(connectivity: pl.DataFrame, flowpaths: pl.DataFrame) -> dict[str, list[str]]: - """Build a graph of upstream flowpath connections. - - Parameters - ---------- - connectivity : pl.DataFrame - The connectivity/flow table with FromNode and ToNode columns - flowpaths : pl.DataFrame - The reference flowpaths to filter to - - Returns - ------- - dict[str, list[str]] - The upstream dictionary containing upstream and downstream connections - Key is the downstream flowpath ID, values are the upstream flowpath IDs - """ - valid_ids = flowpaths.select(pl.col("NHDPlusID").cast(pl.Int64))["NHDPlusID"] - - filtered_connectivity = connectivity.select( - [ - pl.col("NHDPlusID").cast(pl.Int64), - pl.col("FromNode").cast(pl.Int64), - pl.col("ToNode").cast(pl.Int64), - ] - ).filter(pl.col("NHDPlusID").is_in(valid_ids)) - - tonode_lookup = filtered_connectivity.select( - [ - pl.col("ToNode"), - pl.col("NHDPlusID").cast(pl.Utf8).alias("upstream_id"), - ] - ) - - fromnode_lookup = filtered_connectivity.select( - [ - pl.col("FromNode"), - pl.col("NHDPlusID").cast(pl.Utf8).alias("downstream_id"), - ] - ) - - merged = tonode_lookup.join(fromnode_lookup, left_on="ToNode", right_on="FromNode", how="inner").select( - ["upstream_id", "downstream_id"] - ) - - upstream_network_df = merged.group_by("downstream_id").agg(pl.col("upstream_id").alias("upstream_list")) - - upstream_dict: dict[str, list[str]] = dict( - zip( - upstream_network_df["downstream_id"].to_list(), - upstream_network_df["upstream_list"].to_list(), - strict=False, - ) - ) - - all_flowpath_ids = flowpaths.select(pl.col("NHDPlusID").cast(pl.Int64).cast(pl.Utf8))[ - "NHDPlusID" - ].to_list() - - for fp_id in all_flowpath_ids: - if fp_id not in upstream_dict: - upstream_dict[fp_id] = [] - - return upstream_dict - - def _build_rustworkx_object( upstream_network: dict[str, list[str]] | dict[int, list[int]], ) -> tuple[rx.PyDiGraph, dict[str, int] | dict[int, int]]: @@ -93,6 +30,8 @@ def _build_rustworkx_object( """ graph = rx.PyDiGraph(check_cycle=True) node_indices: dict[Any, int] = {} + if None in upstream_network: + upstream_network.pop(None) # type: ignore for to_edge in sorted(upstream_network.keys()): from_edges = upstream_network[to_edge] # type: ignore if to_edge not in node_indices: @@ -106,7 +45,7 @@ def _build_rustworkx_object( return graph, node_indices -def build_graphs(**context: dict[str, Any]) -> dict[str, Any]: +def build_nhd_graphs(**context: dict[str, Any]) -> dict[str, Any]: """Builds and processes graphs from NHD data Parameters @@ -131,3 +70,29 @@ def build_graphs(**context: dict[str, Any]) -> dict[str, Any]: upstream_network = _build_graph(connectivity, flowpaths) graph, node_indices = _build_rustworkx_object(upstream_network) return {"graph": graph, "node_indices": node_indices} + + +def build_geoglows_graphs(**context: dict[str, Any]) -> dict[str, Any]: + """Builds and processes graphs from NHD data + + Parameters + ---------- + **context : dict + Airflow-compatible context containing: + - ti : TaskInstance for XCom operations + - config : HFConfig with pipeline configuration + - task_id : str identifier for this task + - run_id : str identifier for this pipeline run + - ds : str execution date + - execution_date : datetime object + + Returns + ------- + dict[str, Any] + The rustworkx graph object and node_indices for the NHD + """ + ti = cast(TaskInstance, context["ti"]) + flowpaths: pl.DataFrame = ti.xcom_pull(task_id="download", key="geoglows_flowpaths") + upstream_network = _build_geoglows_graph(flowpaths) + graph, node_indices = _build_rustworkx_object(upstream_network) + return {"graph": graph, "node_indices": node_indices} diff --git a/src/reference_builds/utils/geoglows_graph.py b/src/reference_builds/utils/geoglows_graph.py new file mode 100644 index 0000000..66f7089 --- /dev/null +++ b/src/reference_builds/utils/geoglows_graph.py @@ -0,0 +1,52 @@ +"""Contains all code for processing nhd data""" + +import logging + +import polars as pl + +logger = logging.getLogger(__name__) + + +def _build_geoglows_graph(flowpaths: pl.DataFrame) -> dict[str, list[str]]: + """Build a graph of upstream flowpath connections from GeoGLOWS data. + + Parameters + ---------- + flowpaths : pl.DataFrame + The GeoGLOWS flowpaths with LINKNO and DSLINKNO columns + + Returns + ------- + dict[str, list[str]] + The upstream dictionary containing upstream and downstream connections + Key is the downstream flowpath ID, values are the upstream flowpath IDs + """ + # Filter out terminal links (DSLINKNO == -1) for building upstream connections + connectivity = flowpaths.select( + [ + pl.col("LINKNO").cast(pl.Int64), + pl.col("DSLINKNO").cast(pl.Int64), + ] + ).filter(pl.col("DSLINKNO") != -1) + + # Build upstream network: group by downstream link to get all upstream links + upstream_network_df = connectivity.group_by(pl.col("DSLINKNO").cast(pl.Utf8).alias("downstream_id")).agg( + pl.col("LINKNO").cast(pl.Utf8).alias("upstream_list") + ) + + upstream_dict: dict[str, list[str]] = dict( + zip( + upstream_network_df["downstream_id"].to_list(), + upstream_network_df["upstream_list"].to_list(), + strict=False, + ) + ) + + # Ensure all flowpath IDs are in the dictionary (even those with no upstream) + all_flowpath_ids = flowpaths.select(pl.col("LINKNO").cast(pl.Utf8))["LINKNO"].to_list() + + for fp_id in all_flowpath_ids: + if fp_id not in upstream_dict: + upstream_dict[fp_id] = [] + + return upstream_dict diff --git a/src/reference_builds/utils/geometries.py b/src/reference_builds/utils/geometries.py index 3cad09f..a58383e 100644 --- a/src/reference_builds/utils/geometries.py +++ b/src/reference_builds/utils/geometries.py @@ -1,6 +1,88 @@ -"""A file for all graph related internal functions""" +"""A file for all geometry related internal functions""" import geopandas as gpd +from shapely import wkb +from shapely.geometry import LineString, MultiLineString, Point + + +def _ensure_geometry(geom): # type: ignore[no-untyped-def] + """Convert bytes to Shapely geometry if needed.""" + if isinstance(geom, bytes): + return wkb.loads(geom) + return geom + + +def _get_endpoints(geom): # type: ignore[no-untyped-def] + """Get start and end points of a line or multiline geometry.""" + geom = _ensure_geometry(geom) + if geom.geom_type == "MultiLineString": + start_coord = list(geom.geoms)[0].coords[0] + end_coord = list(geom.geoms)[-1].coords[-1] + else: + start_coord = geom.coords[0] + end_coord = geom.coords[-1] + return start_coord, end_coord + + +def _reverse_line(geom): # type: ignore[no-untyped-def] + """Reverse a LineString or MultiLineString.""" + geom = _ensure_geometry(geom) + if geom.geom_type == "MultiLineString": + # Reverse each component and reverse the order of components + reversed_parts = [LineString(part.coords[::-1]) for part in reversed(geom.geoms)] + return MultiLineString(reversed_parts) + else: + return LineString(geom.coords[::-1]) + + +def _orient_flowpath_downstream(geom, ds_geom=None, us_geom=None): # type: ignore[no-untyped-def] + """ + Orient a flowpath so coords go from upstream to downstream. + + Parameters + ---------- + geom : geometry + The flowpath geometry + ds_geom : geometry, optional + The downstream flowpath geometry for direction detection + us_geom : geometry, optional + The upstream flowpath geometry (used for outlets when ds_geom is None) + + Returns + ------- + geometry + The oriented geometry (start = upstream, end = downstream) + """ + geom = _ensure_geometry(geom) + start_coord, end_coord = _get_endpoints(geom) + start_pt = Point(start_coord) + end_pt = Point(end_coord) + + # Primary: use downstream geometry + if ds_geom is not None: + ds_geom = _ensure_geometry(ds_geom) + dist_start = start_pt.distance(ds_geom) + dist_end = end_pt.distance(ds_geom) + + # If start is closer to downstream, the line is reversed + if dist_start < dist_end: + return _reverse_line(geom) + return geom + + # Fallback for outlets: use upstream geometry + if us_geom is not None: + us_geom = _ensure_geometry(us_geom) + dist_start = start_pt.distance(us_geom) + dist_end = end_pt.distance(us_geom) + + # Upstream end should be CLOSER to upstream geometry + # So if end is closer to upstream, line is reversed + if dist_end < dist_start: + return _reverse_line(geom) + return geom + + # No reference at all - return as-is + return geom def _validate_and_fix_geometries(gdf: gpd.GeoDataFrame, geom_type: str) -> gpd.GeoDataFrame: diff --git a/src/reference_builds/utils/nhd_graph.py b/src/reference_builds/utils/nhd_graph.py new file mode 100644 index 0000000..67f18b4 --- /dev/null +++ b/src/reference_builds/utils/nhd_graph.py @@ -0,0 +1,72 @@ +"""Contains all code for processing nhd data""" + +import logging + +import polars as pl + +logger = logging.getLogger(__name__) + + +def _build_graph(connectivity: pl.DataFrame, flowpaths: pl.DataFrame) -> dict[str, list[str]]: + """Build a graph of upstream flowpath connections. + + Parameters + ---------- + connectivity : pl.DataFrame + The connectivity/flow table with FromNode and ToNode columns + flowpaths : pl.DataFrame + The reference flowpaths to filter to + + Returns + ------- + dict[str, list[str]] + The upstream dictionary containing upstream and downstream connections + Key is the downstream flowpath ID, values are the upstream flowpath IDs + """ + valid_ids = flowpaths.select(pl.col("NHDPlusID").cast(pl.Int64))["NHDPlusID"] + + filtered_connectivity = connectivity.select( + [ + pl.col("NHDPlusID").cast(pl.Int64), + pl.col("FromNode").cast(pl.Int64), + pl.col("ToNode").cast(pl.Int64), + ] + ).filter(pl.col("NHDPlusID").is_in(valid_ids)) + + tonode_lookup = filtered_connectivity.select( + [ + pl.col("ToNode"), + pl.col("NHDPlusID").cast(pl.Utf8).alias("upstream_id"), + ] + ) + + fromnode_lookup = filtered_connectivity.select( + [ + pl.col("FromNode"), + pl.col("NHDPlusID").cast(pl.Utf8).alias("downstream_id"), + ] + ) + + merged = tonode_lookup.join(fromnode_lookup, left_on="ToNode", right_on="FromNode", how="inner").select( + ["upstream_id", "downstream_id"] + ) + + upstream_network_df = merged.group_by("downstream_id").agg(pl.col("upstream_id").alias("upstream_list")) + + upstream_dict: dict[str, list[str]] = dict( + zip( + upstream_network_df["downstream_id"].to_list(), + upstream_network_df["upstream_list"].to_list(), + strict=False, + ) + ) + + all_flowpath_ids = flowpaths.select(pl.col("NHDPlusID").cast(pl.Int64).cast(pl.Utf8))[ + "NHDPlusID" + ].to_list() + + for fp_id in all_flowpath_ids: + if fp_id not in upstream_dict: + upstream_dict[fp_id] = [] + + return upstream_dict diff --git a/tests/conftest.py b/tests/conftest.py index 0311222..7d8ea0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -123,3 +123,182 @@ def disconnected_graph() -> tuple[rx.PyDiGraph, dict[str, int]]: graph.add_edge(node_indices["85000100000004"], node_indices["85000100000005"], None) return graph, node_indices + + +@pytest.fixture +def sample_geoglows_graph() -> tuple[rx.PyDiGraph, dict[str, int]]: + """Create a sample GeoGLOWS graph for testing. + + Network topology: + 1 ──┐ + ├──> 3 ──> 4 ──> 5 (outlet) + 2 ──┘ + + Where: + - 1, 2 are headwaters (order 1) + - 3 is confluence (order 2) + - 4, 5 continue downstream (order 2) + """ + graph = rx.PyDiGraph() + + node_data = [ + "810000001", + "810000002", + "810000003", + "810000004", + "810000005", + ] + + node_indices = {} + for fp_id in node_data: + idx = graph.add_node(fp_id) + node_indices[fp_id] = idx + + # Add edges (upstream -> downstream) + graph.add_edge(node_indices["810000001"], node_indices["810000003"], None) + graph.add_edge(node_indices["810000002"], node_indices["810000003"], None) + graph.add_edge(node_indices["810000003"], node_indices["810000004"], None) + graph.add_edge(node_indices["810000004"], node_indices["810000005"], None) + + return graph, node_indices + + +@pytest.fixture +def sample_geoglows_flowpaths() -> gpd.GeoDataFrame: + """Create sample GeoGLOWS flowpaths GeoDataFrame.""" + return gpd.GeoDataFrame( + { + "LINKNO": [810000001, 810000002, 810000003, 810000004, 810000005], + "DSLINKNO": [810000003, 810000003, 810000004, 810000005, -1], + "strmOrder": [1, 1, 2, 2, 2], + "LengthKM": [0.5, 0.6, 0.8, 0.7, 0.4], + }, + geometry=[ + LineString([(0, 0), (1, 1)]), + LineString([(2, 0), (1, 1)]), + LineString([(1, 1), (1, 2)]), + LineString([(1, 2), (1, 3)]), + LineString([(1, 3), (1, 4)]), + ], + crs="EPSG:3857", + ) + + +@pytest.fixture +def sample_geoglows_catchments() -> gpd.GeoDataFrame: + """Create sample GeoGLOWS catchments GeoDataFrame.""" + return gpd.GeoDataFrame( + { + "linkno": [810000001, 810000002, 810000003, 810000004, 810000005], + "areasqkm": [1.0, 1.2, 0.8, 0.9, 0.6], + }, + geometry=[ + MultiPolygon([Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])]), + MultiPolygon([Polygon([(1, 0), (2, 0), (2, 1), (1, 1)])]), + MultiPolygon([Polygon([(0, 1), (2, 1), (2, 2), (0, 2)])]), + MultiPolygon([Polygon([(0, 2), (2, 2), (2, 3), (0, 3)])]), + MultiPolygon([Polygon([(0, 3), (2, 3), (2, 4), (0, 4)])]), + ], + crs="EPSG:3857", + ) + + +@pytest.fixture +def disconnected_geoglows_graph() -> tuple[rx.PyDiGraph, dict[str, int]]: + """Create a disconnected GeoGLOWS graph with two separate networks. + + Network 1: 1 -> 3 (outlet) + Network 2: 2 -> 4 -> 5 (outlet) + """ + graph = rx.PyDiGraph() + + node_data = [ + "810000001", + "810000002", + "810000003", + "810000004", + "810000005", + ] + + node_indices = {} + for fp_id in node_data: + idx = graph.add_node(fp_id) + node_indices[fp_id] = idx + + # Network 1 + graph.add_edge(node_indices["810000001"], node_indices["810000003"], None) + # Network 2 + graph.add_edge(node_indices["810000002"], node_indices["810000004"], None) + graph.add_edge(node_indices["810000004"], node_indices["810000005"], None) + + return graph, node_indices + + +@pytest.fixture +def reversed_flowpaths() -> gpd.GeoDataFrame: + """Create sample flowpaths with reversed digitization (downstream to upstream).""" + data = { + "NHDPlusID": [ + 85000100000001, + 85000100000002, + 85000100000003, + 85000100000004, + 85000100000005, + ], + "VPUID": ["2101", "2101", "2101", "2101", "2101"], + "LengthKM": [0.333, 0.5, 1.201, 0.182, 0.387], + "fcode_description": [ + "Stream/River", + "Artificial Path", + "Stream/River", + "Artificial Path", + "Stream/River", + ], + } + + # Create geometries that connect but are digitized downstream-to-upstream + # Network: 1,2 -> 3 -> 4 -> 5 (outlet) + geometries = [ + MultiLineString([LineString([(1, 3), (0, 4)])]), # 1: connects to 3 at (1,3) + MultiLineString([LineString([(1, 3), (2, 4)])]), # 2: connects to 3 at (1,3) + MultiLineString([LineString([(1, 2), (1, 3)])]), # 3: connects to 4 at (1,2) + MultiLineString([LineString([(1, 1), (1, 2)])]), # 4: connects to 5 at (1,1) + MultiLineString([LineString([(1, 0), (1, 1)])]), # 5: outlet at (1,0) + ] + + return gpd.GeoDataFrame(data, geometry=geometries, crs="EPSG:4269") + + +@pytest.fixture +def correctly_oriented_flowpaths() -> gpd.GeoDataFrame: + """Create sample flowpaths with correct digitization (upstream to downstream).""" + data = { + "NHDPlusID": [ + 85000100000001, + 85000100000002, + 85000100000003, + 85000100000004, + 85000100000005, + ], + "VPUID": ["2101", "2101", "2101", "2101", "2101"], + "LengthKM": [0.333, 0.5, 1.201, 0.182, 0.387], + "fcode_description": [ + "Stream/River", + "Artificial Path", + "Stream/River", + "Artificial Path", + "Stream/River", + ], + } + + # Create geometries digitized upstream-to-downstream + # Network: 1,2 -> 3 -> 4 -> 5 (outlet) + geometries = [ + MultiLineString([LineString([(0, 4), (1, 3)])]), # 1: ends at confluence (1,3) + MultiLineString([LineString([(2, 4), (1, 3)])]), # 2: ends at confluence (1,3) + MultiLineString([LineString([(1, 3), (1, 2)])]), # 3: from confluence to (1,2) + MultiLineString([LineString([(1, 2), (1, 1)])]), # 4: continues downstream + MultiLineString([LineString([(1, 1), (1, 0)])]), # 5: outlet ends at (1,0) + ] + + return gpd.GeoDataFrame(data, geometry=geometries, crs="EPSG:4269") diff --git a/tests/test_builds.py b/tests/test_builds.py index c36a7e1..4ed45ef 100644 --- a/tests/test_builds.py +++ b/tests/test_builds.py @@ -1,17 +1,458 @@ -"""Tests for build_reference module""" +"""Tests for build_nhd_reference module""" import geopandas as gpd import numpy as np import pytest import rustworkx as rx -from shapely.geometry import LineString, MultiLineString, MultiPolygon, Polygon +from shapely.geometry import LineString, MultiLineString, MultiPolygon, Point, Polygon from reference_builds.pipeline.build_reference import ( + _create_geoglows_reference_divides, _create_reference_divides, _trace_attributes, + _trace_geoglows_attributes, ) +class TestTraceGeoglowsAttributes: + """Tests for _trace_geoglows_attributes function.""" + + def test_output_columns( + self, + sample_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that output has expected columns.""" + graph, node_indices = sample_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + expected_columns = { + "flowpath_id", + "flowpath_toid", + "VPUID", + "lengthkm", + "areasqkm", + "totdasqkm", + "mainstemlp", + "pathlength", + "dnhydroseq", + "hydroseq", + "streamorder", + "geometry", + } + + assert set(result.columns) == expected_columns + + def test_all_flowpaths_traced( + self, + sample_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that all flowpaths in graph are traced.""" + graph, node_indices = sample_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + assert len(result) == graph.num_nodes() + + def test_hydroseq_unique( + self, + sample_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that hydroseq values are unique.""" + graph, node_indices = sample_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + assert result["hydroseq"].nunique() == len(result) + + def test_totdasqkm_accumulates_downstream( + self, + sample_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that total drainage area accumulates downstream.""" + graph, node_indices = sample_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + outlet_row = result[result["flowpath_id"] == "810000005"] + max_da = result["totdasqkm"].max() + + assert outlet_row["totdasqkm"].iloc[0] == max_da + + def test_totdasqkm_equals_sum_of_all_areas_at_outlet( + self, + sample_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that outlet totdasqkm equals sum of all upstream areas.""" + graph, node_indices = sample_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + outlet_row = result[result["flowpath_id"] == "810000005"] + total_area = sample_geoglows_catchments["areasqkm"].sum() + + assert np.isclose(outlet_row["totdasqkm"].iloc[0], total_area) + + def test_pathlength_increases_upstream( + self, + sample_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that pathlength increases going upstream.""" + graph, node_indices = sample_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + headwater_row = result[result["flowpath_id"] == "810000001"] + outlet_row = result[result["flowpath_id"] == "810000005"] + + assert headwater_row["pathlength"].iloc[0] > outlet_row["pathlength"].iloc[0] + + def test_pathlength_calculation_correct( + self, + sample_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that pathlength is calculated correctly as sum of downstream lengths.""" + graph, node_indices = sample_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + # Headwater 1's pathlength should be sum of downstream lengths (5->4->3) + # 0.4 + 0.7 + 0.8 = 1.9 + headwater_row = result[result["flowpath_id"] == "810000001"] + expected_pathlength = 0.4 + 0.7 + 0.8 + + assert np.isclose(headwater_row["pathlength"].iloc[0], expected_pathlength) + + def test_mainstemlp_assigned_to_all_nodes( + self, + sample_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that all nodes have a mainstem level path assigned.""" + graph, node_indices = sample_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + assert result["mainstemlp"].notna().all() + + def test_multiple_outlets_handled( + self, + disconnected_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that disconnected subgraphs with multiple outlets are handled.""" + graph, node_indices = disconnected_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + outlets = result[result["dnhydroseq"] == 0] + assert len(outlets) == 2 + + def test_vpuid_assigned_correctly( + self, + sample_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that VPUID is assigned correctly to all rows.""" + graph, node_indices = sample_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + assert (result["VPUID"] == "701").all() + + def test_lengthkm_preserved_from_input( + self, + sample_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that lengthkm values are preserved from input.""" + graph, node_indices = sample_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + for _, row in sample_geoglows_flowpaths.iterrows(): + linkno = str(row["LINKNO"]) + expected_length = row["LengthKM"] + actual_length = result[result["flowpath_id"] == linkno]["lengthkm"].iloc[0] + assert np.isclose(actual_length, expected_length) + + def test_areasqkm_from_catchments( + self, + sample_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that areasqkm values come from catchments.""" + graph, node_indices = sample_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + for _, row in sample_geoglows_catchments.iterrows(): + linkno = str(row["linkno"]) + expected_area = row["areasqkm"] + actual_area = result[result["flowpath_id"] == linkno]["areasqkm"].iloc[0] + assert np.isclose(actual_area, expected_area) + + +class TestCreateGeoglowsReferenceDivides: + """Tests for _create_geoglows_reference_divides function.""" + + @pytest.fixture + def sample_geoglows_reference_flowpaths( + self, sample_geoglows_flowpaths: gpd.GeoDataFrame + ) -> gpd.GeoDataFrame: + """Create sample reference flowpaths (subset of catchments).""" + return gpd.GeoDataFrame( + { + "flowpath_id": ["810000001", "810000003", "810000005"], + "VPUID": ["701", "701", "701"], + }, + geometry=sample_geoglows_flowpaths.geometry.iloc[:3].values, + crs="EPSG:3857", + ) + + def test_returns_geodataframe( + self, + sample_geoglows_catchments: gpd.GeoDataFrame, + sample_geoglows_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that _create_geoglows_reference_divides returns a GeoDataFrame.""" + result = _create_geoglows_reference_divides( + sample_geoglows_catchments, sample_geoglows_reference_flowpaths, "701" + ) + + assert isinstance(result, gpd.GeoDataFrame) + + def test_all_catchments_included( + self, + sample_geoglows_catchments: gpd.GeoDataFrame, + sample_geoglows_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that all catchments are included in output.""" + result = _create_geoglows_reference_divides( + sample_geoglows_catchments, sample_geoglows_reference_flowpaths, "701" + ) + + assert len(result) == len(sample_geoglows_catchments) + + def test_has_flowpath_flag_correct( + self, + sample_geoglows_catchments: gpd.GeoDataFrame, + sample_geoglows_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that has_flowpath flag is set correctly.""" + result = _create_geoglows_reference_divides( + sample_geoglows_catchments, sample_geoglows_reference_flowpaths, "701" + ) + + assert result["has_flowpath"].sum() == 3 + + def test_flowpath_id_assigned_when_has_flowpath( + self, + sample_geoglows_catchments: gpd.GeoDataFrame, + sample_geoglows_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that flowpath_id is assigned when has_flowpath is True.""" + result = _create_geoglows_reference_divides( + sample_geoglows_catchments, sample_geoglows_reference_flowpaths, "701" + ) + + with_flowpath = result[result["has_flowpath"]] + assert with_flowpath["flowpath_id"].notna().all() + assert (with_flowpath["flowpath_id"] == with_flowpath["divide_id"]).all() + + def test_flowpath_id_na_when_no_flowpath( + self, + sample_geoglows_catchments: gpd.GeoDataFrame, + sample_geoglows_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that flowpath_id is NA when has_flowpath is False.""" + result = _create_geoglows_reference_divides( + sample_geoglows_catchments, sample_geoglows_reference_flowpaths, "701" + ) + + without_flowpath = result[~result["has_flowpath"]] + assert without_flowpath["flowpath_id"].isna().all() + + def test_vpuid_assigned_correctly( + self, + sample_geoglows_catchments: gpd.GeoDataFrame, + sample_geoglows_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that vpuid is assigned correctly to all rows.""" + result = _create_geoglows_reference_divides( + sample_geoglows_catchments, sample_geoglows_reference_flowpaths, "701" + ) + + assert (result["vpuid"] == "701").all() + + def test_divide_id_is_string( + self, + sample_geoglows_catchments: gpd.GeoDataFrame, + sample_geoglows_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that divide_id is converted to string.""" + result = _create_geoglows_reference_divides( + sample_geoglows_catchments, sample_geoglows_reference_flowpaths, "701" + ) + + assert result["divide_id"].dtype == object + + def test_areasqkm_preserved( + self, + sample_geoglows_catchments: gpd.GeoDataFrame, + sample_geoglows_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that areasqkm values are preserved.""" + result = _create_geoglows_reference_divides( + sample_geoglows_catchments, sample_geoglows_reference_flowpaths, "701" + ) + + assert "areasqkm" in result.columns + original_areas = set(sample_geoglows_catchments["areasqkm"].tolist()) + result_areas = set(result["areasqkm"].tolist()) + assert original_areas == result_areas + + def test_geometry_preserved( + self, + sample_geoglows_catchments: gpd.GeoDataFrame, + sample_geoglows_reference_flowpaths: gpd.GeoDataFrame, + ) -> None: + """Test that catchment geometries are preserved.""" + result = _create_geoglows_reference_divides( + sample_geoglows_catchments, sample_geoglows_reference_flowpaths, "701" + ) + + assert result.geometry is not None + assert len(result.geometry) == len(sample_geoglows_catchments) + + +# ============================================================================= +# Tests for GeoGLOWS Edge Cases +# ============================================================================= + + +class TestGeoglowsEdgeCases: + """Tests for GeoGLOWS edge cases.""" + + @pytest.fixture + def single_node_geoglows_graph(self) -> tuple[rx.PyDiGraph, dict[str, int]]: + """Create a graph with a single node (headwater that is also outlet).""" + graph = rx.PyDiGraph() + idx = graph.add_node("810000001") + return graph, {"810000001": idx} + + @pytest.fixture + def single_geoglows_flowpath(self) -> gpd.GeoDataFrame: + """Create single GeoGLOWS flowpath GeoDataFrame.""" + return gpd.GeoDataFrame( + { + "LINKNO": [810000001], + "DSLINKNO": [-1], + "strmOrder": [1], + "LengthKM": [0.5], + }, + geometry=[LineString([(0, 0), (1, 1)])], + crs="EPSG:3857", + ) + + @pytest.fixture + def single_geoglows_catchment(self) -> gpd.GeoDataFrame: + """Create single GeoGLOWS catchment GeoDataFrame.""" + return gpd.GeoDataFrame( + { + "linkno": [810000001], + "areasqkm": [1.0], + }, + geometry=[MultiPolygon([Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])])], + crs="EPSG:3857", + ) + + def test_single_node_graph( + self, + single_node_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + single_geoglows_flowpath: gpd.GeoDataFrame, + single_geoglows_catchment: gpd.GeoDataFrame, + ) -> None: + """Test that single node graph is handled correctly.""" + graph, node_indices = single_node_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, single_geoglows_flowpath, single_geoglows_catchment, "701" + ) + + assert len(result) == 1 + assert result["streamorder"].iloc[0] == 1 + assert result["dnhydroseq"].iloc[0] == 0 + assert result["pathlength"].iloc[0] == 0.0 + assert result["totdasqkm"].iloc[0] == 1.0 + + +class TestGeoglowsGraphWithCycles: + """Tests for handling GeoGLOWS graphs with cycles.""" + + @pytest.fixture + def cyclic_geoglows_graph(self) -> tuple[rx.PyDiGraph, dict[str, int]]: + """Create a GeoGLOWS graph with a cycle.""" + graph = rx.PyDiGraph() + + node_data = ["810000001", "810000002", "810000003"] + + node_indices = {} + for fp_id in node_data: + idx = graph.add_node(fp_id) + node_indices[fp_id] = idx + + graph.add_edge(node_indices["810000001"], node_indices["810000002"], None) + graph.add_edge(node_indices["810000002"], node_indices["810000003"], None) + graph.add_edge(node_indices["810000003"], node_indices["810000001"], None) + + return graph, node_indices + + def test_trace_geoglows_attributes_raises_on_cycle( + self, + cyclic_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + sample_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that _trace_geoglows_attributes raises AssertionError on cyclic graph.""" + graph, node_indices = cyclic_geoglows_graph + + with pytest.raises(AssertionError, match="Graph contains cycles"): + _trace_geoglows_attributes( + graph, node_indices, sample_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + class TestTraceAttributes: """Tests for _trace_attributes function.""" @@ -384,3 +825,122 @@ def test_missing_divide_defaults_to_zero_area( # Nodes without divides should have areasqkm = 0 missing_divide_rows = result[~result["flowpath_id"].isin(["85000100000001", "85000100000002"])] assert (missing_divide_rows["areasqkm"] == 0.0).all() + + +class TestFlowpathOrientation: + """Tests for flowpath geometry orientation in _trace_attributes.""" + + def test_reversed_flowpaths_are_corrected( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + reversed_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that reversed flowpaths are oriented correctly.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, reversed_flowpaths, sample_divides, "21") + + # Check that flowpath 4 ends at the confluence with 5 + fp4 = result[result["flowpath_id"] == "85000100000004"].iloc[0] + fp5 = result[result["flowpath_id"] == "85000100000005"].iloc[0] + + # Get endpoints + from reference_builds.utils.geometries import _get_endpoints + + _, fp4_end = _get_endpoints(fp4.geometry) + fp5_start, _ = _get_endpoints(fp5.geometry) + + # fp4 should end where fp5 starts (or very close) + assert Point(fp4_end).distance(Point(fp5_start)) < 0.001 + + def test_correctly_oriented_flowpaths_unchanged( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + correctly_oriented_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that correctly oriented flowpaths remain unchanged.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, correctly_oriented_flowpaths, sample_divides, "21") + + from reference_builds.utils.geometries import _get_endpoints + + # Check original and result have same orientation + for _, row in correctly_oriented_flowpaths.iterrows(): + fp_id = str(int(row["NHDPlusID"])) + original_start, original_end = _get_endpoints(row.geometry) + + result_row = result[result["flowpath_id"] == fp_id].iloc[0] + result_start, result_end = _get_endpoints(result_row.geometry) + + assert original_start == result_start + assert original_end == result_end + + def test_outlet_oriented_correctly( + self, + sample_graph: tuple[rx.PyDiGraph, dict[str, int]], + reversed_flowpaths: gpd.GeoDataFrame, + sample_divides: gpd.GeoDataFrame, + ) -> None: + """Test that outlet flowpath is oriented correctly using upstream.""" + graph, node_indices = sample_graph + result = _trace_attributes(graph, node_indices, reversed_flowpaths, sample_divides, "21") + + from reference_builds.utils.geometries import _get_endpoints + + # Outlet is 85000100000005 + outlet = result[result["flowpath_id"] == "85000100000005"].iloc[0] + upstream = result[result["flowpath_id"] == "85000100000004"].iloc[0] + + _, upstream_end = _get_endpoints(upstream.geometry) + outlet_start, _ = _get_endpoints(outlet.geometry) + + # Outlet should start where upstream ends + assert Point(outlet_start).distance(Point(upstream_end)) < 0.001 + + +class TestGeoglowsFlowpathOrientation: + """Tests for flowpath orientation in _trace_geoglows_attributes.""" + + @pytest.fixture + def reversed_geoglows_flowpaths(self) -> gpd.GeoDataFrame: + """Create GeoGLOWS flowpaths with reversed digitization.""" + return gpd.GeoDataFrame( + { + "LINKNO": [810000001, 810000002, 810000003, 810000004, 810000005], + "DSLINKNO": [810000003, 810000003, 810000004, 810000005, -1], + "strmOrder": [1, 1, 2, 2, 2], + "LengthKM": [0.5, 0.6, 0.8, 0.7, 0.4], + }, + geometry=[ + LineString([(1, 1), (0, 0)]), # Reversed: should be (0,0)->(1,1) + LineString([(1, 1), (2, 0)]), # Reversed: should be (2,0)->(1,1) + LineString([(1, 2), (1, 1)]), # Reversed: should be (1,1)->(1,2) + LineString([(1, 3), (1, 2)]), # Reversed: should be (1,2)->(1,3) + LineString([(1, 4), (1, 3)]), # Reversed: should be (1,3)->(1,4) + ], + crs="EPSG:3857", + ) + + def test_reversed_geoglows_flowpaths_are_corrected( + self, + sample_geoglows_graph: tuple[rx.PyDiGraph, dict[str, int]], + reversed_geoglows_flowpaths: gpd.GeoDataFrame, + sample_geoglows_catchments: gpd.GeoDataFrame, + ) -> None: + """Test that reversed GeoGLOWS flowpaths are oriented correctly.""" + graph, node_indices = sample_geoglows_graph + result = _trace_geoglows_attributes( + graph, node_indices, reversed_geoglows_flowpaths, sample_geoglows_catchments, "701" + ) + + from reference_builds.utils.geometries import _get_endpoints + + # Check that flowpath 4 ends where flowpath 5 starts + fp4 = result[result["flowpath_id"] == "810000004"].iloc[0] + fp5 = result[result["flowpath_id"] == "810000005"].iloc[0] + + _, fp4_end = _get_endpoints(fp4.geometry) + fp5_start, _ = _get_endpoints(fp5.geometry) + + assert Point(fp4_end).distance(Point(fp5_start)) < 0.001 diff --git a/tests/test_geometry_utils.py b/tests/test_geometry_utils.py new file mode 100644 index 0000000..0165399 --- /dev/null +++ b/tests/test_geometry_utils.py @@ -0,0 +1,266 @@ +"""Tests for geometry utility functions""" + +from shapely import wkb +from shapely.geometry import LineString, MultiLineString + +from reference_builds.utils.geometries import ( + _ensure_geometry, + _get_endpoints, + _orient_flowpath_downstream, + _reverse_line, +) + + +class TestEnsureGeometry: + """Tests for _ensure_geometry function.""" + + def test_returns_geometry_unchanged(self) -> None: + """Test that Shapely geometry is returned unchanged.""" + line = LineString([(0, 0), (1, 1)]) + result = _ensure_geometry(line) + assert result == line + + def test_converts_wkb_bytes_to_geometry(self) -> None: + """Test that WKB bytes are converted to Shapely geometry.""" + line = LineString([(0, 0), (1, 1)]) + wkb_bytes = wkb.dumps(line) + result = _ensure_geometry(wkb_bytes) + assert result.equals(line) + + def test_handles_multilinestring(self) -> None: + """Test that MultiLineString is handled correctly.""" + multi = MultiLineString([[(0, 0), (1, 1)], [(2, 2), (3, 3)]]) + result = _ensure_geometry(multi) + assert result == multi + + def test_handles_multilinestring_wkb(self) -> None: + """Test that MultiLineString WKB is converted correctly.""" + multi = MultiLineString([[(0, 0), (1, 1)], [(2, 2), (3, 3)]]) + wkb_bytes = wkb.dumps(multi) + result = _ensure_geometry(wkb_bytes) + assert result.equals(multi) + + +class TestGetEndpoints: + """Tests for _get_endpoints function.""" + + def test_linestring_endpoints(self) -> None: + """Test getting endpoints from LineString.""" + line = LineString([(0, 0), (1, 1), (2, 2)]) + start, end = _get_endpoints(line) + assert start == (0, 0) + assert end == (2, 2) + + def test_multilinestring_endpoints(self) -> None: + """Test getting endpoints from MultiLineString.""" + multi = MultiLineString([[(0, 0), (1, 1)], [(2, 2), (3, 3)]]) + start, end = _get_endpoints(multi) + assert start == (0, 0) + assert end == (3, 3) + + def test_single_segment_linestring(self) -> None: + """Test LineString with only two points.""" + line = LineString([(5, 5), (10, 10)]) + start, end = _get_endpoints(line) + assert start == (5, 5) + assert end == (10, 10) + + def test_handles_wkb_input(self) -> None: + """Test that WKB input is handled.""" + line = LineString([(0, 0), (1, 1)]) + wkb_bytes = wkb.dumps(line) + start, end = _get_endpoints(wkb_bytes) + assert start == (0, 0) + assert end == (1, 1) + + +class TestReverseLine: + """Tests for _reverse_line function.""" + + def test_reverse_linestring(self) -> None: + """Test reversing a LineString.""" + line = LineString([(0, 0), (1, 1), (2, 2)]) + result = _reverse_line(line) + assert list(result.coords) == [(2, 2), (1, 1), (0, 0)] + + def test_reverse_multilinestring(self) -> None: + """Test reversing a MultiLineString.""" + multi = MultiLineString([[(0, 0), (1, 1)], [(2, 2), (3, 3)]]) + result = _reverse_line(multi) + + # Should reverse order of parts and coords within each part + parts = list(result.geoms) + assert len(parts) == 2 + assert list(parts[0].coords) == [(3, 3), (2, 2)] + assert list(parts[1].coords) == [(1, 1), (0, 0)] + + def test_reverse_preserves_geometry_type(self) -> None: + """Test that reversed geometry has same type.""" + line = LineString([(0, 0), (1, 1)]) + result = _reverse_line(line) + assert result.geom_type == "LineString" + + multi = MultiLineString([[(0, 0), (1, 1)]]) + result = _reverse_line(multi) + assert result.geom_type == "MultiLineString" + + def test_handles_wkb_input(self) -> None: + """Test that WKB input is handled.""" + line = LineString([(0, 0), (1, 1)]) + wkb_bytes = wkb.dumps(line) + result = _reverse_line(wkb_bytes) + assert list(result.coords) == [(1, 1), (0, 0)] + + +class TestOrientFlowpathDownstream: + """Tests for _orient_flowpath_downstream function.""" + + def test_already_correct_orientation_with_downstream(self) -> None: + """Test that correctly oriented line is unchanged.""" + # Line goes from (0,0) to (1,1), downstream is at (1,1) to (1,2) + line = LineString([(0, 0), (1, 1)]) + ds_geom = LineString([(1, 1), (1, 2)]) + + result = _orient_flowpath_downstream(line, ds_geom=ds_geom) + + # End point (1,1) is closer to downstream, so no change + assert list(result.coords) == [(0, 0), (1, 1)] + + def test_reversed_orientation_with_downstream(self) -> None: + """Test that reversed line is corrected.""" + # Line goes from (1,1) to (0,0), but downstream is at (1,1) + line = LineString([(1, 1), (0, 0)]) + ds_geom = LineString([(1, 1), (1, 2)]) + + result = _orient_flowpath_downstream(line, ds_geom=ds_geom) + + # Start point (1,1) is closer to downstream, so should reverse + assert list(result.coords) == [(0, 0), (1, 1)] + + def test_outlet_with_upstream_geometry_correct(self) -> None: + """Test outlet orientation using upstream geometry (already correct).""" + # Outlet line goes from (1,1) to (1,2), upstream ends at (1,1) + line = LineString([(1, 1), (1, 2)]) + us_geom = LineString([(0, 0), (1, 1)]) + + result = _orient_flowpath_downstream(line, ds_geom=None, us_geom=us_geom) + + # Start (1,1) is closer to upstream, which is correct + assert list(result.coords) == [(1, 1), (1, 2)] + + def test_outlet_with_upstream_geometry_reversed(self) -> None: + """Test outlet orientation using upstream geometry (needs reversal).""" + # Outlet line goes from (1,2) to (1,1), but upstream ends at (1,1) + line = LineString([(1, 2), (1, 1)]) + us_geom = LineString([(0, 0), (1, 1)]) + + result = _orient_flowpath_downstream(line, ds_geom=None, us_geom=us_geom) + + # End (1,1) is closer to upstream, so should reverse + assert list(result.coords) == [(1, 1), (1, 2)] + + def test_no_reference_returns_unchanged(self) -> None: + """Test that line is unchanged when no reference geometry provided.""" + line = LineString([(0, 0), (1, 1)]) + result = _orient_flowpath_downstream(line, ds_geom=None, us_geom=None) + assert list(result.coords) == [(0, 0), (1, 1)] + + def test_multilinestring_orientation(self) -> None: + """Test orientation of MultiLineString.""" + # Multi goes from (0,0) to (2,2), downstream starts at (2,2) + multi = MultiLineString([[(0, 0), (1, 1)], [(1, 1), (2, 2)]]) + ds_geom = LineString([(2, 2), (3, 3)]) + + result = _orient_flowpath_downstream(multi, ds_geom=ds_geom) + + # End (2,2) is closer to downstream, so no change + start, end = _get_endpoints(result) + assert start == (0, 0) + assert end == (2, 2) + + def test_multilinestring_reversed(self) -> None: + """Test reversal of MultiLineString.""" + # Multi goes from (2,2) to (0,0), but downstream is at (2,2) + multi = MultiLineString([[(2, 2), (1, 1)], [(1, 1), (0, 0)]]) + ds_geom = LineString([(2, 2), (3, 3)]) + + result = _orient_flowpath_downstream(multi, ds_geom=ds_geom) + + # Start (2,2) is closer to downstream, so should reverse + start, end = _get_endpoints(result) + assert start == (0, 0) + assert end == (2, 2) + + def test_handles_wkb_input(self) -> None: + """Test that WKB input is handled for both geometries.""" + line = LineString([(0, 0), (1, 1)]) + ds_geom = LineString([(1, 1), (1, 2)]) + + line_wkb = wkb.dumps(line) + ds_wkb = wkb.dumps(ds_geom) + + result = _orient_flowpath_downstream(line_wkb, ds_geom=ds_wkb) + assert list(result.coords) == [(0, 0), (1, 1)] + + def test_downstream_takes_priority_over_upstream(self) -> None: + """Test that downstream geometry is used when both are provided.""" + line = LineString([(1, 1), (0, 0)]) + ds_geom = LineString([(1, 1), (1, 2)]) # Would cause reversal + us_geom = LineString([(0, 0), (0, -1)]) # Would not cause reversal + + result = _orient_flowpath_downstream(line, ds_geom=ds_geom, us_geom=us_geom) + + # Should use downstream, so should reverse + assert list(result.coords) == [(0, 0), (1, 1)] + + +class TestOrientFlowpathDownstreamEdgeCases: + """Edge case tests for _orient_flowpath_downstream.""" + + def test_identical_endpoints(self) -> None: + """Test handling when both endpoints are equidistant from downstream.""" + # This is a degenerate case - line perpendicular to downstream + line = LineString([(0, 0), (2, 0)]) + ds_geom = LineString([(1, 0), (1, 1)]) + + # Should return unchanged (or reversed, but consistently) + result = _orient_flowpath_downstream(line, ds_geom=ds_geom) + assert result.geom_type == "LineString" + + def test_touching_downstream(self) -> None: + """Test when line endpoint touches downstream geometry.""" + line = LineString([(0, 0), (1, 1)]) + ds_geom = LineString([(1, 1), (2, 2)]) # Starts exactly at line end + + result = _orient_flowpath_downstream(line, ds_geom=ds_geom) + + # End touches downstream (distance 0), so should not reverse + assert list(result.coords) == [(0, 0), (1, 1)] + + def test_touching_upstream(self) -> None: + """Test outlet when line start touches upstream geometry.""" + line = LineString([(1, 1), (2, 2)]) + us_geom = LineString([(0, 0), (1, 1)]) # Ends exactly at line start + + result = _orient_flowpath_downstream(line, ds_geom=None, us_geom=us_geom) + + # Start touches upstream (distance 0), so should not reverse + assert list(result.coords) == [(1, 1), (2, 2)] + + def test_long_multilinestring(self) -> None: + """Test with MultiLineString with many segments.""" + multi = MultiLineString( + [ + [(0, 0), (1, 0)], + [(1, 0), (2, 0)], + [(2, 0), (3, 0)], + [(3, 0), (4, 0)], + ] + ) + ds_geom = LineString([(4, 0), (5, 0)]) + + result = _orient_flowpath_downstream(multi, ds_geom=ds_geom) + + start, end = _get_endpoints(result) + assert start == (0, 0) + assert end == (4, 0) From 162456be84a8d9f9dd3b5f585f547f07a4c69171 Mon Sep 17 00:00:00 2001 From: Tadd Bindas Date: Fri, 9 Jan 2026 10:29:26 -0500 Subject: [PATCH 11/16] readme updates: added quickstart (#12) --- README.md | 37 +++++++++++++++++++++++++++++++++++++ docs/img/hierarchy.png | Bin 0 -> 155037 bytes 2 files changed, 37 insertions(+) create mode 100644 docs/img/hierarchy.png diff --git a/README.md b/README.md index f44e6b6..df2987c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,40 @@ # Reference-Builds This repo is meant to take OCONUS reference data products and convert them into reference-files that can be used in the [NGWPC/nhf-builds](https://github.com/NGWPC/nhf-builds/) Repository + +## Data sources: +### Science Base +The NHDPlusHR is used for *PRVI* and *HI* and can be downloaded in HUC4 form through: +https://www.sciencebase.gov/catalog/item/57645ff2e4b07657d19ba8e8 + +the zipped geopackage is required + +### GeoGlows +GeoGlows v2 is used for the *AK* reference and can be downloaded from the following location: +- http://geoglows-v2.s3-website-us-west-2.amazonaws.com/#hydrography/vpu=801/ +- http://geoglows-v2.s3-website-us-west-2.amazonaws.com/#hydrography/vpu=802/ +- http://geoglows-v2.s3-website-us-west-2.amazonaws.com/#hydrography/vpu=803/ +- http://geoglows-v2.s3-website-us-west-2.amazonaws.com/#hydrography/vpu=705/ + +the `catchment.parquet` and `streams_mapping.gpkg` files are required from each VPU + +### saved location + +This data only needs to be unzipped in it's "domain" folder for it to be picked up by the example scripts +![alt text](docs/img/hierarchy.png) + + +## Quick Start + +All dependencies can be installed through +``` +uv sync +``` + +and the example configs can be used to create the references once the data is downloaded + +``` +uv run python builds/build_reference.py --config config/example_prvi.yaml +uv run python builds/build_reference.py --config config/example_hi.yaml +uv run python builds/build_reference.py --config config/example_ak.yaml +``` diff --git a/docs/img/hierarchy.png b/docs/img/hierarchy.png new file mode 100644 index 0000000000000000000000000000000000000000..629074e47011c5f8eea4554e233c709cf88a4e92 GIT binary patch literal 155037 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�vBLV8> zz*#Tfz;@2_YeK5q@r)1IiF;Q2yKE;-^@wOJpalR#0agP*JW&h(1tww$kW@7Yj4FEv z#?L|W1$=I0WCJh4{ve11H2|aVS)_+wIAW(Td8^|u^mcv1V!ao9W8u#GqQtF!^8=As z2nin`DtS#G%h*&rGf=2?;u-y4^=xTUTLa#8&1LZc*ES)O9(Sp;8bY#GUuSPSYfTgO z(`+fP2X+94qoKG~VuWYMW_=;GH#1BEj>T=ODpIn9?-@JHV3JF?Vo z)@yhINZuHFpE|t5`EXOOLT(rMxZbdV^7r7#Yarzgyk&4Mc&8d08wVXp?t09)E31K2 zHtD$wqN2^VLn+J#!wZ!fR?ScwXLQ8LPtfj0S;~hab2-I?V2}>)E$dnb32bfHKoqPAp=hdw*itG7Mfa z8NW%2UTC9juT;1vY5&9d!EAP-0NYN*=m#UgXwL5VvgnHHT;qF;<|fLpGbK?#U(} zC!S^d)2mIy>;&uCvrlH+9%e0ENv32W4FJc3xHQGS4_O97#gJPO(kQRd3c>|PMG*5w z8sb^y#(SgwSbknXYi%=B1FF?8t;(J3s}SWZogGZR`JEeKe>8r0Jvn7Nc{Kgrm;19b zZQ|eXoX84O8`A1BTyTfXu+79^^k)rTejZVwe;EOD@@uxn)-@+_D~970`QrB#Z0mcC z-9+Oyf1H)eneoD?eyQZQ)I-v*d0;PSjv;M)A3UY!4r4Q3P<0`U1VIIp9W1t7$k(eP2!}YyfSjROGtj#D zS{j>$#(jqDWmb&`YM9Gvy$h~ydmqt&iHtmEzcr14U^(c=kL!^*D3nn0ODOfHIANFt z-as_lT{#+?y>v4QLYG9JJmX4#UWukSs$9y=-ReOg)aEltH!nH-V9sY_l7v6>5A)27 z=P{#e9cyi{`5LHZ53e9i$&!C?fVI>pRdP8EGO8Ap%`>Vlt>ZZc?(){QpsrLm6>X z412x%W0tW$>`}-97R_3HX2p&@8_K!o87ctm`X*c_mobjpS(HCS#2CDbZ zGXJ1{0L$iaC?iz3&gjgce=!lIh#xzP1~B~pJA32?{{JxkKPe#6)dBO#A^7yURgd!}ViX*Y_*J z&)Ar_{9iw83M3;IpwQSp=t5IN*so=`gDnr+h1wD&bxmfEsQs1B6XhmM9boV1jUD?XpaHsuWYR~cGKia$O~aPreQBBdIivQqb1XDUgwoRkN2)6w%SO zt}4jSKc880)3u)2iFM_4BC;ONS1~;}1vJHz>Q8hBX-#g0oYpgLXN?})zFy6w4PQ+d znyqJ@3l_mcbTRYajA+z`>#Mqou4kUBdF~a|yPilC{jeK4-M&eI{;DbzS#YsgRG%!J zEQr=8nVhXLTpzE>(?la?*L2!`o3%4BV&p12xw)IDqBp@%&~%%)?vV7Cjn_K%3Qft!$r^ig(;E22*2BQ+ZvK zU%krKBVHgfYsiBIs1G%m7H2omYA@SPWV2+}0gcbH*Pc-kHyUwYOfe4529ppp@o{nU zfSjWH!AR*D=>AtH>cWjq8ct=Hd#zlHq~})guvK<_8K1gzsZk)3N8%sj*vJZx=hRk+#hA9KLoa<+GGN$ zC5ySs$;J;gw)1ayCW@qZItM}P?^_4Mb|%NSRcOY592)zMT@=^-cu#XAO81*r&@XuF zK!#L95}nKrg|x@zLx9iaeaA3opl;MG={7x~k=&JALtQ!%f{04NZT=Y^NbPuX|pPX+T?> z=S$kX`25=nOXbg=q>`Cir~F#kU;5OfZnZkEuB8)Ba#4U6bVo>L6_nrXEU#w0m_-W3 zcy0je8kWx7gHuxC7(vRf4&-ay4EtiKiH`0NpS{i0C*3qw)6g#Dw9&-jnSG4ShR(>A zvIRYl{dvp06?^OcT@-AW#N;bgZqK4+JMGG|(Q~N8n4z_3MT{Y+*eFy;-4QfNI8pb$ z5-*A%+-S_XjRR6f5>0%ZdmtFsgT)5fR31;($`@A)sPcIj3k~iXBm$NCE$Kr~sl#SB zn8FBGJ^PsxDHi8JHOccai&pwBG8=P`gzir!)Q>K!j};0C_h^;uPDnnTx09~eVw3Ug zUNU{{uCJ(0^#+6azSYa=Hjiib)>;}esPjbP=zqu*;sKuhRo+*>#H+wvaYFVqSJcW= z%d%%3(xXjSA12RwJ~}Sd*cOK}C>fJ(R~r*iy_+)dw}EtAZ*tFb2)|cfCM}m>?02=E zsa9h%8C1cX-iB;PYrWpbzz@#DhsHC`HQI}M_rHriR+dQf!%Ci{j(5+jWfd?ZA|tJ~ znpp~4Kp+{C(3w+crf18C*mQ@qkB7ADeN{WFK6$m9HfiXp>ZfU=iY)m=V*h-1X8*R~ z)uMYiJ)yX$nW)_?g+g)*^T#~G`(I{)sG7k`@EH5@ptY6yJ=WUayz~KpNIEDi2-NuX z%^r3Yz4CJ8O2}eo>MSGhT*x*%V|to9G6Y+JFiz%XonIQV-%(CU8$Yh)BEeEHStlFc z$~4?iP|YJxn4KqNx-=CfO6yuAU@WX5>lfYO^Oe6WxC;ncV;pT+efYpD26i z_2|eC6b}{>&GgIbhx*cz$yJ2%bL>o)k7-5}HY1q;VU7dLFCM^^;NdH>S;mmrfl{}^|kT~aTD?hw7{ zoTZs0!YW(ESX1oaO*oRp1<&iGcIe#nsKvql8=%lDm&V)9CeLZljM$VXDFl?kz|(0n zaxW&$;nEUg>EiMNuPJ31MN$-+b-e)kjGU+`duaFU*+5_!5%VDehr{Vq%y)Z?RlTPC z0ckQzHfJMh6jT_a`A<0eQ1@mLZIvg9A-xx*{rM{WT4$Cx7Zm+|A+k{UPI&}h2~cJ= z&w`4PzU%mh^~4C3p zobeHYQzGd3;+$2=0+1KuCcPRJs9#W3Q&u~X%7 zsJw-zS42tjdl^xKYhVhsN}hk;JoPzPZKLdgNK!SUg5Ppbq(ucgCihXwxjcbnr5LRh zdfKOx;51WX2{#+dK+>Jc%OUZg(WlN=SJ=)W3%BqsqK)7#b=#QH64dj@fDM$Qawobo z;W6D^{&ZtJ5#z~Lr1}=SFf19Z$za+6S;0)Rc*rNzdjZZhJ{HF@jqMS(1oBzfi~YH) zkScIo^};vuZvr3kII)QJY+oWB)l&;eC3&!5;GPI$F}sCDJgGxc=DjL$0P3qBv<;f~ zp|P^#a|pJV7is`a;FcTK1XINBS2SbyCXTs=$5Dd>8P#gz#S*dcL<)jexqd2l4m#P~ z=0UFI$@iBYFA~?m?EuH^dVZgfWAHK8zVT14-RCj4_ZWjHr7-ND?+IosxGIJ;u%n|9 ztA&v;JixU7CR(I-0Yr;beOv9MvUc(5N0LQ(6SPM`7MD$k+ej!73zo!uw(iSsByE?I zy}Is)KY7mVtgsyL>O6c~6_6S-5cZL5Gm=ypP}SY+42zh3%KG4e1PYz_4sQeLU2nm_ zRpds;C_%;J2eVeb6I3}Q2UhdLEtyL*ospw8lUlJeoZ zG~^3n3IdQhlqXe~jsrLR!?1XNZ!Td9>F2OV@4tG#@ENlUIod@e^x>D&ar+OMuVsFI z>C<5Si`n^viC&DSb-2!$ryuX-?ZO%pSC8?Xy1*uI#L-jXiYv_*OZ0u3YEzm02G;1x zK~Yw0B4H6Ph)z{~$!&k;*_M>fGA}dzPLF5ajh;@HgY)fX-pq0hU2|YTCp?IPyu(xw@_jY9z11%hsJ!TS%m`K2S-H99nRb_BT!A&c^JRF=F%2r2@e5!}%iy64z zmYq@#&<+U^pIyz7&XL~1mxOu&va{CE%_)g)0S@(79L-rv<#jrSB8MODyHO6*f39oZ z56w9G@EEUmvjX8Z;xMojaIHfcL<1x8yuCJ?Qi4HOh%X=|3QQslEotBN09$Rr^mVg* zJ239eZdS;3M!2JnGRq-C)A@M!bycY^@BSmRi3ATL?j>nhNa_0K>B=~BF^U7@>LLu! zZ&=<)V>@qo3?@E&Zix|#^srZn?{SyEM#jzueKU{NREs8gAU7md;9rJ+bfD;1 zZNA7_`muR4W8G}N%F7|XxY>yo(vx|Y*{luroL4>&&-qQs@OZS6N`vubqDKJh#A7Xw zW`BRNok`=~c=6Uk?Z zPq+>inxcQ)%J6mxP?O#Ex~;F&8^4=-48A{Y{C0#td7yw)+wfd8J8n%6yx0DLD|MG@ zhVffD-b7Asm6w5iK&rQ4e;APD4JyBan73-tyJu`-B$9(x4gN*OI8Bl!0XehhLS0#7 zp)=#9cb-;K&D*@(v^1Q%Yg$v^UVOCiaP6lEpf#QR|`|Gv*(tz&)8@x z4n8Tu3B&?{q;jz~2e;1FPTj!UzQo3})!DynF}AyDEKD`RV>;|JKJl)2C9+t4dcb=6 z(lRWK=&Yk0$-RCj*g?nnI5W#7+Z^4%bSU?k78AQZ#O6qQ@nXcErw2NEQ5WO%>@K?F zseshf!n@4o3%j9kLjJ)A?#~$5b`n&AK4Ru4jwjOcPJle+5dXt>!~V?_q*#QYk8jQ9 z)?9a`-}?EsNBk!dW1Z|h?QSElCTcV3T#H63@4JUrRoN&F04>xq;&X_nS+yUWwrZ+rTVCKmtoafE?1xO2^V^j@vt(99@%~}tS z@d5PGFh$oE&W7YVtJwCMBy9_iKgovR%KRrxRe=@b*_i`s;vw}&GDOIRdhgAs zn)9$!g~x%xntn-GAV&P99nvP>&*WFfKl%M{rk$CeXAjsj#$iX1ZTmu#cXrU-!}H2?no;9fnJou2=&Q2!xWOGEj9V?eo+TCz{DwYp3+N0Sfln4_Ti z12<(qlUNMc%(J?=?5D3|tuZ+*2vzmKL*+Z;4W)z+P^uOSJGpYFlj|7we!)C0WfkYG7wy}&snIYCxL zq94)vK;-IL{6$9wJXqfT7L&O_+-LaHyZzO!2%>R*eaQd8-djdx{dJ4l3MeHYAfYq} zh_rM|HwuU--Q981dDGpU(jC&B(%s#C)7|HHe#D(gMszDFe%c{V(fki{sztwK25UMGxSc!i>=} zZAup71A%5wS&@mmzyn2WKwNBL~8|iNGs)f$5ZqQw`4F>#!iK0aN=5eFMoQzH=30QrtlW3V>t_> zhkG!N>{IwzZI+FT#k#si**oe3SqI`?ct&2){!Wy{Ir2HdD$|&w>lrc3<=?YEM}eyQ zy|xwXUPq<*lA4IaoPFpjkw9H-ry6OO+iM#rM*+OfK#mnrrXeQl+%j=o?T{HSn~bh} z@v?up02XVT%;eGj%O(WYJ%IDBwOvfL^F~6ns2np{W;#8}Y{6-JaNef*v-1KEX#H#8 z8<5rg2b+d6vD#O1-_qKj`(l7vz*_T>b6pUZo@y#G5QAa@hrUN@QR8Hs8?lBU-Tt#k zxi|_vjDJB~LonH{wHCW%b7@%!^}l{$u>1?#aQ=X(1Um|!%a(cles#>{S!+^`rDKse zX47z=F}G>lm|o&r^L&+9z2L@i;r#YZrT7!Icn0zFgRv>d?u%hOdYOHq{#Ib+^Z|5C zmIXn}sr90?FP_V-ta-lO!=)a`5vgDIWP$M;rWlt-u^93VM7lH)HSn=riuee1Pk%=C zA;WQ4T22ggNK!IfIl0U7yFXgF{UgagXs_p86v3s9otZ952JBQbzT96oqDzN6gQll?EiVf~}h z3!Tf^C52o_Zdj!ZD6^vp0*ARYIw(%N7Wg77|9})s$0WZbVUpLdWyKS4CTv?IJgeRV z$c-bRL@(hT=BiOLyTHY(lpa!jHA*H!Scie@1nnvD#G;rzmdbu-k{}~goQOJ>{)nvePhk7`34)-n6C;E0+}AP*d7Y^kjQFUsnE06q<}cMkj6LtqR23IZ!hM2 zll5uw*r2->8Mwv((rQfop&)<0Vn#L*A_5$2nr*0?_DoweXUXK#m11?J?TQvOE5utb zUf9k9WlmO1h-8!Weo_zOqh<)Z&UMx05O~)6ifTmwUq)_NRs!m7dUwK9X?XfwRJxo9_^x~brfOb+ z-hjttbG}6a$bz+kmw-H@xaFM7?mD8a#bb)dRD^%7K~#K5m~-MsFCE`Ex?Te#t(HM2 z(kYx{>b|cnFPy1sgJ42L8S{Q`{qA5y7Je%$2&~T+O71JLftm5D zewx>XB6Xv}ApIJQ3)HfC2h)o>V}BUWol#YL?~0Ug>&tBoF!Y0W(1vA?_s|fvLZBGR zQSm;UDH@$lnuaK?Qz$(=m!X7w1q1zXR5RBMl>o#6mNczN>1u1Ya)1fSFPun5XJYG7 zfpum2he4?0oS`V%s}95---t;AAu{^?U_bg`bd(#wUE=KW({!n>O!flTV}Cp^lF04s zh~IYgDqMujZo318fVGY7c?Y5_H~Y=ouKDgv6rC>a*z7n4-A)9HoWbPAg7*2m3F>CC zy~6hhFW^W;R;Bdc9xQ6^(j=p=5W2G|M*f+|?p9Ce(PReQ*F`V>8q79{E0&%J3`nf< zb^nNR`i?gLCaTvqsgX`hFzAC>i?s3rf}-8!E((pgN9@>TPk%w zkn?@_xUxx8Qchf8xEd)s{7|;Nndhd@#rDh`lLE^n@u>Na>W^X4z_-{<1Y|GQ7T_sG zDDXLKTAXs?T}~4Lw&VKrFC+6AT6{#ReGO$~w%62ii{|Xujl${;uHo4ON(H5d)1%v- zTLR7wlOZ>TYDNl?+{9N`Z4C+~(KeR{1aJ4v^+`Y8Np<+F2g+Okw|&RR@_=%+*$xLi z78N`gjzz?7ch`s;{w+f!&6tc?f1TE*P81?KvgiwtyZA(A-bT}p7R$KTY9$5doW^lZ zz8fSG5`FKE=PUTRHgk)re)spfKEVs{hml7DRA3jPUTwYGw(H-!VH*Z8T|N!$Dy{P) za6vaNwyU|UkuKZ8QQG7EaDLsTENc>db$jS@R*~}nczwQ&CX%ATdE@!Ot}PN5*1du9 zyHFYJK72WT?0sgiRj^n!{}5wP0`^XZ4|}pR*U*PH)UD~&XO{LMrPy=9c(#sbMcsJ! z)k}=8*RSj&#k;FZ+fRc>Tpak)9vH|w&p$7(@W`sjMVzF3PFR~YxiWCKroxXrY|M5Y z7T`s&oaHuXQvG^eTX%5t?$qrmqIn5D0l0LOp=TpF)h)m3(85W=`WrPh@aD3B5+DpT zy2Q=%tU0BDNHC{k%jvWRI&JIW{<#be-ReDxe|$|d1o79y&95XgCXm6tKFJAIuU0ko zHc@urLBloJQiYp)Sq?y}E;;l%QGmTT9MsP?Ids{u&EE8k1N??JY8MY}*ui3L5uG8r z)dS@1!j8n@umGsIowJc&_hqMe6Mq+YRizEpaOCn?aMwXuep57llKz5-C>@T>y4^SQ zgvX?}0jON2UmWC}^lRug7}q<6s@18g8!`t3p5b~3C~LuuV3nG!l&%bII@gZA=-iT+ z?zxnyYSfyYdd_svIty#q6+*Bt_`us=_r+jroGItajb*$^_wC2-T6w@C!-ezx`47|< zdpi_~PzU25#Uu+>^e2t6d8Nh@iF4Sfn%R9b4F|+}e_JdzNr5^IajG>^doqek!C_@#uT#;IxJLCi2~AsLWN9)mt3YyNlK$a3uiYyx*aS;>(>EW8nksIW8RfW`QmlWTsJ2vtoM=-=4@VQ8{%=25L$;hLR|8y2{fW0e~N&f*qzR z@K}mYV}BQGH-wO&8)SfJ+ke4ANy5jT>rOdj0@oir5L2tox;uvNaKvkdx=R!JM1Rmh zckbL1{c)rGMI%kkZJ)dmRhbpIom2tvg!PJ?7R{-sLuyx4Gw03Wn$!Vt$`eDk#0v#_ zfDQeB^$gHA@MoC+lf@IjLHGZ4rN(+94aD*Mpa2OOxskfral_d5@qq{4dwlzQky2jM z{f$%jUlb(bnFFc07d+ZQ0kyii3?D}L;Vt#QI1$k{h*T(1e!Lgh# zw()4=K;Qbf>iywJhA4jz%o=dre>42aHjyD}ZhPLVYkRo3AhKi8XlK*JX0D?6ftm)l zNzCqe)#3cCC$e-sI0Os3Y6Sn*{`kvTEe?H>anv8Iky=Sb1r?Q5f~LPVj7^FB;S^y` zQ%lRdk5se@o#eV||BVH)#eTi^Y&K0kXK?ZDRIwVO39KTB$Lah#(EV|~>u^2#;{57Z zTXD3ov)Xh{=G^JP!MmKx29k-SRDBVrpkDtgzeH7m&3w^M0sA1H*Ckb6wGO1veLJ{6 zw|jH0WP4Ju=6L(PumPaa3p~It3+Pk>Y!)~jY0P(Sl6b6p3zU=kmG<@oT*+@1KjQ)Z z4Uvf*CzwkyRa4V-JJF<1`xPfMLl;h2@>_H_vl5M@$BUomgi-9(R~o&)7Az@NpF2^@ z7%bF%u6AgSl0Rx#tsT&B;W@cG1)nu4W^&pdKo;E4j_D>2Ta9mAo9tJdGmYDMRI-;c zy5SFL8tQd&-9(oz5BBE_4|cLE91MTjO~|Sv!}LmA(zV6NZUbAT|o!ZeuNRRRI)4T3HVAD&^@czf05}5ad4%My2-@p&adM63f+uw8Lmr6I5 z)~hMze4Hu?`SzWkKV7NjipzBtXw@uuwCCFsvKuFu2N2hru2|Oxq6bhNijBpeV7CED z&@Dg)n+*g+$wL5r%1g+6jjL)hudRS8yPM1@DvraNdNKq#&%IFYSITx3m+eUnr`46a z{E=8wBHMSbfP$!gJW!p1<#%4=lxknpUYzE~p)?&2OR(=N`F4?@vk?9Y^EtV@J~Ome^}rzav+Uy$ ztoqMtLZk6M?XjlYu(!q6{X3DaO@B-1nSgN4y5cLVx2mOO_|0~sT|-3GVXhpJ>z(26 z0IvVwJ-{8mw`1Nzq>2~=s!gr4*lsjeA?j2gA=<_F0D769%vA*kG9X`ikHqDim%?sX zcbVR5>++%%>rV6x{GZo7?w!VW zA}QYy_>XO}_T4&;o8Hexdt{gnq2Xo8B^n6h9)odcX763JhT>h+5;z%0P@DmAA_Ae5o z1!1O)%vbKjY^peUu)0A1<3~~YhLGEff#8m0Xk~Y*$pxN8|KV5E9yo7;-1L*9U}F3( z5YilK{L<;?mtIv+r&G8*6lR)Z*%AU;U&Fp>!t--61csm*Vz%euLH`Y_# zCk;H)9rjFkdV?+l2u?^K!>(ic{o+(NA=t*0XN~Yo%G_)F=Up!|!7rZmGfB7HS_*dV zwlrg%G{Rf&zWM!~XFCw&pwWR%!mr+L#~`;dE*;Tt-BV@u6rEfLv# z-N_RuEBE%RD0c>1<=cI27jx}5zokOE*Xn{5kVls$hCX{&k@on;R~HghKdB<1p&fh~ zP_9An)W4f&``3uGKv<5$t9{>|B;7vk-}M33r3)%hNU zAi}ml5pGc3|KrDDbSDSE3zDHv4<29=Z2wPVN5%D2n*OG`W_DoX?U_8F>}XcEbYT$p zG?b$g7-vQJ$fcegS0bHKTxWZ@qVRl3GV5SjYcTfJ{{%2yW4S022c$e zO8)3PV`V-qN{uIhQOwh=^J&+}ODCf08fLY+^1>N5xOpvp-iSb-2QuWDB&HyMiJ30_ z;;IN9i0|6gPgj53k_H)VoGdw=s@(h0m#qEWD@E&Mo3IdYOJw!VmRFj;v57y|b6&|XtOugYN;M4ItT?GG*_z84T z<_P`}&`1a?9$4wfQE@wCur{__GM_D-#Is}uG6mPNFOQ?Kj0FZx2Wn3my=t)3@8}NQ zN~Bf)_Vvg@A6XwZ1g6tJD9`WD)T=k%KKSmYv%>Z$hPk;j@@41NOw&6C9vIX#X;lF} zkHB(KDu!OGd&gwA#_2G(hK#u9yi;!Omn=A2zXUb8*utR;yGiRjb~sJMS^C9E)y6Ha z>%4UwF_|B(zsN&e4LFWj!)&{KOWO_h=U)uLeVN{V!+k^h+7cPM2- zk1=Mu31Dj*q1#D(c9V&RWF_h$rL8}Q0e|fJO4L4yl8^>7-g62&hLY&#ZS8TIZD&@O zgZn2I8T*~)E=!@ty^RN{_~qfnkwjMcY(_+zQQ`rsDqLUq>-$DNX`@mLi`F@jK$J4@ z!HBtUsoGT}<7h=tPB@V3(14=(Fx~4#go_W>KN8!cYPPwJn$vMzk@3F_FF zuF)p`K%2Kb^1J9D>W?N&oVV0wNb38#^d>zXX+C{fiv2zE`u37sX)5e5++pM1d)1f{ z@6m82X=Z1gB-PAqOKJT?$l5~U@AnOsn#oWrXuc!Wz4jrcP09A>kh!?mo-&2PC+oS4 zYBpiGk!1e-WR9Qs`mvIsnvdfr%?ElcSNOHY9HEO+)jC_c8s`3K#(}Tx$=F7CCOQ`f znp~4UfJQ=O%Zm~#aYwCJe)#~Equq_?DW?(I9Pj3ob>=Tq&Xxl?T!Bq}TCi$leYc;) zZkWK==8rkLaC%{|c9PYM%uIWMT+MoPw-PVx-ZdIk3;wQj@T5hueu^wU(*bZ^Md!i; zEIc6lqz?g<3}{Cp`viw&7qg1Rlxc~}yGPYRn!d)Km`H^jKCj0aLx8oSdn3o~-ToKh1soZ&43v@ARlU zfQpG-xM>K)$oiU2w6Sd5ho@_%i4cL`I(3!|3{v__SxW-$-z|ey;Bb9o99RzvkCx}= za0za?W(+Z6r2?)VpHeU-^Y!_g_=Eh}U0Nw>WP`jmnYvuGU9m1KEZE95aBKxEoL5>% zJv2V>?F47}b+IfPUvum~dr2X3t%m=0gRH|8eDizv@e2T6z1>W@1`>$kxYk@h)#C)( z{t_rV{Z+J!nHm0ajl|3%n$lsvhB{`Np;nVa1Km`Rk2UKbPSqOTnqH#g(Y_`sl{iX) zdPp{;p7UDvL?WOxaMa!@Cfp@^G+qD?@8leU+CD-*A3AJ^mA9mPe1-?;grulb z*M_RW8b5zK{tR%st!YEe(3TbC*TidP!q@dUVMl#qa>_LKhB73#WmemIi}r%+56Bm8 ziu#&DdOF?p0^#+l)Mv`Z?9GHatBrEXGpkD0c&eehUe?blNlv20u(8zgQu0GK&Hu83 zkYO6vGO$RkXkg%ALu6U6`+L3P!MsXv8`NBNfB{5*z(uvvRQ}LHxXxX4@hA*IzGm)u z3Ie#-Od@HaC)CL_|;`(R6kFh}?^~Iegs?*-7gDop7qeCq2w?#FI#jUL9~S&8;3Bg=eJH zfTU|kk`BOy45}+Qql8pEzQ{*+#bPmosuPx~Y29T4^#3YPU7aWIw(NF0lLvQ&SyY$Sb zk=ysK9^--^`4W;lvUV4${aPQOPPXDi4Nr+niurA0P+i-Sz?%0W2!o~WXpB{gv!!R; zBGBB@H5AbyP5}yDAE#GQ1LGbc_o4WeeKf!xifKcw)zaK8Q)-B_uy|Cn-PO5|9S1nB z*-GOQo_BPtvFk33&31>y&E|nfG~9HsgT5R|J4x}{|9Aq8K=yIvc(yUF9U+dIvQiAE{l@JeQOtJn&U=iCD1 zb>yrvsW%duZ)iSrJAK?{U-cC6-Ecx9dz^@&{NczL_Zl5=fJVnr&%t1yqQoh#fsVfn%Cf3OhMF5S&GfdTkxDu<7S3U*h6qmp@~zc6rFP zE}M=ciN^GTZc0sK1RFs!uuOj1!S9L)$Vfv4_*(`o&dBwPjgI-vkM+$8%hLyeZ-cuawQX#O_Tz~;e}`tHXyfA2i%$-=MO!++ZKYC%liH7y^!=%S z9qrt|<0!gX!9p}qcS24Hw%#Oy=So)Xc#{!<483|!7LgXez44vjcfU&MRO>hltj_2o zZO1qbBG-Yi(Q)!+?&s+bS?aA6COUtYOm12;bF8MvcT2?H;uR9`KDPSC(Qks2@jJQR z#_*ehA6X%C!0R)@=)*=4%0L9aGU&U0tqT<%N%ei@T!RR`bF;3t_hngTW~P*MJnz^L z`jlt$7r>}{tGrG$jyHP7QAkjVFLZmczmd1d`rx`uBru$0bolJ?i9!4i#}Tx^dwbqo zP>Hoiz|zMJ>{nC_(1OTxui9r;T0NCge#7EAnA9@5bL0l_9y|nl!RtNoOl@BXgHeGy%po7d=)?NIuI5nH!`}W zQJ>cMhBq_@bfyV}&ykVt@VaXpg#Kj4f9v*~?Hr|Tgq3OWTP@wdJEy!r7T*C5q((m< zuw15(zxM}++R^FcJDUa_-W{(6cMtk284hB<{TFk(Y8R}VgKxWd7>vCfG7v}eP*zcm zwup;I`blnJC32vKsFnE1xS2L(%VC66Dzbx;Myb94dfuta-S+O8?(iMFsggbPZUw5X z-pfVTebdUi8>L>$qTUMm(%W!+@QkS$S3`s`%&#y&2kexRB#&IU6xsK&WoF2crwxbl zbAOu(!E-lw#@()`H2Xij`@oe-KD+A!Y5MXJ=9S_@i@x2>Z9p14v z4r7QwAi@sd%>Z4}kFSugsjpdur!{5Iy#rH=SgHz)5)W#UB8Azjv*MF^LSGi|u86%{ z@qefz5KgQ7hh0%uE8Z;C9esI{V6DJM^toMx(zHqms!CKx$e0CwFB!{cnyK0sbJd4w zu|hv|K<91!eS)#^E!%`}Zv?*jIv#kkeX9qZTQ83m?Y)p!uI2V@d`VV}d`SeenaSgd zPbatHrN5eKpPi<*$@uV_>l#S3FmRj%T*@rwozelmg5J4?)9}7C=g9B6}G)y0wI(SF zS~Q2MNzAiZD|d&}1$(dcD0cwAWk;!(;M6ZtdEI@NBFipXOxYEE8rJvFFI9(}@($ki zmyqQ{_JU>mhgK-$qMjT~g_VToTzhA|#mx|z$3r?kzQ=f7>&gU3B!KM1Z=liZHoF%$ zFMm7(2aS;1LRBr}%3)TLy5qS(oUZ_WY#VJZQ^*K+&@KsFu$UA4y9d1gw38%px>Tv< z6n97x=q8ue1Qzwx2`z+-_SkJin{^DTA(0z(Enp+#zGyQDLll1l%1v}0JR(6qZ1+VQ zTdHOlN-dbuXAb!SL6=xh4!$Hj;rAQev{+|Srtc(4RsviphU*>hL^%v~Mk&>4w@pps ze;(U00jWOI9qitj^J(d|v#Y@0W)UW6JGA@I|8%>!xwm@}NbKk1RHX1)n`yj=pn)JE z&l%)k$ji+urjDu+pcx&8fWnfKYxLYfYSXb$1V)XJYcX}ogL|x~*-JJCcsAYay2(;BjqJl=8*rYf+;_ z!v2SAA#t$dK+0uZYK0Ql%}kl8cJ4L9=Daj*>OTFc=dI>IzAn<@SKaPEPDATBl>?@y za@>M@-|5Ynt;`aca&F^19X05Pgpw^uy=5KN2mWsNCpbA-1qs;h%Q`EO>aN+Wri;6| z;ujekM9Vg9s99))pQKRm7|UgaBR)WeEj=Qiu?vT(TQTOBe}9x^wT@ix)FJ(-!>gwZ zb)HBoO_}4+=JwI@?_+4aF0)vc9bdMGcTl^&DW7e!)y{x0!WeI!E)z>NmJ&uUIEd=& zZI_}qArg;@954IXNF&BqctGNy{C9RC?(>83+TV8m8dXXqc(H>&#b2W#2nBV-%aF}C zwvYehBx~bgP~}=?fVxlavTAWfw5Ljx^MG+GYdFCpC^m!AbAl~}<78D;kvPhC+BUFK zx@z5T2(8MtS%tC?cltj|*VQ!3a$WOZi=cz7#?c+z2lN_Uw}M{|WqeXUivK0b5}ey! zCe_#}lkiC}+O|NQj}}v(wLC?0Zj_pObmgHl0+wG3o5Ih&Id0Sg4m~-S*el1-atalwzBeu zFi#e(QXAKPhi2q5ydUl0w9KOJ-lJvcJvbNuYwF{mV9n=-miD|?_96M_xibJ;Gpp(v z&URzYhxwmu4SWFAjhc^G^mC$SU`T?4HFcm3-dak8X;8(~Ud1UDPb~9??n^a!K7)AY zwW`_$*DDQgj(|_gjV}vc-X<`N27$a<+HcXbyXA&SLf2+7Ph`7156WMe{q1D_C-3mY zlHFY480}pUguGj)1450Ej}Qh7R^tPvsm)wfYoz*I8wNg<$ld*LL{A8H+{LPs{Apkm**D}SCC`7 ze*o=)4iHUrJQHyyG$KiXf=7a?Bq|~X&&?<0gXA8ehP!M?@g5MB0dZ;CH3+woiBM$V zytVKR*?G>{ zknnr$+$wdW8b49LoH(ni=o_BAoS%6 z2MwEKOl-i;@@LF2eof9j&*^d6}G$ZJkTu~u)*tt4M=f2@%{)&uo*3bE=#_4;zak3Rv>#_GjWd0qsX|2>X!G~c8 z73_LL;WjN&zc`*<_M?x+%>75OuBwqa$^|y?*ND(9(ejZL+l^{-acv308>=bOw@E+wPmpk z{DR-P?Dj!Xy#^R!ywBryQ1BL~<7MU(_(sSIEn^Y=ltcg=X583Nd?)C4u*tZ?&;bEb z2F225%k9g;OVG)Cf99vK4hB zANn73&XniaOm}_Odh2aXR>hmWr#V?Yz$~OK17DzkBgzOUX0Sz{q2erm;=vtj-dA3xG~)yu!}xZZWh8oB;*0$`p!xos@adGFNj!@W~D5+HSo-kmFH zj>Tph+~~uR9~4aK##?Cx-|qHK#ma~H?%Qk+%L2yH1M*tiz3lvgB5`rLQyGro($ljY zdDTr=+rt9_Tg6AGvu#=ERFa6%PYOzt(&yVF%Bqc4KNsrls%1G<4hfPE2k@q_dXbQQ z=tor>?A3T&FiIMcftWtTrGYeM+ZqrV;CxrsvY&*f)B-@LWp3z|{G zz(7d_al^cibX4+w1qJtyGq;0VoFR}K?AD~uo*ZIQq@77%+~>gG@EY7a`qJ+@6&^Gc zYwvm#K(#7reboC#m9xvVkGymB1z1e{81HfU8Mcy`p1cW0QVvqam`-ob8ePnb@(T*iZ_-xyU1o2ib@l2(dACQi3hb`$o$O$5TNFUe53s;n zb-xj`*kmi%tX`t}(>i7mT9m(9AVIRV+npE2sx(~F{OVfkE88z8%N_K`}dyZ!YNGXvdf6o(s9fj)~JXXT_g6h?nr@|8BxTXlmYRg0HPRw5d7b zMkj_5O8bfsHq8+>NM5!#lJUdkVTV#pdg9i2e5mQFT)1c9BzkEI_P2=E)X}m>{;b-{ zXA+j$+}t9ivYT7i)w&Y5lEnrGF2939_?ivL?^jz>&gab!u5|>j%(7)3hx>dyR;y_7 zaOx1?MNzdBg_ z`BtJ>4#}KzJG69aKiZD~T!x zjcC54SiL?XBBliPbt2%)&f|5t%_>?2;DX)haPUpyVM=bqp=BM+n2Y4vMeV%_??@z> zLUbZ5<+YW|(#}Ejmj5&0f6H6Iy<^bBhR&ZL|7x%K&bm@#QKh6R0TzBjKlSk!ae{Z+ zTx19A)|X`NY`P>aOh6A;#DdN>W?^uAv0r>rK}0dI_~&g*T^=fA2~+IgeUtcm zP@F<4i<3tVKkFrCmCN~VW&WZvR$`h@v|(zbOPG!pJjz1Q&)~x{7qkmT1U-}#^sdh} z09Tc7vLg2)B3Xt`eO&1H2fM6L{cw}b$@YpMPyBCkT{(&c7Iw6vZBpr9nhwO?9d!3X z0Udpi*r>Db)|;{Mf^Nzmw5Brj{S%m~r7Ee6)~1rp;B^cS5B7Iw#&SG6?&Jt_+jewbM0)!4!`<`_CON&gVY60|?c5Fkw`i5a?w5E!B|KG!wVhI=tFK_UcU? zr|p?VFd${Q)atMCH)?4SVKQ9zxY+1Ue;3I!3Yra?q0)^D6oaf{*0U}kFp(i_mSgnUh?e@h!I0ZFq| zEw%73;deVfiJomTjnt1YpEaqvt}~bCtrl`ecfm`$i<2`f#@O zg3>O+K5ot}8$M5&dXlRWxjXi~7LH!Pgm5B`D}$p-fcU$YZ|6R;!WFBAgToo*Jou*p zpApnChGOD)rZEv+S~{E)MqIdx-)t!t{O64eRgS9nu%5!0QNAnmR%a)4Ur?8pnWlQ-$5 znN0nDsROlHP&W|h;VV*YxH&o;_s`e`8dx668c!Di`RwYtq3fr~Faa_>$?;3}-fCu~ zvUVYbpVyz<44?ON*%_Ql7LHYsr(T*)b!wA-hV%O@N%C21>oYG=2dcu%I#31F-la^v zmud`JfwYgTclSitcL&m`bC(P>qV{D*-A@WYq%s)&IyEK{CIeSQmtu0k=qkRgpzBqv zeEG42;ke9H_lOl+~$A9<=k5?`@k)cNt zCVakO#ds(@tRL*cT`w5}Nlvu6Ok*(og&-df^o|-AM+jnlu1X zc1Gjy+IQk$XUzJQ!_bpk)O+9a#9^8o%v+~`%IYg0G5=G-oZFaeGSGenY^og3vF7B| z>daE5c0rD1ljraq`2X^GUXy}y2Py1CLkSd9o%4HCeE_3M^|Dk<2kfAHfYaI0 z>A?s%Xm-}=p={pJv;)<&y7J9%IWseNSasIwD-ny8`a_h7g!&z|x42T%gVMkONd}Ts zbcUco03&#QBVC>TjwP7Ey3HNI)0RblC^I3?k<#jj-i)aGBQifEP4h?j9a;0N^xh+` zSW4LQ#k-gi$2}bc*h|i1nU#C>>k%}Uh&VH1K0DXik^zrw$Wg0NGr@Fx(Ukc4?!ufT z_;;Eh{CtR%h!M}R__U?GDGP!2Lt2x zpsIMKu$0x3Tq5u9rZ-S0(vkms ziGz^Chco7hzZ%`{IVt^KQP{Kgag@z`c#lG`EqQwAMrPmUg>h--1^8Y4_B*axY&lnB zWMK&BUKr^{VC-#Z$Kpc5tDPfK*(|$MSI7JPdNq(&rpKhB#Qf#7YtwrDGAZb4KZ(Vu zsbodyekbMDd8xX?{r2x6Cj`A?U7LVAwz6m87?X0s?O{DfM%q0iuPl=MxaISHB>=4C z3e-@CMh?$}><2jvflsKQvZL4(OMVY!8JukkhskglG?8QFkkKa?s5`ivcMbS@F zYG8M2!02)5!DEAH#Qd;!&@-I?=Cve7sFOQegE6Q5dGMYhY;qs*mnL#xzg)ccpKg6+ zeOw-T7xwJWxJ(sJb{K=d*R$KA*$PG}langjQ^)312}i@&Yb9i3p$OhcJ#)q4hx9{( zjoc7P(8Oq6+}%myGf7WimR2C*gN)XcUqr`;g#-n|Pk>3$lt4fy$3qD#V4D9F8HM4! zs?+_S&gs9uFxU^8sG7LV5V@sAvf^A%eZzI#WG;(k_Y`H(20ZgmhJ6q~=lW(ISudY6 zWWSEOIC?@7VUMfj>$n+9cQG~RQEHO+BX)I>m#z#9qRD?#X}X2xlL7Yu?KHq}$}lmt zNd43|Ds98^C7B;jmClZln+85Jd&Ct*@bq&$W!U$}o(?}$7hG&F4@^V18)@7P8Fph` z+}GHLLV{pp>E)#=r~Ca~iS6C$0a zAW8Wnbv(zMkA6;gALTgnBETT9A8a4oq97rO$u$$he#^fUObOTBh&M;#$qS~F?RVl6{9lKeJQeBE@IR>c^M@cKFF zFb4F|^p&2uOpxg%FeCn9U;W`{S0J@qWx}`n{iXZH4BHuU$yE~nMIFU}LUlG{#mHK6(d7DcYv@j!1DBulWx1RMPyb3#y`A;Jc#rmOTP@S( z@REdcqy$7t+v$wvSSu0b-C0VBH}lpywkE@}J%zf~C4csL#hbFnVF;`q4!rM(~^~9%zB$f zT_K$Am;CCtCD0S|g7!yP_twX4?om(Xjti%x0cfZq$mJP>LaZv$bf`Sp>GC%|A%(^# z&BGmLpC4U!Qy-3rz71s$!!|$cKjxZ7uTUt3Wamf&;Wq`S#{N%XEfScE%&*M*xwwiJ zPM3-mFg+G2;g#5MVck6$QH}|hS(Is$HkwGki?69_ChC-!bEFJpoyW5wW}8L%{s!$# ztMDx)8bv7JprTb&o`Kh7y5~QyJbTAwsONh@^7(3UVJ?|M>qn}8rSNNe?D;%xb8p1e zK@utN@cmi--V9%qMG~zQ&0(YWy-p;@<$`K0w_951dxKbOg<1ZpQMb%-nYUHz^4gSr zB2J)f3u?3?3u-a?H)W9?Bkld}*1`}A^PP@!+REnZ5AY$4z@XJMY+ALQFBKjd-*+{3 zzQA}!B-Wj}EM*g#{XIg0fg>aX9!VTxB6pd%^uSkHX;q)0J3}eCiY^3sePufmq40o4 z2_vs)Hj`F5so%LD)oJr3NR~UJMSPL7d|Y}7%#_~RIB=P0O6@{gQwR>6 z!31qeErsY-3S1vN;k$^bfTc*-U>H;Nf@}j%rr+6~xunG;@0XV3ZWGmW1cM+WE80ig z7myjr0oBrBbE1P6&9Z-LL*0HEQZ4}zZP&j?v`xUt6Tx|>!7r~^+t@pjKmYz|RlZ6d z^13ws)RABFKGmO?8KYDtFN*|IylOztR0(*G1(;)Bsn~hq2q&2L?(Mtyn^SZY2Zpdz ze(gw*z%iDZq*wKTFA9!u$bd+)t#qTwH#@VRhr0|b`2qbbUsy^i=It0@idYwO6id{+ z1&oaaN2T3g;hz;x>J6RrsUYK=OLss45QR!w>7d4M`%8ob+>KRK_H_hqXq%aT*2hX3 z4B?e}fiu)m6b6)H zr)1_x#@Ir%zl9hjwV|2)7G&>%V%$7BwAa@yEVj>l<087T8D+fTl(b(xeqcSf6CH%; zCj$6Q7nu5_48M-?Hyv{2%4DNI2R57%%ySGb_+8*($C`J$VeVQ0`j*8>Wx-5e0Gz!l z9jI7hhc4_s8DVH!{awYBMf}?O@YyIz)D-Y!mg3&60%qh*@vpfG%T&s4Obg4vEVpNA z8wrJNwe$lJ2n(}CCz|M|Ft!qPQBs3LgMo$oP}l9L*b)|#nKO@uE$7N1_%y?uKvrd1 zTIJOeNw4otTNeR(gbe$87W05~$Mm44myf(1=gMCTewN4JnwTHFL*B?P6;0{`D%d)S%Sj@c!KK`~!;yWKSZSa{0etq8%Jry~jD zH4x372{id9OYb(_O}G{0tJV_~^_Z5wCj=tlO?!l|^WycqXc3v1T!rX@2)+lMovx>B zMUP5d2vR6y;eD+LA0w{x1R!61&OC9M-317hA+3Rpyhh!_D@|{)+TNL{JSwEcC(}$# zBIr8umG`IJo7|)gcc69@7COh9=L6IFq9;{|CzZdoCU6ucgS{LmI~>I4JzuU4Z}Jzw zBaVtWQ6@Y{$FoXusTlSfM|SWjXu%tt95<1NPp_zL)W4)gcg9q2l6r)+3Ay=)j7(?e zq68e{muQ@b1<-KKiI;!qC|0e%Q2T+pGo7Dr73hk#tNXjbWu{7H$?kV=9Pnrak|HF~ zq)5wF}Rba83dR~M>qk=K*_a3vp%5=^!2g902pE&zC zwnDKBbGAN%_5_}9t`z}zDe6ghQAo!Zm$D9b13gleYwmlSr8C${z8t66H!*;oC%eaiQ+bx`jtg} zUubdh+ihv^E2dW5&LFkwy07DN#@TX*WPxs2s+oqaa(syBzV%!`1}4o9yc@a1A<3RC zmp;duFHb8e4qeIZt-$G-%!5FBw<6Ht!j^%sYXteRm?&fzW{3E5)dwJE;amLifzRYh zOg+PsRj{BOD})#(Non1R!L#mtQ!zj5iMKeLbgm?=z&@!KF_f#AUzf$&AdO@8l$e7- zDg4<7wq`gLp!xV}JoED$htb)OCAimHzw9ldbUS8-fMpbhX!IhXlp-FW^nbLOudx}F z@U(cw??o|aPgqpoO*xSNMqkMOCAPs99z|%u7g^VtU%F~ys1QNCdY3th`+mO8bV_1V z+_=OhHz2h)IqrD3SxNogVG3@x%R3luIE(}T0p1onaQHI3O%_@>Kx|R9~pkPW#sC>Ip6;8 z=RAdtVLXsgZy5qSa5YRzdnI}Epj`HJT1v|w(sT9gn|FLW@5T83y$iR+}JNH!YSd(H#lB@QAvG-N?N*^lCE>&{oL>U#@b`A zPiu_5pKlBq&bYYF<2vGZ{Qng)hHq&kiY@IvvS1i^o;N9^za&Ftokz79&(Qp-zen65 zqJul%V?T|ymyp%+Hri*$j&}A`C^MM2Nv;DW2j}Wl%;pe(if#%oFbcbzizGEUNXQ~= z)_;bn){3$@YNB^OJ`vLyGx|`1vf5Dy-g=F8Yf{|YV##e&^!5{E8S}W)!J(_~TcPFL zGsTpFINmZpeA!h;&?TMniw+y3u>SnbO!Z` z`NkLIx)a?#(D_I_{n@^1!ecMzU_X`b4pAuf`jUPvbX{m)*H=BIrb)T9P4t4eGm%M( zS{bQxpN)_ApP8%h+B?k+eba~sL3@B{?rRr=Ni%V^Q%`3;Ehj0|a$qCwE}0&OCDU$X z@m*+1;iSK>S_`$PsI*d2NBMsT7QV6>dux9iox}PegPo@ocLrWOd1iL;)^4kQgn=hN zbWDMF8euC@YtvpTI$}qqOw{fX8Pat9)6+K|{t*0_jb~1MC~_b4dt|?>RV!IWzC_A( zR4LN{{xQ9&6)6T@2+8SOu7zk~{Lf!UQ=I6KZg}^Gk$g{RVc)4bSzF;s=6?>-o};Ce zdHbgoon+Wa_00Ozzhr3*X2VJNmBi7eU5v_E7HYz4tg&h2%yYY6dzsjMiytF1qKlf* zJznd}yp~m%J@95|rRTuCa9P^r-qn}fU2$oz8SGzsIyz*~oXctP1xXn_2wgc(^8j|0 z>iDAh>!#Z@BSu0zzw7Y~p^fj1(X;1xMk))9(6O~IeXPp}uUfnh zv?y7}rMK7E3m&$KKK38CTKLHR&1&athr*KJ6PJR4AF#{K-1FI zsT0l7^fD-Y!B_GC@enUWp?n?4t%kR{PFkoj4R9=0IH1#Pyj42jA!gtO5mayoRx5t9 z1|K;8i%OZ8rIe**YL*x^w(pehy(Y&n5?215o|bQ-Y*q*0ot!4zq^kcK(iwEVbMM&M?k&zB+5Nj>6aiL&{EPvoLY!Ti`hE!- z+#I|n2WNSWp1)fmdsuzk)L4(kl6};`;CGA3mLOoFS(vol(FLvEksV-8!Efg=W9WL) z#)D^A^%#~ufQP3mEaZEFSH1V4-~0Gs+wA@M51YcruXoi%2UMJ(nl;ry2__WAguUy`f2zX_ae4oxSh&RkjG2#Ao zK4WDQf;g$e*7#)OHE5^%dj5FP zsQnubR2k=5JYcGtxl}e?TKr1N8i+O$T<>E9Ht;y^fB6WhK4L0r_Ewo(Y|R62`Lu|E zmlYnVxd<$Nm&%_{(8wu?h%`62ET=4b6BQNjck_KOUp^=TA=z<`+g+7^YOmgHJ!-nv zA+Y8f)UUP+vk4Cs6@5hm>yR?| z+nL$*Mk0#!*1>D?G;KxF+BUqUGf+enj9e1GeDOkIsmW#8Q~cnk^>s-yXVXD_Q_mvj z(q_vIqDvmo*D^FdS{wz}oz`P%;$|a*n5scDB7bzs$Jf8Pb~Pzui6)z{-5Koy?0S?n z{Ah91{^5Qjyu=9!@@hMnPUgJ$9hbmiD+LRFeGfqJL&LF`+;uyBuAJ_D8KR>8@u$kT4x9T-|XV9B2&`{@J4?>5}KBl6(J~3~( z)b>NcCk>s4H9IU8XYqUkiy@8chYin@KAbF$rYW=^nU6UFJdL6k1> z04Fx;R4n>f3U9PvA}@>wIlRjmIx-+{7pvehA;c*&@@6pJP5h}v@y+V*2Zkup?%r!A z>~6a*2SgHAEqw2MFSs3c^Fd~OhG_4LpM>R0F(AQ-fkHZw)mFk-!o@Og_}E3r)CiT# zzATY|UJi$Vhj{d7T18_s1&*&5{kbv!pbfqvAh<8Dtd~ysZpAaEsQ{f=G~ReiJ#Y`` zEY)dymxZ|Gji&Pv*Vh;E-?IM3pJ{Pae93~Y_g1+@I{ITz|6@f{Fm9=!W*>4)w16=} z)Y3^&=Y&3$;l!`@?x%)>>yk()MtB()?y`E_3^g5-;#Bxl_BV$d&## z$iffSstskTBSA<(bnkbdk}_nvSqq2-d+f25y*bTwQvvCu=2&>~tCXFmtfxrl2gsDp zSWA>xJVRrnNzH8&WR3fBx%xhe&2=w0gklY|=i>jtE7&%;-)}E&XI&YasWo5Hilx^m z6s(-+U;mW!oCJmDqX`})UY3N>svow=I=?1!GyAaWm(4Ifc`>_iMYzohTMbb3m5;6Aw;? zklIDQ1t($)EGYa^ra9J_xh=pJp9Foa&dNg~0CI?uG2cD5pG|}E@rCz^ig)J5i0St? z#9S__(T$9HiI2IH2zcIOq1zvwTPE{GVwIb=hw+o*oby8IEPQpJGv~^GUfC zG3(QqTz|ab)rD}FNQYi9Gx)?E%+D&O;S)(4Fh2}*O^GZwcIoOvZ)%WP{0myhd)?vg z>w5qK#*nGw-nOCRntZ7DOJ^NDF2~E4jL5l7(6gJIXdVax2hhT}@ZeAr)(^6ld><{g z*i(Z6yaviKfet(^?YmX{o~lcUWb&3Nqum~99Nc?74e)rp6QdqEwYlZPuffP-L#eQb zn7W9`wab1XM=EQJZs4RZwbl&ef^S2JTNHt&pXl4PwgB&V21XN>*Zktt0*n4T)1_`3 zBfn}_`4LS*q3$9dE+DFFs1x^+D!)>}@+?X?ka3GMt|bAp`Q&+GUJp^v%ZYTn6xamJ zid0|*@D50~WJ$b|&zy9P(w++HaA+iD>)xv<(ev+UU{zm3_j+1zuRWQ~@Pl1?HGYF*d>QS#m)CdK5;QFN0?gur7jb%o;# zi)w@lx=GKEH8B__x0#0Q*kTvdPE0p}^-TrnqnnGGJW7_9Os?WBl8U+RJCeh!K91=$ z^bbUY<|HA)Vaplyj68+DT<>@lBP>roWmS z{p4)A8CD`&HGQs@%nwH5zxS6rfKHN|dHId-SCPA{nGBXS%l0Exh`js79G%qIDIQjk z?s3j~rZ5_X`-bu;!ZDXM&y2onWIo!`D&S0y=LKi-#4X228>au>aW|b(UM{<)(4T9L z8$*A@{zljQ@G%$6CYPO0u39AAGU<9#WaZRX(;EyxG!z|merw4Mn$|t&^{f{8@V;vE zA`d)V^3#3_<>5#^XCf>4?&^mSje~+m53F334!#-u_~89HRku^T&=#8@Ws0rTaqM+a%lu^&Vzi*vH?ijb(!up+}5R5S9a zGW%g9Jf-{>O3{rNL5N{SMlmmerAr=YO)SS(8E-mm?pm5tIDMs6mx81JWXVE}M6XN0 z13wPnh%=)K+t0q$d-I|z$!vOc4&A-CZMSPjS&$vQUl2?#JN!kqKJW9>0&aKeN$E+Q zs67ra>%9a6?~Ib}d-D(39*?mhAoC(HXrE2>k6GQwP9ldsT>KqNdNW2EL&E=6JK0|o z`Ne>g|HeHnDRL&lPHr07s?9jl=#fP&tds}DKy4&Vb~fJpkLQefu5@w^(qgC<^D6g) ziotgNZA=MHNSOa-Kvs zOA8#vGgL*TQ|%ZT(II6j4s)!cq`ITg0xo;5z_$u#juX`Rh?9zA~OGh zsfQxV4ty74^2M^cTuRi#j`}x=Op^f{9rk|uc;gQ9xiXPcxVLQr>Q&axRW32$TtlaE zbxUv%CqzDk9j0uzU7!VLgnYu|$T+9#x1&iP6@!I|vXA_|mtrCqpgCJkg1R$KR_d6L z==6@d3(fqk`>DluQSI?%7TI7Md?C=(I=q0y%yVhO0T-G}CYkH5*dp(r=1_}aom}YP z4#+?Mx$=s{H$?KhKp4fOS4I8d?h(?E1RrUxueEsbOsn*V6`Api07 z34&{SfIex0sIbq?)bP&Q9@|`9{~Y+~#!J}8(nL2@V%&Iqw}j{?$>fMlsoGKh&#s$KVy z`AS*Q$$e-%nwA@&?W+Qzha=J3qlWB3zV3%#dOp@7G}j*d(?mDF>z=n_*dI)1m7i31-K)`|;H1 zKn=}8 zeH^7zux^lxotQ@EeHh4%-fg@0F^nc}bvOsRz%Y7*G@Bwl-(S--%9v-&ho)`4s3m@! z=y7pH?a#SF~B)zWIdfqPG~b~4+?Sy45u2thy`!}&NP*+H^F ziGn7QfsBSNEg^AG%;9S%)3nUF25wAqKBvf21t)_GqTKL{fIA3zMcA3GuX5YY|2tPp8)DyLr+ zT9qC(=t+ASZ6sXJ4!`1Ppxbld=i~j0o~EdByTV!m2=9bR0!Mp6M~2YBg_?xDtd->n zvnvDMl@qCfnvoPH4+2|Sen@2`6Fu(z7o7_{l&N@9ifKgG2eUbR(;Tl#Bdk_4m&Nn_ z9i9>Y{BhB3Z_&kZCECOiA)u}UmLZNVtEZJHO24{nc;wE30|mmR>hFir%`AsF_0ruNp+$U(^!+5+!#Ulc$IY zPRV9nPb#VQ5>!hlU};*^<1g{ip88h=5(MF$T5@!m-x$%RKLEq{%AU)3!42 zD*-cg!>nmGacou)p8>S-+yOuvT4NfDaC#QxQ_6ta;NUSE;7~`v4x)ySG7r^s-s`vY zJroW;M0kqxwBO*z({p&nBN^@r)~7n3;GP?8Uzwh?0QuOw$89!RQ65B_3I?PBrZWE9 z7F+C2K+=;dcc#(ZeSnpEF7zx>H4oWUUVHPwo4CzA&pDaq@0S_rg0i1QT{kJU&ee(- zAhAuzvSH<9s`{H)5sf*60_QXcu$m{{<|#;~jwN?>tjwoqI=b+U>dUlStq_efgEdX- z(LOJVcFCo36*8otqd7*Q=uPiZ!((Y=qrC?N7;`fT4y3sr1@|&XH`HuB4wZJK=sMaB zDF7?;^=BW^hLn@ zEgv5F796dH)bP_Ow}y_^?C&h?kbbX&f8svHF+eskFT)@5mmdvMST0{FJ>m*ztXOSG z%5TK~s-@PWIe>$IR_yQU{oO?1RiRj4D_Kw2uK$!MOqw?f)_FLnW(gx= z7xQE?N^zFKk={EbP$+^ArE5YV2*EOz!U}O}4a7D@Yl=~duzTF4~76hF6AT}VucrfyTIsmjg@LPx;$=dvZ{;oy|)a>nt- zaJQ_u*@LpYx@~qlQ`2m8lk^ssb8YYNubl1L2ZqPDl<-5Tx?pWSbxOTw5M>7mfsfJb zk)G8+53V^SDtT>cQ=i8?%|B`>*vVHwqQ_!$jGZeYDjARgLuv4ktU2AEP$A!s!W`Py z6Ki3|;=vbq1aFN%8~+R`>}iUgk1>iC&T85`zcHzl2P?SKlU`~x%`Q(rsgW627~7@!e(sTRg#QJ%@O~DZl6=Q?>yrkSX*1v zg1cn>=8i6**2x{_Ge$i?jWZ{tBAy_d+oB}aqRQMm08LB4NMrTHx5nk&oZ=Gww4?1f zNTMWm0y2IalsZAa5*8swYjgLtI}`8FYqFRQIQ+)~fPL}3irZFtPt`i`ftk}#OZiiQew3h*jAAK2OW z>2p20uo>~#bsHyOb4u`Jz@wx!DN*Hs7xxdGe*?R8s`~YMXLG9hU|nTv413z($%v<^ zku$))90Bwvz$m@>Ljv(w4A|6=-+>@6bO&PoiGfu7!9XUNYalo^M+zB}iv^OyQhaa` zvKS~>sON8S{7bEVt>pLMQ-+2NF8LnsYvbL z^n0$<7BqUrMBK^2DejRQBY6|V>Gr()Mp^W*I+#Ne1)gXw%_)Z(W0N&fx`kNxtWVXg zug-WHty!6sTQYLz!Jc)L%&x^V+^NzG`=}Yd{Icnd0d)YHj?H$sM?@U;;kBM&ly2H9SC)yJo z956P5MU-|TIks*lI0*fsafTX1$~TNxFUwMP+NA4ijh;26Y>Q`=NT%5`uV!QcI|7yvP7otSi^~WX zt8dY5Ut*53NN>=qX6t`CJFOJ;lxdlHTFvMCXkg%GeND16vpQHK38R27IWs5kPlI=Xi4mu zcY0`GPB_emU@5+p5S^8zC)*L=&awrsQ6+9WhOH?iR;olv&E+KVG%v4Uky{5)OmhT#4PI2}(+fN*SjorrZ_`yG`VxU_WZEOEov3jEv zf#wed)9q);-d7K@XDf!?CS+b@aA7=;;9ecZf|z1Fr==I#m2bWAten10?-$T=Qes`iN?;b3v9+==ndmgQqP3|{e5ta1GfuOh*OssZz+xP7a z8ozW=l*BH^Cv+pu&JW?I>@Q3ht6!*}>2wv#ut0q*!P$NvHXHw|0dbbP%zRD;dt2d? zd?Vt>->iw@BG~AEcd#&9>|&y0cwNDoCkq6nWjQD2bul>K^d_@V`#gXC5vkYciI{ZX z-@u7Ye5q!0W5+$rTzA|1Muc2~7?~lg{_72DF5|^-Y{deM?=5P`kf@}8kv649vfKrw zVN=FPE`LJIFu-h}eAVd5)jhb_Qni9b`oVOI$$UI?lRy?;p)%9*q^5Qx;fr5NsWvpI z4NAA(@fhv+#-h*B$z%~cf1WJPk+CYxAz)uPTg(-c7-7y8%a16IDF>g1h3E&2_#oSl zLB_y*!i2Hk{@NJh!hgRmCzu%GZ%vx=6#tdy@mHt}Ywt|0^-?>EtNtQYcD)4s%XflL z1;!xU6ilt6T;b1m;D_1gvEabe^)ICE;tr|1{-j;lJXyfn9JaB>_8u$zc^6u&z93x) z{J2`w%&fm@0zN)A1t3+75}?~N+M{WFvHiDR;NrJpR8Smslkp7b{5V?ebsLt-c}|re zuYd36hfwM!89d~J7NkGa^2H}^AwA1zMI5x~!r7BIiil=UtJ!$-p@?~^?m>qb4|mO1 z>&=6lhTdu?kN0R08%!VtK64sJ_OG|Tdd0S7=e~OR&QXCYiD?r9<2@RQ?i;7&YkW)U zD;S?5J$A>Z*o4 zl=r8$mM@t64N`)z;~E;Syl(2}`=M37n@t|J+ivvLq}jOHoAtL=r{51`ltcVO`d6&}9XjRi40ag?9(ez#@l@ zjs`>yQ7WggfLO-&k;fuSQC$o#FjS%NCy8Xk0kjm!zFD*)0BdC7vmix)%Z;vWTP5Q~ z7kd%`?_%YS^F_!fSiWnpj%5H z-9>*FFu&ck|7ig8!rCEl3-x~2{N_#d`9{s>8f^$mw#dPc!r`S@6_8HJb4EBb&o}HUkV+6&;z5g@%px4Yd+}6Z5#q>k}03 z#kf}e#htC);~i=KfXQPbnvqDzY9!2RiLveT{zqgddm6q)4ENRPUfGqS8Y?|RX&^P8 zCo-xYPP;v)Wsk)s+0%oqO!%v6>N5T}w)mU0->ZDNQV#{W4f+moSgjhDnIy$j@0*<1 zkssH8+-{=6JNxoDVV2^tUr9*Jy8Z$|2YW!EdqwN0ZOct1LlvX9xnGlBl82o+Bfd={ zLuWRDY&MhYGS+pRjts{Tw_&leidXI(AQR;Wdy3Q8flUGUO=E$J3MZRy%Fzvs-+le9 zHOF`gjae{FKHM`()Wt&FqxI_((b2kG>(?J;V>O{}eoqdE{Jicfa6P}B?qn7BL#F9F zx^ag-ON>Os>CaAhs8fzyB2s77on%sXcKJkYT3Q0TPf!2-LXC1QSd>KZduU(9G+a~E z|IjatY-{XT@Nmd?zRlsF@kJXtwJULr_z&kr(^VM^a-#dvPry3sZEB!E2+!U?I#%u-7-L*E|cu&-0>*YDB0*zXqAl+Qkr z;2LthOD5r-9WLChbGCvNjWDwWX#_Q5m`{#{m5zBg;+c{;k{vyGh5D9F${6u^`#BU< z6qFK$+K#6dv(Nf=?${Rap5WXHM9#eO(LGM*z2E6@Z^xE9!jXYeyQ`GFni=mGRmtO( zv^kRg*BSQJ4|nJ|f7;;Oak}>~e_7o<`LzGdwEUag`Tq$L)z4mW<#P67veq*4a(jn; z`)r3ZObsP^-n)%#A+A=-5W2tkmQjm0Tm8F)D=_TOoHo=8< zTIiBh*6g~frs_z$38u}ZZl^!Da`-$iMJFd0 zs=ZTD**O#H^uMfz(hUwdka~{gNU0ngUqSZ~&wYz_y>5{5f%UWdUX$m}2d`GG#>$PC z&7N0iW`~U;INLxo?$ZmiiJs2l^IOe#?!VK(Tj~}!91g+OG`6Rj8--`((s|#aqMmK9 z1lqg(wk@t~z=O(nerPRgaHN~E+}YK=P_5hCj+N4y(|eN33f-TOL!CO{OQVm0?rtf* zD@%`byZQWs7+}zb^Uw6xM^8P?vaS#kg#B7nBuJTT%3r*A(NWr9zZvIN&`SO`8zym} z-3q6r%xhhD>Q16^2_?;k)S7QuWh$zuCu1zVG|0l{2*fV|4EhO z3(nzAmt)OlkMuX=b9Eia6@_@wH~n7sO+g5Bui(+pbf&&IySr6EhtG3qAButCH@BSw zk9cUVe#|E-ZVzo?>&_~U&}%ikaoqjhFE!U{9p7Mor95zLTC($yrHPC~iq;1XZoV0ui8ZrnN@+vQU`jq;fIZ>e`Jv_s=>EufS4U1>G zU<=s$pD7!@)mrWvm=sX5{0MPzb+TH*y5MJWWhrxrbS7>=26Lp5x$sN$Y^%%SSG2Q2 zU(lv@r6dQZg_1d5o9+Gzfh~n(jxujUTj1Q!I0d8m;+ygY6S+(acO0rtVW=r6W?Zm+ zhGn+{xIDv_K2#nXdI_u~*Jo9BBnMOtj+}YUt)C>^{k8vtfPQyq zhf^o=V*%(~PVKCn%`RyRTYI|5GJOPuD(rte&U+PZ85O*6QI%&nacW^1*`ZTyUT5VC zk2@65C`~|B&}MM}7RnR{dVKB~nJ_(hEkJODxIfvL9LaOHqbyy5%cyQ>)jqcf+q|N3 z&mWdP8#Y0eK_6Y4O7Kq_0?Z6@Y7OTF%j^KZ#ES}e@-!arOmFw63BrGAdc>V71IS$B zn@ynUTn01yM(=YRwH9FdRA8;ubSP~ACVG*_FN63xDa5>fOj;7CXshJ0KVwgme7^XJ zd6n9%s#9pAy20tJlG`r7M<9;Jr6==2Yyh@pHTJIYLDzCz0}#sE4{M8y)o%B&Pls&un%IYidM3gvxtfp0f_0 z)VdfQ?#}38H?rm_uP+%t@2bEiKDs7U{c-=C|R zV9kSKCpjHEE;;e>-hs!}Y%m&wq>sHqgq@?F$K_Y>B{#t?mkTOqx>A%PspY_w}$0YT{yempt8p(=WG8v&5z+@nXz0u zre@Yk!wGKd;b?FdX1WOqc&*^r=c5sW(e<#(jeeE?nG?!j{M8wV?#iiPS0rU*mSX}+ z8`4!Ow{)wf&Oh3_aamL3R${c;unxMjSNLsfI4{x5<2WTgesXt<#_!R1GHU;T+U4al z>W~HhOSc;P-nfhvoGHEz4Ug|XD{ft-UxM-O4yitkX8LNK1896LLUinxK?%h>;cox3 z?jMUFl7g^NF$!o|pz657bEzSyIthciXRkrqd+$9)`rDsmVm-`&=l6|Cxz-YlA^y zICB_jHXk6Vg@w47%qeI;HIMu-ZGmb~*?jZmUUQR>DDhIBp{oC!0d;~EYN zd-RR;QXcp)9s<6t-&BQIZxndad^fP8to2li*^f>Jaj!J*^>R+SJ74yerdln0dbia< zb!*CX`pUHA;xpKiCLa^)yoiWIHUAyK(wZ>sKZBBJs<=WFVG6ZP49YcaeoOs z1QQol^)G-&)+9~3nH^qxrf*h#MbvsYHKJ8FpWCnLgV}n`$*IMY@O4>0!k9GjDWcFz ziFCrds=q)T%ZFBnFNCz=WV3dnYum3=Z+EH6j@(P#MUDyzxB zMTGYbhZ#xVvYqFTI0GYMA@xRs3vsWSdPvorJJzKJy0AEPQAu;#l?cji%^K+ zex8v@VUua*bV!&+4aL4^o@AP$=^1c9+~OXQu}KZY4yq`c>ck3P)MXd@qwSm=VN;tI z`9p_u(zZNioN3dcdz^kecUL)p3^p4L`1kI8Zu# z4yn=-zz0m^BB~E_;#KwJAFKRk@D&Nlp<9e<`xt7j*ugnNfN*tDG1yIp>;GP2EJMu< z%b(4ylDF9Yh57N6g(BRg<2`%;3C2>}0QQ^sQPlS<{t@u6gG}JPW zaw?*^w8o68^7ImhJKT$y)4LE1Y6*CF8FWWWEQ(Tys-~mFj`-;Y2hKbwFPtL$$WAIRbQ@NE8Y~2}ya8~b8W&_M9cooxTH?CS_`2O@-|7y+v4otoVFdk{# zCAX{5awl*0*TQoXU&S4bjxoG6>P63snR>IE{pm8p*Fi%8?xetWm2jHv0qXpnc+t5a z(#_Q!+X58n$tYgtmwDjYHIssCasv=dGNDiLo@-@qQI7eMd-?xT5}{=LTS+9t^8r5V zGs$$xsyY^@xwm_dHw&FU>SufOze)FLGkxUuMsBL&Xefy%deq{Ge{c4I;=~m-?dBXT zCSmYgSO&iC!QH!u^i>!}b*u@FanjAnev$$|KH097@!I1LTJJ8NJ-IP@wtIiYeB}Zq z+GELCb%9s>Zm$R#MvdizSeoeuq*S;)dnlZ1H<`s@f&-*7BD?Z^*W=x{Zamk&tpA&= z2+98-D4Mo~^t>XO)wwC)@C~z}^<-3YPd%BRpQ>ErsyBSFQ4O(#uss zbl)m0E_!A_1VR7Oz0#^tT|%-;lnRmBEf2%qc}4CbH&r&7rvE@P+B2-CO}|IxkhzQf z(B|_P;TGi*G_}+#?QUlL7ew|yATZ&%M3j2O5A=uy?Zckl4AP3VC z73(C+MS4d3Ej8lv`Kz9n%$?52D&z^VoNRBh6pg|lqDP0;O^z1U z-bdgWnxJJH$vU$qdeKnEOim_alZC}|-Q%N^w26L(P~t8%y?IFaX{Kn2yMry^eDlAb zCYjx_&qDA>`QBIY-cv^_AC*d|-+5m-dmor+I`tomD`N4Kq53Z=DzdpU+pWxCIs%o& zgYp_I;Nh(f=$Cpb;vm||#6X!BO!uPwc68B<=VOxXRlNh?%IN>FFGfR2orX7mvoDy4 z@L|TsR512M4zMo;_=0o((oDmA%QpnsORN&yjBy(Tl3#T$gEM|&#ON=onEb-rCQC&q z$zq)@zFiC-Dy@5PpugfeTGI!mO|C=%J#K6p59jPGf_W*J6iEms zZf)1Ca>LOSF3F0T>bJ2)Li(YFbAc)>A>P+cBO>nlKDy@-ICFdlh&*D zv+y}1sf{kVDY=1f5fiDc{~}(9$RqL(m4wUyB{cEjH1+KzT4_vY(=X@LU7{9i&xxfo%*f>7XBkuYmZqn(Q@Lua5@+PNK;e97Fy`Qkye^qvIjtGALKKmLy z4)0@oC9(O?8VY1!3{A+~&94+$ijZHpca}il+vxrOkU1gN_&rJo;MGi_qEM)0Dc`X# zHjH!W2b>E`ht|Bx_=2iHExc#x|p0~IK79#yEfY$LTyWcU`cock_$6wn_bJB^CiZrUVOErq&E){|j{-@DJkHVk9Ih1UG14oZ{bs|7&8)QXYA5SZ-yUuT{j4F}BV zhOUFVRX;R`H7NQ_ij{}Qg%Q#C>8>zS-$&swCjZv$EYiz&Yv!ox9G+wcueA40;7lM} zUxt;X2IGG1U}a7&);B+sN72)sgDIcn)(yzTEu&nER>1F(RC{1NzI9fz!ZnW761Y?z zG(z`NWZ8yKfV>(0MC@;yf)>CpjU*a06VMF~zED-80xzi(FAJT!SWse%+}mK{l|jd? z@osaKnFfi*W#B%oQjXv=V?MQeh@+L8@Efc|nqx1}GnYLYZ7~6Qwgvs~6~+)$W+um@ z8HS$Gy(~+Lnpyv{w{rWA%;pR~Q;TNeQepW=v+yEFZlbk{${QQ8Hh!>El?z=i7! zCPuV;`YWN`M^kq^gm@~LJU|4xqSv}5WbRW*{=}vyky+qyg3)^9oYRTB9Y7Zzv7aR%*Ei(Kq}v zOLW!nJJ=Pnm{_8!P1tb1Dc`rLF`Kk~rUU|X$H3%w2(iObG!hg_??$6~4iSXn?p5xB zio6=1dWLsi}w#dDtUJ|QNYL*?&9u??lP||r@h4;6UqJPbwS$C zGnpIv3!f(a0;TQ~>Y~R)RrjE2%@}!cO;5-t^k@T*8ab1Luv!C=7rT`|1r)_+nDVj9+Qd4fPl& z$VK+$!1LdUiqguTnmDx7fCg1ZKhWBE`Wy5(zhLmp4V@i#qip}G5Xmfw9M;9Zg31co3xf61!uVN8U zV!e28RtU`pP0e4uGm&cr^1wgHI>@OF6LU)FpNc6G_hIcJtv$-RvVIL^xS`aH|aqenZC6 zo+jh@$(=a$7tIpBmnWZ(;;oUe`|yBd$Ju97@19#R(_6 z_?(0&k`epQlpiLWVuQC=OYFy9!6YfQSxos59YfS)#fKFk^S;jZ^~SKd08bj6i#@xm zhUZ6=0bKeMVLKgCh^h;Q$N4TT8ecn^i`Le&rCki8H~_P7^!0y|*(lR)EJPzM=6LO@ zUX2W4zz^R|nc_`D{>Gb&gRx3>7co0MC7yjU(yaG*ODy2_{4n)i0U^Yo@MBZkw+Rm< z4;35!)IRGSAe7F_n7vinztOb!`pGC0b*ROc58Q`Cf`l)xxFpnrnqUZ#wH#jolk@G| znUl+Tol>zPBmJIh%RZrH#vf=c05Ojd;WI_==A_Oi;Jer}ZzF`D+VO;w?Nn{XUK+qnw*{0h3pKmt|4U}2&EQ}B zig{uG7RQ}*Msi(-M*H6~EAtiU|CCwjz33eShPU25_TS-U>M1eOpsgjzDUjGGEzjzn z^Acy-wbp!M5+OqPiEo&0cDX=erIlfhFxOl7z-M=cddNj=-$bt5Mb>TGI6oato`FWT zLt}cdl65xzKrtmG3cx#i75%=ZtDp&AGZ8JO`1%K(OoL1s*BVgjUc&;0wqf#YX)=!R zEj8I{n(O(=lo*yDm$PIQqrmpcAC%>9QGT3$8}X&UjW6x{)YpMPYD`&kLFyCoW6zHw z*mdpJSDV6j3oa!urOLN7&lDbYg@SQOV9!{=^1)2wMbS5C{_f8=5R(jfkaHjCTQg?S0)#UD-uiI{y;;61RDQL9=t^X0Rt z4oThHWBgd;ps3Qo<>WNG!Cl|2vjZblPA_BB-BA0^id3rOJ5JH)*M30Q$s*3c%I>e+ zLGzKuVB(R^=w$$_)EJS!Q#UB>#--K;QZ1*_%ZHzHu>UF4qOTn!bXAds#GY$Ce7Vb8 z&oy$#^cY;%aY2d1cA3gQ7+DP^Ah!g`yyvJE11c?S>YS7(eV)a$NnEZlTIEwA=c^ZF_CAC_xM%Tgn4O-uH>O?w^S`y(;m%$bNc7B0(S9 zi_Y6KEFw$pG9iOK)@ACe@(rxDX7SOquQh`0tp4uo=D+W=Zi!yZMSNm+vc2LpXLhkO21asWxAQ&)Xs-T>crdRN$Oe?utmV& zA~3QUOM4c~i?ER+m&SW8&C2|9NL1d+HardTRu)}`;UljiUBoIFt-fZV7|xc}ivDY$ynT)j?Drh3lp=26KGyyfLn6~Q+IgWIJwm8ng)2tvhWjiiP3PLuw}4QBL=_f?#~ zkCJ$?rEMDn^DUl5(b%1asiZ})5*6k?+v`5+lWt^B^5G#H+QiUvY?_j-MNDV(MH$_3 z&FtwNJx@qy5PJ30Z{3VpL-U5Z^4i;iMdzo~uzrW&jySAI9rRD(m%?5K@iPFbguK3Y z)F>8ix-U&*<-hb3&VHT=?(L2Vs^w=l#u8f6Ddw|DwN8umZ&^WUlD4($)#p2~J_P2C z#Q{8;?nv6JL`&wTv^x+60V@m^|NMah1<@oBO{wNauqX?eoo}1^h?;gzlg#2*YKdz3 z*v#2nt`j0-;$0d0y!~5MNfx_Cdcj4Rr?svw-EPyLu?B|k)F;?$9o*@!T*#65f?nu$ zjYPDapGZrvrn!l}>^*5!a+V*?95S7dQdDm0#q@?^jhoK4ns{4V@`_KNo}T=X=%Il{ z2rr6%YrNcD$$u-^{C{4rCrSgmF<6@|$;HNMsU^{lctCVE!n(yZ69|o@+VSsZDCUh60{v30xZZHm z0;DrX+F8R{TAC!ZmYZ{R%}vg2Fy7rxymdd@ekWhsR-H!GtRz{sSZKWjw++-^w=YhN zhmV)N(zoqM`7Gq~>7*aG^f{kwIG*h#yWA|)TJnUh3YLB3yo7*hZs5CkyHfMI{X+5Z zG%X0=l9e>~ZemW3@7=sFJ~{vS_KRCwOg;&pJ8d@DDR}4TSffP>U|l))*2*Di=r<(I z28T0-R__BV9n!A@`_tY&X49q3(<2~1{t4Zwp)(&DhuNMYw(^K(f^Eje{4X4qyhCOO zOYZ=nP#_*o(o={sI@Bg~P-wN#NEdT++K)XsCSS&&#gzvo?R`YAL^Sj|SK38=hY z(yHfDM|szYfI*+ljn)&odCtX>_+P1GOTVrzH316L;P%Da`VY2#@CX*>(uV%`c#4YU z96MIy2PAZ*$@51@j|%!le9RjTzMZeFW6MH&` zVA&x^tCl95w8&DOBU?i+KGlxp$B(dlKtBiNSI6yd>tNp8q*e z0MxH*qi8R!WYliR2qWcN&8^$ciU^M{?3B0Vu{U9ugJg6=)pSKV-o$t zg)YenrSz5?=_q@5nEJ+UpU{muuJ!$q_hfQ+HG~=lY)$(Y>`A>+_WO8r($Cet8DfS; zqy&WJKKYs8`dP_gq5pUCB(r<&U*rj)ak<|AV(%@(vi!QWUyzmtrBg|f?oO3P!9U$d zbAy01Hz6(3-60{;-KBJQcXxO039hyFTJQ7h_sc%^aqJK8M-TMIdt7slYtAu#<2(!M zq1#z}MeYg|)e#poVH0{|(6JqaWW>oCg^9D#-0@%Pm>0jx-1Wxa@zP#Xbhcm+)ex2v z%`bmBNBFAdW!%E5YcbwX|4O6kqvxmWh;Dq&4+41d&jy*wn3DvYFWxJxSxCi2Hgi(h z_l5=XeHglt&XksR&gDJsj!$~^tay7M%`0B8@b&IWkOf7D>S;sLQ}tttuzk~k%hWPW zSB5mxKcDdTIy(w}L4Ij#;7vRUnoF!U6oM`5MxX0z@zPi%i0I zp%J63E2C$!m%29QJ)&}J;HK!^x0c`{^V6bVXo3U%yCDWG)LwKT}VVU3yz4lWG3{%B)?cE>vw@L zK7)y4x+HUc`OagV;FIob{QR5hp1AeetYY&Y<=KH{G-^25w#T3qd7=ErS!^r>B_4HM z-X#NlEihJf<5zErpGi=9{^ajcPvaK6%VfMQ4HWjFOGBBm9DDpm-s`iSlb*F(?<`W+ zTA<1yFXi^DbIR#fTX29Td*H482rhjG(h$b6>`Of&kqE!PNYK!OQ^m=9fCT+2?woD@ z^KK+t_|~bpGl`1q0X-4j8vAxCzx(8fI$**l&dhFb%?IfB#OR0nlJ7#E6F(WR=xCNE z$Z4W59f~CUWz~3x$R+6bAjoordjcl6Oj|5(;2}g@H~FTjv71Xkux7N3FbEgBsu55q z&NMPd+P3B7<(OZ$7$WJ{Kppqz9P~dSl%^sw+5f%!%(#~&Jxjj#+kP}5Q7Q(m{3x}$ z+ZTd4#Sc{+bc9D$7K>=B-x)c{Qpvo<4_Y<5Z*7kIt%ggD~uXtGww`b=l@|*GlBjrk3W&eDc&8!+yIP?u8`aMJ%VXd zj~JG!5MU)rI%gMOwvQ+XUB@{S{+aaq(HREpFhcRQ8XUC~Tu7s(=&$pACgR>2XrUnT zye;zm^lsJ2cEjoihVo}v#OYP%_tXD3Zc>Zjyf$qT`5RDYpR;;ncg>x33(Djo?p-Al zg^IR8g_g~myWBK)ruOSn@}h_ORw$>@})gvU z(Z0S&M-?X&r}@W`EI~=t8?)!WX1%jt_OakJrmF)U*X`!FkXz5&&gFOI<8RI_xGb@P zX<2l?>fUxvvga86nhPMg2?V@E7g#1H6w$BmI|}WGuY?i9X+L>vP8|JXm4v3Jt3x|S zUw6+Se3hnvGmL(+-p72-iT7Z5M3nMqPa{~_!yV}DAZ@~-$)<>lW>QMYBID%*(ew;R zi<|J++=x>eB%Q6=x+=ip*D(JitDRZFt>s?J@+}@Cn{pqWbj{F;>I$$V(OQO!wZ8kr zR2!H6*U3i5`6R-6B^6AL^X8~`%@RU8*Dq6K@?Oq1FxITexru+BV{OEqp?n-3CV3b& zKwACpswpD@p6WJZ@R+*YHms>z{{V$MYPq+1d@u?|eNAR}ym;p95 z?EPKm$9S1HRZxoVNUsu>VMQmo8E9Ud*k7Wgh-9U#ITGa^ri@A*wdZr<@hoqvBFLlpyCyX3HUAF?+UoCu$hHmJB@DQT_@9MuRw^#}+YC9y?dFX?jb_#k z&P#sK=-q^ATM3&6sbCSExf~9{^;cI7ys1DoRR8xjHLIBS^1Gz_z^H~+?z1M` z-I(MK3`XL6iZuc%6J3&+j}!77z-*v4vYY{Qc};h~EcqCD6)fhXMGZy$01f~ut^ug= zUk)|!ym*9svbf~|K@yCT%jPlj|7A@h1&>{jE*opMHy|G>K+9uKDcAlvxa8;u`Gv9h z*{&sC$dIt^5Lc#|q^n%^Q{^AnRuvYj*!ZCD<=&2$|(G}W{AVGs3@T`ZCLTBH6(d~T#T2u>)rCVHpH3BT=-!}$^9GCeby0`v zN)HSUA$(CsfW(4e|IXP$UH$BFAF)HUtNiF*&&ikb69GYXD`BX73Y^8sLVxSiH3O9b zO)h+9ZGZo{+gGuOKhSG1KWlcB8gNR|sn9FQjwHQ3fi94$mu@X9n;Y8=n4xLvaYa)V zM9Vdn!XaUB0hPj_Z`U-8uBUyz>rHsv`=*=~VHNTug{tS5mt*{w1qqRBIJII|!t<3s zh+w<4{Mc9;M`;R1U)WD|zZ9q}IClIXx8>)$esY{-C1{g2FP(QudcD+L+=dJBq+On_ zv|2TfcHD>VtZbf0p{!D-3H`kXk-q?$-BY`erdS+T)5ect?f$B-)^jW>IxoPFjH)I} z!r!MnQxED|EEb@pS=5wdgUgS`g6BVs#juTiCW4MPgrSV}+408Qs>3;Tui_P9BzK2; zuqLJ%(fn&1{s(;)9c=D-EsZ*tHtv-jkE-9z(wX%wn?j%C-w)_%;PAH;{h6X*NK@6t zrI8O7x6UB-He*aUo61g7${_6!f2qG5eJMabb+(;PgSI+0w#dg+JUq_y33T~?auok@ zz@B>x#7UoVo(_0IOK5hE6OB{?Crp*hdD_e-s<^UA&tT$LS&!;yzQwYAy%2s+mT>A4 zFR}COQu%7?Nl3AO@m11md`X;LUF57)lS+l6hEr1%>rg6Wh9zGzsjhz8$Cl%30Ak>8 z1exLTa~O0ih_0pFACgmPYrY$%%GfHryRv$c&%mgLiz&C0&Lye4rQ%bk%Io@G^KFWW zJ>jf&z-)wq{qqoCnO#Q0@i-r{JPKTWAyZMO*gun=34#tM9ik`~E;D3p8bMad0Z9-G zh*k<+#bhYuAAQCQ!R$(8FW7eWD3Hs()yr#O^UJvo*r%Xm)zjALAj4pGuD1?F4`L`* zK(PDEah{QWb>6vtiY_chx`Mkag4EBPyiC=cfh+KJiUpU4tbT#r!cBhO`m+s4( za{nX@XxOvlmRlO%E1L*M?1r>Dwfkp9flKhQx>M!+W@%!(c{thQj z;lSe5!d#8g*n1%g8Q$IP-;Irwdy`$<`rYesjnF2TAjBtggIL5&q#bNWWD(}clzt`9 zASik!z4H_>DFUB_szH@ImGU-RcYbUWb?);oVA{auE$*76g|3opE;&&BM7_{+GiC zJk67Xu!|8r2~s;G3yzZT{!Ql?AS@y=$`KRQ8GBxs_rrOG^8_Ypo1O6hF4tP`OX{6* zlF>(hZsfK-p3kAWU23wqe4*JbeD}lA%awrpXSi3X;?~%j89^+Q&Y53T@5Erni*@gH z7&zXzSpFK!Dcs_1;ECK4mOwa9uTN!( zoqk5w{KaC#B!HT`f;w9BDPzJmo3?pknst(pfxIiUC9}IyNY4EJsP*F?TNd}lkR>Zv z{V_^=t?JCk^IFCL(kGsO>M(7jNw7#KDyLE9%Kb@uHrM-fNGf5Qm!XFrum$SmlSxxd zj0K3t3%R(h=KM=H&pG7LcAj13OK_e|?#)yO-XJmtr|^eOZgAk*LeWw0~YmMGamAX=!!xj8xP!^V99oCyPF2I&%oQb&6GX6Q$zlZcp3 z>SdF?#mn#KX+N*diIHgac1Qc7JL&JNn%!4O9h}fS7np_Ye;#}-M*O7N!yyXZXgt_& z=BAZQC4AzaE$C3N`R92*%w29uWAPuK!ZSdL3|rtp$Xw2JF-$dq4=*rnozKwYG#(CMyymY93r~!kW9kjQMuyj{1qj#EO>}A7xKYDJ_QAEg! zU>$i(Z{?5jJuu*e-@6^}`o{6GgAn@Q@9|_-5nX>hyk_3*AZ>Ew{gjBnXMWy?#m8aK zReCn($HDV7PT;hdVe2fl*cNX5YV6x)vvgX^gP*?<|*UgT{rq+_bHPr!0IUBC}x=Xqh44*c;k{3Dmt4M-9M=j`wn`G^OSq<3G4MWezJvn=DL z^QxUAU}CYW-eo+NuyoytILuIgTxw~lp4CZx1+9haPUdjSQMHc0CLIZ0ZD1k|T3E+7 zs0&Grnysx!I9|7bYn!&65MyAqyV~IaAj!MfRZf4*~e#aI6h8vCUJr9H$wbUMTszB|^Ps zy9XAQ)0*VGO;4lV5%LrgaX3mQW?HXv*)HBpUJ&quIPyL(C)ogzwB0VK6#u|O@|#GA zaqmiuhjpslz&72KV{8W0ak(+{;KZsddyhJHl*W7in##F|K4dhds2BE3IWGIR^9lLW z4)E$9;y&FP#D`3zfx>}yC0!s;P2N+L#H^g>6M!vy1*)X}<20MsCV|c08XBw`W3aa3 zFw!uvSwmHO%*M+cKfZzDH#X>k0l%4FW+uA3>g0*ugarCrdLlm%;OYlK4CP>~nSzks zX}zPoepqw zHsAY7M}>5j7UW~xTD;D zWzJwk_4vRdp-!c}Q!Z!F)bi`D#aRhQtUPx+1Z|JQ*|G=@p8Y`4jp$|8|Gp}5 zTKcH%_8ZPMN}=loBB-xo^4w*I0T%@ir!GelIu_m1M=EQ;7|RJDkuu%eVf#z#aX2k# zL%7#-G!fUp+tbrx_w6x8LXVMks%vCU#L4jjtpKA-V8Wx4I5QFFq(LwaA7CdZZ%yXu zl`We>7=e({?0vwO$m5R+&8ru8L`KWd6sWYoX(v%h;6$fWk*CkZ{PkvM0g9tdS6Xg*D774yc|U9q+z1`2bYebh(p4ER3*p2JskDqeRV*R%$D1!)qX-!EfJhbp+(6Z ztDMYEDoO&o)dvhp6B+#&Jjy%;{wjBc_w1g|uXt6Zrsc8VW94hu&RVUFfQgW$%>ls* zjJcUbBD>C8H0K5S5z>LnqbZIQwP-<=U))=3vlVXzT zm=rqniV&{w=OtB2_2IFB4vrkeHWn4_Nab6%E9n@O&TVUAA!4z3?W;qf%s`*w875YJI+SX&w;zj6I1y8j!?(tS%+SBb9R2~aMZ)S4Q+xhmMsO5%mG zu?;IM-*hnkhdO06s0^Z>yyXobjrls%nET%uGwn4r@^`qr%P2Q|uG?+Tc07bWi;}Wiwo}7pry#DRxa;sF`ZH3y= zi!4Um7xr|Rz`RF?fL6Us6B1THoMo3SpNuu!C7wf~sf#{Zb%;wva2ZM>7hHgQ&2rE0 zs2ietHIdifs}#Hp@}YW*e7iftN^%4s8`B1Qf=I7z@+gPx08jI0XMTLbOfanP$N4oT z0p0DDI>UcZA>N3rR3`oD)WQMNis%s1silVb)f@Y&bM=7p&U#;$yZ|jqZOd8bZ@g_& zT_{T&*%bv$swDkwi{jU4G{3_$BF{%u3h$J&DD4|g?GvaPNb}G&n`>FL$6nFO={Cn3 z%qJ%q>a1^6__ROXj?5lzMGwHp`~jz&fTdM8Y3wb{w!}~o+fH<*Z~u4a9&VrMFI~HJ;0)vmWH3-+{?!?X4LAd7r2=c0|MM#!9@_s2?7tXG{l8s4;rr~x#5^YcnFWbf6)rn|7aqYwbjX`XCxQ)uY9)VF~w{3ZK zwoPtWZMU6OZGS2)Z?EOh7ZB~W-*7EoaG75X#>%Q0+%d@n4h)a8Mo%}*uTFN25zz=f zF=^H5?#U=c@6S|23OKYd3^_OYi8?hjrrSqOHx10Tuil!D7jVTfsKloWRk`h& z&0V8wr+VOScmdy?x718JH5(Jt1dxhl@J*ENS`F* zE9$U11wvE!B&R*Wy}n-TDz#Z$u05Ev*u3HHs;-)OK9pmqS_c1kM9C{X&vCA=+eQ25 z$k-{Xpv&2}K%XfB=&+pNa*o#Ebg`ygjdN!|<^0zk;}bgUO|%FL7fL7YkGA0Jy#h2G%A^&z zEAdc}smU3&UVQrX<8ou!@CZ(ELZorVky-Nwe<-B4W$&uoJWFqR>SN5tL~(>#*$MMf z9jNFS^kKR=IQg&jXgo{NIUBqWIzQe;BV!wpjpgC(@+DtI-&S6GX1(3VyUAN+eOq(6 z)!pAa$)1g{|0$gNFeryL?C4`u#EIn9UN>$^oWVOx1eB0AhwZg137aS-r*2VZN@Mo* zo{(Ucj;oda`~H~T1TLEq0`@hZm~>l;UH%GA$78|C^4vH!h*4kDPz*LtdT`mwyvnQem@Ta{k0D)yVbyOBva5%h~i>E&htwLgqvTt%}(1Kw|-a$4G*T=i^;2b0Z75 z8=@x#53{BnwykVprv>6BB{BQVkP4N}OF^K>IA>el+}=HC5NHW?=H`*%aFuOf_59Hy zrcop=#mB;WA>KnF=0suRGmgEEOAM+}wVrDIdD_rIgh01vIzzWWv}7q$n3I&x-v7vA zuBuN)a_Q*^28{w}Y7%EIBow($l8;Q@)H-Uf_h}@0G16oy7hc%j{Wk16+_8Px8(YlW z+K7;5MlRq;H}4@Qwvr&G)>CBcmtb8ei=6DBuu#ogWaaQ#KQ(k87Ww9c&{l3gKK_CY zbnMxH$+J=y;FW zolMJgz2NQf_W7KZl1(;}w8_ZeOQc@>6jDrzL!N3u(DgAxi?+9b_?MA$_0DdIdb==v z0Wv0gTGMqa^2-Tt^!+9eq?C*#aM9DQhc^i(b|Ycs!_O4fnT>SLMWey-^Gl&ZeDoHc zC=2o7cX*Bp3bIn8er}4%%avSQv1gbxj~XW3wA@xRp`D3#0iV#|?!tbwujIbsb6@Wx zEtR8TNe#;Jes(Qn7&js3Vk@6PJ?{8VATW&ho^s%90Zp3@EprFMNyn}0DbA&!82i2x zSeo@_U6pIw#GJW!xHPv&-oq-i?(*4ec$m~^R33NIrf?`_^>^{dJy)UP2P@0Lf~)Ao zUu1s@?y+I;injO<_1(JreW6j?$r`L4I54jzIk@A-xdB^@!B2wkOYo`K*yahC-_h(OMM#7Mi; zOs&w~gBx4s3$gD5RzlgK{{9-)0URLC-cn9OuV2 zH%s7oSwViqoS=meoVtF2O!LG_kxh&f#Cb2(6I?IxuvVLMfr`R#b_3h>IoH${DDFH|X^?Q6te17n5tKm1-EG|B z<$*vVFZ31Lbt^YUe-FjrO?v)Sg2P!4eW}B_yvFOfnwGtbLCqk3S@IuNKTT@$%aNaY z%{1%7_lMxkwq@)$U#RGCi~9-U;zs2wrX`Z1r@0?J?Xhd8meQ=!B4p_{7^-FVc7+hci(HZ!n=5*&;) zRXvTx)!Plndw4{%GR%!;$)rLrEG@$~UyRK)J?ro4ER>J+x;~%yQ(`%M=DD9f%GR1F zVCTCDYU(5Kb}xF#mO#Sj-z(+9Tfb<)UvO0v3DvfH@LTYw&tQ}J7RQ}pEe`El(O9CK&OK=BZCu{#Dz zpA%1)TQlrhYT+y9;wRu=!WZ` z%^)1+v?WcWk+hgNSzgc`FLn3AqrwevG6)Ate(m#u3e+Csy?%~FrugbJlIu5eZnpnyB($R4VqFSx$~vb3x;%}ak?k=R2g0^Gk{cb)HM#K zN!A0HqMG$!cgRv$?!Fvo#Ti?O7cUdhqw)4ApHS{J%G!RP=}YzK1pn zxaL)_SkB*ht>N$AOwBMi1sbGw=0u!|p!6x3nVBz_#3osp)jT8LPC9#FSRAtO)T4pU z`R24VCx0HQfZ&eW;Ede&aF$T}qy*5idb_h0$d1Bagqi zx=Ksl>2dAmy0f=o2OSU+_0qDWKZAA=6%S9bxpM1@dKeqNMRfu_)8X-Vqsh*hqReKBrMItTrNz$YP{E5d|J|uI@l_uj z))cJG-OOYMiPXG?#xDL8Qccn*h5dj%#cOZucg_SPG)~B0%C*f^py38vYi3w8>+j}? z&r+fW5SZ%)grv{VnaXJfvj>My^^%73czelI!WxXId%E{p)eazHP~~u0yD(aZbY1sn z$<5J>;}swy$h2U<@8u&{wNJ5a7yYU<@d4Gn6aKYQ4#_QM0et3flaP1Sn8!&0bz@sa zEIv5=nhcxOqmWVaorB%q+&$7n??kRH=|AS86_$@#JZtP9nNVA)2F?o7$I5566mtiDtELS~5todQSox04gw?M(S$LC$m{87Ldg6 zxe$MI)qqq)27~fe4SB5kHx-A}kDYj>n)qhLy*R^1omDj;Sj05I04kbQc`|z#q(b_4 z{mgF9IS!w4lrKEIc8U{bM2YZ94L;OHpGohO1v<}*OmX`UobT=l)5^bOaX~*spW$p! zduhKXf^-h!aGKrv9fNG&wa+j#dq~lQeBz(jRCVTw2s&zYwb75qdYzE~s`laofH z(VH2C0-)E`@IJ}e!(AkCF%c)5M?E!Df_A!!nMak5d=S1bmy~a%nT{7bI;9w%6&=t ziaciI>tb<@xx`E5x;YHtuiIlx{nl1GbU=^pb^ez~ge&_G!bWAs2y_ZZ-!`>}IfXTf zcNBOx$}LTWUqc9;_u-eYY0P zaH@I^yY*^ivRIzQkYRZ0+D>KbKPfopEH^w|S0o4w$DL2@d|~ z#24-wKuzTwig&O#_|mL|_%2<&1!EC)VQ;?c*Q$H4n99VmVkOu_SXx-x$a4|TQ~cLe zH#Hcp1jPuj1lCn!j8_jt<0N^)++T?LMSJ*KCAH_~rh8cN6fz5t;arn-K!e8ToS}6;0_ra;H()HwDrLo`Zxpnpytb#IFMv+Nu zoRV1kMkfj3WLp4sMe=+0-eJxz&dtqHrX~{ne`8mUSp-$TSxN7DRK1w6Ql~}zWg=Qz zgZcE;#wSY|eWVxOjyB3-#f3Doi6nkYJW$1@PN}+T*Yt9WsZAgIl?eL%@Z4 zY+)?^5p*20Mx+L=WL`Tqo1aQja)oXwt`NzDF}xPb3;#hJ9DU6Z36r>@IUH8Z{EvYX z)oaUAxCR3>FJFs?H~rX>YpJ&gD+9JvtSmC4u&vPTxg#j1$P@8JbBlzc(X92)&wOK@ zggmpL-|HF!fGej&hu14UJ{b#4g-4-y0g#5AL_`%i14s+j!};M^@5deHT60PQ$>>b= z{ZP>f`FiSJt+akiCWI6Mm#s)A)R-xaZ-^?+i;evLBHv#!(gAZJQdRu*d^q?d{A;55 zJRJV%2W=I*i~lEHB_C+3Kxtynk`LS#1-({|^-a4?-r*j=JV>kDHt4@VPQy3~uuok^ z-T1x^&Eiolo>PKA2l8g{G2_%xjrxI49{1BC%aHEs(S0TQQ{{vS*!D`i*8u^=wZUlT zieNX!3(>^wY+>P1*3DX_0K1=MH*svXwComxktw^CKi=44m?jX?p1&_VN`E?;<|vu4K3Dps7^Yj&(>M#b2s3Q{ z69zF0RVjeG00T5WscEbnal%`{Cf0`Wi~KF_g`5rv0@G0TYz66y5UpOM!QJ zQcDve4IuaYqWM7se)sRC0z4RWqKn8&&{Wq@8pl!EJ14*Mn|z;AcSbXCQrGF6y7JOK ziQj?CcAYT*$gOPV=~NEs=J%&(hAi7w!%;hszKa*lf>=-oByp*Djp<#M2v2ms)o{QH zvmN4X5+Tv1V%FqYPjqtqc?eNx**hd9*xbelBOLy>B+NtU!!_GKtm=)wdN`imBM5WW z`csxjcJ_dX&-Lqe?4KZJJZ{-tlid_zam=g?7J46DitnfH%KSfn`P=I?aG7?Y6$cK#_Sh5^7TRRtJw#V+IaC=TJ1{8BG_Ion!}p7kZ{NROx901_Ct(DAxZD*lpJe! zIprFyo%Rz_Rthky4USM|y#F~Guaf90*5F{rXnwcU!^qE9_jP-vwf8@oRvJKb9Dl>p z42M5v1bVRO9)EgD49CbI2(q7R3x-M)Cas%DzAkA?c6)G{jD9xK69UJVlGS*E{#Bpv zG|Pg;Qrd;IpFtWnlgHa1;14ZK6dXYfgaCVkpiT4YYrZnLo;2Nf)o&Y|Vn30x+@e&k zbFNeCfdi38c7GzSIak_f+jXQBdN5ZCeAXq6$jrh(ilk%EK9vMY%P5D1=|m`Pn|8sO zRRX+%m16VoX)VrE7aXmsRUMqokxS`G<};|Y_zoV^rPNND>Hc0)*q>Am$SCvzbeT~^ z58D&`E>9X~-N&U$fPV;j4p+q6Tvd)-o;zylU9wjvnx{}s) z4XU_|k%KK57_nTr<&zbC?=Z>ZYtQ%E`U~XFA*bg}zUpC|{@&G});^m5To%8E%yyOD6G5`jv+l!y{57JB!B%B{8>9 zSjIpRw4SQ}ey*A@*_=Q!CZyI(f#9WujHqUcOk`9NLx)=+ov+7`iFqQZ@(YS5Ub{<~ z^^M)QH*lOoH4H*_jg4 zkYnt-veOAyzGyNxbLB!0ILkPy-rFOasK=kjj*E zy^BO1FNdCKs`{idA|ek-TB-$O&5__@g1j|RdWB*e7*Dof{Z4a`lbbmiWe|#nl`5z` zarFWVw~I8$iWMB62jTvd!O1&NY{$zqOFiw$p@{uQr+n#0D*eYusk>^TH2wYdG5o3M zA*4|ndkUk{HG$w9gy9n3D^6ax*#YR(7YH8Gu(sngY5lllrhVHl1Ju1%kCitn2BAXXvNsPw0wJ6^Zw4jHD?S1jN;n5OJlE~@lk0>tp;=dO ztgsYl;kY%Q=1|vX4z>)a-xz)m@?o0Rlm$UMAU3dg^_v^;X1OwIr`{%w&!(KMB~FY~ zk>Fuj-u|82jwF6yD$~Kq7{DU;4)>Ns6&C1aSr2J5Qex-xKlO+$5Fh^cT|-3tj(RQz zM`ay^{*d#SrP0G8M~5$&&lDHOd<=rDiX|k?;oloKz_ORQDrNTvFJq;Dubt~U{T|bH z(nBinTp#ZnSH^!JHo>w+eaYnf=$=`+5ur64Qwl+?s7oS-jxyhQV!<%c18P&=N#1om zc3}eaT5z4$hD-{x`(qh`KWx31`=T&FBjkEP!FP5$ytiUKH!h@8Ej9!W%3zs|_nhY9 zrmtGE`k|G7;BOR724zNxIXSyKW4{oNBtH8gskvKA96khGCM*FY&@si?bvJHcr#M{2 zyVdd>vpnyE1Z?|QLt8YxPko9u~+JzFHnf) z2Pmdh2NE=bugJn3go;%t}gbOvhj@PhKe8-Y3;s2xt%bk0Pd zdZ`c4V3A-EFsK?JW7e!j>(6I%v|yYU>kfw4Haw?bV$pkhZ+1A#{`ot{Jfk_8-5tlZ zYWC$?UiF7DY2K8<-HoBuPP&soXx3+@3|8M(L>l{Kd`QysGV9r`)%*@OfF92~^Uw1s zjk{P#n6-L-5BZrUZ_;d+Cup|4gWL3Q6i@?l2}}dx|H?cGz6Tk9;3=93ClGQ0z_$ZS zP`R$<3}YGgS5B5`>a!TK4XzZ^JDsOF1E&+vAZEl#DI!dbwXu8+qv>d87Xk>>o2c~a zfz=`T)Pz=0$WvX+K3Kt0fP%9fpSUFOHBTW5!yG`$ng0{y#rKDorpqR1Ci!K z-kx-2dK3ymn_ync#Dd&qUE&UR=>YjskB?7`#+Z*@!B1*?z04`S9*@MsV`5otk3JdO z-28ealu{r@gWD?9`1lv2Vnx1N%F{SHDe%=Lxg9GZJbTrfHl?(07jorlAjx7=2HhJF zfCUx^KJ@AmUz>{FH-A6his~-0EHhXUknyW4a-L|PZ->cYogxy zkWiw5!V8!2Tsn!^Ky(WeBeH4=VkxBxds=sl*sCvs_BzYv!$*(@ftSs$3HGVDqcYwj|3OSKZpp5z((^_pDe1dWmu7d+{yi#+9)sWDzxQuIl!`B!IDkjwEu5!d~EPN8{oy6xPCd+^spuC#KEF(|!wKs%VSoCWcX6N9_%BOHR z;7TM=^o|8;F}A)&-){XEf?95TZqpCme{ixUYi+?$-m{{dZQn7I`-V2j+|*(5Yal%k z=?2Y@!o$Bz>lD!u^NM|obp(%#N&zb*3eA>>ng)~cZg5(kMbXp9H7bBzXQHBVn0T=y zv)tPx>g5IJ?rNYL?Qk*BP*R5*KkvcKi3rjuGb@dBI5G+KG#^KvS%^9 zo@~5{*ktf@W!-RFpPi3wo2?G)hwIg4!}iB++Hp>bRTOjiMsb7{8v_Ir8Q6MTDDntI z>p9Eq^zT%NsI!h;qqxfz~s2`YipHiiuVrD+FH6q9C`@ z#IW`a%_o5Tl*$Zgu2*avVmIipGy(<3T$5j)2Cf$x%JvJY?T*`)W#@m?W02~8n-F&H z_aL_<+HWC^oAEF;iVui5gMW8?i@`GMa>AnZ2}+n8mH=AAX`Etf+q;{0Vvts6aarxA z$oe07N)62Foy6P)0p)+t;@-ei8#=Ci4VcWk;$9I_%)=4F0dS@khzk(k@Di2h?mAFJszu-CGM zzr+bT=t-Q&w8CQO<+b-JZtFWe;P5cm9ZU~9xPktmzjk%Di>sBBvnjps4NxPyaIoC) zV)XgijrN(jBz@-F zEB`CoL-Ax3%wI8i*i{E;EwDX&Acp5U)g6)MY>fD;H#<|}#ND1cV_OGKBd@c9RF}(a zE>phIU)8@-3Km(1@ow33IaNI{dc>JTAL_jWV1s$+v4QY_b2Ap~-~T;`)x+-?pa!RC zUBEg0rJw%u=OSwlCHOBW0a`EwG>>XC|Ly0YybqtMC8m!L;Z$$;ZJ{x=*~7kCK#3NfIL^lzTKA%MJ+vH|2J@Nc-X zl83>+VX!F3{>`G8e2}``#KGam{8u(AbqB1NW3Xb9kp6YWytjX-X#paw5AI)yG&+8; zoCLsf`v2r>ev{h)gc0#@1N1ue%)SJAewLq2{V!yf2lAskW-~3hKDXu3Gj{E}Z`2M2 zI^6Hh$DijqM$7!K+r#HE5=E~5vptunJ)vPg1gO_jW>j^o>>*P?TlTf*f7@pdJH)f_ z$-@)j4*#D|ox>!+4tx;xolka5-=F2iFdM!f*9E=b*VoK!QKO|bZQlua zr%pCLFhaKRbT7rGY`51=Qz3CrBYLi8Tgn|Q=X=y8vTj;k@eYz5BJRF&bd{oO4n4if zPC8*{-_iynQ`zZ=dB}*^R^8C9c3o%C9(=UN34Qrt zzZ;)f*2}fhRq^NbS(Hx4mpUs3{Q`)ACH*HD57^ZSs|fwfnh52 zlErt<##oTZcGg zLHnN2THMZmG7kEY1icd()f)JCey5uMalc~*YiDYMb-AYs1=kCZO@5edg{?a?nSpVY zx>BD~2~{XQOAwlNx@!`e4t*ilY;c^@tozK>Ybcr=l?w!PYwXd_LdfQQ?elG&gKpX*QbmCGsuulV%xUJrKMe13m$LeRh-%#{D;Y8C=3QZzbEyHcNdd zFf84UY^86P+t((-ZrAE%KW2o9dillXDAFLdDVNlZpT)nP))zu;s?oWJ6}3IU(5e1H zX*cY4)9voZvol%EiR;vUT&+J^bKv~(Tfoo!gu95EJ6CoLa@MKu#KNpMXV+cV2a7-I zj$P!uWcMyUZ5)Id9Yt<74EGL+dMhN|FvcF3+biOfobYy>{%&QE?cdre`UzY)oM}m( zI?e0(yqRhC9vs;${Y}IY_VxP50aOnH1-4+r8dbjk8G-%RkQVq}1C z8%+c+|BDE=Ju)b?~Mz1!nP`+K{^D@?oBAuh1IK$+ToqQKTzKESBfi9gVKUM~}T@!CqGS)D&p z9)Kz-lN{D=p(!x|r;jdzhg`T`My-#8e6}-%7fa~8fE?Zp`hx7c$b_)@+UI0~`sMH2 zLrBl!IkrR*I6lQNB?dMPfV&HQrLpd-U!3^OO}8DMMk@u00a`IkTD@I>6587bW1pTV zW9+Q#Q3+vj2l2XCCcUDNHHd~Ar`3F2u^+G-__vm*{2Pj+zDfpbu#W;Anb=GhE5p1v={da!U`AoK^U3&!v1Qu zBuM0bz8cEFa@@*|-rpfIcml8@VkBIV*e5khIJJT6KZxu@BNh_qry%B}U)c`&n#AC< zQq;c?N6g*l3>(FPp0rPlz8DT}gmrp%+4^z4m4+!&-@II821|U!7q_a5Nyx zm?A26VHB?7ue3Tc!-rAg#jee0rXCA+<=(sYuYmQ2jGy%u3x_+ygmV0B5^?y3_BxLq zpT*M&dyL7H-dLa8dWeHv)5$`PO{+yck+5{lx6=N&jnnq(e0kBD(qZUKR5K|P07Crk z#oz+2GXMY~JJugx_U2MD@S+ zE;^j#2tfXMFo0*nx)xPxt01of^9cQ{nNvX_TjO8=(xCi4EzIyy z6Hx!PN4aG7{j>>Jd8dfYU*EP>`NQuCWA-#2i9r?=I5aKh0RGe8)^B~0)t;l-WBA)_ zIITR36wvgEKNu-~iPUP{K6^mG|MG1x>%4;5w*-_I)&CcJZy8l(+x~w_NJxWpw{&-h z0#YhU=LQKk9h(Me5D7uLl@Q4--CfeP+0rH5-ORPU@8|hHF>B3l&AgbkX8!MWE%)hp z9p@3Bh9X!o={jddpq7)O(E?*7yOPC=n9~IPeqc|?*u3DH9Cc%2 zza^zhRR`^YGz}i`x`d?NLA`V|3)q+8+wYQ;Qdw-o98MP^2xX*8&U~k$y0arRB5l5# zoG?gvN9^=1Zu{1~D9n>bWz#tST79=+$j8?(_24}*DH{&Fob6kAIbQ3DmH0u$Dl41Kk;HYm zro(CQ(O9>Si1di%$^*+P-m*zh1a&1l@9$s{g(;t;PHt-GxIUu~t0~`mEAzu2Z;)2I zSRnO&uQ6DalLu574<3m7uBG!9fkQJA;BB7R^I9BkKxY*u#oBbg=i55Y1IxG!k4$Tc zJ-3_LpJ@*!j5hj#AsI#k%uk&$=RMV0-)94p0uK4WoI`4%s|LFJNe)t!NCMpl0G7;r z;Aks!?$Nv#-nUE4rxTL&4)L+uA$)N)Won2)?%DP=DJ(LJ+9;<>x9EK5S3*Y$Ao}l$ z!0@%O%Z51~jreT-6ejf5#VV^m1wLp}D~PfC{<;nj7F>MrY{3A$Yqg6qHw1h*)=2my z?cpxU*iYgm(HIQdwyI2&7tgpT8S)(_e}p=XT9x4JbpRszO=h%~v49u`PF#F18GonE zNg$TYeMfykUY1lTgUyVko=9PP#{0(1c(+0PDyE*rTB+Y^zf*ZH(dy-V`29|O2mNfj-YWeMGzqT=ytk(GByLux6 zhTZ;tgvKyCE#kCjtBFB1nvE;IkIQ^jrH2_%a2MXU5Jph%DWxHG;e0%mmBnLsv`eqm z5EQ;U!gIN-VM@79PI4QRu}$C)%VUsuo}%Yf4fy2++mFCrs)FyhDvw9|YKc*hd;$bsd@kV{BPN2h<+LP)Ng zSGERHg=LY8hSLRPYO!0CJfzE`Uq<>V#REYZjiq{|a>jYF(kj^cY6-jVCt#+Lmf|z* zg0PA@I7MV)oBh?#b{=$mVdm&K1+X=VYKM=tHN($$e$;k5@L-+oGThgatJSX9+Z?z| z-Pn$o-G4ykaz`gtX@e4Y+fHts=A@{lqr^G0KsKy&P=W{)m?@;d7ccB;eeuyK3ob}Y=T$HNp?Ldgnn6|48Co3iE{JQ9?HC%Gi-tU zqVcNfYIbnwV$*#6#w}^xT>V>!;uB1}Guy~M`JL>(*(P(Ba++adhZ>-Mi1G-Vr;k_G zMKP*awi# zUvcFBOB~6L8Mi@G7*Y|_=obz!SJc>$4J6%@|Hx-kM2&Ti(os$4O zx>JUdeM>l%JtGH9m@sE4rS$&cxJ3Ap$V1CQ@odq+Ova14M8ka<?8Mo+J_!;76dSbPX|JTdGX zYoDAciB#1)3P#e#NEi4;;pF;}`x%B?tZF4nM(%^*YAV787A9BP+Wg?&fPK_P6Ib6&NAXiv2~oo_@k;x-`MVesM4CM zDR|x9Bmv#c2vUm#<{pi38!ke#BKvzX}7NT*sLEWSKe7 z0`93O>a__#*8RLT=IdGcFMwE`D{lXci9s%~bj_m9-AlF{z*0i0rn+NTlv=xHtr{q} zkXFl|{0z`C|NSQ#6;-S4EdBy_eWz6bv}m52b>LM*S8ca+u1{a|%P?9#+q()C3{u1L z=RE<6*}x%4zX5YZeiV{mz}6@_n(<<})o^ z$z-xG?K{}`P_a9VM#58L>f&^NCYpeXUR({C;xcsoOUyOJM^?5w(giT4cNDT zczuzyox-)|8y3}Dt_3s$f^9F3qGUG$avj;8h;ujip9F%p`D&V)9J3*$ z@C4v(=omvt=Fk)5cX;WHYb`v#{R-Bm+$Re3VvP{aQfxLPqO&jGz#%k7S-=hlTc(3Y zHs0OiK0i3|B9@eTGI4n>L?BV}CKBsxZ`R9WW7)QZ%r^8b$5be;7Csm!;Oh!%yoq^_ zI);W>+~&Y$zK}MZq|-Lv%1`CAeCotpJ=M*`2A|1#M=2+i z29AJQX1xK9d2+u63>^hrgHo&wNi_tQy-KPe{Tyq)4Q{{1L)o@IBy7tIoL6e&*+dvo ztgGrO*sRP-*?nG+;X}#;S`-JT!0z(;<_w5P;(`BBui5d7aMZ@;wl;JUzv1MTR@aw9 zV#}#Ik3eBwRp-)F9WQ%lFC)t-m6-v~22(}9RQc7cKA+UL-skL`BwlGeP=yz#hzm?1 z;kQ#BoLO5^bg%~&kZ^aBHauJr3o;!^2+cNYUs8HI_NG|K_VOg4c^yO^o(-1TF%dsV_<GyFai%qHu6P8jT&9??#1nvd*_A4VF8&u==|l*0`Yz z5owBSz-`4*h6XvTgAN7fzFNh@#z|>~OP}0IGK*l+WL~RqhWGP@oFtr+D5JE|i8VUiYqp1&d4%*WTa{{BZbn;!tMu zIu<}~Qa>bG)7j~%%=lboPjuocT5TTj@Ku|>)2{J0uvkoUEfXym>@uCEEMTsFYrRae zzGHDUASN#viu!9saIBijwqP)s5mzVmsQZ&*#?+p_dZ&GD=UJ-gX8*a+p10|~frF4< zxWGDWzo?nJ-!CU3gYuqth%&Ft*PI!)tUG!lyG-T!ITfBmeIQyiH6wMiBDWa+aJ};J zakHCL-5DTTZ4-)3Nn_C=f@~~R6*iH(eR>QqH7}l(z96}}PTn?%pJ2&k1RhX+#EjuY z3mP$37tcD-B~UQ9)pD0qxHa10^Z2+#{r;k1lVb>fjZ<&S=8cwfAz*|y4wj#NmLLEP z(y9LCLW(*hYuiv3snb)jJU5!)qa0#D5`8=g&-utUZt;TGRTP4vqR$wFJ#qw;N1PuO z!Uf%%61Mq>6fb9+3mC-YngfPjO_%8+OFcUDcD zNBd5R4P_WCx2`Bo1fXM>qdQq;sn&lGLIdO$HqKQ^haX=1r^?_cd*?j$SIVxs-tBGc zJZ<|%%SU2XV)ifxS3{M~o2vvc@sWX5fQ*WWvyQVsb2TNTELCMDEk_z3H{I-7rPRNn z83z0A^fdK3Q1oB_m-D0= zzq3?n_#Hidkt?)YsZ$ojw^&$Oh(vy|9edbYpu)8RKiT;-+jwv;ZTE^)|X@rwfm8XyIhiw8{tkg#s zl~8Yp9S&NiT;NTR&0IS(N4uNpNV_d@lwI7mhxhIj0BC7?Wl%>$S#T{xx!>1-10!y{ z-R%U1U#p4a<`Eb78&z3!18Rzf*$e&+1Kl^ z{a3I1>}m4#bL9B_#|6@e`FykepD18p3#wU3y6>#d>c%@5zQ4Zz_;F2=D;^1gnO;zv zU@lt}fihIX0(C(j*ByS}=9~_Q<zvfzzGPJ{20MRfC$R9%^spXBk;0zO6c$DedQ;rg(Tch) zWh8mNPwZenM8`6=D2e;mf5GnOw)yYZRMW0aP2TH?a;By_m0Bq2K`=ZGk%O0^`BeIJ z9CV*XU~O`$>9v{Xv9tl-W>s-ZKMrr=E zugK%6>80|q2eSKBqlDKZv<{-02SlUYiD||edmp9jIvbB8-94!9qT84THe3?El^h}7 z1PCNeFQU<(`|f{mS|}AB&#(}{*QyX5!=VNNC3hc|@Cp)?Kd<-ctkkwd?$a$bK&s4B; zE0LWa;6a9o1Rnv+O~DHh564&3J*Mq$_x7(68;5?6qXRK~`~FIXtG>tEeLop!EOr?e z1ts)YUZih$F@Nl*6p4-MPjaK)oC0ihRaEB#5EIb{`9~O+Q)mnEhN|s&+_x!>zv^ z+ARVc4j`LF&m&15oyDtX`5nB%jWcGd53I!t@s&t2Qnbg+`<$T$4g#|VBLKVkVC}r) z+0fPc8}L*Jv}vp!U+Z^I)1(E;k4;fay*&}2XHiX^T6eg@Dl`r#Y$apf`5oz+qNU|) z3`}&4h81;nRgKtY^W?}4Fz|v6n$8Li;^ZYRF|h6$wZ2;+O%r=iQ48Bz2<&Fq6DfB9 zQx+7vObv&=W)_HEqjPmG_c=u}LAxg*%nLpN}CE^NZ-#tK> zD1`9Is_#le9WXFxr6=v(C*bR{eGEqD2w{MDIrH{;K7z>rJ0k7awDb%3n5%p5l1D|# zNj%~hb0h3z5Ut|Z(ug6wH0ML;@#_o$F%wWy1+wK6RaD*?Tp~VeO#($4U=mf(NPQ2ncE`9P*4L4; zJ~5$|LiquIt*trw6N01WEQfCfNy@E@P8_R_eov}K(BQhuu8p}Hh)IvnQ|#+=eKx4` z5DkNC`7@;GJAa17E^}tY`fwInj-b!lmI=y(kVc1O{XlqC=*L@oc`~d~H`}0v(7hiT zwDF=BM&SZI4pG7t8bI)Rj^GE-!G9XezX{gczs$dKF{vTO z+glLmiKcRl_q-tnDyVZ}R|bawjK=^~DuGvfiQj+=V$q2&r}?S%cFUr;KPUx11fvoS znUU#k`kXMV1pgH$NC(r76V@#{ffA}dfd5Kp z;;ky|NIvENSFa(iXYV^pyG9P>8342s_%*Hq|A9c11U_hF>bT}Y9rC4-@)=>@HO&LA z#%F|kQ>Nel<~RN~rt<%Y#)$(FH8?b1c2_zqNPla}uw4M8RctW-OSgv(xepQd5BkF3 zQE8H0ACp=@eHq(RgQK@@UF?~^|16&U3w-p#4DqMMIRPBTXgumaz@g9B0rqp>2M!3n4y4sM43tn#4TRy*KGzoE0G2qi zfBZ@3r>4|)(69lG9M=u}*~_nC{>e2*=AWLG|3$ZGON!#r%tji9^1l>boolFBTcX8d zK&Syhb}hZmHz_SV50~p~Y&{73O8cYJFMJNxv;3cD><^?(l?{L|`;R)NimGc0cjtOe zwnat&f0x!q&*OSM;@SnBS9`}+nmAj}2_t!q03fF4=5kZ()tTiw@b7pc>PMOD9|zu_ zYntPX;mPri$;{8s*Kxd>EJ|8axivS@4!SE&Y!niz%Ga?gDcqctPzKw66Vc3^K=(bU zgwn6ydU5nRBKy90GbvTOeE2mC$Z0rA?*ZW)z!ynQf_AnAEbVu*(XU^AqLCq<)}|0j zJ~8(O&FKFcZW$aez*z&@nm_ItwDhEkzysl*zN*L2OpaCCBPH_WO4ZmK<4q^#l~2$6 z4vv_0794Apq zR$fz?Nz;ebvaR`!ldU(Dxl0WJ&8i$1#!C$kZDRK)RWI$|5}c?zwCVKwUK)R1zMOPh zp6hULYA{7AhMrM^n*(}ikD?G}RR>a2Q=Pjjjjs+Ed+^zFEt?=A`rrqZWvL0aP1`;{R`|Xx{ zn!WYuOUQ_+fbZHY-3&n643tu*F(%c%Rnq9DlJvfsn635enr(71KzrU-ktSd@j2H1* z?d7LjOey$X|evl7K^7oGCAtyTA#zlI*%pWS7*I- z|A1h|qG{%Nf<~`D9fwFi_yw>tPO^c%Zx?F)CewlR!d+Yqoi7=nP-QpT6|L6mV;d^0 z{M=uq(fTp2I!~YclE+6Ek+~GhyE`;@+3EE=HfCXuLw!4#F2qAu+U!Fs?IU=-4MDT$ zPb$FQQ~(w{;tSO9KD@KF=Dj}KQ#RW9Y$Grjo6`|=BT{O_hmhSdlmM-g9pC2WpN=G> zwq2)cuW{+J$G`Lq+}WT#(b8|r{W;KrDE67R_e5*g? z@ZhKP?X{DOmf%yBFD>3GmyUY?F0uyL=N|bs6Q>D*v;1Xj-tnfGVJXzH`bmKB7-!@c z+;a~Ys1^;H-VI)ruL5~}GQe%%#MCJu$h#3B7}*F;_!-bcYTw?p8aMg8ob$c0XC{#F zMju(GmVQBMEn}P7FqzvfwJzc3c%S(fFH?Xn`dfL2S4+xj9yB^#eLiQ*M2&L67`DYv zG@(o{hU-fE8E6cI+QZTxitXiZ3SzABNis&SwHwbuwOA`$f4vI|FyIC%sPN@Yi!b4j z9N)Ut%L*(0lnO?Ewy$LY-IjYd%mE}i1wFgw%S(%QO#x>>eE`e`agW?Gem~y<#>n#k zY~ejOVcPP#(>^)&w@Ox@bHy_c!t6Q;&zo?auisj{GZ|8ugRFxb8{+bY0h11Jx4(=S zCvqY~@?u-X=5xNinUb$$J&}pUkGDUhW^eQ-aVJOeLzVw1#dIPh0K-1yR{hP03Or4#Vtk!iYom-qYAV7>%t&Dfvo|&wcR&2!f zK@MAI_0c76KN8|DB@Qy$Ml88MjEoa2fIbf)s$sH1PI1GKF_< z{Ly?$Nt!?3{#(L4vbeXg`!JK}PTYmKyey$3dv8^_opNpP@~X|J?}ykcdcNFixwLO@ z6NQLE9=@;IYkgSuJ7s|j_Sk9d%T)uq%T~Bvk&o~pRl8(uzD|5$t(|OowSZmLiiD6| zx2?fzQ|V@t%2KCzwyyg9@*{@5MfU=S79=#3YF14eQYZ_FI?%7r19m*uH`{>e;XzQ7 z%XL1SRuStZ0NOAKXcnin+13iHbS%06oDJIckfDkydwI9n&q}>~fI~AQDA2egDK>gU zWTAxT_%jNwo-R0uO?%m84o1lF6b~c|i8Kl&gW8yi$l|K;mbbVQ*w%m^t2*GX7Ra*b zTR79y^lO^&zi6A{Tm4##rydfdLm7NJ+)At*rns0AbP`nd8_A?;ew_s-!B3k9ZLS&tS1gccd9QH4sU2i)j~beP-qpBYj`Y_?*k>d} zcC29y?!@KoERh>Mc>M@kVFwwsFE_T`-?KY&tsRtS=Y<fvHT}uBj{2b;N}Jie1J0cAB0T^ z_AE{e^y^fFS$4B}r4_FnR-tYQtibu8Kzi zXB6?MU9o~eJ|<1T2j*&G*0gt4(n2BHU9MOJ@6^!Lcp!jJFS^;se08fh55P2&15O%= z`!wlaIZZAVbaw&TZUCU}+E)yHjg6urKK-V*PdnmI5&#Ezh{(cFKtP~$%mmq;BXza;dN)?oBs|-3lqpR_)+d=N%A~2xKFU~-5l1^nej@^M=uPFxqJmZMy# ze;wMT%F_fQXi!iVaS<%!)?OWa8Z5^bL5(3b@6RDUm?l+LV=XZ+z~QL2EH2%5H5XLD z34XKmE+zhoDi=T1wkW5YsoPlZIsW<}*)G?auYsD6fm$Nm{by2-x(q85DUC`Az}0k} z>@QUMovlWE&6jH=F$f@iB`O{$d;Exluy`ip;P!Ef#TKpKK2-ba@T*MVsi$7k*>wG% zY3|LRmg!62tkJh1X*L4u!4%){_GM5jdYzV#*+0SSEZ;fl_Z8>1Bbb}A3S7rorSjxX zjk2sj0aVfVZ0LP}D$HOW9b{K=CZrty8SGKOz;-}_J=PgPsp;u757P*Bu}&o0^p`x2 zj{ezoR>?z8tJ+}hZ97j;qX0+|z9*FTKEBbIiOkFD6Ynx|PG>8?MJkW7UhG`sQttj5 z-H%{Zt+1HtGyz{gg-PPMwNIu^7Q?HmcwbiOo2F?x%pIMI?E!lvFMj>$K9!Z=H~217 z?|=2!HTVLXyg<82P~I77(#IkY;T?L;euBsLa{-ujGVrDSz;%2Png}9nfG()>B4%Pm z3UIV30(Flc!4i7cvLYpaK{&J(#~y2RWFGc6r?j6`Lu2s22-ih?>$34-O!=LiH83%p zdEG@+z!DfPRsA~UoxBTyXqyw(r4M#Rv75}Tj>j2%31x8kWhg5Vgx4B%FA+6=^p53m zlE+VxUd_gh<34A4h?hFU71cInNoXN3?+o&_cBCr}-2nheyB~h%CpEo7O3~Xc*6pMb zY@To4ycyA0#Mmz#cJ377Jo8;gwmX44i3vf%J8V`aawOe%UGzcdYIip$R^J9Y`ILE@ ziAq;fQJs&ElYvEnpMUBoO}hxV&~yBEQr?f00WC--W2Sw@k;2!Y0;7qQupyz0f#>Yc zKE}ayKolqdi(^H-s3@Bl1R|73-nAx-spsqWHVD+|sAM0i`#94KqC${eq0S5tGM`ez zV=c1h3`6Mz(Y~gHm`*~(SIs-ks`Hg!BlIybaJY(z)vtg|%BbefVX1ZhvuH9JF^?s6$uMSqq~#!l?F?9QxJyPL8WJLnIf!k7udKLl=!dhP6Kvwhb7LQOws z?0Vyx3$N#izkbi_uIBy9;V(S2ux4%&9O>q3{F}Yya~)NEkX2b5>YBqVG)}C8w6F#H z8(a<2Z7PoPp@4!FLnmW9KYL(k_6_?_yLufm%cOjQa)| zZs<7bAKjhA-PA0=171)0AtktO12^BE#+dN_92q z(&(`mHI znLeBWeb@`~*|Jdwg~9E^S*^N^u*}D+#=~bsm^0IhBl?*2`ym&mMsQlM>_kX;kr1;U zm34NMhjAkJJ$-sGQr|Q^_^Y&MVEKA|nc(^yh(!-<)1hMdL>^ zwICB=4{xLLuBm+c=}_ufLzoq0Y;|*Kl`I@1onR9)R?s&S_PHFOa!mUgixx~47qR-{ zL*N-=ZVcfOo8XUr_v31ump{!AD}uiPS?!DhlW7Jh%47T1sfq%Bk*PAF!CY;2=r$lh z;UQ>9dsexf-O5btI9K{`s)I@ceXHy8cwHk6*e%&AZu6T)(x||aVf_riAN6L_sO!i< zXgE0N`e*6$^=sx=hV*B1($+R9B~N!kaVgDMuwGUZS_Rq+(`WO~3n=RsFnS&MJGOX~ zCcGa5q;-I;Bw-9U{B-4u0_u&$%PyDnXX4@7)bzNvH&9stx@p$p820>J^3{UE79qg@ zMP~X`-Rbn}T)o4bgI4PG^E4$*T&9weiQ$5>9Z~u07VnXpZMersDrgyh$g@~P^>dUJ z#;`zTp8nX0r);odLmum~Eipl^y2Zv0w zcOZRk)t+iM$ozu>`k7)T-<gk#_Q#t;F=CP9kDW|bze4xYE%CfD4?*-GYpwJU1vOM{`{NSKAc!6?QoUOdf4@!%&al;G+Y>;wZH?P$NJFp? zgL3~@8$wry_8XakJ?uF%Br7n`H46+ zA~*a4!Q}cSp-OFZ3LSiI4#&_~Fit89AprB_7~b>NwSHqa6Ux?fsF_Vx3Z0vP*3k*R5cq4+@h7JO%8P zZJ#G`6UI9ZCM5^&r5<-idd30o-Vb1NcW4}N+J^Z5yn5`>{hTA}^we@He>KMam0qfc zwG#GKs<4+BE_|KJ6@tu8<`yLO|e@NWhLQZ(& zu~-wdlY=VjK$9*msTT|!CJ!Lss4s*M!e6QU0dzEYXM-9_RU~?mKDBwNX2}*=SFMzz zmg7d^XV=H>r{eP9uXlD$NvD-&v@V`M(3jx?%F|u_CUavpcH85M)WhVf@yWq? zR>@yD~bTim8$76zh}fCoE1k@tctrAOR`cHe`Kz%GmakPTCt*qoOR2;Q5mwU6E_5{S1N;#W}mh@DPPHQm(j zBjOZCE|bUR0WGk}2QtSskACe^oDAmLT{d@6n4V*>?2YlDpJt214DXhrF4ZC<6O{3JQ7-6UQG`Ky1<@bRs!p3+J2ALM8lMtXso?J@#t8AU=$bHB z9ge(X5*L{%I+?;fL-5Y*iziOO%%wg_oOHoRm8RM!va|Vb=SZ%MI+$=OKc3a~Pu|xz z9bicMkB;%$Bo#OKZHf-Eb-E90$L0?k4Qj=ofwcspJfZtD)nZd5@gMs;Q=%t_EJpz$ z9yU^F|8FkWtx7%1gAp@17Z+b1=Q*NbL{Z@PrzpsUcka#h5v?t!j9Z?X-1ome-7u5( zyD$~LUD?ks6IDpAuqZ5equvK(2H#2wP%DiaZQs~>|4f0m1ug8l8en@_%ay*0l7GX40mVL=wHHE*`3`q2p{6=`I3wL;jL zT$K|``UvW>uPWmTiyC%CTjyvhXui%@pz6|HS2mh@s#s(y7x@E90CHhPFzWQC(Ts>t zQ6c_Y z^FeE^)f9~GTZJ8|+7!w27fv(hbi4*n%_-Lb!JT-IXNwzOs+e}^WJ-N)!T~d1c@V2V zJ+yW0edoT<*^W&GLk!wx^gpx!JjWPYG!_?`Uy`Rtm?b^fLAR7n27M3e-A9`VV0z_x8{Hn5Tu?| zzloqy^j5qL1QE3OVQ8G6+%&1x^MipHT4k!}q7N{6cjv`%p#+0gtEmMB!kDzj0Hv8( ztv2Z0=-^f@W2vlEYa1PQ@k4d;O`;dpsL(gr@6?kHS6S+<^|8geZ1e6rwa2N6(n#18_@%T zfb2}|>H`~(82rfHUlq?ro8O>M`=4h#pbrZbq-7I=c3k7^Hv7?x_$9|J`^g?0WPID4 zX73vunEVCTCyO;=kH|+>4wygpYsrFe1 znfbaVWFA=m%r@&7+<9u!__AB53`o;0uknA%-dk6)KqEVtxpB6~7jw8Y1k8@t71(Xb zy^7F9#ieX$Wlo%EE`e_Uua6W`!kc5gVzEI$*82?Y;U{e82PG(tKi#68Z1&cwdg@?y za38L@wSPg0n*NCrrLkC&aVusqJaLT!+$#oTy<=75Sa>SGCK~>(UFW;rPm^>ywiFxU z?{)7-IE0;s5)qeNI`uop$OUF^dhsYqB92kPKmq{XI*o$`G6Vw`*nr-pMZk61+p?*J zCQ;DaaBN4|C2g;C+t#o+z{MmzX=dB6TyrDe-&9 z2e1aPp>(l9P)D@klJSWuhuoi5k1lZZ(GsNH;^kK7F+wW&CtI!UXv>38HO&$NrzZ5yE(7wKN8Mx`7<^ zQ<$v6(JPpxuw>Vbm&+n&DsVz8 z+*)1979R6eVZshb6~5YUFC)IWqdy%nygy{Y>>2?XzpF=Nl{iJCCO`g93Km(DtV$D1 zJGFQId6_8m?Os*#y()afLDk>5u(DRFXRkN+q$1#X907F*$~E;5a9}CjX}^_TXW5y2 zvi(I;{eVU0ZUs-P#xC+)QWHM_#IRUTHe67eF(ZO5(0BQnz}=3ylCN|wArlRq_+V8J zBS6m}XMh@OG!ynDXxIsyZLH@87|Uw=L#q_C;fPEIe<%#@hA&W;8hrX9lU<9|+=yl% z@CgETBBMW?YEBZh4T%L2G(5L9r$6AR;h)uwxU7un93qqMxs;T%ThS+Ms2&||RAq=I zK>$pzF7YKaKmDs>iEJD2GdRdTbB1I15dSGKKmEI~O27}<2e=NQQDnrGzjxE7ph#o= zUvg{_&Y{eRcOT_H2_@j>Ad1!h_e5F%B>w*oS~UOP`I*2L9m4u4-}m}VZgdT-Z2 zQ}w@8q#h6`1903>kS6fK&91UpHr^1c?iscKWl|Z3_917|e~RQh`XfGM1`vBG$E3;h zj7px*hh*xY&GS3cmGt+4y2!u{{_C#F{w2s`@(>a4?3w;u1S#7B!*ye%4VzketP(sm z1h5_bjee)!E!-*?0DO@h3jy-^`wn*6x33P?GL?K$R^FEyBZ* zMzWT)@BRNR=m?MtQ9wW;##K$~cKg!#CM^MhJt)V3;*RB|V@p}??D}vf#Y6%ViODKp z;TnB;JYJByGdSe7G4j&!Mp*ftX==W~yFrh}L>>^rP*+zkTRbTOp1=63p5|h-TRkpM z@Vj^FWz+gv<3;YVF!&3HOvysLt3@Dn8@fK#j$J~%%KrY{+p!B+bTz+j)iJzBLQ(ID z&Ip7674VeL&BbU^mFXs6cP1PZhAr@|0bIjAZj5HwT6}k?aOwSjuBr1s#Ey8enO++6 zcnXgwQAyfmcEw$?=pC|8k}KD& z!}Gf#;VCf9lwqPb@#KxM^m%^zT& zC+u z#f1qP3ZkO&?S~VY`!LC(GI3h&HjMWl2z5XDts|?H(hcGz$SnYj}}fY($K14K-aF2d8`E!)mGgT zAxoJK^R*<2Gx`$$K+B5#X4Xtr)6~Mi^mGW+fDYkz5zJ6O4yaS?QOK0*@R)o=#&>zG z(m5ndtKZmxzJCQ}$sWktQ_V&d!WV?HiMZ786(N*~1t}A9`>T|3*1;&S z&aA~~V_p`MD-?CxY{l`SwJI|ze&$m1#@IJL((soDe*xwWYMTo0<({we48U? z6ZjT ztb4hQL`m5w1@d^Ef;SUj#;7RgjOeMKB&1r|L_kw{syY<_YxL!XnOTGI{eQ$zQ0!`eKS^xG2 z>wY+LWYp{`&5Irsu>%M#F$_D82}r&haF?5_UJhl|RE&pk@?v&*^W9zdSuWP7TaNXj z;QUjqM;;-Up1&l`Vw(tMCAzh^W1dL-a5C*6dHJR_%;12uebEBo&}m=Gw8 z7LmSsR$;v_Oh@c{k-!H3$o{BM_+hx)El@T1F>@8R%G&--BQ0s62nZG%jeYq8f&psV z$2`BGLVd1IKwo?Cqn=BLL68ceTKorkn`^q#y;muqp^O&z$>t1?HS&CtIQb%IFp1>9 zQKTS41d6m7;$k(B&_CMuC|O(ns}(wo4)84zJ`atok^{r;geIC>XO9-|=FEOU&{H2- za~AKw>-iuLiq)~rhP-!r+jL#l3Xiz4X~YLetg7v=G|PvC)SirJOw0Khx&d$>;O7B) z7EM7UNIIRT^98emLPr^>F2#9HK_~}lWbu1{KF+P%(?+N-ini`oXEw42XA))ytTbD^_pP; zfF^_7Dyun*-KCP+nWop)fl395VT1zlsl0GNU`wq`X!(57g^xj2GyAD?rcGpi7e;;m z0WI~|g-LO*Q8WPBJ0TQ#xk4V*TmqJZSVB^~UDM2~Eo5GlSWvo#A3_}q-hG69EsRe^OPT^%b{3|{;>F3)Au_{aU)#h97_7Hw?7Bz79%NF1<* zR@7g_f9n8|{m}uUjyBLPyJc&fP`Zn4qxRV^n|Vvo8K9QaRl;iXs;8u3Haw$E97+a%;)cbq6ew5;+&7L^<@DkHQX|@m2BFIO?vb zG<*_!bHj`bBG>(Z_40u3QDEcI>lNB2Acez}x;_Iwb;qt7uR=cxs zdZHbD-X;tzD8(YQEAce!Lf~!2E%o^0zxD^VWy!5n(#28j^NGT5ew0ZrS5pJ`&0k++(hewjz}Vi zGtLnaH%{f5ibjec8+Y~b-uXT9U$2gkriQwrB;c$rRt5!gQG#Xt}b5M6FAS@e~y4dCRr+_?x} z;~A`44_<8-C?M9=gbS64*c;ldeM4s=t$p9PTQp;{xQB7NT=P8Mp#=G_mjtU?y}_Asg>w>GiOZv;mV}9`5rn)L#VA-KQierws*C7oezx zAv?;B{EI89Rga;@r#YcSq^n+QMs_Ozd`S$}r_$crS-kC#zzXOY>z$Tkb(qZo|DXb} zFcnO2WY7C18&wyaNxDCiF4~=I5mvonC z--EUbyg;b;-o8s@u}l#Z<{e1%sFNzF6X#<^+@q<8P>Jk8@m5TV{5#@!c+{j|-MeC_&GR7ATV|_k@sK7r4_h5t zSo!*TnKR%Nxa6;?sF~BbXoo|pK77TUrk8vA5|$*WZsVPG0~{MPnBYff9|+*qmNk63 z%R<{4L2H3lrSS|l#=gCVHmal0iSFoi_(Kp#?_kdZcpPunGzSw*=FnJq^)-z}pM5wo zY5!`Hupsw#$h$pgesS7;GQa|oZ2)3_L7x~)>N4#(l;}+UKup5f3$DHCF1)W<5kGwj zs`{*u4IJ5aCLh>7RK9S?uK@D`q;*BulueM<_Qak3!;W1q%swVNzXU+LX7wk} zXmy{Nf=W5ikr)TAXL&ETMRl4z(;Vvd!gieYj%m)igeXV%U^jis&o?T-L%bWC=k;%F zVdq4KuZ|W%I;_^edQdqx}F|` zi=W9zT;hs{2woD^oPgZ?9QcXLaMYK?b1LCr=n605&k8{`hk+q|^^E6vNU(0rQbQV? zhy_H9eSsZ>?#{3Og)pIQsb-1t<92@G4>g3D#%~G3%5M<5b+V|$)EvR}uQgd-EP)n3 z0T8_vF_u0kKbVD5X*I56;=5E|R4-{LPCuo96~0y(_3wNjHE&ck_<h(8bwxF>@;K3Tw9Rs0!b$M5}AjQkGAK zzDetpSVt66g7GMXTxD9bdAD_dQyYokrSvgCc@2-Qi2NoDoR#dsMrX?Aw~cXl{zf1O z$c~o(6@aULVG4SRm*N4J6auS0*p(SU(iBQHOsDFcNRBLP7Z=}fn%x5#h{Ne8M3*Mx z0Xrr*U=a9x{vYb2dnUEYA}tsk4lV%OYm>-X40ZCCf(8d8vw5o6U6qp7US znxuEJD=8NwRPt1HYd*}9dhZUaoU(lH{0C`j`3WFR^i1T^RO#?fvgEh6-Ci^lOZcA5f&V&Or?) zyi@ATlqV0Bj1w@nyx*Zx{gxuwp<51+>p`8LDMg14Ulu*F&F*F+8~$Z%r23Julx5=J zi(aspO0G$byOXWKri?t!-ib$Xzx3{K`ER4_+KtQE$~jl;F*s022?q+jC*Ybxe(ESC ziWP8}>D6DDscy2ZR^n9Wr;D$o0by2B%p;mYWQ#=ET^kxR%;S$_JNL*<*rp|Q;d^|& zQ&U6$Iw%!4$y-)731}%Q?+uF!S_*zJj&>!c+~I+#5F#A}+%Vp2#?|i6UW7sufXokC zGA%JR+OFnkBMEtVQi)K2)o(luC2^m&b%*Z^oFYW_^_`px1Dgv&X9g_!T`H=>Ilkww zz9xcIz7pjn1HI8LzZFov-~px~iHd127ZzVW#q*4u+k6?KUasE!V4+!}Pa^L6)T{EWA0XtohI$wAYgm&o8S(U2C<6 z4a1&@iva)WRlHl;8OExjx4^yA^SZ~n`~r=^IN1o1J8hkP?M2nLv4^NalhxLH+4gKI z+Qw}vT3ib727uqT914o5YD$CZSB*DyO{@UOKKwyf;HsuW2wzmNO92o@n|#Kcx+g6% z_d`C~LK#&^T3_wuysL)qMVdhgfn?WTh-o#H{d>Lly)4oa&3uhW%JZ#>;u@WAZaUiZ z`>4%8Gnm}eml>?tQ=vi{Je<#p$?WIf9m1DzZ>3fzH9*YV_cDS3x{ek|3TuTtn6SC; z_!gxH1bTUEsZ3T}gqSM|K;rQy0X*?o1BVQ}A6blyzB2RvF;itFGrK!*yhURSS1Q^C zlvFw6ZN|zB>)rtov{r0~3T_7HVK6f%TbhP(3x3Tx3dcwOrSYi-z}7FXifP~ZoOI(= z%0N}zlgShpP!^;#h)u$(*dULAM(IxS@Dba;u}xvvm9uWyc#MJy`jIcoFz`z5U{dX` z_t9z#ACxE!c-HhQ4$96stOj0>icLGEwDHx=;e_&Ur2Lgxx~ z)NP#=+Ntg4^Bq&MRa?|?#91A)h>FEXC@Lz-(G`6D-J;qdW{pAIYVPYvorx~L{bh}4 zvC(AJR-@yMF3;WWmimaKUHH4Pr5|IfwpEK<<&QNj7JqoM`u64_2i1G;3pb8(A;wDd zVua!pBY-75W!sZqNLrYb;j|vmR{C7QsV94u$%$XvAQQsf=5=~qOUBFLN!+Q^KVD4J3zFu6ItABLR#9!ewHPZ4{EQ_ayIdwrrW>~)> z)3ns2uK=(GBPsS~Uw&&^UH@*Y$l(WG@+UJ3JWD4Q(M;FsLy0kJITib+)1o zN+cgbDy*3y>uM6Jj*OawRrGt^RP8HmpIGRwO)P zm^UJ;jaA8_5f>u8KGnfy$BAQ0UFi`c82Q0r^`zN~XCt&dF~8A~(Q##!h@hZMD@H^) z<6BAVq_f`WegVMh{QUU^<|OkekE*ue9ilF+CA8$Xf30#~JsU4f=I&Yys=(M3QrCa( zK-GVurQjvEbJ;PWMz0EIK|pc&Logs-4O|^oDcyECu-xbX#j6jO&7Rpf4tcAW4m`9I z9^bxDY$t4Z_rrbEqlwe39|wJcn=Z^{?-&RhQRvJ~zw;}O4ym)7x+-=NYlr_PY<#%H z(RTFhwiW&(Bk}hr&B_lGaXk*KbK9uYxtn_6e)~OZB{#+1_PTpX*~l@N6WseowKy#f z$QP*TsbW}0)(SYT-qYq?aYeC$tm@E=b^7*)KjjK0YU4Q?c^fneXAvDl0V;|rnmm#< z&;PJzW)z?L^^cNs%u|~kbVx(+rfPRe^}#q+XvAf`>ckalm2{=+0EfWnLQ2W5Q!UKf z3azGMva+hoCdb_CLE1^I4quBEH2s}sTaSWde!e$lRB2(vyCr_SH#pvHRqov2pgPyd zi;vR6oQ7WA(LbP7rZRNBzXNgd){4=RkN`SWv3Scli_b6|^q#m~-thvy?NFCe?H}y@xo1_e7Ie8kb3A%p2QmEUYB_bTJixnICgfDs`?wMrawWO_93QGlZQhMhLbt51)XhA8 zb?9yQ%zt!`(Te@}OL?ac08rhE$J=-{C$VnwaNY!Iy@8AF8sWl2o2KW$`>6CRsPo-a z;gl>P0%56AkOxd#)4J1oD!h4R8sZW`%`+I0&I!|r{~0@NvW&!x=l$3@&3~SLVo@8#~-Dh z2iQS1bbHOwtWV4r@~PIl6Vro%HX??f2>c*MpUsM)EP3a;7O=l1A6sF2o!5`fLA`I| zr~6}%CD@Y5GVK%(SwzA*a$_7iu^FnbQ!jD*pZR z53|oW>+Ka(HB8k;5B~mPW!@JtzU6MYk)BM!Z3L#rmCIshK^!AX0*&g7hp3L$5k9R^PIq22I&8h_cLFGk&bRXR+m;kY&bjngQpfp+iwIU-okrVz z;&UN=x2anY=+YQ-rUYm$RVzQLb_^t+&Ft!8ZdDTRXY>q@rx3xk;gHLug*j*}KT-NYI_%RLico zaR_x}?^A}RF0feBj;kIXHoM2R_q~*>k+ENc(Akoq$d3v`s=O|6k zL((TDDk`p*Oj#}tz2owLIsJ8Rx7<(bgs_5wU$z@CC*CjQL7v zyH%2;n)EctO+z4(hRZ7&n2Wg{BWYrulTY`Li;?%5Z&dtZWMtB!0n*eYz|>!~kG4>k z^J?O7-SJ!Xcin5!p@e<2b?@1yg9U*J9SRV9k;=|J2_@~;gtwSYkZ;xw2N-DNV)a>Eb`X}KBVycC+^yt6b9Lb=G<045O&~HgXGHg7s?NN-icq4_K9Kf@ z&)MjFv+vY=oik6O8NywAy2UVsR*lNo<@RZwHw~VV4_$>HPc5KI_Xiua+Wl}_^ZX(MfU8=%a%^ue6+W3g zGB|g-62J9FK|_5SbcYJ@qQlo2 zqCSk99ujv8P-4Q4nlGaOYBe>tl3r`R)@+JFRzin|)+!7QgD{&;-ubL*4{{0s(gz|T zu4IH^wQ}M2)my*_Bu{UV20AVgifvW8w>KlP-Q}_HND~_AkvtbKGitD5HxSMK8P{Rh z4YyfLgu=P-av+)(eY>eNm^}Tf#ET4!88ngo(iecf zH}ROL07qhdm<2Iq&;s5!Vyn@Oe`nivy&fnqUKYu`xWR(7=58Yt9vQ3+_3 z=NmNGn@;T{B7u^pXPlu9vq+ecQJ)LFo-BR1?JVWtctN290-pK{94~_Y#fxg(Q8;D9 z?7W=7!aDv}jO3FgCP<@`uK`rclOr)1r(il&e_7!|uciWza6K4kOsAy! z*R2y2O;m~4RJ!U#1%lPWG1q`wGaxzCA|!A98>wY{j#_gZghwDkC@8>k;aZC4J-~eo zcTtnLb>sUNx@=IL@m~da)YO1%!=p#cm<&ink??78^B*A4Qwo&j6+#tz+GLI_2rNGD zdmSBrAduT3hRX~1NBuk8g$NwL0haCmTi2}r_<^*4up9sX--w( zjDvA#tN{K8yuVmyZrJSccJx|@48D%~!#|kpCJR#m<7HYX4Mxu&fhhYWH?>eEGlS)v zRvD=5Ux}!9lBrKAj)8Wmlf!6G?1=)%puvUb$IU7B_eQs)d0u|euy<2{=#Q89<;jr5 zm%;yVK+`RsUCgg`r5P(CJKvOJjwbt0Ie=N-#DB z%aBo3ArNS|p)IX>K3i(*K2}<4@a%3(IC#&_vR+g9#M=-!m2NRzAhliyOkL!tU1VY= zOJ`*Bo4H)x01AW@6}EllK~Iy+@c>z;#6Ri&;eW_Fk)`ncuOX|Gt+8aO4+3P%|EBBI zx@>XXUnj+n_1JZ9nzF8gP2@fI#`4Tq!A92^D|dyv9@=#;qy5J26!}9S2!8;EX?pUixdvvHsik+vc2B5kd@jj z@de!7MZPaJCWn1YLVvl?FLc6?Y`VGvDEh=!O4HOH_|gGy%5BNZO({bZP>;dqo0D+A z{aObI(Hz7D5dosgX;niD;FB*(QR91y#3H>~ zzi$Se%Yy`)w?nd(HU~4?KUdq{zi8_FP*051Mg?p-NbsCFt~6YP>Fyux&>k_Daryu% zRZadcsFV&G!}%-_A*BMiLc|l-Un;wt#o~V@m}+X`Ywv&tI-x(dWy;UTg$i$SGQy~M zKLD7NgD#CPdVo9DP$-!8p9)OKahZyC`h=g|gj1h>Hn22ecfa3FRod7MRre^@Fm~i#${l3SrI`;7)Z-cVW~BD zH*wE@SgPU0ggO0#gfbRdoz#lBaoKs=byRyqIPe>`7Vx=3sfRe1?Rh+$K>y=(ApmGH zAO?svRHIr1Qws*H1A+=#>;|E{M05{^_K6 zo8BbgFE;a*ZKB(~oLT&ff)UW1D>{>k24i0a&>|Sd*yw9~dWB6hV#yRKI^3i$=&B2j zt~981bC)B#rl)f`0>sJ{{286FBf5BKI&cmy(?*@^U)kTpPTDNwZbjNuTYQH_p$e7(Ja&!`@r zc8Zi)FTx=aJ_-t=REw-6K%TuEbChRr$+nE~$a$Zv-hiMzD!Ut?1pC<2=&)UxO2%Y$ ziCJGPho(>OX;E%tU*BzLxX{L+%lUX*t6G6s-ji(VV#Pq3^ZPL@RRph(1a-F?y*k+k& zx36BKmNuxa=v}Gr)>(1##GYPiJP~JUpqwghkrX5AMfGWLxLXQ^z{4uU(Ads>_hpq) z^Y62;g}+dR%rKPc7l`5`slBHTMV>uDWZ{pH>WDPrEA^ zd!zEBfhL)(j8>*0y@jJy>6XfnSS^}+gNFC77wWvnB=}vsBB;tY`t^P@n_;u?n&!)T zN9TGe=HiJcX(T4upC%KxG@jt48kuHRvnVXq=p<8OA_e%-4r`*L!<=ZBMgX~uTHg!) zRL@LT+Bbq7DoY4xFw%bORu8V*-{}O-oB=zWK=HUn=gdZT%$$`6AgYzOJdAQ5SE8w! zbyV?kwinSKX9sow+2wmL{D#tpOBy3qhmCPZ)jq3g)NUq>qS@wxpf!w=;xR%L(m+jh z>U86{&o+B<593?$-J&6Qz)Jy)`E&*{7DxpMWeFhm#sM~uvKG!LYGTbI>=p~Z%2K@} zn%Z)%OS!$g-YZU%(v=LwYjEor94Ds|^HNhz6<)~ZeVI0`oWk3AAaY7GX)j$t$+U6t z7-D~fy+W`lhI5k?jNed}SNH5w819cI;-ULk8IWisgMIv>>T#1ZmDNJPxGB=pD0Nun zVtAz@9v(^nKA!1jO93#1)7vB$JDa%<5olypRpqy4P3lj{MHVg80WY(@bOCJ9@Q*yC ztwFin5ex$h^yOGsOiG(fR!lAw{15Fk(?&nMVjJ1(!CQ_{PNz0Mbfk+}0sD7pg&xfB z4>{~;4xgO>c4j7iCrE6bu6I^&i6wiV^lE+)vbL5VQ9sm4-`QSW8Hp=kQS_AS8|*`z za#%!s&-3hpYYbF&^3ZU|*Z10WhU<9j?&Q!)Zf*A727WOln!OMvjrZuK-3rS`XqwgvTR7F9YQChU+y^c42 z0#Ug9P$k7HKF13#V`**lh$P949hOReMZ7ZO_6-XH?K&`6?lRa^x27_!)Sf$=refal zalP^6uDjv)$OuQ}&&s*oeeJpQlMidWDSoPnK-a6RlW&>hznPBvQLGYrR|2lWY>O-j>vXJHHgu!`E3fE2U2G!mr11~JYX|KX8B2PVZ*Lj%f z)&NV#?g?Gt1XL&c4vdK*>QUO)1277wK417OLV*vBMt#BQxlu^p|BU{)^l((ihr97unXo636sN+v5FNZuXgq%wa>LQ-__Pc18tvF z;mSgkb9b7Ye!(Fenmk7Hk7Q@!SKiB1vo&DPaN}jsr+od(#yBL5 z`AN#@GOOg&2{ZgwCag7B)cEQ1Qw_Y;DSJi@KH(DWxCBVtpTCrb8DwlS?9RnSBm8O) zj?U{1|3&Tfn>l6kJ>j$$zQ{h-)(G+=cPToq_bj(pxW}9k!tG68m|zWFNBWqhB$ER2 zBZ5rcOEac|rt6kfeo3;D4iXdVj;GL-86I|+Q|Sb6yUcXIaK#UrIzwQ^p2b0=bHUja zp@;D(2jOAvptH425&)^ZomD$06a=pm6VdxP~3wvlXtfGrsxBDwH*S=GKk$Q zmsdMlsaY$dD!apWZgv@p`+aqFIHSsle^{dU$Ktg8`B~MBE}XoV%sAvC>cqnl zSAZsOf7UwmXC&@WWLKkfx)1%SIqF9 zt9b(K;s;C@&pfVH-9PHMiR8`zpUXTxuF^NDUE2WXoLrU;sTj%Pcfr4x4;=Ae;m2%z zVVR|Duaz<2lV$F6eyVI=l_4h%tl~#SqZoRXe+&hllH}g30-esgQmT-b=cxVxFW|Fe zM;I=Bgsvo(-4mX~n%qNlz96SH9rS9q-t|C=8oOpo@7SVG!uNii=A;#!;3&7}j|ftO z=hP=sp*lHZr_g5e%C%|}n% z`8Ym(x=vbeJ$(9zlLDxgydDmi36#*PK6#0Z&nbqKe&EQzH2M^9j`+q#At{^;EU8mO zV4D27;T%XY@{}y*&j^`6&`)v-)75uR zOd(cUKmelLFoMtNlNj^WZN2{Pi8y-NR?Wo3BxJWXm1;HwVWm5o`oo}~nK#oUvDC88 zA!p~}BLcT-nUqItpkAq;4K?IX0mqa=n*zNy(r|u zxT$6=gsqw9iiJF#|Eb3?|7i5&#jcz2DJ{>kKEBAjUP^7#mkj}$UoEhyPE7Flth3&2 z7=Q8>eYwEa&4zR5%;sH`eQRvmvrSh99GHxdRF)^cj00_0vsk?OOk58PZK%yBfR%AYT{UZQ?vg!jEV5%SRQEI6O> zILZJoX3^sFla4a{W6nsbHrb-L6lK4EPPj00X&4cWi%IJmdB+0Lo?u3rn z4dlvvDwU7=1R>LVg_rS@=k-YBCz;hbJQ6ydFP&rwoCEi* zy7r2xd2xR8lKPAPOM^WKkK!`d{F`K$wz>(d%gtn-$Zc8}(-GCuaopSBJ{7cu3 z=J6vKEBk)AW@5q(j$F<}*G(WotCXp=OdFGAtr==U10nl%dT6ed1ttH9o%KW1>%-I8 z{N*i+Td|aQ>v+`@pPVn0{9b{{w?GoccYqXay1eH|rL2>3J;bDI2X}1x$yt+QH@_ur zWi~rlHr|Th%h#_x5dI8etN9h!bpFcP1qpZwi{qQ;Ce3N1PozG|luGUr?%RXD+N9?0 z86U*~ae++3qZB_&*%-o@yaN&BKy;Ay+DGRSUH#_vYtd@Q&`d?1{=PNGgG*Px%~)^v zrY-vCJoDVj6tp~)E&9ojAhrcdgwo5cEL5vRL(JpsePt1s5D-4{IUaZm2It0@v!?k* zJd!d4qvcnX60JdFYx9t5Q{vd*{$c}jX=ahF=6nH?^7wWZ;IdDx9yp@l7@DPKd3wwI zUX?eH>F-;}K0^vsLmf?_{hErerfzl!K23;wefu5+QaTZ)y!bs5-%nL@UC=gJBiR6A zqv_yJF>KxfPr&6CytI`#Qf7|<vfWk1dLCc;l8;JZW>OoCNF=`PNre5QWMe(~v;j|>Ns2E+S9w%r&9W;Z z{Q!}pdfoI|Uc*10yQFw$yv~tZU^jE(*YReroT<=j%(3gV=5UX?pfbg3AUsp6g@X{K-atK{*K3+e2j&ZL8 z2hxGp_Q~E6wsiZWp*p@!F~5r?8H%rw0E{8oJmzOxgdoxU6ex+4_xQ9Up%>4yl>6P8 zvyh4YsAPzBpl+`J`meh4iY=%Yp$J~Z=y7CIA$O$)UtS$LcP{rqdb$*U3#+vi;%umKP0Xr>eViSGQj}_P3VbuXhk-}G^GBj0gXq`i?AII z*C?G%sYl2uzqG*-cAJ?|iF-lk0%UPd+TJ^KYw}IpY>9o9kLL-jVVkJj8CPkqp1mD+9t)babSk5ctQN{X}`U zEK8!mPQNLS|Mpn*tt^DBDgJ7*Z0SB14Y>Q4Ctsp8`It7tr$1FUJs$Q@p7DTq_R~P0 zN_0#HJmu=we`YrW09{WC2w26ywDp6Y>k~(h++BHt9+tr(q*60=v@TAY9B;HKzW(Yj zTw52Ui^aS8U_D@`u_xJLVN9L+s!{ph~(XqjDy%)aLo zOW)pac%fM|A6Bx`1216@8NS>2D5XtPm5b+O0X>7*c%T^MV1ajSfq&r{sW?>to^b#n zHX2wlx?CQ0NnafiGTo39Rg&o@B0Hd%I9kW{wW4qTM1}Dzyw24p!AA}YLXKYbiButv zA!*IS*O+~5X?$kGHQ3sZ3Hz!9A{%y5N#$=BK$Km4N=+GC&KIXl1Dq{PmeH`k;p}7g zKVz}ePpKfFpeEIqA}kP#Hif&4)$!1aB13wvf(O^O)f0^BU2;j{kZ*>&Oj|Cu#^fnI zkVb?3>-KMe#_{<4jp!ceD}$)TV|%1f%Ko7Egs)I9J&MnG4S9`wTiau_I}{57M@mXEfU)%6p|W6TE#e-xJO3&&G_qM`X)Gr z3ckEP{S#TUTA9}P-ega{I3n%1yKa6c_)XF_!cA_x*$Nyp0ZE|@KvGDLK*WhNvdWnB z`5eklcGOr-5bg)}CVd<^HFjozd+~js-c>@LYAl$UGm|fZ?O@V~?#N=Hlap^Z>UgCn z;4m@^@I(}Av2V-x_Wf9;!`E#FEL8EhZnmv{a>a}x5n$M6 z+Ac?mBBS9jMcP)ls;oCF;zY;Lds7&|}DxvY2bNP=Lp3b{#&mkgTyO;}QWTBkTXLtSvjw{FcNQqrkobX>X|Sgidu6>-rwUXq@~krhvu=7kxTydL=bs)E@kNf3Ay z%~^PD2S$6v$%}C)CpW07x$-Q$0sj)Ub6c6Zp0&9<$18CiA#QNL-xaI^1PKIQ`KTP! z0L^GjC0=(H58X(%R*I$tF!Z@LSuD(L=={*;d(MD!2UC(sac^oA#QgYtpsQRS5R30r za-oJ&Qc8IAL_1mZg|<>!*j`~ZveBq2dT0v#8QQf$Anq`^sXpVpCZ{gZFPN$Rt=>U} z83#~7&)i;M$Lpt1I1!8+hEh2wu6kqc;ARX7z+^H>FNbmt!lMd9D>+-=A@t8E^T>M} z@E^d~W$f1oMCRbYN>#{f13Q8XTc49+E{t}i{M@GW`pD@fk1Od+LCrPhPC6InC@QA% z^x|8Htq3Qon7&iXR^fz8WF7`^2ET^q&wl6T0YX4`;bk7q}o za|r2~0(yWcJmFGjX%Z?-FrFWGzeb%hb*f7iw{}mpB%M=Vl$8qTb^jQ3;(sT15E8pjQKBy_IybM|Qx2LOpVY)!si|EuVsN%M z7v1v$JQPT{etA}`b=*!v)baeJRTc|6D(?MJj2Y|qlHPg1-9+ z;ML^t?|Bg)=$7eSwyeKY$^rL{lLI1O8YIvbx z2pMcGwr=c&>;4~QH1g!GEDBrNC;XQr%XTetno|II97+xHa1Rfp5 zUxSX*d>dP?tJtI$m8??Oq{vVeD5hXn562*>hx``kchx(6e{_77~XZQj3Ll~Y2h%@f_-^ciu z|Ge-|E=v)}=lR9H`}+w0b*UuYODg!UHlPPaBjVo%;?Er6mJf)8?SzUZ&;ER5C|Y(*I;qpd+=y-}`br zHY}YVT$ZNdtI)gVc`;tdz^6dO@#_(MHvh{s2$1yuGsjGTZ|McAX3+>ClklrS@w!jd zns4`JQ@y3$1pM1ShhJQY`DfC;{*OuP-$*xHtQAWx;Pd(V_E&|6+e6v)`JUe5cG5>+ ze&l@nGiS=rm3D)%X$tE(961<=KvF2>`#d;_I`WgUtKOBWO{v_ukK<;q1gIg&o!(9wa7_v2O@zF(+(3=5P|YcU`2bq)a>hOKA_`KW4AEz zb;OKldtU)sHkc{wo-)zoQ)q3}{N`ZsN90=oF~Mh$F9tl?pC#M@d+*tX@{fAYP2#=g z=(n0nt3K9%C`XZMwyCuq2X5cRcvcARzPl|ZYYE{3<}88yp<$!@7~W=Fk9)iq|KXz9 zFqTygm`d;kkugdME$}P8*bh#9P4>7N7C;8^`<$Ex4gL0vjo#Fn3Sm3!bp7FaAYMfB z1FC%!$+vVF)s$m@3lx4D4}_Y#=BJi6fikXVfnk%Ig~ie0&``EGi`P=SkJE4z;yC-u zaWLdE^!0tc^V|WUJzkS{kHotk&bNU#`Asbs*O&_evmrh$2UF2By}pWW4f;1b0(T`q z7IZ%oFlX}@_B~HvE&h`^1jz&2#~PyLP>0=ElF{4bvdE5oWf^FZkoJ}&9Z#LpCcE`X zwb5P2$GwMv%awM7Lil0sHVS|h4?^}LNQGXpX;=35ov++^zOC|g?RsJ{L&nSo0yKwS z1IbNIRceXP{%?RR!sjQfUi9WH<A4FKD(G7xcETtNK*h+K4?H(jxhxti}66LU~Kh+4pK>b>Gm*K*PpfS3mivFucM5e%IqkNNWkgZJ#bK!*JU5SuHpSvKrE09VWuZLA$d;shQH`nZ+^n;RQUuj zQOC8?zB*u_yUf%pmyJ!H-niNq0}Y3IL+0rz>lKK*Ay`x!eE0{afG!<_fa{LLeYYzn&Eap-8!23uCkB_OvE0>Z6RMUk#vjkE<5oU?{d>zabD#YcfUh7hQ zGnPMe?2F8!@*N69ZDTbDF+~LmWz~u~_~l<}e_8FAxc+0;#T%Ia=B@xa{P=>f?Wk zHQ%;=Z$SSsIUtOFHt0O47swD>af5xk^01oGI-EO;2(lj6b9As%!_q~a>(hj7EimjjhAwYgEW*G75R;UWcLMR@TjSAfOXaE}W=ur&RXo61KL6_$&*tR|$+2Jz z>VzTGcOHuYolO%M5+s$rdp}<|Z=Mh2JP*s(Gx#1=!g}p~HvhRdgn}1?4j#4#_+bNk zI={x#QlQ2r>F~`u8+;5jlqjkY!g#=w_}oa)a6nLA8=M0uT&?h0tpkvSKj6V=(Skro znj+Yy@feECw%BsFK0=Hf%1hWlEe7DY6gAs=ht;atqUTD{i*Z6!3lRYM0wafkKrdoBA3~{M!`K)jIZP&yIJ_d#H5y zQgmTo0jsjYE6n7}U#)=;=!xKTrIn%BCWM1lK&eg43G6lJNYk#cCI(1)MA&=Bps6J% zt`pl*C|Tr^ewq4ND1iym;Kc|-|6T;c3mcHl@%gPytS_~P-&TG6{mS!9t)8uMqQ z=b0e9ep{z!vXIJh3z7)c@LL`L^m+sqq=f)4ucJx!BknP!U5kON@-D9EQmOoKZRmH0 zBkqSuh?kS+w=%&nuf^9I0>Y3N(6ry~E}RvxFV4x)xd0+} zPx_z}_s-fBTIxTRtUaRnO2U(#U!1HO2z&kZ8{4g-uo?p<*u|2VzT4`wi9L3;QRv?l zAVwN~oDlw!i!aiGXQA@;X@`nyp_N_WnQ2JT#n#w8|6mI~bnnKFJfsJ}@$>(4?FJImIKsw_p{ji^>cOJ%@@9duTo=;``Yuxpr+*~+w=nR&Q? zR@z~dkf{+j#`R)wiHA}Hh}LTt$1AA-#HiIOE{W4rOFtqk_C1XNUKRAz_Td%d$YV=P zf#Hr2ez6c}!ZxyJiThQRgK3bfYWk~oI)ceJ#AzS4$1UGy6U%c31!q@k;XUPr51K~_ zKq&~Sj#S_Htxm5%2WRG_8C+^<;B-v56?YeiO^RG30YoNdks%Ns>5Y}A{QrPs(8{U& z8Im!Sp|?Quby2zi_kf1K6gwjT#|XpWn9@!JeN0}gVcuyJY9s+$TNmyR35$io`S`80 zK7B(E$Dl8V4j@%mcK;0oLq$4HigsaUb|4KGr?5%OfiV~W>!xVQNs2+$@$#-Z?LD8wh?;zNGi|sO%2`w7~q-QC!^@8z4^Uul5tGXzuhEmQi z!?{1BXV^K0ydBQLwLKQ}xSub?8k-RJ4xgY;^Mn2|9*Y?v_}D( z)K|2Qcz;{O*J!#`UW-B0|P;>iJEYXpt za|!7cS#&*~)p}FCDBt#%no0kw)B{j6w;>WAyP_UqKKBFUOzcU-hPE>RYpLfCs;TD> zNLGl2L45Md`_A}*>30o`otwAG%^$5E&cl-FBfYLC4mIKs6^7RpR$e^ZV~D0uHl4}c zuy{f#K`j>Z{Ip?xHx-9|>9i=5q^e(=zSm&YY{6N2YV(;{<@;IslEgM+kKNj^EOjcu zB}Dvj$D;tWai1qT`;1pMhloz|uKtAIIR}`G%0G&Cw-^wj7^=km(}K)7rF5@o5+Gw< z>`gVlCiISOGuJm+;kN0(Njsc)_o^(j96{dA)tiDq!m+Q~_i9#5oo9OXCen$jabnx~ zd)bc1+4JMMP|^Gs2)-%+Twh1i2#-fwXf6-sV-&fdZA0H8N)fQ-oDd0yDG$zh${+R(iRb^3*Z;JLp7Bg!<*v{tKqL1&Y~BF^sIuM0vX z8yU>vpm=fk5lixdUZ~yOvV(bSh(l@KE-tw3G^eMpVu3~?JN%9@8(Z91<>Pef5X$OZ z>w8JXrcf&8-Pir!oNqqc0BK9hcz*l40#(Un17bs=s+5lXo|R`+aam5vD6t%`BGjDd z89rVB=V33~m0>jRTxCVuS>F2$G<=Z-3nk;MPfHHk#LEJ>%?p=QN`-A~XLg)hggIcsX?1 zY)8Q6#5d3u=jCY&>0T)JCFflF2fz2~$Q?V~(PHyf&0bgY3?BSolz$;_1e_inAEy>q zqb0T`N+#$15n@wh zzs=3Br7oh@5QTV=C>GtgJ}?wF#a0_-L5TSsuo5>7PHp%>ljHNcqI{DBG9FMCDt7es zO~JZ2DS_dHA|=QVr(0%nmqcz~7VMAkAk{-QQ&}G+w!ACLBl*e)y1A4ZO>~Q@Vxnop z)wE^6k9<|yL#djOTL^WX)i19dGAd$Uo{$Rx87cp^f6OCbl`=2^e92Q0EC3BEulz|#`cR+#0XKY-?(2WCPPO+!dsLYWIXDgCxifur5kCqZhl9JlwCOiW6( zNs%JH%g9wlmDfdYT`YKhs!j@D*A26zCnOEjk&65#t?5m7$RHE@S(<>Wk7;lX~3>@int< zhyiizV0hS;HG}U%uF~_mpRpf+4A@KNKXYspKa47eb3x6hPL_$=l|uR5qr~ZzZjZZs zTOZZ*iq(x4eE;sc|H0l{Mr9dp``#ekAP9nVBhuYnQX&%4B_SZ)-5rW_hlF%@BP9*e zozmUmxgK15?X~uP-|?RDemY~E?H9i|ka0ivbKlpT^EZG0W!HqxW8r3Qul*e=hpkDp zv|n>fum*WA(8j5`Ri5+MhL;9RY+X&VldNsomRntjnE$k(Hwg0XwhN{;M;bHu8&j5m zC#y|Na!0}^nr5C%s0Lu2PxDy+3F}lR7^erT!Jn8Q51^=#>yWs}|1#ipAK9My1j_p> zZyqFrpz|nKkC;Il?6MUPbI;_8ACbDlGzb>v^bR@??mO&u=1-r*OeZeAG0nwnfKh0u zQ^qjW#rT|OFz%y>V}LM-BN}of!&{j;e*?YcD;K+Rj=L-4*L6OK(a`M>i4Yp(NMd7j z-XMEQ@Q@OS#n)wyrg6)wzlsy8%+pi&1ec(Z-jk~^#t>XNTD`{;Txm8Wwd}`YS^^?e-W*n?-{o&dP_~O0iEvL^gyhN8b&kIBRJEc!% zmSHx**FezPN^7_(b&}1)3fiP4SX+h%PST2eb*e<`oztf|vD9_n>FPQeS>Lb;vciaI z?X2^Rk%M9_<<0m&j_<#2olLk(PZY<{5vfGOOrb;Uc{KZI$~*lDQhH2z0!fcclh>}c z?VC4-&7PN|+`^+0u!}Qj)=T0Qcbwefe}J4Xke@&bj8A|EDxpu*EY@!QHT8y!nZuL6 ze9{E8IXt>v*8GY0e3OjuaTGie8Jf{b$Rb)OQ4=mQ*!BY;_+mcIaPGmVytu2$vfOo||MZiJ*>ZkK+04u>1L z!z9{-q!o@j)gD4p$&kt)A;9e+@TZ%Q&%N#XZbsSRW?e~YXp)8oydN*NzD)asml~Se z_)S(FKP-F9xpwWT*QZ()J0kXw zD*c>$5XKQNvj@fqn~JP$^Z3TY8XZ-O{5m>sgh+q8Y%xZ(H#y8q8${$#cjd<5r7`UY zc4U4no!k4#M#B5CWabzvGmZ=0n+g7#NvHXjWY?gvNSgY1_K*cfNsAq? zEFb%KX8FY`DvgekgNx)OG{o~*FvHxY%!50{rfI)A_ZW@WQ9e%}UnxCzrjX{r6SVGH z#)08iKFX>aFdAqgT$(H@_nXYISfFlI*DPZBlT+RaBFzYqb3{D^SK>bcSS+a zrAQ{=gJIPGCX^&^T74`yV<2p6QFl!_)=4cqK-$Mg*A%D|WSE3cQc!>#QXY?=!)sdn z%&OAdiyW>rU9fVr2*v#PBf4>mX36eq4!FeDyRe!^PHKGe^VKSTL&HStZM;x^Ht`@P ztk)}04=IBPx7qoDJ@1gfN!`G1b@gMq!j^*GksDNTI&RFg3dwY#;vCsp zY6CVxm^qsTlLIafg_y;5%?!*p;~&4?uOMIV8!+PjCqd*bgG}hIhjrHRj9vv}Go^E~ z$sOo^ej?EP1&CA)r(#4$ru8rCQ$AD9X_Q;QM-rg448}on%{DCu%ez5-lt?BG@$t)) z+xdZmhh{!xl4&t_(h{LPM+k*P2sx-fmrho*UYD(1>dy4 ziWPb!NDr#HmUecfuxzcMSWJmNl0=)PzRA&WAjV8U&eFvhmJOaFCGX7Q=0OjWcvHlB z_UwI&D%Vu`=!lN=m*MWBne|M2(d?ix+S$l%T)N@w2u>oYq=NHh7#k1yc}sKZ0NXVWoKNtuoZ*KjU6m8%({?Rov{qWlI_d{oRU}Gppv!)?*)xL#j*FVNjrt zMZ)W759v;_(;_qW>z)0cU~xOKF@g<^& zK^SKq9sI;_z4k_VK+rPtg<_(6goWL29xhaC-=C}HUMq6KNVV$K!~pn6X?|2BngU(5 zSZcQJQgPgZb`2pFckmFfM|MOBj8hYG>#k)5yM4JBC-Qs31F!oe>K$`otiA#!=y!eS zfn;iOPF4g3;^{O#1p9t~6`{CTrlN)+`UMp{H=UHcED?W=Cl_rl=Jz&_p!Ww3k z&hYp>)!G_QwPQ!{E^S{6yx!qwo?Dogdyt&*v}_o)8!rwbtll+n-`0ChG0RkXu5rS# zDFqk*(cbryT7Fh2dp~ZiapJ>1p#grOH>CwGp!9ggL&_5JWgD}1f^)~M%rfuIKp&kv z3r!`Jh}J2(ZwMy-$cYrz1AhQkBePZJ=_ZqEc6a#sceGm_akJ!gu*~FR|2Xg?0h@B#p^cL@&1B@BO!(C6$70E1dIb{SM~Wf?@6#Jr8yU43|^a^ zU%(d5yknzQg$bK=6?6=W0mA94<}^45*kUwD0Gevi%TsyxXb6BL(3Sd60BNeSiv`6Y z)=YL7TwWOv-^oZfmtnuxB#D4|OKv7~?P2!>&frbGQ&8;lB;({7XEV@JQL)x9`5}Rr zWm>AQ)6igF%oy4$WX474|I2=ZWW(hIC9*tby;5I6OE1H%DwlUeYs5azFiZd*LEKea zb-Ea&(ES{KGM0G^`s^Y`r&g`r(L@T2(Hs_UpxQ=(0Q1c4 zu<{w6k@oo0Nw5OFNY7gpF2E=HX);`rflu9v347-6Ms!S7mK8Kg<;O0)Ey3-p6S+7HSH3Ie zPXGhWSmMJ5$v*q-@S?7}+7X68o!Dy+V1PRp#k&mbPS3sNGM$xYVKF~Q9xn&G7k{?6 zAqAfyB=!vN{9u2996s3k9h5DenlNT&$T&=MgNE~1QJ=M>zX{Z$mIJX`de)~;)Cd^i z2!$7}BB>6IdT(pMirLcTh~OSo#6E_`k~wk}Z&KZAGmm4PhR59%8O4j)nxra+S)6C< z_IUO3wavQtcr~k}+a`Xcm13uh%^roqk(S2!^L;Tbv0^xGdzQH6gC`sO61DMeJ>mqS zCT;#RlCL2vu7*iZ<_>C$gNbJStVmLxo0aoW_;h|b$2pN~mF?XnuP%EBt)KFG zmY0E47I8~olO%tNW6jNaW9%B2W&LP@RoeeGHpt=kLkxphZ*W>8 z5Ca##^@*U)hPj0{IPxpFQI$eQd5?46X&U*x3#&ilJC685{H+g%f9P)DY^%cIr(Hn4 zy=o*h0}VR&Qe4*%p&c*~~8;G&L+iuK)xjSXA&AS*pDz+_7)*IJwGf=qHr_ZHE{E5}j^_?LEoLN)l z%>_Q5slz?(hFelLHQU{u*%t@KJA&Lb^b>b}M@mJmL4Fqf3IuGzGX+x?8gycTx^x?z zt#M7c8m(4GI=`jTu&{fHieMg@Q8y9En8AxR-aB3f#cfu&dLkV5i?WwgxJ!Ooozv*>$&C@%FFGl`C< z@lSo9UL|VS7@Y)Im+RH{hQ7xn3Sawa5u0(r7LU1+LrYn)nJri}AS+oCW5ut2KYhfz z0)5d@uRPhZer==+Ib>r%u9Bc^(sbr*t10F~+JI-(*{dB1;_cA0OuJ-;@--p$O^o2m zx4io3gz^R^uD4-p_z}t6Gf%!If0&yMf$lLOmrn1s*qf=OK{nU5*nGEH?QTua= zirCJ5(5}P~uUyR2R62J!LCh~6y5y&s<7K1k)p7|}@+TBu3!PxSQ@c%u483&<&4KyT zCgwkl_Q$XEKTbKGG|lWmP*ie9cPQd59ncmsp2|#27W8mLvBcc*e!m=WnkT?EP3wS8 z%o={u2XXe`wAdL+#2xaW_` z_Nj7(S*ClI()84p?5;j^r-=&hJPTDFjuh3mAY^^RaDjl(5jJrzacPTt$x|nOajTVs zvowL1WGM+3opJm_f+V>;3^kLM@6ywvzQFf1QNKR^EG61rCXgd1mn4m!ydzF%{ppMm z6!m=j*^704xUPe?7UYc^^kpY_4dcp)rsfJJFwP2yfb>xRZ1_#{d`Fibikl8lCB=Um z0%e@2{}g_o&bJrzxo%cE-GOo2@FF-B~Sb;Ss-=- zOX1y4U4`y(gM&SKuzF5kRZ>hQPZ}2S;hd`2LUEW94;E|FSR~0@4FwC9NMtN?!)G}W zrru{;$L-lN0$U^TI@VQQIXntQ-SVwU8INLR`ILh<}(@ND*noKt+>aCUuK9Q*L`ZvFRe7NNW~b*tyG=&!xG zHGU=HEESDg!QR`bckTCErPZ2SYvka%82`wKCAj2hU*8o-c%Qvve@j3H?jIKjqy+Qve>kK6Agule ztpZ<+M{eLBfBoNrR*`ytc+n{p{`SX@`-#&nMHLTiOP!tA*ATFZ`4RT5{cm7ZJX1Om ztr7Jag7JDjiw-$4;a#t_2t?j9rK>vumSnHD+wRi@+zNdB;N4snTfOoKc6M1$PAk|H z*op2yzSg@f-JR3Cs2%KkkUuvVH<&c()h5gV{6HK=z1E;pY|ipXrp|@?E#}VR1W(L@2*u zcB-TZ`To6}%kBA#j2n#S88_!7zrS%zOnv7i;LpHVWSCuQ!6`z}|@ zX;U@o0ZN-7tM0qf+2Y!as7tz_=ezT>a6Ru3r@Oh@Qk$^w*w*SpWttsqJ>+ta(&$L& zN}b)i)gNrqA(NX2M8vZR1MGunJ-e@;Co-^GZJv0Xurbh=6xvTYr z6CF0+N;JC_0$juOa5#QAz1fLmDXoF`l1rSc9Iv?%<~I?sfHA)Z#fx_^9$a$J3Rj$K z?@@0Bd-g$naXTV_dQ{lugs~(PzrNF=y|Kkg)C$BKjK^U`5WeSfwDpZ6<1&HX9#yfY;hzs!REbbT}Ua82ZO z*RzB%2QtRKYLuBK#-qWce7|pGV`TX`#BIjoe7hOZ;B{EzdUAz@_Rd#k@qdf2f*+yU zc+o@=-TD8)S7kAiB0pUYX3hHYC!@l5=vc7MVJ)e_=Kl4Yi*UPLP74*FV>%mCNafDb z;rEKO5u8GR>@Mt0I#W)oT->`kO>Bg1Y31Dm4bZFiHT@L!o>}E~QN0VXKk>pR;(~;* zJ}0*7A$?9%Vo>D#Hqqi)tdP#1)iNe70aomow3@We85kXne;D=4xC&apxNJ$VC{|r~ zJh`5&yC(6p+`L4lsj;|?-=| zwc*mxktw9F)n-xv854gmIip?~sIc=`GQO4}^qbg5QuzuZcw}z0-4euA-sVDz zI+(|Hd^unP<{zpH<0n9zFQGI$4c^o8@#5~b)2>kh#oNsB0S209n>Vj6QpH9ML);H$5ln&YD#eNp;Df(6@|1o&G-#A=)9cch|;@YU?$3uCQ~ET4;h5nQ$YA> zAtH!U$WP_Ah%JRMjUB6Tj;r)EchthtB%s~&P|%|Y26w=}QI3G8pbcYda9543zuOzi z*p&HMz;za`)w1V_DT*zg!<($1EVVLE`4TjW=ju4J`|}iu-Exhr`Ubv!3S?)$Z^AdQ zME}lqI9EwwJ*l&0J{Wf?q7g!bc-;T%!^6J59Jxq$zA?*&xAc9-D-$d=~66l4(*H zhlMCx3cGvwn*3`czP249H3Au}7)H3EC-4|VxyEl2?>wd(%^Q_rVw6M%{3crz%|QH# z?@7yu^}65m$_w;DWCed_8@sJZhp4J{cdWW|YRi3a{`$2{CgyP3#E^#1&zB1ZxZTxq z5qxqa%~rH#jn&nN{cU`(VV{{K>(XgGB3aeO*Ts~SJ};$U_n^CNo_UInE4&3ezA+Ol za@v_xLqXeiZ!bw9ys@w+q!-jypN-Y1cn0J4R~?i9Q3nn1hy7C>L~5ZD+gQBInDqr2 zH4q5nivmY7RKw(zu|B-XDy_AQp>(V?9#&gR=$RxPmnag_sB=zS>H_|&@3MG9H$C<9 zA+rcIyr&;R>n(l|XjpM}nS;d zUbp#V{I%&>*#(lXi1o%|Dt}mQ6o&!C8I+v$NK)tcMYZ(wOY2i^mtYI|ykFu^gVsKHKD?$Vf5C4AEUIePBmACc(UTuJOEiZN z%GuJP#soc+1+V^ zI(TZ-d4;Ez3HJDRgsOa4oYnJK0W}~0xSPj7t;_;fsTic)28Nd~kl|(X$p52F=t^YD zx0)P6Q;B)K2zsYirrq+Xa%ZM0_ou4ybVgU{ba@OV2;NK8VS5IoLR=B%c!JSlQ7_5= z$VjIMGVc!KmCxNo{W<1%2ObnoyaUV#hbp(1`55b%!6=izwvnE2qkPZh=WM5xn3;`u zC>W=SqT>!;VfE}V;;_5WVnTvB^k~fFG2e!T9X_~;nCs<{84%L%$l zh$f2UB1)Uj;|90zOU3zv8TVQJ$D-@-*L(J4Ba)rh(QylWUc<~xsJnI#&BFNV!^p)I z@?e`%)5A=5_cgj}R#AbxX5%*jfUt}_GWynFi5(rLcGdIV9Ve1VF#n^u?sboQ-Wztu zrol})hN@Rqi|j|QLNT6ZB|t|KvqiEyg*CAU<7 zz>|sXG|m@WE2uD=)mqE%F$ts?5HR-8&W=LdwilaSA~FWEi-3J-4)Q>taJBfGQpOLal=UEvgAoY$VaMDILXdCV`F6Y8Fe@ljA~EDGx2pL&9lg^_F1cw zK^x>9OkdYCUykOZd4AgoQx3C!`{A!I4r$jZJX#MITULq#9L!9c!%q|;jT-_;kQDD}CyIDe7ow%?l|@C)v`(u84*R<1om%&_)XU7Y!w zN#e%BCRbf-@~p353xRZ5F3&GQquD=~HJwuh-`chDu>3gfZvpRh9Ae0K z++$KHmk_SG5h=6_{pby4BA_S?UqSze`YH2V>*7a>)P;%Bzg7U)_saaD zt1pC<_2Zs5QgCWqOixs_Ve}?5 zRgK`*;j@y;;=%EaZOgIC;$0id%yP{>_T>_H{=IPggP_(m{Vw#aY6n-$YL1HDr6#ESP zL5&Df4~vD6jDZQd{RnQtZ+>#M^WX>^oC&U?g_)G7IfSMX@DWF6IK1$>n984NQtT+l zkRvB+dw@z@heINnCR_6HXuR_TU1v<5&$Q=ETRFpk-FM!r30p_qBIEfn`nJV0Oi>t< z2}R`FmXZa3^QHpm?=~s+M7B+wRV(yg*MQvIB$|wnN(BM%;&oAS^x) zH0s@h(Il7Rh@f2J*7x&2=?ID1S3W;($TB@naVsI1ZCQg+YU?7p(8Db~J?s2j4InUeKmzb028gLL;19pTt8O!uNZ_kN5=^ zTE|xma!bq3YWFh3I5_3`hK8w~jh|b@qqiSzI`nm7!VDJ?Xqkp8I~Pt6T&@Qs_&WQS zFMXU$m(unb2=Ut1DESpdnT0oY%`7&OH8?H1gPJxoMzjvi=u0j3sh7rc3(7Z!8MP)A zQw7IW#U%;SluUE|29((tgI(I@{2Lxv`B{0xD2g$ic9(FW`^)1j7(Rb6%_p^1eUe9| zaRPvS;N>gBMR~%2-ur=(im|+tSD|Rje_3FrI)=aJ?b62;kI3q_ag(777I9){Zpq&f z%s`9Pxr4!{E3S{KXIzkup?P#%*ELi_?ypU6pY-r6J3iFm5H5ZnjFYu1i3F@!_VGmLRAjLpP@E+C?6&o8QAJQ zF)OJiWkSr`PLNI?HvYMkXVGtlV$f4AX$sAst_3br9g<^op0a65-l!cR?Zi|9<}!61 zU;#W0g_+M!gIq0k7dvhU(~Pj(&aI>p;1?cp4mGF7lNdY|MRux!K1{i`gGrMS+6t}K zkdjfq;yP7Y_4Yn;yq{cxfC_23+6rJ* zH*vp%tnyYDZ{^$> z`a$=w$%M=x6c^`#Vc8UF#{PdwkhCgyc}o{_AoJRh;>nY6V1i-fNB7@@r=v5G_*la}j;n0jqDH^6CY| z7Xy}94?9w($x5%$_C;~=o9T!trb;MCsA)5Ptk3f1NNu0e#*oV_am~(Zyl=Aq*1`5f zWSS86BbQBEJvRutcJXGdJCY$E$4TCbc@(qAJ>EgZ5NfL3%i1~|FjHg^fSK|Q&eme2 zkc@o$AJEis@?_bSJhR%GP)0G?Gsd?;7pLIP2_c#-go8mWnjzq|@W(ThH#a|XQ8;nX zD5+_}@U6WZ8by01XJMlaFYQc89w!0Ux%dVz#CrV&H18dmroN1< zDRvl^$WXWj4imY#g6Uo8I`5{+L1Y8%!JI(oALG~HLB?+68bQ$F!5Fgu97?BxmObEC zZ)zI0xl0_7!cg5;2`=O@J;J1PF<{f4*ea8UDCul2Tw1+uFBZZuK7R4k)*&=-0(PH~ zXm|mxp zr0zOOkO@{4?ryCHPY-nWrp@+mC*ryylQ%j<6jU}C=|{ItEk3rQ)wOcQe3DJEIjDv!boNVZDax@PIA<$%$CmCt18_knHldFjvjtxTe%`hW&>dF07KrYRk z0(5^|nMhW_J)dOzxcU2|vtKzgk|_cV`j%pXB^zSa_6hNuZ#8ZOfvzUG=3(4v7AN<9 z<+AG*lOTR!{Z1?lzY*2Ae%4-fYgE(JEeMTdg{Xt@@S$1K&LlcrZM}^)zGKkhns<=a zL2rK{x7htH6I&MwjiYs&eM0fz8%sWn{1THk}4Pn zMa5(3`}-%TS-ge3PW)YE#GkP3fqW0aSqM25mFp}OnOgusrw0EfrNtGSI`U{E0>fRl zo+f=9S^*SeF1TUMgNd}2IE-USR(O$jW|P2Q589phjU89c>h8ofdoJixthPdM%k z-z(;|*uTXIm?mq{0!@Iak_?B>#+_S3x;c7CP%KkL$3AuOna}=!nzm2e$&0?kUbn)r zQ*}ux$kO+x$A?@R?F0!`0v!&X@}ClOoj3ES?;?@vLEWffMb-y0*ndlR)!L)Eq#cac zmd1BJW14JA5mMw-j7!Ov7`-{^3;&5{wr@XfMB7{gFVQsEgSTH8lJGB5JaqBgn9j4$x(u zLwfPGtY}EP1TKVknH`~Gvw@gf2BHmCqcQ$oakG6(1qzyov@ zf%cr$chvpYXrCES8?ulm2evcX^(!}1>b5ghnutGXJmb_$|LbRB?*x$4!4ZtZ@AK*= z!4|8J7xLHwX|>h2pd&VL*?y;!DWIMn>=26iLjPDn{lTWfyV_vH+iV5KCKcBy8O7FU zvab(sh-?79!%`eef!HDrs%cNFO5k;RCoDnkh=xUsBX`eS;2IEJ^oPB)+Zr`$o3_z$ zbZ5h?0GhnR%=ptSy71|MA3yvPIa+8m>L{pFsWl-Cuf^lrhzTN4x#MwIJE5vxiTC=R zCFn76X|*)*sBIc2H8hhc$X1EyyB>pRo4__rQpNe?MFPw>_Tq9syc)2+>PmpT;0$Zg zEV@V}-xv5U;lm1de@2%hk>VNwbWS>tgO-3>5u;(yQRjL^FKamR+IV69{Ll%qbtc{WkK6=jm<*?<%mhZno+A z=YYuJdaS-}(q@aR*EA{Jfap23QWj!ID@Wk2`>SM$96>j-JpN!;_KS5MKVu zp58DzxnONy^W}+;SHYh>|{<_lrZ{xGz8BQ zWhEns3-zcm+8czU8#3;iQow;}3XbOjaJds+izDc!d)m8%E-(HP3kZKzQGbsR##rT{@%oj`8pYq+cb)56hYwf~an6ep5W3>7BP#q|7=J>8< zUTX4T{+cp0?|vbg6c+5$W#FwnU+Q9s<4f8ZQAam_hPs96e*=N0Jm*=|6M?)IXqB3j z__@h-%_;wCgXrkl+a%U&h=5k{;kKO%*An+~7^8viZr$=+)L{TF|CVyz;DB{tz}DSZKqYUO1%zQs zeSBkn2G5oIxjzHJFgeLS7)PalZoQuKZ0F$40XrKz6pV%fVf5r98zttJAwM_5$*2xv zmtx>`?87LOj#jX|@!LZX^M`O}b-wOa&xHUQawJi~ri`j6+P(sWjgmk!{sLj6(tM`y zvK6A$ljmGu-|H$6&m5(g`M3-x2fCieY5g|bCxRz`&YTD_J1x%bn4f>(SulQtM+zsu z(*M)#IGiKl#AcySDfHs=#V@KB`x;HV09A>F5?j7yAp`UL>Ld$ zOAekW>^gmez->b;UFGy?20(8V2+x|D*%re-Ilf~>wC=H@8X6uHF|U z#mvpN!8oslPMAu$M9k$ zhmwSVZ$UNTs)IcmN7s!J4z!>XpVthQXc!Pw`nF~Bil_wFb{Tk#lm z4N+R{N959->?&jrG>Ox&Mv?MfbQ--s=u#`%84EnuO}!pz6tl4|^j{AWen*$=^hMuH zl$d^1s$~aprW(%~%kI^&!OtZTX{JiEAw)j9pd@T!OLmdN3{B#SOV>0}YXq@HZp7Kr zhE36W*Mcy5W%cXCPz387(GZ;l^lu55%9?JP!lqaF`SBIfS%TP65@*Rh7;2c2+yDn< zf_#};^-16NpBxk$JI|)W^D5m2=Qw6d&Rw?C^;Pb_Dy`$C)prxx2LVo+J%_ zzEJ0OtH0Olg>88^}joo$HJB0CB+APH51s7++|MCqeZ z3&f#_2le0){sV#XnKYi9`c>m<9bp@0RxG{Lav!W^e<4>a9I4yt`m8UUJy!3TdUly7 z2DQ(>5Kw4FH_z3CUlaV?k=2+<)A2GGd9QoP`^IknJHo~}H3QQVwaW|>3?Tw_jJus* zD6WDXG+0_2xg12Bc7E^}7mIAmQcQ6{Wc=tI?3K+6Y^w+DjdXGieB-X#MT%~>HSH8H zIqeRrE;Q#IH%?ka_JkW|MeBOretPe7iL3v9c>IF+XhKN9XIC6r^j-j3z@bNa>)r#yhh24P&)h;eWFh^jyXnPOb^hibGgt;hP;td zCP1!I+{`Q@5Pq0%vf-FBSiiv`2WBZci|_0*;AfwBLw}8X}}-kRuT0P~fx!(2aOP?`9IJNJ8SuV8=lU#_SWH zV{DpnjhuR(0^Yw?DI{+?dVh#j%1nK(I@w^=!G5M&daWYB6Jvov_rlE_bi6Z6} zdz+J0K86Sx`9KT>=)wQ>U;o3#^gjkCP=Ea^EA&6os{db}E)aWiIj$&F03sFLs-3d- zb#+^~HOT+F$&3^c7BX!2Q3Sl=Y)bt{-g_#lsKW*2yvPYkm8P!{G3>v8AaH;S@BbW9O*1|&6@UZCziY>ALxq9GX(GV}`(2(w z>O@Qr#;?365<86+9BS~t68%;7eF9dY>7&ocLgdkEOuG3@x`KRfdgZzs+CXE{ImFpI zO~@7ILgXKh{Lmk@>iZBOLcL4(1zQ`;ZB6hrkZ->7d#)?&%J1-%uH^8iYvp%^vq;J z71OKL?$3Vj@U2l}sX{8Ra%*HicYxE=Lt{a>t4MslMYo78)@YI-wQ_4{ve(snY(G8`6xpsT5f+IoGzVI{CI7mu_o&+^m=1 z2)A;XMMFFKj$YY$hNC3N0Uei-WyMgm*BNrOl_f5TTFaZPW zL#d7VgL9te*z`4j)b6iF`GXH6VCGNGlkZRY9$Y!7gx6&<&_~Ui|(*U2h(NI7l z6?=ye`^-M_vc34l)C7nz^Q|yR(X7=k+!?LRi+9}V!a4_b^YGJp!RKJpLBF<}qP?Wz zaH%nyQ5VRO_VWxy4f`roh%-;IBU0VO4@$IJ^m1zW??QSv?%-rKFp<(KFV4-uVkg#l zEcHvd89?(7=JfX1EIMdrZr&2*HI-RO2`tzWvQMgyU{fER67;%wm&)TX z@cIC<1h?B_e@!qp>o{49U3Y)6-^eQi(?Yths|Ssxa3UxToC$%Sj>lrL4y0XRPubJ3 zdc4a4srS9g!tY5Rdd#g;2(1n>b*L%cEFLwOOMmN2xyO%Tt&gh&)7puSD!4^Jk+P{J^>jfs=Y2Y;m%B;6QI(wRgXVUS@QRvI#a|`dW5pXEog^o#B zG;5N2y?z9Y4ht~NOlL@PMq3Q( zDaWr61Ui8y_?r=(WV3a8UmxxX27*qI)E8R3vUuLd^ki*EB z^H`6NGST-6gCZtNYja?HksY+BtK-=FQi6Xc4+9-kEx{yqlc(GWh)Z6euw>Tl>JqhL z^&*xfkWj7+0Q)+@baw)SQ?*Zxa|&grVhSAp0VElp0w4*!Mce%K)CWqhoa&FY+sz+I zW0c)nT}Q-`PkgF6`ITjX{Ao+ZHLTB@QM=LgGVmh!@g0Q;x$x*+Am!y&+;0MSN>gUx z+P+d7;2C{a5#J1eGzyqyI-`tKHa_jn;tHxGkw1G*3EWTWX!yJXULP6Lgw~3+Pq0mC zKvLJF1uL2%v8JCK9@v!lhRdk4xisr{>m(3EB}5j)r*zw<`k_URCB9fa8y<~#l>_=v z7A?_7U*I`j69uU3Z*yav4_-Rn)&6h`aEJzA;Y~asVBas~ig4j2sETIiA*7RjiFMO- z?Udjjh)DK2=D=aD<#)_U!rRy%PHSvq>l>%JI4nMz%-&h!o~v+3zW2FcNHVGy$?cnw zcB60v7@`U`wZa9d56^6xJEE!O|Jy=gZmHu$`g(T*DzoJT!w zyTkg3J_<*B9>a4k4;QKJN>95j|C{iMS?#~4o*BCRGxZGYPEsgOsXP>XzjxE%Hf?L*6%c_k5&8AC zhdLn#tjUcEfrlUq90H3LSU4;)9{LoXj6@z%&BH9h3HKLL@)74cd}kiQOW6EMqhg*O z<{U&-P{dV|S88*AQvA2S5@5)VSWxV-L2{ZZIy!yKnanmdQqklx-c~t6T0P_PI=Wjy zm)&)Mdu*QJ1f8V}U`YViw4A=f@Y3@-Kan_T_?@qIyWS{TwAr6^SO1Ruq_zK%33AcQ zV$@3Z(RQuyB$dWj*o_A2j&&|1vs$wQjAvAETgDcn21 z5ciSGAfb45Dc7=>4Vw{OVit!9ONc$_a_{xgu%wn*63rX*$$T6fqroYyiuBvas}5KK z-|EJr5mdz*@L0W+t2y2i(Q|dZyg(cN()BI#ob@pj%`_g;8nHJA)*>nM@4xOOWbb`u zYfG1U=gos0;9D?5Qq6LW$6t376(tzkH#$9}`KTp@EFjzFdXJz-1G$j%=KFsMMeFzd z8;VwLjI2+F6OqcSUc+NK@}Xn2GjMQ_vdngPDEtU5SMrCur#+8rSUUrJJ?5NVviVa> z0s(FG8g+V&*>#&|J-3dX^9O8>6iD!20K$EKviVvwsACodth*6i*Rlialb4rXAfjDY z{u8ER;7mHI5W9_u=td6!b$XwTIM2u!oJ-=qggM;LDN9jR#Yhr?b$h7n)1!S5q$u#k zpWT1fhm$Yz=#qTLL4D5s;><<}@Nt87yc}Mf7A2hpXp9z+|)~xfR zQ#QGR;ac`7L(8Qg$l20dy@0mT6$~aSWwIa{CP?E69xO+MH>%uZf69^EJ@v|~cVq?U zY32+z4UI3Nno_?U2gYY?%3-9)_tN7mDcKg+*Xa*4<1~>q!BX4l?2Z{&P?NJ^Tk^=V zHf)mW$k}z_ofpD;!lBD$;^qnpST#&Z1|A?mE^!mGzYZht#Kd0I->>e_%)BM`?p`Fu zg0jbA$3Mg00xwAzyLpE-tj`Nh0T2pbrxlRLZv6Rd0Ves?Cr9i}OF)Yvf2KQ=Rjt)k>LCxnZ zE5^x6ljyh4)@_1nu_B|(F5-IBAxUVJEObs$lilXUeC#Jzma94nrC+))K6%67YsWpd z$2}g~<55Hr)6jvxsCMtp9iQXul@=YbYY}9D&iZV5{#d=Nnin%6m9R^+np~yA_?^X6 zAF`Wpa1G6Fsy|a|VO+7!0TTk%4uw4H--tAR;iELwn5{vKb@mWhErTr$ z#jqtaZmpfs^<|VVkpth$TJ@us0}4k&3*nK{3VV=^FM zUU;M7W}&NM8k#P4P*oA<$1{e)Z*Ww_QH=X(0PuqVV>rUjjHTt__|mF(8WNNio73EN zEXbUMxm(5%LS!(lZyAlN_Nwg9(xMCemQBuevjnprCDbP6=Bv;w+EAnc9V~6-F=HSZ&7m;jOvmxw$!%mI|TL>cVGs)a_84gj6hH_^{90UtM~66AM~p zyDcVi=?gzJA}1uKfj_##19N}2^U~8;9VEG*NzF^^K{SgXA>go@K#YCiAz3a z`#o{q;Z$!19HRf9LbcKkqZ6Kpr;hba^qn!m&z!@55Ecq;uRKu_i4+oJ!tLKje!g^* znYK#$GJ|U{&6V<{M~z3_pUsa}+1=t(TBj6+?+$b~X$Z>nTZ47UjUT1A6Ms5TJta(&m;ULvENtc`nZbiATc*3a+ zJRyc1_qa9N?NAy|0nMF&6=&xMO-#q!vRD-XzxqLVG=irWqDdaMA!f|X)H&+yC4O4N z;~E_b$RuAEKKPoIAPwWCvB55>ImJxA`sVl5!>wvdLx7Ig*IkEY>v=A3FK!MD(R^!N zRQ3~soFl}K-G?btYda(POh%?ZI~0uaZRF>FD85`@&P^ zq-HC&;|$hzAaFsQWs0c!3>=llcAhnWd(c%eIU8E+MxA_Drws7tfO& zqzDb{ReroLYAJL~IhFASXOo$G*ZfN}i$p*6%41yCcrD2Mx&7ZF@tt)gv3yi|^6gDW z$@WgxpB@i2a=f!R{iG)esvTq@NKzOih;p5SHO*hKJERi1;x(rGTze{8@{AiVo1?|2 zf3O6^*~N9sSg~zzB1H?&Q3~sC71Un7pVrSBmylHr5O~*JiQ9csq<-({S>mwtpp)#| zgpPz;X%nZoIHrY$Htt~Fg8no78Qn`(M{dreHB{wv&B4V3LYdj3vY4qSmk-Rl1RTa% z&xvZKX!%LoLyP=sDEC~?53-F_hfzv#>?jXLMF&mD1N^hko0JWExQ40lvf~FG@tNFG zM%Q@V4mytRlB-=*YmUt-E}7<1KGtExG5&df#n_hOkkD8%LYBVO$Pqw9{UpeRq(f!j z`*&A)rviJ0PkKZ>n_t3b<~-dh`ws~OF+Gjc#og;GDmRNZaNFS~dBi9ezehWn6WtX$ zrH5aSIZ;)xTsj7!6P;U)^dGhON&i5I=*fC@QdQBz-%p9J{MmkYQJa{cNG!u}NEvp@ zO1z9>MTp0QvYXLr+;CD~bQ?v+KlNvQVk%SjnZ5;y=sTud55ig*Z`fH*XkJPDrG?Co5>gLd=c~YC+Q3N z+E0ttk;83%CwuR+`3D*quQyp2MC#LG%zP4J==!t_Z9fKaZ}KjNnJ$to1XCdAdSW^U zpjV*JhL#oP=fADoVnB7{94(C4l8KbDwW)AJt2nQa=|SnWV$iDRisGqo(;1<_6zI`0 zK3ozMaB)pd;9p=oL|mv0Uc`iDcm7}Py>(br-S_YuvI#i3kXX=J;aTxeV#yC{H4(rBs zZ)NJ3x($P39x=01YBj(0YV1$#lMv1i^imajB2JO=US(WXWmsE}*MDAUCN6i%T%ovJ ze~0ffD*&PVGNmm@Z7AFK$qqejZ< zbEKSpH^2e;h(PpO8gc^Bn#an>nHm*d6YON}G$)HO?tMqS@(OABVi zqSC!(W-bR)Sbj8f_)Vgk%V7}3d zQZ{qRzrBdf>rr~Knj!qK5EVMu`BE1%6Z%yvrDQ0jg^nUIL}zCXRLk!!g)juU>}Kb` zQ6rM<-^6K?uJD@&A0N`zJxwFMGig+rjxdaQSB|ZuIrZFdxRr4%4&!!zd4N4nA^ z^m`NYPG}!3Jw7FT<{9&h=*QHG9L~5X;q-4oQKUp~BqE~XyU!tU#WPRF(lw3NAA(L#nA23`d3uKf; zRKxYx(*=;B)J|gxU zy|k?skTZ!=DkNvt*MIhL^~YHqE-uX8pDI9Bgj=AwwJ->e{82J_E4SIorETL1Snd~s zDoe|a=ADjWzsFYQSQ}oO)oq(`))!K_7K>w&TShMCt7+P#aIRQJHZOj8Y(7-b8>u;Z4XH?MxMq2AWRI8#C`>h*Gk1l+t7*U1)-O+HWV z3;m7O$KbQvM+W)w6u5FEgST|D*Cs=wd+4Je_nT=IXGD*$ntIO_Q%ZS1@7)XG@8Qs`YIxi3Kt;~hbzz*vW}frG11DY-=(DV)h=h(F&BGKfp!9 z2Q|yNj~#;NAxYLRcogD?sA!UFtTa}h1l3ICf!DA+tiQ8moJWqjTW{e+#{@(BywCV$xeQTDxZI$UH;XX?<2o z{xW`4O}a~I6lEzkER^sNuyXc$lt^Ewwiy-=;1;Gue-u_W zO%(qQ;9pLMb~LwM@7R1ZK|B7DT-FOm;bI?{GP>KydBkNh8}Jj2!2DNq-&*UVO$V%t zF;QTx$f^HAwW2>m=SnIvcKo=!jN%)@wzU{;*MgVMZONJXXo)JRIL$GMyIHXF;OHcv zW8dPF&%7EpBRP2n`pkK77su1`yj|TUw-R^m<^x{AuPea@XA@Z-z*_BeOMidLFUngL zwkg#4x$`vPlRFW0u+7}{FFLHmI=-LVmTSBnc&fIP%STXo`$kG&g>;RD?QR{88&)^7Y;z^B-eSb%g4>WTe`nu=>P?>(JX$M zs&NKRYN2CM+{I#pjC@^$=5gp3wK*WOQe^UFKmfi#o=U_Z)=uU=-F_s}!cw-Y2%MX+ zz1yJRjv_f4zB?Y?hyGOJocqD*d-UXL)$HfnC7+uTCIq63yUUMkloy5|y;Cmso;4O% z&0{h#e1IFxKSWQp-PP6%1{qbP`aQ)B3JSVvRrN}eIUSL=8AyaWvJYWBvP?-e5TVCQ z7jV~MBqqQhY{Rfy&eYy8L{R1hzZUOXt2a%A3@GXbi(^wA7WxC`lWRr*$y+kS)%#tp zZMy_}Nf4;q|A_)TI6a=aS)?p*sy{QI0vAm9lU>R-t=RVyH`x8;7r4f@$?@5Z#Eurq zroNz{!*QKmBkqV)C=RBYYa4fyvmzL^rMOXfkrSWG0ISz8>sXo(Fahe}aBX}z!$+?n|PYtEq zYiaS=^STBc*WQLm;tK^%;|i*35Uz9fb6#Or4CLdz1zeokR=P>VQ#QlM#JKnr{z=Xu z{=!yjJeB=V$t*@(d{iK>?A=1^_?YPs%&8ZV~nz!``w!m`n`cDT!o%Qn*1Q13N zISG4(o-QwCD2L=x0iSEoq3qAEjm29d)x9quq?>BJs&E<8hou~#NF{W|)2Kuigq5xL)NEb< zkx@!m-;gGiGa zaV{)jN|Wv=$b4>`nWWnMccB^{D|?W|$SRW?=b9uGjik`%><|lAAD$&k@#Z1wv@=DkB^_ zv;s#@y4lq@7Qf6YeP?NZGJ0!W)xSJl6PT77HheM^9cjIvg{x!|p2E&y_sMuYYGpW0 zfJ}hur*IJ9hTPvnN9t~Hf*2espT;AotDks+CKvto{=U=4ukV`vr)Aue>(RTNZgLMKp5F0ylPF_e z6OesMGn9O|l|^2y?RWXc@JXJTB6w0}cywZvWE=XHnS>v^^*Wb*j&*d!fkSv_sXgd* zLEQlBs2inBe_)}E+mkSE!u%x2M16K!M|K~`Q{^4%3cEtV!%~KXs=H4 z*v+t1B`JlzsnnytYX$HKM#Pck70D9_6??eX9@Y=db$;v8L|fM!ceOZqLV~kY>#T75 zvKJAX>*1YBP}xW}O#mY`q>#IQRkmWSZETGeK(Pxr3_a$fnJH9nJs{F{P*X%K{=Ki{*<$T&$IXI~R zDR_aDf@&o=^apz~nzYZKD>D~u5lSfkfxv5Ql&{yIwg-(C-S-~WBs z|8Iv;2Czghs68_K`uhC7*Ny_zb%y)fw*N7|D33$v0oh!Niz+AQ+a%=Cxg%oMYH^aG zoK;f#hWNsU6@Bk-*K6`W4r#eZz}%Sn_*VO)=U{<#>bdu-`88#_Dc+I;zWBTgf zHO2U&d-phJWZsA>Bb`}EY!E)FWZb_$^>g@Zm0_JC?>pbER)2Koqb}zYfJcf)%nCh1 zZr;z3x0Jc4!%wK$rK@chgzFB0{S2t=ngL}rk~Gv>1Tr6f(zE~>*{^mCb#G-{-OrooUI7b@<}td;Z>~-$xx0Nu zWBD%OccNgWkCfL@moZ!%*KzEbHOnj9yD)5s-ugld?n~w3~ zFcFXZNot0^VkwEQPlXLWZ_PJ}C6-wJIILhD9%oMUt9;RmK=srn0>4y#k^bl=xN>Rg z+vJ(_&COM-V>(@n*2vnWQ1z%W1`r3j!ae}X?%;I`E;M^8HI3~>^Jj^7M*@YOP((k> z+L~E0-~w?`6<`Y5+dYgQ5!5TKf{^V96m|1Z49>8c8zL*65hx; zj}No%M&SoPoZRa{AJ1GMhumXmOtEJ(aTj6mq;}f)DJl~7Zci@ttaqim6>O2%YPE7^ z1f-8zL1m4aeB!dNXM0x*Y4jN%ULDNTnQiv%D+8gx_aZtiPi-6n zkRTMRF`<(st}?*g~PBsRHyt)GEy7EK=G&J_dRu z>B2I!#-jDqDl4Y?H0o|Ret?Y6;+UVurmaJd`pwm?=Waz(nxM-^uHtoL?pNAme$Nj> zm~XYA9ief~`#^`Xt?T+xdpw+xS2_)Eop(oGsEO2hM>TkEn;;AwW#HdcsRwOrX#708 zb(@d<1)Y}*Y+XpBBxeAS8C8@-QiD0Mst)A2RYx@AK==$X$~0P)L^kVS>L@qYZ!*yQ$c^iKAS~aNRX2R$UuqW9$Nge}bbUabM)&Ut)PnRyI?{qO6n zAHXkPwvAGN9Nq&=){9wQ=AG&ILM%dt!mNfyf@(!t*R%00mkeU}60&_1^TW@>?avk) z>^zZYOdHdu((EV*oXNJ?Uujj>62!<0XL(v%fz;Rm<%T{nAySgeR0TBM?hv)j@ym0$r^e6kgN;ar(t3NyaqlBL)G2%YmKgq-h3c*u z0@EVTUdsidQtSd6jaKmjD&>s_D<(cW00pq6v z7bQ>~R@bvb!&N#)wyDdrPyxFa9}o%;Z3u*HS&K&%a)?OgZT!C4ZJW~DB24PZK45uw zlD)eWlhcj9&Kv0eWfdrhd_V5J5DC&-d$*fBkQ=i$J>I`{{!5%5+T{S$$`f@X#A>w{ zJOhT zH0frUGT7b3XZJ!v{Wl1o-iQ4`l|Kt&C}R*|AIy(6pVd%1qZUSk9|BSfU%ZVGP2kw_ zWWN|4Mpj;G7eDZQO#O1lXMt5MZc*FjzOdTO{#^a*s#Yt?Gg&_C4}wnTJrA=&j$J?g zD5$wY)ez5DE6W#AHDpa$Q1;P%wUNx50A z^`8Cc>rn=?l4wsKWNZYzS@61PhoP^%A%)S2m*OhMNpS?ubwr7%vZw3tf6>mTbuTESCv1D)OBDFj_h?ISVb)l0k|mmBfBwsd~~%2pNFG7R+GTgjn#1vsBm zL@!?!`9<+}$&n0m2y4Obk2$|uLE6=sJjjUVMvHA59x1EtwN8pVfhlb~|ecTT*RHvgLThthuQ9a7D-B{z|YShn> zt*nXfgB?GAWp2jA2fv3&frj^^)U_(F!^%m}Ox)NfnVou0pl@@^<4*8UoR-h^d0cLn z@2OV=Qob4gaD(zANl!c%0k7dGZYSk;Yy+Fg1<{@YjZ;1=4%!E%31Bw;+Bo6SZ=V)1 z;#clH6)s=cVUGwt31C(>!wC==46mRb(!(3ug}fLH*)B~(2ZFG4;r`EaxH!5Rf-rK8 z9yEQm)ADygMa7E#0aHM_EZuuy<^D&XKlSxR-v>G=jnCoM{(h^uSrKPk(=wKw_<%LX zxMw0H+Pva+DO$f_-Nu3-f?9T_UIq|r$8;cb^L6@5fI0lCD?7~ z;L#V8qSna!*RmQIho8R^KsHJOLpV_6S$`6^JmAhvD0AC3GAr}S+YK|tZsK8XIhSC3 z>={WVH}4=sJn%b`vX?tBMIEf0;&h4^+phleSNqjq7E3}-cO<^s_X<>bT0I?R)JAev ztwy2n>GD(Zxka19aw5r3L;|JwO$r;WtbfKhr97_5*wyxj-@UWGS(AhJR{9LXQG$L- zbRM&zgp1lvePf&YA7);G3V{(I6{@8lL%@?c@C3I!^>i`1ZVhI?DAdVau5!)gz{i+B zai{Qovp;>EQrYuj{>XLVd0l#YfO()+dw*;t>7BB0omF@>MYz4;jeGoZo!FhT3a z0i}S2RrGD}-Y^C+T{-4!TR|bJ6dkEq-N|Z!&9iAOBAsF~K-lNs0lGik%~LmEZa$fE zOXZx_(}dBr9qAiuMfjA7MX642fn-2Eq`qo!15q|u&--Rj;1d(V#C#nk zX&Pl|ErJ@=k(;bVeIWJNm+XOEq5ng4$5nZk&Uv1%re7fF(X6pQwKT{qk6cLm+y zX#@$6n)VAw9!l&#;18Hm`VJPCz8c|72Qnl6^&V_~?rA}CGMh}#IpzXr35;x)dN|q@g zO-Q%K8K_tF8=Kl@jX~bxu?0aWB&saUTNkW5T}5UKwtq{Dx&HDfuyBoa2aUZUI&L>yO=~fvG(ZJ{ zW%)a{Y2WZzOJYR1l>D}5F{dQ`sFo)Kp8|8S2IHM4c+$_m2yB6(zO(LG|ilM+JoV%s-p6>DGv{~8*^FB6(bPi zr$K;LMt&70NbU_@upqtkf%Z#%Q=ChFo#=Pal~-}tpo>WHTLLpun5o~5aGk^NYzSS| zt0ycQ<82(3wNn(#iHA8M+uAInH4|>$;EKz!&F_ArD`!||!A8E6hhG4YW$me+LD;-R z9DeHQ!%6U+12&=Dl3t2O@=86BC^Lg011`>I%~&A4BraQfexO>DpJwg1emmO?Wq6j$ zD;vg9)Z@WSuSpWwx`iRmfnO(}2F_dm&F3LB_ z2Ix>q*C1Zo>zYoZMM4Ji;oRx3f?}y%@pFucgyu65ZB|X*{OiDZ330aIW-863bb~E) zkB$&*y8=fwP)W$vi*P~VA>j+5C8(=@eb?DqmOCVzUm|%TDU+f}_NW!sD8!ftzL6*l zNV~b6t=o-i9V60#V7UO$2|)uvBc`kTMA>+{q4QiaO~!LMRKF{Vl|v+;<1Jm6bR&M> zO=fWH@y623*8WS&L#qhmdaBV+R4$`9p@nq&zJqu&0hFbV-4nR2u0o&;fJQWO4P*Vt z2J_Uh0m2KPBayMflPgBV{#UBy-s>K{`64jSjF^~Apth@CTf7(!=&JvQSI&KZ)n4u8 zR7muSKpWa1Gx$WvI8G!bZ{2n4>2A?@T*d+{Wz*+1@0(GRPv+Qo!x0%~N9_*%w|UMFuC-*+F=GyttP88Y%c4lQu?`3zP`cRCC{ytg{$)Ng#T zCd8ek+&08Z9hJ;nsL@kCDC0^4XK@p(GixAL)YjW6(N}+wUjv`o{fa zxq}$9LdbpN0iIzqwi*&}+)#=kGO25(iImlo6JcynCMmaT$fa@^3E^tuFJLYIlvc0w zsIJe=w10tdI}?F9U{8k{87z=`!NGkMj*9l(I-!y_`J=vR->0%QJ4#`*pV8$cBZxRz zIb?ySPHYI}s$2TRV@RdxJ!6+IddZe;M}g}PX;VmK9mActQSg0|o=}Pkm$AonC}T|d zIc2US|0FxR657%0bXDNF$TEGf;%vm0G!=}_^fa#%^AOs{aQ6WRWT(|=rNS7#V{cTK z03^Q5ANU+eenIpxy^5Y7Lbzl+fk@l7h;tZ=@qwvx5ZY3L9_(#ns6c+;zDiik)yqk=`NDv zcU?}%^X`o@)vVNgx@aBbKH31)4WzW|p{B4hjAR+8j#q|DcoIoAa{?tO)L^4F$q!*a z!4UAVkfcc|V=CdA#UTc_{ByAcqHRdKlQm4keVxJIRYKnP(1gK87^8ohbr=mB7RxfyZZNS;XUE>~7)dvNj%ZphJGSolTzAW2_oO zJa?~(ayjjUFTxDRNv?slbmZWt28tuF^AfzvO=O&z=u@ecLowNbsspvm<@ zo&CK zIR6f_<~s$fn^mZ%;(nZdcvQQqc)9%pV4%HbgGr-4vH!~@)1$mYxbx3usp?(U1JN#a zinOPAz!8kf>w-nP3=w;_IzI?^fJ zO44;u_a36b@@X}n|JuEo$!Aa~BaF-MH|^xxk+EA|^H$7O%Ih};Pp&gXmTNX|75_Ak z8@3NDMpWWyui4@dmtj)o>6P-xQS4x!lbWo_v`ghHF{E^T$rcVZ>z*2j^EbL_3He_Ng5D55Im{ zXSJBkI=W8^U;@#|2uu1)AeMxBZisPO4APMPflBj~mUQuU>YK&5I{&&IQQ4C=y!H6` zt8!eWVzTea0~+OX#BPWNi#RF{bcf#z>niZEjT82`1JCjNZ zT3oQ_i6a9;RN+yQ*t=Y4+?C6i+#YsnFXZYz@dUAC2u4q8eLI}`*hW@nK zV{gLHh&|N4<|hzX)kDO~Fyynb+dG8*g>=y;B;+|VL{ffKJsBfvz6UT}sJ{&GBx#+p z-NcM;D}Q}i)rp6`DOtZ1>U;$G;M2Le!~Xkvb+pjek<1F_2Bsr)lp8VTZOz~o5lEy8 zSjjut5C3xG)d{7^ODOTM#0xk4C2r~W1cG~VGTrD}JQY96IdhfyHs!wRur|S3#KQZ5 zo3+b=O0&(PpQ54L?K(-Jh7xWBl2O|ULX8$c_jy%!ptW93;Uw2kFrgA1R765RA#~J| zb&7a-C4=dCA`tW2q(SBof(>wB+dwlof0*T1Zed0-ZMziOntU~=rnrJo5~YSvf#rq3Tw1j;0r3A1mTy4&>&)UDHO*ELM?M z*3>^q&Ha&zjiNiwS9;fFiA_W|a`%5CylNdSTC2X9bgzX6*^E^iWN?T1o0k;8wi-#+<o)PP`1){S%WJ&-;T*tANQ9jL(g`%sv%j8x7!Pa!qa4J$Iv72 zSBp=q@#XDPtE2?5_dpbCY`x}H{xoNZq0|M%NeGe`|EW|i z8`i+zlo8vBw)5Kf%@Kt?d+SW4d4v+_+c*)5y{9YBDc{gUD0p;lHl>4IVj$rk0HlDRnF+bbu^2@b#}LM<+pK{GNK(Mf<0&0Zaa9?a1sF>()I4tu=06! z6eY^kF~XUCnocDqH^e|aM>Eli*1PgF6k00kb3I85;rGkxbG7bLiv$A{rfdK<21R0H zv(A{`Y;H+y`iYXpczK0SycpwFrhkBklyN!l=-OEOC7swUIQ)h)`eMVnrB7KEF zd1U9vlF#siWW;*vUUg4EVGrHxz}i`)%TJb z?U~#153(gMN+I(JBahZ@@IBj7`iL@ld!e;<&h%Hm$bm+0d6`+Eo|x=kQ^2;0{JS_! ztSL@WB|EE~#IYNl)3q*%ZrR=VeLkF`i?4qq`EUg7XKEq?wNIqrOM(eNsBuLbWyx=o z{Cx{&T`3ubfBN1UM--=Ls&n{gDwnrnGUG3Y6E{sSz zP?HYh%4c>T7GL`AhnO1bVZ4p;FSJ`+JR$pJJ~`FM4T=z;%! zJa%5R@Y7CYKO3E^PG`C|;^MJ2UY7u^fpwe`qML4;3n;-jRW5G0BP!nHR=_~f1qX4z zVV@sGa$%?QCOjGg0!P|wN*G@sLq;wF>UFM!xepvJR!)ks2~lQ51v!2QAJhHlMc3)u zU`ou!!c{yD5*4cV+cC%AW&n8ix6L${fwA%M zLymwehrQjM%lU77!B4E1G8lAibGMD`11KZEGwks~my)-4d7uVpy5=L+BXmqr0+OtN zsc2_2d?nWv6q$u3Ok{g$e%DWjl7g$!XV;hNqw zz-T|@+k<>-mplL6Poj}~ir=qy=!~WJJ?c-`;y+ZdX(Y80Nfi^l4q&p6@ScyZHJ(T^ zoqah0z^n}{%{n8A^Bmtw!0qFDRms93jB6qe84eI)rBA(?WA4lG%RH5zgc9pog=8mvaW9vm?V`Y#D;cBOmi8%Q_?@bLR&Q zf(``}(2&$`vtKd=IEU(JRQ>1TO;hc29p0j=QgPxfS3rhK;W~!DE9@tJAANo!-AM#t zZ1?d^l5%wGx?(A^ag{<_0T@XN2*>eiCLQA*9 z1YO346}usXUt#A$i?->eQeV|a>Qf-o^?$b7d;nKZJq@%85Sh>oe7EsoUd8!W&E=F> zbDO-Np)@Cbmks}P+tBH0qS3y04HSWB)DHg%cguV5A?J1(suUGi2ROz1)g$pAyL!8v zQzgrUCaPI48ct!$9&LE$OTBj20WwQ8UXusxSTMKW*}Nq}U)@!7V6jFW`jrgB1}xuvK5@IRHW?rGUQm7#w1MKToJN_(8}$)z@CCWjY^Bhd1z*1(KgN99FNa z9x1=l4|as?j!2{=%747UczDcveGuuZJ`Yf2*oSh``iULTutCN|>!e3*UmkZvvMsuC z@LtS*Q6e*GgScJHyvNi}?7#X!WxJm8_MYLpd@}|ylUIeFEby+T2*Ix4)&ie1vy;>Z zqPDCZdnneQnA}TnULaPxBN*42y24%Ib~p0BjudN{`%@*coKd%ZuM9rL6>P8k;oJX^ zK&C**J6*{%Vn`Kk3`QZ$jlI`gs%ozE_82J721QFs6#iw%#nH<0RKFf=RZ~&@>)_o> zRIXP^6#v1>Sl3rMzM%c=36fDi+a=O4tHs|UYAUUaNrDD=;}}#hl~FaQ*2wCKq5EQT z!3xqXbtBoI`(GHF&RzLvBF2jo8$2Sm4svR#zj}1nbh;)DpX`n@xDNfmCy6kVx4l!`kdRm$>E=PU8zU7(g{EYAJ}@~ma&po4=^*Ib)i;X9*e&%?mSuoS z*y`s59)6lhVzUQ9lqp(UDUY9%o)M^~)mDNJY(p(Z)I!MWI5NS=H4K>B?}Y$!o2upy z5Cw2(9{fgXL8jGOzKy?d*K$YEI>4#`#WibbF1yJdSy;BqGr%V_lKp5cf6ra5+zd){ zeDvD~z2%_pi>;23ur^wDfOf;%z7asTfA&L$mmL1jC>m?ynZO=ay+6>`%^e%9g85*J z7o14g*h(x|705|^nLHXz-DQ(aLbtG(%*?VNG_!nApL{nx=>}xj!SawY%E28MpL>CG zp!cDTXpb3b0Ph%oz#~B0-I`u?>U%!J`gINmEB*=xDUhDRd^mYj);jL_Um)!(3x1;1 zpjIE|Xa(-}ZFGOu-WbP-xuh_Oq<$iPlM}V9e&X(>{-$*17}A>S07$p=S7h(*BeUaw zt_MJ>QI-AkKnU7A7SdI*$Spte(dYg*1@!tQ<_}BG#-(SoWw8A9Qds;ahYAAV&JrZ> z*Phlkgc|hgKcDs24~2iAp~wFOe}FLru3Ti4F8?j?B6_5Mdk5rw(Sm;*rL2EE{V#0j z^%jy?EeiPm`opxhiUJ-0+#(XB`_JWT1dMy^fq})}e)s2((g2_b8&ko;dGp`N_-g=r zfv@{wrE~0mjO$-RR9O6&IOLz$l@D4h;M2=?g%$n}JW%=%>GwhP#Vdk;&*#5EEIMG4 z62nQ9{zGQ{H9^sT#`h?R_NydXbDR?|%Ste<6di+VSPD|F`!IzZa;@sU!D4 zX8QGgq=Y0kywvyoJIjdtnT8A)6QjnY|1gn%0gFCp{{kIFG@IU5+v?^uUqB+ZCxChvYhsZ6s0#QL!o?R;|4bX z0=6~9m9~%*jJ;h-a|8u!H~`_Aj2|GM{H|&{UTq02ze8G5xx>cyOGzdpwj1nM8ZI~A zZBO_zksy7{|6b9OfYe566n^{mzUB6}E}ATB8umgpNzgih_!Fu&EY%)33HKgwwLh@wH1@zn+y-2EAlFi&v%u-t@h)d?%m6jX z?2w4x%-*HbE@srZ=eyVH&bPCh?A5ciE@yZwPxk_^>csx`F33d$q+wX&r0Pz}?tY7? zI-06t%mm|6E4EyoELhl|3C`;F#peR}zPQ39kn$^PZZ4Hj1aconKJ!23U^Ne#TQ;op>Z)!w=h4eslZI?C#iVpVFO`A;Kz@5H-pWy6(GyOcK8H?2hfkdU6YfI{%! z@W&T^f48Q4gQEhrl#E{9k1^Dkz3H+{*yzop`BhU6@;f%p zc$XIug5Qz0yHnJX!L*xO4pf@-Z76(4X>6wYkvPfXbZbt^d25VmHjoMi6t?6Y|(zK$Q+mNQHt)(&nKJ0npn=r9636M$r8Zc;1QkfHYfRn)Q7q!e>#6hFygf zE6D>a6?^}pOY}KIxI(zmFndody~^f|$bfzgs^2c(Rx?|YP`E6^^??pjU-jm$bIQ5s zkWux&=>r6y4=UL!JLf7^7=WXNTV><2QQ|SLrhZp}Ld)B+fYLG9!Z%hLuBFpWurYuW zY})~%s1!7#Ex#g-CQ2@*5g&{jTx>6jGJ?ep_gkll!9M!SYw=z;7)8VR!`L}|-)_Wl zD&+J@mY1;qvFMFn0E><&2K!AIcfXMR;pfXRT|g!djl*+}6k?U(OX0Gr*K*DXI0yel zJy-r1;Pm0(JPXuNj!F~Abjk3t==vsLqGYXh6+vuZTjtp}MrHxfn60;k@JG?Ys3L=#~@Bu9ZT}7bzkW zW)gkA?{3Xo<1m-^KFo=1ML-Xk0Av=oVXN>FP-=&Yss+e5o*Zw+*^wI_n0#z;4p`pu z#YV3(|- zZx#*~4x7toTgSbI`sT6NuzAnREhwPav#r z$oTc&9>WLh)qEtj-&l5sQo;GR-MZJ61F*t9b-rHBa3B4JVT` zi#|AyqAvv81GM#m7H{31!YSf(h0>n%H=1^RPER1Q=#&3Y;55mAv~p9g>uAoVfn9_N zT!u45T=ItbI19_kxgdT(gC{Z9X#m?Q1`x=A8NkyibzCi|u$T=9U8(>Aw~8b~8CbQ0 zZ|iv{wP*%-_)6|RhRw-QQ3^UK_kX+{pvZ>U%;W-HDv`klY>U?v-=0+;{XsU=2oIS0!9>;kP)oy{%ry z9a&QHK>|l4#h*U)(QFMA5^gQyldSHBJ7dEhQq2BsqJb2*H1HxZuGb)p#C;|w{q<-_r#_Oghf|#;PDuDD_(N-0+#Dnps#}4}e^*Ok$ zyfT{K3PJYgTsAIl^JG;Tw9D7YDv#r?lmExp{sm}>>FizVyUn{IYY;?ml=vAZ zaIB|(71;(7AoRA<8wNOR@?HVQu&L^SluVL#>tp()u1DC7)@>?13b?Dz83KK2mX zstv7jGj%)0j->{k7F!Pd^fW+szY9Q~xcTq3$N=ab)3}OmV&|5dtGg15Q^cUO(zgh} z$FEDMKT0g1A|h%vHa-erD!W-x$ScTdmqb$}Ik|?~>a={!=?cfG;jkB=XCEd86!@cM zuYbI~D-cQ8Wx?4?e&TF9->B$)b#_;9iYPhBV>^xnVi_tbP0A;;50-vk@6SPl&pm~-6u#0J#d&9J z5Zlw;e+o4*AZ8jgP;&jS2Hm^0J>?x=e_O)OW+gyU3tGFobk47}`_~>@dG@jG*kSfO zlq>)^xl;^OeoK3_J_7_IWAugovCUKgwH790We5zz8d@$>7K%_YzDMC686~PZSz?2p zcmRCv_#%FA(4>#da1Q-TskVO^9Kqds#@qDZGHt`rMhF>*g8T^tk1_n8#xy>lZ84G} zDM3XdcYAjL>p!KWK)tVVvo+~mj7KTFEUli*=Px%FT)HZT(AT%QA^M+p(qRQj~?H5`UAlKT`LvOoUETvRm@j)~@@9 z{~q=Ew^=(P3Mg61lOI62$bSrC^y?vDVjLv9QU9?aAT4A{;145;_^%%P^M-p^tH{$u rrcOf9|I<16zYqC;ZMc>(VR!h9kF%9b%J};40smwqG}OHF0=?6 literal 0 HcmV?d00001 From d91a2d3a9cfc90925456c00b389b25503fc9cb1c Mon Sep 17 00:00:00 2001 From: Quercus Hamlin <75846376+quercoak@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:05:21 -0400 Subject: [PATCH 12/16] fix: updates to download.py (#13) --- config/example_hi.yaml | 14 ++++ config/example_prvi.yaml | 13 ++++ src/reference_builds/pipeline/download.py | 88 +++++++++++++++++++++-- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/config/example_hi.yaml b/config/example_hi.yaml index 6905704..047662e 100644 --- a/config/example_hi.yaml +++ b/config/example_hi.yaml @@ -2,3 +2,17 @@ domain: hi input_file_regex: HI/NHDPLUS_H_20*_HU4_GPKG vpu_id: "20" crs: EPSG:32604 +permitted_fcodes: + - 'Canal/Ditch' + - 'Stream/River: Hydrographic Category = Perennial' + - 'Stream/River: Hydrographic Category = Intermittent' + - 'Artificial Path' + - 'Pipeline: Pipeline Type = Siphon' + - 'Pipeline: Pipeline Type = Aqueduct' + - 'Pipeline' + - 'Connector' + - 'Canal Ditch: Canal Ditch Type = Stormwater' + # These FCODES are currently disabled in HI due to incorrect NULL `toid` and `dn_hydroseq` values. + # - 'Pipeline: Pipeline Type = Aqueduct; Relationship to Surface = At or Near' + # - 'Pipeline: Pipeline Type = Aqueduct; Relationship to Surface = Underground' + # - 'Pipeline: Pipeline Type = Penstock; Relationship to Surface = At or Near' diff --git a/config/example_prvi.yaml b/config/example_prvi.yaml index 099bfd9..15c9b88 100644 --- a/config/example_prvi.yaml +++ b/config/example_prvi.yaml @@ -2,3 +2,16 @@ domain: prvi input_file_regex: PRVI/NHDPLUS_H_21*_HU4_GPKG vpu_id: "21" crs: EPSG:6566 +permitted_fcodes: + - 'Canal/Ditch' + - 'Stream/River: Hydrographic Category = Perennial' + - 'Stream/River: Hydrographic Category = Intermittent' + - 'Artificial Path' + - 'Pipeline: Pipeline Type = Aqueduct; Relationship to Surface = At or Near' + - 'Pipeline: Pipeline Type = Aqueduct; Relationship to Surface = Underground' + - 'Pipeline: Pipeline Type = Siphon' + - 'Pipeline: Pipeline Type = Aqueduct' + - 'Pipeline' + - 'Connector' + - 'Canal Ditch: Canal Ditch Type = Stormwater' + - 'Pipeline: Pipeline Type = Penstock; Relationship to Surface = At or Near' diff --git a/src/reference_builds/pipeline/download.py b/src/reference_builds/pipeline/download.py index 6c1025d..b605208 100644 --- a/src/reference_builds/pipeline/download.py +++ b/src/reference_builds/pipeline/download.py @@ -35,6 +35,86 @@ def _load_and_concat_parquet(parquet_files: list[Path]) -> gpd.GeoDataFrame: return pd.concat(gdfs, ignore_index=True) +def _merge_flowpaths_without_catchments( + flowpaths: gpd.GeoDataFrame, + catchments: gpd.GeoDataFrame, + connectivity: pd.DataFrame, +) -> tuple[gpd.GeoDataFrame, pd.DataFrame]: + """Merge flowpaths without catchments to its downstream neighbor. + + FIXME: This currently breaks connectivity info as well as path length calculations among other things. Needs significant rework. + + Parameters + ---------- + flowpaths : gpd.GeoDataFrame + The flowpath geodataframe, must contain 'DnHydroSeq' and 'HydroSeq' columns + for upstream/downstream connectivity and 'NHDPlusID' to match with its catchment. + catchments : gpd.GeoDataFrame + The catchment geodataframe, must contain 'NHDPlusID' column + + Returns + ------- + tuple[gpd.GeoDataFrame, pd.DataFrame] + The updated flowpath geodataframe and connectivity dataframe after merging flowpaths without catchments + """ + _flowpaths = flowpaths.copy() + _rename_mapping = {key: f"{key}_VAA" for key in connectivity.columns if key != "NHDPlusID"} + _connectivity = connectivity.copy().rename(columns=_rename_mapping) + + # merge flowpaths with connectivity info + n_connect = _flowpaths["NHDPlusID"].isin(_connectivity["NHDPlusID"]).sum() + n_flowpaths = len(_flowpaths.index) + if n_connect != n_flowpaths: + n_missing = n_flowpaths - n_connect + logger.warning( + f"{n_missing}/{n_flowpaths} flowpaths are missing connectivity info. These flowpaths will be dropped from the reference build." + ) + + _flowpaths = _flowpaths.merge(_connectivity, on="NHDPlusID", how="inner") + + # identify flowpaths without catchments + _has_catchment = _flowpaths["NHDPlusID"].isin(catchments["NHDPlusID"]) + _flowpaths_without_catchments = _flowpaths[~_has_catchment] + + logger.info(f"Merging {len(_flowpaths_without_catchments)} flowpaths without catchments") + + for _, row in _flowpaths_without_catchments.iterrows(): + hydroseq = row["HydroSeq_VAA"] + dnhydroseq = row["DnHydroSeq_VAA"] + downstream_flowpath = _flowpaths[_flowpaths["HydroSeq_VAA"] == dnhydroseq] + + if len(downstream_flowpath) == 0 or row["TerminalFl_VAA"] == 1: + continue + elif len(downstream_flowpath) > 1: + logger.warning(f"Multiple downstream flowpaths found for {row['NHDPlusID']}") + continue + else: + # merge geometry + _gdf = gpd.GeoDataFrame([row, downstream_flowpath.iloc[0]], geometry="geometry") + merged_geom = _gdf.geometry.union_all() + # update geometry of downstream flowpath + dn_idx = downstream_flowpath.index[0] + _flowpaths.at[dn_idx, "geometry"] = merged_geom + # update connectivity + upstream_flowpaths = _flowpaths[_flowpaths["DnHydroSeq_VAA"] == hydroseq] + if len(upstream_flowpaths) >= 1: + _flowpaths.loc[upstream_flowpaths.index, "DnHydroSeq_VAA"] = dnhydroseq + _flowpaths.loc[dn_idx, "FromNode_VAA"] = upstream_flowpaths.iloc[0]["ToNode_VAA"] + # TODO: check if other attributes need to be updated?? + + # drop flowpaths without catchments after merging + _flowpaths = _flowpaths[_has_catchment] + + # derive updated connectivity from merged flowpaths + connectivity = _flowpaths[list(_rename_mapping.values())].rename( + columns={v: k for k, v in _rename_mapping.items()} + ) + connectivity["NHDPlusID"] = _flowpaths["NHDPlusID"] + flowpaths = _flowpaths.drop(columns=list(_rename_mapping.values())) + + return flowpaths, connectivity + + def download_geoglows_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: """Opens local / downloads for the reference-build process @@ -112,14 +192,12 @@ def download_nhd_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: # filter/validate layers _flowpaths = _validate_and_fix_geometries(data["NHDFlowline"], geom_type="flowpaths") + _flowpaths = _flowpaths[_flowpaths["fcode_description"].isin(cfg.permitted_fcodes)] + catchments = _validate_and_fix_geometries(data["NHDPlusCatchment"], geom_type="divides") - _flowpaths_with_catchments = _flowpaths[_flowpaths["NHDPlusID"].isin(catchments["NHDPlusID"])] - flowpaths = _flowpaths_with_catchments[ - _flowpaths_with_catchments["fcode_description"].isin(cfg.permitted_fcodes) - ] return { - "nhd_flowpaths": pl.from_pandas(flowpaths.to_wkb()), + "nhd_flowpaths": pl.from_pandas(_flowpaths.to_wkb()), "nhd_divides": pl.from_pandas(catchments.to_wkb()), "nhd_connectivity": pl.from_pandas(data["NHDPlusFlowlineVAA"]), } From 21af77a0227512f871f6bcce671cc2364cf6a7c6 Mon Sep 17 00:00:00 2001 From: Quercus Hamlin <75846376+quercoak@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:56:15 -0400 Subject: [PATCH 13/16] fix: put back in accidentally removed drop fps with no catchments (#14) --- src/reference_builds/pipeline/download.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/reference_builds/pipeline/download.py b/src/reference_builds/pipeline/download.py index b605208..bb2d5fa 100644 --- a/src/reference_builds/pipeline/download.py +++ b/src/reference_builds/pipeline/download.py @@ -192,12 +192,15 @@ def download_nhd_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: # filter/validate layers _flowpaths = _validate_and_fix_geometries(data["NHDFlowline"], geom_type="flowpaths") - _flowpaths = _flowpaths[_flowpaths["fcode_description"].isin(cfg.permitted_fcodes)] - catchments = _validate_and_fix_geometries(data["NHDPlusCatchment"], geom_type="divides") + _flowpaths_with_catchments = _flowpaths[_flowpaths["NHDPlusID"].isin(catchments["NHDPlusID"])] + flowpaths = _flowpaths_with_catchments[ + _flowpaths_with_catchments["fcode_description"].isin(cfg.permitted_fcodes) + ] + return { - "nhd_flowpaths": pl.from_pandas(_flowpaths.to_wkb()), + "nhd_flowpaths": pl.from_pandas(flowpaths.to_wkb()), "nhd_divides": pl.from_pandas(catchments.to_wkb()), "nhd_connectivity": pl.from_pandas(data["NHDPlusFlowlineVAA"]), } From 4e11b44519b93684be11c582d783e1b012fbe42c Mon Sep 17 00:00:00 2001 From: Brodie Alexander Date: Wed, 22 Apr 2026 09:09:25 -0500 Subject: [PATCH 14/16] Change from GeoGLOWS to USGS Reference Hydrofabric for AK domain (#15) * Change from GeoGLOWS to USGS Reference Hydrofabric for AK domain * Merging of exclave Polygons with neighbors * Resolve review comments * Added reasoning for switch to README * Update src/reference_builds/utils/geometries.py Co-authored-by: Quercus Hamlin <75846376+quercoak@users.noreply.github.com> * Update src/reference_builds/utils/geometries.py Co-authored-by: Quercus Hamlin <75846376+quercoak@users.noreply.github.com> * final fixes for matching what nhf-builds expects * remove ruff directive * Update src/reference_builds/utils/geometries.py Co-authored-by: Quercus Hamlin <75846376+quercoak@users.noreply.github.com> --------- Co-authored-by: Quercus Hamlin <75846376+quercoak@users.noreply.github.com> --- README.md | 7 +- builds/build_reference.py | 10 + config/example_ak.yaml | 7 +- .../configs/reference_config.py | 11 +- src/reference_builds/pipeline/__init__.py | 9 +- .../pipeline/build_reference.py | 355 ++++++++++++++++++ src/reference_builds/pipeline/download.py | 58 +++ src/reference_builds/pipeline/processing.py | 27 ++ src/reference_builds/utils/geometries.py | 67 +++- src/reference_builds/utils/usgs_graph.py | 52 +++ 10 files changed, 588 insertions(+), 15 deletions(-) create mode 100644 src/reference_builds/utils/usgs_graph.py diff --git a/README.md b/README.md index df2987c..643627b 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,13 @@ https://www.sciencebase.gov/catalog/item/57645ff2e4b07657d19ba8e8 the zipped geopackage is required +Since reference 0.1.6, The USGS Reference Hydrofabric is used for *AK* and can be downloaded for the entire state here (as VPU 19). This change was made to include more coastal areas for better NWM support: +https://www.sciencebase.gov/catalog/item/6644f800d34e1955f5a42da9 + +the reference_19 geopackage is required + ### GeoGlows -GeoGlows v2 is used for the *AK* reference and can be downloaded from the following location: +Prior to reference 0.1.6, GeoGlows v2 was used for the *AK* reference and the code to use it is still present. The source files can be downloaded from the following location: - http://geoglows-v2.s3-website-us-west-2.amazonaws.com/#hydrography/vpu=801/ - http://geoglows-v2.s3-website-us-west-2.amazonaws.com/#hydrography/vpu=802/ - http://geoglows-v2.s3-website-us-west-2.amazonaws.com/#hydrography/vpu=803/ diff --git a/builds/build_reference.py b/builds/build_reference.py index bb62418..d019baf 100644 --- a/builds/build_reference.py +++ b/builds/build_reference.py @@ -12,8 +12,11 @@ build_geoglows_reference, build_nhd_graphs, build_nhd_reference, + build_usgs_hf_graphs, + build_usgs_hf_reference, download_geoglows_data, download_nhd_data, + download_usgs_hf_data, write_reference, ) @@ -59,6 +62,13 @@ def main() -> int: ) runner.run_task(task_id="build_reference", python_callable=build_geoglows_reference, op_kwargs={}) runner.run_task(task_id="write_reference", python_callable=write_reference, op_kwargs={}) + elif config.base_dataset == BaseDataset.USGS_HF: + runner.run_task(task_id="download", python_callable=download_usgs_hf_data, op_kwargs={}) + runner.run_task( + task_id="build_usgs_hf_graphs", python_callable=build_usgs_hf_graphs, op_kwargs={} + ) + runner.run_task(task_id="build_reference", python_callable=build_usgs_hf_reference, op_kwargs={}) + runner.run_task(task_id="write_reference", python_callable=write_reference, op_kwargs={}) else: raise NotImplementedError("Base Dataset not implemented") diff --git a/config/example_ak.yaml b/config/example_ak.yaml index 9946aab..f8754f1 100644 --- a/config/example_ak.yaml +++ b/config/example_ak.yaml @@ -1,6 +1,5 @@ domain: ak -base_dataset: geoglows -input_file_regex: AK/streams_mapping_[78]*.gpkg -geoglows_catchment_regex: AK/catchments_[78]*.parquet +base_dataset: usgs-reference-hf +input_file_regex: AK/reference_19.gpkg vpu_id: "19" -crs: EPSG:3338 +crs: EPSG:3338 \ No newline at end of file diff --git a/src/reference_builds/configs/reference_config.py b/src/reference_builds/configs/reference_config.py index f2d1545..a377fc4 100644 --- a/src/reference_builds/configs/reference_config.py +++ b/src/reference_builds/configs/reference_config.py @@ -16,6 +16,7 @@ class BaseDataset(str, Enum): NHD = "nhd" GEOGLOWS = "geoglows" + USGS_HF = "usgs-reference-hf" class ReferenceConfig(BaseModel): @@ -70,14 +71,16 @@ class ReferenceConfig(BaseModel): ) output_reference_divides_path: Path = Field( - default_factory=lambda data: data["output_dir"] - / f"{data['domain']}_{__version__}_reference_divides.parquet", + default_factory=lambda data: ( + data["output_dir"] / f"{data['domain']}_{__version__}_reference_divides.parquet" + ), description="Save directory for the domain's reference divides", ) output_reference_flowpaths_path: Path = Field( - default_factory=lambda data: data["output_dir"] - / f"{data['domain']}_{__version__}_reference_flowpaths.parquet", + default_factory=lambda data: ( + data["output_dir"] / f"{data['domain']}_{__version__}_reference_flowpaths.parquet" + ), description="Save directory for the domain's reference flowpaths", ) diff --git a/src/reference_builds/pipeline/__init__.py b/src/reference_builds/pipeline/__init__.py index de3a7e2..c4f544e 100644 --- a/src/reference_builds/pipeline/__init__.py +++ b/src/reference_builds/pipeline/__init__.py @@ -1,14 +1,17 @@ -from .build_reference import build_geoglows_reference, build_nhd_reference -from .download import download_geoglows_data, download_nhd_data -from .processing import build_geoglows_graphs, build_nhd_graphs +from .build_reference import build_geoglows_reference, build_nhd_reference, build_usgs_hf_reference +from .download import download_geoglows_data, download_nhd_data, download_usgs_hf_data +from .processing import build_geoglows_graphs, build_nhd_graphs, build_usgs_hf_graphs from .write import write_reference __all__ = [ + "build_usgs_hf_graphs", "build_geoglows_graphs", "build_nhd_graphs", "download_geoglows_data", "build_nhd_reference", "build_geoglows_reference", + "build_usgs_hf_reference", "download_nhd_data", + "download_usgs_hf_data", "write_reference", ] diff --git a/src/reference_builds/pipeline/build_reference.py b/src/reference_builds/pipeline/build_reference.py index 26c6fe4..11bb786 100644 --- a/src/reference_builds/pipeline/build_reference.py +++ b/src/reference_builds/pipeline/build_reference.py @@ -505,6 +505,255 @@ def _trace_geoglows_attributes( ) +def _trace_usgs_hf_attributes( + graph: rx.PyDiGraph, + node_indices: dict[str, int], + flowpaths: gpd.GeoDataFrame, + catchments: gpd.GeoDataFrame, + vpu_id: str, +) -> gpd.GeoDataFrame: + """Trace flowpath attributes for the entire USGS Reference Hydrofabric graph. + + Parameters + ---------- + graph : rx.PyDiGraph + The rustworkx directed graph (may contain multiple disconnected subgraphs) + node_indices : dict[str, int] + Mapping from hydroseq (as string) to node index + flowpaths : gpd.GeoDataFrame + The USGS Reference Hydrofabric flowpaths GeoDataFrame with LengthKM, streamorde + catchments : gpd.GeoDataFrame + The USGS Reference Hydrofabric catchments GeoDataFrame with hydroseq, areasqkm, and geometry + vpu_id : str + The VPUID for the domain + + Returns + ------- + gpd.GeoDataFrame + Traced attributes: totdasqkm, mainstemlp, pathlength, dnhydroseq, hydroseq, stream_order + """ + length_lookup = flowpaths.set_index("hydroseq")["LengthKM"].to_dict() + order_lookup = flowpaths.set_index("hydroseq")["streamorde"].to_dict() + fp_geom_lookup = flowpaths.set_index("hydroseq")["geometry"].to_dict() + comid_lookup = flowpaths.set_index("hydroseq")["comid"].to_dict() + + # Build catchment lookups (area and geometry keyed by linkno) + catchment_area_lookup = catchments.set_index("COMID")["areasqkm"].to_dict() + catchment_geom_lookup = catchments.set_index("COMID")["geometry"].to_dict() + + for node_idx in graph.node_indices(): + flowpath_id = str(graph[node_idx]) + link_id = int(flowpath_id) + + comid = comid_lookup.get(link_id) + + # Get length in km (already calculated from geometry) + length_km = length_lookup.get(link_id, 0.0) + + # Get local catchment area (from catchments, keyed by comid) + local_areasqkm = catchment_area_lookup.get(comid, 0.0) + + # Get geometries + catchment_geometry = catchment_geom_lookup.get(comid) + flowpath_geometry = fp_geom_lookup.get(link_id) + + graph[node_idx] = { + "flowpath_id": flowpath_id, + "areasqkm": local_areasqkm, + "lengthkm": length_km, + "totdasqkm": 0.0, # Will be accumulated in PASS 2 + "mainstemlp": None, + "pathlength": 0.0, + "dnhydroseq": None, + "hydroseq": None, + "streamorder": order_lookup.get(link_id, 1), + "flowpath_geometry": flowpath_geometry, + "catchment_geometry": catchment_geometry, + } + + # Find all outlets (nodes with no downstream connections) + outlets = [idx for idx in graph.node_indices() if graph.out_degree(idx) == 0] + logger.info(f"build_usgs_hf_reference task: Found {len(outlets)} outlets (disconnected subgraphs)") + + # Get topological order for entire graph + try: + topo_order = rx.topological_sort(graph) + except rx.DAGHasCycle as e: + raise AssertionError("Graph contains cycles") from e + + # PASS 1: Calculate pathlength and hydroseq (reverse topo order - upstream from outlets) + current_hydroseq = 1 + + # Initialize outlets + for outlet_idx in outlets: + graph[outlet_idx]["pathlength"] = 0.0 + graph[outlet_idx]["dnhydroseq"] = 0 + + # Traverse in reverse topo order + for node_idx in reversed(topo_order): + # Assign hydroseq + graph[node_idx]["hydroseq"] = current_hydroseq + current_hydroseq += 1 + + # Calculate pathlength based on downstream node + out_edges = graph.out_edges(node_idx) + if out_edges: + downstream_nodes = [tgt_idx for _, tgt_idx, _ in out_edges] + if downstream_nodes: + downstream_idx = max(downstream_nodes, key=lambda idx: graph[idx]["pathlength"]) + graph[node_idx]["pathlength"] = ( + graph[downstream_idx]["pathlength"] + graph[downstream_idx]["lengthkm"] + ) + + # PASS 2: Calculate totdasqkm and stream_order (forward topo order - downstream from headwaters) + for node_idx in topo_order: + in_edges = list(graph.in_edges(node_idx)) + + # Accumulate upstream drainage area + upstream_total = sum(graph[src_idx]["totdasqkm"] for src_idx, _, _ in in_edges) + graph[node_idx]["totdasqkm"] = upstream_total + graph[node_idx]["areasqkm"] + + # Trace mainstems for each outlet's basin + current_mainstem_id = 1 + processed: set[int] = set() + + for outlet_idx in outlets: + # Trace main mainstem (longest path from outlet to headwater) + current_idx = outlet_idx + + while current_idx not in processed: + graph[current_idx]["mainstemlp"] = current_mainstem_id + processed.add(current_idx) + + in_edges = list(graph.in_edges(current_idx)) + if not in_edges: + break + + upstream_candidates = [src_idx for src_idx, _, _ in in_edges if src_idx not in processed] + if not upstream_candidates: + break + + current_idx = max( + upstream_candidates, + key=lambda idx: (graph[idx]["pathlength"], graph[idx]["totdasqkm"]), + ) + + current_mainstem_id += 1 + + # Assign tributary mainstems for remaining nodes + for node_idx in graph.node_indices(): + if node_idx not in processed: + tributary_id = current_mainstem_id + current_mainstem_id += 1 + + trib_current = node_idx + while trib_current not in processed: + graph[trib_current]["mainstemlp"] = tributary_id + processed.add(trib_current) + + in_edges = list(graph.in_edges(trib_current)) + upstream_in_basin = [src_idx for src_idx, _, _ in in_edges if src_idx not in processed] + + if not upstream_in_basin: + break + + trib_current = max( + upstream_in_basin, + key=lambda idx: (graph[idx]["pathlength"], graph[idx]["totdasqkm"]), + ) + + # Assign dnhydroseq and flowpath_toid based on graph edges + for node_idx in graph.node_indices(): + out_edges = graph.out_edges(node_idx) + downstream_nodes = [tgt_idx for _, tgt_idx, _ in out_edges] + + if downstream_nodes: + downstream_idx = downstream_nodes[0] + graph[node_idx]["dnhydroseq"] = graph[downstream_idx]["hydroseq"] + graph[node_idx]["flowpath_toid"] = graph[downstream_idx]["flowpath_id"] + else: + graph[node_idx]["dnhydroseq"] = 0 + graph[node_idx]["flowpath_toid"] = "0" + + # PASS 3: Orient all flowpath geometries so they flow upstream -> downstream + for node_idx in graph.node_indices(): + in_edges = graph.in_edges(node_idx) + upstream_nodes = [src_idx for src_idx, _, _ in in_edges] + + # Check if this is an outlet (flowpath_toid == "0" means no downstream) + is_outlet = graph[node_idx]["flowpath_toid"] == "0" + + if is_outlet and upstream_nodes: + # Outlet: use upstream geometry + upstream_idx = upstream_nodes[0] + us_geom = graph[upstream_idx]["flowpath_geometry"] + if not graph[node_idx]["flowpath_geometry"]: + print(f"NONETYPE: {graph[node_idx]}") + graph[node_idx]["flowpath_geometry"] = _orient_flowpath_downstream( + graph[node_idx]["flowpath_geometry"], ds_geom=None, us_geom=us_geom + ) + else: + # Normal case: use downstream geometry + out_edges = graph.out_edges(node_idx) + downstream_nodes = [tgt_idx for _, tgt_idx, _ in out_edges] + + ds_geom = None + if downstream_nodes: + downstream_idx = downstream_nodes[0] + ds_geom = graph[downstream_idx]["flowpath_geometry"] + + graph[node_idx]["flowpath_geometry"] = _orient_flowpath_downstream( + graph[node_idx]["flowpath_geometry"], ds_geom=ds_geom, us_geom=None + ) + + # Extract results for flowpaths + flowpath_ids = [] + flowpath_toids = [] + vpu_ids = [] + das = [] + lengthkms = [] + total_das = [] + mainstems = [] + pathlengths = [] + dnhydroseqs = [] + hydroseqs = [] + streamorders = [] + flowpath_geometries = [] + + for node_idx in graph.node_indices(): + node_data = graph[node_idx] + flowpath_ids.append(node_data["flowpath_id"]) + flowpath_toids.append(node_data["flowpath_toid"]) + vpu_ids.append(vpu_id) + das.append(node_data["areasqkm"]) + lengthkms.append(node_data["lengthkm"]) + total_das.append(node_data["totdasqkm"]) + mainstems.append(node_data["mainstemlp"]) + pathlengths.append(node_data["pathlength"]) + dnhydroseqs.append(node_data["dnhydroseq"]) + hydroseqs.append(node_data["hydroseq"]) + streamorders.append(node_data["streamorder"]) + flowpath_geometries.append(node_data["flowpath_geometry"]) + + return gpd.GeoDataFrame( + { + "flowpath_id": flowpath_ids, + "flowpath_toid": flowpath_toids, + "VPUID": vpu_ids, + "lengthkm": lengthkms, + "areasqkm": das, + "totdasqkm": total_das, + "mainstemlp": mainstems, + "pathlength": pathlengths, + "dnhydroseq": dnhydroseqs, + "hydroseq": hydroseqs, + "streamorder": streamorders, + }, + geometry=flowpath_geometries, + crs="EPSG:4326", + ) + + def _create_reference_divides( divides_df: gpd.GeoDataFrame, reference_flowpaths: gpd.GeoDataFrame, vpu_id: str ) -> gpd.GeoDataFrame: @@ -569,6 +818,39 @@ def _create_geoglows_reference_divides( return reference_divides +def _create_usgs_hf_reference_divides( + catchments_df: gpd.GeoDataFrame, reference_flowpaths: gpd.GeoDataFrame, vpu_id: str +) -> gpd.GeoDataFrame: + """A function to create the reference divides table from USGS Reference Hydrofabric catchments + + Parameters + ---------- + catchments_df : gpd.GeoDataFrame + The USGS Reference Hydrofabric catchments table with hydroseq, areasqkm, and geometry + reference_flowpaths : gpd.GeoDataFrame + The reference flowpaths + vpu_id : str + The VPUID we're working in + + Returns + ------- + gpd.GeoDataFrame + The outputted reference_divides with catchment geometries + """ + reference_divides = catchments_df.copy() + reference_divides = reference_divides.rename(columns={"COMID": "divide_id"}) + reference_divides["divide_id"] = reference_divides["divide_id"].astype(int).astype(str) + reference_divides["vpuid"] = vpu_id + + # Filter to only include catchments that have a corresponding flowpath + mask = reference_divides["divide_id"].isin(reference_flowpaths["flowpath_id"]) + reference_divides["has_flowpath"] = mask + reference_divides["flowpath_id"] = pd.NA + reference_divides.loc[mask, "flowpath_id"] = reference_divides.loc[mask, "divide_id"] + + return reference_divides + + def build_nhd_reference(**context: dict[str, Any]) -> dict[str, Any]: """Opens local / downloads for the reference-build process @@ -709,3 +991,76 @@ def build_geoglows_reference(**context: dict[str, Any]) -> dict[str, Any]: reference_divides = _create_geoglows_reference_divides(_catchments_df, reference_flowpaths, cfg.vpu_id) return {"reference_flowpaths": reference_flowpaths, "reference_divides": reference_divides} + + +def build_usgs_hf_reference(**context: dict[str, Any]) -> dict[str, Any]: + """Builds reference fabric from USGS Reference Hydrofabric data + + Parameters + ---------- + **context : dict + Airflow-compatible context containing: + - ti : TaskInstance for XCom operations + - config : HFConfig with pipeline configuration + - task_id : str identifier for this task + - run_id : str identifier for this pipeline run + - ds : str execution date + - execution_date : datetime object + + Returns + ------- + dict[str, Any] + The reference flowpaths and divides in memory + """ + ti = cast(TaskInstance, context["ti"]) + cfg = cast(ReferenceConfig, context["config"]) + graph: rx.PyDiGraph = ti.xcom_pull(task_id="build_usgs_hf_graphs", key="graph") + node_indices: dict[str, int] = ti.xcom_pull(task_id="build_usgs_hf_graphs", key="node_indices") + _flowpaths: pl.DataFrame = ti.xcom_pull(task_id="download", key="usgs_flowpaths") + _catchments: pl.DataFrame = ti.xcom_pull(task_id="download", key="usgs_divides") + + # Check for cycles + cycles_iter = rx.simple_cycles(graph) + cycles: list[list[str]] = [] + cycle_ids: set[str] = set() + for cycle in cycles_iter: + _ids: list[Any] = [graph.get_node_data(node_idx) for node_idx in cycle] + cycles.append(_ids) + cycle_ids.update(_ids) + if cycle_ids: + raise NotImplementedError("Cycle Detected. Please create method for removing") + + _flowpaths_df = gpd.GeoDataFrame( + _flowpaths.select( + [pl.col("hydroseq"), pl.col("dnhydroseq"), pl.col("streamorde"), pl.col("comid")] + ).to_pandas(), + geometry=gpd.GeoSeries.from_wkb(_flowpaths["geometry"]), + crs="EPSG:4326", + ) + + _flowpaths_df_projected = _flowpaths_df.to_crs(cfg.crs) + _flowpaths_df["LengthKM"] = _flowpaths_df_projected.geometry.length / 1000 + + _catchments_df = gpd.GeoDataFrame( + _catchments.select([pl.col("COMID")]).to_pandas(), + geometry=gpd.GeoSeries.from_wkb(_catchments["geometry"]), + crs="EPSG:4326", + ) + _catchments_df_projected = _catchments_df.to_crs(cfg.crs) + _catchments_df["areasqkm"] = _catchments_df_projected.geometry.area / 1e6 + + # Log any flowpaths without matching catchments + flowpath_linkno_set = set(_flowpaths_df["comid"].tolist()) + catchment_linkno_set = set(_catchments_df["COMID"].tolist()) + missing_catchments = flowpath_linkno_set - catchment_linkno_set + if missing_catchments: + logger.warning( + f"build_usgs_hf_reference task: {len(missing_catchments)} flowpaths have no matching catchment" + ) + + reference_flowpaths = _trace_usgs_hf_attributes( + graph, node_indices, _flowpaths_df, _catchments_df, cfg.vpu_id + ) + reference_divides = _create_usgs_hf_reference_divides(_catchments_df, reference_flowpaths, cfg.vpu_id) + + return {"reference_flowpaths": reference_flowpaths, "reference_divides": reference_divides} diff --git a/src/reference_builds/pipeline/download.py b/src/reference_builds/pipeline/download.py index bb2d5fa..db95018 100644 --- a/src/reference_builds/pipeline/download.py +++ b/src/reference_builds/pipeline/download.py @@ -7,9 +7,11 @@ import geopandas as gpd import pandas as pd import polars as pl +from shapely.geometry import MultiLineString from reference_builds.configs import ReferenceConfig from reference_builds.utils import _validate_and_fix_geometries +from reference_builds.utils.geometries import _fix_divide_exclaves logger = logging.getLogger(__name__) @@ -204,3 +206,59 @@ def download_nhd_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: "nhd_divides": pl.from_pandas(catchments.to_wkb()), "nhd_connectivity": pl.from_pandas(data["NHDPlusFlowlineVAA"]), } + + +def download_usgs_hf_data(**context: dict[str, Any]) -> dict[str, pl.DataFrame]: + """Opens local / downloads for the reference-build process + + Parameters + ---------- + **context : dict + Airflow-compatible context containing: + - ti : TaskInstance for XCom operations + - config : HFConfig with pipeline configuration + - task_id : str identifier for this task + - run_id : str identifier for this pipeline run + - ds : str execution date + - execution_date : datetime object + + Returns + ------- + dict[str, pl.DataFrame] + The reference flowpath and divides references in memory + """ + cfg = cast(ReferenceConfig, context["config"]) + + # find the gpkg files + gpkg_files = list(cfg.output_dir.glob(cfg.input_file_regex)) + + # load layers + flowpaths = _load_and_concat_layers(gpkg_files, layer_name="reference_flowline").to_crs("EPSG:4326") + + flowpaths["geometry"] = flowpaths["geometry"].apply( + lambda x: x if x.geom_type == "MultiLineString" else MultiLineString([x]) + ) + + valid_hydroseq = set(flowpaths["hydroseq"].unique()) + flowpaths.loc[~flowpaths["dnhydroseq"].isin(valid_hydroseq), "dnhydroseq"] = 0 + + catchments = _load_and_concat_layers(gpkg_files, layer_name="reference_catchments").to_crs("EPSG:4326") + + flowpaths["dnhydroseq"] = flowpaths["dnhydroseq"].fillna(0) + + hydroseq_lookup = flowpaths.set_index("comid")["hydroseq"].to_dict() + + flowpaths["comid"] = flowpaths["comid"].map(hydroseq_lookup) + catchments["COMID"] = catchments["COMID"].map(hydroseq_lookup) + + # filter/validate layers + _flowpaths = _validate_and_fix_geometries(flowpaths, geom_type="flowpaths") + _flowpaths = _flowpaths[_flowpaths["comid"].isin(catchments["COMID"])] + + catchments = _validate_and_fix_geometries(catchments, geom_type="divides") + catchments = _fix_divide_exclaves(catchments.to_crs("EPSG:3338")).to_crs("EPSG:4326") + + return { + "usgs_flowpaths": pl.from_pandas(_flowpaths.to_wkb()), + "usgs_divides": pl.from_pandas(catchments.to_wkb()), + } diff --git a/src/reference_builds/pipeline/processing.py b/src/reference_builds/pipeline/processing.py index 1851a74..55a3073 100644 --- a/src/reference_builds/pipeline/processing.py +++ b/src/reference_builds/pipeline/processing.py @@ -9,6 +9,7 @@ from reference_builds.task_instance import TaskInstance from reference_builds.utils.geoglows_graph import _build_geoglows_graph from reference_builds.utils.nhd_graph import _build_graph +from reference_builds.utils.usgs_graph import _build_usgs_hf_graph logger = logging.getLogger(__name__) @@ -96,3 +97,29 @@ def build_geoglows_graphs(**context: dict[str, Any]) -> dict[str, Any]: upstream_network = _build_geoglows_graph(flowpaths) graph, node_indices = _build_rustworkx_object(upstream_network) return {"graph": graph, "node_indices": node_indices} + + +def build_usgs_hf_graphs(**context: dict[str, Any]) -> dict[str, Any]: + """Builds and processes graphs from NHD data + + Parameters + ---------- + **context : dict + Airflow-compatible context containing: + - ti : TaskInstance for XCom operations + - config : HFConfig with pipeline configuration + - task_id : str identifier for this task + - run_id : str identifier for this pipeline run + - ds : str execution date + - execution_date : datetime object + + Returns + ------- + dict[str, Any] + The rustworkx graph object and node_indices for the NHD + """ + ti = cast(TaskInstance, context["ti"]) + flowpaths: pl.DataFrame = ti.xcom_pull(task_id="download", key="usgs_flowpaths") + upstream_network = _build_usgs_hf_graph(flowpaths) + graph, node_indices = _build_rustworkx_object(upstream_network) + return {"graph": graph, "node_indices": node_indices} diff --git a/src/reference_builds/utils/geometries.py b/src/reference_builds/utils/geometries.py index a58383e..58d9ce9 100644 --- a/src/reference_builds/utils/geometries.py +++ b/src/reference_builds/utils/geometries.py @@ -1,8 +1,13 @@ """A file for all geometry related internal functions""" +import logging + import geopandas as gpd -from shapely import wkb -from shapely.geometry import LineString, MultiLineString, Point +import pandas as pd +from shapely import Geometry, wkb +from shapely.geometry import LineString, MultiLineString, MultiPolygon, Point + +logger = logging.getLogger(__name__) def _ensure_geometry(geom): # type: ignore[no-untyped-def] @@ -85,6 +90,62 @@ def _orient_flowpath_downstream(geom, ds_geom=None, us_geom=None): # type: igno return geom +def _drop_exclaves(geom: Geometry) -> Geometry: + """Find and destroy non-contiguous parts of MultiPolygons""" + if geom.geom_type != "MultiPolygon": + return geom + main_part = geom.geoms[0] + for part in geom.geoms: + if part.area > main_part.area: + main_part = part + main_part_buffered = main_part.buffer(1.0) + for part in geom.geoms: + if part.intersects(main_part_buffered): + main_part = main_part.union(part) + return main_part + + +def _find_exclaves(geom: Geometry) -> pd.Series: + """Find and exclude non-contiguous parts of MultiPolygons, appending them to a list to be resolved later""" + exclaves = [] + if geom.geom_type != "MultiPolygon": + return pd.Series(data={"geometry": geom, "exclaves": exclaves}, index=["geometry", "exclaves"]) + + main_part = geom.geoms[0] + for part in geom.geoms: + if part.area > main_part.area: + main_part = part + included_parts = [main_part] + main_part_buffered = main_part.buffer(1.0) + for part in geom.geoms: + if part.intersects(main_part_buffered): + included_parts.append(part) + main_part = MultiPolygon(included_parts) + else: + exclaves.append(part) + return pd.Series(data={"geometry": main_part, "exclaves": exclaves}, index=["geometry", "exclaves"]) + + +def _fix_divide_exclaves(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + """Remove exclaves from catchment geometries, merging them in with the neighbor it intersects the most with""" + exclaves = gdf["geometry"].apply(_find_exclaves) + + fixed_count = 0 + + gdf["geometry"] = exclaves["geometry"] + for idx, e in exclaves["exclaves"].items(): + for exclave in e: + buffer = exclave.buffer(1.0) + intersection_areas = gdf.intersection(buffer).area + intersection_areas[idx] = 0.0 + best_idx = intersection_areas.argmax() + gdf.loc[best_idx, "geometry"] = gdf["geometry"][best_idx].union(exclave) + fixed_count += 1 + + logger.info(f"fix_divide_exclaves: fixed/merged {fixed_count} polygons") + return gdf + + def _validate_and_fix_geometries(gdf: gpd.GeoDataFrame, geom_type: str) -> gpd.GeoDataFrame: """Validate and fix invalid geometries in a GeoDataFrame. @@ -112,7 +173,7 @@ def _validate_and_fix_geometries(gdf: gpd.GeoDataFrame, geom_type: str) -> gpd.G return gdf # No invalid geometries geometries = gdf[invalid_mask].geometry - gdf.loc[invalid_mask, "geometry"] = geometries.make_valid() + gdf.loc[invalid_mask, "geometry"] = geometries.make_valid(method="structure") if len(gdf[~gdf.geometry.is_valid]) > 0: raise ValueError(f"Could not fix invalid geometries in {geom_type}") diff --git a/src/reference_builds/utils/usgs_graph.py b/src/reference_builds/utils/usgs_graph.py new file mode 100644 index 0000000..92f531d --- /dev/null +++ b/src/reference_builds/utils/usgs_graph.py @@ -0,0 +1,52 @@ +"""Contains all code for processing USGS Reference Hydrofabric data""" + +import logging + +import polars as pl + +logger = logging.getLogger(__name__) + + +def _build_usgs_hf_graph(flowpaths: pl.DataFrame) -> dict[str, list[str]]: + """Build a graph of upstream flowpath connections from USGS Reference Hydrofabric data. + + Parameters + ---------- + flowpaths : pl.DataFrame + The USGS Reference Hydrofabric flowpaths with hydroseq and dnhydroseq columns + + Returns + ------- + dict[str, list[str]] + The upstream dictionary containing upstream and downstream connections + Key is the downstream flowpath ID, values are the upstream flowpath IDs + """ + # Filter out terminal links (dnhydroseq == 0) for building upstream connections + connectivity = flowpaths.select( + [ + pl.col("hydroseq").cast(pl.Int64), + pl.col("dnhydroseq").cast(pl.Int64), + ] + ).filter(pl.col("dnhydroseq") != 0) + + # Build upstream network: group by downstream link to get all upstream links + upstream_network_df = connectivity.group_by( + pl.col("dnhydroseq").cast(pl.Utf8).alias("downstream_id") + ).agg(pl.col("hydroseq").cast(pl.Utf8).alias("upstream_list")) + + upstream_dict: dict[str, list[str]] = dict( + zip( + upstream_network_df["downstream_id"].to_list(), + upstream_network_df["upstream_list"].to_list(), + strict=False, + ) + ) + + # Ensure all flowpath IDs are in the dictionary (even those with no upstream) + all_flowpath_ids = flowpaths.select(pl.col("hydroseq").cast(pl.Utf8))["hydroseq"].to_list() + + for fp_id in all_flowpath_ids: + if fp_id not in upstream_dict: + upstream_dict[fp_id] = [] + + return upstream_dict From 7d2a91edd4c89d8b6ab4fe59c206fc7a516e4c82 Mon Sep 17 00:00:00 2001 From: Quercus Hamlin <75846376+quercoak@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:20:34 -0400 Subject: [PATCH 15/16] chore: update authors/maintainers (#16) --- pyproject.toml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1730ec2..7cc8b61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,18 +17,16 @@ readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [ + { name = "Brodie Alexander", email = "Brodie.A.Alexander@rtx.com" }, { name = "Tadd Bindas", email = "tadd.bindas@ertcorp.com" }, - { name = "Daniel Cumpton", email = "dcumpton@rtx.com" }, { name = "Quercus Hamlin", email = "qhamlin@asrcfederal.com" }, - { name = "Brock Hinkson", email = "brock.w.hinkson@rtx.com" }, + { name = "Dylan Lee", email = "dylan.lee@entarian.com" }, { name = "Farshid Rahmani", email = "Farshid.Rahmani@rtx.com" }, ] maintainers = [ - { name = "Tadd Bindas", email = "tadd.bindas@ertcorp.com" }, - { name = "Daniel Cumpton", email = "dcumpton@rtx.com" }, + { name = "Brodie Alexander", email = "Brodie.A.Alexander@rtx.com" }, { name = "Quercus Hamlin", email = "qhamlin@asrcfederal.com" }, - { name = "Brock Hinkson", email = "brock.w.hinkson@rtx.com" }, - { name = "Farshid Rahmani", email = "Farshid.Rahmani@rtx.com" }, + { name = "Dylan Lee", email = "dylan.lee@entarian.com" }, ] dependencies = [ From 44c4dc3ebf27bf47e6d7bac0d4d2cc38797c8566 Mon Sep 17 00:00:00 2001 From: Dylan Lee Date: Tue, 12 May 2026 15:34:45 -0400 Subject: [PATCH 16/16] nit: exclude _version.py to avoid quoting errors in ci/cd --- pyproject.toml | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7cc8b61..85f6b1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,22 +62,15 @@ dev = [ "types-PyYaml", "types-requests==2.32.4.20250611", ] -examples = [ - "ipykernel==6.29.5", - "jupyterlab==4.4.3" -] -tests = [ - "pytest==8.4.1", - "pytest-cov==6.1.1", - "astropy==7.1.0" -] +examples = ["ipykernel==6.29.5", "jupyterlab==4.4.3"] +tests = ["pytest==8.4.1", "pytest-cov==6.1.1", "astropy==7.1.0"] [tool.uv] default-groups = ["dev", "examples", "tests"] [tool.ruff] line-length = 110 -exclude = [".csv", "LICENSE", ".tf", ".tfvars"] +exclude = [".csv", "LICENSE", ".tf", ".tfvars", "_version.py"] lint.select = [ "F", # Errors detected by Pyflakes "E", # Error detected by Pycodestyle @@ -145,7 +138,7 @@ warn_unused_ignores = true [tool.pytest.ini_options] filterwarnings = [ - "ignore::DeprecationWarning:pyogrio", - "ignore:The 'shapely.geos' module is deprecated:DeprecationWarning", - "ignore:The behavior of DataFrame concatenation with empty or all-NA entries is deprecated:FutureWarning", + "ignore::DeprecationWarning:pyogrio", + "ignore:The 'shapely.geos' module is deprecated:DeprecationWarning", + "ignore:The behavior of DataFrame concatenation with empty or all-NA entries is deprecated:FutureWarning", ]