pax_global_header00006660000000000000000000000064151202555560014520gustar00rootroot0000000000000052 comment=b796d59fea77f5e068d4c0ed8bb9d3f7c5316c19 scverse-anndata-b796d59/000077500000000000000000000000001512025555600151515ustar00rootroot00000000000000scverse-anndata-b796d59/.cirun.yml000066400000000000000000000003361512025555600170740ustar00rootroot00000000000000runners: - name: aws-gpu-runner cloud: aws instance_type: g4dn.xlarge machine_image: ami-067a4ba2816407ee9 region: eu-north-1 preemptible: - true - false labels: - cirun-aws-gpu scverse-anndata-b796d59/.codecov.yml000066400000000000000000000004041512025555600173720ustar00rootroot00000000000000# Based on pydata/xarray codecov: require_ci_to_pass: false coverage: status: project: default: # Require 80% coverage target: 80 changes: false comment: layout: "diff, flags, files" behavior: once require_base: false scverse-anndata-b796d59/.cruft.json000066400000000000000000000030061512025555600172440ustar00rootroot00000000000000{ "template": "https://github.com/scverse/cookiecutter-scverse", "commit": "d383d94fadff9e4e6fdb59d77c68cb900d7cedec", "checkout": "v0.6.0", "context": { "cookiecutter": { "project_name": "anndata", "package_name": "anndata", "project_description": "Annotated data.", "author_full_name": "Philipp Angerer", "author_email": "philipp.angerer@helmholtz-munich.de", "github_user": "scverse", "github_repo": "anndata", "license": "BSD 3-Clause License", "ide_integration": true, "_copy_without_render": [ ".github/workflows/build.yaml", ".github/workflows/test.yaml", "docs/_templates/autosummary/**.rst" ], "_exclude_on_template_update": [ "CHANGELOG.md", "LICENSE", "README.md", "docs/api.md", "docs/index.md", "docs/notebooks/example.ipynb", "docs/references.bib", "docs/references.md", "src/**", "tests/**" ], "_render_devdocs": false, "_jinja2_env_vars": { "lstrip_blocks": true, "trim_blocks": true }, "_template": "https://github.com/scverse/cookiecutter-scverse", "_commit": "d383d94fadff9e4e6fdb59d77c68cb900d7cedec" } }, "directory": null } scverse-anndata-b796d59/.editorconfig000066400000000000000000000003101512025555600176200ustar00rootroot00000000000000root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true max_line_length = 88 indent_size = 4 indent_style = space [*.{yml,yaml}] indent_size = 2 scverse-anndata-b796d59/.github/000077500000000000000000000000001512025555600165115ustar00rootroot00000000000000scverse-anndata-b796d59/.github/ISSUE_TEMPLATE/000077500000000000000000000000001512025555600206745ustar00rootroot00000000000000scverse-anndata-b796d59/.github/ISSUE_TEMPLATE/bug-report.yml000066400000000000000000000042141512025555600235060ustar00rootroot00000000000000name: Bug report description: anndata doesn’t do what it should? Please help us fix it! #title: ... labels: - Bug 🐛 - Triage 🩺 #assignees: [] body: - type: checkboxes id: terms attributes: label: Please make sure these conditions are met # description: ... options: - label: I have checked that this issue has not already been reported. required: true - label: I have confirmed this bug exists on the latest version of anndata. required: true - label: (optional) I have confirmed this bug exists on the master branch of anndata. required: false - type: markdown attributes: value: | **Note**: Please read [this guide][] detailing how to provide the necessary information for us to reproduce your bug. [this guide]: https://matthewrocklin.com/blog/work/2018/02/28/minimal-bug-reports - type: textarea id: Report attributes: label: Report description: | Describe the bug you encountered, and what you were trying to do. Please use [github markdown][] features for readability. [github markdown]: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax value: | Code: ```python ``` Traceback: ```pytb ``` validations: required: true - type: textarea id: versions attributes: label: Versions description: | Which version of anndata and other related software you used. Please install `session-info2`, run the following command in a notebook, click the “Copy as Markdown” button, then paste the results into the text box below. ```python In[1]: import anndata, session_info2; session_info2.session_info(dependencies=True) ``` Alternatively, run this in a console: ```python >>> import session_info2; print(session_info2.session_info(dependencies=True)._repr_mimebundle_()["text/markdown"]) ``` render: python validations: required: true scverse-anndata-b796d59/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000003051512025555600226620ustar00rootroot00000000000000blank_issues_enabled: false contact_links: - name: Scverse Community Forum url: https://discourse.scverse.org/ about: If you have questions about “How to do X”, please ask them here. scverse-anndata-b796d59/.github/ISSUE_TEMPLATE/enhancement-request.yml000066400000000000000000000005421512025555600253730ustar00rootroot00000000000000name: Enhancement request description: Anything you’d like to see in anndata? #title: ... labels: - enhancement - Triage 🩺 #assignees: [] body: - type: textarea id: description attributes: label: | Please describe your wishes and possible alternatives to achieve the desired result. validations: required: true scverse-anndata-b796d59/.github/ISSUE_TEMPLATE/question.yml000066400000000000000000000006211512025555600232650ustar00rootroot00000000000000name: Technical question description: You wonder about a design decision or implementation detail? #title: ... labels: - question #assignees: [] body: - type: textarea id: description attributes: label: Question description: If you have *usage* question, please visit the [Scverse Community Forum](https://discourse.scverse.org/) instead. validations: required: true scverse-anndata-b796d59/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000002661512025555600223160ustar00rootroot00000000000000 - [ ] Closes # - [ ] Tests added - [ ] Release note added (or unnecessary) scverse-anndata-b796d59/.github/dependabot.yml000066400000000000000000000002451512025555600213420ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly labels: - no milestone - skip-gpu-ci scverse-anndata-b796d59/.github/workflows/000077500000000000000000000000001512025555600205465ustar00rootroot00000000000000scverse-anndata-b796d59/.github/workflows/benchmark.yml000066400000000000000000000032001512025555600232160ustar00rootroot00000000000000name: Benchmark on: push: branches: [main, "[0-9]+.[0-9]+.x"] pull_request: branches: [main] env: FORCE_COLOR: "1" defaults: run: # Add `-l` to GitHub’s default bash options to activate mamba environments # https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#exit-codes-and-error-action-preference # https://github.com/mamba-org/setup-micromamba/#readme shell: bash -elo pipefail {0} jobs: benchmark: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: python: ["3.12"] os: [ubuntu-latest] env: OS: ${{ matrix.os }} PYTHON: ${{ matrix.python }} ASV_DIR: "./benchmarks" steps: - uses: actions/checkout@v5 with: fetch-depth: 0 # no blob filter so asv can checkout other commits - run: git fetch origin main:main if: ${{ github.ref_name != 'main' }} # Errors on main branch - uses: mamba-org/setup-micromamba@v2 with: environment-name: asv cache-environment: true # Deps documented in https://asv.readthedocs.io/en/latest/installing.html create-args: >- python=${{ matrix.python }} asv py_rattler conda-build - name: Cache datasets uses: actions/cache@v4 with: path: | ~/.cache key: benchmark-state-${{ hashFiles('benchmarks/**') }} - name: Quick benchmark run working-directory: ${{ env.ASV_DIR }} run: | asv machine --yes asv run --quick --show-stderr --verbose scverse-anndata-b796d59/.github/workflows/check-pr.yml000066400000000000000000000022631512025555600227700ustar00rootroot00000000000000name: Pull Request Validation on: pull_request: branches: - main - master types: # milestone changes - milestoned - demilestoned # label changes for “no milestone” - labeled - unlabeled # initial check - opened - edited - reopened # code change (e.g. this workflow) - synchronize env: LABELS: ${{ join(github.event.pull_request.labels.*.name, '|') }} jobs: check-milestone: name: "Triage: Check PR title, milestone, and labels" runs-on: ubuntu-latest steps: - name: Check if merging isn’t blocked uses: flying-sheep/check@v1 with: success: ${{ ! contains(env.LABELS, 'DON’T MERGE') }} - name: Check if a milestone is necessary and exists uses: flying-sheep/check@v1 with: success: ${{ github.event.pull_request.milestone != null || contains(env.LABELS, 'no milestone') }} - name: Check if PR title is valid uses: amannn/action-semantic-pull-request@v6 env: # Needs repo options: “Squash and merge” with commit message set to “PR title” GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} scverse-anndata-b796d59/.github/workflows/close-stale.yml000066400000000000000000000011261512025555600235040ustar00rootroot00000000000000name: "Close stale issues" on: schedule: - cron: "0 2 * * *" workflow_dispatch: jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@v5 with: days-before-issue-stale: -1 # We don't want to mark issues as stale in this action days-before-issue-close: 14 days-before-pr-close: -1 # don't close PRs days-before-pr-stale: -1 # don't mark PRs as stale stale-issue-label: stale any-of-labels: "needs info" debug-only: true # enable dry-run, remove when we know from the logs it's working. scverse-anndata-b796d59/.github/workflows/codespell.yml000066400000000000000000000005631512025555600232470ustar00rootroot00000000000000--- name: Codespell on: push: branches: [main] pull_request: branches: [main] permissions: contents: read jobs: codespell: name: Check for spelling errors runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 filter: blob:none - uses: codespell-project/actions-codespell@v2 scverse-anndata-b796d59/.github/workflows/label-stale.yml000066400000000000000000000014021512025555600234530ustar00rootroot00000000000000name: "Label stale issues" on: schedule: - cron: "30 1 * * 1,2,3,4,5" workflow_dispatch: jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@v5 with: days-before-issue-stale: 60 days-before-pr-stale: -1 # We don't want to mark PRs as stale days-before-close: -1 # We don't want to close issues in this action stale-issue-label: stale exempt-issue-labels: pinned,enhancement stale-issue-message: | This issue has been automatically marked as stale because it has not had recent activity. Please add a comment if you want to keep the issue open. Thank you for your contributions! debug-only: false # set to `true` to enable dry-run scverse-anndata-b796d59/.github/workflows/publish.yml000066400000000000000000000010551512025555600227400ustar00rootroot00000000000000name: Publish Python Package on: release: types: [published] jobs: publish: runs-on: ubuntu-latest environment: pypi permissions: id-token: write # to authenticate as Trusted Publisher to pypi.org steps: - uses: actions/checkout@v5 with: fetch-depth: 0 filter: blob:none - uses: actions/setup-python@v5 with: python-version: "3.x" cache: "pip" - run: pip install build - run: python -m build - uses: pypa/gh-action-pypi-publish@release/v1 scverse-anndata-b796d59/.github/workflows/test-cpu.yml000066400000000000000000000071461512025555600230450ustar00rootroot00000000000000name: CI on: push: branches: - main - "[0-9]+.[0-9]+.x" pull_request: env: FORCE_COLOR: "1" # Cancel the job if new commits are pushed: https://stackoverflow.com/q/66335225/247482 concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: get-environments: runs-on: ubuntu-latest outputs: envs: ${{ steps.get-envs.outputs.envs }} steps: - uses: actions/checkout@v5 with: filter: blob:none fetch-depth: 0 - uses: astral-sh/setup-uv@v6 with: enable-cache: false - id: get-envs run: | ENVS_JSON=$(NO_COLOR=1 uvx hatch env show --json | jq -c 'to_entries | map( select(.key | startswith("hatch-test")) | { name: .key, python: .value.python, args: (.value."extra-args" // [] | join(" ")) } )') echo "envs=${ENVS_JSON}" | tee $GITHUB_OUTPUT test: needs: get-environments runs-on: ubuntu-latest strategy: matrix: env: ${{ fromJSON(needs.get-environments.outputs.envs) }} io_mark: ["zarr_io", "not zarr_io", "dask_distributed"] # dask_distributed should not be run with -n auto as it uses a client with processes env: # environment variables for use in codecov’s env_vars tagging ENV_NAME: ${{ matrix.env.name }} IO_MARK: ${{ matrix.io_mark }} steps: - uses: actions/checkout@v5 with: fetch-depth: 0 filter: blob:none - name: Install system dependencies run: sudo apt install -y hdf5-tools - name: Install UV uses: astral-sh/setup-uv@v6 with: enable-cache: true python-version: ${{ matrix.env.python }} - name: Install dependencies run: | uv tool install hatch hatch -v env create ${{ matrix.env.name }} - name: Run tests run: | hatch run ${{ matrix.env.name }}:run-cov -v --color=yes ${{ matrix.io_mark != 'dask_distributed' && '-n auto' || '' }} --junitxml=test-data/test-results.xml -m "${{ matrix.io_mark }}" ${{ matrix.env.args }} hatch run ${{ matrix.env.name }}:cov-combine hatch run ${{ matrix.env.name }}:coverage xml - name: Upload test results if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} env_vars: ENV_NAME,IO_MARK fail_ci_if_error: true file: test-data/test-results.xml - name: Upload coverage data uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} env_vars: ENV_NAME,IO_MARK fail_ci_if_error: true files: test-data/coverage.xml build: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v5 with: fetch-depth: 0 filter: blob:none - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.x' cache: pip - name: Install build tools and requirements run: | python -m pip install --upgrade pip pip install build twine - name: Display installed versions run: pip list - name: Build & Twine check run: | python -m build --sdist --wheel . twine check dist/* check: if: always() needs: - get-environments - test - build runs-on: ubuntu-latest steps: - uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} scverse-anndata-b796d59/.github/workflows/test-gpu.yml000066400000000000000000000065441512025555600230520ustar00rootroot00000000000000name: AWS GPU on: push: branches: [main, "[0-9]+.[0-9]+.x"] pull_request: types: - labeled - opened - synchronize env: PYTEST_ADDOPTS: "-v --color=yes" FORCE_COLOR: "1" # Cancel the job if new commits are pushed # https://stackoverflow.com/questions/66335225/how-to-cancel-previous-runs-in-the-pr-when-you-push-new-commitsupdate-the-curre concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true # There are two jobs: # 1. `check` determines if the second job (`test`) will be run (through a job dependency). # 2. `test` runs on an AWS runner and executes the GPU tests. jobs: # If the `skip-gpu-ci` label is set, this job is skipped, and consequently the `test` job too. # If the `run-gpu-ci` label is set or we reacted to a `push` event, this job succeeds (and `test` is run). # If neither is set, this job fails, `test` is skipped, and the whole workflow fails. check: name: "Triage: Check if GPU tests are allowed to run" if: (!contains(github.event.pull_request.labels.*.name, 'skip-gpu-ci')) runs-on: ubuntu-latest steps: - uses: flying-sheep/check@v1 with: success: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'run-gpu-ci') }} # If `check` wasn’t skipped or failed, start an AWS runner and run the GPU tests on it. test: name: GPU Tests needs: check runs-on: "cirun-aws-gpu--${{ github.run_id }}" # Setting a timeout of 30 minutes, as the AWS costs money # At time of writing, a typical run takes about 5 minutes timeout-minutes: 30 steps: - uses: actions/checkout@v4 # TODO: upgrade once cirun image supports node 24 with: fetch-depth: 0 filter: blob:none - name: Nvidia SMI sanity check run: nvidia-smi - name: Install yq run: | sudo snap install yq - name: Extract max Python version from classifiers run: | classifiers=$(yq .project.classifiers pyproject.toml -oy | grep --only-matching --perl-regexp '(?<=Python :: )(\d\.\d+)') max_version=$(echo "$classifiers" | sort -V | tail -1) echo "max_python_version=$max_version" >> $GITHUB_ENV - name: Install UV uses: astral-sh/setup-uv@v6 # TODO: upgrade once cirun image supports node 24 with: enable-cache: true # Any Cuda 14+ will support Python 3.14: https://github.com/cupy/cupy/issues/9346 python-version: '3.13' # ${{ env.max_python_version }} - name: Install AnnData run: | uv venv uv pip install -e ".[dev,test,cu12]" -c ci/constraints.txt - name: Env list run: uv pip list - name: Run test env: COVERAGE_PROCESS_START: ${{ github.workspace }}/pyproject.toml run: | uv run coverage run -m pytest -m gpu -n auto uv run coverage combine uv run coverage xml - uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true files: test-data/coverage.xml - name: Remove 'run-gpu-ci' Label if: always() uses: actions-ecosystem/action-remove-labels@v1 with: labels: "run-gpu-ci" github_token: ${{ secrets.GITHUB_TOKEN }} scverse-anndata-b796d59/.gitignore000066400000000000000000000007221512025555600171420ustar00rootroot00000000000000# Temp files .DS_Store *~ # Caches for compiled and downloaded files __pycache__/ /*cache/ /node_modules/ /data/ /venv/ # Distribution / packaging /dist/ /ci/min-deps.txt /ci/pre-deps.txt /requirements*.lock /.python-version # Test results (nunit/junit) and coverage /test-data/ /*coverage* # jupyter .ipynb_checkpoints # docs /docs/generated/ /docs/_build/ # IDEs /.idea/ # Benchmark .asv benchmark/benchmarks/data benchmarks/benchmarks/data benchmarks/pkgs scverse-anndata-b796d59/.gitmodules000066400000000000000000000001741512025555600173300ustar00rootroot00000000000000[submodule "docs/tutorials/notebooks"] path = docs/tutorials/notebooks url = https://github.com/scverse/anndata-tutorials scverse-anndata-b796d59/.pre-commit-config.yaml000066400000000000000000000020651512025555600214350ustar00rootroot00000000000000repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.7 hooks: - id: ruff-check args: ["--fix"] - id: ruff-format # The following can be removed once PLR0917 is out of preview - name: ruff preview rules id: ruff args: ["--preview", "--select=PLR0917"] - repo: https://github.com/biomejs/pre-commit rev: v2.3.8 hooks: - id: biome-format - repo: https://github.com/ComPWA/taplo-pre-commit rev: v0.9.3 hooks: - id: taplo-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-added-large-files - id: check-case-conflict - id: check-toml - id: check-yaml - id: check-merge-conflict - id: detect-private-key - id: no-commit-to-branch args: ["--branch=main"] - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell additional_dependencies: - tomli scverse-anndata-b796d59/.readthedocs.yml000066400000000000000000000011761512025555600202440ustar00rootroot00000000000000version: 2 build: os: ubuntu-24.04 tools: python: "3.13" jobs: post_checkout: # unshallow so version can be derived from tag - git fetch --unshallow || true pre_build: # run towncrier to preview the next version’s release notes - ( find docs/release-notes -regex '[^.]+[.][^.]+.md' | grep -q . ) && towncrier build --keep || true sphinx: configuration: docs/conf.py fail_on_warning: true # do not change or you will be fired python: install: - method: pip path: . extra_requirements: - doc submodules: include: - "docs/tutorials/notebooks" recursive: true scverse-anndata-b796d59/.taplo.toml000066400000000000000000000001521512025555600172410ustar00rootroot00000000000000[formatting] array_auto_collapse = false column_width = 120 compact_arrays = false indent_string = ' ' scverse-anndata-b796d59/.vscode/000077500000000000000000000000001512025555600165125ustar00rootroot00000000000000scverse-anndata-b796d59/.vscode/launch.json000066400000000000000000000014011512025555600206530ustar00rootroot00000000000000{ "version": "0.2.0", "configurations": [ { "name": "Python: Build Docs", "type": "debugpy", "request": "launch", "module": "sphinx", "args": ["-M", "html", ".", "_build"], "cwd": "${workspaceFolder}/docs", "console": "internalConsole", "justMyCode": false, }, { "name": "Python: Debug Test", "type": "debugpy", "request": "launch", "program": "${file}", "purpose": ["debug-test"], "console": "internalConsole", "justMyCode": false, "env": { "PYTEST_ADDOPTS": "--color=yes" }, "presentation": { "hidden": true }, }, ], } scverse-anndata-b796d59/.vscode/settings.json000066400000000000000000000013501512025555600212440ustar00rootroot00000000000000{ "[python][toml][json][jsonc]": { "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports": "explicit", "source.fixAll": "explicit", }, }, "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", }, "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml", }, "[json][jsonc]": { "editor.defaultFormatter": "biomejs.biome", }, "python.analysis.typeCheckingMode": "basic", "python.testing.pytestEnabled": true, "python.testing.pytestArgs": [ "--color=yes", "-vv", "--strict-warnings", //"-nauto", ], "python.terminal.activateEnvironment": true, } scverse-anndata-b796d59/LICENSE000066400000000000000000000030471512025555600161620ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2025, scverse® Copyright (c) 2017-2018, P. Angerer, F. Alexander Wolf, Theis Lab All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * 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. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 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. scverse-anndata-b796d59/README.md000066400000000000000000000106711512025555600164350ustar00rootroot00000000000000[![Tests](https://github.com/scverse/anndata/actions/workflows/test-cpu.yml/badge.svg)](https://github.com/scverse/anndata/actions) [![Conda](https://img.shields.io/conda/vn/conda-forge/anndata.svg)](https://anaconda.org/conda-forge/anndata) [![Coverage](https://codecov.io/gh/scverse/anndata/branch/main/graph/badge.svg?token=IN1mJN1Wi8)](https://codecov.io/gh/scverse/anndata) [![Docs](https://readthedocs.com/projects/icb-anndata/badge/?version=latest)](https://anndata.readthedocs.io) [![PyPI](https://img.shields.io/pypi/v/anndata.svg)](https://pypi.org/project/anndata) [![Downloads](https://static.pepy.tech/badge/anndata/month)](https://pepy.tech/project/anndata) [![Downloads](https://static.pepy.tech/badge/anndata)](https://pepy.tech/project/anndata) [![Stars](https://img.shields.io/github/stars/scverse/anndata?style=flat&logo=github&color=yellow)](https://github.com/scverse/anndata/stargazers) [![Powered by NumFOCUS](https://img.shields.io/badge/powered%20by-NumFOCUS-orange.svg?style=flat&colorA=E1523D&colorB=007D8A)](http://numfocus.org) image # anndata - Annotated data anndata is a Python package for handling annotated data matrices in memory and on disk, positioned between pandas and xarray. anndata offers a broad range of computationally efficient features including, among others, sparse data support, lazy operations, and a PyTorch interface. - Discuss development on [GitHub](https://github.com/scverse/anndata). - Read the [documentation](https://anndata.readthedocs.io). - Ask questions on the [scverse Discourse](https://discourse.scverse.org). - Install via `pip install anndata` or `conda install anndata -c conda-forge`. - See [Scanpy's documentation](https://scanpy.readthedocs.io/) for usage related to single cell data. anndata was initially built for Scanpy. [//]: # (numfocus-fiscal-sponsor-attribution) anndata is part of the scverse® project ([website](https://scverse.org), [governance](https://scverse.org/about/roles)) and is fiscally sponsored by [NumFOCUS](https://numfocus.org/). If you like scverse® and want to support our mission, please consider making a tax-deductible [donation](https://numfocus.org/donate-to-scverse) to help the project pay for developer time, professional services, travel, workshops, and a variety of other needs.
## Public API Our public API is documented in the [API section][] of these docs. We cannot guarantee the stability of our internal APIs, whether it's the location of a function, its arguments, or something else. In other words, we do not officially support (or encourage users to do) something like `from anndata._core import AnnData` as `_core` is both not documented and contains a [leading underscore][]. However, we are aware that [many users do use these internal APIs][] and thus encourage them to [open an issue][] or migrate to the public API. That is, if something is missing from our public API as documented, for example a feature you wish to be exported publicly, please open an issue. [api section]: https://anndata.readthedocs.io/en/stable/api.html [leading underscore]: https://peps.python.org/pep-0008/#public-and-internal-interfaces [many users do use these internal APIs]: https://github.com/search?q=%22anndata._io%22&type=code [open an issue]: https://github.com/scverse/anndata/issues/new/choose ## Citation If you use `anndata` in your work, please cite the `anndata` publication as follows: > **anndata: Annotated data** > > Isaac Virshup, Sergei Rybakov, Fabian J. Theis, Philipp Angerer, F. Alexander Wolf > > _JOSS_ 2024 Sep 16. doi: [10.21105/joss.04371](https://doi.org/10.21105/joss.04371). You can cite the scverse publication as follows: > **The scverse project provides a computational ecosystem for single-cell omics data analysis** > > Isaac Virshup, Danila Bredikhin, Lukas Heumos, Giovanni Palla, Gregor Sturm, Adam Gayoso, Ilia Kats, Mikaela Koutrouli, Scverse Community, Bonnie Berger, Dana Pe’er, Aviv Regev, Sarah A. Teichmann, Francesca Finotello, F. Alexander Wolf, Nir Yosef, Oliver Stegle & Fabian J. Theis > > _Nat Biotechnol._ 2023 Apr 10. doi: [10.1038/s41587-023-01733-8](https://doi.org/10.1038/s41587-023-01733-8). scverse-anndata-b796d59/benchmarks/000077500000000000000000000000001512025555600172665ustar00rootroot00000000000000scverse-anndata-b796d59/benchmarks/README.md000066400000000000000000000074331512025555600205540ustar00rootroot00000000000000# AnnData Benchmarks This repo contains some work in progress benchmarks for [AnnData](https://github.com/theislab/anndata) using [asv](https://asv.readthedocs.io). ## Setup I definitely recommend reading through the asv docs. Currently, this assumes the benchmark suite can reach the `anndata` repo via the path `../anndata`. Otherwise, all you'll need to do is create a [machine file](https://asv.readthedocs.io/en/stable/commands.html#asv-machine) for your system and make sure `anndata`s dependencies are installable via `conda`. ### Data Data will need to be retrieved for these benchmarks. This can be downloaded using the script fetch_datasets.py. Note that the `h5ad` format has changed since it's inception. While the `anndata` package maintains backwards compatibility, older versions of `anndata` will not be able to read files written by more recent versions. To get around this for the benchmarks, datasets have to be able to be read by all versions which can require a setup function that creates the anndata object. ## Usage ### Runnings the benchmarks: To run benchmarks for a particular commit: `asv run {commit} --steps 1 -b` To run benchmarks for a range of commits: `asv run {commit1}..{commit2}` You can filter out the benchmarks which are run with the `-b {pattern}` flag. ### Accessing the benchmarks You can see what benchmarks you've already run using `asv show`. If you don't specify a commit, it will search for the available commits. If you specify a commit it'll show you those results. For example: ```bash $ asv show -b "views" Commits with results: Machine : mimir.mobility.unimelb.net.au Environment: conda-py3.7-h5py-memory_profiler-natsort-numpy-pandas-scipy 61eb5bb7 e9ccfc33 22f12994 0ebe187e ``` ```bash $ asv show -b "views" 0ebe187e Commit: 0ebe187e views.SubsetMemorySuite.track_repeated_subset_memratio [mimir.mobility.unimelb.net.au/conda-py3.7-h5py-memory_profiler-natsort-numpy-pandas-scipy] ok ======= ======= ========== ============ ===================== ====================== ====================== -- index_kind --------------------------------------- ------------------------------------------------------------------- n_obs n_var attr_set subset_dim intarray boolarray slice ======= ======= ========== ============ ===================== ====================== ====================== 100 100 X-csr obs 2.84 1.7916666666666667 0.5 100 100 X-csr var 2.5357142857142856 1.8695652173913044 0.5652173913043478 100 100 X-dense obs 3.1739130434782608 1.6538461538461537 0.6 ... ``` You can compare two commits with `asv compare` ```bash $ asv compare e9ccfc 0ebe187e All benchmarks: before after ratio [e9ccfc33] [0ebe187e] - 2.16 1.7916666666666667 0.83 views.SubsetMemorySuite.track_repeated_subset_memratio(100, 100, 'X-csr', 'obs', 'boolarray') + 2.533333333333333 2.84 1.12 views.SubsetMemorySuite.track_repeated_subset_memratio(100, 100, 'X-csr', 'obs', 'intarray') - 1.1923076923076923 0.5 0.42 views.SubsetMemorySuite.track_repeated_subset_memratio(100, 100, 'X-csr', 'obs', 'slice') 1.9615384615384615 1.8695652173913044 0.95 views.SubsetMemorySuite.track_repeated_subset_memratio(100, 100, 'X-csr', 'var', 'boolarray') ``` ### View in the browser: You can view the benchmarks in the browser with `asv publish` followed by `asv preview`. If you want to include benchmarks of a local branch, I think you'll have to add that branch to the `"branches"` list in `asv.conf.json`. scverse-anndata-b796d59/benchmarks/asv.conf.json000066400000000000000000000154551512025555600217100ustar00rootroot00000000000000{ // The version of the config file format. Do not change, unless // you know what you are doing. "version": 1, // The name of the project being benchmarked "project": "anndata", // The project's homepage "project_url": "https://anndata.readthedocs.io/", // The URL or local path of the source code repository for the // project being benchmarked "repo": "../../anndata", // The Python project's subdirectory in your repo. If missing or // the empty string, the project is assumed to be located at the root // of the repository. // "repo_subdir": "", // Customizable commands for building, installing, and // uninstalling the project. See asv.conf.json documentation. // // "install_command": ["python -mpip install {wheel_file}"], // "uninstall_command": ["return-code=any python -mpip uninstall -y {project}"], "build_command": [ "python -m pip install build", "python -m build --wheel -o {build_cache_dir} {build_dir}", ], // List of branches to benchmark. If not provided, defaults to "master" // (for git) or "default" (for mercurial). "branches": ["main"], // for git // "branches": ["default"], // for mercurial // The DVCS being used. If not set, it will be automatically // determined from "repo" by looking at the protocol in the URL // (if remote), or by looking for special directories, such as // ".git" (if local). "dvcs": "git", // The tool to use to create environments. May be "conda", // "virtualenv" or other value depending on the plugins in use. // If missing or the empty string, the tool will be automatically // determined by looking for tools on the PATH environment // variable. "environment_type": "rattler", // timeout in seconds for installing any dependencies in environment // defaults to 10 min //"install_timeout": 600, // the base URL to show a commit for the project. "show_commit_url": "https://github.com/theislab/anndata/commit/", // The Pythons you'd like to test against. If not provided, defaults // to the current version of Python used to run `asv`. // "pythons": ["2.7", "3.6"], // The list of conda channel names to be searched for benchmark // dependency packages in the specified order "conda_channels": ["conda-forge", "defaults"], // The matrix of dependencies to test. Each key is the name of a // package (in PyPI) and the values are version numbers. An empty // list or empty string indicates to just test against the default // (latest) version. null indicates that the package is to not be // installed. If the package to be tested is only available from // PyPi, and the 'environment_type' is conda, then you can preface // the package name by 'pip+', and the package will be installed via // pip (with all the conda available packages installed first, // followed by the pip installed packages). // "matrix": { "numpy": [""], // "scipy": ["1.2", ""], "scipy": [""], "h5py": [""], "natsort": [""], "pandas": [""], "memory_profiler": [""], "zarr": [""], "pytoml": [""], "pytest": [""], "pooch": [""], "xarray": [""], "dask": [""], // "scanpy": [""], // "psutil": [""] }, // Combinations of libraries/python versions can be excluded/included // from the set to test. Each entry is a dictionary containing additional // key-value pairs to include/exclude. // // An exclude entry excludes entries where all values match. The // values are regexps that should match the whole string. // // An include entry adds an environment. Only the packages listed // are installed. The 'python' key is required. The exclude rules // do not apply to includes. // // In addition to package names, the following keys are available: // // - python // Python version, as in the *pythons* variable above. // - environment_type // Environment type, as above. // - sys_platform // Platform, as in sys.platform. Possible values for the common // cases: 'linux2', 'win32', 'cygwin', 'darwin'. // // "exclude": [ // {"python": "3.2", "sys_platform": "win32"}, // skip py3.2 on windows // {"environment_type": "conda", "six": null}, // don't run without six on conda // ], // // "include": [ // // additional env for python2.7 // {"python": "2.7", "numpy": "1.8"}, // // additional env if run on windows+conda // {"platform": "win32", "environment_type": "mamba", "python": "2.7", "libpython": ""}, // ], // The directory (relative to the current directory) that benchmarks are // stored in. If not provided, defaults to "benchmarks" // "benchmark_dir": "benchmarks", // The directory (relative to the current directory) to cache the Python // environments in. If not provided, defaults to "env" "env_dir": ".asv/env", // The directory (relative to the current directory) that raw benchmark // results are stored in. If not provided, defaults to "results". "results_dir": ".asv/results", // The directory (relative to the current directory) that the html tree // should be written to. If not provided, defaults to "html". "html_dir": ".asv/html", // The number of characters to retain in the commit hashes. // "hash_length": 8, // `asv` will cache results of the recent builds in each // environment, making them faster to install next time. This is // the number of builds to keep, per environment. // "build_cache_size": 2, // The commits after which the regression search in `asv publish` // should start looking for regressions. Dictionary whose keys are // regexps matching to benchmark names, and values corresponding to // the commit (exclusive) after which to start looking for // regressions. The default is to start from the first commit // with results. If the commit is `null`, regression detection is // skipped for the matching benchmark. // // "regressions_first_commits": { // "some_benchmark": "352cdf", // Consider regressions only after this commit // "another_benchmark": null, // Skip regression detection altogether // }, // The thresholds for relative change in results, after which `asv // publish` starts reporting regressions. Dictionary of the same // form as in ``regressions_first_commits``, with values // indicating the thresholds. If multiple entries match, the // maximum is taken. If no entry matches, the default is 5%. // // "regressions_thresholds": { // "some_benchmark": 0.01, // Threshold of 1% // "another_benchmark": 0.5, // Threshold of 50% // }, } scverse-anndata-b796d59/benchmarks/benchmarks/000077500000000000000000000000001512025555600214035ustar00rootroot00000000000000scverse-anndata-b796d59/benchmarks/benchmarks/__init__.py000066400000000000000000000000001512025555600235020ustar00rootroot00000000000000scverse-anndata-b796d59/benchmarks/benchmarks/anndata.py000066400000000000000000000022751512025555600233710ustar00rootroot00000000000000from __future__ import annotations import tracemalloc import numpy as np from .utils import gen_adata class GarbargeCollectionSuite: runs = 10 # custom because `memory_profiler` is a line-by-line profiler (also: https://github.com/pythonprofilers/memory_profiler/issues/402) def track_peakmem_garbage_collection(self, *_): def display_top(snapshot, key_type="lineno"): snapshot = snapshot.filter_traces(( tracemalloc.Filter( inclusive=False, filename_pattern="", ), tracemalloc.Filter( inclusive=False, filename_pattern="", ), )) top_stats = snapshot.statistics(key_type) total = sum(stat.size for stat in top_stats) return total total = np.zeros(self.runs) tracemalloc.start() for i in range(self.runs): data = gen_adata(10000, 10000, "X-csc") # noqa: F841 snapshot = tracemalloc.take_snapshot() total[i] = display_top(snapshot) tracemalloc.stop() return max(total) scverse-anndata-b796d59/benchmarks/benchmarks/backed_hdf5.py000066400000000000000000000075621512025555600241060ustar00rootroot00000000000000from __future__ import annotations import numpy as np import pandas as pd from scipy import sparse import anndata as ad file_paths = {"sparse": "adata_sparse.h5ad"} class BackedHDF5Indexing: param_names = ("arr_type",) params = ("sparse",) def setup_cache(self): X_sparse = sparse.random( 10000, 50000, density=0.01, format="csr", random_state=np.random.default_rng(42), ) for X, arr_type in [ (X_sparse, "sparse"), ]: n_obs, n_var = X.shape # Create obs and var dataframes obs = pd.DataFrame( { "cell_type": pd.Categorical( np.random.choice(["TypeA", "TypeB", "TypeC"], n_obs) ), "total_counts": np.random.randint(1000, 5000, n_obs), }, index=[f"cell_{i}" for i in range(n_obs)], ) var = pd.DataFrame( { "gene_name": [f"gene_{i}" for i in range(n_var)], }, index=[f"ENSG_{i:08d}" for i in range(n_var)], ) # Create AnnData object and save to HDF5 adata = ad.AnnData(X=X, obs=obs, var=var) # Create temporary file adata.write_h5ad(file_paths[arr_type]) def setup(self, arr_type): # Open as backed self.adata_backed = ad.read_h5ad(file_paths[arr_type], backed="r") self.n_obs, self.n_var = self.adata_backed.shape # Prepare indices for duplicate index testing self.obs_idx_with_dupes = np.array([0, 1, 0, 2, 1] * (self.n_obs // 100 + 1))[ : (self.n_obs // 10) ] self.var_idx_with_dupes = np.array([0, 1, 2, 0, 3] * (self.n_var // 100 + 1))[ : (self.n_var // 10) ] self.obs_idx_no_dupes = np.arange(0, self.n_obs, 10) self.var_idx_no_dupes = np.arange(0, self.n_var, 10) def time_slice_obs(self, *_): """Time slicing observations from backed HDF5""" self.adata_backed[0 : (self.n_obs // 2), :] def time_slice_obs_to_memory(self, *_): """Time slicing observations from backed HDF5""" self.adata_backed[0 : (self.n_obs // 2), :].to_memory() def peakmem_slice_obs(self, *_): """Peak memory for slicing observations from backed HDF5""" self.adata_backed[0 : (self.n_obs // 2), :] def time_fancy_index_no_dupes(self, *_): """Time fancy indexing without duplicates""" self.adata_backed[self.obs_idx_no_dupes, self.var_idx_no_dupes] def peakmem_fancy_index_no_dupes(self, *_): """Peak memory for fancy indexing without duplicates""" self.adata_backed[self.obs_idx_no_dupes, self.var_idx_no_dupes] def time_fancy_index_no_dupes_to_memory(self, *_): """Time fancy indexing without duplicates""" self.adata_backed[self.obs_idx_no_dupes, self.var_idx_no_dupes].to_memory() def time_index_with_dupes_obs(self, *_): """Time fancy indexing with duplicate observation indices""" self.adata_backed[self.obs_idx_with_dupes, :] def peakmem_index_with_dupes_obs(self, *_): """Peak memory for fancy indexing with duplicate observation indices""" self.adata_backed[self.obs_idx_with_dupes, :] def time_to_memory_subset(self, *_): """Time converting subset to memory""" subset = self.adata_backed[0 : (self.n_obs // 4), 0 : (self.n_var // 4)] subset.to_memory() def peakmem_to_memory_subset(self, *_): """Peak memory for converting subset to memory""" subset = self.adata_backed[0 : (self.n_obs // 4), 0 : (self.n_var // 4)] subset.to_memory() def teardown(self, *_): """Clean up temporary files""" if hasattr(self, "adata_backed"): self.adata_backed.file.close() scverse-anndata-b796d59/benchmarks/benchmarks/dataset2d.py000066400000000000000000000056151512025555600236370ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import h5py import numpy as np import pandas as pd import zarr import anndata as ad if TYPE_CHECKING: from typing import Literal class Dataset2D: param_names = ("store_type", "chunks", "array_type") params = ( ("zarr", "h5ad"), ((-1,), None), ("cat", "numeric", "string-array", "nullable-string-array"), ) def setup_cache(self): n_obs = 10000 array_types = { "numeric": np.arange(n_obs), "string-array": np.array(["a"] * n_obs), "nullable-string-array": pd.array( ["a", pd.NA] * (n_obs // 2), dtype="string" ), "cat": pd.Categorical(np.array(["a"] * n_obs)), } for k, v in array_types.items(): for store in [ h5py.File(f"data_{k}.h5ad", mode="w"), zarr.open(f"data_{k}.zarr", mode="w", zarr_version=2), ]: df = pd.DataFrame({"a": v}, index=[f"cell{i}" for i in range(n_obs)]) if writing_string_array_on_disk := ( isinstance(v, np.ndarray) and df["a"].dtype == "string" ): df["a"] = df["a"].to_numpy() with ad.settings.override(allow_write_nullable_strings=True): ad.io.write_elem(store, "df", df) if writing_string_array_on_disk: assert store["df"]["a"].attrs["encoding-type"] == "string-array" def setup( self, store_type: Literal["zarr", "h5ad"], chunks: None | tuple[int], array_type: Literal["cat", "numeric", "string-array", "nullable-string-array"], ): self.store = ( h5py.File(f"data_{array_type}.h5ad", mode="r") if store_type == "h5ad" else zarr.open(f"data_{array_type}.zarr") ) self.ds = ad.experimental.read_elem_lazy(self.store["df"], chunks=chunks) self.n_obs = self.ds.shape[0] def time_read_lazy_default(self, *_): ad.experimental.read_elem_lazy(self.store["df"]) def peakmem_read_lazy_default(self, *_): ad.experimental.read_elem_lazy(self.store["df"]) def time_getitem_slice(self, *_): self.ds.iloc[0 : (self.n_obs // 2)].to_memory() def peakmem_getitem_slice(self, *_): self.ds.iloc[0 : (self.n_obs // 2)].to_memory() def time_full_to_memory(self, *_): self.ds.to_memory() def peakmem_full_to_memory(self, *_): self.ds.to_memory() def time_getitem_bool_mask(self, *_): self.ds.iloc[np.random.randint(0, self.n_obs, self.n_obs // 2)].to_memory() def peakmem_getitem_bool_mask(self, *_): self.ds.iloc[np.random.randint(0, self.n_obs, self.n_obs // 2)].to_memory() def time_concat(self, *_): adatas = [ad.AnnData(obs=self.ds)] * 50 ad.concat(adatas, join="outer") scverse-anndata-b796d59/benchmarks/benchmarks/readwrite.py000066400000000000000000000111251512025555600237430ustar00rootroot00000000000000""" This module will benchmark io of AnnData objects Things to test: * Read time, write time * Peak memory during io * File sizes Parameterized by: * What method is being used * What data is being included * Size of data being used Also interesting: * io for views * io for backed objects * Reading dense as sparse, writing sparse as dense """ from __future__ import annotations import sys import tempfile from pathlib import Path from types import MappingProxyType import numpy as np import pooch from memory_profiler import memory_usage # from . import datasets import anndata from .utils import get_actualsize, get_peak_mem, sedate PBMC_3K_URL = "https://falexwolf.de/data/pbmc3k_raw.h5ad" class H5ADInMemorySizeSuite: filepath = "pbmc_in_mem.h5ad" def setup_cache(self): # Need to specify path because the working directory is special for asv pooch.retrieve( url=PBMC_3K_URL, known_hash=None, path=Path.cwd(), fname=self.filepath ) def track_in_memory_size(self, *_): adata = anndata.read_h5ad(self.filepath) adata_size = sys.getsizeof(adata) return adata_size def track_actual_in_memory_size(self, *_): adata = anndata.read_h5ad(self.filepath) adata_size = get_actualsize(adata) return adata_size class H5ADReadSuite: filepath = "pbmc_read.h5ad" def setup_cache(self): # Need to specify path because the working directory is special for asv pooch.retrieve( url=PBMC_3K_URL, known_hash=None, path=Path.cwd(), fname=self.filepath ) def time_read_full(self, *_): anndata.read_h5ad(self.filepath) def peakmem_read_full(self, *_): anndata.read_h5ad(self.filepath) def mem_readfull_object(self, *_): return anndata.read_h5ad(self.filepath) def track_read_full_memratio(self, *_): mem_recording = memory_usage( (sedate(anndata.read_h5ad, 0.005), (self.filepath,)), interval=0.001 ) # adata = anndata.read_h5ad(self.filepath) base_size = mem_recording[-1] - mem_recording[0] print(np.max(mem_recording) - np.min(mem_recording)) print(base_size) return (np.max(mem_recording) - np.min(mem_recording)) / base_size def peakmem_read_backed(self, *_): anndata.read_h5ad(self.filepath, backed="r") # causes benchmarking to break from: https://github.com/pympler/pympler/issues/151 # def mem_read_backed_object(self, *_): # return anndata.read_h5ad(self.filepath, backed="r") class H5ADWriteSuite: _urls = MappingProxyType(dict(pbmc3k=PBMC_3K_URL)) params = _urls.keys() param_names = ("input_data",) def setup(self, input_data: str): mem_recording, adata = memory_usage( ( sedate(anndata.read_h5ad, 0.005), (pooch.retrieve(self._urls[input_data], known_hash=None),), ), retval=True, interval=0.001, ) self.adata = adata self.base_size = mem_recording[-1] - mem_recording[0] self.tmpdir = tempfile.TemporaryDirectory() self.writepth = Path(self.tmpdir.name) / "out.h5ad" def teardown(self, *_): self.tmpdir.cleanup() def time_write_full(self, *_): self.adata.write_h5ad(self.writepth, compression=None) def peakmem_write_full(self, *_): self.adata.write_h5ad(self.writepth) def track_peakmem_write_full(self, *_): return get_peak_mem((sedate(self.adata.write_h5ad), (self.writepth,))) def time_write_compressed(self, *_): self.adata.write_h5ad(self.writepth, compression="gzip") def peakmem_write_compressed(self, *_): self.adata.write_h5ad(self.writepth, compression="gzip") def track_peakmem_write_compressed(self, *_): return get_peak_mem(( sedate(self.adata.write_h5ad), (self.writepth,), {"compression": "gzip"}, )) class H5ADBackedWriteSuite(H5ADWriteSuite): _urls = MappingProxyType(dict(pbmc3k=PBMC_3K_URL)) params = _urls.keys() param_names = ("input_data",) def setup(self, input_data): mem_recording, adata = memory_usage( ( sedate(anndata.read_h5ad, 0.005), (pooch.retrieve(self._urls[input_data], known_hash=None),), {"backed": "r"}, ), retval=True, interval=0.001, ) self.adata = adata self.base_size = mem_recording[-1] - mem_recording[0] self.tmpdir = tempfile.TemporaryDirectory() self.writepth = Path(self.tmpdir.name) / "out.h5ad" scverse-anndata-b796d59/benchmarks/benchmarks/sparse_dataset.py000066400000000000000000000056011512025555600247610ustar00rootroot00000000000000from __future__ import annotations from types import MappingProxyType import numpy as np import zarr from dask.array.core import Array as DaskArray from scipy import sparse from anndata import AnnData, concat from anndata._core.sparse_dataset import sparse_dataset from anndata._io.specs import write_elem from anndata.experimental import read_elem_lazy def make_alternating_mask(n): mask_alternating = np.ones(10_000, dtype=bool) for i in range(0, 10_000, n): mask_alternating[i] = False return mask_alternating class SparseCSRContiguousSlice: _indexers = MappingProxyType({ "0:1000": slice(0, 1000), "0:9000": slice(0, 9000), ":9000:-1": slice(None, 9000, -1), "::-2": slice(None, None, 2), "array": np.array([0, 5000, 9999]), "arange": np.arange(0, 1000), "first": 0, "alternating": make_alternating_mask(10), }) filepath = "data.zarr" params = ( list(_indexers.keys()), [True, False], ) param_names = ( "index", "use_dask", ) def setup_cache(self): X = sparse.random( 10_000, 10_000, density=0.01, format="csr", random_state=np.random.default_rng(42), ) g = zarr.group(self.filepath) write_elem(g, "X", X) def setup(self, index: str, use_dask: bool): # noqa: FBT001 g = zarr.open(self.filepath) self.x = read_elem_lazy(g["X"]) if use_dask else sparse_dataset(g["X"]) self.adata = AnnData(self.x) self.index = self._indexers[index] def time_getitem(self, *_): res = self.x[self.index] if isinstance(res, DaskArray): res.compute() def peakmem_getitem(self, *_): res = self.x[self.index] if isinstance(res, DaskArray): res.compute() def time_getitem_adata(self, *_): res = self.adata[self.index] if isinstance(res, DaskArray): res.compute() def peakmem_getitem_adata(self, *_): res = self.adata[self.index] if isinstance(res, DaskArray): res.compute() class SparseCSRDask: filepath = "data.zarr" def setup_cache(self): X = sparse.random( 10_000, 10_000, density=0.01, format="csr", random_state=np.random.default_rng(42), ) g = zarr.group(self.filepath) write_elem(g, "X", X) def setup(self): self.group = zarr.group(self.filepath) self.adata = AnnData(X=read_elem_lazy(self.group["X"])) def time_concat(self): concat([self.adata for i in range(100)]) def peakmem_concat(self): concat([self.adata for i in range(100)]) def time_read(self): AnnData(X=read_elem_lazy(self.group["X"])) def peakmem_read(self): AnnData(X=read_elem_lazy(self.group["X"])) scverse-anndata-b796d59/benchmarks/benchmarks/utils.py000066400000000000000000000072331512025555600231220ustar00rootroot00000000000000from __future__ import annotations import gc import sys from string import ascii_lowercase from time import sleep import numpy as np import pandas as pd from memory_profiler import memory_usage from scipy import sparse from anndata import AnnData def get_actualsize(input_obj): """Using Python Garbage Collector to calculate the size of all elements attached to an object""" memory_size = 0 ids = set() objects = [input_obj] while objects: new = [] for obj in objects: if id(obj) not in ids: ids.add(id(obj)) memory_size += sys.getsizeof(obj) new.append(obj) objects = gc.get_referents(*new) return memory_size def get_anndata_memsize(adata): recording = memory_usage( (sedate(adata.copy, naplength=0.005), (adata,)), interval=0.001 ) diff = recording[-1] - recording[0] return diff def get_peak_mem(op, interval=0.001): recording = memory_usage(op, interval=interval) return np.max(recording) - np.min(recording) def sedate(func, naplength=0.05): """Make a function sleepy, so we can sample the start and end state.""" def wrapped_function(*args, **kwargs): sleep(naplength) val = func(*args, **kwargs) sleep(naplength) return val return wrapped_function # TODO: Factor out the time it takes to generate these def gen_indexer(adata, dim, index_kind, ratio): dimnames = ("obs", "var") index_kinds = {"slice", "intarray", "boolarray", "strarray"} if index_kind not in index_kinds: msg = f"Argument 'index_kind' must be one of {index_kinds}. Was {index_kind}." raise ValueError(msg) axis = dimnames.index(dim) subset = [slice(None), slice(None)] axis_size = adata.shape[axis] if index_kind == "slice": subset[axis] = slice(0, int(np.round(axis_size * ratio))) elif index_kind == "intarray": subset[axis] = np.random.choice( np.arange(axis_size), int(np.round(axis_size * ratio)), replace=False ) subset[axis].sort() elif index_kind == "boolarray": pos = np.random.choice( np.arange(axis_size), int(np.round(axis_size * ratio)), replace=False ) a = np.zeros(axis_size, dtype=bool) a[pos] = True subset[axis] = a elif index_kind == "strarray": subset[axis] = np.random.choice( getattr(adata, dim).index, int(np.round(axis_size * ratio)), replace=False ) else: raise ValueError() return tuple(subset) def gen_adata(n_obs, n_var, attr_set): if "X-csr" in attr_set: X = sparse.random( n_obs, n_var, density=0.1, format="csr", random_state=np.random.default_rng(42), ) elif "X-dense" in attr_set: X = sparse.random( n_obs, n_var, density=0.1, format="csr", random_state=np.random.default_rng(42), ) X = X.toarray() else: # TODO: There's probably a better way to do this X = sparse.random( n_obs, n_var, density=0, format="csr", random_state=np.random.default_rng(42), ) adata = AnnData(X) if "obs,var" in attr_set: adata.obs = pd.DataFrame( {k: np.random.randint(0, 100, n_obs) for k in ascii_lowercase}, index=[f"cell{i}" for i in range(n_obs)], ) adata.var = pd.DataFrame( {k: np.random.randint(0, 100, n_var) for k in ascii_lowercase}, index=[f"gene{i}" for i in range(n_var)], ) return adata scverse-anndata-b796d59/biome.jsonc000066400000000000000000000010001512025555600172710ustar00rootroot00000000000000{ "$schema": "https://biomejs.dev/schemas/2.1.1/schema.json", "formatter": { "useEditorconfig": true }, "overrides": [ { "includes": ["./.vscode/*.json", "**/*.jsonc", "**/asv.conf.json"], "json": { "formatter": { "trailingCommas": "all", }, "parser": { "allowComments": true, "allowTrailingCommas": true, }, }, }, ], } scverse-anndata-b796d59/ci/000077500000000000000000000000001512025555600155445ustar00rootroot00000000000000scverse-anndata-b796d59/ci/constraints.txt000066400000000000000000000000141512025555600206470ustar00rootroot00000000000000numba>=0.56 scverse-anndata-b796d59/ci/min-constraints.txt000066400000000000000000000000131512025555600214270ustar00rootroot00000000000000pyarrow<21 scverse-anndata-b796d59/ci/scripts/000077500000000000000000000000001512025555600172335ustar00rootroot00000000000000scverse-anndata-b796d59/ci/scripts/min-deps.py000077500000000000000000000125031512025555600213250ustar00rootroot00000000000000#!/usr/bin/env python3 # /// script # dependencies = [ # "tomli; python_version < '3.11'", # "packaging", # ] # /// from __future__ import annotations import argparse import sys import tomllib from collections import deque from contextlib import ExitStack from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING from packaging.requirements import Requirement from packaging.version import Version if TYPE_CHECKING: from collections.abc import Generator, Iterable, Sequence from collections.abc import Set as AbstractSet from typing import Any, Self def min_dep(req: Requirement) -> Requirement: """ Given a requirement, return the minimum version specifier. Example ------- >>> min_dep(Requirement("numpy>=1.0")) >>> min_dep(Requirement("numpy<3.0")) """ req_name = req.name if req.extras: req_name = f"{req_name}[{','.join(req.extras)}]" filter_specs = [ spec for spec in req.specifier if spec.operator in {"==", "~=", ">=", ">"} ] if not filter_specs: # TODO: handle markers return Requirement(f"{req_name}{req.specifier}") min_version = Version("0.0.0.a1") for spec in filter_specs: if spec.operator in {">", ">=", "~="}: min_version = max(min_version, Version(spec.version)) elif spec.operator == "==": min_version = Version(spec.version) return Requirement(f"{req_name}=={min_version}") def extract_min_deps( dependencies: Iterable[Requirement], *, pyproject ) -> Generator[Requirement, None, None]: dependencies = deque(dependencies) # We'll be mutating this project_name = pyproject["project"]["name"] deps = {} while len(dependencies) > 0: req = dependencies.pop() # If we are referring to other optional dependency lists, resolve them if req.name == project_name: assert req.extras, ( f"Project included itself as dependency, without specifying extras: {req}" ) for extra in req.extras: extra_deps = pyproject["project"]["optional-dependencies"][extra] dependencies += map(Requirement, extra_deps) else: if req.name in deps: req.specifier &= deps[req.name].specifier req.extras |= deps[req.name].extras deps[req.name] = min_dep(req) yield from deps.values() class Args(argparse.Namespace): """\ Parse a pyproject.toml file and output a list of minimum dependencies. Output is optimized for `[uv] pip install` (see `-o`/`--output` for details). """ _path: Path output: Path | None _extras: list[str] _all_extras: bool @classmethod def parse(cls, argv: Sequence[str] | None = None) -> Self: return cls.parser().parse_args(argv, cls()) @classmethod def parser(cls) -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="min-deps", description=cls.__doc__, usage="pip install `python min-deps.py pyproject.toml`", allow_abbrev=False, ) parser.add_argument( "_path", metavar="pyproject.toml", type=Path, help="Path to pyproject.toml to parse minimum dependencies from", ) parser.add_argument( "--extras", dest="_extras", metavar="EXTRA", type=str, nargs="*", default=(), help="extras to install", ) parser.add_argument( "--all-extras", dest="_all_extras", action="store_true", help="get all extras", ) parser.add_argument( *("--output", "-o"), metavar="FILE", type=Path, default=None, help=( "output file (default: stdout). " "Without this option, output is space-separated for direct passing to `pip install`. " "With this option, output written to a file newline-separated file usable as `requirements.txt` or `constraints.txt`." ), ) return parser @cached_property def pyproject(self) -> dict[str, Any]: return tomllib.loads(self._path.read_text()) @cached_property def extras(self) -> AbstractSet[str]: if self._extras: if self._all_extras: sys.exit("Cannot specify both --extras and --all-extras") return dict.fromkeys(self._extras).keys() if not self._all_extras: return set() return self.pyproject["project"]["optional-dependencies"].keys() def main(argv: Sequence[str] | None = None) -> None: args = Args.parse(argv) project_name = args.pyproject["project"]["name"] deps = [ *map(Requirement, args.pyproject["project"]["dependencies"]), *(Requirement(f"{project_name}[{extra}]") for extra in args.extras), ] min_deps = extract_min_deps(deps, pyproject=args.pyproject) sep = "\n" if args.output else " " with ExitStack() as stack: f = stack.enter_context(args.output.open("w")) if args.output else sys.stdout print(sep.join(map(str, min_deps)), file=f) if __name__ == "__main__": main() scverse-anndata-b796d59/ci/scripts/towncrier_automation.py000077500000000000000000000101431512025555600240630ustar00rootroot00000000000000#!/usr/bin/env python3 # /// script # dependencies = [ "towncrier", "packaging" ] # /// from __future__ import annotations import argparse import re import subprocess from functools import cache from typing import TYPE_CHECKING from packaging.version import Version if TYPE_CHECKING: from collections.abc import Sequence class BumpVersion(Version): def __init__(self, version: str) -> None: super().__init__(version) if len(self.release) != 3: msg = f"{version} must contain major, minor, and patch version." raise argparse.ArgumentTypeError(msg) base_branch = get_base_branch() patch_branch_pattern = re.compile(r"\d+\.\d+\.x") if self.micro != 0 and not patch_branch_pattern.fullmatch(base_branch): msg = ( f"{version} is a patch release, but " f"you are trying to release from a non-patch release branch: {base_branch}." ) raise argparse.ArgumentTypeError(msg) if self.micro == 0 and base_branch != "main": msg = ( f"{version} is a minor or major release, " f"but you are trying to release not from main: {base_branch}." ) raise argparse.ArgumentTypeError(msg) class Args(argparse.Namespace): version: BumpVersion dry_run: bool def parse_args(argv: Sequence[str] | None = None) -> Args: parser = argparse.ArgumentParser( prog="towncrier-automation", description=( "This script runs towncrier for a given version, " "creates a branch off of the current one, " "and then creates a PR into the original branch with the changes. " "The PR will be backported to main if the current branch is not main." ), ) parser.add_argument( "version", type=BumpVersion, help=( "The new version for the release must have at least three parts, like `major.minor.patch` and no `major.minor`. " "It can have a suffix like `major.minor.patch.dev0` or `major.minor.0rc1`." ), ) parser.add_argument( "--dry-run", help="Whether or not to dry-run the actual creation of the pull request", action="store_true", ) args = parser.parse_args(argv, Args()) return args def main(argv: Sequence[str] | None = None) -> None: args = parse_args(argv) # Run towncrier subprocess.run( ["towncrier", "build", f"--version={args.version}", "--yes"], check=True ) # Check if we are on the main branch to know if we need to backport base_branch = get_base_branch() pr_description = "" if base_branch == "main" else "@meeseeksdev backport to main" branch_name = f"release_notes_{args.version}" # Create a new branch + commit subprocess.run(["git", "switch", "-c", branch_name], check=True) subprocess.run(["git", "add", "docs/release-notes"], check=True) pr_title = f"(chore): generate {args.version} release notes" subprocess.run(["git", "commit", "-m", pr_title], check=True) # push if not args.dry_run: subprocess.run( ["git", "push", "--set-upstream", "origin", branch_name], check=True ) else: print("Dry run, not pushing") # Create a PR subprocess.run( [ "gh", "pr", "create", f"--base={base_branch}", f"--title={pr_title}", f"--body={pr_description}", "--label=skip-gpu-ci", *(["--label=no milestone"] if base_branch == "main" else []), *(["--dry-run"] if args.dry_run else []), ], check=True, ) # Enable auto-merge if not args.dry_run: subprocess.run( ["gh", "pr", "merge", branch_name, "--auto", "--squash"], check=True ) else: print("Dry run, not merging") @cache def get_base_branch(): return subprocess.run( ["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, check=True, ).stdout.strip() if __name__ == "__main__": main() scverse-anndata-b796d59/docs/000077500000000000000000000000001512025555600161015ustar00rootroot00000000000000scverse-anndata-b796d59/docs/Makefile000066400000000000000000000012641512025555600175440ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = python3 -msphinx SPHINXPROJ = Scanpy SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile clean: rm -r "$(BUILDDIR)" rm -r "generated" find . -name anndata.*.rst -delete # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) scverse-anndata-b796d59/docs/_key_contributors.rst000066400000000000000000000006041512025555600223770ustar00rootroot00000000000000.. sidebar:: Key Contributors * Isaac Virshup: anndata >= 0.7, diverse contributions * Sergei Rybakov: diverse contributions * Alex Wolf: initial conception/development * Philipp Angerer: initial conception/development, software quality * Ilan Gold: improved cloud support, software quality .. _contributions graph: https://github.com/scverse/anndata/graphs/contributors scverse-anndata-b796d59/docs/_static/000077500000000000000000000000001512025555600175275ustar00rootroot00000000000000scverse-anndata-b796d59/docs/_static/img/000077500000000000000000000000001512025555600203035ustar00rootroot00000000000000scverse-anndata-b796d59/docs/_static/img/anndata_schema.svg000066400000000000000000002121141512025555600237530ustar00rootroot00000000000000 scverse-anndata-b796d59/docs/_templates/000077500000000000000000000000001512025555600202365ustar00rootroot00000000000000scverse-anndata-b796d59/docs/_templates/autosummary/000077500000000000000000000000001512025555600226245ustar00rootroot00000000000000scverse-anndata-b796d59/docs/_templates/autosummary/class.rst000066400000000000000000000012131512025555600244600ustar00rootroot00000000000000{{ fullname | escape | underline}} .. currentmodule:: {{ module }} .. add toctree option to make autodoc generate the pages .. autoclass:: {{ objname }} {% block attributes %} {% if attributes %} .. rubric:: Attributes .. autosummary:: :toctree: . {% for item in attributes %} ~{{ name }}.{{ item }} {%- endfor %} {% endif %} {% endblock %} {% block methods %} {% if methods %} .. rubric:: Methods .. autosummary:: :toctree: . {% for item in methods %} {%- if item != '__init__' %} ~{{ name }}.{{ item }} {%- endif -%} {%- endfor %} {% endif %} {% endblock %} scverse-anndata-b796d59/docs/api.md000066400000000000000000000076501512025555600172040ustar00rootroot00000000000000# API ```{eval-rst} .. module:: anndata ``` The central class: ```{eval-rst} .. autosummary:: :toctree: generated/ AnnData ``` (combining-api)= ## Combining Combining {class}`AnnData` objects. See also the section on concatenation. ```{eval-rst} .. autosummary:: :toctree: generated/ concat ``` (reading-api)= ## Reading Reading anndata’s native formats `.h5ad` and `zarr`. ```{eval-rst} .. autosummary:: :toctree: generated/ io.read_h5ad io.read_zarr ``` Reading individual portions ({attr}`~AnnData.obs`, {attr}`~AnnData.varm` etc.) of the {class}`AnnData` object. ```{eval-rst} .. autosummary:: :toctree: generated/ io.read_elem io.sparse_dataset ``` Reading file formats that cannot represent all aspects of {class}`AnnData` objects. ```{tip} You might have more success by assembling the {class}`AnnData` object yourself from the individual parts. ``` ```{eval-rst} .. autosummary:: :toctree: generated/ io.read_csv io.read_excel io.read_hdf io.read_loom io.read_mtx io.read_text io.read_umi_tools ``` (writing-api)= ## Writing Writing a complete {class}`AnnData` object to disk in anndata’s native formats `.h5ad` and `zarr`. (These functions are also exported as {func}`io.write_h5ad` and {func}`io.write_zarr`.) ```{eval-rst} .. autosummary:: :toctree: generated/ AnnData.write_h5ad AnnData.write_zarr .. .. autosummary:: :toctree: generated/ io.write_h5ad io.write_zarr .. toctree:: :hidden: generated/anndata.io.write_h5ad generated/anndata.io.write_zarr ``` Writing individual portions ({attr}`~AnnData.obs`, {attr}`~AnnData.varm` etc.) of the {class}`AnnData` object. ```{eval-rst} .. autosummary:: :toctree: generated/ io.write_elem ``` Writing formats that cannot represent all aspects of {class}`AnnData` objects. ```{eval-rst} .. autosummary:: :toctree: generated/ AnnData.write_csvs AnnData.write_loom ``` (experimental-api)= ## Experimental API ```{warning} APIs in the experimental module are currently in development and subject to change at any time. ``` Two classes for working with batched access to collections of many {class}`AnnData` objects or `.h5ad` files. In particular, for pytorch-based models. ```{eval-rst} .. autosummary:: :toctree: generated/ experimental.AnnCollection experimental.AnnLoader ``` Out of core concatenation ```{eval-rst} .. autosummary:: :toctree: generated/ experimental.concat_on_disk ``` Low level methods for reading and writing elements of an {class}`AnnData` object to a store: ```{eval-rst} .. autosummary:: :toctree: generated/ experimental.read_elem_lazy experimental.read_lazy ``` Utilities for customizing the IO process: ```{eval-rst} .. autosummary:: :toctree: generated/ experimental.read_dispatched experimental.write_dispatched ``` Types used by the former: ```{eval-rst} .. autosummary:: :toctree: generated/ experimental.IOSpec experimental.Read experimental.Write experimental.ReadCallback experimental.WriteCallback experimental.StorageType experimental.backed.MaskedArray experimental.backed.CategoricalArray experimental.backed.Dataset2D experimental.Dataset2DIlocIndexer ``` (extensions-api)= ## Extensions ```{eval-rst} .. autosummary:: :toctree: generated/ register_anndata_namespace ``` Types used by the former: ```{eval-rst} .. autosummary:: :toctree: generated/ types.ExtensionNamespace ``` (errors-api)= ## Errors and warnings ```{eval-rst} .. autosummary:: :toctree: generated/ ImplicitModificationWarning ``` (settings-api)= ## Settings ```{eval-rst} .. autosummary:: :toctree: generated/ settings settings.override ``` (types-api)= ## Custom Types/Classes for Readable/Writeable Elements ```{eval-rst} .. autosummary:: :toctree: generated/ abc.CSRDataset abc.CSCDataset typing.Index typing.AxisStorable typing.RWAble ``` scverse-anndata-b796d59/docs/benchmark-read-write.ipynb000066400000000000000000000070571512025555600231500ustar00rootroot00000000000000{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Simple benchmarks" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Here, we perform simple benchmarks to demonstrate basic performance." ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "from __future__ import annotations\n", "\n", "import scanpy as sc\n", "\n", "import anndata as ad" ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "adata = sc.datasets.pbmc3k()" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "AnnData object with n_obs × n_vars = 2700 × 32738\n", " var: 'gene_ids'" ] }, "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ "adata" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Reading & writing" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Let us start by writing & reading anndata's native HDF5 file format: `.h5ad`:" ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "CPU times: user 93.9 ms, sys: 17.4 ms, total: 111 ms\n", "Wall time: 118 ms\n" ] } ], "source": [ "%%time\n", "adata.write(\"test.h5ad\")" ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "CPU times: user 51.2 ms, sys: 13.3 ms, total: 64.5 ms\n", "Wall time: 64.1 ms\n" ] } ], "source": [ "%%time\n", "adata = ad.read_h5ad(\"test.h5ad\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We see that reading and writing is much faster than for loom files. The efficiency gain here is due to explicit storage of the sparse matrix structure." ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "CPU times: user 2.82 s, sys: 457 ms, total: 3.27 s\n", "Wall time: 3.31 s\n" ] } ], "source": [ "%%time\n", "adata.write_loom(\"test.loom\")" ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "CPU times: user 1.05 s, sys: 221 ms, total: 1.28 s\n", "Wall time: 1.28 s\n" ] }, { "name": "stderr", "output_type": "stream", "text": [ "/Users/alexwolf/repos/anndata/anndata/_core/anndata.py:120: ImplicitModificationWarning: Transforming to str index.\n", " warnings.warn(\"Transforming to str index.\", ImplicitModificationWarning)\n" ] } ], "source": [ "%%time\n", "adata = ad.io.read_loom(\"test.loom\")" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "version": "3.7.6" } }, "nbformat": 4, "nbformat_minor": 4 } scverse-anndata-b796d59/docs/benchmarks.md000066400000000000000000000003751512025555600205450ustar00rootroot00000000000000# Benchmarks Computational operations in anndata are consistently benchmarked [here](https://github.com/ivirshup/anndata-benchmarks). Below follows a simple benchmark showing read-write efficiency. ```{toctree} :maxdepth: 1 benchmark-read-write ``` scverse-anndata-b796d59/docs/concatenation.rst000066400000000000000000000310771512025555600214700ustar00rootroot00000000000000Concatenation ============= With :func:`~anndata.concat`, :class:`~anndata.AnnData` objects can be combined via a composition of two operations: concatenation and merging. * Concatenation is when we keep all sub elements of each object, and stack these elements in an ordered way. * Merging is combining a set of collections into one resulting collection which contains elements from the objects. .. note:: This function borrows from similar functions in pandas_ and xarray_. Argument which are used to control concatenation are modeled after :func:`pandas.concat` while strategies for merging are inspired by :func:`xarray.merge`'s `compat` argument. .. _pandas: https://pandas.pydata.org .. _xarray: http://xarray.pydata.org Concatenation ------------- Let's start off with an example: >>> import scanpy as sc, anndata as ad, numpy as np, pandas as pd >>> from scipy import sparse >>> from anndata import AnnData >>> pbmc = sc.datasets.pbmc68k_reduced() >>> pbmc # doctest: +ELLIPSIS AnnData object with n_obs × n_vars = 700 × 765 obs: 'bulk_labels', 'n_genes', 'percent_mito', 'n_counts', 'S_score', 'G2M_score', 'phase', 'louvain' var: 'n_counts', 'means', 'dispersions', 'dispersions_norm', 'highly_variable' uns: 'bulk_labels_colors', 'louvain', 'louvain_colors', 'neighbors', 'pca', 'rank_genes_groups' obsm: 'X_pca', 'X_umap' varm: 'PCs' obsp: ... If we split this object up by clusters of observations, then stack those subsets we'll obtain the same values – just ordered differently. >>> groups = pbmc.obs.groupby("louvain", observed=True).indices >>> pbmc_concat = ad.concat([pbmc[inds] for inds in groups.values()], merge="same") >>> assert np.array_equal(pbmc.X, pbmc_concat[pbmc.obs_names].X) >>> pbmc_concat AnnData object with n_obs × n_vars = 700 × 765 obs: 'bulk_labels', 'n_genes', 'percent_mito', 'n_counts', 'S_score', 'G2M_score', 'phase', 'louvain' var: 'n_counts', 'means', 'dispersions', 'dispersions_norm', 'highly_variable' obsm: 'X_pca', 'X_umap' varm: 'PCs' Note that we concatenated along the observations by default, and that most elements aligned to the observations were concatenated as well. A notable exception is :attr:`~anndata.AnnData.obsp`, which can be re-enabled with the `pairwise` keyword argument. This is because it's not obvious that combining graphs or distance matrices padded with 0s is particularly useful, and may be unintuitive. Inner and outer joins ~~~~~~~~~~~~~~~~~~~~~ When the variables present in the objects to be concatenated aren't exactly the same, you can choose to take either the intersection or union of these variables. This is otherwise called taking the `"inner"` (intersection) or `"outer"` (union) join. For example, given two anndata objects with differing variables: >>> a = AnnData(sparse.eye(3, format="csr"), var=pd.DataFrame(index=list("abc"))) >>> b = AnnData(sparse.eye(2, format="csr"), var=pd.DataFrame(index=list("ba"))) >>> ad.concat([a, b], join="inner").X.toarray() array([[1., 0.], [0., 1.], [0., 0.], [0., 1.], [1., 0.]]) >>> ad.concat([a, b], join="outer").X.toarray() array([[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0., 1., 0.], [1., 0., 0.]]) The join argument is used for any element which has both (1) an axis being concatenated and (2) an axis not being concatenated. When concatenating along the `obs` dimension, this means elements of `.X`, `obs`, `.layers`, and `.obsm` will be affected by the choice of `join`. To demonstrate this, let's say we're trying to combine a droplet based experiment with a spatial one. When building a joint anndata object, we would still like to store the coordinates for the spatial samples. >>> coords = np.hstack([np.repeat(np.arange(10), 10), np.tile(np.arange(10), 10)]).T >>> spatial = AnnData( ... sparse.random(5000, 10000, format="csr"), ... obsm={"coords": np.random.randn(5000, 2)} ... ) >>> droplet = AnnData(sparse.random(5000, 10000, format="csr")) >>> combined = ad.concat([spatial, droplet], join="outer") >>> sc.pl.embedding(combined, "coords") # doctest: +SKIP .. TODO: Get the above plot to show up Annotating data source (`label`, `keys`, and `index_unique`) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Often, you'd like to be able to tell which values came from which object. This can be accomplished with the `label`, `keys`, and `index_unique` keyword arguments. For an example, we'll show how you can keep track of the original dataset by passing a `Mapping` of dataset names to `AnnData` objects to `concat`: >>> adatas = { ... "a": ad.AnnData( ... sparse.random(3, 50, format="csr", density=0.1), ... obs=pd.DataFrame(index=[f"a-{i}" for i in range(3)]) ... ), ... "b": ad.AnnData( ... sparse.random(5, 50, format="csr", density=0.1), ... obs=pd.DataFrame(index=[f"b-{i}" for i in range(5)]) ... ), ... } >>> ad.concat(adatas, label="dataset").obs dataset a-0 a a-1 a a-2 a b-0 b b-1 b b-2 b b-3 b b-4 b Here, a categorical column (with the name specified by `label`) was added to the result. As an alternative to passing a `Mapping`, you can also specify dataset names with the `keys` argument. In some cases, your objects may share names along the axes being concatenated. These values can be made unique by appending the relevant key using the `index_unique` argument: .. TODO: skipping example since doctest does not capture stderr, but it's relevant to show the unique message >>> adatas = { ... "a": ad.AnnData( ... sparse.random(3, 10, format="csr", density=0.1), ... obs=pd.DataFrame(index=[f"cell-{i}" for i in range(3)]) ... ), ... "b": ad.AnnData( ... sparse.random(5, 10, format="csr", density=0.1), ... obs=pd.DataFrame(index=[f"cell-{i}" for i in range(5)]) ... ), ... } >>> ad.concat(adatas).obs # doctest: +SKIP Observation names are not unique. To make them unique, call `.obs_names_make_unique`. Empty DataFrame Columns: [] Index: [cell-0, cell-1, cell-2, cell-0, cell-1, cell-2, cell-3, cell-4] >>> ad.concat(adatas, index_unique="_").obs Empty DataFrame Columns: [] Index: [cell-0_a, cell-1_a, cell-2_a, cell-0_b, cell-1_b, cell-2_b, cell-3_b, cell-4_b] Merging ------- Combining elements not aligned to the axis of concatenation is controlled through the `merge` arguments. We provide a few strategies for merging elements aligned to the alternative axes: * `None`: No elements aligned to alternative axes are present in the result object. * `"same"`: Elements that are the same in each of the objects. * `"unique"`: Elements for which there is only one possible value. * `"first"`: The first element seen in each from each position. * `"only"`: Elements that show up in only one of the objects. We'll show how this works with elements aligned to the alternative axis, and then how merging works with `.uns`. First, our example case: >>> import scanpy as sc >>> blobs = sc.datasets.blobs(n_variables=30, n_centers=5) >>> sc.pp.pca(blobs) >>> blobs AnnData object with n_obs × n_vars = 640 × 30 obs: 'blobs' uns: 'pca' obsm: 'X_pca' varm: 'PCs' Now we will split this object by the categorical `"blobs"` and recombine it to illustrate different merge strategies. >>> adatas = [] >>> for group, idx in blobs.obs.groupby("blobs").indices.items(): ... sub_adata = blobs[idx].copy() ... sub_adata.obsm["qc"], sub_adata.varm[f"{group}_qc"] = sc.pp.calculate_qc_metrics( ... sub_adata, percent_top=(), inplace=False, log1p=False ... ) ... adatas.append(sub_adata) >>> adatas[0] AnnData object with n_obs × n_vars = 128 × 30 obs: 'blobs' uns: 'pca' obsm: 'X_pca', 'qc' varm: 'PCs', '0_qc' `adatas` is now a list of datasets with disjoint sets of observations and a common set of variables. Each object has had QC metrics computed, with observation-wise metrics stored under `"qc"` in `.obsm`, and variable-wise metrics stored with a unique key for each subset. Taking a look at how this affects concatenation: >>> ad.concat(adatas) AnnData object with n_obs × n_vars = 640 × 30 obs: 'blobs' obsm: 'X_pca', 'qc' >>> ad.concat(adatas, merge="same") AnnData object with n_obs × n_vars = 640 × 30 obs: 'blobs' obsm: 'X_pca', 'qc' varm: 'PCs' >>> ad.concat(adatas, merge="unique") AnnData object with n_obs × n_vars = 640 × 30 obs: 'blobs' obsm: 'X_pca', 'qc' varm: 'PCs', '0_qc', '1_qc', '2_qc', '3_qc', '4_qc' Note that comparisons are made after indices are aligned. That is, if the objects only share a subset of indices on the alternative axis, it's only required that values for those indices match when using a strategy like `"same"`. >>> a = AnnData( ... sparse.eye(3, format="csr"), ... var=pd.DataFrame({"nums": [1, 2, 3]}, index=list("abc")) ... ) >>> b = AnnData( ... sparse.eye(2, format="csr"), ... var=pd.DataFrame({"nums": [2, 1]}, index=list("ba")) ... ) >>> ad.concat([a, b], merge="same").var nums a 1 b 2 Merging `.uns` ~~~~~~~~~~~~~~ We use the same set of strategies for merging `uns` as we do for entries aligned to an axis, but these strategies are applied recursively. This is a little abstract, so we'll look at some examples of this. Here's our setup: >>> from anndata import AnnData >>> import numpy as np >>> a = AnnData(np.zeros((10, 10)), uns={"a": 1, "b": 2, "c": {"c.a": 3, "c.b": 4}}) >>> b = AnnData(np.zeros((10, 10)), uns={"a": 1, "b": 3, "c": {"c.b": 4}}) >>> c = AnnData(np.zeros((10, 10)), uns={"a": 1, "b": 4, "c": {"c.a": 3, "c.b": 4, "c.c": 5}}) For quick reference, these are the results from each of the merge strategies. These are discussed in more depth below: =========== ======================================================= `uns_merge` Result =========== ======================================================= `None` `{}` `"same"` `{"a": 1, "c": {"c.b": 4}}` `"unique"` `{"a": 1, "c": {"c.a": 3, "c.b": 4, "c.c": 5}}` `"only"` `{"c": {"c.c": 5}}` `"first"` `{"a": 1, "b": 2, "c": {"c.a": 3, "c.b": 4, "c.c": 5}}` =========== ======================================================= The default returns a fairly obvious result: >>> ad.concat([a, b, c]).uns == {} True But let's take a look at the others in a bit more depth. Here, we'll be wrapping the output data in a `dict` for simplicity of the return value. >>> dict(ad.concat([a, b, c], uns_merge="same").uns) {'a': 1, 'c': {'c.b': 4}} Here only the values for `uns["a"]` and `uns["c"]["c.b"]` were exactly the same, so only they were kept. `uns["b"]` has a number of values and neither `uns["c"]["c.a"]` or `uns["c"]["c.b"]` appears in each `uns`. A key feature to note is that comparisons are aware of the nested structure of `uns` and will be applied at any depth. This is why `uns["c"]["c.b"]` was kept. Merging `uns` in this way can be useful when there is some shared data between the objects being concatenated. For example, if each was put through the same pipeline with the same parameters, those parameters used would still be present in the resulting object. Now let's look at the behaviour of `unique`: >>> dict(ad.concat([a, b, c], uns_merge="unique").uns) {'a': 1, 'c': {'c.a': 3, 'c.b': 4, 'c.c': 5}} The results here are a super-set of those from `"same"`. Note that there was only one possible value at each position in the resulting mapping. That is, there were not alternative values present for `uns["c"]["c.c"]` even though it appeared only once. This can be useful when the object's were both run through the same pipeline but contain specific metadata per object. An example of this would be a spatial dataset, where the images are stored in `uns`. >>> dict(ad.concat([a, b, c], uns_merge="only").uns) {'c': {'c.c': 5}} `uns["c"]["c.c"]` is the only value that is kept, since it is the only one which was specified in only one `uns`. >>> dict(ad.concat([a, b, c], uns_merge="first").uns) {'a': 1, 'b': 2, 'c': {'c.a': 3, 'c.b': 4, 'c.c': 5}} In this case, the result has the union of the keys from all the starting dictionaries. The value is taken from the first object to have a value at this key. scverse-anndata-b796d59/docs/conf.py000066400000000000000000000176711512025555600174140ustar00rootroot00000000000000from __future__ import annotations import sys from datetime import datetime from functools import partial from importlib import metadata from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING from docutils import nodes if TYPE_CHECKING: from sphinx.application import Sphinx HERE = Path(__file__).parent _extension_dir = HERE / "extensions" sys.path[:0] = [str(_extension_dir)] # -- General configuration ------------------------------------------------ # General information project = "anndata" author = f"{project} developers" copyright = f"{datetime.now():%Y}, scverse" release = version = metadata.version("anndata") # default settings templates_path = ["_templates"] html_static_path = ["_static"] source_suffix = {".rst": "restructuredtext", ".md": "myst-nb"} master_doc = "index" default_role = "literal" exclude_patterns = [ "_build", "Thumbs.db", ".DS_Store", "**.ipynb_checkpoints", "tutorials/notebooks/*.rst", # exclude all 0.x.y.md files, but not index.md "release-notes/[!i]*.md", "news.md", # is `include`d into index.md ] pygments_style = "sphinx" extensions = [ "myst_nb", "sphinx_copybutton", "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.doctest", "sphinx.ext.coverage", "sphinx.ext.mathjax", "sphinx.ext.napoleon", "sphinx.ext.autosummary", "sphinx_autodoc_typehints", # needs to be after napoleon "sphinx_issues", "sphinx_design", "sphinxext.opengraph", "scanpydoc", # needs to be before linkcode "sphinx.ext.linkcode", "IPython.sphinxext.ipython_console_highlighting", "sphinx_toolbox.more_autodoc.autoprotocol", *(p.stem for p in _extension_dir.glob("*.py")), ] myst_enable_extensions = [ "html_image", # So README.md can be used on github and sphinx docs "colon_fence", "dollarmath", ] myst_heading_anchors = 3 nb_execution_mode = "off" # Generate the API documentation when building autosummary_generate = True autodoc_member_order = "bysource" autodoc_mock_imports = ["torch"] # autodoc_default_flags = ['members'] issues_github_path = "scverse/anndata" rtd_links_prefix = PurePosixPath("src") napoleon_google_docstring = False napoleon_numpy_docstring = True napoleon_include_init_with_doc = False napoleon_use_rtype = True # having a separate entry generally helps readability napoleon_use_param = True napoleon_custom_sections = [("Params", "Parameters")] typehints_defaults = "braces" todo_include_todos = False nitpicky = True # Report broken links nitpick_ignore = [ # APIs without an intersphinx entry # These APIs aren’t actually documented ("py:class", "anndata._core.raw.Raw"), ("py:class", "pandas._libs.missing.NAType"), # TODO: remove zappy support; the zappy repo is archived ("py:class", "anndata.compat.ZappyArray"), ] def setup(app: Sphinx): app.add_generic_role("small", partial(nodes.inline, classes=["small"])) app.add_generic_role("smaller", partial(nodes.inline, classes=["smaller"])) # TODO: move to scanpydoc if TYPE_CHECKING: from docutils.nodes import TextElement, reference from sphinx.addnodes import pending_xref from sphinx.environment import BuildEnvironment def res( app: Sphinx, env: BuildEnvironment, node: pending_xref, contnode: TextElement ) -> reference | None: return env.domains["py"].resolve_xref( env, node["refdoc"], app.builder, node["reftype"], node["reftarget"], node, contnode, ) app.connect("missing-reference", res, priority=502) intersphinx_mapping = dict( awkward=("https://awkward-array.org/doc/stable", None), cupy=("https://docs.cupy.dev/en/stable", None), dask=("https://docs.dask.org/en/stable", None), fsspec=("https://filesystem-spec.readthedocs.io/en/stable/", None), h5py=("https://docs.h5py.org/en/latest", None), hdf5plugin=("https://hdf5plugin.readthedocs.io/en/latest", None), kvikio=("https://docs.rapids.ai/api/kvikio/stable/", None), loompy=("https://linnarssonlab.org/loompy", None), numpy=("https://numpy.org/doc/stable", None), obstore=("https://developmentseed.org/obstore/latest/", None), pandas=("https://pandas.pydata.org/pandas-docs/stable", None), # TODO: switch to `/3` once docs are built with Python 3.14 # https://github.com/readthedocs/readthedocs.org/issues/12523 python=("https://docs.python.org/3.13", None), scipy=("https://docs.scipy.org/doc/scipy", None), sklearn=("https://scikit-learn.org/stable", None), xarray=("https://docs.xarray.dev/en/stable", None), zarr=("https://zarr.readthedocs.io/en/stable/", None), zarrs=("https://zarrs-python.readthedocs.io/en/stable/", None), ) qualname_overrides = { "h5py._hl.group.Group": "h5py.Group", "h5py._hl.files.File": "h5py.File", "h5py._hl.dataset.Dataset": "h5py.Dataset", "anndata._core.anndata.AnnData": "anndata.AnnData", **{ f"anndata._core.aligned_mapping.{cls}{kind}": "collections.abc.Mapping" for cls in ["Layers", "AxisArrays", "PairwiseArrays"] for kind in ["", "View"] }, "anndata._types.ReadCallback": "anndata.experimental.ReadCallback", "anndata._types.WriteCallback": "anndata.experimental.WriteCallback", "anndata._types.Read": "anndata.experimental.Read", "anndata._types.Write": "anndata.experimental.Write", "anndata._types.Dataset2DIlocIndexer": "anndata.experimental.Dataset2DIlocIndexer", "zarr.core.array.Array": "zarr.Array", "zarr.core.group.Group": "zarr.Group", # Buffer is not yet exported, so the buffer class registry is the closest thing "zarr.core.buffer.core.Buffer": "zarr.registry.Registry", "zarr.storage._common.StorePath": "zarr.storage.StorePath", "anndata.compat.DaskArray": "dask.array.Array", "anndata.compat.CupyArray": "cupy.ndarray", "anndata.compat.CupySparseMatrix": "cupyx.scipy.sparse.spmatrix", "anndata.compat.XDataArray": "xarray.DataArray", "anndata.compat.XDataset": "xarray.Dataset", "awkward.highlevel.Array": "ak.Array", "numpy.int64": ("py:attr", "numpy.int64"), "numpy.dtypes.StringDType": ("py:attr", "numpy.dtypes.StringDType"), "pandas.DataFrame.iloc": ("py:attr", "pandas.DataFrame.iloc"), "pandas.DataFrame.loc": ("py:attr", "pandas.DataFrame.loc"), # should be fixed soon: https://github.com/tox-dev/sphinx-autodoc-typehints/pull/516 "types.EllipsisType": ("py:data", "types.EllipsisType"), "pathlib._local.Path": "pathlib.Path", } autodoc_type_aliases = dict( NDArray=":data:`~numpy.typing.NDArray`", AxisStorable=":data:`~anndata.typing.AxisStorable`", **{ f"{v}variantRWAble": ":data:`~anndata.typing.RWAble`" for v in ["In", "Co", "Contra"] }, ) # -- Social cards --------------------------------------------------------- ogp_site_url = "https://anndata.readthedocs.io/" ogp_image = "https://anndata.readthedocs.io/en/latest/_static/img/anndata_schema.svg" # -- Options for HTML output ---------------------------------------------- # The theme is sphinx-book-theme, with patches for readthedocs-sphinx-search html_theme = "scanpydoc" html_theme_options = dict( use_repository_button=True, repository_url="https://github.com/scverse/anndata", repository_branch="main", navigation_with_keys=False, # https://github.com/pydata/pydata-sphinx-theme/issues/1492 ) html_logo = "_static/img/anndata_schema.svg" issues_github_path = "scverse/anndata" html_show_sphinx = False # -- Options for other output formats ------------------------------------------ htmlhelp_basename = f"{project}doc" doc_title = f"{project} Documentation" latex_documents = [(master_doc, f"{project}.tex", doc_title, author, "manual")] man_pages = [(master_doc, project, doc_title, [author], 1)] texinfo_documents = [ ( master_doc, project, doc_title, author, project, "One line description of project.", "Miscellaneous", ) ] scverse-anndata-b796d59/docs/contributing.md000066400000000000000000000006761512025555600211430ustar00rootroot00000000000000# Contributing AnnData follows the development practices outlined in the [Scanpy contribution guide](https://scanpy.readthedocs.io/en/latest/dev/release.html). ```{eval-rst} .. include:: _key_contributors.rst ``` ## CI ### GPU CI To test GPU specific code we have a paid self-hosted runner to run the gpu specific tests on. This CI runs by default on the main branch, but for PRs requires the `run-gpu-ci` label to prevent unnecessary runs. scverse-anndata-b796d59/docs/extensions/000077500000000000000000000000001512025555600203005ustar00rootroot00000000000000scverse-anndata-b796d59/docs/extensions/autosummary_skip_inherited.py000066400000000000000000000026661512025555600263330ustar00rootroot00000000000000"""Extension to skip inherited methods and properties in autosummary.""" from __future__ import annotations from traceback import walk_stack from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Literal from sphinx.application import Sphinx from sphinx.ext.autodoc import Options def skip_inherited( # noqa: PLR0917 app: Sphinx, what: Literal[ "module", "class", "exception", "function", "method", "attribute", "property" ], name: str, obj: object, skip: bool, # noqa: FBT001 options: Options | dict[str, object], ) -> bool | None: """Skip inherited members.""" # Skip `getdoc` property if what == "method" and name == "getdoc": return True # find parent class for frame, _ in walk_stack(None): if frame.f_code.co_name == "_get_members" and frame.f_code.co_filename.endswith( "/generate.py" ): parent = frame.f_locals["obj"] if not isinstance(parent, type): return None break else: return None # return if it’s a member of the parent class typ = parent while typ is not type: if name in typ.__dict__: return None typ = type(typ) # skip since we know it’s not a member of the parent class return True def setup(app: Sphinx) -> None: """App setup hook.""" app.connect("autodoc-skip-member", skip_inherited) scverse-anndata-b796d59/docs/extensions/no_skip_abc_members.py000066400000000000000000000012631512025555600246350ustar00rootroot00000000000000"""Sphinx extension to not skip abstract methods.""" from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Literal from sphinx.application import Sphinx from sphinx.ext.autodoc import Options def autodoc_skip_member( # noqa: PLR0917 app: Sphinx, what: Literal["module", "class", "exception", "function", "method", "attribute"], name: str, obj: object, skip: bool, # noqa: FBT001 options: Options, ): if what == "method" and getattr(obj, "__isabstractmethod__", False): return False return None def setup(app: Sphinx): app.connect("autodoc-skip-member", autodoc_skip_member) scverse-anndata-b796d59/docs/extensions/patch_myst_cite.py000066400000000000000000000015071512025555600240340ustar00rootroot00000000000000"""Override MyST’s cite role with one that works.""" from __future__ import annotations from types import MappingProxyType from typing import TYPE_CHECKING from docutils import nodes, utils if TYPE_CHECKING: from collections.abc import Mapping, Sequence from typing import Any from docutils.parsers.rst.states import Inliner from sphinx.application import Sphinx def cite_role( # noqa: PLR0917 name: str, rawsource: str, text: str, lineno: int, inliner: Inliner, options: Mapping[str, Any] = MappingProxyType({}), content: Sequence[str] = (), ) -> tuple[list[nodes.Node], list[nodes.system_message]]: key = utils.unescape(text) node = nodes.citation_reference(f"[{key}]_", key) return [node], [] def setup(app: Sphinx): app.add_role("cite", cite_role, override=True) scverse-anndata-b796d59/docs/fileformat-prose.md000066400000000000000000000571121512025555600217070ustar00rootroot00000000000000# On-disk format ```{note} These docs are written for anndata 0.8+. Files written before this version may differ in some conventions, but will still be read by newer versions of the library. ``` AnnData objects are saved on disk to hierarchical array stores like [HDF5] (via {doc}`H5py `) and {mod}`zarr`. This allows us to have very similar structures in disk and on memory. As an example we’ll look into a typical `.h5ad`/ `.zarr` object that’s been through an analysis. The structures are largely equivalent, though there are a few minor differences when it comes to type encoding. (elements)= ## Elements `````{tab-set} ````{tab-item} HDF5 :sync: hdf5 ```python >>> import h5py >>> store = h5py.File("for-ondisk-docs/cart-164k-processed.h5ad", mode="r") >>> list(store.keys()) ['X', 'layers', 'obs', 'obsm', 'obsp', 'uns', 'var', 'varm', 'varp'] ``` ```` ````{tab-item} Zarr :sync: zarr ```python >>> import zarr >>> store = zarr.open("for-ondisk-docs/cart-164k-processed.zarr", mode="r") >>> list(store.keys()) ['X', 'layers', 'obs', 'obsm', 'obsp', 'uns', 'var', 'varm', 'varp'] ``` ```` ````` In general, `AnnData` objects are comprised of various types of elements. Each element is encoded as either an Array (or Dataset in hdf5 terminology) or a collection of elements (e.g. Group) in the store. We record the type of an element using the `encoding-type` and `encoding-version` keys in its attributes. For example, we can see that this file represents an `AnnData` object from its metadata: ```python >>> dict(store.attrs) {'encoding-type': 'anndata', 'encoding-version': '0.1.0'} ``` Using this information, we're able to dispatch onto readers for the different element types that you'd find in an anndata. (element)= ### Element Specification * An element can be any object within the storage hierarchy (typically an array or group) with associated metadata * An element MUST have a string-valued field `"encoding-type"` in its metadata * An element MUST have a string-valued field `"encoding-version"` in its metadata that can be evaluated to a version (anndata)= ### AnnData specification (v0.1.0) * An `AnnData` object MUST be a group. * The group's metadata MUST include entries: `"encoding-type": "anndata"`, `"encoding-version": "0.1.0"`. * An `AnnData` group MUST contain entries `"obs"` and `"var"`, which MUST be dataframes (though this may only have an index with no columns). * The group MAY contain an entry `X`, which MUST be either a dense or sparse array and whose shape MUST be (`n_obs`, `n_var`) * The group MAY contain a mapping `layers`. Entries in `layers` MUST be dense or sparse arrays which have shapes (`n_obs`, `n_var`) * The group MAY contain a mapping `obsm`. Entries in `obsm` MUST be sparse arrays, dense arrays, or dataframes. These entries MUST have a first dimension of size `n_obs` * The group MAY contain a mapping `varm`. Entries in `varm` MUST be sparse arrays, dense arrays, or dataframes. These entries MUST have a first dimension of size `n_var` * The group MAY contain a mapping `obsp`. Entries in `obsp` MUST be sparse or dense arrays. The entries first two dimensions MUST be of size `n_obs` * The group MAY contain a mapping `varp`. Entries in `varp` MUST be sparse or dense arrays. The entries first two dimensions MUST be of size `n_var` * The group MAY contain a mapping `uns`. Entries in `uns` MUST be an anndata encoded type. (dense-arrays)= ## Dense arrays Dense numeric arrays have the most simple representation on disk, as they have native equivalents in H5py {doc}`h5py:high/dataset` and {class}`zarr.Array`\ s. We can see an example of this with dimensionality reductions stored in the `obsm` group: `````{tab-set} ````{tab-item} HDF5 :sync: hdf5 ```python >>> store["obsm/X_pca"] ``` ```` ````{tab-item} Zarr :sync: zarr ```python >>> store["obsm/X_pca"] ``` ```` ````` ```python >>> dict(store["obsm"]["X_pca"].attrs) {'encoding-type': 'array', 'encoding-version': '0.2.0'} ``` (array)= ### Dense arrays specification (v0.2.0) * Dense arrays MUST be stored in an Array object * Dense arrays MUST have the entries `'encoding-type': 'array'` and `'encoding-version': '0.2.0'` in their metadata (sparse-arrays)= ## Sparse arrays Sparse arrays don’t have a native representations in HDF5 or Zarr, so we've defined our own based on their in-memory structure. Currently two sparse data formats are supported by `AnnData` objects, CSC and CSR (corresponding to {class}`scipy.sparse.csc_matrix` and {class}`scipy.sparse.csr_matrix` respectively). These formats represent a two-dimensional sparse array with three one-dimensional arrays, `indptr`, `indices`, and `data`. ```{note} A full description of these formats is out of scope for this document, but are [easy to find]. ``` We represent a sparse array as a `Group` on-disk, where the kind and shape of the sparse array is defined in the `Group`'s attributes: ```python >>> dict(store["X"].attrs) {'encoding-type': 'csr_matrix', 'encoding-version': '0.1.0', 'shape': [164114, 40145]} ``` The group contains three arrays: `````{tab-set} ````{tab-item} HDF5 :sync: hdf5 ```python >>> store["X"].visititems(print) data indices indptr ``` ```` ````{tab-item} Zarr :sync: zarr ```python >>> store["X"].visititems(print) data indices indptr ``` ```` ````` (csr_matrix)= (csc_matrix)= ### Sparse array specification (v0.1.0) * Each sparse array MUST be its own group * The group MUST contain arrays `indices`, `indptr`, and `data` * The group's metadata MUST contain: * `"encoding-type"`, which is set to `"csr_matrix"` or `"csc_matrix"` for compressed sparse row and compressed sparse column, respectively. * `"encoding-version"`, which is set to `"0.1.0"` * `"shape"` which is an integer array of length 2 whose values are the sizes of the array's dimensions (dataframes)= ## DataFrames DataFrames are saved as a columnar format in a group, so each column of a DataFrame is saved as a separate array. We save a little more information in the attributes here. ```python >>> dict(store["var"].attrs) {'_index': 'ensembl_id', 'column-order': ['highly_variable', 'means', 'variances', 'variances_norm', 'feature_is_filtered', 'feature_name', 'feature_reference', 'feature_biotype', 'mito'], 'encoding-type': 'dataframe', 'encoding-version': '0.2.0'} ``` These attributes identify the index of the dataframe, as well as the original order of the columns. Each column in this dataframe is encoded as its own array. `````{tab-set} ````{tab-item} HDF5 :sync: hdf5 ```python >>> store["var"].visititems(print) ensembl_id feature_biotype feature_biotype/categories feature_biotype/codes feature_is_filtered ... ``` ```` ````{tab-item} Zarr :sync: zarr ```python >>> store["var"].visititems(print) ensembl_id feature_biotype feature_biotype/categories feature_biotype/codes feature_is_filtered ... ``` ```` ````` ```python >>> dict(store["var"]["feature_name"].attrs) {'encoding-type': 'categorical', 'encoding-version': '0.2.0', 'ordered': False} >>> dict(store["var"]["feature_is_filtered"].attrs) {'encoding-type': 'array', 'encoding-version': '0.2.0'} ``` (dataframe)= ### Dataframe Specification (v0.2.0) * A dataframe MUST be stored as a group * The group's metadata: * MUST contain the field `"_index"`, whose value is the key of the array to be used as an index/ row labels * MUST contain encoding metadata `"encoding-type": "dataframe"`, `"encoding-version": "0.2.0"` * MUST contain `"column-order"` an array of strings denoting the order of column entries * The group MUST contain an array for the index * Each entry in the group MUST correspond to an array with equivalent first dimensions * Each entry SHOULD share chunk sizes (in the HDF5 or zarr container) (mappings)= ## Mappings Mappings are simply stored as `Group`s on disk. These are distinct from DataFrames and sparse arrays since they don’t have any special attributes. A `Group` is created for any `Mapping` in the AnnData object, including the standard `obsm`, `varm`, `layers`, and `uns`. Notably, this definition is used recursively within `uns`: `````{tab-set} ````{tab-item} HDF5 :sync: hdf5 ```python >>> store["uns"].visititems(print) [...] pca pca/variance pca/variance_ratio [...] ``` ```` ````{tab-item} Zarr :sync: zarr ```python >>> store["uns"].visititems(print) [...] pca pca/variance pca/variance_ratio [...] ``` ```` ````` (dict)= (mapping)= ### Mapping specifications (v0.1.0) * Each mapping MUST be its own group * The group's metadata MUST contain the encoding metadata `"encoding-type": "dict"`, `"encoding-version": "0.1.0"` (scalars)= ## Scalars Zero dimensional arrays are used for scalar values (i.e. single values like strings, numbers or booleans). These should only occur inside of `uns`, and are commonly saved parameters: `````{tab-set} ````{tab-item} HDF5 :sync: hdf5 ```python >>> store["uns/neighbors/params"].visititems(print) method metric n_neighbors random_state ``` ```` ````{tab-item} Zarr :sync: zarr ```python >>> store["uns/neighbors/params"].visititems(print) method metric n_neighbors random_state ``` ```` ````` ```python >>> store["uns/neighbors/params/metric"][()] 'euclidean' >>> dict(store["uns/neighbors/params/metric"].attrs) {'encoding-type': 'string', 'encoding-version': '0.2.0'} ``` (numeric-scalar)= (string)= ### Scalar specification (v0.2.0) * Scalars MUST be written as a 0 dimensional array * Numeric scalars * MUST have `"encoding-type": "numeric-scalar"`, `"encoding-version": "0.2.0"` in their metadata * MUST be a single numeric value, including boolean, unsigned integer, signed integer, floating point, or complex floating point * String scalars * MUST have `"encoding-type": "string"`, `"encoding-version": "0.2.0"` in their metadata * In zarr, scalar strings MUST be stored as a fixed length unicode dtype * In HDF5, scalar strings MUST be stored as a variable length utf-8 encoded string dtype (categorical-arrays)= ## Categorical arrays ```python >>> categorical = store["obs"]["development_stage"] >>> dict(categorical.attrs) {'encoding-type': 'categorical', 'encoding-version': '0.2.0', 'ordered': False} ``` Discrete values can be efficiently represented with categorical arrays (similar to `factors` in `R`). These arrays encode the values as small width integers (`codes`), which map to the original label set (`categories`). Each entry in the `codes` array is the zero-based index of the encoded value in the `categories` array. To represent a missing value, a code of `-1` is used. We store these two arrays separately. `````{tab-set} ````{tab-item} HDF5 :sync: hdf5 ```python >>> categorical.visititems(print) categories codes ``` ```` ````{tab-item} Zarr :sync: zarr ```python >>> categorical.visititems(print) categories codes ``` ```` ````` (categorical)= ### Categorical array specification (v0.2.0) * Categorical arrays MUST be stored as a group * The group's metadata MUST contain the encoding metadata `"encoding-type": "categorical"`, `"encoding-version": "0.2.0"` * The group's metadata MUST contain the boolean valued field `"ordered"`, which indicates whether the categories are ordered * The group MUST contain an integer valued array named `"codes"` whose maximum value is the number of categories - 1 * The `"codes"` array MAY contain signed integer values. If so, the code `-1` denotes a missing value * The group MUST contain an array called `"categories"` (string-arrays)= ## String arrays Arrays of strings are handled differently than numeric arrays since numpy doesn't really have a good way of representing arrays of unicode strings. `anndata` assumes strings are text-like data, so it uses a variable length encoding. `````{tab-set} ````{tab-item} HDF5 :sync: hdf5 ```python >>> store["var"][store["var"].attrs["_index"]] ``` ```` ````{tab-item} Zarr :sync: zarr ```python >>> store["var"][store["var"].attrs["_index"]] ``` ```` ````` ```python >>> dict(categorical["categories"].attrs) {'encoding-type': 'string-array', 'encoding-version': '0.2.0'} ``` (string-array)= ### String array specifications (v0.2.0) * String arrays MUST be stored in arrays * The arrays's metadata MUST contain the encoding metadata `"encoding-type": "string-array"`, `"encoding-version": "0.2.0"` * In `zarr`, string arrays MUST be stored using `numcodecs`' `VLenUTF8` codec * In `HDF5`, string arrays MUST be stored using the variable length string data type, with a utf-8 encoding (nullable-arrays)= ## Nullable integers, booleans, and strings We support IO with Pandas nullable integer, boolean, and string arrays. We represent these on disk similar to `numpy` masked arrays, `julia` nullable arrays, or `arrow` validity bitmaps (see {issue}`504` for more discussion). That is, we store an indicator array (or mask) of null values alongside the array of all values. `````{tab-set} ````{tab-item} HDF5 :sync: hdf5 ```python >>> from anndata import write_elem >>> null_store = h5py.File("tmp.h5", mode="w") >>> int_array = pd.array([1, None, 3, 4]) >>> int_array [1, , 3, 4] Length: 4, dtype: Int64 >>> write_elem(null_store, "nullable_integer", int_array) >>> null_store.visititems(print) nullable_integer nullable_integer/mask nullable_integer/values ``` ```` ````{tab-item} Zarr :sync: zarr ```python >>> from anndata import write_elem >>> null_store = zarr.open() >>> int_array = pd.array([1, None, 3, 4]) >>> int_array [1, , 3, 4] Length: 4, dtype: Int64 >>> write_elem(null_store, "nullable_integer", int_array) >>> null_store.visititems(print) nullable_integer nullable_integer/mask nullable_integer/values ``` ```` ````` ```python >>> dict(null_store["nullable_integer"].attrs) {'encoding-type': 'nullable-integer', 'encoding-version': '0.1.0'} ``` (nullable-integer)= ### Nullable integer specifications (v0.1.0) * Nullable integers MUST be stored as a group * The group’s attributes MUST contain the encoding metadata `"encoding-type": "nullable-integer"`, `"encoding-version": "0.1.0"` * The group MUST contain an integer valued array under the key `"values"` * The group MUST contain an boolean valued array under the key `"mask"` (nullable-boolean)= ### Nullable boolean specifications (v0.1.0) * Nullable booleans MUST be stored as a group * The group’s attributes MUST contain the encoding metadata `"encoding-type": "nullable-boolean"`, `"encoding-version": "0.1.0"` * The group MUST contain an boolean valued array under the key `"values"` * The group MUST contain an boolean valued array under the key `"mask"` * The `"values"` and `"mask"` arrays MUST be the same shape (nullable-string-array)= ### Nullable string specifications (v0.1.0) * Nullable strings MUST be stored as a group * The group’s attributes MUST contain the encoding metadata `"encoding-type": "nullable-string-array"`, `"encoding-version": "0.1.0"` * The group MUST contain a string valued array under the key `"values"` * The group MUST contain a boolean valued array under the key `"mask"` * The `"values"` and `"mask"` arrays MUST be the same shape (awkward-array)= ## AwkwardArrays ```{warning} **Experimental** Support for ragged arrays via awkward array is considered experimental under the 0.9.0 release series. Please direct feedback on it's implementation to [https://github.com/scverse/anndata](https://github.com/scverse/anndata). ``` Ragged arrays are supported in `anndata` through the [Awkward Array](https://awkward-array.org/) library. For storage on disk, we break down the awkward array into it’s constituent arrays using [`ak.to_buffers`](https://awkward-array.readthedocs.io/en/latest/_auto/ak.to_buffers.html) then writing these arrays using `anndata`’s methods. `````{tab-set} ````{tab-item} HDF5 :sync: hdf5 ```python >>> store["varm/transcript"].visititems(print) node1-mask node10-data node11-mask node12-offsets node13-mask node14-data node16-offsets node17-data node2-offsets node3-data node4-mask node5-offsets node6-data node7-mask node8-offsets node9-mask ``` ```` ````{tab-item} Zarr :sync: zarr ```python >>> store["varm/transcript"].visititems(print) node1-mask node10-data node11-mask node12-offsets node13-mask node14-data node16-offsets node17-data node2-offsets node3-data node4-mask node5-offsets node6-data node7-mask node8-offsets node9-mask ``` ```` ````` The length of the array is saved to it’s own `"length"` attribute, while metadata for the array structure is serialized and saved to the `“form”` attribute. ```python >>> dict(store["varm/transcript"].attrs) {'encoding-type': 'awkward-array', 'encoding-version': '0.1.0', 'form': '{"class": "RecordArray", "fields": ["tx_id", "seq_name", ' '"exon_seq_start", "exon_seq_end", "ensembl_id"], "contents": ' '[{"class": "BitMaskedArray", "mask": "u8", "valid_when": true, ' '"lsb_order": true, "content": {"class": "ListOffsetArray", ' '"offsets": "i64", "content": {"class": "NumpyArray", "primitive": ' '"uint8", "inner_shape": [], "parameters": {"__array__": "char"}, ' '"form_key": "node3"}, "parameters": {"__array__": "string"}, ' '"form_key": "node2"}, "parameters": {}, "form_key": "node1"}, ' ... 'length': 40145} ``` These can be read back as awkward arrays using the [`ak.from_buffers`](https://awkward-array.readthedocs.io/en/latest/_auto/ak.from_buffers.html) function: ```python >>> import awkward as ak >>> from anndata.io import read_elem >>> awkward_group = store["varm/transcript"] >>> ak.from_buffers( ... awkward_group.attrs["form"], ... awkward_group.attrs["length"], ... {k: read_elem(v) for k, v in awkward_group.items()} ... ) >>> transcript_models[:5] [{tx_id: 'ENST00000450305', seq_name: '1', exon_seq_start: [...], ...}, {tx_id: 'ENST00000488147', seq_name: '1', exon_seq_start: [...], ...}, {tx_id: 'ENST00000473358', seq_name: '1', exon_seq_start: [...], ...}, {tx_id: 'ENST00000477740', seq_name: '1', exon_seq_start: [...], ...}, {tx_id: 'ENST00000495576', seq_name: '1', exon_seq_start: [...], ...}] ----------------------------------------------------------------------- type: 5 * { tx_id: ?string, seq_name: ?string, exon_seq_start: option[var * ?int64], exon_seq_end: option[var * ?int64], ensembl_id: ?string } >>> transcript_models[0] {tx_id: 'ENST00000450305', seq_name: '1', exon_seq_start: [12010, 12179, 12613, 12975, 13221, 13453], exon_seq_end: [12057, 12227, 12697, 13052, 13374, 13670], ensembl_id: 'ENSG00000223972'} ------------------------------------------------------------ type: { tx_id: ?string, seq_name: ?string, exon_seq_start: option[var * ?int64], exon_seq_end: option[var * ?int64], ensembl_id: ?string } ``` [easy to find]: https://en.wikipedia.org/wiki/Sparse_matrix#Compressed_sparse_row_(CSR,_CRS_or_Yale_format) [hdf5]: https://en.wikipedia.org/wiki/Hierarchical_Data_Format scverse-anndata-b796d59/docs/index.md000066400000000000000000000003751512025555600175370ustar00rootroot00000000000000```{include} ../README.md ``` # Latest additions See {doc}`/release-notes/index`. ```{toctree} :hidden: true :maxdepth: 1 tutorials/index api concatenation fileformat-prose interoperability benchmarks contributing release-notes/index references ``` scverse-anndata-b796d59/docs/interoperability.md000066400000000000000000000027151512025555600220150ustar00rootroot00000000000000# Interoperability The on-disk representation of anndata files can be read from other languages. Here we list interfaces for working with AnnData from your language of choice: ## R - [zellkonverter](https://bioconductor.org/packages/release/bioc/html/zellkonverter.html) zellkonverter provides basilisk based tooling for loading from `h5ad` files to `SingleCellExperiment` - [anndata](https://anndata.dynverse.org) provides an R implementation of `AnnData` as well as IO for the HDF5 format. - [MuData](https://bioconductor.org/packages/release/bioc/html/MuData.html) provides IO for `AnnData` and `MuData` stored in HDF5 to Bioconductor's `SingleCellExperiment` and `MultiAssayExperiment` objects. - [MuDataSeurat](https://pmbio.github.io/MuDataSeurat/) provides IO from `AnnData` and `MuData` stored in HDF5 to `Seurat` objects. ## Julia - [Muon.jl](https://docs.juliahub.com/Muon/QfqCh/0.1.1/objects/) provides Julia implementations of `AnnData` and `MuData` objects, as well as IO for the HDF5 format - [scVI.jl](https://maren-ha.github.io/scVI.jl/index.html) provides a Julia implementation of `AnnData` as well as IO for the HDF5 format. ## Javascript - [Vitessce](https://github.com/vitessce/vitessce) contains loaders from `AnnData`s stored as Zarr, and uses this to provide interactive visualization ## Rust - [anndata-rs](https://github.com/kaizhang/anndata-rs) provides a Rust implementation of `AnnData` as well as advanced IO support for the HDF5 storage format. scverse-anndata-b796d59/docs/references.rst000066400000000000000000000007501512025555600207560ustar00rootroot00000000000000References ---------- .. [Hastie09] Hastie *et al.* (2009), *The Elements of Statistical Learning*, Springer https://web.stanford.edu/~hastie/ElemStatLearn/. .. [Huber15] Huber *et al.* (2015), *Orchestrating high-throughput genomic analysis with Bioconductor*, Nature Methods https://doi.org/10.1038/nmeth.3252. .. [Murphy12] Murphy (2012, *Machine Learning: A Probabilistic Perspective*, MIT Press https://mitpress.mit.edu/9780262018029/machine-learning/. scverse-anndata-b796d59/docs/release-notes/000077500000000000000000000000001512025555600206475ustar00rootroot00000000000000scverse-anndata-b796d59/docs/release-notes/0.10.0.md000066400000000000000000000042651512025555600217140ustar00rootroot00000000000000(v0.10.0)= ### 0.10.0 {small}`2023-10-06` #### Features **GPU Support** * Dense and sparse [`CuPy`](https://docs.cupy.dev/) arrays are now supported {pr}`1066` {user}`ivirshup` * Once you have `CuPy` arrays in your anndata, use it with: [`rapids-singlecell`](https://rapids-singlecell.readthedocs.io/en/latest/index.html) from v0.9+ * anndata now has GPU enabled CI. Made possibly by a grant from [CZI's EOSS program](https://chanzuckerberg.com/eoss/) and managed via [Cirun](https://Cirun.io) {pr}`1066` {pr}`1084` {user}`Zethson` {user}`ivirshup` **Out of core** * Concatenate on-disk anndata objects with {func}`anndata.experimental.concat_on_disk` {pr}`955` {user}`selmanozleyen` * AnnData can now hold dask arrays with `scipy.sparse.spmatrix` chunks {pr}`1114` {user}`ivirshup` * Public API for interacting with on disk sparse arrays: {func}`~anndata.io.sparse_dataset`, {class}`~anndata.abc.CSRDataset`, and {class}`~anndata.abc.CSCDataset` {pr}`765` {user}`ilan-gold` {user}`ivirshup` * Improved performance for simple slices of OOC sparse arrays {pr}`1131` {user}`ivirshup` **Improved errors and warnings** * Improved error messages when combining dataframes with duplicated column names {pr}`1029` {user}`ivirshup` * Improved warnings when modifying views of `AlingedMappings` {pr}`1016` {user}`flying-sheep` {user}`ivirshup` * `AnnDataReadError`s have been removed. The original error is now thrown with additional information in a note {pr}`1055` {user}`ivirshup` #### Documentation * Added zarr examples to {doc}`file format docs` {pr}`1162` {user}`ivirshup` #### Breaking changes * {meth}`anndata.AnnData.transpose` no longer copies unnecessarily. If you rely on the copying behavior, call `.copy` on the resulting object. {pr}`1114` {user}`ivirshup` #### Other updates * Bump minimum python version to 3.9 {pr}`1117` {user}`flying-sheep` #### Deprecations * Deprecate `anndata.read`, which was just an alias for {func}`anndata.io.read_h5ad` {pr}`1108` {user}`ivirshup`. * `dtype` argument to `AnnData` constructor is now deprecated {pr}`1153` {user}`ivirshup` #### Bug fixes * Fix shape inference on initialization when `X=None` is specified {pr}`1121` {user}`flying-sheep` scverse-anndata-b796d59/docs/release-notes/0.10.1.md000066400000000000000000000002451512025555600217070ustar00rootroot00000000000000(v0.10.1)= ### 0.10.1 {small}`2023-10-08` #### Bug fixes * Fix `ad.concat` erroring when concatenating a categorical and object column {pr}`1171` {user}`ivirshup` scverse-anndata-b796d59/docs/release-notes/0.10.2.md000066400000000000000000000014401512025555600217060ustar00rootroot00000000000000(v0.10.2)= ### 0.10.2 {small}`2023-10-11` #### Bug fixes * Added compatibility layer for packages relying on `anndata._core.sparse_dataset.SparseDataset`. Note that this API is *deprecated* and new code should use `anndata.CSRDataset`, `~anndata.CSCDataset`, and `anndata.sparse_dataset` instead. {pr}`1185` {user}`ivirshup` * Handle deprecation warning from `pd.Categorical.map` thrown during `anndata.concat` {pr}`1189` {user}`flying-sheep` {user}`ivirshup` * Fixed extra steps being included in IO tracebacks {pr}`1193` {user}`flying-sheep` * `as_dense` argument of `write_h5ad` no longer writes an array without encoding metadata {pr}`1193` {user}`flying-sheep` #### Performance * Improved performance of `concat_on_disk` with dense arrays in some cases {pr}`1169` {user}`selmanozleyen` scverse-anndata-b796d59/docs/release-notes/0.10.3.md000066400000000000000000000007651512025555600217200ustar00rootroot00000000000000(v0.10.3)= ### 0.10.3 {small}`2023-10-31` #### Bug fixes * Prevent pandas from causing infinite recursion when setting a slice of a categorical column {pr}`1211` {user}`flying-sheep` #### Documentation * Stop showing “Support for Awkward Arrays is currently experimental” warnings when reading, concatenating, slicing, or transposing AnnData objects {pr}`1182` {user}`flying-sheep` #### Other updates * Fail canary CI job when tests raise unexpected warnings. {pr}`1182` {user}`flying-sheep` scverse-anndata-b796d59/docs/release-notes/0.10.4.md000066400000000000000000000014001512025555600217040ustar00rootroot00000000000000(v0.10.4)= ### 0.10.4 {small}`2024-01-04` #### Bug fixes * Only try to use `Categorical.map(na_action=…)` in actually supported Pandas ≥2.1 {pr}`1226` {user}`flying-sheep` * `AnnData.__sizeof__()` support for backed datasets {pr}`1230` {user}`Neah-Ko` * `adata[:, []]` now returns an `AnnData` object empty on the appropriate dimensions instead of erroring {pr}`1243` {user}`ilan-gold` * `adata.X[mask]` works in newer `numpy` versions when `X` is `backed` {pr}`1255` {user}`ilan-gold` * `adata.X[...]` fixed for `X` as a `BaseCompressedSparseDataset` with `zarr` backend {pr}`1265` {user}`ilan-gold` * Improve read/write error reporting {pr}`1273` {user}`flying-sheep` #### Documentation * Improve aligned mapping error messages {pr}`1252` {user}`flying-sheep` scverse-anndata-b796d59/docs/release-notes/0.10.5.md000066400000000000000000000014771512025555600217230ustar00rootroot00000000000000(v0.10.5)= ### 0.10.5 {small}`2024-01-25` #### Bug fixes * Fix outer concatenation along variables when only a subset of objects had an entry in layers {pr}`1291` {user}`ivirshup` * Fix comparison of >2d arrays in `uns` during concatenation {pr}`1300` {user}`ivirshup` * Fix IO with awkward array version 2.5.2 {pr}`1328` {user}`ivirshup` * Fix bug (introduced in 0.10.4) where indexing an AnnData with `list[bool]` would return the wrong result {pr}`1332` {user}`ivirshup` #### Documentation * Re-add search-as-you-type, this time via `readthedocs-sphinx-search` {pr}`1311` {user}`flying-sheep` #### Performance * `BaseCompressedSparseDataset`'s `indptr` is cached {pr}`1266` {user}`ilan-gold` * Improved performance when indexing backed sparse matrices with boolean masks along their major axis {pr}`1233` {user}`ilan-gold` scverse-anndata-b796d59/docs/release-notes/0.10.6.md000066400000000000000000000027451512025555600217230ustar00rootroot00000000000000(v0.10.6)= ### 0.10.6 {small}`2024-03-11` #### Bug fixes * Defer import of zarr in test helpers, as scanpy CI job relies on them {pr}`1343` {user}`ilan-gold` * Writing a dataframe with non-unique column names now throws an error, instead of silently overwriting {pr}`1335` {user}`ivirshup` * Bring optimization from {pr}`1233` to indexing on the whole `AnnData` object, not just the sparse dataset itself {pr}`1365` {user}`ilan-gold` * Fix mean slice length checking to use improved performance when indexing backed sparse matrices with boolean masks along their major axis {pr}`1366` {user}`ilan-gold` * Fixed overflow occurring when writing dask arrays with sparse chunks by always writing dask arrays with 64 bit indptr and indices, and adding an overflow check to `.append` method of sparse on disk structures {pr}`1348` {user}`ivirshup` * Modified `ValueError` message for invalid `.X` during construction to show more helpful list instead of ambiguous `__name__` {pr}`1395` {user}`eroell` * Pin `array-api-compat!=1.5` to avoid incorrect implementation of `asarray` {pr}`1411` {user}`ivirshup` #### Documentation * Type hints and docstrings for `.to_df` method are updated and fixed {pr}`1402` {user}`WeilerP` #### Development * `anndata`'s CI now tests against minimum versions of it's dependencies. As a result, several dependencies had their minimum required version bumped. See diff for details {pr}`1314` {user}`ivirshup` * `anndata` now tests against Python 3.12 {pr}`1373` {user}`ivirshup` scverse-anndata-b796d59/docs/release-notes/0.10.7.md000066400000000000000000000006511512025555600217160ustar00rootroot00000000000000(v0.10.7)= ### 0.10.7 {small}`2024-04-09` #### Bug fixes * Handle upstream `numcodecs` bug where read-only string arrays cannot be encoded {user}`ivirshup` {pr}`1421` * Use in-memory sparse matrix directly to fix compatibility with `scipy` `1.13` {user}`ilan-gold` {pr}`1435` #### Performance * Remove `vindex` for subsetting `dask.array.Array` because of its slowness and memory consumption {user}`ilan-gold` {pr}`1432` scverse-anndata-b796d59/docs/release-notes/0.10.8.md000066400000000000000000000007221512025555600217160ustar00rootroot00000000000000(v0.10.8)= ### 0.10.8 {small}`2024-06-20` #### Bug fixes * Write out `64bit` indptr when appropriate for {func}`~anndata.experimental.concat_on_disk` {pr}`1493` {user}`ilan-gold` * Support for Numpy 2 {pr}`1499` {user}`flying-sheep` * Fix {func}`~anndata.io.sparse_dataset` docstring test on account of new {mod}`scipy` version {pr}`1514` {user}`ilan-gold` #### Documentation * Improved example for {func}`~anndata.io.sparse_dataset` {pr}`1468` {user}`ivirshup` scverse-anndata-b796d59/docs/release-notes/0.10.9.md000066400000000000000000000025071512025555600217220ustar00rootroot00000000000000(v0.10.9)= ### 0.10.9 {small}`2024-08-28` #### Bug fixes - Fix writing large number of columns for `h5` files {user}`ilan-gold` {user}`selmanozleyen` ({pr}`1147`) - Add warning for setting `X` on a view with repeated indices {user}`ilan-gold` ({pr}`1501`) - Coerce {class}`numpy.matrix` classes to arrays when trying to store them in `AnnData` {user}`flying-sheep` ({pr}`1516`) - Fix for setting a dense `X` view with a sparse matrix {user}`ilan-gold` ({pr}`1532`) - Upper bound {mod}`numpy` for `gpu` installation on account of {issue}`cupy/cupy#8391` {user}`ilan-gold` ({pr}`1540`) - Upper bound dask on account of {issue}`1579` {user}`ilan-gold` ({pr}`1580`) - Ensure setting {attr}`pandas.DataFrame.index` on a view of a {class}`~anndata.AnnData` instantiates the {class}`~pandas.DataFrame` from the view {user}`ilan-gold` ({pr}`1586`) - Disallow using {class}`~pandas.DataFrame`s with multi-index columns {user}`ilan-gold` ({pr}`1589`) #### Development Process - create new `cupy` installation options for cuda 11 & 12 called `cu11` and `cu12` {user}`Intron7` ({pr}`1596`) #### Documentation - add `callback` typing for {func}`~anndata.experimental.read_dispatched` and {func}`~anndata.experimental.write_dispatched` {user}`ilan-gold` ({pr}`1557`) #### Performance - Support for `concat_on_disk` outer join {user}`ilan-gold` ({pr}`1504`) scverse-anndata-b796d59/docs/release-notes/0.11.0.md000066400000000000000000000107641512025555600217160ustar00rootroot00000000000000(v0.11.0)= ### 0.11.0 {small}`2024-11-07` Release candidates: - (v0.11.0rc3)= {guilabel}`rc3` 2024-10-14 - (v0.11.0rc2)= {guilabel}`rc2` 2024-09-24 - (v0.11.0rc1)= {guilabel}`rc1` 2024-09-04 #### Bug fixes - Ensure {func}`anndata.concat` of {class}`~anndata.AnnData` object with {class}`scipy.sparse.spmatrix` and {class}`scipy.sparse.sparray` dask arrays uses the correct fill value of 0. {user}`ilan-gold` ({pr}`1719`) - Ensure that views of AwkwardArrays have their "view" attributes removed on saving an {class}`~anndata.AnnData` object to disk. {user}`grst` ({pr}`1736`) #### Breaking changes - {guilabel}`rc3` Drop support for `python` 3.9 {user}`ilan-gold` ({pr}`1712`) - {guilabel}`rc2` A new `anndata.io` module contains all `read_*` and `write_*` functions, and all imports of such functions should go through this module. Old ways of importing these functions i.e., `from anndata import read_csv` or `from anndata._io.specs import read_elem` will still work, but are now considered deprecated and give a warning on import with the exception of {func}`anndata.io.read_zarr` and {func}`anndata.io.read_h5ad`, which will remain at the top-level `anndata` without warning. {user}`ilan-gold ({pr}`1682`) - {guilabel}`rc1` Removed deprecated modules `anndata.core` and `anndata.readwrite` {user}`ivirshup` ({pr}`1197`) - {guilabel}`rc1` No longer export `sparse_dataset` from `anndata.experimental`, instead exporting {func}`anndata.io.sparse_dataset` {user}`ilan-gold` ({pr}`1642`) - {guilabel}`rc1` Move `RWAble` and `InMemoryElem` out of `experimental`, renaming `RWAble` to {type}`~anndata.typing.AxisStorable` and `InMemoryElem` to {type}`~anndata.typing.RWAble` {user}`ilan-gold` ({pr}`1643`) #### Development Process - {guilabel}`rc2` Add extra `dask` dependency for installation i.e., `pip install anndata[dask]` {user}`ilan-gold` ({pr}`1677`) - {guilabel}`rc2` Remove `shall_` from variable names in `settings` {user}`ilan-gold` ({pr}`1685`) - {guilabel}`rc1` Create new `cupy` installation options for cuda 11 & 12 called `cu11` and `cu12` {user}`Intron7` ({pr}`1596`) #### Documentation - {guilabel}`rc1` Correct {attr}`anndata.AnnData.X` type to include {class}`~anndata.abc.CSRDataset` and {class}`~anndata.abc.CSCDataset` as possible types and being deprecation process for non-csr/csc {class}`scipy.sparse.spmatrix` types in {attr}`anndata.AnnData.X` {user}`ilan-gold` ({pr}`1616`) #### Features - Add support for ellipsis indexing of the {class}`~anndata.AnnData` object {user}`ilan-gold` ({pr}`1729`) - {guilabel}`rc1` `scipy.sparse.csr_array` and `scipy.sparse.csc_array` are now supported when constructing `AnnData` objects {user}`ilan-gold` {user}`isaac-virshup` ({pr}`1028`) - {guilabel}`rc1` Allow `axis` parameter of e.g. {func}`anndata.concat` to accept `'obs'` and `'var'` {user}`flying-sheep` ({pr}`1244`) - {guilabel}`rc1` Add `settings` object with methods for altering internally-used options, like checking for uniqueness on `obs`' index {user}`ilan-gold` ({pr}`1270`) - {guilabel}`rc1` Add {attr}`~anndata.settings.remove_unused_categories` option to {attr}`anndata.settings` to override current behavior {user}`ilan-gold` ({pr}`1340`) - {guilabel}`rc1` Add `~anndata.experimental.read_elem_as_dask` function to handle i/o with sparse and dense arrays {user}`ilan-gold` ({pr}`1469`) - {guilabel}`rc1` Add ability to convert strings to categoricals on write in {meth}`~anndata.AnnData.write_h5ad` and {meth}`~anndata.AnnData.write_zarr` via `convert_strings_to_categoricals` parameter {user}` falexwolf` ({pr}`1474`) - {guilabel}`rc1` Add {attr}`~anndata.settings.check_uniqueness` option to {attr}`anndata.settings` to override current behavior {user}`ilan-gold` ({pr}`1507`) - {guilabel}`rc1` Add functionality to write from GPU {class}`dask.array.Array` to disk {user}`ilan-gold` ({pr}`1550`) - {guilabel}`rc1` Read and write support for nullable string arrays ({class}`pandas.arrays.StringArray`). Use pandas’ {doc}`pandas:user_guide/options` `mode.string_storage` to control which storage mode is used when reading `dtype="string"` columns. {user}`flying-sheep` ({pr}`1558`) - {guilabel}`rc1` Export {func}`~anndata.io.write_elem` and {func}`~anndata.io.read_elem` directly from the main package instead of `experimental` {user}`ilan-gold` ({pr}`1598`) - {guilabel}`rc1` Allow reading sparse data (via {func}`~anndata.io.read_elem` or {func}`~anndata.io.sparse_dataset`) into either {class}`scipy.sparse.csr_array` or {class}`scipy.sparse.csc_array` via {attr}`anndata.settings.use_sparse_array_on_read` {user}`ilan-gold` ({pr}`1633`) scverse-anndata-b796d59/docs/release-notes/0.11.1.md000066400000000000000000000005611512025555600217110ustar00rootroot00000000000000(v0.11.1)= ### 0.11.1 {small}`2024-11-12` #### Bug fixes - Remove upper pin on `dask` and exclude versions broken with sparse indexing {user}`ilan-gold` ({pr}`1725`) - Fix chunking with -1 in `chunks` argument of `~anndata.experimental.read_elem_as_dask` {user}`ilan-gold` ({pr}`1743`) - Fix `cupy<0.13` imports in non-gpu environments {user}`ilan-gold` ({pr}`1754`) scverse-anndata-b796d59/docs/release-notes/0.11.2.md000066400000000000000000000012671512025555600217160ustar00rootroot00000000000000(v0.11.2)= ### 0.11.2 {small}`2025-01-07` #### Bug fixes - Cache accesses to the `data` and `indices` arrays in {class}`~anndata.abc.CSRDataset` and {class}`~anndata.abc.CSCDataset` {user}`ilan-gold` ({pr}`1744`) - Error out on floating point indices that are not actually integers {user}`ilan-gold` ({pr}`1746`) - `write_elem` now filters out incompatible `dataset_kwargs` when saving zero-dimensional arrays {user}`ilia-kats` ({pr}`1783`) - Add {mod}`scipy` 1.5 compatibility {user}`flying-sheep` ({pr}`1806`) ### Performance - Batch slice-based indexing in {class}`anndata.abc.CSRDataset` and {class}`anndata.abc.CSCDataset` for performance boost in `zarr` {user}`ilan-gold` ({pr}`1790`) scverse-anndata-b796d59/docs/release-notes/0.11.3.md000066400000000000000000000001721512025555600217110ustar00rootroot00000000000000(v0.11.3)= ### 0.11.3 {small}`2025-01-10` #### Bug fixes - Upper bound `zarr` at runtime {user}`ilan-gold` ({pr}`1819`) scverse-anndata-b796d59/docs/release-notes/0.11.4.md000066400000000000000000000015521512025555600217150ustar00rootroot00000000000000(v0.11.4)= ### 0.11.4 {small}`2025-03-26` #### Bug fixes - Raise {class}`~anndata.ImplicitModificationWarning` when setting `X` on a view. {user}`ilan-gold` ({pr}`1853`) - Bound `dask` due to {issue}`dask/dask#11752` {user}`ilan-gold` ({pr}`1859`) - Fix concatenation of {class}`anndata.AnnData` objects along `var` using `join="outer"` when `varm` is not empty. {user}`ilia-kats` ({pr}`1911`) - Add `convert_strings_to_categoricals` parameter also to {meth}`~anndata.AnnData.write_h5ad` and {meth}`~anndata.AnnData.write_zarr` as intended {user}`flying-sheep` ({pr}`1914`) - Allow initialization of {class}`anndata.AnnData` objects without `X` (since they could be constructed previously by deleting `X`) {user}`ilan-gold` ({pr}`1941`) #### Development Process - Fix version number inference in development environments (CI and local) {user}`flying-sheep` ({pr}`1831`) scverse-anndata-b796d59/docs/release-notes/0.12.0.md000066400000000000000000000102151512025555600217060ustar00rootroot00000000000000(v0.12.0)= ### 0.12.0 {small}`2025-07-16` - (v0.12.0rc4)= {guilabel}`rc4` 2025-06-18 - (v0.12.0rc3)= {guilabel}`rc3` 2025-05-20 - (v0.12.0rc2)= {guilabel}`rc2` 2025-05-15 - (v0.12.0rc1)= {guilabel}`rc1` 2025-04-09 #### Breaking changes - {guilabel}`rc1` Remove `anndata.read` {user}`ilan-gold` ({pr}`1766`) - {guilabel}`rc1` Tighten usage of {class}`scipy.sparse.spmatrix` for describing sparse matrices in types and instance checks to only {class}`scipy.sparse.csr_matrix` and {class}`scipy.sparse.csc_matrix` {user}`ilan-gold` ({pr}`1768`) - {guilabel}`rc1` Disallow declaration of {class}`~anndata.AnnData` with non-`cs{r,c}` sparse data-structures {user}`ilan-gold` ({pr}`1829`) - {guilabel}`rc1` Upgrade all `DeprecationWarning`s to `FutureWarning`s {user}`ilan-gold` ({pr}`1874`) - {guilabel}`rc4` Lower bound `xarray` by `2025.06.01`. {class}`pandas.arrays.StringArray` was previously used as the in-memory `nullable-string-array` container in `xarray`, but due to {issue}`pydata/xarray#10419` now uses {class}`numpy.ndarray` with an object data type. {user}`ilan-gold` ({pr}`2008`) #### Bug fixes - Fix {func}`anndata.experimental.backed.Dataset2D.reindex` internal setting {user}`ilan-gold` ({pr}`2018`) - {guilabel}`rc1` Disallow writing of {class}`~anndata.experimental.backed.Dataset2D` objects {user}`ilan-gold` ({pr}`1887`) - {guilabel}`rc1` Upgrade old deprecation warning to a `FutureWarning` on `BaseCompressedSparseDataset.__setitem__`, showing our intent to remove the feature in the next release. {user}`ilan-gold` ({pr}`1928`) - {guilabel}`rc1` Don't use {func}`asyncio.run` internally for any operations {user}`ilan-gold` ({pr}`1933`) - {guilabel}`rc1` Disallow forward slashes in keys for writing {user}`ilan-gold` ({pr}`1940`) - {guilabel}`rc2` Convert 1d {class}`numpy.ndarray` and {class}`cupy.ndarray`s in {attr}`anndata.AnnData.obsm` and {attr}`anndata.AnnData.varm` to 2d {user}`ilan-gold` ({pr}`1962`) - {guilabel}`rc3` Update zarr v3 bound to >3.0.8 to prevent corrupted data {issue}`zarr-developers/zarr-python#3061` {user}`ilan-gold` ({pr}`1993`) #### Features - {guilabel}`rc1` {data}`None` values can now be serialized to `.h5ad` and `.zarr`, preserving e.g. {attr}`~anndata.AnnData.uns` structure through saving and loading {user}`flying-sheep` ({pr}`999`) - {guilabel}`rc1` Add {func}`~anndata.experimental.read_elem_lazy` (in place of `read_elem_as_dask`) to handle backed dataframes, sparse arrays, and dense arrays, as well as a {func}`~anndata.experimental.read_lazy` to handle reading in as much of the on-disk data as possible to produce a {class}`~anndata.AnnData` object {user}`ilan-gold` ({pr}`1247`) - {guilabel}`rc1` Support {mod}`zarr` version 3 python package {user}`ilan-gold` ({pr}`1726`) - {guilabel}`rc1` Adopt the Scientific Python [deprecation schedule](https://scientific-python.org/specs/spec-0000/) {user}`ilan-gold` ({pr}`1768`) - {guilabel}`rc1` Allow {mod}`zarr` v3 writing of data {user}`ilan-gold` ({pr}`1892`) - {guilabel}`rc1` {func}`anndata.register_anndata_namespace` functionality for adding custom functionality to an {class}`~anndata.AnnData` object {user}`srivarra` ({pr}`1870`) - {guilabel}`rc2` Allow xarray Datasets to be used for obs/var/obsm/varm. {user}`ilia-kats` ({pr}`1966`) - {guilabel}`rc4` {class}`anndata.experimental.backed.Dataset2D` now takes a compositional approach to wrapping {class}`xarray.Dataset` which may have breaking changes over the past release versions. {user}`ilan-gold` ({pr}`1997`) - {guilabel}`rc4` Use {attr}`numpy.dtypes.StringDType` with `na_object` set to {attr}`pandas.NA` for nullable string data with {class}`anndata.experimental.backed.Dataset2D` {user}`ilan-gold` ({pr}`2011`) #### Performance - {guilabel}`rc2` Load AnnLoader lazily to prevent expensive unnecessary `torch` imports when its available on the system. {user}`Zethson` & {user}`flying-sheep` ({pr}`1950`) - {guilabel}`rc4` Improve {func}`~anndata.experimental.read_elem_lazy` performance for `h5ad` files by not caching `indptr`. {user}`ilan-gold` ({pr}`2005`) #### Development - {guilabel}`rc4` Temporarily bound {mod}`zarr` to `<3.1` until {pr}`1995` is merged to handle the new data type structure. {user}`ilan-gold` ({pr}`2013`) scverse-anndata-b796d59/docs/release-notes/0.12.1.md000066400000000000000000000007561512025555600217200ustar00rootroot00000000000000(v0.12.1)= ### 0.12.1 {small}`2025-07-23` ### Bug fixes - Fix `chunks` argument for {func}`anndata.experimental.read_elem_lazy` so that it uses the on-disk chunking when possible, and allow users to pass this argument through to the reading of {class}`anndata.experimental.backed.Dataset2D` {user}`ilan-gold` ({pr}`2033`) ### Performance - Improve integer indexing performance of `h5` 1d arrays that are opened via {func}`anndata.experimental.read_elem_lazy` {user}`ilan-gold` ({pr}`2035`) scverse-anndata-b796d59/docs/release-notes/0.12.2.md000066400000000000000000000002771512025555600217170ustar00rootroot00000000000000(v0.12.2)= ### 0.12.2 {small}`2025-08-11` ### Bug fixes - Revert accidental change where {attr}`~anndata.AnnData.X` got written to disk when it was `None` {user}`flying-sheep` ({pr}`2054`) scverse-anndata-b796d59/docs/release-notes/0.12.3.md000066400000000000000000000015371512025555600217200ustar00rootroot00000000000000(v0.12.3)= ### 0.12.3 {small}`2025-10-16` #### Miscellaneous changes - Deprecate `AnnData.*_keys()` methods. {user}`flying-sheep` ({pr}`2102`) #### Bug fixes - Deprecate `__version__` and use standard {func}`~importlib.metadata.version` API {user}`flying-sheep` ({pr}`1318`) - Allow writing of views of {class}`dask.array.Array` {user}`ilan-gold` ({pr}`2084`) - Enable writing of views of {class}`~anndata.AnnData` in backed mode {user}`ilan-gold` ({pr}`2092`) - Reallow writing of keys in `h5ad` files with forward slashes instead of erroring. Now a warning will be raised that the behavior will be disallowed in the future. To enable the new behavior, use {attr}`anndata.settings.disallow_forward_slash_in_h5ad`. {user}`ilan-gold` ({pr}`2097`) - Respect off-axis merge options in {func}`anndata.experimental.concat_on_disk` {user}`ilan-gold` ({pr}`2122`) scverse-anndata-b796d59/docs/release-notes/0.12.4.md000066400000000000000000000001031512025555600217050ustar00rootroot00000000000000(v0.12.4)= ### 0.12.4 {small}`2025-10-27` No significant changes. scverse-anndata-b796d59/docs/release-notes/0.12.5.md000066400000000000000000000014601512025555600217150ustar00rootroot00000000000000(v0.12.5)= ### 0.12.5 {small}`2025-11-03` #### Bug fixes - Remove use of private `read_dataset` internally inside {func}`anndata.experimental.read_elem_lazy` {user}`ilan-gold` ({pr}`2158`) - Unblock version restriction on `dask` distributed writing by using threading scheduler always (see {pr}`2172`) {user}`ilan-gold` ({pr}`2183`) #### Performance - Use `name` on {func}`dask.array.map_blocks` internally when concatenating {class}`anndata.experimental.backed.Dataset2D` objects whose categoricals/nullable types must be converted to dask arrays {user}`ilan-gold` ({pr}`2121`) - Enable automatic sharding in zarr v3 via {attr}`anndata.settings.auto_shard_zarr_v3` (via {mod}`zarr`'s own auto sharding mechanism i.e., `shards="auto"`) for all types except {class}`numpy.recarray` {user}`ilan-gold` ({pr}`2167`) scverse-anndata-b796d59/docs/release-notes/0.12.6.md000066400000000000000000000003511512025555600217140ustar00rootroot00000000000000(v0.12.6)= ### 0.12.6 {small}`2025-11-06` #### Bug fixes - Attach {class}`h5py.File` object to {class}`~anndata.AnnData` at the `file` attribute returned from {func}`~anndata.experimental.read_lazy` {user}`ilan-gold` ({pr}`2204`) scverse-anndata-b796d59/docs/release-notes/0.12.7.md000066400000000000000000000011211512025555600217110ustar00rootroot00000000000000(v0.12.7)= ### 0.12.7 {small}`2025-12-16` #### Documentation - Add cruft config & update contributors {smaller}`zethson` ({pr}`2223`) #### Bug fixes - Handle `pandas.options.future.infer_string` {smaller}`P Angerer` {smaller}`I Gold` ({pr}`2133`) - Ensure there are no dangling file handles in {func}`anndata.experimental.concat_on_disk` when passing `.h5ad`-terminated strings as path inputs. {user}`ilan-gold` ({pr}`2218`) - Ensure {attr}`anndata.experimental.backed.Dataset2D.true_index` is carried over when subsetting by columns i.,e `ds[[col1, col2]]` {user}`ilan-gold` ({pr}`2220`) scverse-anndata-b796d59/docs/release-notes/0.4.0.md000066400000000000000000000010071512025555600216260ustar00rootroot00000000000000(v0.4.0)= ### 0.4.0 {small}`23 December, 2017` - read/write [.loom](https://loompy.org) files - scalability beyond dataset sizes that fit into memory: see this [blog post] - {class}`~anndata.AnnData` has a {class}`~anndata.AnnData.raw` attribute, which simplifies storing the data matrix when you consider it *raw*: see the [clustering tutorial] [blog post]: http://falexwolf.de/blog/171223_AnnData_indexing_views_HDF5-backing/ [clustering tutorial]: https://github.com/scverse/scanpy_usage/tree/master/170505_seurat scverse-anndata-b796d59/docs/release-notes/0.5.0.md000066400000000000000000000007151512025555600216340ustar00rootroot00000000000000(v0.5.0)= ### 0.5.0 {small}`9 February, 2018` - inform about duplicates in {class}`~anndata.AnnData.var_names` and resolve them using {func}`~anndata.AnnData.var_names_make_unique` - automatically remove unused categories after slicing - read/write [.loom](https://loompy.org) files using loompy 2 - fixed read/write for a few text file formats - read [UMI tools] files: {func}`~anndata.io.read_umi_tools` [umi tools]: https://github.com/CGATOxford/UMI-tools scverse-anndata-b796d59/docs/release-notes/0.6.0.md000066400000000000000000000006341512025555600216350ustar00rootroot00000000000000(v0.6.0)= ### 0.6.0 {small}`1 May, 2018` - compatibility with Seurat converter - tremendous speedup for {meth}`~anndata.AnnData.concatenate` - bug fix for deep copy of unstructured annotation after slicing - bug fix for reading HDF5 stored single-category annotations - `'outer join'` concatenation: adds zeros for concatenation of sparse data and nans for dense data - better memory efficiency in loom exports scverse-anndata-b796d59/docs/release-notes/0.6.x.md000066400000000000000000000024261512025555600217460ustar00rootroot00000000000000(v0.6.x)= ### 0.6.\* {small}`2019-*-*` - better support for aligned mappings (obsm, varm, layers) `0.6.22` {pr}`155` {smaller}`I Virshup` - convenience accessors {func}`~anndata.AnnData.obs_vector`, {func}`~anndata.AnnData.var_vector` for 1d arrays. `0.6.21` {pr}`144` {smaller}`I Virshup` - compatibility with Scipy >=1.3 by removing `IndexMixin` dependency. `0.6.20` {pr}`151` {smaller}`P Angerer` - bug fix for second-indexing into views. `0.6.19` {smaller}`P Angerer` - bug fix for reading excel files. `0.6.19` {smaller}`A Wolf` - changed default compression to `None` in {func}`~anndata.AnnData.write_h5ad` to speed up read and write, disk space use is usually less critical. `0.6.16` {smaller}`A Wolf` - maintain dtype upon copy. `0.6.13` {smaller}`A Wolf` - {attr}`~anndata.AnnData.layers` inspired by [.loom](https://loompy.org) files allows their information lossless reading via {func}`~anndata.io.read_loom`. `0.6.7`–`0.6.9` {pr}`46` & {pr}`48` {smaller}`S Rybakov` - support for reading zarr files: {func}`~anndata.io.read_zarr` `0.6.7` {pr}`38` {smaller}`T White` - initialization from pandas DataFrames `0.6.` {smaller}`A Wolf` - iteration over chunks {func}`~anndata.AnnData.chunked_X` and {func}`~anndata.AnnData.chunk_X` `0.6.1` {pr}`20` {smaller}`S Rybakov` scverse-anndata-b796d59/docs/release-notes/0.7.0.md000066400000000000000000000046461512025555600216450ustar00rootroot00000000000000(v0.7.0)= ### 0.7.0 {small}`22 January, 2020` ```{warning} Breaking changes introduced between `0.6.22.post1` and `0.7`: - Elements of {class}`~anndata.AnnData`s don’t have their dimensionality reduced when the main object is subset. This is to maintain consistency when subsetting. See discussion in {issue}`145`. - Internal modules like `anndata.core` are private and their contents are not stable: See {issue}`174`. - The old deprecated attributes `.smp*`. `.add` and `.data` have been removed. ``` #### View overhaul {pr}`164` - Indexing into a view no longer keeps a reference to intermediate view, see {issue}`62`. - Views are now lazy. Elements of view of AnnData are not indexed until they’re accessed. - Indexing with scalars no longer reduces dimensionality of contained arrays, see {issue}`145`. - All elements of AnnData should now follow the same rules about how they’re subset, see {issue}`145`. - Can now index by observations and variables at the same time. #### IO overhaul {pr}`167` - Reading and writing has been overhauled for simplification and speed. - Time and memory usage can be half of previous in typical use cases - Zarr backend now supports sparse arrays, and generally is closer to having the same features as HDF5. - Backed mode should see significant speed and memory improvements for access along compressed dimensions and IO. PR {pr}`241`. - {class}`~pandas.Categorical`s can now be ordered (PR {pr}`230`) and written to disk with a large number of categories (PR {pr}`217`). #### Mapping attributes overhaul {smaller}`(obsm, varm, layers, ...)` - New attributes {attr}`~anndata.AnnData.obsp` and {attr}`~anndata.AnnData.varp` have been added for two dimensional arrays where each axis corresponds to a single axis of the AnnData object. PR {pr}`207`. - These are intended to store values like cell-by-cell graphs, which are currently stored in {attr}`~anndata.AnnData.uns`. - Sparse arrays are now allowed as values in all mapping attributes. - DataFrames are now allowed as values in {attr}`~anndata.AnnData.obsm` and {attr}`~anndata.AnnData.varm`. - All mapping attributes now share an implementation and will have the same behaviour. PR {pr}`164`. #### Miscellaneous improvements - Mapping attributes now have ipython tab completion (e.g. `adata.obsm["\\t` can provide suggestions) PR {pr}`183`. - {class}`~anndata.AnnData` attributes are now delete-able (e.g. `del adata.raw`) PR {pr}`242`. - Many many bug fixes scverse-anndata-b796d59/docs/release-notes/0.7.2.md000066400000000000000000000035021512025555600216350ustar00rootroot00000000000000(v0.7.2)= ### 0.7.2 {small}`15 May, 2020` #### Concatenation overhaul {smaller}`I Virshup` - Elements of `uns` can now be merged, see {pr}`350` - Outer joins now work for `layers` and `obsm`, see {pr}`352` - Fill value for outer joins can now be specified - Expect improvements in performance, see {issue}`303` #### Functionality - {attr}`~anndata.AnnData.obsp` and {attr}`~anndata.AnnData.varp` can now be transposed {pr}`370` {smaller}`A Wolf` - {meth}`~anndata.AnnData.obs_names_make_unique` is now better at making values unique, and will warn if ambiguities arise {pr}`345` {smaller}`M Weiden` - {attr}`~anndata.AnnData.obsp` is now preferred for storing pairwise relationships between observations. In practice, this means there will be deprecation warnings and reformatting applied to objects which stored connectivities under `uns["neighbors"]`. Square matrices in {attr}`~anndata.AnnData.uns` will no longer be sliced (use `.{obs,var}p` instead). {pr}`337` {smaller}`I Virshup` - {class}`~anndata.ImplicitModificationWarning` is now exported {pr}`315` {smaller}`P Angerer` - Better support for {class}`~numpy.ndarray` subclasses stored in `AnnData` objects {pr}`335` {smaller}`michalk8` #### Bug fixes - Fixed inplace modification of {class}`~pandas.Index` objects by the make unique function {pr}`348` {smaller}`I Virshup` - Passing ambiguous keys to {meth}`~anndata.AnnData.obs_vector` and {meth}`~anndata.AnnData.var_vector` now throws errors {pr}`340` {smaller}`I Virshup` - Fix instantiating {class}`~anndata.AnnData` objects from {class}`~pandas.DataFrame` {pr}`316` {smaller}`P Angerer` - Fixed indexing into `AnnData` objects with arrays like `adata[adata[:, gene].X > 0]` {pr}`332` {smaller}`I Virshup` - Fixed type of version {pr}`315` {smaller}`P Angerer` - Fixed deprecated import from {mod}`pandas` {pr}`319` {smaller}`P Angerer` scverse-anndata-b796d59/docs/release-notes/0.7.3.md000066400000000000000000000002251512025555600216350ustar00rootroot00000000000000(v0.7.3)= ### 0.7.3 {small}`20 May, 2020` #### Bug fixes - Fixed bug where graphs used too much memory when copying {pr}`381` {smaller}`I Virshup` scverse-anndata-b796d59/docs/release-notes/0.7.4.md000066400000000000000000000011351512025555600216370ustar00rootroot00000000000000(v0.7.4)= ### 0.7.4 {small}`10 July, 2020` #### Concatenation overhaul {pr}`378` {smaller}`I Virshup` - New function {func}`anndata.concat` for concatenating `AnnData` objects along either observations or variables - New documentation section: {doc}`/concatenation` #### Functionality - AnnData object created from dataframes with sparse values will have sparse `.X` {pr}`395` {smaller}`I Virshup` #### Bug fixes - Fixed error from `AnnData.concatenate` by bumping minimum versions of numpy and pandas {issue}`385` - Fixed colors being incorrectly changed when `AnnData` object was subset {pr}`388` scverse-anndata-b796d59/docs/release-notes/0.7.5.md000066400000000000000000000006231512025555600216410ustar00rootroot00000000000000(v0.7.5)= ### 0.7.5 {small}`12 November, 2020` #### Functionality - Added ipython tab completion and a useful return from `.keys` to `adata.uns` {pr}`415` {smaller}`I Virshup` #### Bug fixes - Compatibility with `h5py>=3` strings {pr}`444` {smaller}`I Virshup` - Allow `adata.raw = None`, as is documented {pr}`447` {smaller}`I Virshup` - Fix warnings from pandas 1.1 {pr}`425` {smaller}`I Virshup` scverse-anndata-b796d59/docs/release-notes/0.7.6.md000066400000000000000000000027501512025555600216450ustar00rootroot00000000000000(v0.7.6)= ### 0.7.6 {small}`11 April, 2021` #### Features - Added {meth}`anndata.AnnData.to_memory` for returning an in memory object from a backed one {pr}`470` {pr}`542` {smaller}`V Bergen` {smaller}`I Virshup` - {meth}`anndata.AnnData.write_loom` now writes `obs_names` and `var_names` using the `Index`'s `.name` attribute, if set {pr}`538` {smaller}`I Virshup` #### Bug fixes - Fixed bug where `np.str_` column names errored at write time {pr}`457` {smaller}`I Virshup` - Fixed "value.index does not match parent’s axis 0/1 names" error triggered when a data frame is stored in obsm/varm after obs_names/var_names is updated {pr}`461` {smaller}`G Eraslan` - Fixed `adata.write_csvs` when `adata` is a view {pr}`462` {smaller}`I Virshup` - Fixed null values being converted to strings when strings are converted to categorical {pr}`529` {smaller}`I Virshup` - Fixed handling of compression key word arguments {pr}`536` {smaller}`I Virshup` - Fixed copying a backed `AnnData` from changing which file the original object points at {pr}`533` {smaller}`ilia-kats` - Fixed a bug where calling `AnnData.concatenate` an `AnnData` with no variables would error {pr}`537` {smaller}`I Virshup` #### Deprecations - Passing positional arguments to {func}`anndata.io.read_loom` besides the path is now deprecated {pr}`538` {smaller}`I Virshup` - {func}`anndata.io.read_loom` arguments `obsm_names` and `varm_names` are now deprecated in favour of `obsm_mapping` and `varm_mapping` {pr}`538` {smaller}`I Virshup` scverse-anndata-b796d59/docs/release-notes/0.7.7.md000066400000000000000000000012321512025555600216400ustar00rootroot00000000000000(v0.7.7)= ### 0.7.7 {small}`9 November, 2021` #### Bug fixes - Fixed propagation of import error when importing `write_zarr` but not all dependencies are installed {pr}`579` {smaller}`R Hillje` - Fixed issue with `.uns` sub-dictionaries being referenced by copies {pr}`576` {smaller}`I Virshup` - Fixed out-of-bounds integer indices not raising {class}`IndexError` {pr}`630` {smaller}`M Klein` - Fixed backed `SparseDataset` indexing with scipy 1.7.2 {pr}`638` {smaller}`I Virshup` #### Development processes - Use PEPs 621 (standardized project metadata), 631 (standardized dependencies), and 660 (standardized editable installs) {pr}`639` {smaller}`I Virshup` scverse-anndata-b796d59/docs/release-notes/0.7.8.md000066400000000000000000000001701512025555600216410ustar00rootroot00000000000000(v0.7.8)= ### 0.7.8 {small}`9 November, 2021` #### Bug fixes - Re-include test helpers {pr}`641` {smaller}`I Virshup` scverse-anndata-b796d59/docs/release-notes/0.8.0.md000066400000000000000000000043721512025555600216420ustar00rootroot00000000000000(v0.8.0)= ### 0.8.0 {small}`14th March, 2022` #### IO Specification ```{warning} The on disk format of AnnData objects has been updated with this release. Previous releases of `anndata` will not be able to read all files written by this version. For discussion of possible future solutions to this issue, see {issue}`698` ``` Internal handling of IO has been overhauled. This should make it much easier to support new datatypes, use partial access, and use `AnnData` internally in other formats. - Each element should be tagged with an `encoding_type` and `encoding_version`. See updated docs on the {doc}`file format ` - Support for nullable integer and boolean data arrays. More data types to come! - Experimental support for low level access to the IO API via {func}`~anndata.io.read_elem` and {func}`~anndata.io.write_elem` #### Features - Added PyTorch dataloader {class}`~anndata.experimental.AnnLoader` and lazy concatenation object {class}`~anndata.experimental.AnnCollection`. See the [tutorials] {pr}`416` {smaller}`S Rybakov` - Compatibility with `h5ad` files written from Julia {pr}`569` {smaller}`I Kats` - Many logging messages that should have been warnings are now warnings {pr}`650` {smaller}`I Virshup` - Significantly more efficient {func}`anndata.io.read_umi_tools` {pr}`661` {smaller}`I Virshup` - Fixed deepcopy of a copy of a view retaining sparse matrix view mixin type {pr}`670` {smaller}`M Klein` - In many cases {attr}`~anndata.AnnData.X` can now be `None` {pr}`463` {smaller}`R Cannoodt` {pr}`677` {smaller}`I Virshup`. Remaining work is documented in {issue}`467`. - Removed hard `xlrd` dependency {smaller}`I Virshup` - `obs` and `var` dataframes are no longer copied by default on `AnnData` instantiation {issue}`371` {smaller}`I Virshup` #### Bug fixes - Fixed issue where `.copy` was creating sparse matrices views when copying {pr}`670` {smaller}`michalk8` - Fixed issue where `.X` matrix read in from `zarr` would always have `float32` values {pr}`701` {smaller}`I Virshup` - `` Raw.to_adata` `` now includes `obsp` in the output {pr}`404` {smaller}`G Eraslan` #### Dependencies - `xlrd` dropped as a hard dependency - Now requires `h5py` `v3.0.0` or newer [tutorials]: https://anndata-tutorials.readthedocs.io/en/latest/index.html scverse-anndata-b796d59/docs/release-notes/0.9.0.md000066400000000000000000000057541512025555600216500ustar00rootroot00000000000000(v0.9.0)= ### 0.9.0 {small}`2023-04-11` #### Features - Added experimental support for dask arrays {pr}`813` {user}`syelman` {user}`rahulbshrestha` - `obsm`, `varm` and `uns` can now hold [AwkwardArrays](https://awkward-array.org/quickstart.html) {pr}`647` {user}`giovp`, {user}`grst`, {user}`ivirshup` - Added experimental functions {func}`anndata.experimental.read_dispatched` and {func}`anndata.experimental.write_dispatched` which allow customizing IO with a callback {pr}`873` {user}`ilan-gold` {user}`ivirshup` - Better error messages during IO {pr}`734` {user}`flying-sheep`, {user}`ivirshup` - Unordered categorical columns are no longer cast to object during {func}`anndata.concat` {pr}`763` {user}`ivirshup` #### Documentation - New tutorials for experimental features > - {doc}`/tutorials/notebooks/anndata_dask_array` – {pr}`886` {user}`syelman` > - {doc}`/tutorials/notebooks/{read,write}_dispatched` – {pr}`scverse/anndata-tutorials#17` {user}`ilan-gold` > - {doc}`/tutorials/notebooks/awkward-arrays` – {pr}`scverse/anndata-tutorials#15` {user}`grst` - {doc}`File format description ` now includes a more formal specification {pr}`882` {user}`ivirshup` - {doc}`/interoperability`: new page on interoperability with other packages {pr}`831` {user}`ivirshup` - Expanded docstring more documentation for `backed` argument of {func}`anndata.io.read_h5ad` {pr}`812` {user}`jeskowagner` - Documented how to use alternative compression methods for the `h5ad` file format, see {meth}`AnnData.write_h5ad() ` {pr}`857` {user}`nigeil` - General typo corrections 😅 {pr}`870` {user}`folded` #### Breaking changes - The `AnnData` `dtype` argument no longer defaults to `float32` {pr}`854` {user}`ivirshup` - Previously deprecated `force_dense` argument {meth}`AnnData.write_h5ad() ` has been removed. {pr}`855` {user}`ivirshup` - Previously deprecated behaviour around storing adjacency matrices in `uns` has been removed {pr}`866` {user}`ivirshup` #### Other updates - Bump minimum python version to 3.8 {pr}`820` {user}`ivirshup` #### Deprecations - {meth}`AnnData.concatenate() ` is now deprecated in favour of {func}`anndata.concat` {pr}`845` {user}`ivirshup` #### Bug fixes - Fix warning from `rename_categories` {pr}`790` {smaller}`I Virshup` - Remove backwards compat checks for categories in `uns` when we can tell the file is new enough {pr}`790` {smaller}`I Virshup` - Categorical arrays are now created with a python `bool` instead of a `numpy.bool_` {pr}`856` - Fixed order dependent outer concatenation bug {pr}`904` {user}`ivirshup`, reported by {user}`szalata` - Fixed bug in renaming categories {pr}`790` {user}`ivirshup`, reported by {user}`perrin-isir` - Fixed IO bug when keys in `uns` ended in `_categories` {pr}`806` {user}`ivirshup`, reported by {user}`Hrovatin` - Fixed `raw.to_adata` not populating `obs` aligned values when `raw` was assigned through the setter {pr}`939` {user}`ivirshup` scverse-anndata-b796d59/docs/release-notes/0.9.1.md000066400000000000000000000001541512025555600216360ustar00rootroot00000000000000(v0.9.1)= ### 0.9.1 {small}`2023-04-11` #### Bug fixes * Fixing windows support {pr}`958` {user}`Koncopd` scverse-anndata-b796d59/docs/release-notes/0.9.2.md000066400000000000000000000007041512025555600216400ustar00rootroot00000000000000(v0.9.2)= ### 0.9.2 {small}`2023-07-25` #### Bug fixes * Views of `awkward.Array`s now work with `awkward>=2.3` {pr}`1040` {user}`ivirshup` * Fix ufuncs of views like `adata.X[:10].cov(axis=0)` returning views {pr}`1043` {user}`flying-sheep` * Fix instantiating AnnData where `.X` is a `DataFrame` with an integer valued index {pr}`1002` {user}`flying-sheep` * Fix {func}`~anndata.io.read_zarr` when used on `zarr.Group` {pr}`1057` {user}`ivirshup` scverse-anndata-b796d59/docs/release-notes/2172.bug.md000066400000000000000000000007271512025555600223460ustar00rootroot00000000000000{func}`dask.array.store` was producing corrupted data with zarr v3 + distributed scheduler + a lock (which we used internally): see {ref}`dask/dask#12109`. Thus dense arrays were potentially being stored with corrupted data. The solution is to remove the lock for newer versions of dask but without the lock in older versions, it is impossible to store the data. Thus versions of dask older than `2025.4.0` will not be supported for writing dense data. {user}`ilan-gold` scverse-anndata-b796d59/docs/release-notes/index.md000066400000000000000000000000521512025555600222750ustar00rootroot00000000000000# Release notes ```{release-notes} . ``` scverse-anndata-b796d59/docs/tutorials/000077500000000000000000000000001512025555600201275ustar00rootroot00000000000000scverse-anndata-b796d59/docs/tutorials/index.md000066400000000000000000000007151512025555600215630ustar00rootroot00000000000000# Tutorials For a quick introduction to `AnnData`, check out {doc}`Getting Started with AnnData `. For working with the experimental data loaders also see {ref}`experimental-api`. ```{toctree} :maxdepth: 1 notebooks/getting-started notebooks/annloader notebooks/anncollection notebooks/anncollection-annloader notebooks/anndata_dask_array notebooks/awkward-arrays notebooks/{read,write}_dispatched notebooks/read_lazy zarr-v3 ``` scverse-anndata-b796d59/docs/tutorials/notebooks/000077500000000000000000000000001512025555600221325ustar00rootroot00000000000000scverse-anndata-b796d59/docs/tutorials/zarr-v3.md000066400000000000000000000177321512025555600217670ustar00rootroot00000000000000# zarr-v3 Guide/Roadmap `anndata` now uses the much improved {mod}`zarr` v3 package and also allows writing of datasets in the v3 format via {attr}`anndata.settings.zarr_write_format` via {func}`anndata.io.write_zarr` or {meth}`anndata.AnnData.write_zarr`, with the exception of structured arrays. Users should notice a significant performance improvement, especially for cloud data, but also likely for local data as well. Here is a quick guide on some of our learnings so far: ## Consolidated Metadata All `zarr` stores are now consolidated by default when written via {func}`anndata.io.write_zarr` or {meth}`anndata.AnnData.write_zarr`. For more information on this topic, please see the zarr [consolidated metadata] user guide. Practcally, this changes means that once a store has been written, it should be treated as immutable **unless you remove the consolidated metadata and/or rewrite after the mutating operation** i.e., if you wish to use {func}`anndata.io.write_elem` to add a column to `obs`, a `layer` etc. to an existing store. For example, to mutate an existing store on-disk, you may do: ```python g = zarr.open_group(orig_path, mode="a", use_consolidated=False) ad.io.write_elem( g, "obs", obs, dataset_kwargs=dict(chunks=(250,)), ) zarr.consolidate_metadata(g.store) ``` In this example, the store was opened unconsolidated (trying to open it as a consolidated store would error out), edited, and then reconsolidated. Alternatively, one could simple delete the file containing the consolidated metadata first at the root, `.zmetadata`. ## Remote data We now provide the {func}`anndata.experimental.read_lazy` feature for reading as much of the {class}`~anndata.AnnData` object as lazily as possible, using `dask` and {mod}`xarray`. Please note that this feature is experimental and subject to change. To enable this functionality in a performant and feature-complete way for remote data sources, we use [consolidated metadata] on the `zarr` store (written by default). Please note that this introduces consistency issues – if you update the structure of the underlying `zarr` store i.e., remove a column from `obs`, the consolidated metadata will no longer be valid. Further, note that without consolidated metadata, we cannot guarantee your stored `AnnData` object will be fully readable. And even if it is fully readable, it will almost certainly be much slower to read. There are two ways of opening remote `zarr` stores from the `zarr-python` package, {class}`zarr.storage.FsspecStore` and {class}`zarr.storage.ObjectStore`, and both can be used with `read_lazy`. [`obstore` claims] to be more performant out-of-the-box, but notes that this claim has not been benchmarked with the `uvloop` event loop, which itself claims to be 2× more performant than the default event loop for `python`. ## Local data Local data generally poses a different set of challenges. First, write speeds can be somewhat slow and second, the creation of many small files on a file system can slow down a filesystem. For the "many small files" problem, `zarr` has introduced [sharding] in the v3 file format. We offer {attr}`anndata.settings.auto_shard_zarr_v3` to hook into zarr's ability to automatically compute shards, which is experimental at the moment. Manual sharding requires knowledge of the array element you are writing (such as shape or data type), though, and therefore you will need to use {func}`anndata.experimental.write_dispatched` to use custom sharding. For example, you cannot shard a 1D array with `shard` sizes `(256, 256)`. Here is a short example, although you should tune the sizes to your own use-case and also use the compression that makes the most sense for you: ```python import zarr import anndata as ad from collections.abc import Mapping from typing import Any g = zarr.open_group(orig_path, mode="a", use_consolidated=False, zarr_version=3) # zarr_version 3 is default but note that sharding only works with v3! def write_sharded(group: zarr.Group, adata: ad.AnnData): def callback( func: ad.experimental.Write, g: zarr.Group, k: str, elem: ad.typing.RWAble, dataset_kwargs: Mapping[str, Any], iospec: ad.experimental.IOSpec, ): if iospec.encoding_type in {"array"}: dataset_kwargs = { "shards": tuple(int(2 ** (16 / len(elem.shape))) for _ in elem.shape), **dataset_kwargs, } dataset_kwargs["chunks"] = tuple(i // 2 for i in dataset_kwargs["shards"]) elif iospec.encoding_type in {"csr_matrix", "csc_matrix"}: dataset_kwargs = {"shards": (2**16,), "chunks": (2**8,), **dataset_kwargs} func(g, k, elem, dataset_kwargs=dataset_kwargs) return ad.experimental.write_dispatched(group, "/", adata, callback=callback) ``` However, `zarr-python` can be slow with sharding throughput as well as writing throughput. Thus if you wish to speed up either writing, sharding, or both (or receive a modest speed-boost for reading), a bridge to the `zarr` implementation in Rust {doc}`zarrs-python ` can help with that (see the [zarr-benchmarks]): ``` uv pip install zarrs ``` ```python import zarr import zarrs zarr.config.set({"codec_pipeline.path": "zarrs.ZarrsCodecPipeline"}) ``` However, this pipeline is not compatible with all types of zarr store, especially remote stores and there are limitations on where rust can give a performance boost for indexing. We therefore recommend this pipeline for writing full datasets and reading contiguous regions of said written data. ## Codecs The default `zarr-python` v3 codec for the v3 format is no longer `blosc` but `zstd`. While `zstd` is more widespread, you may find its performance to not meet your old expectations. Therefore, we recommend passing in the {class}`zarr.codecs.BloscCodec` to `compressor` on {func}`~anndata.AnnData.write_zarr` if you wish to return to the old behavior. ## Dask Zarr v3 should be compatible with dask, although the default behavior is to use zarr's chunking for dask's own. With sharding, this behavior may be undesirable as shards can often contain many small chunks, thereby slowing down i/o as dask will need to index into the zarr store for every chunk. Therefore it may be better to customize this behavior by passing `chunks=my_zarr_array.shards` as an argument to {func}`dask.array.from_zarr` or similar. ## GPU i/o At the moment, it is unlikely your `anndata` i/o will work if you use [`zarr.config.enable_gpu`][GPU user guide]. It's *possible* dense data i/o i.e., using {func}`anndata.io.read_elem` will work as expected, but this functionality is untested – sparse data, awkward arrays, and dataframes will not. `kvikio` currently provides a {class}`kvikio.zarr.GDSStore` although there are no working compressors at the moment exported from the `zarr-python` package (work is underway for `Zstd`: {pr}`zarr-developers/zarr-python#2863`. We anticipate enabling officially supporting this functionality officially for dense data, sparse data, and possibly awkward arrays in the next minor release, 0.13. ## Asynchronous i/o At the moment, `anndata` exports no `async` functions. However, `zarr-python` has a fully `async` API and provides its own event-loop so that users like `anndata` can interact with a synchronous API while still beenfitting from `zarr-python`'s asynchronous functionality under that API. We anticipate providing `async` versions of {func}`anndata.io.read_elem` and {func}`anndata.experimental.read_dispatched` so that users can download data asynchronously without using the `zarr-python` event loop. We also would like to create an asynchronous partial reader to enable iterative streaming of a dataset. [consolidated metadata]: https://zarr.readthedocs.io/en/latest/user-guide/consolidated_metadata/ [`obstore` claims]: https://developmentseed.org/obstore/latest/performance [sharding]: https://zarr.readthedocs.io/en/stable/user-guide/arrays/#sharding [zarr-benchmarks]: https://github.com/LDeakin/zarr_benchmarks [GPU user guide]: https://zarr.readthedocs.io/en/stable/user-guide/gpu/ scverse-anndata-b796d59/hatch.toml000066400000000000000000000037631512025555600171460ustar00rootroot00000000000000[envs.default] installer = "uv" features = [ "dev" ] [envs.docs] features = [ "doc" ] scripts.build = "sphinx-build -M html docs docs/_build -W --keep-going {args}" scripts.open = "python3 -m webbrowser -t docs/_build/html/index.html" scripts.clean = "git clean -fdX -- {args:docs}" [envs.towncrier] scripts.create = "towncrier create {args}" scripts.build = "python3 ci/scripts/towncrier_automation.py {args}" scripts.clean = "git restore --source=HEAD --staged --worktree -- docs/release-notes" [envs.hatch-test] default-args = [ ] features = [ "dev", "test-min" ] extra-dependencies = [ "ipykernel" ] env-vars.UV_CONSTRAINT = "ci/constraints.txt" overrides.matrix.deps.env-vars = [ { if = [ "pre" ], key = "UV_PRERELEASE", value = "allow" }, { if = [ "pre" ], key = "UV_CONSTRAINT", value = "ci/pre-deps.txt" }, { if = [ "min" ], key = "UV_CONSTRAINT", value = "ci/constraints.txt ci/min-constraints.txt ci/min-deps.txt" }, ] overrides.matrix.deps.pre-install-commands = [ { if = [ "min", ], value = "uv run ci/scripts/min-deps.py pyproject.toml --all-extras -o ci/min-deps.txt" }, # To prevent situations like https://github.com/pydata/xarray/issues/10419 going forward, and test against zarr as well # IMPORTANT: `zarr` being a pre-release is used in the testing code { if = [ "pre", ], value = "echo 'xarray @ git+https://github.com/pydata/xarray.git\nzarr @ git+https://github.com/zarr-developers/zarr-python.git' > ci/pre-deps.txt" }, ] overrides.matrix.deps.python = [ { if = [ "min" ], value = "3.11" }, # transitive test dep numba doesn’t support 3.14 in a stable release yet: # https://github.com/numba/numba/issues/9957 { if = [ "stable" ], value = "3.13" }, { if = [ "pre" ], value = "3.14" }, ] overrides.matrix.deps.features = [ { if = [ "stable", "pre" ], value = "test" }, ] overrides.matrix.deps.extra-args = { if = [ "stable", "pre" ], value = [ "--strict-warnings" ] } [[envs.hatch-test.matrix]] deps = [ "stable", "pre", "min" ] scverse-anndata-b796d59/pyproject.toml000066400000000000000000000225201512025555600200660ustar00rootroot00000000000000[build-system] build-backend = "hatchling.build" requires = [ "hatchling", "hatch-vcs" ] [project] name = "anndata" description = "Annotated data." requires-python = ">=3.11" license = "BSD-3-Clause" authors = [ { name = "Philipp Angerer" }, { name = "Alex Wolf" }, { name = "Isaac Virshup" }, { name = "Sergei Rybakov" }, { name = "Ilan Gold" }, ] maintainers = [ { name = "Philipp Angerer", email = "philipp.angerer@helmholtz-munich.de" }, { name = "Ilan Gold", email = "ilan.gold@helmholtz-munich.de" }, ] readme = "README.md" classifiers = [ "Environment :: Console", "Framework :: Jupyter", "Intended Audience :: Developers", "Intended Audience :: Science/Research", "Natural Language :: English", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Bio-Informatics", "Topic :: Scientific/Engineering :: Visualization", ] dependencies = [ "pandas >=2.1.0, !=2.1.2, <3", "numpy>=1.26", # https://github.com/scverse/anndata/issues/1434 "scipy >=1.12", "h5py>=3.8", "natsort", "packaging>=24.2", "array_api_compat>=1.7.1", "legacy-api-wrap", "zarr >=2.18.7, !=3.0.*", ] dynamic = [ "version" ] [project.urls] Documentation = "https://anndata.readthedocs.io/" Source = "https://github.com/scverse/anndata" Home-page = "https://github.com/scverse/anndata" [project.optional-dependencies] dev = [ "anndata[dev-doc]", ] doc = [ "sphinx>=8.2.1,<9", # https://github.com/tox-dev/sphinx-autodoc-typehints/issues/586 "sphinx-book-theme>=1.1.0", "sphinx-autodoc-typehints>=2.2.0", "sphinx-issues>=5.0.1", "sphinx-copybutton", "sphinx-toolbox>=3.8.0", "sphinxext.opengraph", "myst-nb", "scanpydoc[theme,typehints] >=0.15.3", "awkward>=2.3", "IPython", # For syntax highlighting in notebooks "myst_parser", "sphinx_design>=0.5.0", # for unreleased changes "anndata[dev-doc,dask]", ] dev-doc = [ "towncrier>=24.8.0" ] # release notes tool test-min = [ "loompy>=3.0.5", "pytest", "pytest-cov", # only for VS Code "pytest-randomly", "pytest-memray", "pytest-mock", "pytest-xdist[psutil]", "filelock", "matplotlib", "scikit-learn", "openpyxl", "joblib", "boltons", "scanpy>=1.10", # TODO: Is 1.0dev1 a real pre-release? https://pypi.org/project/httpx/#history "httpx<1.0", # For data downloading "dask[distributed]", "awkward>=2.3.2", "pyarrow", "anndata[dask]", ] test = [ "anndata[test-min,lazy]" ] gpu = [ "cupy" ] cu12 = [ "cupy-cuda12x" ] cu11 = [ "cupy-cuda11x" ] # requests and aiohttp needed for zarr remote data lazy = [ "xarray>=2025.06.1", "aiohttp", "requests", "anndata[dask]" ] # https://github.com/dask/dask/issues/11290 # https://github.com/dask/dask/issues/11752 dask = [ "dask[array]>=2023.5.1,!=2024.8.*,!=2024.9.*,!=2025.2.*,!=2025.3.*,!=2025.4.*,!=2025.5.*,!=2025.6.*,!=2025.7.*,!=2025.8.*", ] [tool.hatch.version] source = "vcs" raw-options.version_scheme = "release-branch-semver" [tool.hatch.build.targets.wheel] packages = [ "src/anndata", "src/testing" ] [tool.coverage.run] data_file = "test-data/raw-coverage" source_pkgs = [ "anndata" ] omit = [ "src/anndata/_version.py", "**/test_*.py" ] patch = [ "subprocess" ] [tool.coverage.xml] output = "test-data/coverage.xml" [tool.coverage.paths] source = [ "./src", "**/site-packages" ] [tool.coverage.report] exclude_also = [ "if TYPE_CHECKING:", ] [tool.pytest] strict = true addopts = [ "--import-mode=importlib", "--doctest-modules", "--pyargs", "-ptesting.anndata._pytest", "--dist=loadgroup", ] filterwarnings = [ "ignore::anndata._warnings.OldFormatWarning", "ignore::anndata._warnings.ExperimentalFeatureWarning", "ignore:.*first_column_names:FutureWarning:scanpy", # scanpy 1.10.x "ignore:Importing read_.* from `anndata` is deprecated:FutureWarning:scanpy", "ignore:`__version__` is deprecated:FutureWarning:scanpy", # https://github.com/matplotlib/matplotlib/pull/30589 "ignore:.*'(oneOf|parseString|resetCache|enablePackrat)'.*'(one_of|parse_string|reset_cache|enable_packrat)':DeprecationWarning:matplotlib", ] # When `--strict-warnings` is used, all warnings are treated as errors, except those: filterwarnings_when_strict = [ "default::anndata._warnings.ImplicitModificationWarning", "default:Transforming to str index:UserWarning", "default:(Observation|Variable) names are not unique. To make them unique:UserWarning", "default::scipy.sparse.SparseEfficiencyWarning", "default::dask.array.core.PerformanceWarning", "default:anndata will no longer support zarr v2:DeprecationWarning", "default:The codec `vlen-utf8:UserWarning", "default:The dtype `StringDType():UserWarning", "default:Consolidated metadata is:UserWarning", "default:.*Structured:zarr.core.dtype.common.UnstableSpecificationWarning", "default:.*FixedLengthUTF32:zarr.core.dtype.common.UnstableSpecificationWarning", "default:Automatic shard shape inference is experimental", ] python_files = [ "test_*.py" ] testpaths = [ "anndata", # docstrings (module name due to --pyargs) "./tests", # unit tests "./ci/scripts", # CI script tests "./docs/concatenation.rst", # further doctests ] # For some reason this effects how logging is shown when tests are run markers = [ "gpu: mark test to run on GPU", "zarr_io: mark tests that involve zarr io", "dask_distributed: tests that need a distributed client with multiple processes", ] [tool.ruff] src = [ "src" ] [tool.ruff.format] preview = true docstring-code-format = true [tool.ruff.lint] select = [ "B", # Likely bugs and design issues "BLE", # Blind except "C4", # Comprehensions "E", # Error detected by Pycodestyle "EM", # Traceback-friendly error messages "F", # Errors detected by Pyflakes "FBT", # Boolean positional arguments "I", # isort "ICN", # Follow import conventions "ISC", # Implicit string concatenation "PERF", # Performance "PIE", # Syntax simplifications "PTH", # Pathlib instead of os.path "PT", # Pytest conventions "PL", # Pylint "PYI", # Typing "RUF", # Unused noqa "SIM", # Code simplifications "TC", # manage type checking blocks "TID", # Banned imports "UP", # pyupgrade "W", # Warning detected by Pycodestyle ] external = [ "PLR0917" ] # preview rule ignore = [ "C408", # dict() syntax is preferable for dicts used as kwargs "E501", # line too long -> we accept long comment lines; formatter gets rid of long code lines "E731", # Do not assign a lambda expression, use a def -> AnnData allows lambda expression assignments, "E741", # allow I, O, l as variable names -> I is the identity matrix, i, j, k, l is reasonable indexing notation "TID252", # We use relative imports from parent modules "PLC0415", # We use a lot of non-top-level imports "PLR2004", # “2” is often not too “magic” a number "PLW2901", # Shadowing loop variables isn’t a big deal ] allowed-confusables = [ "×", "’", "–", "α" ] [tool.ruff.lint.per-file-ignores] # E721 comparing types, but we specifically are checking that we aren't getting subtypes (views) "tests/test_readwrite.py" = [ "E721" ] # PLR0913, PLR0917: tests can use a lot of “arguments” that are actually fixtures "tests/**/*.py" = [ "PLR0913", "PLR0917" ] [tool.ruff.lint.isort] known-first-party = [ "anndata" ] required-imports = [ "from __future__ import annotations" ] [tool.ruff.lint.flake8-bugbear] extend-immutable-calls = [ "slice" ] [tool.ruff.lint.flake8-tidy-imports.banned-api] "subprocess.call".msg = "Use `subprocess.run([…])` instead" "subprocess.check_call".msg = "Use `subprocess.run([…], check=True)` instead" "subprocess.check_output".msg = "Use `subprocess.run([…], check=True, capture_output=True)` instead" "legacy_api_wrap.legacy_api".msg = "Use anndata.compat.old_positionals instead" [tool.ruff.lint.flake8-type-checking] exempt-modules = [ ] strict = true [tool.ruff.lint.pylint] max-args = 7 max-positional-args = 5 [tool.codespell] skip = ".git,*.pdf,*.svg" ignore-words-list = "theis,coo,homogenous" [tool.towncrier] package = "anndata" directory = "docs/release-notes" filename = "docs/release-notes/{version}.md" single_file = false package_dir = "src" issue_format = "{{pr}}`{issue}`" title_format = "(v{version})=\n### {version} {{small}}`{project_date}`" # Valid fragments should be a subset of conventional commit types (except for `breaking`): # https://github.com/commitizen/conventional-commit-types/blob/master/index.json # style, refactor, test, build, ci: should not go into changelog fragment.feat.name = "Features" fragment.fix.name = "Bug fixes" fragment.docs.name = "Documentation" fragment.perf.name = "Performance" fragment.chore.name = "Miscellaneous changes" fragment.revert.name = "Revert" fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) scverse-anndata-b796d59/src/000077500000000000000000000000001512025555600157405ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/000077500000000000000000000000001512025555600173465ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/__init__.py000066400000000000000000000032561512025555600214650ustar00rootroot00000000000000"""Annotated multivariate observation data.""" from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any from ._core.anndata import AnnData from ._core.extensions import register_anndata_namespace from ._core.merge import concat from ._core.raw import Raw from ._settings import settings from ._warnings import ( ExperimentalFeatureWarning, ImplicitModificationWarning, OldFormatWarning, WriteWarning, ) from .io import read_h5ad, read_zarr from .utils import module_get_attr_redirect # Submodules need to be imported last from . import abc, experimental, typing, io, types # isort: skip # We use these in tests by attribute access from . import logging # noqa: F401 # isort: skip __all__ = [ "AnnData", "ExperimentalFeatureWarning", "ImplicitModificationWarning", "OldFormatWarning", "Raw", "WriteWarning", "abc", "concat", "experimental", "io", "read_h5ad", "read_zarr", "register_anndata_namespace", "settings", "types", "typing", ] _DEPRECATED_IO = ( "read_loom", "read_hdf", "read_excel", "read_umi_tools", "read_csv", "read_text", "read_mtx", ) _DEPRECATED = {method: f"io.{method}" for method in _DEPRECATED_IO} def __getattr__(attr_name: str) -> Any: if attr_name == "__version__": import warnings from importlib.metadata import version msg = "`__version__` is deprecated, use `importlib.metadata.version('anndata')` instead." warnings.warn(msg, FutureWarning, stacklevel=2) return version("anndata") return module_get_attr_redirect(attr_name, deprecated_mapping=_DEPRECATED) scverse-anndata-b796d59/src/anndata/_core/000077500000000000000000000000001512025555600204355ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/_core/__init__.py000066400000000000000000000000001512025555600225340ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/_core/access.py000066400000000000000000000015511512025555600222520ustar00rootroot00000000000000from __future__ import annotations from functools import reduce from typing import TYPE_CHECKING, NamedTuple if TYPE_CHECKING: from anndata import AnnData class ElementRef(NamedTuple): parent: AnnData attrname: str keys: tuple[str, ...] = () def __str__(self) -> str: return f".{self.attrname}" + "".join(f"[{x!r}]" for x in self.keys) @property def _parent_el(self): return reduce( lambda d, k: d[k], self.keys[:-1], getattr(self.parent, self.attrname) ) def get(self): """Get referenced value in self.parent.""" return reduce(lambda d, k: d[k], self.keys, getattr(self.parent, self.attrname)) def set(self, val): """Set referenced value in self.parent.""" self._parent_el[self.keys[-1]] = val def delete(self): del self._parent_el[self.keys[-1]] scverse-anndata-b796d59/src/anndata/_core/aligned_df.py000066400000000000000000000102661512025555600230700ustar00rootroot00000000000000from __future__ import annotations import warnings from collections.abc import Mapping from functools import singledispatch from typing import TYPE_CHECKING import pandas as pd from pandas.api.types import is_string_dtype from .._warnings import ImplicitModificationWarning from ..compat import XDataset, pandas_as_str from .xarray import Dataset2D if TYPE_CHECKING: from collections.abc import Iterable from typing import Any, Literal @singledispatch def _gen_dataframe( anno: Any, index_names: Iterable[str], *, source: Literal["X", "shape"], attr: Literal["obs", "var"], length: int | None = None, ) -> pd.DataFrame: # pragma: no cover msg = f"Cannot convert {type(anno)} to {attr} DataFrame" raise ValueError(msg) @_gen_dataframe.register(Mapping) @_gen_dataframe.register(type(None)) def _gen_dataframe_mapping( anno: Mapping[str, Any] | None, index_names: Iterable[str], *, source: Literal["X", "shape"], attr: Literal["obs", "var"], length: int | None = None, ) -> pd.DataFrame: if anno is None or len(anno) == 0: anno = {} def mk_index(l: int) -> pd.Index: return pd.RangeIndex(0, l, name=None).astype(str) for index_name in index_names: if index_name not in anno: continue df = pd.DataFrame( anno, index=anno[index_name], columns=[k for k in anno if k != index_name], ) break else: df = pd.DataFrame( anno, index=None if length is None else mk_index(length), columns=None if anno else pd.array([], dtype="str"), ) if length is None: df.index = mk_index(len(df)) elif length != len(df): raise _mk_df_error(source, attr, length, len(df)) return df @_gen_dataframe.register(pd.DataFrame) def _gen_dataframe_df( anno: pd.DataFrame, index_names: Iterable[str], *, source: Literal["X", "shape"], attr: Literal["obs", "var"], length: int | None = None, ): if isinstance(anno.index, pd.MultiIndex): msg = ( "pandas.MultiIndex not supported as index for obs or var on declaration.\n\ You can set `obs_names` manually although most operations after will error or convert to str.\n\ This behavior will likely be clarified in a future breaking release." ) raise ValueError(msg) if length is not None and length != len(anno): raise _mk_df_error(source, attr, length, len(anno)) anno = anno.copy(deep=False) if not is_string_dtype(anno.index[~anno.index.isna()]): msg = "Transforming to str index." warnings.warn(msg, ImplicitModificationWarning, stacklevel=2) anno.index = pandas_as_str(anno.index) if not len(anno.columns): anno.columns = pandas_as_str(anno.columns) return anno @_gen_dataframe.register(pd.Series) @_gen_dataframe.register(pd.Index) def _gen_dataframe_1d( anno: pd.Series | pd.Index, index_names: Iterable[str], *, source: Literal["X", "shape"], attr: Literal["obs", "var"], length: int | None = None, ): msg = f"Cannot convert {type(anno)} to {attr} DataFrame" raise ValueError(msg) def _mk_df_error( source: Literal["X", "shape"], attr: Literal["obs", "var"], expected: int, actual: int, ): what = "row" if attr == "obs" else "column" if source == "X": msg = ( f"Observations annot. `{attr}` must have as many rows as `X` has {what}s " f"({expected}), but has {actual} rows." ) else: msg = ( f"`shape` is inconsistent with `{attr}` " f"({actual} {what}s instead of {expected})" ) return ValueError(msg) @_gen_dataframe.register(Dataset2D) def _gen_dataframe_xr( anno: Dataset2D, index_names: Iterable[str], *, source: Literal["X", "shape"], attr: Literal["obs", "var"], length: int | None = None, ): return anno @_gen_dataframe.register(XDataset) def _gen_dataframe_xdataset( anno: XDataset, index_names: Iterable[str], *, source: Literal["X", "shape"], attr: Literal["obs", "var"], length: int | None = None, ): return Dataset2D(anno) scverse-anndata-b796d59/src/anndata/_core/aligned_mapping.py000066400000000000000000000336611512025555600241360ustar00rootroot00000000000000from __future__ import annotations import warnings from abc import ABC, abstractmethod from collections.abc import MutableMapping, Sequence from copy import copy from dataclasses import dataclass from typing import TYPE_CHECKING, Generic, TypeVar import numpy as np import pandas as pd from .._warnings import ExperimentalFeatureWarning, ImplicitModificationWarning from ..compat import AwkArray, CSArray, CSMatrix, CupyArray, XDataset from ..utils import ( axis_len, convert_to_dict, deprecated, raise_value_error_if_multiindex_columns, warn_once, ) from .access import ElementRef from .index import _subset from .storage import coerce_array from .views import as_view, view_update from .xarray import Dataset2D if TYPE_CHECKING: from collections.abc import Callable, Iterable, Iterator, Mapping from typing import ClassVar, Literal, Self from .anndata import AnnData from .raw import Raw OneDIdx = Sequence[int] | Sequence[bool] | slice TwoDIdx = tuple[OneDIdx, OneDIdx] # TODO: pd.DataFrame only allowed in AxisArrays? Value = pd.DataFrame | CSMatrix | CSArray | np.ndarray P = TypeVar("P", bound="AlignedMappingBase") """Parent mapping an AlignedView is based on.""" I = TypeVar("I", OneDIdx, TwoDIdx) class AlignedMappingBase(MutableMapping[str, Value], ABC): """\ An abstract base class for Mappings containing array-like values aligned to either one or both AnnData axes. """ _allow_df: ClassVar[bool] """If this mapping supports heterogeneous DataFrames""" _view_class: ClassVar[type[AlignedView]] """The view class for this aligned mapping.""" _actual_class: ClassVar[type[AlignedActual]] """The actual class (which has it’s own data) for this aligned mapping.""" _parent: AnnData | Raw """The parent object that this mapping is aligned to.""" def __repr__(self): return f"{type(self).__name__} with keys: {', '.join(self.keys())}" def _ipython_key_completions_(self) -> list[str]: return list(self.keys()) def _validate_value(self, val: Value, key: str) -> Value: """Raises an error if value is invalid""" if isinstance(val, AwkArray): warn_once( "Support for Awkward Arrays is currently experimental. " "Behavior may change in the future. Please report any issues you may encounter!", ExperimentalFeatureWarning, # stacklevel=3, ) elif isinstance(val, np.ndarray | CupyArray) and len(val.shape) == 1: val = val.reshape((val.shape[0], 1)) elif isinstance(val, XDataset): val = Dataset2D(val) for i, axis in enumerate(self.axes): if self.parent.shape[axis] == axis_len(val, i): continue right_shape = tuple(self.parent.shape[a] for a in self.axes) actual_shape = tuple(axis_len(val, a) for a, _ in enumerate(self.axes)) if actual_shape[i] is None and isinstance(val, AwkArray): dim = ("obs", "var")[i] msg = ( f"The AwkwardArray is of variable length in dimension {dim}.", f"Try ak.to_regular(array, {i}) before including the array in AnnData", ) else: dims = tuple(("obs", "var")[ax] for ax in self.axes) msg = ( f"Value passed for key {key!r} is of incorrect shape. " f"Values of {self.attrname} must match dimensions {dims} of parent. " f"Value had shape {actual_shape} while it should have had {right_shape}." ) raise ValueError(msg) name = f"{self.attrname.title().rstrip('s')} {key!r}" return coerce_array(val, name=name, allow_df=self._allow_df) @property @abstractmethod def attrname(self) -> str: """What attr for the AnnData is this?""" @property @abstractmethod def axes(self) -> tuple[Literal[0, 1], ...]: """Which axes of the parent is this aligned to?""" @property @abstractmethod def is_view(self) -> bool: ... @property def parent(self) -> AnnData | Raw: return self._parent def copy(self) -> dict[str, Value]: # Shallow copy for awkward array since their buffers are immutable return { k: copy(v) if isinstance(v, AwkArray) else v.copy() for k, v in self.items() } def _view(self, parent: AnnData, subset_idx: I) -> AlignedView[Self, I]: """Returns a subset copy-on-write view of the object.""" return self._view_class(self, parent, subset_idx) @deprecated("dict(obj)") def as_dict(self) -> dict: return dict(self) class AlignedView(AlignedMappingBase, Generic[P, I]): is_view: ClassVar[Literal[True]] = True # override docstring parent: AnnData """Reference to parent AnnData view""" attrname: str """What attribute in the parent is this?""" parent_mapping: P """The object this is a view of.""" subset_idx: I """The subset of the parent to view.""" def __init__(self, parent_mapping: P, parent_view: AnnData, subset_idx: I): self.parent_mapping = parent_mapping self._parent = parent_view self.subset_idx = subset_idx if hasattr(parent_mapping, "_axis"): # LayersBase has no _axis, the rest does self._axis = parent_mapping._axis # type: ignore def __getitem__(self, key: str) -> Value: return as_view( _subset(self.parent_mapping[key], self.subset_idx), ElementRef(self.parent, self.attrname, (key,)), ) def __setitem__(self, key: str, value: Value) -> None: value = self._validate_value(value, key) # Validate before mutating warnings.warn( f"Setting element `.{self.attrname}['{key}']` of view, " "initializing view as actual.", ImplicitModificationWarning, stacklevel=2, ) with view_update(self.parent, self.attrname, ()) as new_mapping: new_mapping[key] = value def __delitem__(self, key: str) -> None: if key not in self: msg = f"{key!r} not found in view of {self.attrname}" raise KeyError(msg) # Make sure it exists before bothering with a copy warnings.warn( f"Removing element `.{self.attrname}['{key}']` of view, " "initializing view as actual.", ImplicitModificationWarning, stacklevel=2, ) with view_update(self.parent, self.attrname, ()) as new_mapping: del new_mapping[key] def __contains__(self, key: str) -> bool: return key in self.parent_mapping def __iter__(self) -> Iterator[str]: return iter(self.parent_mapping) def __len__(self) -> int: return len(self.parent_mapping) class AlignedActual(AlignedMappingBase): is_view: ClassVar[Literal[False]] = False _data: MutableMapping[str, Value] """Underlying mapping to the data""" def __init__(self, parent: AnnData | Raw, *, store: MutableMapping[str, Value]): self._parent = parent self._data = store for k, v in self._data.items(): self._data[k] = self._validate_value(v, k) def __getitem__(self, key: str) -> Value: return self._data[key] def __setitem__(self, key: str, value: Value): value = self._validate_value(value, key) self._data[key] = value def __contains__(self, key: str) -> bool: return key in self._data def __delitem__(self, key: str): del self._data[key] def __iter__(self) -> Iterator[str]: return iter(self._data) def __len__(self) -> int: return len(self._data) class AxisArraysBase(AlignedMappingBase): """\ Mapping of key→array-like, where array-like is aligned to an axis of parent AnnData. """ _allow_df: ClassVar = True _dimnames: ClassVar = ("obs", "var") _axis: Literal[0, 1] @property def attrname(self) -> str: return f"{self.dim}m" @property def axes(self) -> tuple[Literal[0, 1]]: """Axes of the parent this is aligned to""" return (self._axis,) @property def dim(self) -> str: """Name of the dimension this aligned to.""" return self._dimnames[self._axis] def to_df(self) -> pd.DataFrame: """Convert to pandas dataframe.""" df = pd.DataFrame(index=self.dim_names) for key in self.keys(): value = self[key] for icolumn, column in enumerate(value.T): df[f"{key}{icolumn + 1}"] = column return df def _validate_value(self, val: Value, key: str) -> Value: if isinstance(val, pd.DataFrame): raise_value_error_if_multiindex_columns(val, f"{self.attrname}[{key!r}]") if not val.index.equals(self.dim_names): # Could probably also re-order index if it’s contained try: pd.testing.assert_index_equal(val.index, self.dim_names) except AssertionError as e: msg = f"value.index does not match parent’s {self.dim} names:\n{e}" raise ValueError(msg) from None else: msg = "Index.equals and pd.testing.assert_index_equal disagree" raise AssertionError(msg) val.index.name = ( self.dim_names.name ) # this is consistent with AnnData.obsm.setter and AnnData.varm.setter return super()._validate_value(val, key) @property def dim_names(self) -> pd.Index: return (self.parent.obs_names, self.parent.var_names)[self._axis] class AxisArrays(AlignedActual, AxisArraysBase): def __init__( self, parent: AnnData | Raw, *, axis: Literal[0, 1], store: MutableMapping[str, Value] | AxisArraysBase, ): if axis not in {0, 1}: raise ValueError() self._axis = axis super().__init__(parent, store=store) class AxisArraysView(AlignedView[AxisArraysBase, OneDIdx], AxisArraysBase): pass AxisArraysBase._view_class = AxisArraysView AxisArraysBase._actual_class = AxisArrays class LayersBase(AlignedMappingBase): """\ Mapping of key: array-like, where array-like is aligned to both axes of the parent anndata. """ _allow_df: ClassVar = False attrname: ClassVar[Literal["layers"]] = "layers" axes: ClassVar[tuple[Literal[0], Literal[1]]] = (0, 1) class Layers(AlignedActual, LayersBase): pass class LayersView(AlignedView[LayersBase, TwoDIdx], LayersBase): pass LayersBase._view_class = LayersView LayersBase._actual_class = Layers class PairwiseArraysBase(AlignedMappingBase): """\ Mapping of key: array-like, where both axes of array-like are aligned to one axis of the parent anndata. """ _allow_df: ClassVar = False _dimnames: ClassVar = ("obs", "var") _axis: Literal[0, 1] @property def attrname(self) -> str: return f"{self.dim}p" @property def axes(self) -> tuple[Literal[0], Literal[0]] | tuple[Literal[1], Literal[1]]: """Axes of the parent this is aligned to""" return self._axis, self._axis # type: ignore @property def dim(self) -> str: """Name of the dimension this aligned to.""" return self._dimnames[self._axis] class PairwiseArrays(AlignedActual, PairwiseArraysBase): def __init__( self, parent: AnnData, *, axis: Literal[0, 1], store: MutableMapping[str, Value], ): if axis not in {0, 1}: raise ValueError() self._axis = axis super().__init__(parent, store=store) class PairwiseArraysView(AlignedView[PairwiseArraysBase, OneDIdx], PairwiseArraysBase): pass PairwiseArraysBase._view_class = PairwiseArraysView PairwiseArraysBase._actual_class = PairwiseArrays AlignedMapping = ( AxisArrays | AxisArraysView | Layers | LayersView | PairwiseArrays | PairwiseArraysView ) T = TypeVar("T", bound=AlignedMapping) """Pair of types to be aligned.""" @dataclass class AlignedMappingProperty(property, Generic[T]): """A :class:`property` that creates an ephemeral AlignedMapping. The actual data is stored as `f'_{self.name}'` in the parent object. """ name: str """Name of the attribute in the parent object.""" cls: type[T] """Concrete type that will be constructed.""" axis: Literal[0, 1] | None = None """Axis of the parent to align to.""" def construct(self, obj: AnnData, *, store: MutableMapping[str, Value]) -> T: if self.axis is None: return self.cls(obj, store=store) return self.cls(obj, axis=self.axis, store=store) @property def fget(self) -> Callable[[], None]: """Fake fget for sphinx-autodoc-typehints.""" def fake(): ... fake.__annotations__ = {"return": self.cls._actual_class | self.cls._view_class} return fake def __get__(self, obj: None | AnnData, objtype: type | None = None) -> T: if obj is None: # When accessed from the class, e.g. via `AnnData.obs`, # this needs to return a `property` instance, e.g. for Sphinx return self # type: ignore if not obj.is_view: return self.construct(obj, store=getattr(obj, f"_{self.name}")) parent_anndata = obj._adata_ref idxs = (obj._oidx, obj._vidx) parent: AlignedMapping = getattr(parent_anndata, self.name) return parent._view(obj, tuple(idxs[ax] for ax in parent.axes)) def __set__( self, obj: AnnData, value: Mapping[str, Value] | Iterable[tuple[str, Value]] ) -> None: value = convert_to_dict(value) _ = self.construct(obj, store=value) # Validate if obj.is_view: obj._init_as_actual(obj.copy()) setattr(obj, f"_{self.name}", value) def __delete__(self, obj) -> None: setattr(obj, self.name, dict()) scverse-anndata-b796d59/src/anndata/_core/anndata.py000066400000000000000000002326541512025555600224310ustar00rootroot00000000000000"""\ Main class and helper functions. """ from __future__ import annotations import warnings from collections import OrderedDict from collections.abc import Mapping, MutableMapping, Sequence from copy import copy, deepcopy from functools import partial, singledispatchmethod from pathlib import Path from textwrap import dedent from typing import TYPE_CHECKING, cast import h5py import numpy as np import pandas as pd from natsort import natsorted from numpy import ma from pandas.api.types import infer_dtype from scipy import sparse from scipy.sparse import issparse from anndata._warnings import ImplicitModificationWarning from .. import utils from .._settings import settings from ..compat import ( CSArray, DaskArray, ZarrArray, _move_adj_mtx, old_positionals, pandas_as_str, ) from ..logging import anndata_logger as logger from ..utils import ( axis_len, deprecated, ensure_df_homogeneous, raise_value_error_if_multiindex_columns, ) from .access import ElementRef from .aligned_df import _gen_dataframe from .aligned_mapping import AlignedMappingProperty, AxisArrays, Layers, PairwiseArrays from .file_backing import AnnDataFileManager, to_memory from .index import _normalize_indices, _subset, get_vector from .raw import Raw from .sparse_dataset import BaseCompressedSparseDataset, sparse_dataset from .storage import coerce_array from .views import DictView, _resolve_idxs, as_view from .xarray import Dataset2D if TYPE_CHECKING: from collections.abc import Iterable from os import PathLike from typing import Any, ClassVar, Literal, NoReturn from zarr.storage import StoreLike from ..compat import Index1D, Index1DNorm, XDataset from ..typing import XDataType from .aligned_mapping import AxisArraysView, LayersView, PairwiseArraysView from .index import Index class AnnData(metaclass=utils.DeprecationMixinMeta): # noqa: PLW1641 """\ An annotated data matrix. .. figure:: ../_static/img/anndata_schema.svg :width: 260px :align: right :class: dark-light :class:`~anndata.AnnData` stores a data matrix :attr:`X` together with annotations of observations :attr:`obs` (:attr:`obsm`, :attr:`obsp`), variables :attr:`var` (:attr:`varm`, :attr:`varp`), and unstructured annotations :attr:`uns`. An :class:`~anndata.AnnData` object `adata` can be sliced like a :class:`~pandas.DataFrame`, for instance `adata_subset = adata[:, list_of_variable_names]`. :class:`~anndata.AnnData`’s basic structure is similar to R’s ExpressionSet [Huber15]_. If setting an `.h5ad`-formatted HDF5 backing file `.filename`, data remains on the disk but is automatically loaded into memory if needed. Parameters ---------- X A #observations × #variables data matrix. A view of the data is used if the data type matches, otherwise, a copy is made. obs Key-indexed one-dimensional observations annotation of length #observations. var Key-indexed one-dimensional variables annotation of length #variables. uns Key-indexed unstructured annotation. obsm Key-indexed multi-dimensional observations annotation of length #observations. If passing a :class:`~numpy.ndarray`, it needs to have a structured datatype. varm Key-indexed multi-dimensional variables annotation of length #variables. If passing a :class:`~numpy.ndarray`, it needs to have a structured datatype. layers Key-indexed multi-dimensional arrays aligned to dimensions of `X`. shape Shape tuple (#observations, #variables). Can only be provided if `X` is `None`. filename Name of backing file. See :class:`h5py.File`. filemode Open mode of backing file. See :class:`h5py.File`. See Also -------- io.read_h5ad io.read_csv io.read_excel io.read_hdf io.read_loom io.read_zarr io.read_mtx io.read_text io.read_umi_tools Notes ----- :class:`~anndata.AnnData` stores observations (samples) of variables/features in the rows of a matrix. This is the convention of the modern classics of statistics [Hastie09]_ and machine learning [Murphy12]_, the convention of dataframes both in R and Python and the established statistics and machine learning packages in Python (statsmodels_, scikit-learn_). Single dimensional annotations of the observation and variables are stored in the :attr:`obs` and :attr:`var` attributes as :class:`~pandas.DataFrame`\\ s. This is intended for metrics calculated over their axes. Multi-dimensional annotations are stored in :attr:`obsm` and :attr:`varm`, which are aligned to the objects observation and variable dimensions respectively. Square matrices representing graphs are stored in :attr:`obsp` and :attr:`varp`, with both of their own dimensions aligned to their associated axis. Additional measurements across both observations and variables are stored in :attr:`layers`. Indexing into an AnnData object can be performed by relative position with numeric indices (like pandas’ :meth:`~pandas.DataFrame.iloc`), or by labels (like :meth:`~pandas.DataFrame.loc`). To avoid ambiguity with numeric indexing into observations or variables, indexes of the AnnData object are converted to strings by the constructor. Subsetting an AnnData object by indexing into it will also subset its elements according to the dimensions they were aligned to. This means an operation like `adata[list_of_obs, :]` will also subset :attr:`obs`, :attr:`obsm`, and :attr:`layers`. Subsetting an AnnData object returns a view into the original object, meaning very little additional memory is used upon subsetting. This is achieved lazily, meaning that the constituent arrays are subset on access. Copying a view causes an equivalent “real” AnnData object to be generated. Attempting to modify a view (at any attribute except X) is handled in a copy-on-modify manner, meaning the object is initialized in place. Here’s an example:: batch1 = adata[adata.obs["batch"] == "batch1", :] batch1.obs["value"] = 0 # This makes batch1 a “real” AnnData object At the end of this snippet: `adata` was not modified, and `batch1` is its own AnnData object with its own data. Similar to Bioconductor’s `ExpressionSet` and :mod:`scipy.sparse` matrices, subsetting an AnnData object retains the dimensionality of its constituent arrays. Therefore, unlike with the classes exposed by :mod:`pandas`, :mod:`numpy`, and `xarray`, there is no concept of a one dimensional AnnData object. AnnDatas always have two inherent dimensions, :attr:`obs` and :attr:`var`. Additionally, maintaining the dimensionality of the AnnData object allows for consistent handling of :mod:`scipy.sparse` matrices and :mod:`numpy` arrays. .. _statsmodels: http://www.statsmodels.org/stable/index.html .. _scikit-learn: http://scikit-learn.org/ """ _BACKED_ATTRS: ClassVar[list[str]] = ["X", "raw.X"] # backwards compat _H5_ALIASES: ClassVar[dict[str, set[str]]] = dict( X={"X", "_X", "data", "_data"}, obs={"obs", "_obs", "smp", "_smp"}, var={"var", "_var"}, uns={"uns"}, obsm={"obsm", "_obsm", "smpm", "_smpm"}, varm={"varm", "_varm"}, layers={"layers", "_layers"}, ) _H5_ALIASES_NAMES: ClassVar[dict[str, set[str]]] = dict( obs={"obs_names", "smp_names", "row_names", "index"}, var={"var_names", "col_names", "index"}, ) _accessors: ClassVar[set[str]] = set() # view attributes _adata_ref: AnnData | None _oidx: Index1DNorm | None _vidx: Index1DNorm | None @old_positionals( "obsm", "varm", "layers", "raw", "dtype", "shape", "filename", "filemode", "asview", ) def __init__( # noqa: PLR0913 self, X: XDataType | pd.DataFrame | None = None, obs: pd.DataFrame | Mapping[str, Iterable[Any]] | None = None, var: pd.DataFrame | Mapping[str, Iterable[Any]] | None = None, uns: Mapping[str, Any] | None = None, *, obsm: np.ndarray | Mapping[str, Sequence[Any]] | None = None, varm: np.ndarray | Mapping[str, Sequence[Any]] | None = None, layers: Mapping[str, XDataType] | None = None, raw: Mapping[str, Any] | None = None, dtype: np.dtype | type | str | None = None, shape: tuple[int, int] | None = None, filename: PathLike[str] | str | None = None, filemode: Literal["r", "r+"] | None = None, asview: bool = False, obsp: np.ndarray | Mapping[str, Sequence[Any]] | None = None, varp: np.ndarray | Mapping[str, Sequence[Any]] | None = None, oidx: Index1DNorm | int | np.integer | None = None, vidx: Index1DNorm | int | np.integer | None = None, ): # check for any multi-indices that aren’t later checked in coerce_array for attr, key in [(obs, "obs"), (var, "var"), (X, "X")]: if isinstance(attr, pd.DataFrame): raise_value_error_if_multiindex_columns(attr, key) if asview: if not isinstance(X, AnnData): msg = "`X` has to be an AnnData object." raise ValueError(msg) assert oidx is not None assert vidx is not None self._init_as_view(X, oidx, vidx) else: self._init_as_actual( X=X, obs=obs, var=var, uns=uns, obsm=obsm, varm=varm, raw=raw, layers=layers, dtype=dtype, shape=shape, obsp=obsp, varp=varp, filename=filename, filemode=filemode, ) def _init_as_view( self, adata_ref: AnnData, oidx: Index1DNorm | int | np.integer, vidx: Index1DNorm | int | np.integer, ): if adata_ref.isbacked and adata_ref.is_view: msg = ( "Currently, you cannot index repeatedly into a backed AnnData, " "that is, you cannot make a view of a view." ) raise ValueError(msg) self._is_view = True if isinstance(oidx, int | np.integer): if not (-adata_ref.n_obs <= oidx < adata_ref.n_obs): msg = f"Observation index `{oidx}` is out of range." raise IndexError(msg) oidx += adata_ref.n_obs * (oidx < 0) oidx = slice(oidx, oidx + 1, 1) if isinstance(vidx, int | np.integer): if not (-adata_ref.n_vars <= vidx < adata_ref.n_vars): msg = f"Variable index `{vidx}` is out of range." raise IndexError(msg) vidx += adata_ref.n_vars * (vidx < 0) vidx = slice(vidx, vidx + 1, 1) if adata_ref.is_view: assert adata_ref._adata_ref is not None assert adata_ref._oidx is not None assert adata_ref._vidx is not None prev_oidx, prev_vidx = adata_ref._oidx, adata_ref._vidx adata_ref = adata_ref._adata_ref oidx, vidx = _resolve_idxs((prev_oidx, prev_vidx), (oidx, vidx), adata_ref) # self._adata_ref is never a view self._adata_ref = adata_ref self._oidx = oidx self._vidx = vidx # the file is the same as of the reference object self.file = adata_ref.file # views on attributes of adata_ref obs_sub = adata_ref.obs.iloc[oidx] var_sub = adata_ref.var.iloc[vidx] # fix categories uns = copy(adata_ref._uns) if settings.remove_unused_categories: self._remove_unused_categories(adata_ref.obs, obs_sub, uns) self._remove_unused_categories(adata_ref.var, var_sub, uns) # set attributes self._obs = as_view(obs_sub, view_args=(self, "obs")) self._var = as_view(var_sub, view_args=(self, "var")) self._uns = uns # set data if self.isbacked: self._X = None # set raw, easy, as it’s immutable anyways... if adata_ref._raw is not None: # slicing along variables axis is ignored self._raw = adata_ref.raw[oidx] self._raw._adata = self else: self._raw = None def _init_as_actual( # noqa: PLR0912, PLR0913, PLR0915 self, X=None, *, obs=None, var=None, uns=None, obsm=None, varm=None, varp=None, obsp=None, raw=None, layers=None, dtype=None, shape=None, filename=None, filemode=None, ): # view attributes self._is_view = False self._adata_ref = None self._oidx = None self._vidx = None # ---------------------------------------------------------------------- # various ways of initializing the data # ---------------------------------------------------------------------- # If X is a data frame, we store its indices for verification x_indices = [] # init from file if filename is not None: self.file = AnnDataFileManager(self, filename, filemode) else: self.file = AnnDataFileManager(self, None) # init from AnnData if isinstance(X, AnnData): if any((obs, var, uns, obsm, varm, obsp, varp)): msg = "If `X` is a dict no further arguments must be provided." raise ValueError(msg) X, obs, var, uns, obsm, varm, obsp, varp, layers, raw = ( X._X, X.obs, X.var, X.uns, X.obsm, X.varm, X.obsp, X.varp, X.layers, X.raw, ) # init from DataFrame elif isinstance(X, pd.DataFrame): # to verify index matching, we wait until obs and var are DataFrames if obs is None: obs = pd.DataFrame(index=X.index) elif not isinstance(X.index, pd.RangeIndex): x_indices.append(("obs", "index", pandas_as_str(X.index))) if var is None: var = pd.DataFrame(index=X.columns) elif not isinstance(X.columns, pd.RangeIndex): x_indices.append(("var", "columns", pandas_as_str(X.columns))) X = ensure_df_homogeneous(X, "X") # ---------------------------------------------------------------------- # actually process the data # ---------------------------------------------------------------------- # check data type of X if X is not None: X = coerce_array(X, name="X") if shape is not None: msg = "`shape` needs to be `None` if `X` is not `None`." raise ValueError(msg) _check_2d_shape(X) # if type doesn’t match, a copy is made, otherwise, use a view if dtype is not None: msg = ( "The dtype argument is deprecated and will be removed in late 2024." ) warnings.warn(msg, FutureWarning, stacklevel=3) if issparse(X) or isinstance(X, ma.MaskedArray): # TODO: maybe use view on data attribute of sparse matrix # as in readwrite.read_10x_h5 if X.dtype != np.dtype(dtype): X = X.astype(dtype) elif isinstance(X, ZarrArray | DaskArray): X = X.astype(dtype) else: # is np.ndarray or a subclass, convert to true np.ndarray X = np.asarray(X, dtype) # data matrix and shape self._X = X n_obs, n_vars = X.shape source = "X" else: self._X = None n_obs, n_vars = ( shape if shape is not None else _infer_shape( obs, var, obsm=obsm, varm=varm, layers=layers, obsp=obsp, varp=varp ) ) source = "shape" # annotations self._obs = _gen_dataframe( obs, ["obs_names", "row_names"], source=source, attr="obs", length=n_obs ) self._var = _gen_dataframe( var, ["var_names", "col_names"], source=source, attr="var", length=n_vars ) # now we can verify if indices match! for attr_name, x_name, idx in x_indices: attr = getattr(self, attr_name) if isinstance(attr.index, pd.RangeIndex): attr.index = idx elif not idx.equals(attr.index): msg = f"Index of {attr_name} must match {x_name} of X." raise ValueError(msg) # unstructured annotations self.uns = uns or OrderedDict() self.obsm = obsm self.varm = varm self.obsp = obsp self.varp = varp # Backwards compat for connectivities matrices in uns["neighbors"] _move_adj_mtx({"uns": self._uns, "obsp": self._obsp}) self._check_dimensions() if settings.check_uniqueness: self._check_uniqueness() if self.filename: assert not isinstance(raw, Raw), ( "got raw from other adata but also filename?" ) if {"raw", "raw.X"} & set(self.file): raw = dict(X=None, **raw) if not raw: self._raw = None elif isinstance(raw, Mapping): self._raw = Raw(self, **raw) else: # is a Raw from another AnnData self._raw = Raw(self, raw._X, raw.var, raw.varm) # clean up old formats self._clean_up_old_format(uns) # layers self.layers = layers @old_positionals("show_stratified", "with_disk") def __sizeof__( self, *, show_stratified: bool = False, with_disk: bool = False ) -> int: def get_size(X) -> int: def cs_to_bytes(X) -> int: return int(X.data.nbytes + X.indptr.nbytes + X.indices.nbytes) if isinstance(X, h5py.Dataset) and with_disk: return int(np.array(X.shape).prod() * X.dtype.itemsize) elif isinstance(X, BaseCompressedSparseDataset) and with_disk: return cs_to_bytes(X._to_backed()) elif issparse(X): return cs_to_bytes(X) else: return X.__sizeof__() sizes = {} attrs = ["X", "_obs", "_var"] attrs_multi = ["_uns", "_obsm", "_varm", "varp", "_obsp", "_layers"] for attr in attrs + attrs_multi: if attr in attrs_multi: keys = getattr(self, attr).keys() s = sum(get_size(getattr(self, attr)[k]) for k in keys) else: s = get_size(getattr(self, attr)) if s > 0 and show_stratified: from tqdm import tqdm print( f"Size of {attr.replace('_', '.'):<7}: {tqdm.format_sizeof(s, 'B')}" ) sizes[attr] = s return sum(sizes.values()) def _gen_repr(self, n_obs, n_vars) -> str: backed_at = f" backed at {str(self.filename)!r}" if self.isbacked else "" descr = f"AnnData object with n_obs × n_vars = {n_obs} × {n_vars}{backed_at}" for attr in [ "obs", "var", "uns", "obsm", "varm", "layers", "obsp", "varp", ]: keys = getattr(self, attr).keys() if len(keys) > 0: descr += f"\n {attr}: {str(list(keys))[1:-1]}" return descr def __repr__(self) -> str: if self.is_view: return "View of " + self._gen_repr(self.n_obs, self.n_vars) else: return self._gen_repr(self.n_obs, self.n_vars) def __eq__(self, other): """Equality testing""" msg = ( "Equality comparisons are not supported for AnnData objects, " "instead compare the desired attributes." ) raise NotImplementedError(msg) @property def shape(self) -> tuple[int, int]: """Shape of data matrix (:attr:`n_obs`, :attr:`n_vars`).""" return self.n_obs, self.n_vars @property def X(self) -> XDataType | None: """Data matrix of shape :attr:`n_obs` × :attr:`n_vars`.""" if self.isbacked: if not self.file.is_open: self.file.open() X = self.file["X"] if isinstance(X, h5py.Group): X = sparse_dataset(X) # This is so that we can index into a backed dense dataset with # indices that aren’t strictly increasing if self.is_view: X = _subset(X, (self._oidx, self._vidx)) elif self.is_view and self._adata_ref.X is None: X = None elif self.is_view: X = as_view( _subset(self._adata_ref.X, (self._oidx, self._vidx)), ElementRef(self, "X"), ) else: X = self._X return X # if self.n_obs == 1 and self.n_vars == 1: # return X[0, 0] # elif self.n_obs == 1 or self.n_vars == 1: # if issparse(X): X = X.toarray() # return X.flatten() # else: # return X @X.setter def X(self, value: XDataType | None): # noqa: PLR0912 if value is None: if self.isbacked: msg = "Cannot currently remove data matrix from backed object." raise NotImplementedError(msg) if self.is_view: self._init_as_actual(self.copy()) self._X = None return value = coerce_array(value, name="X", allow_array_like=True) # If indices are both arrays, we need to modify them # so we don’t set values like coordinates # This can occur if there are successive views if ( self.is_view and isinstance(self._oidx, np.ndarray) and isinstance(self._vidx, np.ndarray) ): oidx, vidx = np.ix_(self._oidx, self._vidx) else: oidx, vidx = self._oidx, self._vidx if ( np.isscalar(value) or (hasattr(value, "shape") and (self.shape == value.shape)) or (self.n_vars == 1 and self.n_obs == len(value)) or (self.n_obs == 1 and self.n_vars == len(value)) ): if not np.isscalar(value): if self.is_view and any( isinstance(idx, np.ndarray) and len(np.unique(idx)) != len(idx.ravel()) for idx in [oidx, vidx] ): msg = ( "You are attempting to set `X` to a matrix on a view which has non-unique indices. " "The resulting `adata.X` will likely not equal the value to which you set it. " "To avoid this potential issue, please make a copy of the data first. " "In the future, this operation will throw an error." ) warnings.warn(msg, FutureWarning, stacklevel=1) if self.shape != value.shape: # For assigning vector of values to 2d array or matrix # Not necessary for row of 2d array value = value.reshape(self.shape) if self.isbacked: if self.is_view: X = self.file["X"] if isinstance(X, h5py.Group): X = sparse_dataset(X) X[oidx, vidx] = value else: self._set_backed("X", value) elif self.is_view: if sparse.issparse(self._adata_ref._X) and isinstance( value, np.ndarray ): if isinstance(self._adata_ref.X, CSArray): memory_class = sparse.coo_array else: memory_class = sparse.coo_matrix value = memory_class(value) elif sparse.issparse(value) and isinstance( self._adata_ref._X, np.ndarray ): warnings.warn( "Trying to set a dense array with a sparse array on a view." "Densifying the sparse array." "This may incur excessive memory usage", stacklevel=2, ) value = value.toarray() warnings.warn( "Modifying `X` on a view results in data being overridden", ImplicitModificationWarning, stacklevel=2, ) self._adata_ref._X[oidx, vidx] = value else: self._X = value else: msg = f"Data matrix has wrong shape {value.shape}, need to be {self.shape}." raise ValueError(msg) @X.deleter def X(self): self.X = None layers: AlignedMappingProperty[Layers | LayersView] = AlignedMappingProperty( "layers", Layers ) """\ Dictionary-like object with values of the same dimensions as :attr:`X`. Layers in AnnData are inspired by loompy’s :ref:`loomlayers`. Return the layer named `"unspliced"`:: adata.layers["unspliced"] Create or replace the `"spliced"` layer:: adata.layers["spliced"] = ... Assign the 10th column of layer `"spliced"` to the variable a:: a = adata.layers["spliced"][:, 10] Delete the `"spliced"` layer:: del adata.layers["spliced"] Return layers’ names:: adata.layers.keys() """ @property def raw(self) -> Raw: """\ Store raw version of :attr:`X` and :attr:`var` as `.raw.X` and `.raw.var`. The :attr:`raw` attribute is initialized with the current content of an object by setting:: adata.raw = adata.copy() Its content can be deleted:: adata.raw = None # or del adata.raw Upon slicing an AnnData object along the obs (row) axis, :attr:`raw` is also sliced. Slicing an AnnData object along the vars (columns) axis leaves :attr:`raw` unaffected. Note that you can call:: adata.raw[:, 'orig_variable_name'].X to retrieve the data associated with a variable that might have been filtered out or "compressed away" in :attr:`X`. """ return self._raw @raw.setter def raw(self, value: AnnData): if value is None: del self.raw elif not isinstance(value, AnnData): msg = "Can only init raw attribute with an AnnData object." raise ValueError(msg) else: if self.is_view: self._init_as_actual(self.copy()) self._raw = Raw(self, X=value.X, var=value.var, varm=value.varm) @raw.deleter def raw(self): if self.is_view: self._init_as_actual(self.copy()) self._raw = None @property def n_obs(self) -> int: """Number of observations.""" return len(self.obs_names) @property def n_vars(self) -> int: """Number of variables/features.""" return len(self.var_names) def _set_dim_df(self, value: pd.DataFrame | XDataset, attr: Literal["obs", "var"]): value = _gen_dataframe( value, [f"{attr}_names", f"{'row' if attr == 'obs' else 'col'}_names"], source="shape", attr=attr, length=self.n_obs if attr == "obs" else self.n_vars, ) raise_value_error_if_multiindex_columns(value, attr) value_idx = self._prep_dim_index(value.index, attr) if self.is_view: self._init_as_actual(self.copy()) setattr(self, f"_{attr}", value) self._set_dim_index(value_idx, attr) if not len(value.columns): value.columns = value.columns.astype(str) def _prep_dim_index(self, value, attr: str) -> pd.Index: """Prepares index to be uses as obs_names or var_names for AnnData object.AssertionError If a pd.Index is passed, this will use a reference, otherwise a new index object is created. """ if self.shape[attr == "var"] != len(value): msg = f"Length of passed value for {attr}_names is {len(value)}, but this AnnData has shape: {self.shape}" raise ValueError(msg) if isinstance(value, pd.Index) and not isinstance(value.name, str | type(None)): msg = ( f"AnnData expects .{attr}.index.name to be a string or None, " f"but you passed a name of type {type(value.name).__name__!r}" ) raise ValueError(msg) else: value = ( value if isinstance(value, pd.Index) else pandas_as_str(pd.Index(value)) ) if not isinstance(value.name, str | type(None)): value.name = None if ( len(value) > 0 and not isinstance(value, pd.RangeIndex) and infer_dtype(value) not in {"string", "bytes"} ): sample = list(value[: min(len(value), 5)]) msg = dedent( f""" AnnData expects .{attr}.index to contain strings, but got values like: {sample} Inferred to be: {infer_dtype(value)} """ ) warnings.warn(msg, stacklevel=2) return value def _set_dim_index(self, value: pd.Index, attr: str): # Assumes _prep_dim_index has been run if self.is_view: self._init_as_actual(self.copy()) getattr(self, attr).index = value for v in getattr(self, f"_{attr}m").values(): if isinstance(v, pd.DataFrame): v.index = value @property def obs(self) -> pd.DataFrame | Dataset2D: """One-dimensional annotation of observations (`pd.DataFrame`).""" return self._obs @obs.setter def obs(self, value: pd.DataFrame | XDataset): self._set_dim_df(value, "obs") @obs.deleter def obs(self): self.obs = pd.DataFrame({}, index=self.obs_names) @property def obs_names(self) -> pd.Index: """Names of observations (alias for `.obs.index`).""" return self.obs.index @obs_names.setter def obs_names(self, names: Sequence[str]): names = self._prep_dim_index(names, "obs") self._set_dim_index(names, "obs") @property def var(self) -> pd.DataFrame | Dataset2D: """One-dimensional annotation of variables/ features (`pd.DataFrame`).""" return self._var @var.setter def var(self, value: pd.DataFrame | XDataset): self._set_dim_df(value, "var") @var.deleter def var(self): self.var = pd.DataFrame({}, index=self.var_names) @property def var_names(self) -> pd.Index: """Names of variables (alias for `.var.index`).""" return self.var.index @var_names.setter def var_names(self, names: Sequence[str]): names = self._prep_dim_index(names, "var") self._set_dim_index(names, "var") @property def uns(self) -> MutableMapping: """Unstructured annotation (ordered dictionary).""" uns = self._uns if self.is_view: uns = DictView(uns, view_args=(self, "_uns")) return uns @uns.setter def uns(self, value: MutableMapping): if not isinstance(value, MutableMapping): msg = "Only mutable mapping types (e.g. dict) are allowed for `.uns`." raise ValueError(msg) if isinstance(value, DictView): value = value.copy() if self.is_view: self._init_as_actual(self.copy()) self._uns = value @uns.deleter def uns(self): self.uns = OrderedDict() obsm: AlignedMappingProperty[AxisArrays | AxisArraysView] = AlignedMappingProperty( "obsm", AxisArrays, 0 ) """\ Multi-dimensional annotation of observations (mutable structured :class:`~numpy.ndarray`). Stores for each key a two or higher-dimensional :class:`~numpy.ndarray` of length `n_obs`. Is sliced with `data` and `obs` but behaves otherwise like a :term:`mapping`. """ varm: AlignedMappingProperty[AxisArrays | AxisArraysView] = AlignedMappingProperty( "varm", AxisArrays, 1 ) """\ Multi-dimensional annotation of variables/features (mutable structured :class:`~numpy.ndarray`). Stores for each key a two or higher-dimensional :class:`~numpy.ndarray` of length `n_vars`. Is sliced with `data` and `var` but behaves otherwise like a :term:`mapping`. """ obsp: AlignedMappingProperty[PairwiseArrays | PairwiseArraysView] = ( AlignedMappingProperty("obsp", PairwiseArrays, 0) ) """\ Pairwise annotation of observations, a mutable mapping with array-like values. Stores for each key a two or higher-dimensional :class:`~numpy.ndarray` whose first two dimensions are of length `n_obs`. Is sliced with `data` and `obs` but behaves otherwise like a :term:`mapping`. """ varp: AlignedMappingProperty[PairwiseArrays | PairwiseArraysView] = ( AlignedMappingProperty("varp", PairwiseArrays, 1) ) """\ Pairwise annotation of variables/features, a mutable mapping with array-like values. Stores for each key a two or higher-dimensional :class:`~numpy.ndarray` whose first two dimensions are of length `n_var`. Is sliced with `data` and `var` but behaves otherwise like a :term:`mapping`. """ @deprecated("obs (e.g. `k in adata.obs` or `str(adata.obs.columns.tolist())`)") def obs_keys(self) -> list[str]: """List keys of observation annotation :attr:`obs`.""" return self._obs.keys().tolist() @deprecated("var (e.g. `k in adata.var` or `str(adata.var.columns.tolist())`)") def var_keys(self) -> list[str]: """List keys of variable annotation :attr:`var`.""" return self._var.keys().tolist() @deprecated("obsm (e.g. `k in adata.obsm` or `adata.obsm.keys() | {'u'}`)") def obsm_keys(self) -> list[str]: """List keys of observation annotation :attr:`obsm`.""" return list(self.obsm.keys()) @deprecated("varm (e.g. `k in adata.varm` or `adata.varm.keys() | {'u'}`)") def varm_keys(self) -> list[str]: """List keys of variable annotation :attr:`varm`.""" return list(self.varm.keys()) @deprecated("uns (e.g. `k in adata.uns` or `sorted(adata.uns)`)") def uns_keys(self) -> list[str]: """List keys of unstructured annotation.""" return sorted(self._uns.keys()) @property def isbacked(self) -> bool: """`True` if object is backed on disk, `False` otherwise.""" is_filename_none = self.filename is not None is_x_none = ( getattr(self._adata_ref if self._is_view else self, "_X", None) is None ) return is_filename_none and is_x_none @property def is_view(self) -> bool: """`True` if object is view of another AnnData object, `False` otherwise.""" return self._is_view @property def filename(self) -> Path | None: """\ Change to backing mode by setting the filename of a `.h5ad` file. - Setting the filename writes the stored data to disk. - Setting the filename when the filename was previously another name moves the backing file from the previous file to the new file. If you want to copy the previous file, use `copy(filename='new_filename')`. """ return self.file.filename @filename.setter def filename(self, filename: PathLike[str] | str | None): # convert early for later comparison filename = None if filename is None else Path(filename) # change from backing-mode back to full loading into memory if filename is None: if self.filename is not None: self.file._to_memory_mode() else: # both filename and self.filename are None # do nothing return else: if self.filename is not None: if self.filename != filename: # write the content of self to the old file # and close the file self.write() self.filename.rename(filename) else: # do nothing return else: # change from memory to backing-mode # write the content of self to disk as_dense = ("X", "raw/X") if self.raw is not None else ("X",) self.write(filename, as_dense=as_dense) # open new file for accessing self.file.open(filename, "r+") # as the data is stored on disk, we can safely set self._X to None self._X = None def _set_backed(self, attr, value): from .._io.utils import write_attribute write_attribute(self.file._file, attr, value) def _normalize_indices( self, index: Index | None ) -> tuple[Index1DNorm | int | np.integer, Index1DNorm | int | np.integer]: return _normalize_indices(index, self.obs_names, self.var_names) # TODO: this is not quite complete... def __delitem__(self, index: Index): obs, var = self._normalize_indices(index) # TODO: does this really work? if not self.isbacked: del self._X[obs, var] else: X = self.file["X"] del X[obs, var] self._set_backed("X", X) if var == slice(None): del self._obs.iloc[obs, :] if obs == slice(None): del self._var.iloc[var, :] def __getitem__(self, index: Index) -> AnnData: """Returns a sliced view of the object.""" oidx, vidx = self._normalize_indices(index) return AnnData(self, oidx=oidx, vidx=vidx, asview=True) @singledispatchmethod @staticmethod def _remove_unused_categories( df_full: pd.DataFrame, df_sub: pd.DataFrame, uns: dict[str, Any] ): for k in df_full: if not isinstance(df_full[k].dtype, pd.CategoricalDtype): continue all_categories = df_full[k].cat.categories # TODO: this mode is going away with pd.option_context("mode.chained_assignment", None): df_sub[k] = df_sub[k].cat.remove_unused_categories() # also correct the colors... color_key = f"{k}_colors" if color_key not in uns: continue color_vec = uns[color_key] if np.array(color_vec).ndim == 0: # Make 0D arrays into 1D ones uns[color_key] = np.array(color_vec)[(None,)] elif len(color_vec) != len(all_categories): # Reset colors del uns[color_key] else: idx = np.where(np.isin(all_categories, df_sub[k].cat.categories))[0] uns[color_key] = np.array(color_vec)[(idx,)] def rename_categories(self, key: str, categories: Sequence[Any]): """\ Rename categories of annotation `key` in :attr:`obs`, :attr:`var`, and :attr:`uns`. Only supports passing a list/array-like `categories` argument. Besides calling `self.obs[key].cat.categories = categories` – similar for :attr:`var` - this also renames categories in unstructured annotation that uses the categorical annotation `key`. Parameters ---------- key Key for observations or variables annotation. categories New categories, the same number as the old categories. """ if isinstance(categories, Mapping): msg = "Only list-like `categories` is supported." raise ValueError(msg) if key in self.obs: old_categories = self.obs[key].cat.categories.tolist() self.obs[key] = self.obs[key].cat.rename_categories(categories) elif key in self.var: old_categories = self.var[key].cat.categories.tolist() self.var[key] = self.var[key].cat.rename_categories(categories) else: msg = f"{key} is neither in `.obs` nor in `.var`." raise ValueError(msg) # this is not a good solution # but depends on the scanpy conventions for storing the categorical key # as `groupby` in the `params` slot for k1, v1 in self.uns.items(): if not ( isinstance(v1, Mapping) and "params" in v1 and "groupby" in v1["params"] and v1["params"]["groupby"] == key ): continue for k2, v2 in v1.items(): # picks out the recarrays that are named according to the old # categories if isinstance(v2, np.ndarray) and v2.dtype.names is not None: if list(v2.dtype.names) == old_categories: self.uns[k1][k2].dtype.names = categories else: logger.warning( f"Omitting {k1}/{k2} as old categories do not match." ) def strings_to_categoricals(self, df: pd.DataFrame | None = None): """\ Transform string annotations to categoricals. Only affects string annotations that lead to less categories than the total number of observations. Params ------ df If `df` is `None`, modifies both :attr:`obs` and :attr:`var`, otherwise modifies `df` inplace. Notes ----- Turns the view of an :class:`~anndata.AnnData` into an actual :class:`~anndata.AnnData`. """ dont_modify = False # only necessary for backed views if df is None: dfs = [self.obs, self.var] if self.is_view and self.isbacked: dont_modify = True else: dfs = [df] del df for df in dfs: string_cols = [ key for key in df.columns if infer_dtype(df[key]) == "string" ] for key in string_cols: c = pd.Categorical(df[key]) # TODO: We should only check if non-null values are unique, but # this would break cases where string columns with nulls could # be written as categorical, but not as string. # Possible solution: https://github.com/scverse/anndata/issues/504 if len(c.categories) >= len(c): continue # Ideally this could be done inplace sorted_categories = natsorted(c.categories) if not np.array_equal(c.categories, sorted_categories): c = c.reorder_categories(sorted_categories) if dont_modify: msg = ( "Please call `.strings_to_categoricals()` on full " "AnnData, not on this view. You might encounter this" "error message while copying or writing to disk." ) raise RuntimeError(msg) df[key] = c logger.info(f"... storing {key!r} as categorical") _sanitize = strings_to_categoricals # backwards compat def _inplace_subset_var(self, index: Index1D): """\ Inplace subsetting along variables dimension. Same as `adata = adata[:, index]`, but inplace. """ adata_subset = self[:, index].copy() self._init_as_actual(adata_subset) def _inplace_subset_obs(self, index: Index1D): """\ Inplace subsetting along variables dimension. Same as `adata = adata[index, :]`, but inplace. """ adata_subset = self[index].copy() self._init_as_actual(adata_subset) # TODO: Update, possibly remove def __setitem__(self, index: Index, val: float | XDataType): if self.is_view: msg = "Object is view and cannot be accessed with `[]`." raise ValueError(msg) obs, var = self._normalize_indices(index) if not self.isbacked: self._X[obs, var] = val else: X = self.file["X"] X[obs, var] = val self._set_backed("X", X) def __len__(self) -> int: return self.shape[0] def transpose(self) -> AnnData: """\ Transpose whole object. Data matrix is transposed, observations and variables are interchanged. Ignores `.raw`. """ from anndata.compat import _safe_transpose X = self.X if not self.isbacked else self.file["X"] if self.is_view: msg = ( "You’re trying to transpose a view of an `AnnData`, " "which is currently not implemented. Call `.copy()` before transposing." ) raise ValueError(msg) return AnnData( X=_safe_transpose(X) if X is not None else None, layers={k: _safe_transpose(v) for k, v in self.layers.items()}, obs=self.var, var=self.obs, uns=self._uns, obsm=self.varm, varm=self.obsm, obsp=self.varp, varp=self.obsp, filename=self.filename, ) T = property(transpose) def to_df(self, layer: str | None = None) -> pd.DataFrame: """\ Generate shallow :class:`~pandas.DataFrame`. The data matrix :attr:`X` is returned as :class:`~pandas.DataFrame`, where :attr:`obs_names` initializes the index, and :attr:`var_names` the columns. * No annotations are maintained in the returned object. * The data matrix is densified in case it is sparse. Params ------ layer Key for `.layers`. Returns ------- Pandas DataFrame of specified data matrix. """ if layer is not None: X = self.layers[layer] elif not self._has_X(): msg = "X is None, cannot convert to dataframe." raise ValueError(msg) else: X = self.X if issparse(X): X = X.toarray() return pd.DataFrame(X, index=self.obs_names, columns=self.var_names) def _get_X(self, *, use_raw: bool = False, layer: str | None = None): """\ Convenience method for getting expression values with common arguments and error handling. """ is_layer = layer is not None if use_raw and is_layer: msg = ( "Cannot use expression from both layer and raw. You provided:" f"`use_raw={use_raw}` and `layer={layer}`" ) raise ValueError(msg) if is_layer: return self.layers[layer] elif use_raw: if self.raw is None: msg = "This AnnData doesn’t have a value in `.raw`." raise ValueError(msg) return self.raw.X else: return self.X def obs_vector(self, k: str, *, layer: str | None = None) -> np.ndarray: """\ Convenience function for returning a 1 dimensional ndarray of values from :attr:`X`, :attr:`layers`\\ `[k]`, or :attr:`obs`. Made for convenience, not performance. Intentionally permissive about arguments, for easy iterative use. Params ------ k Key to use. Should be in :attr:`var_names` or :attr:`obs`\\ `.columns`. layer What layer values should be returned from. If `None`, :attr:`X` is used. Returns ------- A one dimensional ndarray, with values for each obs in the same order as :attr:`obs_names`. """ if layer == "X": if "X" in self.layers: pass else: msg = ( "In a future version of AnnData, access to `.X` by passing" " `layer='X'` will be removed. Instead pass `layer=None`." ) warnings.warn(msg, FutureWarning, stacklevel=2) layer = None return get_vector(self, k, "obs", "var", layer=layer) def var_vector(self, k, *, layer: str | None = None) -> np.ndarray: """\ Convenience function for returning a 1 dimensional ndarray of values from :attr:`X`, :attr:`layers`\\ `[k]`, or :attr:`obs`. Made for convenience, not performance. Intentionally permissive about arguments, for easy iterative use. Params ------ k Key to use. Should be in :attr:`obs_names` or :attr:`var`\\ `.columns`. layer What layer values should be returned from. If `None`, :attr:`X` is used. Returns ------- A one dimensional ndarray, with values for each var in the same order as :attr:`var_names`. """ if layer == "X": if "X" in self.layers: pass else: msg = ( "In a future version of AnnData, access to `.X` by passing " "`layer='X'` will be removed. Instead pass `layer=None`." ) warnings.warn(msg, FutureWarning, stacklevel=2) layer = None return get_vector(self, k, "var", "obs", layer=layer) @deprecated("obs_vector") def _get_obs_array(self, k, use_raw=False, layer=None): # noqa: FBT002 """\ Get an array from the layer (default layer='X') along the :attr:`obs` dimension by first looking up `obs.keys` and then :attr:`obs_names`. """ if not use_raw or k in self.obs.columns: return self.obs_vector(k=k, layer=layer) else: return self.raw.obs_vector(k) @deprecated("var_vector") def _get_var_array(self, k, use_raw=False, layer=None): # noqa: FBT002 """\ Get an array from the layer (default layer='X') along the :attr:`var` dimension by first looking up `var.keys` and then :attr:`var_names`. """ if not use_raw or k in self.var.columns: return self.var_vector(k=k, layer=layer) else: return self.raw.var_vector(k) def _mutated_copy(self, **kwargs): """Creating AnnData with attributes optionally specified via kwargs.""" if self.isbacked and ( "X" not in kwargs or (self.raw is not None and "raw" not in kwargs) ): msg = ( "This function does not currently handle backed objects " "internally, this should be dealt with before." ) raise NotImplementedError(msg) new = {} for key in ["obs", "var", "obsm", "varm", "obsp", "varp", "layers"]: if key in kwargs: new[key] = kwargs[key] else: new[key] = getattr(self, key).copy() if "X" in kwargs: new["X"] = kwargs["X"] elif self._has_X(): new["X"] = self.X.copy() if "uns" in kwargs: new["uns"] = kwargs["uns"] else: new["uns"] = deepcopy(self._uns) if "raw" in kwargs: new["raw"] = kwargs["raw"] elif self.raw is not None: new["raw"] = self.raw.copy() return AnnData(**new) @old_positionals("copy") def to_memory(self, *, copy: bool = False) -> AnnData: """Return a new AnnData object with all non-in-memory arrays loaded into memory. Params ------ copy Whether the arrays that are already in-memory should be copied. Example ------- .. code:: python import anndata backed = anndata.io.read_h5ad("file.h5ad", backed="r") mem = backed[backed.obs["cluster"] == "a", :].to_memory() """ new = {} for attr_name in [ "X", "obs", "var", "obsm", "varm", "obsp", "varp", "layers", "uns", ]: attr = getattr(self, attr_name, None) if attr is not None: new[attr_name] = to_memory(attr, copy=copy) if self.raw is not None: new["raw"] = { "X": to_memory(self.raw.X, copy=copy), "var": to_memory(self.raw.var, copy=copy), "varm": to_memory(self.raw.varm, copy=copy), } if self.isbacked: self.file.close() return AnnData(**new) def copy(self, filename: PathLike[str] | str | None = None) -> AnnData: """Full copy, optionally on disk.""" if not self.isbacked: if self.is_view and self._has_X(): # TODO: How do I unambiguously check if this is a copy? # Subsetting this way means we don’t have to have a view type # defined for the matrix, which is needed for some of the # current distributed backend. Specifically Dask. return self._mutated_copy( X=_subset(self._adata_ref.X, (self._oidx, self._vidx)).copy() ) else: return self._mutated_copy() else: from ..io import read_h5ad, write_h5ad if filename is None: msg = ( "To copy an AnnData object in backed mode, " "pass a filename: `.copy(filename='myfilename.h5ad')`. " "To load the object into memory, use `.to_memory()`." ) raise ValueError(msg) mode = self.file._filemode write_h5ad(filename, self) return read_h5ad(filename, backed=mode) @deprecated( "anndata.concat", add_msg="See the tutorial for concat at: " "https://anndata.readthedocs.io/en/latest/concatenation.html", hide=False, ) def concatenate( self, *adatas: AnnData, join: str = "inner", batch_key: str = "batch", batch_categories: Sequence[Any] | None = None, uns_merge: str | None = None, index_unique: str | None = "-", fill_value=None, ) -> AnnData: """\ Concatenate along the observations axis. The :attr:`uns`, :attr:`varm` and :attr:`obsm` attributes are ignored. Currently, this works only in `'memory'` mode. .. note:: For more flexible and efficient concatenation, see: :func:`~anndata.concat`. Parameters ---------- adatas AnnData matrices to concatenate with. Each matrix is referred to as a “batch”. join Use intersection (`'inner'`) or union (`'outer'`) of variables. batch_key Add the batch annotation to :attr:`obs` using this key. batch_categories Use these as categories for the batch annotation. By default, use increasing numbers. uns_merge Strategy to use for merging entries of uns. These strategies are applied recusivley. Currently implemented strategies include: * `None`: The default. The concatenated object will just have an empty dict for `uns`. * `"same"`: Only entries which have the same value in all AnnData objects are kept. * `"unique"`: Only entries which have one unique value in all AnnData objects are kept. * `"first"`: The first non-missing value is used. * `"only"`: A value is included if only one of the AnnData objects has a value at this path. index_unique Make the index unique by joining the existing index names with the batch category, using `index_unique='-'`, for instance. Provide `None` to keep existing indices. fill_value Scalar value to fill newly missing values in arrays with. Note: only applies to arrays and sparse matrices (not dataframes) and will only be used if `join="outer"`. .. note:: If not provided, the default value is `0` for sparse matrices and `np.nan` for numpy arrays. See the examples below for more information. Returns ------- :class:`~anndata.AnnData` The concatenated :class:`~anndata.AnnData`, where `adata.obs[batch_key]` stores a categorical variable labeling the batch. Notes ----- .. warning:: If you use `join='outer'` this fills 0s for sparse data when variables are absent in a batch. Use this with care. Dense data is filled with `NaN`. See the examples. Examples -------- Joining on intersection of variables. >>> adata1 = AnnData( ... np.array([[1, 2, 3], [4, 5, 6]]), ... dict(obs_names=['s1', 's2'], anno1=['c1', 'c2']), ... dict(var_names=['a', 'b', 'c'], annoA=[0, 1, 2]), ... ) >>> adata2 = AnnData( ... np.array([[1, 2, 3], [4, 5, 6]]), ... dict(obs_names=['s3', 's4'], anno1=['c3', 'c4']), ... dict(var_names=['d', 'c', 'b'], annoA=[0, 1, 2]), ... ) >>> adata3 = AnnData( ... np.array([[1, 2, 3], [4, 5, 6]]), ... dict(obs_names=['s1', 's2'], anno2=['d3', 'd4']), ... dict(var_names=['d', 'c', 'b'], annoA=[0, 2, 3], annoB=[0, 1, 2]), ... ) >>> adata = adata1.concatenate(adata2, adata3) >>> adata AnnData object with n_obs × n_vars = 6 × 2 obs: 'anno1', 'anno2', 'batch' var: 'annoA-0', 'annoA-1', 'annoA-2', 'annoB-2' >>> adata.X array([[2, 3], [5, 6], [3, 2], [6, 5], [3, 2], [6, 5]]) >>> adata.obs anno1 anno2 batch s1-0 c1 NaN 0 s2-0 c2 NaN 0 s3-1 c3 NaN 1 s4-1 c4 NaN 1 s1-2 NaN d3 2 s2-2 NaN d4 2 >>> adata.var.T b c annoA-0 1 2 annoA-1 2 1 annoA-2 3 2 annoB-2 2 1 Joining on the union of variables. >>> outer = adata1.concatenate(adata2, adata3, join='outer') >>> outer AnnData object with n_obs × n_vars = 6 × 4 obs: 'anno1', 'anno2', 'batch' var: 'annoA-0', 'annoA-1', 'annoA-2', 'annoB-2' >>> outer.var.T a b c d annoA-0 0.0 1.0 2.0 NaN annoA-1 NaN 2.0 1.0 0.0 annoA-2 NaN 3.0 2.0 0.0 annoB-2 NaN 2.0 1.0 0.0 >>> outer.var_names.astype("string") Index(['a', 'b', 'c', 'd'], dtype='string') >>> outer.X array([[ 1., 2., 3., nan], [ 4., 5., 6., nan], [nan, 3., 2., 1.], [nan, 6., 5., 4.], [nan, 3., 2., 1.], [nan, 6., 5., 4.]]) >>> outer.X.sum(axis=0) array([nan, 25., 23., nan]) >>> import pandas as pd >>> Xdf = pd.DataFrame(outer.X, columns=outer.var_names) >>> Xdf a b c d 0 1.0 2.0 3.0 NaN 1 4.0 5.0 6.0 NaN 2 NaN 3.0 2.0 1.0 3 NaN 6.0 5.0 4.0 4 NaN 3.0 2.0 1.0 5 NaN 6.0 5.0 4.0 >>> Xdf.sum() a 5.0 b 25.0 c 23.0 d 10.0 dtype: float64 One way to deal with missing values is to use masked arrays: >>> from numpy import ma >>> outer.X = ma.masked_invalid(outer.X) >>> outer.X masked_array( data=[[1.0, 2.0, 3.0, --], [4.0, 5.0, 6.0, --], [--, 3.0, 2.0, 1.0], [--, 6.0, 5.0, 4.0], [--, 3.0, 2.0, 1.0], [--, 6.0, 5.0, 4.0]], mask=[[False, False, False, True], [False, False, False, True], [ True, False, False, False], [ True, False, False, False], [ True, False, False, False], [ True, False, False, False]], fill_value=1e+20) >>> outer.X.sum(axis=0).data array([ 5., 25., 23., 10.]) The masked array is not saved but has to be reinstantiated after saving. >>> outer.write('./test.h5ad') >>> from anndata import read_h5ad >>> outer = read_h5ad('./test.h5ad') >>> outer.X array([[ 1., 2., 3., nan], [ 4., 5., 6., nan], [nan, 3., 2., 1.], [nan, 6., 5., 4.], [nan, 3., 2., 1.], [nan, 6., 5., 4.]]) For sparse data, everything behaves similarly, except that for `join='outer'`, zeros are added. >>> from scipy.sparse import csr_matrix >>> adata1 = AnnData( ... csr_matrix([[0, 2, 3], [0, 5, 6]], dtype=np.float32), ... dict(obs_names=['s1', 's2'], anno1=['c1', 'c2']), ... dict(var_names=['a', 'b', 'c']), ... ) >>> adata2 = AnnData( ... csr_matrix([[0, 2, 3], [0, 5, 6]], dtype=np.float32), ... dict(obs_names=['s3', 's4'], anno1=['c3', 'c4']), ... dict(var_names=['d', 'c', 'b']), ... ) >>> adata3 = AnnData( ... csr_matrix([[1, 2, 0], [0, 5, 6]], dtype=np.float32), ... dict(obs_names=['s5', 's6'], anno2=['d3', 'd4']), ... dict(var_names=['d', 'c', 'b']), ... ) >>> adata = adata1.concatenate(adata2, adata3, join='outer') >>> adata.var_names.astype("string") Index(['a', 'b', 'c', 'd'], dtype='string') >>> adata.X.toarray() array([[0., 2., 3., 0.], [0., 5., 6., 0.], [0., 3., 2., 0.], [0., 6., 5., 0.], [0., 0., 2., 1.], [0., 6., 5., 0.]], dtype=float32) """ from .merge import concat, merge_dataframes, merge_outer, merge_same if self.isbacked: msg = "Currently, concatenate only works in memory mode." raise ValueError(msg) if len(adatas) == 0: return self.copy() elif len(adatas) == 1 and not isinstance(adatas[0], AnnData): adatas = adatas[0] # backwards compatibility all_adatas = (self, *adatas) out = concat( all_adatas, axis=0, join=join, label=batch_key, keys=batch_categories, uns_merge=uns_merge, fill_value=fill_value, index_unique=index_unique, pairwise=False, ) # Backwards compat (some of this could be more efficient) # obs used to always be an outer join sparse_class = sparse.csr_matrix if any(isinstance(a.X, CSArray) for a in all_adatas): sparse_class = sparse.csr_array out.obs = concat( [AnnData(sparse_class(a.shape), obs=a.obs) for a in all_adatas], axis=0, join="outer", label=batch_key, keys=batch_categories, index_unique=index_unique, ).obs # Removing varm del out.varm # Implementing old-style merging of var if batch_categories is None: batch_categories = np.arange(len(all_adatas)).astype(str) pat = rf"-({'|'.join(batch_categories)})$" out.var = merge_dataframes( [a.var for a in all_adatas], out.var_names, partial(merge_outer, batch_keys=batch_categories, merge=merge_same), ) out.var = out.var.iloc[ :, ( out.var.columns.str.extract(pat, expand=False) .fillna("") .argsort(kind="stable") ), ] return out def var_names_make_unique(self, join: str = "-") -> None: # Important to go through the setter so obsm dataframes are updated too self.var_names = utils.make_index_unique(self.var.index, join) var_names_make_unique.__doc__ = utils.make_index_unique.__doc__ def obs_names_make_unique(self, join: str = "-") -> None: # Important to go through the setter so obsm dataframes are updated too self.obs_names = utils.make_index_unique(self.obs.index, join) obs_names_make_unique.__doc__ = utils.make_index_unique.__doc__ def _check_uniqueness(self) -> None: if self.obs.index[~self.obs.index.isna()].has_duplicates: utils.warn_names_duplicates("obs") if self.var.index[~self.var.index.isna()].has_duplicates: utils.warn_names_duplicates("var") def __contains__(self, key: Any) -> NoReturn: msg = "AnnData has no attribute __contains__, don’t check `in adata`." raise AttributeError(msg) def _check_dimensions(self, key=None): key = {"obsm", "varm"} if key is None else {key} if "obsm" in key and ( not all(axis_len(o, 0) == self.n_obs for o in self.obsm.values()) and len(self.obsm.dim_names) != self.n_obs ): msg = ( "Observations annot. `obsm` must have number of rows of `X`" f" ({self.n_obs}), but has {len(self.obsm)} rows." ) raise ValueError(msg) if "varm" in key and ( not all(axis_len(v, 0) == self.n_vars for v in self.varm.values()) and len(self.varm.dim_names) != self.n_vars ): msg = ( "Variables annot. `varm` must have number of columns of `X`" f" ({self.n_vars}), but has {len(self.varm)} rows." ) raise ValueError(msg) @old_positionals("compression", "compression_opts", "as_dense") def write_h5ad( self, filename: PathLike[str] | str | None = None, *, convert_strings_to_categoricals: bool = True, compression: Literal["gzip", "lzf"] | None = None, compression_opts: int | Any = None, as_dense: Sequence[str] = (), ): """\ Write `.h5ad`-formatted hdf5 file. .. note:: Setting compression to `'gzip'` can save disk space but will slow down writing and subsequent reading. Prior to v0.6.16, this was the default for parameter `compression`. Generally, if you have sparse data that are stored as a dense matrix, you can dramatically improve performance and reduce disk space by converting to a :class:`~scipy.sparse.csr_matrix`:: from scipy.sparse import csr_matrix adata.X = csr_matrix(adata.X) Parameters ---------- filename Filename of data file. Defaults to backing file. convert_strings_to_categoricals Convert string columns to categorical. compression For [`lzf`, `gzip`], see the h5py :ref:`dataset_compression`. Alternative compression filters such as `zstd` can be passed from the :doc:`hdf5plugin ` library. Experimental. Usage example:: import hdf5plugin adata.write_h5ad( filename, compression=hdf5plugin.FILTERS["zstd"] ) .. note:: Datasets written with hdf5plugin-provided compressors cannot be opened without first loading the hdf5plugin library using `import hdf5plugin`. When using alternative compression filters such as `zstd`, consider writing to `zarr` format instead of `h5ad`, as the `zarr` library provides a more transparent compression pipeline. compression_opts For [`lzf`, `gzip`], see the h5py :ref:`dataset_compression`. Alternative compression filters such as `zstd` can be configured using helpers from the :doc:`hdf5plugin ` library. Experimental. Usage example (setting `zstd` compression level to 5):: import hdf5plugin adata.write_h5ad( filename, compression=hdf5plugin.FILTERS["zstd"], compression_opts=hdf5plugin.Zstd(clevel=5).filter_options ) as_dense Sparse arrays in AnnData object to write as dense. Currently only supports `X` and `raw/X`. """ from ..io import write_h5ad if filename is None and not self.isbacked: msg = "Provide a filename!" raise ValueError(msg) if filename is None: filename = self.filename write_h5ad( Path(filename), self, convert_strings_to_categoricals=convert_strings_to_categoricals, compression=compression, compression_opts=compression_opts, as_dense=as_dense, ) # Only reset the filename if the AnnData object now points to a complete new copy if self.isbacked and not self.is_view: self.file.filename = filename write = write_h5ad # a shortcut and backwards compat @old_positionals("skip_data", "sep") def write_csvs( self, dirname: PathLike[str] | str, *, skip_data: bool = True, sep: str = "," ): """\ Write annotation to `.csv` files. It is not possible to recover the full :class:`~anndata.AnnData` from these files. Use :meth:`write` for this. Parameters ---------- dirname Name of directory to which to export. skip_data Skip the data matrix :attr:`X`. sep Separator for the data. """ from ..io import write_csvs write_csvs(dirname, self, skip_data=skip_data, sep=sep) @old_positionals("write_obsm_varm") def write_loom( self, filename: PathLike[str] | str, *, write_obsm_varm: bool = False ): """\ Write `.loom`-formatted hdf5 file. Parameters ---------- filename The filename. """ from ..io import write_loom write_loom(filename, self, write_obsm_varm=write_obsm_varm) @old_positionals("chunks") def write_zarr( self, store: StoreLike, *, chunks: tuple[int, ...] | None = None, convert_strings_to_categoricals: bool = True, ): """\ Write a hierarchical Zarr array store. Parameters ---------- store The filename, a :class:`~typing.MutableMapping`, or a Zarr storage class. chunks Chunk shape. convert_strings_to_categoricals Convert string columns to categorical. """ from ..io import write_zarr # TODO: What is a bool for chunks supposed to do? if isinstance(chunks, bool): msg = ( "Passing `write_zarr(adata, chunks=True)` is no longer supported. " "Please pass `write_zarr(adata)` instead." ) raise ValueError(msg) write_zarr( store, self, chunks=chunks, convert_strings_to_categoricals=convert_strings_to_categoricals, ) def chunked_X(self, chunk_size: int | None = None): """\ Return an iterator over the rows of the data matrix :attr:`X`. Parameters ---------- chunk_size Row size of a single chunk. """ if chunk_size is None: # Should be some adaptive code chunk_size = 6000 start = 0 n = self.n_obs for _ in range(int(n // chunk_size)): end = start + chunk_size yield (self.X[start:end], start, end) start = end if start < n: yield (self.X[start:n], start, n) @old_positionals("replace") def chunk_X( self, select: int | Sequence[int] | np.ndarray = 1000, *, replace: bool = True, ): """\ Return a chunk of the data matrix :attr:`X` with random or specified indices. Parameters ---------- select Depending on the type: :class:`int` A random chunk with `select` rows will be returned. :term:`sequence` (e.g. a list, tuple or numpy array) of :class:`int` A chunk with these indices will be returned. replace If `select` is an integer then `True` means random sampling of indices with replacement, `False` without replacement. """ if isinstance(select, int): select = select if select < self.n_obs else self.n_obs choice = np.random.choice(self.n_obs, select, replace) elif isinstance(select, np.ndarray | Sequence): choice = np.asarray(select) else: msg = "select should be int or array" raise ValueError(msg) reverse = None if self.isbacked: # h5py can only slice with a sorted list of unique index values # so random batch with indices [2, 2, 5, 3, 8, 10, 8] will fail # this fixes the problem indices, reverse = np.unique(choice, return_inverse=True) selection = self.X[indices.tolist()] else: selection = self.X[choice] selection = selection.toarray() if issparse(selection) else selection return selection if reverse is None else selection[reverse] def _has_X(self) -> bool: """ Check if X is None. This is more efficient than trying `adata.X is None` for views, since creating views (at least anndata's kind) can be expensive. """ if not self.is_view: return self.X is not None else: return self._adata_ref.X is not None # -------------------------------------------------------------------------- # all of the following is for backwards compat # -------------------------------------------------------------------------- @property @deprecated("is_view") def isview(self): return self.is_view def _clean_up_old_format(self, uns): # multicolumn keys # all of the rest is only for backwards compat for bases in [["obs", "smp"], ["var"]]: axis = bases[0] for k in [f"{p}{base}_keys_multicol" for p in ["", "_"] for base in bases]: if uns and k in uns: keys = list(uns[k]) del uns[k] break else: keys = [] # now, for compat, fill the old multicolumn entries into obsm and varm # and remove them from obs and var m_attr = getattr(self, f"_{axis}m") for key in keys: m_attr[key] = self._get_and_delete_multicol_field(axis, key) def _get_and_delete_multicol_field(self, a, key_multicol): keys = [k for k in getattr(self, a).columns if k.startswith(key_multicol)] values = getattr(self, a)[keys].values getattr(self, a).drop(keys, axis=1, inplace=True) return values @AnnData._remove_unused_categories.register(Dataset2D) @staticmethod def _remove_unused_categories_xr( df_full: Dataset2D, df_sub: Dataset2D, uns: dict[str, Any] ): pass # this is handled automatically by the categorical arrays themselves i.e., they dedup upon access. def _check_2d_shape(X): """\ Check shape of array or sparse matrix. Assure that X is always 2D: Unlike numpy we always deal with 2D arrays. """ if X.dtype.names is None and len(X.shape) != 2: msg = f"X needs to be 2-dimensional, not {len(X.shape)}-dimensional." raise ValueError(msg) def _infer_shape_for_axis( xxx: pd.DataFrame | Mapping[str, Iterable[Any]] | None, xxxm: np.ndarray | Mapping[str, Sequence[Any]] | None, layers: Mapping[str, np.ndarray | sparse.spmatrix] | None, xxxp: np.ndarray | Mapping[str, Sequence[Any]] | None, axis: Literal[0, 1], ) -> int | None: for elem in [xxx, xxxm, xxxp]: if elem is not None and hasattr(elem, "shape"): return elem.shape[0] for elem, id in zip([layers, xxxm, xxxp], ["layers", "xxxm", "xxxp"], strict=True): if elem is not None: elem = cast("Mapping", elem) for sub_elem in elem.values(): if hasattr(sub_elem, "shape"): size = cast("int", sub_elem.shape[axis if id == "layers" else 0]) return size return None def _infer_shape( obs: pd.DataFrame | Mapping[str, Iterable[Any]] | None = None, var: pd.DataFrame | Mapping[str, Iterable[Any]] | None = None, *, obsm: np.ndarray | Mapping[str, Sequence[Any]] | None = None, varm: np.ndarray | Mapping[str, Sequence[Any]] | None = None, layers: Mapping[str, np.ndarray | sparse.spmatrix] | None = None, obsp: np.ndarray | Mapping[str, Sequence[Any]] | None = None, varp: np.ndarray | Mapping[str, Sequence[Any]] | None = None, ): return ( _infer_shape_for_axis(obs, obsm, layers, obsp, 0), _infer_shape_for_axis(var, varm, layers, varp, 1), ) scverse-anndata-b796d59/src/anndata/_core/extensions.py000066400000000000000000000242711512025555600232140ustar00rootroot00000000000000from __future__ import annotations import inspect from pathlib import Path from typing import TYPE_CHECKING, Generic, TypeVar, get_type_hints, overload from warnings import warn from ..types import ExtensionNamespace from .anndata import AnnData if TYPE_CHECKING: from collections.abc import Callable # Based off of the extension framework in Polars # https://github.com/pola-rs/polars/blob/main/py-polars/polars/api.py __all__ = ["register_anndata_namespace"] def find_stacklevel() -> int: """ Find the first place in the stack that is not inside AnnData. Taken from: https://github.com/pola-rs/polars/blob/main/py-polars/polars/_utils/various.py#L447 """ pkg_dir = str(Path(__file__).parent.parent) # https://stackoverflow.com/questions/17407119/python-inspect-stack-is-slow frame = inspect.currentframe() n = 0 try: while frame: fname = inspect.getfile(frame) if fname.startswith(pkg_dir) or ( (qualname := getattr(frame.f_code, "co_qualname", None)) # ignore @singledispatch wrappers and qualname.startswith("singledispatch.") ): frame = frame.f_back n += 1 else: break finally: # https://docs.python.org/3/library/inspect.html # > Though the cycle detector will catch these, destruction of the frames # > (and local variables) can be made deterministic by removing the cycle # > in a finally clause. del frame return n # Reserved namespaces include accessors built into AnnData (currently there are none) # and all current attributes of AnnData _reserved_namespaces: set[str] = set(dir(AnnData)) NameSpT = TypeVar("NameSpT", bound=ExtensionNamespace) T = TypeVar("T") class AccessorNameSpace(ExtensionNamespace, Generic[NameSpT]): """Establish property-like namespace object for user-defined functionality.""" def __init__(self, name: str, namespace: type[NameSpT]) -> None: self._accessor = name self._ns = namespace @overload def __get__(self, instance: None, cls: type[T]) -> type[NameSpT]: ... @overload def __get__(self, instance: T, cls: type[T]) -> NameSpT: ... def __get__(self, instance: T | None, cls: type[T]) -> NameSpT | type[NameSpT]: if instance is None: return self._ns ns_instance = self._ns(instance) # type: ignore[call-arg] setattr(instance, self._accessor, ns_instance) return ns_instance def _check_namespace_signature(ns_class: type) -> None: """Validate the signature of a namespace class for AnnData extensions. This function ensures that any class intended to be used as an extension namespace has a properly formatted `__init__` method such that: 1. Accepts at least two parameters (self and adata) 2. Has 'adata' as the name of the second parameter 3. Has the second parameter properly type-annotated as 'AnnData' or any equivalent import alias The function performs runtime validation of these requirements before a namespace can be registered through the `register_anndata_namespace` decorator. Parameters ---------- ns_class The namespace class to validate. Raises ------ TypeError If the `__init__` method has fewer than 2 parameters (missing the AnnData parameter). AttributeError If the second parameter of `__init__` lacks a type annotation. TypeError If the second parameter of `__init__` is not named 'adata'. TypeError If the second parameter of `__init__` is not annotated as the 'AnnData' class. TypeError If both the name and type annotation of the second parameter are incorrect. """ sig = inspect.signature(ns_class.__init__) params = list(sig.parameters.values()) # Ensure there are at least two parameters (self and adata) if len(params) < 2: error_msg = "Namespace initializer must accept an AnnData instance as the second parameter." raise TypeError(error_msg) # Get the second parameter (expected to be 'adata') param = params[1] if param.annotation is inspect._empty: err_msg = "Namespace initializer's second parameter must be annotated as the 'AnnData' class, got empty annotation." raise AttributeError(err_msg) name_ok = param.name == "adata" # Resolve the annotation using get_type_hints to handle forward references and aliases. try: type_hints = get_type_hints(ns_class.__init__) resolved_type = type_hints.get(param.name, param.annotation) except NameError as e: err_msg = f"Namespace initializer's second parameter must be named 'adata', got '{param.name}'." raise NameError(err_msg) from e type_ok = resolved_type is AnnData match (name_ok, type_ok): case (True, True): return # Signature is correct. case (False, True): msg = f"Namespace initializer's second parameter must be named 'adata', got {param.name!r}." raise TypeError(msg) case (True, False): type_repr = getattr(resolved_type, "__name__", str(resolved_type)) msg = f"Namespace initializer's second parameter must be annotated as the 'AnnData' class, got '{type_repr}'." raise TypeError(msg) case _: type_repr = getattr(resolved_type, "__name__", str(resolved_type)) msg = ( f"Namespace initializer's second parameter must be named 'adata', got {param.name!r}. " f"And must be annotated as 'AnnData', got {type_repr!r}." ) raise TypeError(msg) def _create_namespace( name: str, cls: type[AnnData] ) -> Callable[[type[NameSpT]], type[NameSpT]]: """Register custom namespace against the underlying AnnData class.""" def namespace(ns_class: type[NameSpT]) -> type[NameSpT]: _check_namespace_signature(ns_class) # Perform the runtime signature check if name in _reserved_namespaces: msg = f"cannot override reserved attribute {name!r}" raise AttributeError(msg) elif name in cls._accessors: warn( f"Overriding existing custom namespace {name!r} (on {cls.__name__!r})", UserWarning, stacklevel=find_stacklevel(), ) setattr(cls, name, AccessorNameSpace(name, ns_class)) cls._accessors.add(name) return ns_class return namespace def register_anndata_namespace( name: str, ) -> Callable[[type[NameSpT]], type[NameSpT]]: """Decorator for registering custom functionality with an :class:`~anndata.AnnData` object. This decorator allows you to extend AnnData objects with custom methods and properties organized under a namespace. The namespace becomes accessible as an attribute on AnnData instances, providing a clean way to you to add domain-specific functionality without modifying the AnnData class itself, or extending the class with additional methods as you see fit in your workflow. Parameters ---------- name Name under which the accessor should be registered. This will be the attribute name used to access your namespace's functionality on AnnData objects (e.g., `adata.{name}`). Cannot conflict with existing AnnData attributes like `obs`, `var`, `X`, etc. The list of reserved attributes includes everything outputted by `dir(AnnData)`. Returns ------- A decorator that registers the decorated class as a custom namespace. Notes ----- Implementation requirements: 1. The decorated class must have an `__init__` method that accepts exactly one parameter (besides `self`) named `adata` and annotated with type :class:`~anndata.AnnData`. 2. The namespace will be initialized with the AnnData object on first access and then cached on the instance. 3. If the namespace name conflicts with an existing namespace, a warning is issued. 4. If the namespace name conflicts with a built-in AnnData attribute, an AttributeError is raised. Examples -------- Simple transformation namespace with two methods: >>> import anndata as ad >>> import numpy as np >>> >>> @ad.register_anndata_namespace("transform") ... class TransformX: ... def __init__(self, adata: ad.AnnData): ... self._adata = adata ... ... def log1p( ... self, layer: str = None, inplace: bool = False ... ) -> ad.AnnData | None: ... '''Log1p transform the data.''' ... data = self._adata.layers[layer] if layer else self._adata.X ... log1p_data = np.log1p(data) ... ... if layer: ... layer_name = f"{layer}_log1p" if not inplace else layer ... else: ... layer_name = "log1p" ... ... self._adata.layers[layer_name] = log1p_data ... ... if not inplace: ... return self._adata ... ... def arcsinh( ... self, layer: str = None, scale: float = 1.0, inplace: bool = False ... ) -> ad.AnnData | None: ... '''Arcsinh transform the data with optional scaling.''' ... data = self._adata.layers[layer] if layer else self._adata.X ... asinh_data = np.arcsinh(data / scale) ... ... if layer: ... layer_name = f"{layer}_arcsinh" if not inplace else layer ... else: ... layer_name = "arcsinh" ... ... self._adata.layers[layer_name] = asinh_data ... ... if not inplace: ... return self._adata >>> >>> # Create an AnnData object >>> rng = np.random.default_rng(42) >>> adata = ad.AnnData(X=rng.poisson(1, size=(100, 2000))) >>> >>> # Use the registered namespace >>> adata.transform.log1p() # Transforms X and returns the AnnData object AnnData object with n_obs × n_vars = 100 × 2000 layers: 'log1p' >>> adata.transform.arcsinh() # Transforms X and returns the AnnData object AnnData object with n_obs × n_vars = 100 × 2000 layers: 'log1p', 'arcsinh' """ return _create_namespace(name, AnnData) scverse-anndata-b796d59/src/anndata/_core/file_backing.py000066400000000000000000000127431512025555600234130ustar00rootroot00000000000000from __future__ import annotations import weakref from collections.abc import Mapping from functools import singledispatch from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING import h5py from ..compat import AwkArray, DaskArray, ZarrArray, ZarrGroup from .sparse_dataset import BaseCompressedSparseDataset from .xarray import Dataset2D if TYPE_CHECKING: from collections.abc import Iterator from os import PathLike from typing import Literal from .._types import ArrayStorageType from . import anndata class AnnDataFileManager: """Backing file manager for AnnData.""" def __init__( self, adata: anndata.AnnData, file_name: PathLike[str] | str | None = None, file_mode: Literal["r", "r+"] | None = None, file_obj: h5py.File | None = None, ): if file_obj is not None and (file_name is not None or file_mode is not None): msg = "Cannot provide both a h5py.File and the name and/or mode arguments to constructor" raise ValueError(msg) self._adata_ref = weakref.ref(adata) if file_obj is not None: self.filename = filename(file_obj) self._filemode = file_obj.mode self._file = file_obj else: self.filename = file_name self._filemode = file_mode self._file = file_obj if file_name and not self._file: self.open() def __getstate__(self): state = self.__dict__.copy() state["_adata_ref"] = state["_adata_ref"]() return state def __setstate__(self, state): self.__dict__ = state.copy() self.__dict__["_adata_ref"] = weakref.ref(state["_adata_ref"]) @property def _adata(self): return self._adata_ref() def __repr__(self) -> str: if self.filename is None: return "Backing file manager: no file is set." else: return f"Backing file manager of file {self.filename}." def __contains__(self, x) -> bool: return x in self._file def __iter__(self) -> Iterator[str]: return iter(self._file) def __getitem__( self, key: str ) -> h5py.Group | h5py.Dataset | BaseCompressedSparseDataset: return self._file[key] def __setitem__( self, key: str, value: h5py.Group | h5py.Dataset | BaseCompressedSparseDataset, ): self._file[key] = value def __delitem__(self, key: str): del self._file[key] @property def filename(self) -> Path: return self._filename @filename.setter def filename(self, file_name: PathLike[str] | str | None): self._filename = None if file_name is None else Path(file_name) def open( self, file_name: PathLike[str] | str | None = None, filemode: Literal["r", "r+"] | None = None, ): if file_name is not None: self.filename = file_name if filemode is not None: self._filemode = filemode if self.filename is None: msg = "Cannot open backing file if backing not initialized." raise ValueError(msg) self._file = h5py.File(self.filename, self._filemode) def close(self): """Close the backing file, remember filename, do *not* change to memory mode.""" if self._file is not None: self._file.close() def _to_memory_mode(self): """Close the backing file, forget filename, *do* change to memory mode.""" self._adata._X = self._adata.X[()] self._file.close() self._file = None self._filename = None @property def is_open(self) -> bool: """State of backing file.""" if self._file is None: return False # try accessing the id attribute to see if the file is open return bool(self._file.id) @singledispatch def to_memory(x, *, copy: bool = False): """Permissivley convert objects to in-memory representation. If they already are in-memory, (or are just unrecognized) pass a copy through. """ if copy and hasattr(x, "copy"): return x.copy() else: return x @to_memory.register(ZarrArray) @to_memory.register(h5py.Dataset) def _(x: ArrayStorageType, *, copy: bool = False): return x[...] @to_memory.register(BaseCompressedSparseDataset) def _(x: BaseCompressedSparseDataset, *, copy: bool = False): return x.to_memory() @to_memory.register(DaskArray) def _(x: DaskArray, *, copy: bool = False): return x.compute() @to_memory.register(Mapping) def _(x: Mapping, *, copy: bool = False): return {k: to_memory(v, copy=copy) for k, v in x.items()} @to_memory.register(AwkArray) def _(x: AwkArray, *, copy: bool = False): from copy import copy as _copy if copy: return _copy(x) else: return x @to_memory.register(Dataset2D) def _(x: Dataset2D, *, copy: bool = False): return x.to_memory(copy=copy) @singledispatch def filename(x): msg = f"Not implemented for {type(x)}" raise NotImplementedError(msg) @filename.register(h5py.Group) @filename.register(h5py.Dataset) def _(x): return x.file.filename @filename.register(ZarrArray) @filename.register(ZarrGroup) def _(x): return x.store.path @singledispatch def get_elem_name(x): msg = f"Not implemented for {type(x)}" raise NotImplementedError(msg) @get_elem_name.register(h5py.Group) def _(x): return x.name @get_elem_name.register(ZarrGroup) def _(x): return PurePosixPath(x.path).name scverse-anndata-b796d59/src/anndata/_core/index.py000066400000000000000000000322401512025555600221170ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Iterable, Sequence from functools import singledispatch from itertools import repeat from typing import TYPE_CHECKING, cast, overload import h5py import numpy as np import pandas as pd from scipy.sparse import issparse from ..compat import AwkArray, CSArray, CSMatrix, DaskArray, XDataArray from .xarray import Dataset2D if TYPE_CHECKING: from numpy.typing import NDArray from ..compat import Index, Index1D, Index1DNorm def _normalize_indices( index: Index | None, names0: pd.Index, names1: pd.Index ) -> tuple[Index1DNorm | int | np.integer, Index1DNorm | int | np.integer]: # deal with tuples of length 1 if isinstance(index, tuple) and len(index) == 1: index = index[0] # deal with pd.Series if isinstance(index, pd.Series): index = index.values if isinstance(index, tuple): # TODO: The series should probably be aligned first index = tuple(i.values if isinstance(i, pd.Series) else i for i in index) ax0, ax1 = unpack_index(index) ax0 = _normalize_index(ax0, names0) ax1 = _normalize_index(ax1, names1) return ax0, ax1 def _normalize_index( # noqa: PLR0911, PLR0912 indexer: Index1D, index: pd.Index ) -> Index1DNorm | int | np.integer: # TODO: why is this here? All tests pass without it and it seems at the minimum not strict enough. if not isinstance(index, pd.RangeIndex) and index.dtype in (np.float64, np.int64): msg = f"Don’t call _normalize_index with non-categorical/string names and non-range index {index}" raise TypeError(msg) # the following is insanely slow for sequences, # we replaced it using pandas below def name_idx(i): if isinstance(i, str): i = index.get_loc(i) return i if isinstance(indexer, slice): start = name_idx(indexer.start) stop = name_idx(indexer.stop) # string slices can only be inclusive, so +1 in that case if isinstance(indexer.stop, str): stop = None if stop is None else stop + 1 step = indexer.step return slice(start, stop, step) elif isinstance(indexer, np.integer | int): return indexer elif isinstance(indexer, str): return index.get_loc(indexer) # int elif isinstance( indexer, Sequence | np.ndarray | pd.Index | CSMatrix | np.matrix | CSArray ): if hasattr(indexer, "shape") and ( (indexer.shape == (index.shape[0], 1)) or (indexer.shape == (1, index.shape[0])) ): if isinstance(indexer, CSMatrix | CSArray): indexer = indexer.toarray() indexer = np.ravel(indexer) if not isinstance(indexer, np.ndarray | pd.Index): indexer = np.array(indexer) if len(indexer) == 0: indexer = indexer.astype(int) if isinstance(indexer, np.ndarray) and np.issubdtype( indexer.dtype, np.floating ): indexer_int = indexer.astype(int) if np.all((indexer - indexer_int) != 0): msg = f"Indexer {indexer!r} has floating point values." raise IndexError(msg) if issubclass(indexer.dtype.type, np.integer | np.floating): return indexer # Might not work for range indexes elif issubclass(indexer.dtype.type, np.bool_): if indexer.shape != index.shape: msg = ( f"Boolean index does not match AnnData’s shape along this " f"dimension. Boolean index has shape {indexer.shape} while " f"AnnData index has shape {index.shape}." ) raise IndexError(msg) return indexer else: # indexer should be string array positions = index.get_indexer(indexer) if np.any(positions < 0): not_found = indexer[positions < 0] msg = ( f"Values {list(not_found)}, from {list(indexer)}, " "are not valid obs/ var names or indices." ) raise KeyError(msg) return positions # np.ndarray[int] elif isinstance(indexer, XDataArray): if isinstance(indexer.data, DaskArray): return indexer.data.compute() return indexer.data msg = f"Unknown indexer {indexer!r} of type {type(indexer)}" raise IndexError() def _fix_slice_bounds(s: slice, length: int) -> slice: """The slice will be clipped to length, and the step won't be None. E.g. infer None valued attributes. """ step = s.step if s.step is not None else 1 # slice constructor would have errored if step was 0 if step > 0: start = s.start if s.start is not None else 0 stop = s.stop if s.stop is not None else length elif step < 0: # Reverse start = s.start if s.start is not None else length stop = s.stop if s.stop is not None else 0 return slice(start, stop, step) def unpack_index(index: Index) -> tuple[Index1D, Index1D]: if not isinstance(index, tuple): if index is Ellipsis: index = slice(None) return index, slice(None) num_ellipsis = sum(i is Ellipsis for i in index) if num_ellipsis > 1: msg = "an index can only have a single ellipsis ('...')" raise IndexError(msg) # If index has Ellipsis, filter it out (and if not, error) if len(index) > 2: if not num_ellipsis: msg = "Received a length 3 index without an ellipsis" raise IndexError(msg) index = tuple(i for i in index if i is not Ellipsis) return index # If index has Ellipsis, replace it with slice if len(index) == 2: index = tuple(slice(None) if i is Ellipsis else i for i in index) return index if len(index) == 1: index = index[0] if index is Ellipsis: index = slice(None) return index, slice(None) msg = "invalid number of indices" raise IndexError(msg) @singledispatch def _subset( a: np.ndarray | pd.DataFrame, subset_idx: tuple[Index1DNorm] | tuple[Index1DNorm, Index1DNorm], ): # Select as combination of indexes, not coordinates # Correcting for indexing behaviour of np.ndarray if all(isinstance(x, Iterable) for x in subset_idx): subset_idx = np.ix_(*subset_idx) return a[subset_idx] @_subset.register(DaskArray) def _subset_dask( a: DaskArray, subset_idx: tuple[Index1DNorm] | tuple[Index1DNorm, Index1DNorm] ): if len(subset_idx) > 1 and all(isinstance(x, Iterable) for x in subset_idx): if issparse(a._meta) and a._meta.format == "csc": return a[:, subset_idx[1]][subset_idx[0], :] return a[subset_idx[0], :][:, subset_idx[1]] return a[subset_idx] @_subset.register(CSMatrix) @_subset.register(CSArray) def _subset_sparse( a: CSMatrix | CSArray, subset_idx: tuple[Index1DNorm] | tuple[Index1DNorm, Index1DNorm], ): # Correcting for indexing behaviour of sparse.spmatrix if len(subset_idx) > 1 and all(isinstance(x, Iterable) for x in subset_idx): first_idx = subset_idx[0] if issubclass(first_idx.dtype.type, np.bool_): first_idx = np.flatnonzero(first_idx) subset_idx = (first_idx.reshape(-1, 1), *subset_idx[1:]) return a[subset_idx] @_subset.register(pd.DataFrame) @_subset.register(Dataset2D) def _subset_df( df: pd.DataFrame | Dataset2D, subset_idx: tuple[Index1DNorm] | tuple[Index1DNorm, Index1DNorm], ): return df.iloc[subset_idx] @_subset.register(AwkArray) def _subset_awkarray( a: AwkArray, subset_idx: tuple[Index1DNorm] | tuple[Index1DNorm, Index1DNorm] ): if all(isinstance(x, Iterable) for x in subset_idx): subset_idx = np.ix_(*subset_idx) return a[subset_idx] # Registration for SparseDataset occurs in sparse_dataset.py @_subset.register(h5py.Dataset) def _subset_dataset( d: h5py.Dataset, subset_idx: tuple[Index1DNorm] | tuple[Index1DNorm, Index1DNorm] ): order: tuple[NDArray[np.integer] | slice, ...] inv_order: tuple[NDArray[np.integer] | slice, ...] order, inv_order = zip(*map(_index_order_and_inverse, subset_idx), strict=True) # check for duplicates or multi-dimensional fancy indexing array_dims = [i for i in order if isinstance(i, np.ndarray)] has_duplicates = any(len(np.unique(i)) != len(i) for i in array_dims) # Use safe indexing if there are duplicates OR multiple array dimensions # (h5py doesn't support multi-dimensional fancy indexing natively) if has_duplicates or len(array_dims) > 1: # For multi-dimensional indexing, bypass the sorting logic and use original indices return _safe_fancy_index_h5py(d, subset_idx) # from hdf5, then to real order return d[order][inv_order] @overload def _index_order_and_inverse( axis_idx: NDArray[np.integer] | NDArray[np.bool_], ) -> tuple[NDArray[np.integer], NDArray[np.integer]]: ... @overload def _index_order_and_inverse(axis_idx: slice) -> tuple[slice, slice]: ... def _index_order_and_inverse( axis_idx: Index1DNorm, ) -> tuple[Index1DNorm, NDArray[np.integer] | slice]: """Order and get inverse index array.""" if not isinstance(axis_idx, np.ndarray): return axis_idx, slice(None) if axis_idx.dtype == bool: axis_idx = np.flatnonzero(axis_idx) order = np.argsort(axis_idx) return axis_idx[order], np.argsort(order) @overload def _process_index_for_h5py( idx: NDArray[np.integer] | NDArray[np.bool_], ) -> tuple[NDArray[np.integer], NDArray[np.integer]]: ... @overload def _process_index_for_h5py(idx: slice) -> tuple[slice, None]: ... def _process_index_for_h5py( idx: Index1DNorm, ) -> tuple[Index1DNorm, NDArray[np.integer] | None]: """Process a single index for h5py compatibility, handling sorting and duplicates.""" if not isinstance(idx, np.ndarray): # Not an array (slice, integer, list) - no special processing needed return idx, None if idx.dtype == bool: idx = np.flatnonzero(idx) # For h5py fancy indexing, we need sorted indices # But we also need to track how to reverse the sorting unique, inverse = np.unique(idx, return_inverse=True) return ( # Has duplicates - use unique + inverse mapping approach (unique, inverse) if len(unique) != len(idx) # No duplicates - just sort and track reverse mapping else _index_order_and_inverse(idx) ) def _safe_fancy_index_h5py( dataset: h5py.Dataset, subset_idx: tuple[Index1DNorm] | tuple[Index1DNorm, Index1DNorm], ) -> h5py.Dataset: # Handle multi-dimensional indexing of h5py dataset # This avoids h5py's limitation with multi-dimensional fancy indexing # without loading the entire dataset into memory # Convert boolean arrays to integer arrays and handle sorting for h5py processed_indices: tuple[NDArray[np.integer] | slice, ...] reverse_indices: tuple[NDArray[np.integer] | None, ...] processed_indices, reverse_indices = zip( *map(_process_index_for_h5py, subset_idx), strict=True ) # First find the index that reduces the size of the dataset the most i_min = np.argmin([ _get_index_size(inds, dataset.shape[i]) / dataset.shape[i] for i, inds in enumerate(processed_indices) ]) # Apply the most selective index first to h5py dataset first_index = [slice(None)] * len(processed_indices) first_index[i_min] = processed_indices[i_min] in_memory_array = cast("np.ndarray", dataset[tuple(first_index)]) # Apply remaining indices to the numpy array remaining_indices = list(processed_indices) remaining_indices[i_min] = slice(None) # Already applied result = in_memory_array[tuple(remaining_indices)] # Now apply reverse mappings to get the original order for dim, reverse_map in enumerate(reverse_indices): if reverse_map is not None: result = result.take(reverse_map, axis=dim) return result def _get_index_size(idx: Index1DNorm, dim_size: int) -> int: """Get size for any index type.""" if isinstance(idx, slice): return len(range(*idx.indices(dim_size))) elif isinstance(idx, int): return 1 else: # For other types, try to get length return len(idx) def make_slice(idx, dimidx: int, n: int = 2) -> tuple[slice, ...]: mut = list(repeat(slice(None), n)) mut[dimidx] = idx return tuple(mut) def get_vector(adata, k, coldim, idxdim, layer=None): # adata could be self if Raw and AnnData shared a parent dims = ("obs", "var") col = getattr(adata, coldim).columns idx = getattr(adata, f"{idxdim}_names") in_col = k in col in_idx = k in idx if (in_col + in_idx) == 2: msg = f"Key {k} could be found in both .{idxdim}_names and .{coldim}.columns" raise ValueError(msg) elif (in_col + in_idx) == 0: msg = f"Could not find key {k} in .{idxdim}_names or .{coldim}.columns." raise KeyError(msg) elif in_col: return getattr(adata, coldim)[k].values elif in_idx: selected_dim = dims.index(idxdim) idx = adata._normalize_indices(make_slice(k, selected_dim)) a = adata._get_X(layer=layer)[idx] if issparse(a): a = a.toarray() return np.ravel(a) scverse-anndata-b796d59/src/anndata/_core/merge.py000066400000000000000000001667201512025555600221220ustar00rootroot00000000000000""" Code for merging/ concatenating AnnData objects. """ from __future__ import annotations import uuid from collections import OrderedDict from collections.abc import Callable, Mapping, MutableSet from functools import partial, reduce, singledispatch from itertools import repeat from operator import and_, or_, sub from typing import TYPE_CHECKING, Literal, TypeVar from warnings import warn import numpy as np import pandas as pd from natsort import natsorted from scipy import sparse from anndata._core.file_backing import to_memory from anndata._warnings import ExperimentalFeatureWarning from ..compat import ( AwkArray, CSArray, CSMatrix, CupyArray, CupyCSRMatrix, CupySparseMatrix, DaskArray, ) from ..utils import asarray, axis_len, warn_once from .anndata import AnnData from .index import _subset, make_slice from .xarray import Dataset2D if TYPE_CHECKING: from collections.abc import Collection, Generator, Iterable, Sequence from typing import Any from numpy.typing import NDArray from pandas.api.extensions import ExtensionDtype from anndata._types import Join_T from ..compat import XDataArray, XDataset T = TypeVar("T") ################### # Utilities ################### # Pretty much just for maintaining order of keys class OrderedSet(MutableSet): def __init__(self, vals=()): self.dict = OrderedDict(zip(vals, repeat(None))) def __contains__(self, val): return val in self.dict def __iter__(self): return iter(self.dict) def __len__(self): return len(self.dict) def __repr__(self): return "OrderedSet: {" + ", ".join(map(str, self)) + "}" def copy(self): return OrderedSet(self.dict.copy()) def add(self, val): self.dict[val] = None def union(self, *vals) -> OrderedSet: return reduce(or_, vals, self) def discard(self, val): if val in self: del self.dict[val] def difference(self, *vals) -> OrderedSet: return reduce(sub, vals, self) def union_keys(ds: Collection) -> OrderedSet: return reduce(or_, ds, OrderedSet()) def intersect_keys(ds: Collection) -> OrderedSet: return reduce(and_, map(OrderedSet, ds)) class MissingVal: """Represents a missing value.""" def is_missing(v) -> bool: return v is MissingVal def not_missing(v) -> bool: return v is not MissingVal # We need to be able to check for equality of arrays to know which are the same. # Unfortunately equality of arrays is poorly defined. # * `np.array_equal` does not work for sparse arrays # * `np.array_equal(..., equal_nan=True)` does not work for null values at the moment # (see https://github.com/numpy/numpy/issues/16377) # So we have to define it ourselves with these two issues in mind. # TODO: Hopefully this will stop being an issue in the future and this code can be removed. @singledispatch def equal(a, b) -> bool: a = asarray(a) b = asarray(b) if a.ndim == b.ndim == 0: return bool(a == b) a_na = ( pd.isna(a) if a.dtype.names is None else np.False_ ) # pd.isna doesn't work for record arrays b_na = pd.isna(b) if b.dtype.names is None else np.False_ return np.array_equal(a_na, b_na) and np.array_equal(a[~a_na], b[~b_na]) @equal.register(pd.DataFrame) @equal.register(Dataset2D) @equal.register(pd.Series) def equal_dataframe(a, b) -> bool: return a.equals(b) @equal.register(DaskArray) def equal_dask_array(a, b) -> bool: import dask.array as da from dask.base import tokenize if a is b: return True if a.shape != b.shape: return False if isinstance(b, DaskArray) and tokenize(a) == tokenize(b): return True if isinstance(a._meta, np.ndarray): return da.equal(a, b, where=~(da.isnan(a) & da.isnan(b))).all().compute() if a.chunksize == b.chunksize and isinstance( a._meta, CupySparseMatrix | CSMatrix | CSArray ): # TODO: Maybe also do this in the other case? return da.map_blocks(equal, a, b, drop_axis=(0, 1)).all() msg = "Misaligned chunks detected when checking for merge equality of dask arrays. Reading full arrays into memory." warn(msg, UserWarning, stacklevel=3) return equal(a.compute(), b.compute()) @equal.register(np.ndarray) def equal_array(a, b) -> bool: # Reshaping allows us to compare inputs with >2 dimensions # We cast to pandas since it will still work with non-numeric types b = asarray(b) if a.shape != b.shape: return False return equal(pd.DataFrame(a.reshape(-1)), pd.DataFrame(b.reshape(-1))) @equal.register(CupyArray) def equal_cupyarray(a, b) -> bool: import cupy as cp return bool(cp.array_equal(a, b, equal_nan=True)) @equal.register(CSMatrix) @equal.register(CSArray) @equal.register(CupySparseMatrix) def equal_sparse(a, b) -> bool: # It's a weird api, don't blame me import array_api_compat xp = array_api_compat.array_namespace(a.data) if isinstance(b, CupySparseMatrix | CSMatrix | CSArray): if isinstance(a, CupySparseMatrix): # Comparison broken for CSC matrices # https://github.com/cupy/cupy/issues/7757 a, b = CupyCSRMatrix(a), CupyCSRMatrix(b) comp = a != b if isinstance(comp, bool): return not comp if isinstance(comp, CupySparseMatrix): # https://github.com/cupy/cupy/issues/7751 comp = comp.get() # fmt: off return ( (len(comp.data) == 0) or ( xp.isnan(a[comp]).all() and xp.isnan(b[comp]).all() ) ) # fmt: on else: return False @equal.register(AwkArray) def equal_awkward(a, b) -> bool: from ..compat import awkward as ak return ak.almost_equal(a, b) def as_sparse(x, *, use_sparse_array: bool = False) -> CSMatrix | CSArray: if not isinstance(x, CSMatrix | CSArray): in_memory_array_class = ( sparse.csr_array if use_sparse_array else sparse.csr_matrix ) if isinstance(x, DaskArray): x = x.map_blocks( sparse.csr_matrix, meta=sparse.csr_matrix(x._meta), dtype=x.dtype, ).compute() return in_memory_array_class(x) return x def as_cp_sparse(x) -> CupySparseMatrix: import cupyx.scipy.sparse as cpsparse if isinstance(x, cpsparse.spmatrix): return x elif isinstance(x, np.ndarray): return cpsparse.csr_matrix(as_sparse(x)) else: return cpsparse.csr_matrix(x) def unify_dtypes( dfs: Iterable[pd.DataFrame | Dataset2D], ) -> list[pd.DataFrame | Dataset2D]: """ Attempts to unify datatypes from multiple dataframes. For catching cases where pandas would convert to object dtype. """ dfs = list(dfs) # Get shared categorical columns df_dtypes = [dict(df.dtypes) for df in dfs] columns = reduce(lambda x, y: x.union(y), [df.columns for df in dfs]) dtypes: dict[str, list[np.dtype | ExtensionDtype]] = {col: [] for col in columns} for col in columns: for df in df_dtypes: dtypes[col].append(df.get(col, None)) if len(dtypes) == 0: return dfs else: dfs = [df.copy(deep=False) for df in dfs] new_dtypes = { col: target_dtype for col, dtype in dtypes.items() if (target_dtype := try_unifying_dtype(dtype)) is not None } for df in dfs: for col, dtype in new_dtypes.items(): if col in df: df[col] = df[col].astype(dtype) return dfs def try_unifying_dtype( # noqa PLR0911, PLR0912 col: Sequence[np.dtype | ExtensionDtype], ) -> pd.core.dtypes.base.ExtensionDtype | None: """ If dtypes can be unified, returns the dtype they would be unified to. Returns None if they can't be unified, or if we can expect pandas to unify them for us. Params ------ col: A list of dtypes to unify. Can be numpy/ pandas dtypes, or None (which denotes a missing value) """ dtypes: set[pd.CategoricalDtype] = set() # Categorical if any(isinstance(dtype, pd.CategoricalDtype) for dtype in col): ordered = False for dtype in col: if isinstance(dtype, pd.CategoricalDtype): dtypes.add(dtype) ordered = ordered | dtype.ordered elif not pd.isnull(dtype): return None if len(dtypes) > 0: categories = reduce( lambda x, y: x.union(y), (dtype.categories for dtype in dtypes if not pd.isnull(dtype)), ) if not ordered: return pd.CategoricalDtype(natsorted(categories), ordered=False) else: # for xarray Datasets, see https://github.com/pydata/xarray/issues/10247 categories_intersection = reduce( lambda x, y: x.intersection(y), ( dtype.categories for dtype in dtypes if not pd.isnull(dtype) and len(dtype.categories) > 0 ), ) if len(categories_intersection) < len(categories): return object else: same_orders = all( dtype.ordered for dtype in dtypes if not pd.isnull(dtype) and len(dtype.categories) > 0 ) same_orders &= all( np.all(categories == dtype.categories) for dtype in dtypes if not pd.isnull(dtype) and len(dtype.categories) > 0 ) if same_orders: return next( dtype for dtype in dtypes if not pd.isnull(dtype) and len(dtype.categories) > 0 ) return object # Boolean elif all(pd.api.types.is_bool_dtype(dtype) or dtype is None for dtype in col): if any(dtype is None for dtype in col): return pd.BooleanDtype() else: return None else: return None def check_combinable_cols(cols: list[pd.Index], join: Join_T): """Given columns for a set of dataframes, checks if the can be combined. Looks for if there are duplicated column names that would show up in the result. """ repeated_cols = reduce(lambda x, y: x.union(y[y.duplicated()]), cols, set()) if join == "inner": intersecting_cols = intersect_keys(cols) problem_cols = repeated_cols.intersection(intersecting_cols) elif join == "outer": problem_cols = repeated_cols else: raise ValueError() if len(problem_cols) > 0: problem_cols = list(problem_cols) msg = ( f"Cannot combine dataframes as some contained duplicated column names - " "causing ambiguity.\n\n" f"The problem columns are: {problem_cols}" ) raise pd.errors.InvalidIndexError(msg) # TODO: open PR or feature request to cupy def _cp_block_diag(mats, format=None, dtype=None): """ Modified version of scipy.sparse.block_diag for cupy sparse. """ import cupy as cp from cupyx.scipy import sparse as cpsparse row = [] col = [] data = [] r_idx = 0 c_idx = 0 for a in mats: # if isinstance(a, (list, numbers.Number)): # a = cpsparse.coo_matrix(a) nrows, ncols = a.shape if cpsparse.issparse(a): a = a.tocoo() row.append(a.row + r_idx) col.append(a.col + c_idx) data.append(a.data) else: a_row, a_col = cp.divmod(cp.arange(nrows * ncols), ncols) row.append(a_row + r_idx) col.append(a_col + c_idx) data.append(a.reshape(-1)) r_idx += nrows c_idx += ncols row = cp.concatenate(row) col = cp.concatenate(col) data = cp.concatenate(data) return cpsparse.coo_matrix( (data, (row, col)), shape=(r_idx, c_idx), dtype=dtype ).asformat(format) def _dask_block_diag(mats): from itertools import permutations import dask.array as da blocks = np.zeros((len(mats), len(mats)), dtype=object) for i, j in permutations(range(len(mats)), 2): blocks[i, j] = da.from_array( sparse.csr_matrix((mats[i].shape[0], mats[j].shape[1])) ) for i, x in enumerate(mats): if not isinstance(x._meta, sparse.csr_matrix): x = x.map_blocks(sparse.csr_matrix) blocks[i, i] = x return da.block(blocks.tolist()) ################### # Per element logic ################### def unique_value(vals: Collection[T]) -> T | MissingVal: """ Given a collection vals, returns the unique value (if one exists), otherwise returns MissingValue. """ unique_val = vals[0] for v in vals[1:]: if not equal(v, unique_val): return MissingVal return unique_val def first(vals: Collection[T]) -> T | MissingVal: """ Given a collection of vals, return the first non-missing one.If they're all missing, return MissingVal. """ for val in vals: if not_missing(val): return val return MissingVal def only(vals: Collection[T]) -> T | MissingVal: """Return the only value in the collection, otherwise MissingVal.""" if len(vals) == 1: return vals[0] else: return MissingVal ################### # Merging ################### def merge_nested(ds: Collection[Mapping], keys_join: Callable, value_join: Callable): out = {} for k in keys_join(ds): v = _merge_nested(ds, k, keys_join, value_join) if not_missing(v): out[k] = v return out def _merge_nested( ds: Collection[Mapping], k, keys_join: Callable, value_join: Callable ): vals = [d[k] for d in ds if k in d] if len(vals) == 0: return MissingVal elif all(isinstance(v, Mapping) and not isinstance(v, Dataset2D) for v in vals): new_map = merge_nested(vals, keys_join, value_join) if len(new_map) == 0: return MissingVal else: return new_map else: return value_join(vals) def merge_unique(ds: Collection[Mapping]) -> Mapping: return merge_nested(ds, union_keys, unique_value) def merge_same(ds: Collection[Mapping]) -> Mapping: return merge_nested(ds, intersect_keys, unique_value) def merge_first(ds: Collection[Mapping]) -> Mapping: return merge_nested(ds, union_keys, first) def merge_only(ds: Collection[Mapping]) -> Mapping: return merge_nested(ds, union_keys, only) ################### # Interface ################### # Leaving out for now, it's ugly in the rendered docs and would be adding a dependency. # from typing_extensions import Literal # UNS_STRATEGIES_TYPE = Literal[None, "same", "unique", "first", "only"] MERGE_STRATEGIES = { None: lambda x: {}, "same": merge_same, "unique": merge_unique, "first": merge_first, "only": merge_only, } StrategiesLiteral = Literal["same", "unique", "first", "only"] def resolve_merge_strategy( strategy: str | Callable | None, ) -> Callable[[Collection[Mapping]], Mapping]: if not isinstance(strategy, Callable): strategy = MERGE_STRATEGIES[strategy] return strategy ##################### # Concatenation ##################### class Reindexer: """ Indexing to be applied to axis of 2d array orthogonal to the axis being concatenated. Attrs ----- old_idx Original index new_idx Target index old_pos Indices of original index which will be kept new_pos Indices of new index which data from old_pos will be placed in. Together with `old_pos` this forms a mapping. """ def __init__(self, old_idx: pd.Index, new_idx: pd.Index) -> None: self.old_idx = old_idx self.new_idx = new_idx self.no_change = new_idx.equals(old_idx) new_pos = new_idx.get_indexer(old_idx) old_pos = np.arange(len(new_pos)) mask = new_pos != -1 self.new_pos = new_pos[mask] self.old_pos = old_pos[mask] def __call__(self, el, *, axis=1, fill_value=None): return self.apply(el, axis=axis, fill_value=fill_value) def apply(self, el, *, axis, fill_value=None): # noqa: PLR0911 """ Reindex element so el[axis] is aligned to self.new_idx. Missing values are to be replaced with `fill_value`. """ if self.no_change and (axis_len(el, axis) == len(self.old_idx)): return el if isinstance(el, pd.DataFrame | Dataset2D): return self._apply_to_df_like(el, axis=axis, fill_value=fill_value) elif isinstance(el, CSMatrix | CSArray | CupySparseMatrix): return self._apply_to_sparse(el, axis=axis, fill_value=fill_value) elif isinstance(el, AwkArray): return self._apply_to_awkward(el, axis=axis, fill_value=fill_value) elif isinstance(el, DaskArray): return self._apply_to_dask_array(el, axis=axis, fill_value=fill_value) elif isinstance(el, CupyArray): return self._apply_to_cupy_array(el, axis=axis, fill_value=fill_value) else: return self._apply_to_array(el, axis=axis, fill_value=fill_value) def _apply_to_df_like(self, el: pd.DataFrame | Dataset2D, *, axis, fill_value=None): if fill_value is None: fill_value = np.nan return el.reindex(self.new_idx, axis=axis, fill_value=fill_value) def _apply_to_dask_array(self, el: DaskArray, *, axis, fill_value=None): import dask.array as da if fill_value is None: fill_value = default_fill_value([el]) shape = list(el.shape) if el.shape[axis] == 0: # Presumably faster since it won't allocate the full array shape[axis] = len(self.new_idx) return da.broadcast_to(fill_value, tuple(shape)) indexer = self.idx sub_el = _subset(el, make_slice(indexer, axis, len(shape))) if any(indexer == -1): # TODO: Remove this condition once https://github.com/dask/dask/pull/12078 is released if isinstance(sub_el._meta, CSArray | CSMatrix) and np.isscalar(fill_value): fill_value = np.array([[fill_value]]) sub_el[make_slice(indexer == -1, axis, len(shape))] = fill_value return sub_el def _apply_to_cupy_array(self, el, *, axis, fill_value=None): import cupy as cp if fill_value is None: fill_value = default_fill_value([el]) if el.shape[axis] == 0: # Presumably faster since it won't allocate the full array shape = list(el.shape) shape[axis] = len(self.new_idx) return cp.broadcast_to(cp.asarray(fill_value), tuple(shape)) old_idx_tuple = [slice(None)] * len(el.shape) old_idx_tuple[axis] = self.old_pos old_idx_tuple = tuple(old_idx_tuple) new_idx_tuple = [slice(None)] * len(el.shape) new_idx_tuple[axis] = self.new_pos new_idx_tuple = tuple(new_idx_tuple) out_shape = list(el.shape) out_shape[axis] = len(self.new_idx) out = cp.full(tuple(out_shape), fill_value) out[new_idx_tuple] = el[old_idx_tuple] return out def _apply_to_array(self, el, *, axis, fill_value=None): if fill_value is None: fill_value = default_fill_value([el]) if el.shape[axis] == 0: # Presumably faster since it won't allocate the full array shape = list(el.shape) shape[axis] = len(self.new_idx) return np.broadcast_to(fill_value, tuple(shape)) indexer = self.idx # Indexes real fast, and does outer indexing return pd.api.extensions.take( el, indexer, axis=axis, allow_fill=True, fill_value=fill_value ) def _apply_to_sparse( # noqa: PLR0912 self, el: CSMatrix | CSArray, *, axis, fill_value=None ) -> CSMatrix: if isinstance(el, CupySparseMatrix): from cupyx.scipy import sparse else: from scipy import sparse import array_api_compat xp = array_api_compat.array_namespace(el.data) if fill_value is None: fill_value = default_fill_value([el]) if fill_value != 0: to_fill = self.new_idx.get_indexer(self.new_idx.difference(self.old_idx)) else: to_fill = xp.array([]) # Fixing outer indexing for missing values if el.shape[axis] == 0: shape = list(el.shape) shape[axis] = len(self.new_idx) shape = tuple(shape) if fill_value == 0: if isinstance(el, CSArray): memory_class = sparse.csr_array else: memory_class = sparse.csr_matrix return memory_class(shape) else: return type(el)(xp.broadcast_to(xp.asarray(fill_value), shape)) fill_idxer = None if len(to_fill) > 0 or isinstance(el, CupySparseMatrix): idxmtx_dtype = xp.promote_types(el.dtype, xp.array(fill_value).dtype) else: idxmtx_dtype = bool if isinstance(el, CSArray): memory_class = sparse.coo_array else: memory_class = sparse.coo_matrix if axis == 1: idxmtx = memory_class( ( xp.ones(len(self.new_pos), dtype=idxmtx_dtype), (xp.asarray(self.old_pos), xp.asarray(self.new_pos)), ), shape=(len(self.old_idx), len(self.new_idx)), dtype=idxmtx_dtype, ) out = el @ idxmtx if len(to_fill) > 0: out = out.tocsc() fill_idxer = (slice(None), to_fill) elif axis == 0: idxmtx = memory_class( ( xp.ones(len(self.new_pos), dtype=idxmtx_dtype), (xp.asarray(self.new_pos), xp.asarray(self.old_pos)), ), shape=(len(self.new_idx), len(self.old_idx)), dtype=idxmtx_dtype, ) out = idxmtx @ el if len(to_fill) > 0: out = out.tocsr() fill_idxer = (to_fill, slice(None)) if fill_idxer is not None: out[fill_idxer] = fill_value return out def _apply_to_awkward(self, el: AwkArray, *, axis, fill_value=None): import awkward as ak if self.no_change: return el elif axis == 1: # Indexing by field if self.new_idx.isin(self.old_idx).all(): # inner join return el[self.new_idx] else: # outer join # TODO: this code isn't actually hit, we should refactor msg = "This should be unreachable, please open an issue." raise Exception(msg) else: if len(self.new_idx) > len(self.old_idx): el = ak.pad_none(el, 1, axis=axis) # axis == 0 return el[self.idx] @property def idx(self) -> NDArray[np.intp]: return self.old_idx.get_indexer(self.new_idx) def merge_indices(inds: Iterable[pd.Index], join: Join_T) -> pd.Index: if join == "inner": return reduce(lambda x, y: x.intersection(y), inds) elif join == "outer": return reduce(lambda x, y: x.union(y), inds) else: msg = f"`join` must be one of 'inner' or 'outer', got {join!r}" raise ValueError(msg) def default_fill_value(els): """Given some arrays, returns what the default fill value should be. This is largely due to backwards compat, and might not be the ideal solution. """ if any( isinstance(el, CSMatrix | CSArray) or (isinstance(el, DaskArray) and isinstance(el._meta, CSMatrix | CSArray)) for el in els ): return 0 else: return np.nan def gen_reindexer(new_var: pd.Index, cur_var: pd.Index) -> Reindexer: """ Given a new set of var_names, and a current set, generates a function which will reindex a matrix to be aligned with the new set. Usage ----- >>> a = AnnData(sparse.eye(3, format="csr"), var=pd.DataFrame(index=list("abc"))) >>> b = AnnData(sparse.eye(2, format="csr"), var=pd.DataFrame(index=list("ba"))) >>> reindexer = gen_reindexer(a.var_names, b.var_names) >>> sparse.vstack([a.X, reindexer(b.X)]).toarray() array([[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0., 1., 0.], [1., 0., 0.]]) """ return Reindexer(cur_var, new_var) def np_bool_to_pd_bool_array(df: pd.DataFrame): for col_name, col_type in dict(df.dtypes).items(): if col_type is np.dtype(bool): df[col_name] = pd.array(df[col_name].values) return df def concat_arrays( # noqa: PLR0911, PLR0912 arrays, reindexers, axis=0, index=None, fill_value=None, *, force_lazy: bool = False ): from anndata.experimental.backed._compat import Dataset2D arrays = list(arrays) if fill_value is None: fill_value = default_fill_value(arrays) if any(isinstance(a, Dataset2D) for a in arrays): if any(isinstance(a, pd.DataFrame) for a in arrays): arrays = [to_memory(a) if isinstance(a, Dataset2D) else a for a in arrays] elif not all(isinstance(a, Dataset2D) for a in arrays): msg = f"Cannot concatenate a Dataset2D with other array types {[type(a) for a in arrays if not isinstance(a, Dataset2D)]}." raise ValueError(msg) else: return concat_dataset2d_on_annot_axis( arrays, join="outer", force_lazy=force_lazy ) if any(isinstance(a, pd.DataFrame) for a in arrays): # TODO: This is hacky, 0 is a sentinel for outer_concat_aligned_mapping if not all( isinstance(a, pd.DataFrame) or a is MissingVal or 0 in a.shape for a in arrays ): msg = "Cannot concatenate a dataframe with other array types." raise NotImplementedError(msg) # TODO: behaviour here should be chosen through a merge strategy df = pd.concat( unify_dtypes(f(x) for f, x in zip(reindexers, arrays, strict=True)), axis=axis, ignore_index=True, ) df.index = index return df elif any(isinstance(a, AwkArray) for a in arrays): from ..compat import awkward as ak if not all( isinstance(a, AwkArray) or a is MissingVal or 0 in a.shape for a in arrays ): msg = "Cannot concatenate an AwkwardArray with other array types." raise NotImplementedError(msg) return ak.concatenate( [f(a) for f, a in zip(reindexers, arrays, strict=True)], axis=axis ) elif any(isinstance(a, CupySparseMatrix) for a in arrays): import cupyx.scipy.sparse as cpsparse if not all( isinstance(a, CupySparseMatrix | CupyArray) or 0 in a.shape for a in arrays ): msg = "Cannot concatenate a cupy array with other array types." raise NotImplementedError(msg) sparse_stack = (cpsparse.vstack, cpsparse.hstack)[axis] return sparse_stack( [ f(as_cp_sparse(a), axis=1 - axis, fill_value=fill_value) for f, a in zip(reindexers, arrays, strict=True) ], format="csr", ) elif any(isinstance(a, CupyArray) for a in arrays): import cupy as cp if not all(isinstance(a, CupyArray) or 0 in a.shape for a in arrays): msg = "Cannot concatenate a cupy array with other array types." raise NotImplementedError(msg) return cp.concatenate( [ f(cp.asarray(x), fill_value=fill_value, axis=1 - axis) for f, x in zip(reindexers, arrays, strict=True) ], axis=axis, ) elif any(isinstance(a, CSMatrix | CSArray) for a in arrays): sparse_stack = (sparse.vstack, sparse.hstack)[axis] use_sparse_array = any(issubclass(type(a), CSArray) for a in arrays) mat = sparse_stack( [ f( as_sparse(a, use_sparse_array=use_sparse_array), axis=1 - axis, fill_value=fill_value, ) for f, a in zip(reindexers, arrays, strict=True) ], format="csr", ) return mat else: return np.concatenate( [ f(x, fill_value=fill_value, axis=1 - axis) for f, x in zip(reindexers, arrays, strict=True) ], axis=axis, ) def inner_concat_aligned_mapping( mappings, *, reindexers=None, index=None, axis=0, concat_axis=None, force_lazy: bool = False, ): if concat_axis is None: concat_axis = axis result = {} for k in intersect_keys(mappings): els = [m[k] for m in mappings] if reindexers is None: cur_reindexers = gen_inner_reindexers( els, new_index=index, axis=concat_axis ) else: cur_reindexers = reindexers result[k] = concat_arrays( els, cur_reindexers, index=index, axis=concat_axis, force_lazy=force_lazy ) return result def gen_inner_reindexers(els, new_index, axis: Literal[0, 1] = 0) -> list[Reindexer]: alt_axis = 1 - axis if axis == 0: df_indices = lambda x: x.columns elif axis == 1: df_indices = lambda x: x.indices if all(isinstance(el, pd.DataFrame) for el in els if not_missing(el)): common_ind = reduce( lambda x, y: x.intersection(y), (df_indices(el) for el in els) ) reindexers = [Reindexer(df_indices(el), common_ind) for el in els] elif any(isinstance(el, AwkArray) for el in els if not_missing(el)): if not all(isinstance(el, AwkArray) for el in els if not_missing(el)): msg = "Cannot concatenate an AwkwardArray with other array types." raise NotImplementedError(msg) common_keys = intersect_keys(el.fields for el in els) # TODO: replace dtype=object once this is fixed: https://github.com/scikit-hep/awkward/issues/3730 reindexers = [ Reindexer( pd.Index(el.fields, dtype=object), pd.Index(list(common_keys), dtype=object), ) for el in els ] else: min_ind = min(el.shape[alt_axis] for el in els) reindexers = [ gen_reindexer(pd.RangeIndex(min_ind), pd.RangeIndex(el.shape[alt_axis])) for el in els ] return reindexers def gen_outer_reindexers(els, shapes, new_index: pd.Index, *, axis=0): if all(isinstance(el, pd.DataFrame) for el in els if not_missing(el)): reindexers = [ (lambda x: x) if not_missing(el) else (lambda _, shape=shape: pd.DataFrame(index=range(shape))) for el, shape in zip(els, shapes, strict=True) ] elif any(isinstance(el, AwkArray) for el in els if not_missing(el)): import awkward as ak if not all(isinstance(el, AwkArray) for el in els if not_missing(el)): msg = "Cannot concatenate an AwkwardArray with other array types." raise NotImplementedError(msg) warn_once( "Outer joins on awkward.Arrays will have different return values in the future. " "For details, and to offer input, please see:\n\n\t" "https://github.com/scverse/anndata/issues/898", ExperimentalFeatureWarning, ) # all_keys = union_keys(el.fields for el in els if not_missing(el)) reindexers = [] for el in els: if not_missing(el): reindexers.append(lambda x: x) else: reindexers.append( lambda x: ak.pad_none( ak.Array([]), len(x), 0, ) ) else: max_col = max(el.shape[1] for el in els if not_missing(el)) orig_cols = [el.shape[1] if not_missing(el) else 0 for el in els] reindexers = [ gen_reindexer(pd.RangeIndex(max_col), pd.RangeIndex(n)) for n in orig_cols ] return reindexers def missing_element( n: int, els: list[CSArray | CSMatrix | np.ndarray | DaskArray], axis: Literal[0, 1] = 0, fill_value: Any | None = None, off_axis_size: int = 0, ) -> NDArray[np.bool_] | DaskArray: """Generates value to use when there is a missing element.""" should_return_dask = any(isinstance(el, DaskArray) for el in els) # 0 sized array for in-memory prevents allocating unnecessary memory while preserving broadcasting. shape = (n, off_axis_size) if axis == 0 else (off_axis_size, n) if should_return_dask: import dask.array as da return da.full( shape, default_fill_value(els) if fill_value is None else fill_value ) return np.zeros(shape, dtype=bool) def outer_concat_aligned_mapping( mappings, *, reindexers=None, index=None, axis=0, concat_axis=None, fill_value=None, force_lazy: bool = False, ): if concat_axis is None: concat_axis = axis result = {} ns = [m.parent.shape[axis] for m in mappings] for k in union_keys(mappings): els = [m.get(k, MissingVal) for m in mappings] if reindexers is None: cur_reindexers = gen_outer_reindexers( els, ns, new_index=index, axis=concat_axis ) else: cur_reindexers = reindexers # Dask needs to create a full array and can't do the size-0 trick off_axis_size = 0 if any(isinstance(e, DaskArray) for e in els): if not isinstance(cur_reindexers[0], Reindexer): # pragma: no cover msg = "Cannot re-index a dask array without a Reindexer" raise ValueError(msg) off_axis_size = cur_reindexers[0].idx.shape[0] # Handling of missing values here is hacky for dataframes # We should probably just handle missing elements for all types result[k] = concat_arrays( [ el if not_missing(el) else missing_element( n, axis=concat_axis, els=els, fill_value=fill_value, off_axis_size=off_axis_size, ) for el, n in zip(els, ns, strict=True) ], cur_reindexers, axis=concat_axis, index=index, fill_value=fill_value, force_lazy=force_lazy, ) return result def concat_pairwise_mapping( mappings: Collection[Mapping], shapes: Collection[int], join_keys=intersect_keys ): result = {} if any(any(isinstance(v, CSArray) for v in m.values()) for m in mappings): sparse_class = sparse.csr_array else: sparse_class = sparse.csr_matrix for k in join_keys(mappings): els = [ m.get(k, sparse_class((s, s), dtype=bool)) for m, s in zip(mappings, shapes, strict=True) ] if all(isinstance(el, CupySparseMatrix | CupyArray) for el in els): result[k] = _cp_block_diag(els, format="csr") elif all(isinstance(el, DaskArray) for el in els): result[k] = _dask_block_diag(els) else: result[k] = sparse.block_diag(els, format="csr") return result def merge_dataframes( dfs: Iterable[pd.DataFrame], new_index, merge_strategy=merge_unique ) -> pd.DataFrame: dfs = [df.reindex(index=new_index) for df in dfs] # New dataframe with all shared data new_df = pd.DataFrame(merge_strategy(dfs), index=new_index) return new_df def merge_outer(mappings, batch_keys, *, join_index="-", merge=merge_unique): """ Combine elements of two mappings, such that non-overlapping entries are added with their batch-key appended. Note: this currently does NOT work for nested mappings. Additionally, values are not promised to be unique, and may be overwritten. """ all_keys = union_keys(mappings) out = merge(mappings) for key in all_keys.difference(out.keys()): for b, m in zip(batch_keys, mappings, strict=True): val = m.get(key, None) if val is not None: out[f"{key}{join_index}{b}"] = val return out def _resolve_axis( axis: Literal["obs", 0, "var", 1], ) -> tuple[Literal[0], Literal["obs"]] | tuple[Literal[1], Literal["var"]]: if axis in {0, "obs"}: return (0, "obs") if axis in {1, "var"}: return (1, "var") msg = f"`axis` must be either 0, 1, 'obs', or 'var', was {axis}" raise ValueError(msg) def axis_indices(adata: AnnData, axis: Literal["obs", 0, "var", 1]) -> pd.Index: """Helper function to get adata.{dim}_names.""" _, axis_name = _resolve_axis(axis) attr = getattr(adata, axis_name) if isinstance(attr, Dataset2D): return attr.true_index else: return attr.index # TODO: Resolve https://github.com/scverse/anndata/issues/678 and remove this function def concat_Xs(adatas, reindexers, axis, fill_value): """ Shimy until support for some missing X's is implemented. Basically just checks if it's one of the two supported cases, or throws an error. This is not done inline in `concat` because we don't want to maintain references to the values of a.X. """ Xs = [a.X for a in adatas] if all(X is None for X in Xs): return None elif any(X is None for X in Xs): msg = ( "Some (but not all) of the AnnData's to be concatenated had no .X value. " "Concatenation is currently only implemented for cases where all or none of" " the AnnData's have .X assigned." ) raise NotImplementedError(msg) else: return concat_arrays(Xs, reindexers, axis=axis, fill_value=fill_value) def make_dask_col_from_extension_dtype( col: XDataArray, *, use_only_object_dtype: bool = False ) -> DaskArray: """ Creates dask arrays from :class:`pandas.api.extensions.ExtensionArray` dtype :class:`xarray.DataArray`s. Parameters ---------- col The columns to be converted use_only_object_dtype Whether or not to cast all :class:`pandas.api.extensions.ExtensionArray` dtypes to `object` type, by default False Returns ------- A :class:`dask.Array`: representation of the column. """ import dask.array as da import xarray as xr from xarray.core.indexing import LazilyIndexedArray from anndata._io.specs.lazy_methods import ( compute_chunk_layout_for_axis_size, get_chunksize, maybe_open_h5, ) from anndata.compat import XDataArray from anndata.experimental import read_elem_lazy base_path_or_zarr_group = col.attrs.get("base_path_or_zarr_group") elem_name = col.attrs.get("elem_name") if ( base_path_or_zarr_group is not None and elem_name is not None ): # lazy, backed by store dims = col.dims coords = col.coords.copy() with maybe_open_h5(base_path_or_zarr_group, elem_name) as f: maybe_chunk_size = get_chunksize(read_elem_lazy(f)) chunk_size = ( compute_chunk_layout_for_axis_size( 1000 if maybe_chunk_size is None else maybe_chunk_size[0], col.shape[0], ), ) def get_chunk(block_info=None): # reopening is important to get around h5py's unserializable lock in processes with maybe_open_h5(base_path_or_zarr_group, elem_name) as f: v = read_elem_lazy(f) variable = xr.Variable(data=LazilyIndexedArray(v), dims=dims) data_array = XDataArray( variable, coords=coords, dims=dims, ) idx = tuple( slice(start, stop) for start, stop in block_info[None]["array-location"] ) chunk = np.array(data_array.data[idx]) return chunk if col.dtype == "category" or col.dtype == "string" or use_only_object_dtype: # noqa PLR1714 dtype = "object" else: dtype = col.dtype.numpy_dtype return da.map_blocks( get_chunk, chunks=chunk_size, meta=np.array([], dtype=dtype), dtype=dtype, name=f"{uuid.uuid4()}/{base_path_or_zarr_group}/{elem_name}-{dtype}", ) return da.from_array(col.values, chunks=-1) # in-memory def make_xarray_extension_dtypes_dask( annotations: Iterable[Dataset2D], *, use_only_object_dtype: bool = False ) -> Generator[XDataset, None, None]: """ Creates a generator of Dataset2D objects with dask arrays in place of :class:`pandas.api.extensions.ExtensionArray` dtype columns. Parameters ---------- annotations The datasets to be altered use_only_object_dtype Whether or not to cast all :class:`pandas.api.extensions.ExtensionArray` dtypes to `object` type, by default False Yields ------ An altered dataset. """ for a in annotations: extension_cols = { col for col in a.columns if pd.api.types.is_extension_array_dtype(a[col]) } yield a.copy( data={ name: ( make_dask_col_from_extension_dtype( col, use_only_object_dtype=use_only_object_dtype ) if name in extension_cols else col ) for name, col in a._items() } ) DS_CONCAT_DUMMY_INDEX_NAME = "concat_index" def concat_dataset2d_on_annot_axis( annotations: Iterable[Dataset2D], join: Join_T, *, force_lazy: bool, concat_indices: pd.Index | None = None, ) -> Dataset2D: """Create a concatenate dataset from a list of :class:`~anndata.experimental.backed.Dataset2D` objects. The goal of this function is to mimic `pd.concat(..., ignore_index=True)` so has some complicated logic for handling the "index" to ensure (a) nothing is loaded into memory and (b) the true index is always tracked. Parameters ---------- annotations The :class:`~anndata.experimental.backed.Dataset2D` objects to be concatenated. join Type of join operation force_lazy Whether to lazily concatenate elements using dask even when eager concatenation is possible. concat_indices Already calculated indices to be used as the index on the concatenated object. Returns ------- Concatenated :class:`~anndata.experimental.backed.Dataset2D` """ import xarray as xr from anndata._core.xarray import Dataset2D from anndata._io.specs.lazy_methods import DUMMY_RANGE_INDEX_KEY annotations_re_indexed = [] have_backed = any(a.is_backed for a in annotations) if have_backed or force_lazy: annotations = make_xarray_extension_dtypes_dask(annotations) else: annotations = unify_dtypes(annotations) for a in annotations: old_key = a.index_dim is_fake_index = old_key != a.true_index_dim # First create a dummy index a.ds.coords[DS_CONCAT_DUMMY_INDEX_NAME] = ( old_key, pd.RangeIndex(a.shape[0]), ) # Set all the dimensions to this new dummy index ds_swapped = a.ds.swap_dims({old_key: DS_CONCAT_DUMMY_INDEX_NAME}) # Move the old coordinate into a variable old_coord = ds_swapped.coords[old_key] del ds_swapped.coords[old_key] ds_swapped[old_key] = old_coord a = Dataset2D(ds_swapped) if not is_fake_index: a.true_index_dim = old_key annotations_re_indexed.append(a) # Concat along the dummy index ds_concat = xr.concat( [a.ds for a in annotations_re_indexed], join=join, dim=DS_CONCAT_DUMMY_INDEX_NAME, ) ds_concat.attrs.pop("indexing_key", None) # Wrapping allows us to use the Dataset2D methods # directly for setting certain attrs/coords without duplicating here. ds_concat_2d = Dataset2D(ds_concat) ds_concat_2d.is_backed = have_backed if concat_indices is not None: concat_indices.name = DS_CONCAT_DUMMY_INDEX_NAME ds_concat_2d.index = concat_indices ds_concat = ds_concat_2d.ds else: ds_concat.coords[DS_CONCAT_DUMMY_INDEX_NAME] = pd.RangeIndex( ds_concat.coords[DS_CONCAT_DUMMY_INDEX_NAME].shape[0] ) # Drop any lingering dimensions (swap doesn't delete) ds_concat = ds_concat.drop_dims( d for d in ds_concat.dims if d != DS_CONCAT_DUMMY_INDEX_NAME ) # Create a new true index and then delete the columns resulting from the concatenation for each index. # This includes the dummy column (which is neither a dimension nor a true indexing column) if concat_indices is None: index = xr.concat( [a.true_xr_index for a in annotations_re_indexed], dim=DS_CONCAT_DUMMY_INDEX_NAME, ) # prevent duplicate values index.coords[DS_CONCAT_DUMMY_INDEX_NAME] = ds_concat.coords[ DS_CONCAT_DUMMY_INDEX_NAME ] ds_concat.coords[DS_CONCAT_DUMMY_INDEX_NAME] = index for key in { true_index for a in annotations_re_indexed if (true_index := a.true_index_dim) != a.index_dim }: del ds_concat[key] if DUMMY_RANGE_INDEX_KEY in ds_concat: del ds_concat[DUMMY_RANGE_INDEX_KEY] ds_concat_2d = Dataset2D(ds_concat) return ds_concat_2d def concat( # noqa: PLR0912, PLR0913, PLR0915 adatas: Collection[AnnData] | Mapping[str, AnnData], *, axis: Literal["obs", 0, "var", 1] = "obs", join: Join_T = "inner", merge: StrategiesLiteral | Callable | None = None, uns_merge: StrategiesLiteral | Callable | None = None, label: str | None = None, keys: Collection | None = None, index_unique: str | None = None, fill_value: Any | None = None, pairwise: bool = False, force_lazy: bool = False, ) -> AnnData: """Concatenates AnnData objects along an axis. See the :doc:`concatenation <../concatenation>` section in the docs for a more in-depth description. Params ------ adatas The objects to be concatenated. If a Mapping is passed, keys are used for the `keys` argument and values are concatenated. axis Which axis to concatenate along. join How to align values when concatenating. If "outer", the union of the other axis is taken. If "inner", the intersection. See :doc:`concatenation <../concatenation>` for more. merge How elements not aligned to the axis being concatenated along are selected. Currently implemented strategies include: * `None`: No elements are kept. * `"same"`: Elements that are the same in each of the objects. * `"unique"`: Elements for which there is only one possible value. * `"first"`: The first element seen at each from each position. * `"only"`: Elements that show up in only one of the objects. For :class:`xarray.Dataset` objects, we use their :func:`xarray.merge` with `override` to stay lazy. uns_merge How the elements of `.uns` are selected. Uses the same set of strategies as the `merge` argument, except applied recursively. label Column in axis annotation (i.e. `.obs` or `.var`) to place batch information in. If it's None, no column is added. keys Names for each object being added. These values are used for column values for `label` or appended to the index if `index_unique` is not `None`. Defaults to incrementing integer labels. index_unique Whether to make the index unique by using the keys. If provided, this is the delimiter between "{orig_idx}{index_unique}{key}". When `None`, the original indices are kept. fill_value When `join="outer"`, this is the value that will be used to fill the introduced indices. By default, sparse arrays are padded with zeros, while dense arrays and DataFrames are padded with missing values. pairwise Whether pairwise elements along the concatenated dimension should be included. This is False by default, since the resulting arrays are often not meaningful. force_lazy Whether to lazily concatenate elements using dask even when eager concatenation is possible. At the moment, this only affects obs/var and elements of obsm/varm that are xarray Datasets. Notes ----- .. warning:: If you use `join='outer'` this fills 0s for sparse data when variables are absent in a batch. Use this with care. Dense data is filled with `NaN`. Examples -------- Preparing example objects >>> import anndata as ad, pandas as pd, numpy as np >>> from scipy import sparse >>> a = ad.AnnData( ... X=sparse.csr_matrix(np.array([[0, 1], [2, 3]])), ... obs=pd.DataFrame({"group": ["a", "b"]}, index=["s1", "s2"]), ... var=pd.DataFrame(index=["var1", "var2"]), ... varm={ ... "ones": np.ones((2, 5)), ... "rand": np.random.randn(2, 3), ... "zeros": np.zeros((2, 5)), ... }, ... uns={"a": 1, "b": 2, "c": {"c.a": 3, "c.b": 4}}, ... ) >>> b = ad.AnnData( ... X=sparse.csr_matrix(np.array([[4, 5, 6], [7, 8, 9]])), ... obs=pd.DataFrame( ... {"group": ["b", "c"], "measure": [1.2, 4.3]}, index=["s3", "s4"] ... ), ... var=pd.DataFrame(index=["var1", "var2", "var3"]), ... varm={"ones": np.ones((3, 5)), "rand": np.random.randn(3, 5)}, ... uns={"a": 1, "b": 3, "c": {"c.b": 4}}, ... ) >>> c = ad.AnnData( ... X=sparse.csr_matrix(np.array([[10, 11], [12, 13]])), ... obs=pd.DataFrame({"group": ["a", "b"]}, index=["s1", "s2"]), ... var=pd.DataFrame(index=["var3", "var4"]), ... uns={"a": 1, "b": 4, "c": {"c.a": 3, "c.b": 4, "c.c": 5}}, ... ) Concatenating along different axes >>> ad.concat([a, b]).to_df() var1 var2 s1 0 1 s2 2 3 s3 4 5 s4 7 8 >>> ad.concat([a, c], axis="var").to_df() var1 var2 var3 var4 s1 0 1 10 11 s2 2 3 12 13 Inner and outer joins >>> inner = ad.concat([a, b]) # Joining on intersection of variables >>> inner AnnData object with n_obs × n_vars = 4 × 2 obs: 'group' >>> ( ... inner.obs_names.astype("string"), ... inner.var_names.astype("string"), ... ) # doctest: +NORMALIZE_WHITESPACE (Index(['s1', 's2', 's3', 's4'], dtype='string'), Index(['var1', 'var2'], dtype='string')) >>> outer = ad.concat([a, b], join="outer") # Joining on union of variables >>> outer AnnData object with n_obs × n_vars = 4 × 3 obs: 'group', 'measure' >>> outer.var_names.astype("string") Index(['var1', 'var2', 'var3'], dtype='string') >>> outer.to_df() # Sparse arrays are padded with zeroes by default var1 var2 var3 s1 0 1 0 s2 2 3 0 s3 4 5 6 s4 7 8 9 Using the axis’ index instead of its name >>> ad.concat([a, b], axis=0).to_df() # Equivalent to axis="obs" var1 var2 s1 0 1 s2 2 3 s3 4 5 s4 7 8 >>> ad.concat([a, c], axis=1).to_df() # Equivalent to axis="var" var1 var2 var3 var4 s1 0 1 10 11 s2 2 3 12 13 Keeping track of source objects >>> ad.concat({"a": a, "b": b}, label="batch").obs group batch s1 a a s2 b a s3 b b s4 c b >>> ad.concat([a, b], label="batch", keys=["a", "b"]).obs # Equivalent to previous group batch s1 a a s2 b a s3 b b s4 c b >>> ad.concat({"a": a, "b": b}, index_unique="-").obs group s1-a a s2-a b s3-b b s4-b c Combining values not aligned to axis of concatenation >>> ad.concat([a, b], merge="same") AnnData object with n_obs × n_vars = 4 × 2 obs: 'group' varm: 'ones' >>> ad.concat([a, b], merge="unique") AnnData object with n_obs × n_vars = 4 × 2 obs: 'group' varm: 'ones', 'zeros' >>> ad.concat([a, b], merge="first") AnnData object with n_obs × n_vars = 4 × 2 obs: 'group' varm: 'ones', 'rand', 'zeros' >>> ad.concat([a, b], merge="only") AnnData object with n_obs × n_vars = 4 × 2 obs: 'group' varm: 'zeros' The same merge strategies can be used for elements in `.uns` >>> dict(ad.concat([a, b, c], uns_merge="same").uns) {'a': 1, 'c': {'c.b': 4}} >>> dict(ad.concat([a, b, c], uns_merge="unique").uns) {'a': 1, 'c': {'c.a': 3, 'c.b': 4, 'c.c': 5}} >>> dict(ad.concat([a, b, c], uns_merge="only").uns) {'c': {'c.c': 5}} >>> dict(ad.concat([a, b, c], uns_merge="first").uns) {'a': 1, 'b': 2, 'c': {'c.a': 3, 'c.b': 4, 'c.c': 5}} """ from anndata._core.xarray import Dataset2D from anndata.compat import xarray as xr # Argument normalization merge = resolve_merge_strategy(merge) uns_merge = resolve_merge_strategy(uns_merge) if isinstance(adatas, Mapping): if keys is not None: msg = ( "Cannot specify categories in both mapping keys and using `keys`. " "Only specify this once." ) raise TypeError(msg) keys, adatas = list(adatas.keys()), list(adatas.values()) else: adatas = list(adatas) if keys is None: keys = np.arange(len(adatas)).astype(str) axis, axis_name = _resolve_axis(axis) alt_axis, alt_axis_name = _resolve_axis(axis=1 - axis) # Label column label_col = pd.Categorical.from_codes( np.repeat(np.arange(len(adatas)), [a.shape[axis] for a in adatas]), categories=keys, ) # Combining indexes concat_indices = pd.concat( [axis_indices(a, axis=axis).to_series() for a in adatas], ignore_index=True ) if index_unique is not None: concat_indices = concat_indices.str.cat( label_col.map(str, na_action="ignore"), sep=index_unique ) concat_indices = pd.Index(concat_indices) alt_indices = merge_indices( [axis_indices(a, axis=alt_axis) for a in adatas], join=join ) reindexers = [ gen_reindexer(alt_indices, axis_indices(a, axis=alt_axis)) for a in adatas ] # Annotation for concatenation axis check_combinable_cols([getattr(a, axis_name).columns for a in adatas], join=join) annotations = [getattr(a, axis_name) for a in adatas] are_any_annotations_dataframes = any( isinstance(a, pd.DataFrame) for a in annotations ) if are_any_annotations_dataframes: annotations_in_memory = ( to_memory(a) if isinstance(a, Dataset2D) else a for a in annotations ) concat_annot = pd.concat( unify_dtypes(annotations_in_memory), join=join, ignore_index=True, ) concat_annot.index = concat_indices else: concat_annot = concat_dataset2d_on_annot_axis( annotations, join, force_lazy=force_lazy, concat_indices=concat_indices, ) if label is not None: concat_annot[label] = label_col # Annotation for other axis alt_annotations = [getattr(a, alt_axis_name) for a in adatas] are_any_alt_annotations_dataframes = any( isinstance(a, pd.DataFrame) for a in alt_annotations ) if are_any_alt_annotations_dataframes: alt_annotations_in_memory = [ to_memory(a) if isinstance(a, Dataset2D) else a for a in alt_annotations ] alt_annot = merge_dataframes(alt_annotations_in_memory, alt_indices, merge) else: # TODO: figure out mapping of our merge to theirs instead of just taking first, although this appears to be # the only "lazy" setting so I'm not sure we really want that. # Because of xarray's merge upcasting, it's safest to simply assume that all dtypes are objects. annotations_with_only_dask = list( make_xarray_extension_dtypes_dask( alt_annotations, use_only_object_dtype=True ) ) annotations_with_only_dask = [ a.ds.rename({a.true_index_dim: "merge_index"}) for a in annotations_with_only_dask ] alt_annot = Dataset2D( xr.merge(annotations_with_only_dask, join=join, compat="override") ) alt_annot.true_index_dim = "merge_index" X = concat_Xs(adatas, reindexers, axis=axis, fill_value=fill_value) if join == "inner": concat_aligned_mapping = inner_concat_aligned_mapping join_keys = intersect_keys elif join == "outer": concat_aligned_mapping = partial( outer_concat_aligned_mapping, fill_value=fill_value ) join_keys = union_keys else: msg = f"{join=} should have been validated above by pd.concat" raise AssertionError(msg) layers = concat_aligned_mapping( [a.layers for a in adatas], axis=axis, reindexers=reindexers ) concat_mapping = concat_aligned_mapping( [getattr(a, f"{axis_name}m") for a in adatas], axis=axis, concat_axis=0, index=concat_indices, force_lazy=force_lazy, ) if pairwise: concat_pairwise = concat_pairwise_mapping( mappings=[getattr(a, f"{axis_name}p") for a in adatas], shapes=[a.shape[axis] for a in adatas], join_keys=join_keys, ) else: concat_pairwise = {} # TODO: Reindex lazily, so we don't have to make those copies until we're sure we need the element alt_mapping = merge( [ {k: r(v, axis=0) for k, v in getattr(a, f"{alt_axis_name}m").items()} for r, a in zip(reindexers, adatas, strict=True) ], ) alt_pairwise = merge([ {k: r(r(v, axis=0), axis=1) for k, v in getattr(a, f"{alt_axis_name}p").items()} for r, a in zip(reindexers, adatas, strict=True) ]) uns = uns_merge([a.uns for a in adatas]) raw = None has_raw = [a.raw is not None for a in adatas] if all(has_raw): raw = concat( [ AnnData( X=a.raw.X, obs=pd.DataFrame(index=a.obs_names), var=a.raw.var, varm=a.raw.varm, ) for a in adatas ], join=join, label=label, keys=keys, index_unique=index_unique, fill_value=fill_value, axis=axis, ) elif any(has_raw): msg = ( "Only some AnnData objects have `.raw` attribute, " "not concatenating `.raw` attributes." ) warn(msg, UserWarning, stacklevel=2) return AnnData(**{ "X": X, "layers": layers, axis_name: concat_annot, alt_axis_name: alt_annot, f"{axis_name}m": concat_mapping, f"{alt_axis_name}m": alt_mapping, f"{axis_name}p": concat_pairwise, f"{alt_axis_name}p": alt_pairwise, "uns": uns, "raw": raw, }) scverse-anndata-b796d59/src/anndata/_core/raw.py000066400000000000000000000173651512025555600216140ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import h5py import numpy as np import pandas as pd from scipy.sparse import issparse from ..compat import CupyArray, CupySparseMatrix from .aligned_df import _gen_dataframe from .aligned_mapping import AlignedMappingProperty, AxisArrays from .index import _normalize_index, _subset, get_vector, unpack_index from .sparse_dataset import sparse_dataset if TYPE_CHECKING: from collections.abc import Mapping, Sequence from typing import ClassVar from ..compat import CSMatrix, Index, Index1DNorm from .aligned_mapping import AxisArraysView from .anndata import AnnData from .sparse_dataset import BaseCompressedSparseDataset # TODO: Implement views for Raw class Raw: is_view: ClassVar = False def __init__( self, adata: AnnData, X: np.ndarray | CSMatrix | None = None, var: pd.DataFrame | Mapping[str, Sequence] | None = None, varm: AxisArrays | Mapping[str, np.ndarray] | None = None, ): self._adata = adata self._n_obs = adata.n_obs # construct manually if adata.isbacked == (X is None): # Move from GPU to CPU since it's large and not always used if isinstance(X, CupyArray | CupySparseMatrix): self._X = X.get() else: self._X = X n_var = None if self._X is None else self._X.shape[1] self._var = _gen_dataframe( var, ["var_names"], source="X", attr="var", length=n_var ) self.varm = varm elif X is None: # construct from adata # Move from GPU to CPU since it's large and not always used if isinstance(adata.X, CupyArray | CupySparseMatrix): self._X = adata.X.get() else: self._X = adata.X.copy() self._var = adata.var.copy() self.varm = adata.varm.copy() elif adata.isbacked: msg = "Cannot specify X if adata is backed" raise ValueError(msg) def _get_X(self, layer=None): if layer is not None: raise ValueError() return self.X @property def X(self) -> BaseCompressedSparseDataset | np.ndarray | CSMatrix: # TODO: Handle unsorted array of integer indices for h5py.Datasets if not self._adata.isbacked: return self._X if not self._adata.file.is_open: self._adata.file.open() # Handle legacy file formats: if "raw/X" in self._adata.file: X = self._adata.file["raw/X"] elif "raw.X" in self._adata.file: X = self._adata.file["raw.X"] # Backwards compat else: msg = ( f"Could not find dataset for raw X in file: " f"{self._adata.file.filename}." ) raise AttributeError(msg) if isinstance(X, h5py.Group): X = sparse_dataset(X) # Check if we need to subset if self._adata.is_view: # TODO: As noted above, implement views of raw # so we can know if we need to subset by var return _subset(X, (self._adata._oidx, slice(None))) else: return X @property def shape(self) -> tuple[int, int]: return self.n_obs, self.n_vars @property def var(self) -> pd.DataFrame: return self._var @property def n_vars(self) -> int: return self._var.shape[0] @property def n_obs(self) -> int: return self._n_obs varm: AlignedMappingProperty[AxisArrays | AxisArraysView] = AlignedMappingProperty( "varm", AxisArrays, 1 ) @property def var_names(self) -> pd.Index[str]: return self.var.index @property def obs_names(self) -> pd.Index[str]: return self._adata.obs_names def __getitem__(self, index: Index) -> Raw: oidx, vidx = self._normalize_indices(index) # To preserve two dimensional shape if isinstance(vidx, int | np.integer): vidx = slice(vidx, vidx + 1, 1) if isinstance(oidx, int | np.integer): oidx = slice(oidx, oidx + 1, 1) X = _subset(self.X, (oidx, vidx)) if not self._adata.isbacked else None var = self._var.iloc[vidx] new = Raw(self._adata, X=X, var=var) if self.varm is not None: # Since there is no view of raws new.varm = self.varm._view(_RawViewHack(self, vidx), (vidx,)).copy() return new def __str__(self) -> str: descr = f"Raw AnnData with n_obs × n_vars = {self.n_obs} × {self.n_vars}" for attr in ["var", "varm"]: keys = getattr(self, attr).keys() if len(keys) > 0: descr += f"\n {attr}: {str(list(keys))[1:-1]}" return descr def copy(self) -> Raw: return Raw( self._adata, X=self.X.copy(), var=self.var.copy(), varm=None if self._varm is None else self._varm.copy(), ) def to_adata(self) -> AnnData: """Create full AnnData object.""" from anndata import AnnData return AnnData( X=self.X.copy(), var=self.var.copy(), varm=None if self._varm is None else self._varm.copy(), obs=self._adata.obs.copy(), obsm=self._adata.obsm.copy(), obsp=self._adata.obsp.copy(), uns=self._adata.uns.copy(), ) def _normalize_indices( self, packed_index: Index ) -> tuple[Index1DNorm | int | np.integer, Index1DNorm | int | np.integer]: # deal with slicing with pd.Series if isinstance(packed_index, pd.Series): packed_index = packed_index.values if isinstance(packed_index, tuple): if len(packed_index) != 2: raise IndexDimError(len(packed_index)) if isinstance(packed_index[1], pd.Series): packed_index = packed_index[0], packed_index[1].values if isinstance(packed_index[0], pd.Series): packed_index = packed_index[0].values, packed_index[1] obs, var = unpack_index(packed_index) obs = _normalize_index(obs, self._adata.obs_names) var = _normalize_index(var, self.var_names) return obs, var def var_vector(self, k: str) -> np.ndarray: # TODO decorator to copy AnnData.var_vector docstring return get_vector(self, k, "var", "obs") def obs_vector(self, k: str) -> np.ndarray: # TODO decorator to copy AnnData.obs_vector docstring idx = self._normalize_indices((slice(None), k)) a = self.X[idx] if issparse(a): a = a.toarray() return np.ravel(a) # This exists to accommodate AlignedMappings, # until we implement a proper RawView or get rid of Raw in favor of modes. class _RawViewHack: def __init__(self, raw: Raw, vidx: slice | np.ndarray): self.parent_raw = raw self.vidx = vidx @property def shape(self) -> tuple[int, int]: return self.parent_raw.n_obs, len(self.var_names) @property def obs_names(self) -> pd.Index: return self.parent_raw.obs_names @property def var_names(self) -> pd.Index: return self.parent_raw.var_names[self.vidx] class IndexDimError(IndexError): MSG = ( "You tried to slice an AnnData(View) object with an" "{}-dimensional index, but only 2 dimensions exist in such an object." ) MSG_1D = ( "\nIf you tried to slice cells using adata[cells, ], " "note that Python (unlike R) uses adata[cells, :] as slicing syntax." ) def __init__(self, n_dims: int): msg = self.MSG.format(n_dims) if n_dims == 1: msg += self.MSG_1D super().__init__(msg) scverse-anndata-b796d59/src/anndata/_core/sparse_dataset.py000066400000000000000000000652201512025555600240160ustar00rootroot00000000000000"""\ This module implements on disk sparse datasets. This code is based on and uses the conventions of h5sparse_ by `Appier Inc.`_. See the copyright and license note in this directory source code. .. _h5sparse: https://github.com/appier/h5sparse .. _Appier Inc.: https://www.appier.com/ """ # TODO: # - think about supporting the COO format from __future__ import annotations import warnings from abc import ABC from collections.abc import Iterable from functools import cached_property from importlib.metadata import version from itertools import accumulate, chain, pairwise from math import floor from pathlib import Path from typing import TYPE_CHECKING, NamedTuple import h5py import numpy as np import scipy.sparse as ss from packaging.version import Version from scipy.sparse import _sparsetools from .. import abc from .._settings import settings from ..compat import ( CSArray, CSMatrix, H5Group, ZarrArray, ZarrGroup, _read_attr, is_zarr_v2, ) from .index import _fix_slice_bounds, _subset, unpack_index if TYPE_CHECKING: from collections.abc import Sequence from typing import Literal from scipy.sparse._compressed import _cs_matrix from .._types import GroupStorageType from ..compat import H5Array, Index, Index1D, Index1DNorm else: from scipy.sparse import spmatrix as _cs_matrix SCIPY_1_15 = Version(version("scipy")) >= Version("1.15rc0") class BackedFormat(NamedTuple): format: Literal["csr", "csc"] backed_type: type[BackedSparseMatrix] memory_type: type[_cs_matrix] class BackedSparseMatrix(_cs_matrix): """\ Mixin class for backed sparse matrices. Largely needed for the case `backed_sparse_csr(...)[:]`, since that calls copy on `.data`, `.indices`, and `.indptr`. """ data: GroupStorageType indices: GroupStorageType indptr: np.ndarray def copy(self) -> CSMatrix: if isinstance(self.data, h5py.Dataset): return sparse_dataset(self.data.parent).to_memory() if isinstance(self.data, ZarrArray): import zarr if is_zarr_v2(): sparse_group = zarr.open( store=self.data.store, mode="r", chunk_store=self.data.chunk_store, # chunk_store is needed, not clear why )[Path(self.data.path).parent] else: anndata_group = zarr.open_group(store=self.data.store, mode="r") sparse_group = anndata_group[ str( Path(str(self.data.store_path)) .relative_to(str(anndata_group.store_path)) .parent ) ] return sparse_dataset(sparse_group).to_memory() return super().copy() def _set_many(self, i: Iterable[int], j: Iterable[int], x): """\ Sets value at each (i, j) to x Here (i,j) index major and minor respectively, and must not contain duplicate entries. """ # Scipy 1.3+ compat n_samples = 1 if np.isscalar(x) else len(x) offsets = self._offsets(i, j, n_samples) if -1 not in offsets: # make a list for interaction with h5py offsets = list(offsets) # only affects existing non-zero cells self.data[offsets] = x return else: msg = "You cannot change the sparsity structure of a SparseDataset." raise ValueError(msg) # replace where possible # mask = offsets > -1 # # offsets[mask] # bool_data_mask = np.zeros(len(self.data), dtype=bool) # bool_data_mask[offsets[mask]] = True # self.data[bool_data_mask] = x[mask] # # self.data[offsets[mask]] = x[mask] # # only insertions remain # mask = ~mask # i = i[mask] # i[i < 0] += M # j = j[mask] # j[j < 0] += N # self._insert_many(i, j, x[mask]) def _zero_many(self, i: Sequence[int], j: Sequence[int]): """\ Sets value at each (i, j) to zero, preserving sparsity structure. Here (i,j) index major and minor respectively. """ offsets = self._offsets(i, j, len(i)) # only assign zeros to the existing sparsity structure self.data[list(offsets[offsets > -1])] = 0 def _offsets( self, i: Iterable[int], j: Iterable[int], n_samples: int ) -> np.ndarray: i, j, M, N = self._prepare_indices(i, j) offsets = np.empty(n_samples, dtype=self.indices.dtype) ret = _sparsetools.csr_sample_offsets( M, N, self.indptr, self.indices, n_samples, i, j, offsets ) if ret == 1: # rinse and repeat self.sum_duplicates() _sparsetools.csr_sample_offsets( M, N, self.indptr, self.indices, n_samples, i, j, offsets ) return offsets def _get_contiguous_compressed_slice( self, s: slice ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: new_indptr = self.indptr[s.start : s.stop + 1] # If indptr is cached, we need to make a copy of the subset # so as not to alter the underlying cached data. if isinstance(self.indptr, np.ndarray): new_indptr = new_indptr.copy() start = new_indptr[0] stop = new_indptr[-1] new_indptr -= start new_data = self.data[start:stop] new_indices = self.indices[start:stop] return new_data, new_indices, new_indptr class backed_csr_matrix(BackedSparseMatrix, ss.csr_matrix): def _get_intXslice(self, row: int, col: slice) -> ss.csr_matrix: return ss.csr_matrix( get_compressed_vector(self, row), shape=(1, self.shape[1]) )[:, col] def _get_sliceXslice(self, row: slice, col: slice) -> ss.csr_matrix: row = _fix_slice_bounds(row, self.shape[0]) col = _fix_slice_bounds(col, self.shape[1]) out_shape = ( slice_len(row, self.shape[0]), slice_len(col, self.shape[1]), ) if out_shape[0] == 1: return self._get_intXslice(slice_as_int(row, self.shape[0]), col) if row.step != 1: return self._get_arrayXslice(np.arange(*row.indices(self.shape[0])), col) res = ss.csr_matrix( self._get_contiguous_compressed_slice(row), shape=(out_shape[0], self.shape[1]), ) return res if out_shape[1] == self.shape[1] else res[:, col] def _get_arrayXslice(self, row: Sequence[int], col: slice) -> ss.csr_matrix: idxs = np.asarray(row) if len(idxs) == 0: return ss.csr_matrix((0, self.shape[1])) if idxs.dtype == bool: idxs = np.where(idxs) return ss.csr_matrix( get_compressed_vectors(self, idxs), shape=(len(idxs), self.shape[1]) )[:, col] class backed_csc_matrix(BackedSparseMatrix, ss.csc_matrix): def _get_sliceXint(self, row: slice, col: int) -> ss.csc_matrix: return ss.csc_matrix( get_compressed_vector(self, col), shape=(self.shape[0], 1) )[row, :] def _get_sliceXslice(self, row: slice, col: slice) -> ss.csc_matrix: row = _fix_slice_bounds(row, self.shape[0]) col = _fix_slice_bounds(col, self.shape[1]) out_shape = ( slice_len(row, self.shape[0]), slice_len(col, self.shape[1]), ) if out_shape[1] == 1: return self._get_sliceXint(row, slice_as_int(col, self.shape[1])) if col.step != 1: return self._get_sliceXarray(row, np.arange(*col.indices(self.shape[1]))) res = ss.csc_matrix( self._get_contiguous_compressed_slice(col), shape=(self.shape[0], out_shape[1]), ) return res if out_shape[0] == self.shape[0] else res[row, :] def _get_sliceXarray(self, row: slice, col: Sequence[int]) -> ss.csc_matrix: idxs = np.asarray(col) if len(idxs) == 0: return ss.csc_matrix((self.shape[0], 0)) if idxs.dtype == bool: idxs = np.where(idxs) return ss.csc_matrix( get_compressed_vectors(self, idxs), shape=(self.shape[0], len(idxs)) )[row, :] FORMATS = [ BackedFormat("csr", backed_csr_matrix, ss.csr_matrix), BackedFormat("csc", backed_csc_matrix, ss.csc_matrix), BackedFormat("csr", backed_csr_matrix, ss.csr_array), BackedFormat("csc", backed_csc_matrix, ss.csc_array), ] def slice_len(s: slice, l: int) -> int: """Returns length of `a[s]` where `len(a) == l`.""" return len(range(*s.indices(l))) def slice_as_int(s: slice, l: int) -> int: """Converts slices of length 1 to the integer index they’ll access.""" out = list(range(*s.indices(l))) assert len(out) == 1 return out[0] def get_compressed_vectors( x: BackedSparseMatrix, row_idxs: Iterable[int] ) -> tuple[Sequence, Sequence, Sequence]: indptr_slices = [slice(*(x.indptr[i : i + 2])) for i in row_idxs] # HDF5 cannot handle out-of-order integer indexing if isinstance(x.data, ZarrArray): as_np_indptr = np.concatenate([ np.arange(s.start, s.stop) for s in indptr_slices ]) data = x.data[as_np_indptr] indices = x.indices[as_np_indptr] else: data = np.concatenate([x.data[s] for s in indptr_slices]) indices = np.concatenate([x.indices[s] for s in indptr_slices]) indptr = list(accumulate(chain((0,), (s.stop - s.start for s in indptr_slices)))) return data, indices, indptr def get_compressed_vectors_for_slices( x: BackedSparseMatrix, slices: Iterable[slice] ) -> tuple[Sequence, Sequence, Sequence]: indptr_indices = [x.indptr[slice(s.start, s.stop + 1)] for s in slices] indptr_limits = [slice(i[0], i[-1]) for i in indptr_indices] # HDF5 cannot handle out-of-order integer indexing if isinstance(x.data, ZarrArray): indptr_int = np.concatenate([np.arange(s.start, s.stop) for s in indptr_limits]) data = x.data[indptr_int] indices = x.indices[indptr_int] else: data = np.concatenate([x.data[s] for s in indptr_limits]) indices = np.concatenate([x.indices[s] for s in indptr_limits]) # Need to track the size of the gaps in the slices to each indptr subselection gaps = (s1.start - s0.stop for s0, s1 in pairwise(indptr_limits)) offsets = accumulate(chain([indptr_limits[0].start], gaps)) start_indptr = indptr_indices[0] - next(offsets) if len(slices) < 2: # there is only one slice so no need to concatenate return data, indices, start_indptr end_indptr = np.concatenate([ s[1:] - o for s, o in zip(indptr_indices[1:], offsets, strict=True) ]) indptr = np.concatenate([start_indptr, end_indptr]) return data, indices, indptr def get_compressed_vector( x: BackedSparseMatrix, idx: int ) -> tuple[Sequence, Sequence, Sequence]: s = slice(*(x.indptr[idx : idx + 2])) data = x.data[s] indices = x.indices[s] indptr = [0, len(data)] return data, indices, indptr def subset_by_major_axis_mask( mtx: _cs_matrix, mask: np.ndarray ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: slices = np.ma.extras._ezclump(mask) def mean_slice_length(slices): return floor(sum(s.stop - s.start for s in slices) / len(slices)) # heuristic for whether slicing should be optimized if len(slices) > 0: if mean_slice_length(slices) <= 7: return get_compressed_vectors(mtx, np.where(mask)[0]) else: return get_compressed_vectors_for_slices(mtx, slices) return [], [], [0] def get_memory_class( format: Literal["csr", "csc"], *, use_sparray_in_io: bool = False ) -> type[_cs_matrix]: for fmt, _, memory_class in FORMATS: if format == fmt and ( (use_sparray_in_io and issubclass(memory_class, CSArray)) or (not use_sparray_in_io and issubclass(memory_class, CSMatrix)) ): return memory_class msg = f"Format string {format} is not supported." raise ValueError(msg) def get_backed_class( format: Literal["csr", "csc"], *, use_sparray_in_io: bool = False ) -> type[BackedSparseMatrix]: for fmt, backed_class, _ in FORMATS: if format == fmt and ( (use_sparray_in_io and issubclass(backed_class, CSArray)) or (not use_sparray_in_io and issubclass(backed_class, CSMatrix)) ): return backed_class msg = f"Format string {format} is not supported." raise ValueError(msg) def _get_group_format(group: GroupStorageType) -> str: if "h5sparse_format" in group.attrs: # TODO: Warn about an old format # If this is only just going to be public, I could insist it's not like this return _read_attr(group.attrs, "h5sparse_format") else: # Should this be an extra field? return _read_attr(group.attrs, "encoding-type").replace("_matrix", "") # Check for the overridden few methods above in our BackedSparseMatrix subclasses def is_sparse_indexing_overridden( format: Literal["csr", "csc"], row: Index1D, col: Index1D ): major_indexer, minor_indexer = (row, col) if format == "csr" else (col, row) return isinstance(minor_indexer, slice) and ( isinstance(major_indexer, int | np.integer | slice) or (isinstance(major_indexer, np.ndarray) and major_indexer.ndim == 1) ) def validate_indices( mtx: BackedSparseMatrix, indices: tuple[Index1D, Index1D] ) -> tuple[Index1D, Index1D]: if hasattr(mtx, "_validate_indices"): res = mtx._validate_indices(indices) return res[0] if SCIPY_1_15 else res # https://github.com/scipy/scipy/pull/23267 elif Version(version("scipy")) >= Version("1.17.0rc0"): from scipy.sparse._index import _validate_indices # type: ignore return _validate_indices(indices, mtx.shape, mtx.format)[0] else: # pragma: no cover msg = "Cannot validate indices" raise RuntimeError(msg) class BaseCompressedSparseDataset(abc._AbstractCSDataset, ABC): _group: GroupStorageType _should_cache_indptr: bool def __init__(self, group: GroupStorageType, *, should_cache_indptr: bool = True): type(self)._check_group_format(group) self._group = group self._should_cache_indptr = should_cache_indptr @property def group(self) -> GroupStorageType: """The group underlying the backed matrix.""" return self._group @group.setter def group(self, val): msg = f"Do not reset group on a {type(self)} with {val}. Instead use `sparse_dataset` to make a new class." raise AttributeError(msg) @property def backend(self) -> Literal["zarr", "hdf5"]: """Which file type is used on-disk.""" if isinstance(self.group, ZarrGroup): return "zarr" elif isinstance(self.group, H5Group): return "hdf5" else: msg = f"Unknown group type {type(self.group)}" raise ValueError(msg) @property def dtype(self) -> np.dtype: """The :class:`numpy.dtype` of the `data` attribute of the sparse matrix.""" return self._data.dtype @classmethod def _check_group_format(cls, group): group_format = _get_group_format(group) assert group_format == cls.format @property def _name(self) -> str: """Name of the group.""" return self.group.name @property def shape(self) -> tuple[int, int]: """Shape of the matrix read off disk.""" shape = _read_attr(self.group.attrs, "shape", None) if shape is None: # TODO warn shape = self.group.attrs.get("h5sparse_shape") return tuple(map(int, shape)) def __repr__(self) -> str: name = type(self).__name__.removeprefix("_") return f"{name}: backend {self.backend}, shape {self.shape}, data_dtype {self.dtype}" def __getitem__(self, index: Index | tuple[()]) -> float | CSMatrix | CSArray: indices = self._normalize_index(index) row, col = indices mtx = self._to_backed() row_sp_matrix_validated, col_sp_matrix_validated = validate_indices( mtx, indices ) # Handle masked indexing along major axis if self.format == "csr" and np.array(row).dtype == bool: sub = ss.csr_matrix( subset_by_major_axis_mask(mtx, row), shape=(row.sum(), mtx.shape[1]) )[:, col] elif self.format == "csc" and np.array(col).dtype == bool: sub = ss.csc_matrix( subset_by_major_axis_mask(mtx, col), shape=(mtx.shape[0], col.sum()) )[row, :] # read into memory data if we do not override access methods elif not is_sparse_indexing_overridden( self.format, row_sp_matrix_validated, col_sp_matrix_validated ): sub = self.to_memory()[row_sp_matrix_validated, col_sp_matrix_validated] else: sub = mtx[row, col] # If indexing is array x array it returns a backed_sparse_matrix # Not sure what the performance is on that operation # Also need to check if memory format is not matrix mtx_fmt = get_memory_class( self.format, use_sparray_in_io=settings.use_sparse_array_on_read ) must_convert_to_array = issubclass(mtx_fmt, CSArray) and not isinstance( sub, CSArray ) if isinstance(sub, BackedSparseMatrix) or must_convert_to_array: return mtx_fmt(sub) else: return sub def _normalize_index( self, index: Index | tuple[()] ) -> tuple[np.ndarray, np.ndarray]: if isinstance(index, tuple) and not len(index): index = slice(None) row, col = unpack_index(index) if all(isinstance(x, Iterable) for x in (row, col)): row, col = np.ix_(row, col) return row, col def __setitem__(self, index: Index | tuple[()], value) -> None: msg = ( "__setitem__ for backed sparse will be removed in the next anndata release." ) warnings.warn(msg, FutureWarning, stacklevel=2) row, col = self._normalize_index(index) mock_matrix = self._to_backed() mock_matrix[row, col] = value # TODO: split to other classes? def append(self, sparse_matrix: CSMatrix | CSArray) -> None: # noqa: PLR0912, PLR0915 """Append an in-memory or on-disk sparse matrix to the current object's store. Parameters ---------- sparse_matrix The matrix to append. Raises ------ NotImplementedError If the matrix to append is not one of :class:`~scipy.sparse.csr_array`, :class:`~scipy.sparse.csc_array`, :class:`~scipy.sparse.csr_matrix`, or :class:`~scipy.sparse.csc_matrix`. ValueError If both the on-disk and to-append matrices are not of the same format i.e., `csr` or `csc`. OverflowError If the underlying data store has a 32 bit indptr, and the new matrix is too large to fit in it i.e., would cause a 64 bit `indptr` to be written. AssertionError If the on-disk data does not have `csc` or `csr` format. """ # Prep variables shape = self.shape if isinstance(sparse_matrix, BaseCompressedSparseDataset): sparse_matrix = sparse_matrix._to_backed() # Check input if not ss.issparse(sparse_matrix): msg = ( "Currently, only sparse matrices of equivalent format can be " "appended to a SparseDataset." ) raise NotImplementedError(msg) if self.format not in {"csr", "csc"}: msg = f"The append method for format {self.format} is not implemented." raise NotImplementedError(msg) if self.format != sparse_matrix.format: msg = ( f"Matrices must have same format. Currently are " f"{self.format!r} and {sparse_matrix.format!r}" ) raise ValueError(msg) [indptr_offset] = self.group["indices"].shape if self.group["indptr"].dtype == np.int32: new_nnz = indptr_offset + sparse_matrix.indices.shape[0] if new_nnz >= np.iinfo(np.int32).max: msg = ( "This array was written with a 32 bit intptr, but is now large " "enough to require 64 bit values. Please recreate the array with " "a 64 bit indptr." ) raise OverflowError(msg) # shape if self.format == "csr": assert shape[1] == sparse_matrix.shape[1], ( "CSR matrices must have same size of dimension 1 to be appended." ) new_shape = (shape[0] + sparse_matrix.shape[0], shape[1]) elif self.format == "csc": assert shape[0] == sparse_matrix.shape[0], ( "CSC matrices must have same size of dimension 0 to be appended." ) new_shape = (shape[0], shape[1] + sparse_matrix.shape[1]) else: msg = "We forgot to update this branching to a new format" raise AssertionError(msg) if "h5sparse_shape" in self.group.attrs: del self.group.attrs["h5sparse_shape"] self.group.attrs["shape"] = new_shape # data data = self.group["data"] orig_data_size = data.shape[0] data.resize((orig_data_size + sparse_matrix.data.shape[0],)) # see https://github.com/zarr-developers/zarr-python/discussions/2712 for why we need to read first append_data = sparse_matrix.data append_indices = sparse_matrix.indices if isinstance(sparse_matrix.data, ZarrArray) and not is_zarr_v2(): data[orig_data_size:] = append_data[...] else: data[orig_data_size:] = append_data # indptr indptr = self.group["indptr"] orig_data_size = indptr.shape[0] indptr.resize((orig_data_size + sparse_matrix.indptr.shape[0] - 1,)) indptr[orig_data_size:] = ( sparse_matrix.indptr[1:].astype(np.int64) + indptr_offset ) # indices if isinstance(sparse_matrix.data, ZarrArray) and not is_zarr_v2(): append_indices = append_indices[...] indices = self.group["indices"] orig_data_size = indices.shape[0] indices.resize((orig_data_size + sparse_matrix.indices.shape[0],)) indices[orig_data_size:] = append_indices # Clear cached property for attr in ["_indptr", "_indices", "_data"]: if hasattr(self, attr): delattr(self, attr) @cached_property def _indptr(self) -> np.ndarray: """\ Other than `data` and `indices`, this is only as long as the major axis It should therefore fit into memory, so we cache it for faster access. """ if self._should_cache_indptr: return self.group["indptr"][...] return self.group["indptr"] @cached_property def _indices(self) -> H5Array | ZarrArray: """\ Cache access to the indices to prevent unnecessary reads of the zarray """ return self.group["indices"] @cached_property def _data(self) -> H5Array | ZarrArray: """\ Cache access to the data to prevent unnecessary reads of the zarray """ return self.group["data"] def _to_backed(self) -> BackedSparseMatrix: format_class = get_backed_class(self.format) mtx = format_class(self.shape, dtype=self.dtype) mtx.data = self._data mtx.indices = self._indices mtx.indptr = self._indptr return mtx def to_memory(self) -> CSMatrix | CSArray: format_class = get_memory_class( self.format, use_sparray_in_io=settings.use_sparse_array_on_read ) mtx = format_class(self.shape, dtype=self.dtype) mtx.data = self._data[...] mtx.indices = self._indices[...] mtx.indptr = self._indptr return mtx class _CSRDataset(BaseCompressedSparseDataset, abc.CSRDataset): """Internal concrete version of :class:`anndata.abc.CSRDataset`.""" class _CSCDataset(BaseCompressedSparseDataset, abc.CSCDataset): """Internal concrete version of :class:`anndata.abc.CSRDataset`.""" def sparse_dataset( group: GroupStorageType, *, should_cache_indptr: bool = True, ) -> abc.CSRDataset | abc.CSCDataset: """Generates a backed mode-compatible sparse dataset class. Parameters ---------- group The backing group store. should_cache_indptr Whether or not to cache the indptr for repeated reuse as a :class:`numpy.ndarray`. The default is `True` but one might set it to false if the dataset is repeatedly reopened using this command, and then only a subset is read in before closing again. See https://github.com/scverse/anndata/blob/3c489b979086c39c59d3eb5dad90ebacce3b9a80/src/anndata/_io/specs/lazy_methods.py#L85-L95 for the target use-case. Returns ------- Sparse dataset class. Example ------- First we'll need a stored dataset: >>> import scanpy as sc >>> import h5py >>> from anndata.io import sparse_dataset >>> from anndata.io import read_elem >>> sc.datasets.pbmc68k_reduced().raw.to_adata().write_h5ad("pbmc.h5ad") Initialize a sparse dataset from storage >>> f = h5py.File("pbmc.h5ad") >>> X = sparse_dataset(f["X"]) >>> X CSRDataset: backend hdf5, shape (700, 765), data_dtype float32 Indexing returns sparse matrices >>> X[100:200] # doctest: +ELLIPSIS <...sparse matrix of...float32...with 25003 stored elements...> These can also be used inside of an AnnData object, no need for backed mode >>> from anndata import AnnData >>> adata = AnnData( ... layers={"backed": X}, obs=read_elem(f["obs"]), var=read_elem(f["var"]) ... ) >>> adata.layers["backed"] CSRDataset: backend hdf5, shape (700, 765), data_dtype float32 Indexing access (i.e., from views) brings selection into memory >>> adata[adata.obs["bulk_labels"] == "CD56+ NK"].layers[ ... "backed" ... ] # doctest: +ELLIPSIS <...sparse matrix of...float32...with 7340 stored elements...> """ encoding_type = _get_group_format(group) if encoding_type == "csr": return _CSRDataset(group, should_cache_indptr=should_cache_indptr) elif encoding_type == "csc": return _CSCDataset(group, should_cache_indptr=should_cache_indptr) msg = f"Unknown encoding type {encoding_type}" raise ValueError(msg) @_subset.register(BaseCompressedSparseDataset) def subset_sparsedataset( d, subset_idx: tuple[Index1DNorm] | tuple[Index1DNorm, Index1DNorm] ): return d[subset_idx] scverse-anndata-b796d59/src/anndata/_core/storage.py000066400000000000000000000047731512025555600224660ustar00rootroot00000000000000from __future__ import annotations import warnings from typing import TYPE_CHECKING, get_args import numpy as np import pandas as pd from scipy import sparse from anndata.compat import CSArray, CSMatrix from .._warnings import ImplicitModificationWarning from ..compat import XDataset from ..utils import ( ensure_df_homogeneous, join_english, raise_value_error_if_multiindex_columns, ) from .xarray import Dataset2D if TYPE_CHECKING: from typing import Any def coerce_array( value: Any, *, name: str, allow_df: bool = False, allow_array_like: bool = False, ): """Coerce arrays stored in layers/X, and aligned arrays ({obs,var}{m,p}).""" from ..typing import ArrayDataStructureTypes # If value is a scalar and we allow that, return it if allow_array_like and np.isscalar(value): return value # If value is one of the allowed types, return it array_data_structure_types = get_args(ArrayDataStructureTypes) if isinstance(value, XDataset): value = Dataset2D(value) if isinstance(value, (*array_data_structure_types, Dataset2D)): if isinstance(value, np.matrix): msg = f"{name} should not be a np.matrix, use np.ndarray instead." warnings.warn(msg, ImplicitModificationWarning, stacklevel=3) value = value.A return value is_non_csc_r_array_or_matrix = ( (isinstance(value, base) and not isinstance(value, csr_c_format)) for base, csr_c_format in [ (sparse.spmatrix, CSMatrix), (sparse.sparray, CSArray), ] ) if any(is_non_csc_r_array_or_matrix): msg = f"Only CSR and CSC {'matrices' if isinstance(value, sparse.spmatrix) else 'arrays'} are supported." raise ValueError(msg) if isinstance(value, pd.DataFrame): if allow_df: raise_value_error_if_multiindex_columns(value, name) return value if allow_df else ensure_df_homogeneous(value, name) # if value is an array-like object, try to convert it e = None if allow_array_like: try: # TODO: asarray? asanyarray? return np.array(value) except (ValueError, TypeError) as _e: e = _e # if value isn’t the right type or convertible, raise an error msg = f"{name} needs to be of one of {join_english(map(str, array_data_structure_types))}, not {type(value)}." if e is not None: msg += " (Failed to convert it to an array, see above for details.)" raise ValueError(msg) from e scverse-anndata-b796d59/src/anndata/_core/views.py000066400000000000000000000353331512025555600221530ustar00rootroot00000000000000from __future__ import annotations import warnings from contextlib import contextmanager from copy import deepcopy from functools import reduce, singledispatch, wraps from typing import TYPE_CHECKING, Literal import numpy as np import pandas as pd from pandas.api.types import is_bool_dtype from scipy import sparse from anndata._warnings import ImplicitModificationWarning from .._settings import settings from ..compat import ( AwkArray, CupyArray, CupyCSCMatrix, CupyCSRMatrix, DaskArray, ZappyArray, ) from .access import ElementRef from .xarray import Dataset2D if TYPE_CHECKING: from collections.abc import Callable, Iterable, KeysView, Sequence from typing import Any, ClassVar from numpy.typing import NDArray from anndata import AnnData from ..compat import Index1DNorm @contextmanager def view_update(adata_view: AnnData, attr_name: str, keys: tuple[str, ...]): """Context manager for updating a view of an AnnData object. Contains logic for "actualizing" a view. Yields the object to be modified in-place. Parameters ---------- adata_view A view of an AnnData attr_name Name of the attribute being updated keys Keys to the attribute being updated Yields ------ `adata.attr[key1][key2][keyn]...` """ new = adata_view.copy() attr = getattr(new, attr_name) container = reduce(lambda d, k: d[k], keys, attr) yield container adata_view._init_as_actual(new) class _SetItemMixin: """\ Class which (when values are being set) lets their parent AnnData view know, so it can make a copy of itself. This implements copy-on-modify semantics for views of AnnData objects. """ _view_args: ElementRef | None def __setitem__(self, idx: Any, value: Any): if self._view_args is None: super().__setitem__(idx, value) else: warnings.warn( f"Trying to modify attribute `.{self._view_args.attrname}` of view, " "initializing view as actual.", ImplicitModificationWarning, stacklevel=2, ) with view_update(*self._view_args) as container: container[idx] = value class _ViewMixin(_SetItemMixin): def __init__( self, *args, view_args: tuple[AnnData, str, tuple[str, ...]] | None = None, **kwargs, ): if view_args is not None: view_args = ElementRef(*view_args) self._view_args = view_args super().__init__(*args, **kwargs) # TODO: This makes `deepcopy(obj)` return `obj._view_args.parent._adata_ref`, fix it def __deepcopy__(self, memo): parent, attrname, _keys = self._view_args return deepcopy(getattr(parent._adata_ref, attrname)) _UFuncMethod = Literal["__call__", "reduce", "reduceat", "accumulate", "outer", "inner"] class ArrayView(_SetItemMixin, np.ndarray): def __new__( cls, input_array: Sequence[Any], view_args: tuple[AnnData, str, tuple[str, ...]] | None = None, ): arr = np.asanyarray(input_array).view(cls) if view_args is not None: view_args = ElementRef(*view_args) arr._view_args = view_args return arr def __array_finalize__(self, obj: np.ndarray | None): if obj is not None: self._view_args = getattr(obj, "_view_args", None) def __array_ufunc__( self: ArrayView, ufunc: Callable[..., Any], method: _UFuncMethod, *inputs, out: tuple[np.ndarray, ...] | None = None, **kwargs, ) -> np.ndarray: """Makes numpy ufuncs convert all instances of views to plain arrays. See https://numpy.org/devdocs/user/basics.subclassing.html#array-ufunc-for-ufuncs """ def convert_all(arrs: Iterable[np.ndarray]) -> Iterable[np.ndarray]: return ( arr.view(np.ndarray) if isinstance(arr, ArrayView) else arr for arr in arrs ) if out is None: outputs = (None,) * ufunc.nout else: out = outputs = tuple(convert_all(out)) results = super().__array_ufunc__( ufunc, method, *convert_all(inputs), out=out, **kwargs ) if results is NotImplemented: return NotImplemented if ufunc.nout == 1: results = (results,) results = tuple( (np.asarray(result) if output is None else output) for result, output in zip(results, outputs, strict=True) ) return results[0] if len(results) == 1 else results def keys(self) -> KeysView[str]: # it’s a structured array return self.dtype.names def copy(self, order: str = "C") -> np.ndarray: # we want a conventional array return np.array(self) def toarray(self) -> np.ndarray: return self.copy() # Extends DaskArray # Calls parent __new__ constructor since # even calling astype on a dask array # needs a .compute() call to actually happen. # So no construction by view casting like ArrayView class DaskArrayView(_SetItemMixin, DaskArray): def __new__( cls, input_array: DaskArray, view_args: tuple[AnnData, str, tuple[str, ...]] | None = None, ): arr = super().__new__( cls, dask=input_array.dask, name=input_array.name, chunks=input_array.chunks, dtype=input_array.dtype, meta=input_array._meta, shape=input_array.shape, ) if view_args is not None: view_args = ElementRef(*view_args) arr._view_args = view_args return arr def __array_finalize__(self, obj: DaskArray | None): if obj is not None: self._view_args = getattr(obj, "_view_args", None) def keys(self) -> KeysView[str]: # it’s a structured array return self.dtype.names # Unlike array views, SparseCSRMatrixView and SparseCSCMatrixView # do not propagate through subsetting class SparseCSRMatrixView(_ViewMixin, sparse.csr_matrix): # https://github.com/scverse/anndata/issues/656 def copy(self) -> sparse.csr_matrix: return sparse.csr_matrix(self).copy() class SparseCSCMatrixView(_ViewMixin, sparse.csc_matrix): # https://github.com/scverse/anndata/issues/656 def copy(self) -> sparse.csc_matrix: return sparse.csc_matrix(self).copy() class SparseCSRArrayView(_ViewMixin, sparse.csr_array): # https://github.com/scverse/anndata/issues/656 def copy(self) -> sparse.csr_array: return sparse.csr_array(self).copy() class SparseCSCArrayView(_ViewMixin, sparse.csc_array): # https://github.com/scverse/anndata/issues/656 def copy(self) -> sparse.csc_array: return sparse.csc_array(self).copy() class CupySparseCSRView(_ViewMixin, CupyCSRMatrix): def copy(self) -> CupyCSRMatrix: return CupyCSRMatrix(self).copy() class CupySparseCSCView(_ViewMixin, CupyCSCMatrix): def copy(self) -> CupyCSCMatrix: return CupyCSCMatrix(self).copy() class CupyArrayView(_ViewMixin, CupyArray): def __new__( cls, input_array: Sequence[Any], view_args: tuple[AnnData, str, tuple[str, ...]] | None = None, ): import cupy as cp arr = cp.asarray(input_array).view(type=cls) if view_args is not None: view_args = ElementRef(*view_args) arr._view_args = view_args return arr def copy(self) -> CupyArray: import cupy as cp return cp.array(self).copy() class DictView(_ViewMixin, dict): pass class DataFrameView(_ViewMixin, pd.DataFrame): _metadata: ClassVar = ["_view_args"] @wraps(pd.DataFrame.drop) def drop(self, *args, inplace: bool = False, **kw): if not inplace: return self.copy().drop(*args, **kw) with view_update(*self._view_args) as df: df.drop(*args, inplace=True, **kw) def __setattr__(self, key: str, value: Any): if key == "index": warnings.warn( f"Trying to modify {key} of attribute `.{self._view_args.attrname}` of view, " "initializing view as actual.", ImplicitModificationWarning, stacklevel=2, ) with view_update(*self._view_args) as container: setattr(container, key, value) else: super().__setattr__(key, value) @singledispatch def as_view(obj, view_args): msg = f"No view type has been registered for {type(obj)}" raise NotImplementedError(msg) @as_view.register(np.ndarray) def as_view_array(array, view_args): return ArrayView(array, view_args=view_args) @as_view.register(DaskArray) def as_view_dask_array(array, view_args): return DaskArrayView(array, view_args=view_args) @as_view.register(pd.DataFrame) def as_view_df(df, view_args): if settings.remove_unused_categories: for col in df.columns: if isinstance(df[col].dtype, pd.CategoricalDtype): # TODO: this mode is going away with pd.option_context("mode.chained_assignment", None): df[col] = df[col].cat.remove_unused_categories() return DataFrameView(df, view_args=view_args) @as_view.register(sparse.csr_matrix) def as_view_csr_matrix(mtx, view_args): return SparseCSRMatrixView(mtx, view_args=view_args) @as_view.register(sparse.csc_matrix) def as_view_csc_matrix(mtx, view_args): return SparseCSCMatrixView(mtx, view_args=view_args) @as_view.register(sparse.csr_array) def as_view_csr_array(mtx, view_args): return SparseCSRArrayView(mtx, view_args=view_args) @as_view.register(sparse.csc_array) def as_view_csc_array(mtx, view_args): return SparseCSCArrayView(mtx, view_args=view_args) @as_view.register(dict) def as_view_dict(d, view_args): return DictView(d, view_args=view_args) @as_view.register(ZappyArray) def as_view_zappy(z, view_args): # Previous code says ZappyArray works as view, # but as far as I can tell they’re immutable. return z @as_view.register(CupyArray) def as_view_cupy(array, view_args): return CupyArrayView(array, view_args=view_args) @as_view.register(CupyCSRMatrix) def as_view_cupy_csr(mtx, view_args): return CupySparseCSRView(mtx, view_args=view_args) @as_view.register(CupyCSCMatrix) def as_view_cupy_csc(mtx, view_args): return CupySparseCSCView(mtx, view_args=view_args) @as_view.register(Dataset2D) def _(a: Dataset2D, view_args): return a try: import weakref from ..compat import awkward as ak # Registry to store weak references from AwkwardArrayViews to their parent AnnData container _registry = weakref.WeakValueDictionary() _PARAM_NAME = "_view_args" class AwkwardArrayView(_ViewMixin, AwkArray): @property def _view_args(self): """Override _view_args to retrieve the values from awkward arrays parameters. Awkward arrays cannot be subclassed like other python objects. Instead subclasses need to be attached as "behavior". These "behaviors" cannot take any additional parameters (as we do for other data types to store `_view_args`). Therefore, we need to store `_view_args` using awkward's parameter mechanism. These parameters need to be json-serializable, which is why we can't store ElementRef directly, but need to replace the reference to the parent AnnDataView container with a weak reference. """ parent_key, attrname, keys = self.layout.parameter(_PARAM_NAME) parent = _registry[parent_key] return ElementRef(parent, attrname, keys) def __copy__(self) -> AwkArray: """ Turn the AwkwardArrayView into an actual AwkwardArray with no special behavior. Need to override __copy__ instead of `.copy()` as awkward arrays don't implement `.copy()` and are copied using python's standard copy mechanism in `aligned_mapping.py`. """ array = self # makes a shallow copy and removes the reference to the original AnnData object array = ak.with_parameter(self, _PARAM_NAME, None) array = ak.with_parameter(array, "__list__", None) return array @as_view.register(AwkArray) def as_view_awkarray(array, view_args): parent, attrname, keys = view_args parent_key = f"target-{id(parent)}" _registry[parent_key] = parent # TODO: See https://github.com/scverse/anndata/pull/647#discussion_r963494798_ for more details and # possible strategies to stack behaviors. # A better solution might be based on xarray-style "attrs", once this is implemented # https://github.com/scikit-hep/awkward/issues/1391#issuecomment-1412297114 if type(array).__name__ != "Array": msg = ( "Cannot create a view of an awkward array with __array__ parameter. " "Please open an issue in the AnnData repo and describe your use-case." ) raise NotImplementedError(msg) array = ak.with_parameter(array, _PARAM_NAME, (parent_key, attrname, keys)) array = ak.with_parameter(array, "__list__", "AwkwardArrayView") return array ak.behavior["AwkwardArrayView"] = AwkwardArrayView except ImportError: class AwkwardArrayView: pass def _resolve_idxs( old: tuple[Index1DNorm, Index1DNorm], new: tuple[Index1DNorm, Index1DNorm], adata: AnnData, ) -> tuple[Index1DNorm, Index1DNorm]: o, v = (_resolve_idx(old[i], new[i], adata.shape[i]) for i in (0, 1)) return o, v @singledispatch def _resolve_idx(old: Index1DNorm, new: Index1DNorm, l: Literal[0, 1]) -> Index1DNorm: raise NotImplementedError @_resolve_idx.register(np.ndarray) def _resolve_idx_ndarray( old: NDArray[np.bool_] | NDArray[np.integer], new: Index1DNorm, l: Literal[0, 1] ) -> NDArray[np.bool_] | NDArray[np.integer]: if is_bool_dtype(old) and is_bool_dtype(new): mask_new = np.zeros_like(old) mask_new[np.flatnonzero(old)[new]] = True return mask_new if is_bool_dtype(old): old = np.where(old)[0] return old[new] @_resolve_idx.register(slice) def _resolve_idx_slice( old: slice, new: Index1DNorm, l: Literal[0, 1] ) -> slice | NDArray[np.integer]: if isinstance(new, slice): return _resolve_idx_slice_slice(old, new, l) else: return np.arange(*old.indices(l))[new] def _resolve_idx_slice_slice(old: slice, new: slice, l: Literal[0, 1]) -> slice: r = range(*old.indices(l))[new] # Convert back to slice start, stop, step = r.start, r.stop, r.step if len(r) == 0: stop = start elif stop < 0: stop = None return slice(start, stop, step) scverse-anndata-b796d59/src/anndata/_core/xarray.py000066400000000000000000000403601512025555600223200ustar00rootroot00000000000000from __future__ import annotations import warnings from dataclasses import dataclass from functools import wraps from typing import TYPE_CHECKING, TypeVar, overload import numpy as np import pandas as pd from ..compat import XDataArray, XDataset, XVariable, pandas_as_str if TYPE_CHECKING: from collections.abc import ( Callable, Collection, Hashable, Iterable, Iterator, Mapping, ) from typing import Any, Literal from .._types import Dataset2DIlocIndexer P = TypeVar("P") R = TypeVar("R") def requires_xarray(func: Callable[P, R]) -> Callable[P, R]: @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: try: import xarray # noqa: F401 except ImportError as e: msg = "xarray is required to read dataframes lazily. Please install xarray." raise ImportError(msg) from e return func(*args, **kwargs) return wrapper class Dataset2D: r""" Bases :class:`~collections.abc.Mapping`\ [:class:`~collections.abc.Hashable`, :class:`~xarray.DataArray` | :class:`~anndata.experimental.backed.Dataset2D`\ ] A wrapper class meant to enable working with lazy dataframe data according to :class:`~anndata.AnnData`'s internal API. This class ensures that "dataframe-invariants" are respected, namely that there is only one 1d dim and coord with the same name i.e., like a :class:`pandas.DataFrame`. You should not have to initiate this class yourself. Setting an :class:`xarray.Dataset` into a relevant part of the :class:`~anndata.AnnData` object will attempt to wrap that object in this object, trying to enforce the "dataframe-invariants." Because xarray requires :attr:`xarray.Dataset.coords` to be in-memory, this class provides handling for an out-of-memory index via :attr:`~anndata.experimental.backed.Dataset2D.true_index`. This feature is helpful for loading remote data faster where the index itself may not be initially useful for constructing the object e.g., cell ids. """ @staticmethod def _validate_shape_invariants(ds: XDataset): """ Validate that the dataset has only one dimension, which is the index dimension. This is a requirement for 2D datasets. """ if not isinstance(ds, XDataset): msg = f"Expected an xarray Dataset, found {type(ds)}" raise TypeError(msg) if (is_coords_too_long := (len(ds.coords) != 1)) or len(ds.dims) != 1: string, length, rep = ( ("coordinate", len(ds.coords), ds.coords) if is_coords_too_long else ("dimension", len(ds.dims), ds.dims) ) msg = f"Dataset should have exactly one {string}, found {length}: {rep}" raise ValueError(msg) if next(iter(ds.dims)) != next(iter(ds.coords)): msg = f"Dataset dimension {next(iter(ds.dims))} does not match coordinate {next(iter(ds.coords))}." raise ValueError(msg) def __init__(self, ds: XDataset): Dataset2D._validate_shape_invariants(ds) self._ds = ds @property def ds(self) -> XDataset: """The underlying :class:`xarray.Dataset`.""" return self._ds def keys(self) -> list[Hashable]: return list(iter(self.ds)) @property def is_backed(self) -> bool: """ Check whether or not the object is backed, used to indicate if there are any in-memory objects. Must be externally set, defaults false. """ return self.ds.attrs.get("is_backed", False) @is_backed.setter def is_backed(self, isbacked: bool) -> None: if not isbacked and "is_backed" in self.ds.attrs: del self.ds.attrs["is_backed"] else: self.ds.attrs["is_backed"] = isbacked @property def index_dim(self) -> str: """The underlying computational index i.e., the lone coordinate dimension.""" if len(self.ds.sizes) != 1: msg = f"xarray Dataset should not have more than 1 dims, found {len(self.ds.sizes)} {self.ds.sizes}, {self}" raise ValueError(msg) return next(iter(self.ds.coords.keys())) @property def true_index_dim(self) -> str: """ Because xarray loads its coordinates/indexes in memory, we allow for signaling that a given variable, which is not a coordinate, is the "true" index. For example, the true index may be cell names but loading these over an internet connection may not be desirable or necessary for most use cases such as getting a quick preview of the columns or loading only one column that isn't the index. This property is the key of said variable. The default is `index_dim` if this variable has not been set. """ return self.ds.attrs.get("indexing_key", self.index_dim) @true_index_dim.setter def true_index_dim(self, val: str): if val is None or (val == self.index_dim and "indexing_key" in self.ds.attrs): del self.ds.attrs["indexing_key"] elif val not in self.ds.dims: if val not in self.ds.data_vars: msg = f"Unknown variable `{val}`." raise ValueError(msg) self.ds.attrs["indexing_key"] = val @property def xr_index(self) -> XDataArray: """The coordinate of :attr:`anndata.experimental.backed.Dataset2D.index_dim`""" return self.ds[self.index_dim] @property def index(self) -> pd.Index: """:attr:`~anndata.AnnData` internally looks for :attr:`~pandas.DataFrame.index` so this ensures usability A :class:`pandas.Index` object corresponding to :attr:`anndata.experimental.backed.Dataset2D.index_dim` Returns ------- The index of the of the dataframe as resolved from :attr:`~xarray.Dataset.coords`. """ return self.ds.indexes[self.index_dim] @index.setter def index(self, val) -> None: index_dim = self.index_dim self.ds.coords[index_dim] = (index_dim, val) if isinstance(val, pd.Index) and val.name is not None and val.name != index_dim: self.ds.update(self.ds.rename({self.index_dim: val.name})) del self.ds.coords[index_dim] # without `indexing_key` explicitly set on `self.ds.attrs`, `self.true_index_dim` will use the `self.index_dim` if "indexing_key" in self.ds.attrs: del self.ds.attrs["indexing_key"] @property def true_xr_index(self) -> XDataArray: """The index :class:`~anndata.AnnData` is actually interested in e.g., cell names, for verification.""" return self.ds[self.true_index_dim] @property def true_index(self) -> pd.Index: """:attr:`~anndata.experimental.backed.Dataset2D.true_xr_index` as a :class:`pandas.Index`""" return self.true_xr_index.to_index() @property def shape(self) -> tuple[int, int]: """:attr:`~anndata.AnnData` internally looks for :attr:`~pandas.DataFrame.shape` so this ensures usability Returns ------- The (2D) shape of the dataframe resolved from :attr:`~xarray.Dataset.sizes`. """ return (self.ds.sizes[self.index_dim], len(self.ds)) @property def iloc(self) -> Dataset2DIlocIndexer: """:attr:`~anndata.AnnData` internally looks for :attr:`~pandas.DataFrame.iloc` so this ensures usability Returns ------- Handler class for doing the iloc-style indexing using :meth:`~xarray.Dataset.isel`. """ return IlocGetter(self.ds, self.index_dim) # See https://github.com/pydata/xarray/blob/568f3c1638d2d34373408ce2869028faa3949446/xarray/core/dataset.py#L1239-L1248 # for typing @overload def __getitem__(self, key: Hashable) -> XDataArray: ... @overload def __getitem__(self, key: Collection[Hashable]) -> Dataset2D: ... def __getitem__( self, key: Mapping[Any, Any] | Hashable | Iterable[Hashable] ) -> Dataset2D | XDataArray: ret = self.ds.__getitem__(key) if is_empty := (len(key) == 0 and not isinstance(key, tuple)): # empty Dataset ret.coords[self.index_dim] = self.xr_index if isinstance(ret, XDataset): # If we get an xarray Dataset, we return a Dataset2D as_2d = Dataset2D(ret) if not is_empty and self.true_index_dim not in [ *as_2d.columns, as_2d.index_dim, ]: as_2d[self.true_index_dim] = self.true_index as_2d.is_backed = self.is_backed return as_2d return ret def to_memory(self, *, copy: bool = False) -> pd.DataFrame: """ Converts to :class:`pandas.DataFrame`. The index of the dataframe comes from :attr:`~anndata.experimental.backed.Dataset2D.true_index_dim` if it differs from :attr:`~anndata.experimental.backed.Dataset2D.index_dim`. Parameters ---------- copy Unused argument Returns ------- :class:`pandas.DataFrame` with index set accordingly. """ index_key = self.ds.attrs.get("indexing_key", None) all_columns = {*self.columns, *([] if index_key is None else [index_key])} # https://github.com/pydata/xarray/issues/10419 non_nullable_string_cols = { col for col in all_columns if not self[col].attrs.get("is_nullable_string", False) } df = self.ds.to_dataframe() for col in all_columns - non_nullable_string_cols: df[col] = ( pandas_as_str(df[col]) if col == index_key else df[col].astype("string") ) if df.index.name != index_key and index_key is not None: df = df.set_index(index_key) df.index.name = None # matches old AnnData object return df @property def columns(self) -> pd.Index: """ :class:`~anndata.AnnData` internally looks for :attr:`~pandas.DataFrame.columns` so this ensures usability Returns ------- :class:`pandas.Index` that represents the "columns." """ columns = set(self.ds.keys()) index_key = self.ds.attrs.get("indexing_key", None) if index_key is not None: columns.discard(index_key) return pd.Index(columns) def __setitem__( self, key: Hashable | Iterable[Hashable] | Mapping, value: Any ) -> None: """ Setting can only be performed when the incoming value is “standalone” like :class:`nump.ndarray` to mimic pandas. One can also use the tuple setting style like `ds["foo"] = (ds.index_dim, value)` to set the value, although the index name must match. Similarly, one can use the :class:`xarray.DataArray` but it must have the same (one and only one) dim name/coord name as `self.index_dim`. For supported setter values see :meth:`xarray.Dataset.__setitem__`. """ if key == self.index_dim: msg = f"Cannot set the index dimension {self.index_dim} as if it were a variable. Use `ds.index = ...` instead." raise KeyError(msg) if isinstance(value, tuple): if isinstance(value[0], tuple): if value[0][0] != self.index_dim: msg = f"Dimension tuple should have only {self.index_dim} as its dimension, found {value[0][0]}" raise ValueError(msg) if len(value[0]) > 1: msg = "Dimension tuple is too long." raise ValueError(msg) elif value[0] != self.index_dim: msg = f"Setting value tuple should have first entry {self.index_dim}, found {value[0]}" raise ValueError(msg) elif isinstance(value, XDataArray | XDataset | XVariable): value_typ = type(value).__name__ # https://docs.xarray.dev/en/stable/generated/xarray.Dataset.dims.html#xarray.Dataset.dims # Unfortunately `dims` not the same across data structures. with warnings.catch_warnings(action="ignore"): dims = ( list(value.dims.keys()) if isinstance(value, XDataset) else value.dims ) if ( isinstance(value, XDataArray) and value.name is not None and value.name != key ): msg = f"{value_typ} should have name {key}, found {value.name}" raise ValueError(msg) if len(dims) != 1: msg = f"{value_typ} should have only one dimension, found {len(dims)}" raise ValueError(msg) if dims[0] != self.index_dim: msg = f"{value_typ} should have dimension {self.index_dim}, found {dims[0]}" raise ValueError(msg) if not isinstance(value, XVariable) and ( self.index_dim not in value.coords or value.coords[self.index_dim].name != self.index_dim ): msg = f"{value_typ} should have coordinate {self.index_dim} with same name, found {value.coords} with name {value.coords[next(iter(value.coords.keys()))].name}" raise ValueError(msg) else: # maintain setting behavior of a 2D dataframe i.e., one dim value = (self.index_dim, value) self.ds.__setitem__(key, value) def copy( self, data: Mapping | None = None, *, deep: bool = False, ) -> Dataset2D: """ Return a copy of the Dataset2D object. See :meth:`xarray.Dataset.copy` for more information. """ as_2d = Dataset2D(self.ds.copy(deep=deep, data=data)) as_2d.true_index_dim = self.true_index_dim as_2d.is_backed = self.is_backed return as_2d def __iter__(self) -> Iterator[Hashable]: return iter(self.ds) def __len__(self) -> int: return len(self.ds) @property def dtypes(self) -> pd.Series: """ Return a Series with the dtypes of the variables in the Dataset2D. """ return self.ds.dtypes def equals(self, b: object) -> bool: """Thin wrapper around :meth:`xarray.Dataset.equals`""" if isinstance(b, Dataset2D): b = b.ds return self.ds.equals(b) def reindex( self, index: pd.Index | None = None, axis: Literal[0] = 0, fill_value: Any | None = np.nan, ) -> Dataset2D: """Reindex the current object against a new index. Parameters ---------- index The new index for reindexing, by default None axis Provided for API consistency, should not be called over axis!=0, by default 0 fill_value The value with which to fill in via :meth:`pandas.Series.reindex`, by default np.nan Returns ------- Reindexed dataset. """ index_dim = self.index_dim if axis != 0: # pragma: no cover msg = f"Only axis 0 is supported, got axis: {axis}" raise ValueError(msg) # Dataset.reindex() can't handle ExtensionArrays extension_arrays = { col: data for col, data in self._items() if pd.api.types.is_extension_array_dtype(data.dtype) } el = self.ds.drop_vars(extension_arrays.keys()) el = el.reindex({index_dim: index}, method=None, fill_value=fill_value) for col, data in extension_arrays.items(): el[col] = XDataArray.from_series( pd.Series(data.data, index=self.index).reindex( index.rename(self.index.name) if index is not None else index, fill_value=fill_value, ) ) return Dataset2D(el) # Used "publicly" in src/anndata/_core/merge.py but not intended for public use. def _items(self): for col in self: yield col, self[col] @dataclass(frozen=True) class IlocGetter: _ds: XDataset _coord: str def __getitem__(self, idx) -> Dataset2D: # xarray seems to have some code looking for a second entry in tuples, # so we unpack the tuple if isinstance(idx, tuple) and len(idx) == 1: idx = idx[0] return Dataset2D(self._ds.isel(**{self._coord: idx})) scverse-anndata-b796d59/src/anndata/_io/000077500000000000000000000000001512025555600201145ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/_io/__init__.py000066400000000000000000000005321512025555600222250ustar00rootroot00000000000000from __future__ import annotations import warnings __all__: list[str] = [] def __getattr__(key: str): from .. import io attr = getattr(io, key) msg = ( f"Importing {key} from `anndata._io` is deprecated. " "Please use anndata.io instead." ) warnings.warn(msg, FutureWarning, stacklevel=2) return attr scverse-anndata-b796d59/src/anndata/_io/h5ad.py000066400000000000000000000332301512025555600213100ustar00rootroot00000000000000from __future__ import annotations import re from functools import partial from pathlib import Path from types import MappingProxyType from typing import TYPE_CHECKING, TypeVar, cast from warnings import warn import h5py import numpy as np import pandas as pd from scipy import sparse from anndata._warnings import OldFormatWarning from .._core.anndata import AnnData from .._core.file_backing import filename from .._core.sparse_dataset import BaseCompressedSparseDataset from ..compat import ( CSMatrix, _clean_uns, _decode_structured_array, _from_fixed_length_strings, ) from ..experimental import read_dispatched from .specs import read_elem, write_elem from .specs.registry import IOSpec, write_spec from .utils import ( _read_legacy_raw, idx_chunks_along_axis, no_write_dataset_2d, report_read_key_on_error, report_write_key_on_error, ) if TYPE_CHECKING: from collections.abc import Callable, Collection, Container, Mapping, Sequence from os import PathLike from typing import Any, Literal from .._core.file_backing import AnnDataFileManager from .._core.raw import Raw from .._types import StorageType T = TypeVar("T") @no_write_dataset_2d def write_h5ad( filepath: PathLike[str] | str, adata: AnnData, *, as_dense: Sequence[str] = (), convert_strings_to_categoricals: bool = True, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), **kwargs, ) -> None: """See :meth:`~anndata.AnnData.write_h5ad`.""" if isinstance(as_dense, str): as_dense = [as_dense] if "raw.X" in as_dense: as_dense = list(as_dense) as_dense[as_dense.index("raw.X")] = "raw/X" if any(val not in {"X", "raw/X"} for val in as_dense): msg = "Currently, only `X` and `raw/X` are supported values in `as_dense`" raise NotImplementedError(msg) if "raw/X" in as_dense and adata.raw is None: msg = "Cannot specify writing `raw/X` to dense if it doesn’t exist." raise ValueError(msg) if convert_strings_to_categoricals: adata.strings_to_categoricals() if adata.raw is not None: adata.strings_to_categoricals(adata.raw.var) dataset_kwargs = {**dataset_kwargs, **kwargs} filepath = Path(filepath) mode = "a" if adata.isbacked else "w" if adata.isbacked: # close so that we can reopen below adata.file.close() with h5py.File(filepath, mode) as f: # TODO: Use spec writing system for this # Currently can't use write_dispatched here because this function is also called to do an # inplace update of a backed object, which would delete "/" f = cast("h5py.Group", f["/"]) f.attrs.setdefault("encoding-type", "anndata") f.attrs.setdefault("encoding-version", "0.1.0") _write_x( f, adata, # accessing adata.X reopens adata.file if it’s backed is_backed=adata.isbacked and adata.filename == filepath, as_dense=as_dense, dataset_kwargs=dataset_kwargs, ) _write_raw(f, adata.raw, as_dense=as_dense, dataset_kwargs=dataset_kwargs) write_elem(f, "obs", adata.obs, dataset_kwargs=dataset_kwargs) write_elem(f, "var", adata.var, dataset_kwargs=dataset_kwargs) write_elem(f, "obsm", dict(adata.obsm), dataset_kwargs=dataset_kwargs) write_elem(f, "varm", dict(adata.varm), dataset_kwargs=dataset_kwargs) write_elem(f, "obsp", dict(adata.obsp), dataset_kwargs=dataset_kwargs) write_elem(f, "varp", dict(adata.varp), dataset_kwargs=dataset_kwargs) write_elem(f, "layers", dict(adata.layers), dataset_kwargs=dataset_kwargs) write_elem(f, "uns", dict(adata.uns), dataset_kwargs=dataset_kwargs) def _write_x( f: h5py.Group, adata: AnnData, *, is_backed: bool, as_dense: Container[str], dataset_kwargs: Mapping[str, Any], ) -> None: if "X" in as_dense and isinstance(adata.X, CSMatrix | BaseCompressedSparseDataset): write_sparse_as_dense(f, "X", adata.X, dataset_kwargs=dataset_kwargs) elif is_backed: pass # If adata.isbacked, X should already be up to date elif adata.X is None: f.pop("X", None) else: write_elem(f, "X", adata.X, dataset_kwargs=dataset_kwargs) def _write_raw( f: h5py.Group, raw: Raw, *, as_dense: Container[str], dataset_kwargs: Mapping[str, Any], ) -> None: if "raw/X" in as_dense and isinstance( raw.X, CSMatrix | BaseCompressedSparseDataset ): write_sparse_as_dense(f, "raw/X", raw.X, dataset_kwargs=dataset_kwargs) write_elem(f, "raw/var", raw.var, dataset_kwargs=dataset_kwargs) write_elem(f, "raw/varm", dict(raw.varm), dataset_kwargs=dataset_kwargs) elif raw is not None: write_elem(f, "raw", raw, dataset_kwargs=dataset_kwargs) @report_write_key_on_error @write_spec(IOSpec("array", "0.2.0")) def write_sparse_as_dense( f: h5py.Group, key: str, value: CSMatrix | BaseCompressedSparseDataset, *, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): real_key = None # Flag for if temporary key was used if key in f: if isinstance(value, BaseCompressedSparseDataset) and ( filename(value.group) == filename(f) ): # Write to temporary key before overwriting real_key = key # Transform key to temporary, e.g. raw/X -> raw/_X, or X -> _X key = re.sub(r"(.*)(\w(?!.*/))", r"\1_\2", key.rstrip("/")) else: del f[key] # Wipe before write dset = f.create_dataset(key, shape=value.shape, dtype=value.dtype, **dataset_kwargs) compressed_axis = int(isinstance(value, sparse.csc_matrix)) for idx in idx_chunks_along_axis(value.shape, compressed_axis, 1000): dset[idx] = value[idx].toarray() if real_key is not None: del f[real_key] f[real_key] = f[key] del f[key] def read_h5ad_backed( filename: str | PathLike[str], mode: Literal["r", "r+"] ) -> AnnData: d = dict(filename=filename, filemode=mode) f = h5py.File(filename, mode) attributes = ["obsm", "varm", "obsp", "varp", "uns", "layers"] df_attributes = ["obs", "var"] if "encoding-type" in f.attrs: attributes.extend(df_attributes) else: for k in df_attributes: if k in f: # Backwards compat d[k] = read_dataframe(f[k]) d.update({k: read_elem(f[k]) for k in attributes if k in f}) d["raw"] = _read_raw(f, attrs={"var", "varm"}) adata = AnnData(**d) # Backwards compat to <0.7 if isinstance(f["obs"], h5py.Dataset): _clean_uns(adata) return adata def read_h5ad( filename: PathLike[str] | str, backed: Literal["r", "r+"] | bool | None = None, # noqa: FBT001 *, as_sparse: Sequence[str] = (), as_sparse_fmt: type[CSMatrix] = sparse.csr_matrix, chunk_size: int = 6000, # TODO, probably make this 2d chunks ) -> AnnData: """\ Read `.h5ad`-formatted hdf5 file. Parameters ---------- filename File name of data file. backed If `'r'`, load :class:`~anndata.AnnData` in `backed` mode instead of fully loading it into memory (`memory` mode). If you want to modify backed attributes of the AnnData object, you need to choose `'r+'`. Currently, `backed` only support updates to `X`. That means any changes to other slots like `obs` will not be written to disk in `backed` mode. If you would like save changes made to these slots of a `backed` :class:`~anndata.AnnData`, write them to a new file (see :meth:`~anndata.AnnData.write`). For an example, see :ref:`read-partial`. as_sparse If an array was saved as dense, passing its name here will read it as a sparse_matrix, by chunk of size `chunk_size`. as_sparse_fmt Sparse format class to read elements from `as_sparse` in as. chunk_size Used only when loading sparse dataset that is stored as dense. Loading iterates through chunks of the dataset of this row size until it reads the whole dataset. Higher size means higher memory consumption and higher (to a point) loading speed. """ if backed not in {None, False}: mode = backed if mode is True: mode = "r+" assert mode in {"r", "r+"} return read_h5ad_backed(filename, mode) if as_sparse_fmt not in (sparse.csr_matrix, sparse.csc_matrix): msg = "Dense formats can only be read to CSR or CSC matrices at this time." raise NotImplementedError(msg) as_sparse = [as_sparse] if isinstance(as_sparse, str) else list(as_sparse) for i in range(len(as_sparse)): if as_sparse[i] in {("raw", "X"), "raw.X"}: as_sparse[i] = "raw/X" elif as_sparse[i] not in {"raw/X", "X"}: msg = "Currently only `X` and `raw/X` can be read as sparse." raise NotImplementedError(msg) rdasp = partial( read_dense_as_sparse, sparse_format=as_sparse_fmt, axis_chunk=chunk_size ) with h5py.File(filename, "r") as f: def callback(read_func, elem_name: str, elem: StorageType, iospec: IOSpec): if iospec.encoding_type == "anndata" or elem_name.endswith("/"): return AnnData(**{ # This is covering up backwards compat in the anndata initializer # In most cases we should be able to call `func(elen[k])` instead k: read_dispatched(elem[k], callback) for k in elem if not k.startswith("raw.") }) elif elem_name.startswith("/raw."): return None elif elem_name == "/X" and "X" in as_sparse: return rdasp(elem) elif elem_name == "/raw": return _read_raw(f, as_sparse, rdasp) elif elem_name in {"/obs", "/var"}: # Backwards compat return read_dataframe(elem) return read_func(elem) adata = read_dispatched(f, callback=callback) # Backwards compat (should figure out which version) if "raw.X" in f: raw = AnnData(**_read_raw(f, as_sparse, rdasp)) raw.obs_names = adata.obs_names adata.raw = raw # Backwards compat to <0.7 if isinstance(f["obs"], h5py.Dataset): _clean_uns(adata) return adata def _read_raw( f: h5py.File | AnnDataFileManager, as_sparse: Collection[str] = (), rdasp: Callable[[h5py.Dataset], CSMatrix] | None = None, *, attrs: Collection[str] = ("X", "var", "varm"), ) -> dict: if as_sparse: assert rdasp is not None, "must supply rdasp if as_sparse is supplied" raw = {} if "X" in attrs and "raw/X" in f: read_x = rdasp if "raw/X" in as_sparse else read_elem raw["X"] = read_x(f["raw/X"]) for v in ("var", "varm"): if v in attrs and f"raw/{v}" in f: raw[v] = read_elem(f[f"raw/{v}"]) return _read_legacy_raw(f, raw, read_dataframe, read_elem, attrs=attrs) @report_read_key_on_error def read_dataframe_legacy(dataset: h5py.Dataset) -> pd.DataFrame: """Read pre-anndata 0.7 dataframes.""" msg = ( f"{dataset.name!r} was written with a very old version of AnnData. " "Consider rewriting it." ) warn(msg, OldFormatWarning, stacklevel=2) df = pd.DataFrame( _decode_structured_array( _from_fixed_length_strings(dataset[()]), dtype=dataset.dtype ) ) return df.set_index(df.columns[0]) def read_dataframe(group: h5py.Group | h5py.Dataset) -> pd.DataFrame: """Backwards compat function""" if not isinstance(group, h5py.Group): return read_dataframe_legacy(group) else: return read_elem(group) @report_read_key_on_error def read_dataset(dataset: h5py.Dataset): string_dtype = h5py.check_string_dtype(dataset.dtype) if (string_dtype is not None) and (string_dtype.encoding == "utf-8"): dataset = dataset.asstr() value = dataset[()] if not hasattr(value, "dtype"): return value elif isinstance(value.dtype, str): pass elif issubclass(value.dtype.type, np.bytes_): value = value.astype(str) # Backwards compat, old datasets have strings as one element 1d arrays if len(value) == 1: return value[0] elif len(value.dtype.descr) > 1: # Compound dtype # For backwards compat, now strings are written as variable length value = _decode_structured_array( _from_fixed_length_strings(value), dtype=value.dtype ) if value.shape == (): value = value[()] return value @report_read_key_on_error def read_dense_as_sparse( dataset: h5py.Dataset, sparse_format: CSMatrix, axis_chunk: int ): if sparse_format == sparse.csr_matrix: return read_dense_as_csr(dataset, axis_chunk) elif sparse_format == sparse.csc_matrix: return read_dense_as_csc(dataset, axis_chunk) else: msg = f"Cannot read dense array as type: {sparse_format}" raise ValueError(msg) def read_dense_as_csr(dataset: h5py.Dataset, axis_chunk: int = 6000): sub_matrices = [] for idx in idx_chunks_along_axis(dataset.shape, 0, axis_chunk): dense_chunk = dataset[idx] sub_matrix = sparse.csr_matrix(dense_chunk) sub_matrices.append(sub_matrix) return sparse.vstack(sub_matrices, format="csr") def read_dense_as_csc(dataset: h5py.Dataset, axis_chunk: int = 6000): sub_matrices = [] for idx in idx_chunks_along_axis(dataset.shape, 1, axis_chunk): sub_matrix = sparse.csc_matrix(dataset[idx]) sub_matrices.append(sub_matrix) return sparse.hstack(sub_matrices, format="csc") scverse-anndata-b796d59/src/anndata/_io/read.py000066400000000000000000000371161512025555600214110ustar00rootroot00000000000000from __future__ import annotations import bz2 import gzip from collections import OrderedDict from os import PathLike, fspath from pathlib import Path from types import MappingProxyType from typing import TYPE_CHECKING from warnings import warn import h5py import numpy as np import pandas as pd from scipy import sparse from .. import AnnData from ..compat import old_positionals, pandas_as_str from .utils import is_float if TYPE_CHECKING: from collections.abc import Generator, Iterable, Iterator, Mapping @old_positionals("first_column_names", "dtype") def read_csv( filename: PathLike[str] | str | Iterator[str], delimiter: str | None = ",", *, first_column_names: bool | None = None, dtype: str = "float32", ) -> AnnData: """\ Read `.csv` file. Same as :func:`~anndata.io.read_text` but with default delimiter `','`. Parameters ---------- filename Data file. delimiter Delimiter that separates data within text file. If `None`, will split at arbitrary number of white spaces, which is different from enforcing splitting at single white space `' '`. first_column_names Assume the first column stores row names. dtype Numpy data type. """ return read_text( filename, delimiter, first_column_names=first_column_names, dtype=dtype ) def read_excel( filename: PathLike[str] | str, sheet: str | int, dtype: str = "float32" ) -> AnnData: """\ Read `.xlsx` (Excel) file. Assumes that the first columns stores the row names and the first row the column names. Parameters ---------- filename File name to read from. sheet Name of sheet in Excel file. """ # rely on pandas for reading an excel file from pandas import read_excel df = read_excel(fspath(filename), sheet) X = df.values[:, 1:] row = dict(row_names=pandas_as_str(df.iloc[:, 0]).array) col = dict(col_names=pandas_as_str(df.columns[1:]).array) return AnnData(X, row, col) def read_umi_tools(filename: PathLike[str] | str, dtype=None) -> AnnData: """\ Read a gzipped condensed count matrix from umi_tools. Parameters ---------- filename File name to read from. """ # import pandas for conversion of a dict of dicts into a matrix # import gzip to read a gzipped file :-) table = pd.read_table(filename, dtype={"gene": "category", "cell": "category"}) X = sparse.csr_matrix( (table["count"], (table["cell"].cat.codes, table["gene"].cat.codes)), dtype=dtype, ) obs = pd.DataFrame(index=pd.Index(table["cell"].cat.categories, name="cell")) var = pd.DataFrame(index=pd.Index(table["gene"].cat.categories, name="gene")) return AnnData(X=X, obs=obs, var=var) def read_hdf(filename: PathLike[str] | str, key: str) -> AnnData: """\ Read `.h5` (hdf5) file. Note: Also looks for fields `row_names` and `col_names`. Parameters ---------- filename Filename of data file. key Name of dataset in the file. """ with h5py.File(filename, "r") as f: # the following is necessary in Python 3, because only # a view and not a list is returned keys = list(f) if key == "": msg = ( f"The file {filename} stores the following sheets:\n{keys}\n" f"Call read/read_hdf5 with one of them." ) raise ValueError(msg) # read array X = f[key][()] # try to find row and column names rows_cols = [{}, {}] for iname, name in enumerate(["row_names", "col_names"]): if name in keys: rows_cols[iname][name] = f[name][()] adata = AnnData(X, rows_cols[0], rows_cols[1]) return adata def _fmt_loom_axis_attrs( input: Mapping, idx_name: str, dimm_mapping: Mapping[str, Iterable[str]] ) -> tuple[pd.DataFrame, Mapping[str, np.ndarray]]: axis_df = pd.DataFrame() axis_mapping = {} for key, names in dimm_mapping.items(): axis_mapping[key] = np.array([input.pop(name) for name in names]).T for k, v in input.items(): if v.ndim > 1 and v.shape[1] > 1: axis_mapping[k] = v else: axis_df[k] = v if idx_name in axis_df: axis_df.set_index(idx_name, drop=True, inplace=True) return axis_df, axis_mapping @old_positionals( "sparse", "cleanup", "X_name", "obs_names", "obsm_names", "var_names", "varm_names", "dtype", "obsm_mapping", "varm_mapping", ) def read_loom( # noqa: PLR0912, PLR0913 filename: PathLike[str] | str, *, sparse: bool = True, cleanup: bool = False, X_name: str = "spliced", obs_names: str = "CellID", obsm_names: Mapping[str, Iterable[str]] | None = None, var_names: str = "Gene", varm_names: Mapping[str, Iterable[str]] | None = None, dtype: str = "float32", obsm_mapping: Mapping[str, Iterable[str]] = MappingProxyType({}), varm_mapping: Mapping[str, Iterable[str]] = MappingProxyType({}), **kwargs, ) -> AnnData: """\ Read `.loom`-formatted hdf5 file. This reads the whole file into memory. Beware that you have to explicitly state when you want to read the file as sparse data. Parameters ---------- filename The filename. sparse Whether to read the data matrix as sparse. cleanup Whether to collapse all obs/var fields that only store one unique value into `.uns['loom-.']`. X_name Loompy key with which the data matrix :attr:`~anndata.AnnData.X` is initialized. obs_names Loompy key where the observation/cell names are stored. obsm_mapping Loompy keys which will be constructed into observation matrices var_names Loompy key where the variable/gene names are stored. varm_mapping Loompy keys which will be constructed into variable matrices **kwargs: Arguments to loompy.connect Example ------- .. code:: python pbmc = anndata.io.read_loom( "pbmc.loom", sparse=True, X_name="lognorm", obs_names="cell_names", var_names="gene_names", obsm_mapping={ "X_umap": ["umap_1", "umap_2"] } ) """ # Deprecations if obsm_names is not None: msg = ( "Argument obsm_names has been deprecated in favour of `obsm_mapping`. " "In 0.9 this will be an error." ) warn(msg, FutureWarning, stacklevel=2) if obsm_mapping != {}: msg = ( "Received values for both `obsm_names` and `obsm_mapping`. This is " "ambiguous, only pass `obsm_mapping`." ) raise ValueError(msg) obsm_mapping = obsm_names if varm_names is not None: msg = ( "Argument varm_names has been deprecated in favour of `varm_mapping`. " "In 0.9 this will be an error." ) warn(msg, FutureWarning, stacklevel=2) if varm_mapping != {}: msg = ( "Received values for both `varm_names` and `varm_mapping`. This is " "ambiguous, only pass `varm_mapping`." ) raise ValueError(msg) varm_mapping = varm_names filename = fspath(filename) # allow passing pathlib.Path objects from loompy import connect if TYPE_CHECKING: from loompy import LoomConnection lc: LoomConnection with connect(filename, "r", **kwargs) as lc: assert lc.layers is not None if X_name not in lc.layers: X_name = "" X = lc.layers[X_name].sparse().T.tocsr() if sparse else lc.layers[X_name][()].T X = X.astype(dtype, copy=False) layers = OrderedDict() if X_name != "": layers["matrix"] = ( lc.layers[""].sparse().T.tocsr() if sparse else lc.layers[""][()].T ) for key, layer in lc.layers.items(): if key != "": layers[key] = layer.sparse().T.tocsr() if sparse else layer[()].T # TODO: Figure out the singleton obs elements obs, obsm = _fmt_loom_axis_attrs(dict(lc.col_attrs), obs_names, obsm_mapping) var, varm = _fmt_loom_axis_attrs(dict(lc.row_attrs), var_names, varm_mapping) uns = {} if cleanup: uns_obs = {} for key in obs.columns: if len(obs[key].unique()) == 1: uns_obs[key] = obs[key].iloc[0] del obs[key] if uns_obs: uns["loom-obs"] = uns_obs uns_var = {} for key in var.columns: if len(var[key].unique()) == 1: uns_var[key] = var[key].iloc[0] del var[key] if uns_var: uns["loom-var"] = uns_var adata = AnnData( X, obs=obs, var=var, layers=layers, obsm=obsm if obsm else None, varm=varm if varm else None, uns=uns, ) return adata def read_mtx(filename: PathLike[str] | str, dtype: str = "float32") -> AnnData: """\ Read `.mtx` file. Parameters ---------- filename The filename. dtype Numpy data type. """ from scipy.io import mmread # could be rewritten accounting for dtype to be more performant X = mmread(fspath(filename)).astype(dtype) from scipy.sparse import csr_matrix X = csr_matrix(X) return AnnData(X) @old_positionals("first_column_names", "dtype") def read_text( filename: PathLike[str] | str | Iterator[str], delimiter: str | None = None, *, first_column_names: bool | None = None, dtype: str = "float32", ) -> AnnData: """\ Read `.txt`, `.tab`, `.data` (text) file. Same as :func:`~anndata.io.read_csv` but with default delimiter `None`. Parameters ---------- filename Data file, filename or stream. delimiter Delimiter that separates data within text file. If `None`, will split at arbitrary number of white spaces, which is different from enforcing splitting at single white space `' '`. first_column_names Assume the first column stores row names. dtype Numpy data type. """ if not isinstance(filename, PathLike | str | bytes): return _read_text( filename, delimiter, first_column_names=first_column_names, dtype=dtype ) filename = Path(filename) if filename.suffix == ".gz": with gzip.open(str(filename), mode="rt") as f: return _read_text( f, delimiter, first_column_names=first_column_names, dtype=dtype ) elif filename.suffix == ".bz2": with bz2.open(str(filename), mode="rt") as f: return _read_text( f, delimiter, first_column_names=first_column_names, dtype=dtype ) else: with filename.open() as f: return _read_text( f, delimiter, first_column_names=first_column_names, dtype=dtype ) def _iter_lines(file_like: Iterable[str]) -> Generator[str, None, None]: """Helper for iterating only nonempty lines without line breaks""" for line in file_like: line = line.rstrip("\r\n") if line: yield line def _read_text( # noqa: PLR0912, PLR0915 f: Iterator[str], delimiter: str | None, *, first_column_names: bool | None, dtype: str, ) -> AnnData: comments = [] data = [] lines = _iter_lines(f) col_names = [] row_names = [] # read header and column names for line in lines: if line.startswith("#"): comment = line.lstrip("# ") if comment: comments.append(comment) else: if delimiter is not None and delimiter not in line: msg = f"Did not find delimiter {delimiter!r} in first line." raise ValueError(msg) line_list = line.split(delimiter) # the first column might be row names, so check the last if not is_float(line_list[-1]): col_names = line_list # logg.msg(" assuming first line in file stores column names", v=4) elif not is_float(line_list[0]) or first_column_names: first_column_names = True row_names.append(line_list[0]) data.append(np.array(line_list[1:], dtype=dtype)) else: data.append(np.array(line_list, dtype=dtype)) break if not col_names: # try reading col_names from the last comment line if len(comments) > 0: # logg.msg(" assuming last comment line stores variable names", v=4) col_names = np.array(comments[-1].split()) # just numbers as col_names else: # logg.msg(" did not find column names in file", v=4) col_names = np.arange(len(data[0])).astype(str) col_names = np.array(col_names, dtype=str) # read another line to check if first column contains row names or not if first_column_names is None: first_column_names = False for line in lines: line_list = line.split(delimiter) if first_column_names or not is_float(line_list[0]): # logg.msg(" assuming first column in file stores row names", v=4) first_column_names = True row_names.append(line_list[0]) data.append(np.array(line_list[1:], dtype=dtype)) else: data.append(np.array(line_list, dtype=dtype)) break # if row names are just integers if len(data) > 1 and data[0].size != data[1].size: # logg.msg( # " assuming first row stores column names and first column row names", # v=4, # ) first_column_names = True col_names = np.array(data[0]).astype(int).astype(str) row_names.append(data[1][0].astype(int).astype(str)) data = [data[1][1:]] # parse the file for line in lines: line_list = line.split(delimiter) if first_column_names: row_names.append(line_list[0]) data.append(np.array(line_list[1:], dtype=dtype)) else: data.append(np.array(line_list, dtype=dtype)) # logg.msg(" read data into list of lists", t=True, v=4) # transform to array, this takes a long time and a lot of memory # but it’s actually the same thing as np.genfromtxt does # - we don’t use the latter as it would involve another slicing step # in the end, to separate row_names from float data, slicing takes # a lot of memory and CPU time if data[0].size != data[-1].size: msg = ( f"Length of first line ({data[0].size}) is different " f"from length of last line ({data[-1].size})." ) raise ValueError(msg) data = np.array(data, dtype=dtype) # logg.msg(" constructed array from list of list", t=True, v=4) # transform row_names if not row_names: row_names = np.arange(len(data)).astype(str) # logg.msg(" did not find row names in file", v=4) else: row_names = np.array(row_names) for iname, name in enumerate(row_names): row_names[iname] = name.strip('"') # adapt col_names if necessary if col_names.size > data.shape[1]: col_names = col_names[1:] for iname, name in enumerate(col_names): col_names[iname] = name.strip('"') return AnnData( data, obs=dict(obs_names=row_names), var=dict(var_names=col_names), ) scverse-anndata-b796d59/src/anndata/_io/specs/000077500000000000000000000000001512025555600212315ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/_io/specs/__init__.py000066400000000000000000000006531512025555600233460ustar00rootroot00000000000000from __future__ import annotations from . import lazy_methods, methods from .registry import ( _LAZY_REGISTRY, # noqa: F401 _REGISTRY, # noqa: F401 IOSpec, Reader, Writer, get_spec, read_elem, read_elem_lazy, write_elem, ) __all__ = [ "IOSpec", "Reader", "Writer", "get_spec", "lazy_methods", "methods", "read_elem", "read_elem_lazy", "write_elem", ] scverse-anndata-b796d59/src/anndata/_io/specs/lazy_methods.py000066400000000000000000000314451512025555600243140ustar00rootroot00000000000000from __future__ import annotations from contextlib import contextmanager from functools import partial, singledispatch from pathlib import Path from typing import TYPE_CHECKING, overload import h5py import numpy as np import pandas as pd from scipy import sparse import anndata as ad from anndata._core.file_backing import filename, get_elem_name from anndata._core.xarray import Dataset2D, requires_xarray from anndata.abc import CSCDataset, CSRDataset from anndata.compat import ( NULLABLE_NUMPY_STRING_TYPE, DaskArray, H5Array, H5Group, XDataArray, XDataset, ZarrArray, ZarrGroup, ) from .registry import _LAZY_REGISTRY, IOSpec, read_elem if TYPE_CHECKING: from collections.abc import Generator, Mapping, Sequence from typing import Literal, ParamSpec, TypeVar from anndata.experimental.backed._lazy_arrays import CategoricalArray, MaskedArray from ...compat import CSArray, CSMatrix, H5File from .registry import LazyDataStructures, LazyReader BlockInfo = Mapping[ None, dict[str, Sequence[tuple[int, int]]], ] P = ParamSpec("P") R = TypeVar("R") D = TypeVar("D") @overload @contextmanager def maybe_open_h5( path_or_other: Path, elem_name: str ) -> Generator[H5File, None, None]: ... @overload @contextmanager def maybe_open_h5(path_or_other: D, elem_name: str) -> Generator[D, None, None]: ... @contextmanager def maybe_open_h5( path_or_other: H5File | D, elem_name: str ) -> Generator[H5File | D, None, None]: if not isinstance(path_or_other, Path): yield path_or_other return file = h5py.File(path_or_other, "r") try: yield file[elem_name] finally: file.close() _DEFAULT_STRIDE = 1000 def compute_chunk_layout_for_axis_size( chunk_axis_size: int, full_axis_size: int ) -> tuple[int, ...]: n_strides, rest = np.divmod(full_axis_size, chunk_axis_size) chunk = (chunk_axis_size,) * n_strides if rest > 0: chunk += (rest,) return chunk def make_dask_chunk( path_or_sparse_dataset: Path | D, elem_name: str, block_info: BlockInfo | None = None, ) -> CSMatrix | CSArray: if block_info is None: msg = "Block info is required" raise ValueError(msg) # We need to open the file in each task since `dask` cannot share h5py objects when using `dask.distributed` # https://github.com/scverse/anndata/issues/1105 with maybe_open_h5(path_or_sparse_dataset, elem_name) as f: # See https://github.com/scverse/anndata/pull/2005 for why # should_cache_indptr is False. # The prupose of caching the indptr was when the dataset is reused # which is in general the case but is not here. Hence # caching it on every access to the dataset here is quite costly. mtx = ( ad.io.sparse_dataset(f, should_cache_indptr=False) if isinstance(f, H5Group) else f ) idx = tuple( slice(start, stop) for start, stop in block_info[None]["array-location"] ) chunk = mtx[idx] return chunk @singledispatch def get_chunksize(obj) -> tuple[int, ...]: if hasattr(obj, "chunks"): return obj.chunks msg = "object of type {type(obj)} has no recognized chunks" raise ValueError(msg) @_LAZY_REGISTRY.register_read(H5Group, IOSpec("csc_matrix", "0.1.0")) @_LAZY_REGISTRY.register_read(H5Group, IOSpec("csr_matrix", "0.1.0")) @_LAZY_REGISTRY.register_read(ZarrGroup, IOSpec("csc_matrix", "0.1.0")) @_LAZY_REGISTRY.register_read(ZarrGroup, IOSpec("csr_matrix", "0.1.0")) def read_sparse_as_dask( elem: H5Group | ZarrGroup, *, _reader: LazyReader, chunks: tuple[int, ...] | None = None, # only tuple[int, int] is supported here ) -> DaskArray: import dask.array as da path_or_sparse_dataset = ( Path(filename(elem)) if isinstance(elem, H5Group) else ad.io.sparse_dataset(elem, should_cache_indptr=False) ) elem_name = get_elem_name(elem) shape: tuple[int, int] = tuple(elem.attrs["shape"]) if isinstance(path_or_sparse_dataset, CSRDataset | CSCDataset): dtype = path_or_sparse_dataset.dtype else: dtype = elem["data"].dtype is_csc: bool = elem.attrs["encoding-type"] == "csc_matrix" stride: int = _DEFAULT_STRIDE major_dim, minor_dim = (1, 0) if is_csc else (0, 1) if chunks is not None: if len(chunks) != 2: msg = "`chunks` must be a tuple of two integers" raise ValueError(msg) if chunks[minor_dim] not in {shape[minor_dim], -1, None}: msg = ( "Only the major axis can be chunked. " f"Try setting chunks to {((-1, _DEFAULT_STRIDE) if is_csc else (_DEFAULT_STRIDE, -1))}" ) raise ValueError(msg) stride = ( chunks[major_dim] if chunks[major_dim] not in {None, -1} else shape[major_dim] ) shape_minor, shape_major = shape if is_csc else shape[::-1] chunks_major = compute_chunk_layout_for_axis_size(stride, shape_major) chunks_minor = (shape_minor,) chunk_layout = ( (chunks_minor, chunks_major) if is_csc else (chunks_major, chunks_minor) ) memory_format = sparse.csc_matrix if is_csc else sparse.csr_matrix make_chunk = partial(make_dask_chunk, path_or_sparse_dataset, elem_name) da_mtx = da.map_blocks( make_chunk, dtype=dtype, chunks=chunk_layout, meta=memory_format((0, 0), dtype=dtype), ) return da_mtx def resolve_chunks( elem: H5Array | ZarrArray, chunks_arg: tuple[int, ...] | None, shape: tuple[int, ...], ) -> tuple[int, ...]: shape = tuple(elem.shape) if chunks_arg is not None: # None and -1 on a given axis indicate that one should use the shape # in `dask`'s semantics. return tuple( c if c not in {None, -1} else s for c, s in zip(chunks_arg, shape, strict=True) ) elif elem.chunks is None: # h5 unchunked return tuple(min(_DEFAULT_STRIDE, s) for s in shape) return elem.chunks # TODO: `map_blocks` of a string array in h5py is so insanely slow on benchmarking that in the case someone has # a pure string annotation (not categoricals! or nullables strings!), it's probably better to pay the memory penalty. # In the long run, it might be good to figure out what exactly is going on here but for now, this will do. @_LAZY_REGISTRY.register_read(H5Array, IOSpec("string-array", "0.2.0")) def read_h5_string_array( elem: H5Array, *, _reader: LazyReader, chunks: tuple[int] | None = None, ) -> DaskArray: import dask.array as da chunks = resolve_chunks(elem, chunks, tuple(elem.shape)) return da.from_array(read_elem(elem), chunks=chunks) @_LAZY_REGISTRY.register_read(H5Array, IOSpec("array", "0.2.0")) def read_h5_array( elem: H5Array, *, _reader: LazyReader, chunks: tuple[int, ...] | None = None ) -> DaskArray: import dask.array as da path = Path(elem.file.filename) elem_name: str = elem.name shape = tuple(elem.shape) dtype = elem.dtype chunks = resolve_chunks(elem, chunks, shape) chunk_layout = tuple( compute_chunk_layout_for_axis_size(chunks[i], shape[i]) for i in range(len(shape)) ) make_chunk = partial(make_dask_chunk, path, elem_name) return da.map_blocks( make_chunk, dtype=dtype, chunks=chunk_layout, meta=np.array([]) ) @_LAZY_REGISTRY.register_read(ZarrArray, IOSpec("string-array", "0.2.0")) @_LAZY_REGISTRY.register_read(ZarrArray, IOSpec("array", "0.2.0")) def read_zarr_array( elem: ZarrArray, *, _reader: LazyReader, chunks: tuple[int, ...] | None = None ) -> DaskArray: import dask.array as da return da.from_zarr(elem, chunks=chunks) def _gen_xarray_dict_iterator_from_elems( elem_dict: dict[str, LazyDataStructures], dim_name: str, index: np.NDArray, ) -> Generator[tuple[str, XDataArray], None, None]: from anndata.experimental.backed._lazy_arrays import CategoricalArray, MaskedArray from ...compat import XDataArray from ...compat import xarray as xr for k, v in elem_dict.items(): if isinstance(v, DaskArray) and k != dim_name: data_array = XDataArray(v, coords=[index], dims=[dim_name], name=k) elif isinstance(v, CategoricalArray | MaskedArray) and k != dim_name: variable = xr.Variable( data=xr.core.indexing.LazilyIndexedArray(v), dims=[dim_name] ) data_array = XDataArray( variable, coords=[index], dims=[dim_name], name=k, attrs={ "base_path_or_zarr_group": v.base_path_or_zarr_group, "elem_name": v.elem_name, "is_nullable_string": isinstance(v, MaskedArray) and ( v.dtype == NULLABLE_NUMPY_STRING_TYPE or isinstance(v.dtype, pd.StringDtype | np.dtypes.StringDType) ), }, ) elif k == dim_name: data_array = XDataArray( index, coords=[index], dims=[dim_name], name=dim_name ) else: msg = f"Could not read {k}: {v} from into xarray Dataset2D" raise ValueError(msg) yield k, data_array DUMMY_RANGE_INDEX_KEY = "_anndata_dummy_range_index" @_LAZY_REGISTRY.register_read(ZarrGroup, IOSpec("dataframe", "0.2.0")) @_LAZY_REGISTRY.register_read(H5Group, IOSpec("dataframe", "0.2.0")) @requires_xarray def read_dataframe( elem: H5Group | ZarrGroup, *, _reader: LazyReader, use_range_index: bool = False, chunks: tuple[int] | None = None, ) -> Dataset2D: from xarray.core.indexing import BasicIndexer from ...experimental.backed._lazy_arrays import MaskedArray elem_dict = { k: _reader.read_elem(elem[k], chunks=chunks) for k in [*elem.attrs["column-order"], elem.attrs["_index"]] } # If we use a range index, the coord axis needs to have the special dim name # which is used below as well. if not use_range_index: dim_name = elem.attrs["_index"] # no sense in reading this in multiple times since xarray requires an in-memory index if isinstance(elem_dict[dim_name], DaskArray): index = elem_dict[dim_name].compute() elif isinstance(elem_dict[dim_name], MaskedArray): index = elem_dict[dim_name][BasicIndexer((slice(None),))] else: raise NotImplementedError() else: dim_name = DUMMY_RANGE_INDEX_KEY index = pd.RangeIndex(len(elem_dict[elem.attrs["_index"]])).astype("str") elem_xarray_dict = dict( _gen_xarray_dict_iterator_from_elems(elem_dict, dim_name, index) ) if use_range_index: elem_xarray_dict[DUMMY_RANGE_INDEX_KEY] = XDataArray( index, coords=[index], dims=[DUMMY_RANGE_INDEX_KEY], name=DUMMY_RANGE_INDEX_KEY, ) ds = Dataset2D(XDataset(elem_xarray_dict)) ds.is_backed = True # We ensure the indexing_key attr always points to the true index # so that the roundtrip works even for the `use_range_index` `True` case ds.true_index_dim = elem.attrs["_index"] return ds @_LAZY_REGISTRY.register_read(ZarrGroup, IOSpec("categorical", "0.2.0")) @_LAZY_REGISTRY.register_read(H5Group, IOSpec("categorical", "0.2.0")) @requires_xarray def read_categorical( elem: H5Group | ZarrGroup, *, _reader: LazyReader, ) -> CategoricalArray: from anndata.experimental.backed._lazy_arrays import CategoricalArray base_path_or_zarr_group = ( Path(filename(elem)) if isinstance(elem, H5Group) else elem ) elem_name = get_elem_name(elem) return CategoricalArray( codes=elem["codes"], categories=elem["categories"], ordered=elem.attrs["ordered"], base_path_or_zarr_group=base_path_or_zarr_group, elem_name=elem_name, ) @requires_xarray def read_nullable( elem: H5Group | ZarrGroup, *, encoding_type: Literal[ "nullable-integer", "nullable-boolean", "nullable-string-array" ], _reader: LazyReader, ) -> MaskedArray: from anndata.experimental.backed._lazy_arrays import MaskedArray base_path_or_zarr_group = ( Path(filename(elem)) if isinstance(elem, H5Group) else elem ) elem_name = get_elem_name(elem) return MaskedArray( values=elem["values"], mask=elem.get("mask", None), dtype_str=encoding_type, base_path_or_zarr_group=base_path_or_zarr_group, elem_name=elem_name, ) for dtype in ["integer", "boolean", "string-array"]: for group_type in [ZarrGroup, H5Group]: _LAZY_REGISTRY.register_read(group_type, IOSpec(f"nullable-{dtype}", "0.1.0"))( partial(read_nullable, encoding_type=f"nullable-{dtype}") ) scverse-anndata-b796d59/src/anndata/_io/specs/methods.py000066400000000000000000001325261512025555600232570ustar00rootroot00000000000000from __future__ import annotations import warnings from collections.abc import Mapping from copy import copy from functools import partial from importlib.metadata import version from itertools import product from types import MappingProxyType from typing import TYPE_CHECKING from warnings import warn import h5py import numpy as np import pandas as pd from packaging.version import Version from scipy import sparse import anndata as ad from anndata import AnnData, Raw from anndata._core import views from anndata._core.index import _normalize_indices from anndata._core.merge import intersect_keys from anndata._core.sparse_dataset import _CSCDataset, _CSRDataset, sparse_dataset from anndata._io.utils import check_key, zero_dim_array_as_scalar from anndata._warnings import OldFormatWarning from anndata.compat import ( AwkArray, CupyArray, CupyCSCMatrix, CupyCSRMatrix, DaskArray, H5Array, H5File, H5Group, ZarrArray, ZarrGroup, _decode_structured_array, _from_fixed_length_strings, _read_attr, _require_group_write_dataframe, ) from ..._settings import settings from ...compat import NULLABLE_NUMPY_STRING_TYPE, PANDAS_STRING_ARRAY_TYPES, is_zarr_v2 from .registry import _REGISTRY, IOSpec, read_elem, read_elem_partial if TYPE_CHECKING: from collections.abc import Callable, Iterator from os import PathLike from typing import Any, Literal from numpy import typing as npt from numpy.typing import NDArray from anndata._types import ArrayStorageType, GroupStorageType from anndata.compat import CSArray, CSMatrix from anndata.typing import AxisStorable, InMemoryArrayOrScalarType from .registry import Reader, Writer #################### # Dask utils # #################### try: from dask.utils import SerializableLock as Lock except ImportError: from threading import Lock # to fix https://github.com/dask/distributed/issues/780 GLOBAL_LOCK = Lock() #################### # Dispatch methods # #################### # def is_full_slice(idx): # if isinstance(idx, tuple)len(idx) == 1: # if isinstance(idx, type(None)): # return True # elif idx is Ellipsis: # return True # elif isinstance(idx, tuple): # for el in idx: # if isinstance(el, type(None)): # pass # elif isinstance(el, slice): # if el != slice(None): # return False # else: # return False # return True # return False def zarr_v3_compressor_compat(dataset_kwargs) -> dict: if not is_zarr_v2() and (compressor := dataset_kwargs.pop("compressor", None)): dataset_kwargs["compressors"] = compressor return dataset_kwargs def zarr_v3_sharding(dataset_kwargs) -> dict: if "shards" not in dataset_kwargs and ad.settings.auto_shard_zarr_v3: dataset_kwargs = {**dataset_kwargs, "shards": "auto"} return dataset_kwargs def _to_cpu_mem_wrapper(write_func): """ Wrapper to bring cupy types into cpu memory before writing. Ideally we do direct writing at some point. """ def wrapper( f, k, cupy_val: CupyArray | CupyCSCMatrix | CupyCSRMatrix, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): return write_func( f, k, cupy_val.get(), _writer=_writer, dataset_kwargs=dataset_kwargs ) return wrapper ################################ # Fallbacks / backwards compat # ################################ # Note: there is no need for writing in a backwards compatible format, maybe @_REGISTRY.register_read(H5File, IOSpec("", "")) @_REGISTRY.register_read(H5Group, IOSpec("", "")) @_REGISTRY.register_read(H5Array, IOSpec("", "")) def read_basic( elem: H5File | H5Group | H5Array, *, _reader: Reader ) -> dict[str, InMemoryArrayOrScalarType] | npt.NDArray | CSMatrix | CSArray: from anndata._io import h5ad warn( f"Element '{elem.name}' was written without encoding metadata.", OldFormatWarning, stacklevel=3, ) if isinstance(elem, Mapping): # Backwards compat sparse arrays if "h5sparse_format" in elem.attrs: return sparse_dataset(elem).to_memory() return {k: _reader.read_elem(v) for k, v in dict(elem).items()} elif isinstance(elem, h5py.Dataset): return h5ad.read_dataset(elem) # TODO: Handle legacy @_REGISTRY.register_read(ZarrGroup, IOSpec("", "")) @_REGISTRY.register_read(ZarrArray, IOSpec("", "")) def read_basic_zarr( elem: ZarrGroup | ZarrArray, *, _reader: Reader ) -> dict[str, InMemoryArrayOrScalarType] | npt.NDArray | CSMatrix | CSArray: from anndata._io import zarr warn( f"Element '{elem.name}' was written without encoding metadata.", OldFormatWarning, stacklevel=3, ) if isinstance(elem, ZarrGroup): # Backwards compat sparse arrays if "h5sparse_format" in elem.attrs: return sparse_dataset(elem).to_memory() return {k: _reader.read_elem(v) for k, v in dict(elem).items()} elif isinstance(elem, ZarrArray): return zarr.read_dataset(elem) # TODO: Handle legacy # @_REGISTRY.register_read_partial(IOSpec("", "")) # def read_basic_partial(elem, *, items=None, indices=(slice(None), slice(None))): # if isinstance(elem, Mapping): # return _read_partial(elem, items=items, indices=indices) # elif indices != (slice(None), slice(None)): # return elem[indices] # else: # return elem[()] ########### # AnnData # ########### def read_indices(group): obs_group = group["obs"] obs_idx_elem = obs_group[_read_attr(obs_group.attrs, "_index")] obs_idx = read_elem(obs_idx_elem) var_group = group["var"] var_idx_elem = var_group[_read_attr(var_group.attrs, "_index")] var_idx = read_elem(var_idx_elem) return obs_idx, var_idx def read_partial( # noqa: PLR0913 pth: PathLike[str] | str, *, obs_idx=slice(None), var_idx=slice(None), X=True, obs=None, var=None, obsm=None, varm=None, obsp=None, varp=None, layers=None, uns=None, ) -> ad.AnnData: result = {} with h5py.File(pth, "r") as f: obs_idx, var_idx = _normalize_indices((obs_idx, var_idx), *read_indices(f)) result["obs"] = read_elem_partial( f["obs"], items=obs, indices=(obs_idx, slice(None)) ) result["var"] = read_elem_partial( f["var"], items=var, indices=(var_idx, slice(None)) ) if X: result["X"] = read_elem_partial(f["X"], indices=(obs_idx, var_idx)) else: result["X"] = sparse.csr_matrix((len(result["obs"]), len(result["var"]))) if "obsm" in f: result["obsm"] = _read_partial( f["obsm"], items=obsm, indices=(obs_idx, slice(None)) ) if "varm" in f: result["varm"] = _read_partial( f["varm"], items=varm, indices=(var_idx, slice(None)) ) if "obsp" in f: result["obsp"] = _read_partial( f["obsp"], items=obsp, indices=(obs_idx, obs_idx) ) if "varp" in f: result["varp"] = _read_partial( f["varp"], items=varp, indices=(var_idx, var_idx) ) if "layers" in f: result["layers"] = _read_partial( f["layers"], items=layers, indices=(obs_idx, var_idx) ) if "uns" in f: result["uns"] = _read_partial(f["uns"], items=uns) return ad.AnnData(**result) def _read_partial(group, *, items=None, indices=(slice(None), slice(None))): if group is None: return None keys = intersect_keys((group,)) if items is None else intersect_keys((group, items)) result = {} for k in keys: next_items = items.get(k, None) if isinstance(items, Mapping) else None result[k] = read_elem_partial(group[k], items=next_items, indices=indices) return result @_REGISTRY.register_write(ZarrGroup, AnnData, IOSpec("anndata", "0.1.0")) @_REGISTRY.register_write(H5Group, AnnData, IOSpec("anndata", "0.1.0")) def write_anndata( f: GroupStorageType, k: str, adata: AnnData, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): g = f.require_group(k) if adata.X is not None: _writer.write_elem(g, "X", adata.X, dataset_kwargs=dataset_kwargs) _writer.write_elem(g, "obs", adata.obs, dataset_kwargs=dataset_kwargs) _writer.write_elem(g, "var", adata.var, dataset_kwargs=dataset_kwargs) _writer.write_elem(g, "obsm", dict(adata.obsm), dataset_kwargs=dataset_kwargs) _writer.write_elem(g, "varm", dict(adata.varm), dataset_kwargs=dataset_kwargs) _writer.write_elem(g, "obsp", dict(adata.obsp), dataset_kwargs=dataset_kwargs) _writer.write_elem(g, "varp", dict(adata.varp), dataset_kwargs=dataset_kwargs) _writer.write_elem(g, "layers", dict(adata.layers), dataset_kwargs=dataset_kwargs) _writer.write_elem(g, "uns", dict(adata.uns), dataset_kwargs=dataset_kwargs) _writer.write_elem(g, "raw", adata.raw, dataset_kwargs=dataset_kwargs) @_REGISTRY.register_read(H5Group, IOSpec("anndata", "0.1.0")) @_REGISTRY.register_read(H5Group, IOSpec("raw", "0.1.0")) @_REGISTRY.register_read(H5File, IOSpec("anndata", "0.1.0")) @_REGISTRY.register_read(H5File, IOSpec("raw", "0.1.0")) @_REGISTRY.register_read(ZarrGroup, IOSpec("anndata", "0.1.0")) @_REGISTRY.register_read(ZarrGroup, IOSpec("raw", "0.1.0")) def read_anndata(elem: GroupStorageType | H5File, *, _reader: Reader) -> AnnData: d = {} for k in [ "X", "obs", "var", "obsm", "varm", "obsp", "varp", "layers", "uns", "raw", ]: if k in elem: d[k] = _reader.read_elem(elem[k]) return AnnData(**d) @_REGISTRY.register_write(H5Group, Raw, IOSpec("raw", "0.1.0")) @_REGISTRY.register_write(ZarrGroup, Raw, IOSpec("raw", "0.1.0")) def write_raw( f: GroupStorageType, k: str, raw: Raw, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): g = f.require_group(k) _writer.write_elem(g, "X", raw.X, dataset_kwargs=dataset_kwargs) _writer.write_elem(g, "var", raw.var, dataset_kwargs=dataset_kwargs) _writer.write_elem(g, "varm", dict(raw.varm), dataset_kwargs=dataset_kwargs) ######## # Null # ######## @_REGISTRY.register_read(H5Array, IOSpec("null", "0.1.0")) @_REGISTRY.register_read(ZarrArray, IOSpec("null", "0.1.0")) def read_null(_elem, _reader) -> None: return None @_REGISTRY.register_write(H5Group, type(None), IOSpec("null", "0.1.0")) def write_null_h5py(f, k, _v, _writer, dataset_kwargs=MappingProxyType({})): dataset_kwargs = _remove_scalar_compression_args(dataset_kwargs) f.create_dataset(k, data=h5py.Empty("f"), **dataset_kwargs) @_REGISTRY.register_write(ZarrGroup, type(None), IOSpec("null", "0.1.0")) def write_null_zarr(f, k, _v, _writer, dataset_kwargs=MappingProxyType({})): dataset_kwargs = _remove_scalar_compression_args(dataset_kwargs) # zarr has no first-class null dataset if is_zarr_v2(): import zarr # zarr has no first-class null dataset f.create_dataset(k, data=zarr.empty(()), **dataset_kwargs) else: # TODO: why is this not actually storing the empty info with a f.empty call? # It fails complaining that k doesn't exist when updating the attributes. f.create_array(k, shape=(), dtype="bool") ############ # Mappings # ############ @_REGISTRY.register_read(H5Group, IOSpec("dict", "0.1.0")) @_REGISTRY.register_read(ZarrGroup, IOSpec("dict", "0.1.0")) def read_mapping(elem: GroupStorageType, *, _reader: Reader) -> dict[str, AxisStorable]: return {k: _reader.read_elem(v) for k, v in dict(elem).items()} @_REGISTRY.register_write(H5Group, dict, IOSpec("dict", "0.1.0")) @_REGISTRY.register_write(ZarrGroup, dict, IOSpec("dict", "0.1.0")) def write_mapping( f: GroupStorageType, k: str, v: dict[str, AxisStorable], *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): g = f.require_group(k) for sub_k, sub_v in v.items(): _writer.write_elem(g, sub_k, sub_v, dataset_kwargs=dataset_kwargs) ############## # np.ndarray # ############## @_REGISTRY.register_write(H5Group, list, IOSpec("array", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, list, IOSpec("array", "0.2.0")) def write_list( f: GroupStorageType, k: str, elem: list[AxisStorable], *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): _writer.write_elem(f, k, np.array(elem), dataset_kwargs=dataset_kwargs) # TODO: Is this the right behavior for MaskedArrays? # It's in the `AnnData.concatenate` docstring, but should we keep it? @_REGISTRY.register_write(H5Group, views.ArrayView, IOSpec("array", "0.2.0")) @_REGISTRY.register_write(H5Group, np.ndarray, IOSpec("array", "0.2.0")) @_REGISTRY.register_write(H5Group, np.ma.MaskedArray, IOSpec("array", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, views.ArrayView, IOSpec("array", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, np.ndarray, IOSpec("array", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, np.ma.MaskedArray, IOSpec("array", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, ZarrArray, IOSpec("array", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, H5Array, IOSpec("array", "0.2.0")) @zero_dim_array_as_scalar def write_basic( f: GroupStorageType, k: str, elem: views.ArrayView | np.ndarray | h5py.Dataset | np.ma.MaskedArray | ZarrArray, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): """Write methods which underlying library handles natively.""" dataset_kwargs = dataset_kwargs.copy() dtype = dataset_kwargs.pop("dtype", elem.dtype) if isinstance(f, H5Group) or is_zarr_v2(): f.create_dataset(k, data=elem, shape=elem.shape, dtype=dtype, **dataset_kwargs) else: dataset_kwargs = zarr_v3_compressor_compat(dataset_kwargs) dataset_kwargs = zarr_v3_sharding(dataset_kwargs) f.create_array(k, shape=elem.shape, dtype=dtype, **dataset_kwargs) # see https://github.com/zarr-developers/zarr-python/discussions/2712 if isinstance(elem, ZarrArray | H5Array): f[k][...] = elem[...] else: f[k][...] = elem def _iter_chunks_for_copy( elem: ArrayStorageType, dest: ArrayStorageType ) -> Iterator[slice | tuple[list[slice]]]: """ Returns an iterator of tuples of slices for copying chunks from `elem` to `dest`. * If `dest` has chunks, it will return the chunks of `dest`. * If `dest` is not chunked, we write it in ~100MB chunks or 1000 rows, whichever is larger. """ if dest.chunks and hasattr(dest, "iter_chunks"): return dest.iter_chunks() else: shape = elem.shape # Number of rows that works out to n_rows = max( ad.settings.min_rows_for_chunked_h5_copy, elem.chunks[0] if elem.chunks is not None else 1, ) return (slice(i, min(i + n_rows, shape[0])) for i in range(0, shape[0], n_rows)) @_REGISTRY.register_write(H5Group, H5Array, IOSpec("array", "0.2.0")) @_REGISTRY.register_write(H5Group, ZarrArray, IOSpec("array", "0.2.0")) def write_chunked_dense_array_to_group( f: H5Group, k: str, elem: ArrayStorageType, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): """Write to a h5py.Dataset in chunks. `h5py.Group.create_dataset(..., data: h5py.Dataset)` will load all of `data` into memory before writing. Instead, we will write in chunks to avoid this. We don't need to do this for zarr since zarr handles this automatically. """ dtype = dataset_kwargs.get("dtype", elem.dtype) kwargs = {**dataset_kwargs, "dtype": dtype} dest = f.create_dataset(k, shape=elem.shape, **kwargs) for chunk in _iter_chunks_for_copy(elem, dest): dest[chunk] = elem[chunk] _REGISTRY.register_write(H5Group, CupyArray, IOSpec("array", "0.2.0"))( _to_cpu_mem_wrapper(write_basic) ) _REGISTRY.register_write(ZarrGroup, CupyArray, IOSpec("array", "0.2.0"))( _to_cpu_mem_wrapper(write_basic) ) @_REGISTRY.register_write(ZarrGroup, views.DaskArrayView, IOSpec("array", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, DaskArray, IOSpec("array", "0.2.0")) @_REGISTRY.register_write(H5Group, views.DaskArrayView, IOSpec("array", "0.2.0")) @_REGISTRY.register_write(H5Group, DaskArray, IOSpec("array", "0.2.0")) def write_basic_dask_dask_dense( f: ZarrGroup | H5Group, k: str, elem: DaskArray, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): import dask.array as da dataset_kwargs = dataset_kwargs.copy() is_h5 = isinstance(f, H5Group) if not is_h5: dataset_kwargs = zarr_v3_compressor_compat(dataset_kwargs) dataset_kwargs = zarr_v3_sharding(dataset_kwargs) if is_zarr_v2() or is_h5: g = f.require_dataset(k, shape=elem.shape, dtype=elem.dtype, **dataset_kwargs) else: g = f.require_array(k, shape=elem.shape, dtype=elem.dtype, **dataset_kwargs) da.store(elem, g, scheduler="threads") @_REGISTRY.register_read(H5Array, IOSpec("array", "0.2.0")) @_REGISTRY.register_read(ZarrArray, IOSpec("array", "0.2.0")) @_REGISTRY.register_read(ZarrArray, IOSpec("string-array", "0.2.0")) def read_array(elem: ArrayStorageType, *, _reader: Reader) -> npt.NDArray: return elem[()] @_REGISTRY.register_read_partial(H5Array, IOSpec("array", "0.2.0")) @_REGISTRY.register_read_partial(ZarrArray, IOSpec("string-array", "0.2.0")) def read_array_partial(elem, *, items=None, indices=(slice(None, None))): return elem[indices] @_REGISTRY.register_read_partial(ZarrArray, IOSpec("array", "0.2.0")) def read_zarr_array_partial(elem, *, items=None, indices=(slice(None, None))): return elem.oindex[indices] # arrays of strings @_REGISTRY.register_read(H5Array, IOSpec("string-array", "0.2.0")) def read_string_array(d: H5Array, *, _reader: Reader): return read_array(d.asstr(), _reader=_reader) @_REGISTRY.register_read_partial(H5Array, IOSpec("string-array", "0.2.0")) def read_string_array_partial(d, items=None, indices=slice(None)): return read_array_partial(d.asstr(), items=items, indices=indices) @_REGISTRY.register_write( H5Group, (views.ArrayView, "U"), IOSpec("string-array", "0.2.0") ) @_REGISTRY.register_write( H5Group, (views.ArrayView, "O"), IOSpec("string-array", "0.2.0") ) @_REGISTRY.register_write(H5Group, (np.ndarray, "U"), IOSpec("string-array", "0.2.0")) @_REGISTRY.register_write(H5Group, (np.ndarray, "O"), IOSpec("string-array", "0.2.0")) @_REGISTRY.register_write(H5Group, (np.ndarray, "T"), IOSpec("string-array", "0.2.0")) @zero_dim_array_as_scalar def write_vlen_string_array( f: H5Group, k: str, elem: np.ndarray, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): """Write methods which underlying library handles nativley.""" str_dtype = h5py.special_dtype(vlen=str) f.create_dataset(k, data=elem.astype(str_dtype), dtype=str_dtype, **dataset_kwargs) @_REGISTRY.register_write( ZarrGroup, (views.ArrayView, "U"), IOSpec("string-array", "0.2.0") ) @_REGISTRY.register_write( ZarrGroup, (views.ArrayView, "O"), IOSpec("string-array", "0.2.0") ) @_REGISTRY.register_write(ZarrGroup, (np.ndarray, "U"), IOSpec("string-array", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, (np.ndarray, "O"), IOSpec("string-array", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, (np.ndarray, "T"), IOSpec("string-array", "0.2.0")) @zero_dim_array_as_scalar def write_vlen_string_array_zarr( f: ZarrGroup, k: str, elem: np.ndarray, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): if is_zarr_v2(): import numcodecs if Version(version("numcodecs")) < Version("0.13"): msg = "Old numcodecs version detected. Please update for improved performance and stability." warnings.warn(msg, UserWarning, stacklevel=2) # Workaround for https://github.com/zarr-developers/numcodecs/issues/514 if hasattr(elem, "flags") and not elem.flags.writeable: elem = elem.copy() f.create_dataset( k, shape=elem.shape, dtype=object, object_codec=numcodecs.VLenUTF8(), **dataset_kwargs, ) f[k][:] = elem else: from numcodecs import VLenUTF8 from zarr.core.dtype import VariableLengthUTF8 dataset_kwargs = dataset_kwargs.copy() dataset_kwargs = zarr_v3_compressor_compat(dataset_kwargs) dtype = VariableLengthUTF8() filters, fill_value = None, None if f.metadata.zarr_format == 2: filters, fill_value = [VLenUTF8()], "" dataset_kwargs = zarr_v3_sharding(dataset_kwargs) f.create_array( k, shape=elem.shape, dtype=dtype, filters=filters, fill_value=fill_value, **dataset_kwargs, ) f[k][:] = elem ############### # np.recarray # ############### def _to_hdf5_vlen_strings(value: np.ndarray) -> np.ndarray: """This corrects compound dtypes to work with hdf5 files.""" new_dtype = [] for dt_name, (dt_type, _) in value.dtype.fields.items(): if dt_type.kind in {"U", "O"}: new_dtype.append((dt_name, h5py.special_dtype(vlen=str))) else: new_dtype.append((dt_name, dt_type)) return value.astype(new_dtype) @_REGISTRY.register_read(H5Array, IOSpec("rec-array", "0.2.0")) @_REGISTRY.register_read(ZarrArray, IOSpec("rec-array", "0.2.0")) def read_recarray(d: ArrayStorageType, *, _reader: Reader) -> np.recarray | npt.NDArray: value = d[()] value = _decode_structured_array( _from_fixed_length_strings(value), dtype=value.dtype ) return value @_REGISTRY.register_write(H5Group, (np.ndarray, "V"), IOSpec("rec-array", "0.2.0")) @_REGISTRY.register_write(H5Group, np.recarray, IOSpec("rec-array", "0.2.0")) def write_recarray( f: H5Group, k: str, elem: np.ndarray | np.recarray, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): f.create_dataset(k, data=_to_hdf5_vlen_strings(elem), **dataset_kwargs) @_REGISTRY.register_write(ZarrGroup, (np.ndarray, "V"), IOSpec("rec-array", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, np.recarray, IOSpec("rec-array", "0.2.0")) def write_recarray_zarr( f: ZarrGroup, k: str, elem: np.ndarray | np.recarray, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): from anndata.compat import _to_fixed_length_strings elem = _to_fixed_length_strings(elem) if is_zarr_v2(): f.create_dataset(k, data=elem, shape=elem.shape, **dataset_kwargs) else: dataset_kwargs = dataset_kwargs.copy() dataset_kwargs = zarr_v3_compressor_compat(dataset_kwargs) # https://github.com/zarr-developers/zarr-python/issues/3546 # if "shards" not in dataset_kwargs and ad.settings.auto_shard_zarr_v3: # dataset_kwargs = {**dataset_kwargs, "shards": "auto"} f.create_array(k, shape=elem.shape, dtype=elem.dtype, **dataset_kwargs) f[k][...] = elem ################# # Sparse arrays # ################# def write_sparse_compressed( f: GroupStorageType, key: str, value: CSMatrix | CSArray, *, _writer: Writer, fmt: Literal["csr", "csc"], dataset_kwargs=MappingProxyType({}), ): g = f.require_group(key) g.attrs["shape"] = value.shape dataset_kwargs = dict(dataset_kwargs) indptr_dtype = dataset_kwargs.pop("indptr_dtype", value.indptr.dtype) # Allow resizing for hdf5 if isinstance(f, H5Group): dataset_kwargs = dict(maxshape=(None,), **dataset_kwargs) dataset_kwargs = zarr_v3_compressor_compat(dataset_kwargs) for attr_name in ["data", "indices", "indptr"]: attr = getattr(value, attr_name) dtype = indptr_dtype if attr_name == "indptr" else attr.dtype if isinstance(f, H5Group) or is_zarr_v2(): g.create_dataset( attr_name, data=attr, shape=attr.shape, dtype=dtype, **dataset_kwargs ) else: dataset_kwargs = zarr_v3_sharding(dataset_kwargs) arr = g.create_array( attr_name, shape=attr.shape, dtype=dtype, **dataset_kwargs ) # see https://github.com/zarr-developers/zarr-python/discussions/2712 arr[...] = attr[...] write_csr = partial(write_sparse_compressed, fmt="csr") write_csc = partial(write_sparse_compressed, fmt="csc") for store_type, (cls, spec, func) in product( (H5Group, ZarrGroup), [ # spmatrix (sparse.csr_matrix, IOSpec("csr_matrix", "0.1.0"), write_csr), (views.SparseCSRMatrixView, IOSpec("csr_matrix", "0.1.0"), write_csr), (sparse.csc_matrix, IOSpec("csc_matrix", "0.1.0"), write_csc), (views.SparseCSCMatrixView, IOSpec("csc_matrix", "0.1.0"), write_csc), # sparray (sparse.csr_array, IOSpec("csr_matrix", "0.1.0"), write_csr), (views.SparseCSRArrayView, IOSpec("csr_matrix", "0.1.0"), write_csr), (sparse.csc_array, IOSpec("csc_matrix", "0.1.0"), write_csc), (views.SparseCSCArrayView, IOSpec("csc_matrix", "0.1.0"), write_csc), # cupy spmatrix (CupyCSRMatrix, IOSpec("csr_matrix", "0.1.0"), _to_cpu_mem_wrapper(write_csr)), ( views.CupySparseCSRView, IOSpec("csr_matrix", "0.1.0"), _to_cpu_mem_wrapper(write_csr), ), (CupyCSCMatrix, IOSpec("csc_matrix", "0.1.0"), _to_cpu_mem_wrapper(write_csc)), ( views.CupySparseCSCView, IOSpec("csc_matrix", "0.1.0"), _to_cpu_mem_wrapper(write_csc), ), ], ): _REGISTRY.register_write(store_type, cls, spec)(func) @_REGISTRY.register_write(H5Group, _CSRDataset, IOSpec("csr_matrix", "0.1.0")) @_REGISTRY.register_write(H5Group, _CSCDataset, IOSpec("csc_matrix", "0.1.0")) @_REGISTRY.register_write(ZarrGroup, _CSRDataset, IOSpec("csr_matrix", "0.1.0")) @_REGISTRY.register_write(ZarrGroup, _CSCDataset, IOSpec("csc_matrix", "0.1.0")) def write_sparse_dataset( f: GroupStorageType, k: str, elem: _CSCDataset | _CSRDataset, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): write_sparse_compressed( f, k, elem._to_backed(), _writer=_writer, fmt=elem.format, dataset_kwargs=dataset_kwargs, ) def write_cupy_dask(f, k, elem, _writer, dataset_kwargs=MappingProxyType({})): _writer.write_elem( f, k, elem.map_blocks(lambda x: x.get(), dtype=elem.dtype, meta=elem._meta.get()), dataset_kwargs=dataset_kwargs, ) def write_dask_sparse( f: GroupStorageType, k: str, elem: DaskArray, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): sparse_format = elem._meta.format def as_int64_indices(x): x.indptr = x.indptr.astype(np.int64, copy=False) x.indices = x.indices.astype(np.int64, copy=False) return x if sparse_format == "csr": axis = 0 elif sparse_format == "csc": axis = 1 else: msg = f"Cannot write dask sparse arrays with format {sparse_format}" raise NotImplementedError(msg) def chunk_slice(start: int, stop: int) -> tuple[slice | None, slice | None]: result = [slice(None), slice(None)] result[axis] = slice(start, stop) return tuple(result) axis_chunks = elem.chunks[axis] chunk_start = 0 chunk_stop = axis_chunks[0] _writer.write_elem( f, k, as_int64_indices(elem[chunk_slice(chunk_start, chunk_stop)].compute()), dataset_kwargs=dataset_kwargs, ) disk_mtx = sparse_dataset(f[k]) for chunk_size in axis_chunks[1:]: chunk_start = chunk_stop chunk_stop += chunk_size disk_mtx.append(elem[chunk_slice(chunk_start, chunk_stop)].compute()) for array_type, group_type in product( [DaskArray, views.DaskArrayView], [H5Group, ZarrGroup] ): for cupy_array_type, spec in [ (CupyArray, IOSpec("array", "0.2.0")), (CupyCSCMatrix, IOSpec("csc_matrix", "0.1.0")), (CupyCSRMatrix, IOSpec("csr_matrix", "0.1.0")), ]: _REGISTRY.register_write(group_type, (array_type, cupy_array_type), spec)( write_cupy_dask ) for scipy_sparse_type, spec in [ (sparse.csr_matrix, IOSpec("csr_matrix", "0.1.0")), (sparse.csc_matrix, IOSpec("csc_matrix", "0.1.0")), ]: _REGISTRY.register_write(group_type, (array_type, scipy_sparse_type), spec)( write_dask_sparse ) @_REGISTRY.register_read(H5Group, IOSpec("csc_matrix", "0.1.0")) @_REGISTRY.register_read(H5Group, IOSpec("csr_matrix", "0.1.0")) @_REGISTRY.register_read(ZarrGroup, IOSpec("csc_matrix", "0.1.0")) @_REGISTRY.register_read(ZarrGroup, IOSpec("csr_matrix", "0.1.0")) def read_sparse(elem: GroupStorageType, *, _reader: Reader) -> CSMatrix | CSArray: return sparse_dataset(elem).to_memory() @_REGISTRY.register_read_partial(H5Group, IOSpec("csc_matrix", "0.1.0")) @_REGISTRY.register_read_partial(H5Group, IOSpec("csr_matrix", "0.1.0")) @_REGISTRY.register_read_partial(ZarrGroup, IOSpec("csc_matrix", "0.1.0")) @_REGISTRY.register_read_partial(ZarrGroup, IOSpec("csr_matrix", "0.1.0")) def read_sparse_partial(elem, *, items=None, indices=(slice(None), slice(None))): return sparse_dataset(elem)[indices] ################# # Awkward array # ################# @_REGISTRY.register_write(H5Group, AwkArray, IOSpec("awkward-array", "0.1.0")) @_REGISTRY.register_write(ZarrGroup, AwkArray, IOSpec("awkward-array", "0.1.0")) @_REGISTRY.register_write( H5Group, views.AwkwardArrayView, IOSpec("awkward-array", "0.1.0") ) @_REGISTRY.register_write( ZarrGroup, views.AwkwardArrayView, IOSpec("awkward-array", "0.1.0") ) def write_awkward( f: GroupStorageType, k: str, v: views.AwkwardArrayView | AwkArray, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): from anndata.compat import awkward as ak group = f.require_group(k) del k if isinstance(v, views.AwkwardArrayView): # copy to remove the view attributes v = copy(v) form, length, container = ak.to_buffers(ak.to_packed(v)) group.attrs["length"] = length group.attrs["form"] = form.to_json() for k, v in container.items(): _writer.write_elem(group, k, v, dataset_kwargs=dataset_kwargs) @_REGISTRY.register_read(H5Group, IOSpec("awkward-array", "0.1.0")) @_REGISTRY.register_read(ZarrGroup, IOSpec("awkward-array", "0.1.0")) def read_awkward(elem: GroupStorageType, *, _reader: Reader) -> AwkArray: from anndata.compat import awkward as ak form = _read_attr(elem.attrs, "form") length = _read_attr(elem.attrs, "length") container = {k: _reader.read_elem(elem[k]) for k in elem} return ak.from_buffers(form, int(length), container) ############## # DataFrames # ############## @_REGISTRY.register_write(H5Group, views.DataFrameView, IOSpec("dataframe", "0.2.0")) @_REGISTRY.register_write(H5Group, pd.DataFrame, IOSpec("dataframe", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, views.DataFrameView, IOSpec("dataframe", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, pd.DataFrame, IOSpec("dataframe", "0.2.0")) def write_dataframe( f: GroupStorageType, key: str, df: views.DataFrameView | pd.DataFrame, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): # Check arguments for reserved in ("_index",): if reserved in df.columns: msg = f"{reserved!r} is a reserved name for dataframe columns." raise ValueError(msg) group = _require_group_write_dataframe(f, key, df) if not df.columns.is_unique: duplicates = list(df.columns[df.columns.duplicated()]) msg = f"Found repeated column names: {duplicates}. Column names must be unique." raise ValueError(msg) col_names = [check_key(c) for c in df.columns] group.attrs["column-order"] = col_names if df.index.name is not None: if df.index.name in col_names and not pd.Series( df.index, index=df.index ).equals(df[df.index.name]): msg = ( f"DataFrame.index.name ({df.index.name!r}) is also used by a column " "whose values are different. This is not supported. Please make sure " "the values are the same, or use a different name." ) raise ValueError(msg) index_name = df.index.name else: index_name = "_index" group.attrs["_index"] = check_key(index_name) # ._values is "the best" array representation. It's the true array backing the # object, where `.values` is always a np.ndarray and .array is always a pandas # array. _writer.write_elem( group, index_name, df.index._values, dataset_kwargs=dataset_kwargs ) for colname, series in df.items(): # TODO: this should write the "true" representation of the series (i.e. the underlying array or ndarray depending) _writer.write_elem( group, colname, series._values, dataset_kwargs=dataset_kwargs ) @_REGISTRY.register_read(H5Group, IOSpec("dataframe", "0.2.0")) @_REGISTRY.register_read(ZarrGroup, IOSpec("dataframe", "0.2.0")) def read_dataframe(elem: GroupStorageType, *, _reader: Reader) -> pd.DataFrame: columns = list(_read_attr(elem.attrs, "column-order")) idx_key = _read_attr(elem.attrs, "_index") df = pd.DataFrame( {k: _reader.read_elem(elem[k]) for k in columns}, index=_reader.read_elem(elem[idx_key]), columns=columns if columns else None, ) if idx_key != "_index": df.index.name = idx_key return df # TODO: Figure out what indices is allowed to be at each element @_REGISTRY.register_read_partial(H5Group, IOSpec("dataframe", "0.2.0")) @_REGISTRY.register_read_partial(ZarrGroup, IOSpec("dataframe", "0.2.0")) def read_dataframe_partial( elem, *, items=None, indices=(slice(None, None), slice(None, None)) ): if items is not None: columns = [ col for col in _read_attr(elem.attrs, "column-order") if col in items ] else: columns = list(_read_attr(elem.attrs, "column-order")) idx_key = _read_attr(elem.attrs, "_index") df = pd.DataFrame( {k: read_elem_partial(elem[k], indices=indices[0]) for k in columns}, index=read_elem_partial(elem[idx_key], indices=indices[0]), columns=columns if columns else None, ) if idx_key != "_index": df.index.name = idx_key return df # Backwards compat dataframe reading @_REGISTRY.register_read(H5Group, IOSpec("dataframe", "0.1.0")) @_REGISTRY.register_read(ZarrGroup, IOSpec("dataframe", "0.1.0")) def read_dataframe_0_1_0(elem: GroupStorageType, *, _reader: Reader) -> pd.DataFrame: columns = _read_attr(elem.attrs, "column-order") idx_key = _read_attr(elem.attrs, "_index") df = pd.DataFrame( {k: read_series(elem[k]) for k in columns}, index=read_series(elem[idx_key]), columns=columns if len(columns) else None, ) if idx_key != "_index": df.index.name = idx_key return df def read_series(dataset: h5py.Dataset) -> np.ndarray | pd.Categorical: # For reading older dataframes if "categories" in dataset.attrs: if isinstance(dataset, ZarrArray): import zarr parent_name = dataset.name.rstrip(dataset.basename).strip("/") parent = zarr.open(dataset.store, mode="r")[parent_name] else: parent = dataset.parent categories_dset = parent[_read_attr(dataset.attrs, "categories")] categories = read_elem(categories_dset) ordered = bool(_read_attr(categories_dset.attrs, "ordered", default=False)) return pd.Categorical.from_codes( read_elem(dataset), categories, ordered=ordered ) else: return read_elem(dataset) @_REGISTRY.register_read_partial(H5Group, IOSpec("dataframe", "0.1.0")) @_REGISTRY.register_read_partial(ZarrGroup, IOSpec("dataframe", "0.1.0")) def read_partial_dataframe_0_1_0( elem, *, items=None, indices=(slice(None), slice(None)) ): items = slice(None) if items is None else list(items) return read_elem(elem)[items].iloc[indices[0]] ############### # Categorical # ############### @_REGISTRY.register_write(H5Group, pd.Categorical, IOSpec("categorical", "0.2.0")) @_REGISTRY.register_write(ZarrGroup, pd.Categorical, IOSpec("categorical", "0.2.0")) def write_categorical( f: GroupStorageType, k: str, v: pd.Categorical, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): g = f.require_group(k) g.attrs["ordered"] = bool(v.ordered) _writer.write_elem(g, "codes", v.codes, dataset_kwargs=dataset_kwargs) _writer.write_elem( g, "categories", v.categories._values, dataset_kwargs=dataset_kwargs ) @_REGISTRY.register_read(H5Group, IOSpec("categorical", "0.2.0")) @_REGISTRY.register_read(ZarrGroup, IOSpec("categorical", "0.2.0")) def read_categorical(elem: GroupStorageType, *, _reader: Reader) -> pd.Categorical: return pd.Categorical.from_codes( codes=_reader.read_elem(elem["codes"]), categories=_reader.read_elem(elem["categories"]), ordered=bool(_read_attr(elem.attrs, "ordered")), ) @_REGISTRY.register_read_partial(H5Group, IOSpec("categorical", "0.2.0")) @_REGISTRY.register_read_partial(ZarrGroup, IOSpec("categorical", "0.2.0")) def read_partial_categorical(elem, *, items=None, indices=(slice(None),)): return pd.Categorical.from_codes( codes=read_elem_partial(elem["codes"], indices=indices), categories=read_elem(elem["categories"]), ordered=bool(_read_attr(elem.attrs, "ordered")), ) #################### # Pandas nullables # #################### @_REGISTRY.register_write( H5Group, pd.arrays.IntegerArray, IOSpec("nullable-integer", "0.1.0") ) @_REGISTRY.register_write( ZarrGroup, pd.arrays.IntegerArray, IOSpec("nullable-integer", "0.1.0") ) @_REGISTRY.register_write( H5Group, pd.arrays.BooleanArray, IOSpec("nullable-boolean", "0.1.0") ) @_REGISTRY.register_write( ZarrGroup, pd.arrays.BooleanArray, IOSpec("nullable-boolean", "0.1.0") ) def write_nullable( f: GroupStorageType, k: str, v: pd.arrays.IntegerArray | pd.arrays.BooleanArray | pd.arrays.StringArray | pd.arrays.ArrowStringArray, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ) -> None: if ( isinstance(v, pd.arrays.StringArray | pd.arrays.ArrowStringArray) and not settings.allow_write_nullable_strings ): msg = ( "`anndata.settings.allow_write_nullable_strings` is False, " "because writing of `pd.arrays.{StringArray,ArrowStringArray}` is new " "and not supported in anndata < 0.11, still use by many people. " "Opt-in to writing these arrays by toggling the setting to True." ) raise RuntimeError(msg) g = f.require_group(k) values = ( v.to_numpy(na_value="") if isinstance(v, pd.arrays.StringArray | pd.arrays.ArrowStringArray) else v.to_numpy(na_value=0, dtype=v.dtype.numpy_dtype) ) _writer.write_elem(g, "values", values, dataset_kwargs=dataset_kwargs) _writer.write_elem(g, "mask", v.isna(), dataset_kwargs=dataset_kwargs) for store_type, array_type in product([H5Group, ZarrGroup], PANDAS_STRING_ARRAY_TYPES): _REGISTRY.register_write( store_type, array_type, IOSpec("nullable-string-array", "0.1.0") )(write_nullable) def _read_nullable( elem: GroupStorageType, *, _reader: Reader, # BaseMaskedArray array_type: Callable[ [NDArray[np.number], NDArray[np.bool_]], pd.api.extensions.ExtensionArray ], ) -> pd.api.extensions.ExtensionArray: return array_type( _reader.read_elem(elem["values"]), mask=_reader.read_elem(elem["mask"]), ) _REGISTRY.register_read(H5Group, IOSpec("nullable-integer", "0.1.0"))( read_nullable_integer := partial(_read_nullable, array_type=pd.arrays.IntegerArray) ) _REGISTRY.register_read(ZarrGroup, IOSpec("nullable-integer", "0.1.0"))( read_nullable_integer ) _REGISTRY.register_read(H5Group, IOSpec("nullable-boolean", "0.1.0"))( read_nullable_boolean := partial(_read_nullable, array_type=pd.arrays.BooleanArray) ) _REGISTRY.register_read(ZarrGroup, IOSpec("nullable-boolean", "0.1.0"))( read_nullable_boolean ) @_REGISTRY.register_read(H5Group, IOSpec("nullable-string-array", "0.1.0")) @_REGISTRY.register_read(ZarrGroup, IOSpec("nullable-string-array", "0.1.0")) def _read_nullable_string( elem: GroupStorageType, *, _reader: Reader ) -> pd.api.extensions.ExtensionArray: values = _reader.read_elem(elem["values"]) mask = _reader.read_elem(elem["mask"]) dtype = pd.StringDtype() arr = pd.array( values.astype(NULLABLE_NUMPY_STRING_TYPE), dtype=dtype, ) arr[mask] = pd.NA return arr ########### # Scalars # ########### @_REGISTRY.register_read(H5Array, IOSpec("numeric-scalar", "0.2.0")) @_REGISTRY.register_read(ZarrArray, IOSpec("numeric-scalar", "0.2.0")) def read_scalar(elem: ArrayStorageType, *, _reader: Reader) -> np.number: # TODO: `item` ensures the return is in fact a scalar (needed after zarr v3 which now returns a 1 elem array) # https://github.com/zarr-developers/zarr-python/issues/2713 return elem[()].item() def _remove_scalar_compression_args(dataset_kwargs: Mapping[str, Any]) -> dict: # Can’t compress scalars, error is thrown dataset_kwargs = dict(dataset_kwargs) for arg in ( "compression", "compression_opts", "chunks", "shuffle", "fletcher32", "scaleoffset", "compressor", ): dataset_kwargs.pop(arg, None) return dataset_kwargs def write_scalar_zarr( f: ZarrGroup, key: str, value, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): # these args are ignored in v2: https://zarr.readthedocs.io/en/v2.18.4/api/hierarchy.html#zarr.hierarchy.Group.create_dataset # and error out in v3 dataset_kwargs = _remove_scalar_compression_args(dataset_kwargs) if is_zarr_v2(): return f.create_dataset(key, data=np.array(value), shape=(), **dataset_kwargs) else: from numcodecs import VLenUTF8 from zarr.core.dtype import VariableLengthUTF8 match f.metadata.zarr_format, value: case 2, str(): filters, dtype, fill_value = [VLenUTF8()], VariableLengthUTF8(), "" case 3, str(): filters, dtype, fill_value = None, VariableLengthUTF8(), None case _, _: filters, dtype, fill_value = None, np.array(value).dtype, None a = f.create_array( key, shape=(), dtype=dtype, filters=filters, fill_value=fill_value, **dataset_kwargs, ) a[...] = np.array(value) def write_hdf5_scalar( f: H5Group, key: str, value, *, _writer: Writer, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ): # Can’t compress scalars, error is thrown dataset_kwargs = _remove_scalar_compression_args(dataset_kwargs) f.create_dataset(key, data=np.array(value), **dataset_kwargs) for numeric_scalar_type in [ *(bool, np.bool_), *(np.uint8, np.uint16, np.uint32, np.uint64), *(int, np.int8, np.int16, np.int32, np.int64), *(float, *np.floating.__subclasses__()), *np.complexfloating.__subclasses__(), ]: _REGISTRY.register_write( H5Group, numeric_scalar_type, IOSpec("numeric-scalar", "0.2.0") )(write_hdf5_scalar) _REGISTRY.register_write( ZarrGroup, numeric_scalar_type, IOSpec("numeric-scalar", "0.2.0") )(write_scalar_zarr) _REGISTRY.register_write(ZarrGroup, str, IOSpec("string", "0.2.0"))(write_scalar_zarr) _REGISTRY.register_write(ZarrGroup, np.str_, IOSpec("string", "0.2.0"))( write_scalar_zarr ) @_REGISTRY.register_read(H5Array, IOSpec("string", "0.2.0")) def read_hdf5_string(elem: H5Array, *, _reader: Reader) -> str: return elem.asstr()[()] @_REGISTRY.register_read(ZarrArray, IOSpec("string", "0.2.0")) def read_zarr_string(elem: ZarrArray, *, _reader: Reader) -> str: return str(elem[()]) _REGISTRY.register_read(H5Array, IOSpec("bytes", "0.2.0"))(read_scalar) _REGISTRY.register_read(ZarrArray, IOSpec("bytes", "0.2.0"))(read_scalar) @_REGISTRY.register_write(H5Group, np.str_, IOSpec("string", "0.2.0")) @_REGISTRY.register_write(H5Group, str, IOSpec("string", "0.2.0")) def write_string( f: H5Group, k: str, v: np.str_ | str, *, _writer: Writer, dataset_kwargs: Mapping[str, Any], ): dataset_kwargs = dataset_kwargs.copy() dataset_kwargs.pop("compression", None) dataset_kwargs.pop("compression_opts", None) f.create_dataset( k, data=np.array(v, dtype=h5py.string_dtype(encoding="utf-8")), **dataset_kwargs ) # @_REGISTRY.register_write(np.bytes_, IOSpec("bytes", "0.2.0")) # @_REGISTRY.register_write(bytes, IOSpec("bytes", "0.2.0")) # def write_string(f, k, v, dataset_kwargs): # if "compression" in dataset_kwargs: # dataset_kwargs = dict(dataset_kwargs) # dataset_kwargs.pop("compression") # f.create_dataset(k, data=np.array(v), **dataset_kwargs) scverse-anndata-b796d59/src/anndata/_io/specs/registry.py000066400000000000000000000422031512025555600234540ustar00rootroot00000000000000from __future__ import annotations import inspect import warnings from collections.abc import Mapping from dataclasses import dataclass from functools import partial, singledispatch, wraps from types import MappingProxyType from typing import TYPE_CHECKING, Generic, TypeVar from anndata._io.utils import report_read_key_on_error, report_write_key_on_error from anndata._settings import settings from anndata._types import Read, ReadLazy, _ReadInternal, _ReadLazyInternal from anndata.compat import DaskArray, ZarrGroup, _read_attr, is_zarr_v2 if TYPE_CHECKING: from collections.abc import Callable, Generator, Iterable from typing import Any from anndata._types import ( GroupStorageType, ReadCallback, StorageType, Write, WriteCallback, _WriteInternal, ) from anndata.experimental.backed._lazy_arrays import CategoricalArray, MaskedArray from anndata.typing import RWAble from ..._core.xarray import Dataset2D T = TypeVar("T") W = TypeVar("W", bound=_WriteInternal) LazyDataStructures = DaskArray | Dataset2D | CategoricalArray | MaskedArray # TODO: This probably should be replaced by a hashable Mapping due to conversion b/w "_" and "-" # TODO: Should filetype be included in the IOSpec if it changes the encoding? Or does the intent that these things be "the same" overrule that? @dataclass(frozen=True) class IOSpec: encoding_type: str encoding_version: str # TODO: Should this subclass from LookupError? class IORegistryError(Exception): @classmethod def _from_write_parts( cls, dest_type: type, typ: type | tuple[type, str], modifiers: frozenset[str] ) -> IORegistryError: msg = f"No method registered for writing {typ} into {dest_type}" if modifiers: msg += f" with {modifiers}" return cls(msg) @classmethod def _from_read_parts( cls, method: str, registry: Mapping, src_typ: type[StorageType], spec: IOSpec, ) -> IORegistryError: # TODO: Improve error message if type exists, but version does not msg = ( f"No {method} method registered for {spec} from {src_typ}. " "You may need to update your installation of anndata." ) return cls(msg) def write_spec(spec: IOSpec): def decorator(func: W) -> W: @wraps(func) def wrapper(g: GroupStorageType, k: str, *args, **kwargs): result = func(g, k, *args, **kwargs) g[k].attrs.setdefault("encoding-type", spec.encoding_type) g[k].attrs.setdefault("encoding-version", spec.encoding_version) return result return wrapper return decorator _R = TypeVar("_R", _ReadInternal, _ReadLazyInternal) R = TypeVar("R", Read, ReadLazy) class IORegistry(Generic[_R, R]): def __init__(self): self.read: dict[tuple[type, IOSpec, frozenset[str]], _R] = {} self.read_partial: dict[tuple[type, IOSpec, frozenset[str]], Callable] = {} self.write: dict[ tuple[type, type | tuple[type, str], frozenset[str]], _WriteInternal ] = {} self.write_specs: dict[type | tuple[type, str] | tuple[type, type], IOSpec] = {} def register_write( self, dest_type: type, src_type: type | tuple[type, str], spec: IOSpec | Mapping[str, str], modifiers: Iterable[str] = frozenset(), ) -> Callable[[_WriteInternal[T]], _WriteInternal[T]]: spec = proc_spec(spec) modifiers = frozenset(modifiers) # Record specification for src_type if src_type in self.write_specs and (spec != self.write_specs[src_type]): # First check for consistency current_spec = self.write_specs[src_type] msg = ( "Cannot overwrite IO specifications. Attempted to overwrite encoding " f"for {src_type} from {current_spec} to {spec}" ) raise TypeError(msg) else: self.write_specs[src_type] = spec def _register(func): self.write[(dest_type, src_type, modifiers)] = write_spec(spec)(func) return func return _register def get_write( self, dest_type: type, src_type: type | tuple[type, str], modifiers: frozenset[str] = frozenset(), *, writer: Writer, ) -> Write: import h5py if dest_type is h5py.File: dest_type = h5py.Group if (dest_type, src_type, modifiers) not in self.write: raise IORegistryError._from_write_parts(dest_type, src_type, modifiers) internal = self.write[(dest_type, src_type, modifiers)] return partial(internal, _writer=writer) def has_write( self, dest_type: type, src_type: type | tuple[type, str], modifiers: frozenset[str], ) -> bool: return (dest_type, src_type, modifiers) in self.write def register_read( self, src_type: type, spec: IOSpec | Mapping[str, str], modifiers: Iterable[str] = frozenset(), ) -> Callable[[_R], _R]: spec = proc_spec(spec) modifiers = frozenset(modifiers) def _register(func): self.read[(src_type, spec, modifiers)] = func return func return _register def get_read( self, src_type: type, spec: IOSpec, modifiers: frozenset[str] = frozenset(), *, reader: Reader, ) -> R: if (src_type, spec, modifiers) not in self.read: raise IORegistryError._from_read_parts("read", self.read, src_type, spec) # noqa: EM101 internal = self.read[(src_type, spec, modifiers)] return partial(internal, _reader=reader) def has_read( self, src_type: type, spec: IOSpec, modifiers: frozenset[str] = frozenset() ) -> bool: return (src_type, spec, modifiers) in self.read def register_read_partial( self, src_type: type, spec: IOSpec | Mapping[str, str], modifiers: Iterable[str] = frozenset(), ): spec = proc_spec(spec) modifiers = frozenset(modifiers) def _register(func): self.read_partial[(src_type, spec, modifiers)] = func return func return _register def get_partial_read( self, src_type: type, spec: IOSpec, modifiers: frozenset[str] = frozenset() ): if (src_type, spec, modifiers) in self.read_partial: return self.read_partial[(src_type, spec, modifiers)] name = "read_partial" raise IORegistryError._from_read_parts(name, self.read_partial, src_type, spec) def get_spec(self, elem: Any) -> IOSpec: if isinstance(elem, DaskArray): if (typ_meta := (DaskArray, type(elem._meta))) in self.write_specs: return self.write_specs[typ_meta] elif ( hasattr(elem, "dtype") and (typ_kind := (type(elem), elem.dtype.kind)) in self.write_specs ): return self.write_specs[typ_kind] return self.write_specs[type(elem)] _REGISTRY: IORegistry[_ReadInternal, Read] = IORegistry() _LAZY_REGISTRY: IORegistry[_ReadLazyInternal, ReadLazy] = IORegistry() @singledispatch def proc_spec(spec) -> IOSpec: msg = f"proc_spec not defined for type: {type(spec)}." raise NotImplementedError(msg) @proc_spec.register(IOSpec) def proc_spec_spec(spec: IOSpec) -> IOSpec: return spec @proc_spec.register(Mapping) def proc_spec_mapping(spec: Mapping[str, str]) -> IOSpec: return IOSpec(**{k.replace("-", "_"): v for k, v in spec.items()}) def get_spec( elem: StorageType, ) -> IOSpec: return proc_spec({ k: _read_attr(elem.attrs, k, "") for k in ["encoding-type", "encoding-version"] }) def _iter_patterns( elem, ) -> Generator[tuple[type, type | str] | tuple[type, type, str], None, None]: """Iterates over possible patterns for an element in order of precedence.""" from anndata.compat import DaskArray t = type(elem) if isinstance(elem, DaskArray): yield (t, type(elem._meta), elem.dtype.kind) yield (t, type(elem._meta)) if hasattr(elem, "dtype"): yield (t, elem.dtype.kind) yield t class Reader: def __init__( self, registry: IORegistry, callback: ReadCallback | None = None ) -> None: self.registry = registry self.callback = callback @report_read_key_on_error def read_elem( self, elem: StorageType, modifiers: frozenset[str] = frozenset(), ) -> RWAble: """Read an element from a store. See exported function for more details.""" iospec = get_spec(elem) read_func: Read = self.registry.get_read( type(elem), iospec, modifiers, reader=self ) if self.callback is None: return read_func(elem) return self.callback(read_func, elem.name, elem, iospec=iospec) class LazyReader(Reader): @report_read_key_on_error def read_elem( self, elem: StorageType, modifiers: frozenset[str] = frozenset(), chunks: tuple[int, ...] | None = None, **kwargs, ) -> LazyDataStructures: """Read a dask element from a store. See exported function for more details.""" iospec = get_spec(elem) read_func: ReadLazy = self.registry.get_read( type(elem), iospec, modifiers, reader=self ) if self.callback is not None: msg = "Dask reading does not use a callback. Ignoring callback." warnings.warn(msg, stacklevel=2) read_params = inspect.signature(read_func).parameters for kwarg in kwargs: if kwarg not in read_params: msg = ( f"Keyword argument {kwarg} passed to read_elem_lazy are not supported by the " "registered read function." ) raise ValueError(msg) if "chunks" in read_params: kwargs["chunks"] = chunks return read_func(elem, **kwargs) class Writer: def __init__(self, registry: IORegistry, callback: WriteCallback | None = None): self.registry = registry self.callback = callback def find_write_func( self, dest_type: type, elem: Any, modifiers: frozenset[str] ) -> Write: for pattern in _iter_patterns(elem): if self.registry.has_write(dest_type, pattern, modifiers): return self.registry.get_write( dest_type, pattern, modifiers, writer=self ) # Raises IORegistryError return self.registry.get_write(dest_type, type(elem), modifiers, writer=self) @report_write_key_on_error def write_elem( self, store: GroupStorageType, k: str, elem: RWAble, *, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), modifiers: frozenset[str] = frozenset(), ): from pathlib import PurePosixPath import h5py from anndata._io.zarr import is_group_consolidated # we allow stores to have a prefix like /uns which are then written to with keys like /uns/foo is_zarr_group = isinstance(store, ZarrGroup) if "/" in k.split(store.name)[-1][1:]: if is_zarr_group or settings.disallow_forward_slash_in_h5ad: msg = f"Forward slashes are not allowed in keys in {type(store)}" raise ValueError(msg) else: msg = "Forward slashes will be disallowed in h5 stores in the next minor release" warnings.warn(msg, FutureWarning, stacklevel=2) if isinstance(store, h5py.File): store = store["/"] dest_type = type(store) # Normalize k to absolute path if (is_zarr_group and is_zarr_v2()) or ( isinstance(store, h5py.Group) and not PurePosixPath(k).is_absolute() ): k = str(PurePosixPath(store.name) / k) is_consolidated = is_group_consolidated(store) if is_zarr_group else False if is_consolidated: msg = "Cannot overwrite/edit a store with consolidated metadata" raise ValueError(msg) if k == "/": if isinstance(store, ZarrGroup) and not is_zarr_v2(): from zarr.core.sync import sync sync(store.store.clear()) else: store.clear() elif k in store: del store[k] write_func = self.find_write_func(dest_type, elem, modifiers) if self.callback is None: return write_func(store, k, elem, dataset_kwargs=dataset_kwargs) return self.callback( write_func, store, k, elem, dataset_kwargs=dataset_kwargs, iospec=self.registry.get_spec(elem), ) def read_elem(elem: StorageType) -> RWAble: """ Read an element from a store. Assumes that the element is encoded using the anndata encoding. This function will determine the encoded type using the encoding metadata stored in elem's attributes. Params ------ elem The stored element. """ return Reader(_REGISTRY).read_elem(elem) def read_elem_lazy( elem: StorageType, chunks: tuple[int, ...] | None = None, **kwargs ) -> LazyDataStructures: """ Read an element from a store lazily. Assumes that the element is encoded using the anndata encoding. This function will determine the encoded type using the encoding metadata stored in elem's attributes. Parameters ---------- elem The stored element. chunks length `n`, the same `n` as the size of the underlying array. Note that the minor axis dimension must match the shape for sparse. Defaults to `(1000, adata.shape[1])` for CSR sparse, `(adata.shape[0], 1000)` for CSC sparse, and the on-disk chunking otherwise for dense. Can use `-1` or `None` to indicate use of the size of the corresponding dimension. Returns ------- A "lazy" elem Examples -------- Setting up our example: >>> from scanpy.datasets import pbmc3k >>> import tempfile >>> import anndata as ad >>> import zarr >>> tmp_path = tempfile.gettempdir() >>> zarr_path = tmp_path + "/adata.zarr" >>> adata = pbmc3k() >>> adata.layers["dense"] = adata.X.toarray() >>> adata.write_zarr(zarr_path) Reading a sparse matrix from a zarr store lazily, with custom chunk size and default: >>> g = zarr.open(zarr_path) >>> adata.X = ad.experimental.read_elem_lazy(g["X"]) >>> adata.X dask.array >>> adata.X = ad.experimental.read_elem_lazy(g["X"], chunks=(500, adata.shape[1])) >>> adata.X dask.array Reading a dense matrix from a zarr store lazily: >>> adata.layers["dense"] = ad.experimental.read_elem_lazy(g["layers/dense"]) >>> adata.layers["dense"] dask.array Making a new anndata object from on-disk, with custom chunks: >>> adata = ad.AnnData( ... obs=ad.io.read_elem(g["obs"]), ... var=ad.io.read_elem(g["var"]), ... uns=ad.io.read_elem(g["uns"]), ... obsm=ad.io.read_elem(g["obsm"]), ... varm=ad.io.read_elem(g["varm"]), ... ) >>> adata.X = ad.experimental.read_elem_lazy(g["X"], chunks=(500, adata.shape[1])) >>> adata.layers["dense"] = ad.experimental.read_elem_lazy(g["layers/dense"]) We also support using -1 and None as a chunk size to signify the reading the whole axis: >>> adata.X = ad.experimental.read_elem_lazy(g["X"], chunks=(500, -1)) >>> adata.X = ad.experimental.read_elem_lazy(g["X"], chunks=(500, None)) """ return LazyReader(_LAZY_REGISTRY).read_elem(elem, chunks=chunks, **kwargs) def write_elem( store: GroupStorageType, k: str, elem: RWAble, *, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ) -> None: """ Write an element to a storage group using anndata encoding. Params ------ store The group to write to. k The key to write to in the group. Note that absolute paths will be written from the root. elem The element to write. Typically an in-memory object, e.g. an AnnData, pandas dataframe, scipy sparse matrix, etc. dataset_kwargs Keyword arguments to pass to the stores dataset creation function. E.g. for zarr this would be `chunks`, `compressor`. """ Writer(_REGISTRY).write_elem(store, k, elem, dataset_kwargs=dataset_kwargs) # TODO: If all items would be read, just call normal read method def read_elem_partial( elem, *, items=None, indices=(slice(None), slice(None)), modifiers: frozenset[str] = frozenset(), ): """Read part of an element from an on disk store.""" read_partial = _REGISTRY.get_partial_read( type(elem), get_spec(elem), frozenset(modifiers) ) return read_partial(elem, items=items, indices=indices) scverse-anndata-b796d59/src/anndata/_io/utils.py000066400000000000000000000224221512025555600216300ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Callable from functools import WRAPPER_ASSIGNMENTS, wraps from itertools import pairwise from typing import TYPE_CHECKING, Literal, cast from warnings import warn from .._core.sparse_dataset import BaseCompressedSparseDataset if TYPE_CHECKING: from collections.abc import Callable, Mapping from typing import Any, Literal from .._types import StorageType, _WriteInternal from ..compat import H5Group, ZarrGroup from ..typing import RWAble from .specs.registry import Writer Storage = StorageType | BaseCompressedSparseDataset # ------------------------------------------------------------------------------- # Type conversion # ------------------------------------------------------------------------------- # Could be numba’d if it returned tuples instead of slices def idx_chunks_along_axis(shape: tuple, axis: int, chunk_size: int): """\ Gives indexer tuples chunked along an axis. Params ------ shape Shape of array to be chunked axis Axis to chunk along chunk_size Size of chunk along axis Returns ------- An iterator of tuples for indexing into an array of passed shape. """ total = shape[axis] cur = 0 mutable_idx = [slice(None) for i in range(len(shape))] while cur + chunk_size < total: mutable_idx[axis] = slice(cur, cur + chunk_size) yield tuple(mutable_idx) cur += chunk_size mutable_idx[axis] = slice(cur, None) yield tuple(mutable_idx) def is_float(string): """\ Check whether string is float. See also -------- http://stackoverflow.com/questions/736043/checking-if-a-string-can-be-converted-to-float-in-python """ try: float(string) return True except ValueError: return False def is_int(string): """Check whether string is integer.""" try: int(string) return True except ValueError: return False def convert_bool(string): """Check whether string is boolean.""" if string == "True": return True, True elif string == "False": return True, False else: return False, False def convert_string(string): """Convert string to int, float or bool.""" if is_int(string): return int(string) elif is_float(string): return float(string) elif convert_bool(string)[0]: return convert_bool(string)[1] elif string == "None": return None else: return string def check_key(key): """Checks that passed value is a valid h5py key. Should convert it if there is an obvious conversion path, error otherwise. """ typ = type(key) if issubclass(typ, str): return str(key) # TODO: Should I try to decode bytes? It's what h5py would do, # but it will be read out as a str. # elif issubclass(typ, bytes): # return key else: msg = f"{key} of type {typ} is an invalid key. Should be str." raise TypeError(msg) # ------------------------------------------------------------------------------- # Generic functions # ------------------------------------------------------------------------------- def read_attribute(*args, **kwargs): from .specs import read_elem msg = "This internal function has been deprecated, please use read_elem instead" warn(msg, FutureWarning, stacklevel=2) return read_elem(*args, **kwargs) def write_attribute(*args, **kwargs): from .specs import write_elem msg = "This internal function has been deprecated, please use write_elem instead" warn(msg, FutureWarning, stacklevel=2) return write_elem(*args, **kwargs) # ------------------------------------------------------------------------------- # Errors handling # ------------------------------------------------------------------------------- # TODO: Is there a consistent way to do this which just modifies the previously # thrown error? Could do a warning? class AnnDataReadError(OSError): """Error caused while trying to read in AnnData.""" def _get_display_path(store: Storage) -> str: """Return an absolute path of an element (always starts with “/”).""" if isinstance(store, BaseCompressedSparseDataset): store = store.group path = store.name or "??" # can be None return f"/{path.removeprefix('/')}" def add_key_note( e: BaseException, store: Storage, path: str, key: str, op: Literal["read", "writ"] ) -> None: if any( f"Error raised while {op}ing key" in note for note in getattr(e, "__notes__", []) ): return dir = "to" if op == "writ" else "from" msg = f"Error raised while {op}ing key {key!r} of {type(store)} {dir} {path}" e.add_note(msg) def report_read_key_on_error(func): """\ A decorator for hdf5/zarr element reading which makes keys involved in errors get reported. Example ------- >>> import zarr >>> import numpy as np >>> @report_read_key_on_error ... def read_arr(group): ... raise NotImplementedError() >>> z = zarr.open("tmp.zarr", mode="w") >>> z["X"] = np.array([1, 2, 3]) >>> read_arr(z["X"]) # doctest: +SKIP """ @wraps(func) def func_wrapper(*args, **kwargs): from anndata._io.specs import Reader # Figure out signature (method vs function) by going through args for arg in args: if not isinstance(arg, Reader): store = cast("Storage", arg) break else: msg = "No element found in args." raise ValueError(msg) try: return func(*args, **kwargs) except Exception as e: path, key = _get_display_path(store).rsplit("/", 1) add_key_note(e, store, path or "/", key, "read") raise return func_wrapper def report_write_key_on_error(func): """\ A decorator for hdf5/zarr element writing which makes keys involved in errors get reported. Example ------- >>> import zarr >>> @report_write_key_on_error ... def write_arr(group, key, val): ... raise NotImplementedError() >>> z = zarr.open("tmp.zarr", mode="w") >>> X = [1, 2, 3] >>> write_arr(z, "X", X) # doctest: +SKIP """ @wraps(func) def func_wrapper(*args, **kwargs): from anndata._io.specs import Writer # Figure out signature (method vs function) by going through args for arg, _key in pairwise(args): key = _key if not isinstance(arg, Writer): store = cast("Storage", arg) break else: msg = "No element found in args." raise ValueError(msg) try: return func(*args, **kwargs) except Exception as e: path = _get_display_path(store) add_key_note(e, store, path, key, "writ") raise return func_wrapper # ------------------------------------------------------------------------------- # Common h5ad/zarr stuff # ------------------------------------------------------------------------------- def _read_legacy_raw( f: ZarrGroup | H5Group, modern_raw, # TODO: type read_df: Callable, read_attr: Callable, *, attrs=("X", "var", "varm"), ) -> dict: """\ Backwards compat for reading legacy raw. Makes sure that no modern raw group coexists with legacy raw.* groups. """ if modern_raw: if any(k.startswith("raw.") for k in f): what = f"File {f.filename}" if hasattr(f, "filename") else "Store" msg = f"{what} has both legacy and current raw formats." raise ValueError(msg) return modern_raw raw = {} if "X" in attrs and "raw.X" in f: raw["X"] = read_attr(f["raw.X"]) if "var" in attrs and "raw.var" in f: raw["var"] = read_df(f["raw.var"]) # Backwards compat if "varm" in attrs and "raw.varm" in f: raw["varm"] = read_attr(f["raw.varm"]) return raw def zero_dim_array_as_scalar(func: _WriteInternal): """\ A decorator for write_elem implementations of arrays where zero-dimensional arrays need special handling. """ @wraps(func, assigned=(*WRAPPER_ASSIGNMENTS, "__defaults__", "__kwdefaults__")) def func_wrapper( f: StorageType, k: str, elem: RWAble, *, _writer: Writer, dataset_kwargs: Mapping[str, Any], ): if elem.shape == (): _writer.write_elem(f, k, elem[()], dataset_kwargs=dataset_kwargs) else: func(f, k, elem, _writer=_writer, dataset_kwargs=dataset_kwargs) return func_wrapper def no_write_dataset_2d(write): def raise_error_if_dataset_2d_present(store, adata, *args, **kwargs): from anndata.experimental.backed._compat import has_dataset_2d if has_dataset_2d(adata): msg = ( "Writing AnnData objects with a Dataset2D not supported yet. " "Please use `ds.to_memory` to bring the dataset into memory. " "Note that if you have generated this object by concatenating several `AnnData` objects" "the original types may be lost." ) raise NotImplementedError(msg) return write(store, adata, *args, **kwargs) return raise_error_if_dataset_2d_present scverse-anndata-b796d59/src/anndata/_io/write.py000066400000000000000000000113131512025555600216170ustar00rootroot00000000000000from __future__ import annotations import math import warnings from os import fspath from pathlib import Path from typing import TYPE_CHECKING import numpy as np import pandas as pd from scipy.sparse import issparse from anndata._io.utils import no_write_dataset_2d from .._warnings import WriteWarning from ..compat import old_positionals from ..logging import get_logger if TYPE_CHECKING: from os import PathLike from .. import AnnData logger = get_logger(__name__) @no_write_dataset_2d @old_positionals("skip_data", "sep") def write_csvs( dirname: PathLike[str] | str, adata: AnnData, *, skip_data: bool = True, sep: str = ",", ): """See :meth:`~anndata.AnnData.write_csvs`.""" dirname = Path(dirname) if dirname.suffix == ".csv": dirname = dirname.with_suffix("") logger.info(f"writing .csv files to {dirname}") if not dirname.is_dir(): dirname.mkdir(parents=True, exist_ok=True) dir_uns = dirname / "uns" if not dir_uns.is_dir(): dir_uns.mkdir(parents=True, exist_ok=True) d = dict( obs=adata._obs, var=adata._var, obsm=adata.obsm.to_df(), varm=adata.varm.to_df(), ) if not skip_data: d["X"] = pd.DataFrame(adata.X.toarray() if issparse(adata.X) else adata.X) d_write = {**d, **adata._uns} not_yet_raised_sparse_warning = True for key, value in d_write.items(): if issparse(value): if not_yet_raised_sparse_warning: msg = "Omitting to write sparse annotation." warnings.warn(msg, WriteWarning, stacklevel=2) not_yet_raised_sparse_warning = False continue filename = dirname if key not in {"X", "var", "obs", "obsm", "varm"}: filename = dir_uns filename /= f"{key}.csv" df = value if not isinstance(value, pd.DataFrame): value = np.array(value) if np.ndim(value) == 0: value = value[None] try: df = pd.DataFrame(value) except Exception as e: # noqa: BLE001 msg = f"Omitting to write {key!r} of type {type(e)}." warnings.warn(msg, WriteWarning, stacklevel=2) continue df.to_csv( filename, sep=sep, header=key in {"obs", "var", "obsm", "varm"}, index=key in {"obs", "var"}, ) @no_write_dataset_2d @old_positionals("write_obsm_varm") def write_loom( filename: PathLike[str] | str, adata: AnnData, *, write_obsm_varm: bool = False ) -> None: """See :meth:`~anndata.AnnData.write_loom`.""" filename = Path(filename) row_attrs = {k: np.array(v) for k, v in adata.var.to_dict("list").items()} row_names = adata.var_names row_dim = row_names.name if row_names.name is not None else "var_names" row_attrs[row_dim] = row_names.values col_attrs = {k: np.array(v) for k, v in adata.obs.to_dict("list").items()} col_names = adata.obs_names col_dim = col_names.name if col_names.name is not None else "obs_names" col_attrs[col_dim] = col_names.values if adata.X is None: msg = "loompy does not accept empty matrices as data" raise ValueError(msg) if write_obsm_varm: col_attrs.update(adata.obsm) row_attrs.update(adata.varm) elif len(adata.obsm.keys()) > 0 or len(adata.varm.keys()) > 0: logger.warning( f"The loom file will lack these fields:\n" f"{adata.obsm.keys() | adata.varm.keys()}\n" f"Use write_obsm_varm=True to export multi-dimensional annotations" ) layers = {"": adata.X.T} for key, layer in adata.layers.items(): layers[key] = layer.T from loompy import create if filename.exists(): filename.unlink() create(fspath(filename), layers, row_attrs=row_attrs, col_attrs=col_attrs) def _get_chunk_indices(za): # TODO: does zarr provide code for this? """\ Return all the indices (coordinates) for the chunks in a zarr array, even empty ones. """ return [ (i, j) for i in range(math.ceil(float(za.shape[0]) / za.chunks[0])) for j in range(math.ceil(float(za.shape[1]) / za.chunks[1])) ] def _write_in_zarr_chunks(za, key, value): if key != "X": za[:] = value # don’t chunk metadata else: for ci in _get_chunk_indices(za): s0, e0 = za.chunks[0] * ci[0], za.chunks[0] * (ci[0] + 1) s1, e1 = za.chunks[1] * ci[1], za.chunks[1] * (ci[1] + 1) print(ci, s0, e1, s1, e1) if issparse(value): za[s0:e0, s1:e1] = value[s0:e0, s1:e1].todense() else: za[s0:e0, s1:e1] = value[s0:e0, s1:e1] scverse-anndata-b796d59/src/anndata/_io/zarr.py000066400000000000000000000125251512025555600214510ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, TypeVar from warnings import warn import numpy as np import pandas as pd import zarr from scipy import sparse from .._core.anndata import AnnData from .._settings import settings from .._warnings import OldFormatWarning from ..compat import _clean_uns, _from_fixed_length_strings, is_zarr_v2 from ..experimental import read_dispatched, write_dispatched from .specs import read_elem from .utils import _read_legacy_raw, no_write_dataset_2d, report_read_key_on_error if TYPE_CHECKING: from collections.abc import MutableMapping from os import PathLike from zarr.core.common import AccessModeLiteral from zarr.storage import StoreLike T = TypeVar("T") @no_write_dataset_2d def write_zarr( store: StoreLike, adata: AnnData, *, chunks: tuple[int, ...] | None = None, convert_strings_to_categoricals: bool = True, **ds_kwargs, ) -> None: """See :meth:`~anndata.AnnData.write_zarr`.""" if convert_strings_to_categoricals: adata.strings_to_categoricals() if adata.raw is not None: adata.strings_to_categoricals(adata.raw.var) # TODO: Use spec writing system for this f = open_write_group(store) f.attrs.setdefault("encoding-type", "anndata") f.attrs.setdefault("encoding-version", "0.1.0") def callback( write_func, store, elem_name: str, elem, *, dataset_kwargs, iospec ) -> None: if ( chunks is not None and not isinstance(elem, sparse.spmatrix) and elem_name.lstrip("/") == "X" ): dataset_kwargs = dict(dataset_kwargs, chunks=chunks) write_func(store, elem_name, elem, dataset_kwargs=dataset_kwargs) write_dispatched(f, "/", adata, callback=callback, dataset_kwargs=ds_kwargs) if is_zarr_v2(): zarr.convenience.consolidate_metadata(f.store) else: zarr.consolidate_metadata(f.store) def read_zarr(store: PathLike[str] | str | MutableMapping | zarr.Group) -> AnnData: """\ Read from a hierarchical Zarr array store. Parameters ---------- store The filename, a :class:`~typing.MutableMapping`, or a Zarr storage class. """ f = store if isinstance(store, zarr.Group) else zarr.open(store, mode="r") # Read with handling for backwards compat def callback(func, elem_name: str, elem, iospec): if iospec.encoding_type == "anndata" or elem_name.endswith("/"): return AnnData(**{ k: read_dispatched(v, callback) for k, v in dict(elem).items() if not k.startswith("raw.") }) elif elem_name.startswith("/raw."): return None elif elem_name in {"/obs", "/var"}: return read_dataframe(elem) elif elem_name == "/raw": # Backwards compat return _read_legacy_raw(f, func(elem), read_dataframe, func) return func(elem) adata = read_dispatched(f, callback=callback) # Backwards compat (should figure out which version) if "raw.X" in f: raw = AnnData(**_read_legacy_raw(f, adata.raw, read_dataframe, read_elem)) raw.obs_names = adata.obs_names adata.raw = raw # Backwards compat for <0.7 if isinstance(f["obs"], zarr.Array): _clean_uns(adata) return adata @report_read_key_on_error def read_dataset(dataset: zarr.Array): """Legacy method for reading datasets without encoding_type.""" value = dataset[...] if not hasattr(value, "dtype"): return value elif isinstance(value.dtype, str): pass elif issubclass(value.dtype.type, np.bytes_): value = value.astype(str).astype(object) # bytestring -> unicode -> str elif len(value.dtype.descr) > 1: # Compound dtype # For backwards compat, now strings are written as variable length value = _from_fixed_length_strings(value) if value.shape == () and not np.isscalar(value): value = value[()] return value @report_read_key_on_error def read_dataframe_legacy(dataset: zarr.Array) -> pd.DataFrame: """Reads old format of dataframes""" # NOTE: Likely that categoricals need to be removed from uns msg = ( f"'{dataset.name}' was written with a very old version of AnnData. " "Consider rewriting it." ) warn(msg, OldFormatWarning, stacklevel=3) df = pd.DataFrame(_from_fixed_length_strings(dataset[()])) df.set_index(df.columns[0], inplace=True) return df @report_read_key_on_error def read_dataframe(group: zarr.Group | zarr.Array) -> pd.DataFrame: # Fast paths if isinstance(group, zarr.Array): return read_dataframe_legacy(group) else: return read_elem(group) def open_write_group( store: StoreLike, *, mode: AccessModeLiteral = "w", **kwargs ) -> zarr.Group: if not is_zarr_v2() and "zarr_format" not in kwargs: kwargs["zarr_format"] = settings.zarr_write_format return zarr.open_group(store, mode=mode, **kwargs) def is_group_consolidated(group: zarr.Group) -> bool: if not isinstance(group, zarr.Group): msg = f"Expected zarr.Group, got {type(group)}" raise TypeError(msg) if is_zarr_v2(): from zarr.storage import ConsolidatedMetadataStore return isinstance(group.store, ConsolidatedMetadataStore) return group.metadata.consolidated_metadata is not None scverse-anndata-b796d59/src/anndata/_settings.py000066400000000000000000000423031512025555600217210ustar00rootroot00000000000000from __future__ import annotations import inspect import os import textwrap import warnings from collections.abc import Iterable from contextlib import contextmanager from dataclasses import dataclass, field, fields from enum import Enum from functools import partial from inspect import Parameter, signature from types import GenericAlias from typing import TYPE_CHECKING, Generic, NamedTuple, TypeVar, cast from .compat import is_zarr_v2, old_positionals if TYPE_CHECKING: from collections.abc import Callable, Sequence from typing import Any, Self, TypeGuard T = TypeVar("T") class DeprecatedOption(NamedTuple): option: str message: str | None removal_version: str | None def _is_plain_type(obj: object) -> TypeGuard[type]: return isinstance(obj, type) and not isinstance(obj, GenericAlias) def describe(self: RegisteredOption, *, as_rst: bool = False) -> str: type_str = self.type.__name__ if _is_plain_type(self.type) else str(self.type) if as_rst: default_str = repr(self.default_value).replace("\\", "\\\\") doc = f"""\ .. attribute:: settings.{self.option} :type: {type_str} :value: {default_str} {self.description} """ else: doc = f"""\ {self.option}: `{type_str}` {self.description} (default: `{self.default_value!r}`). """ return textwrap.dedent(doc) class RegisteredOption(NamedTuple, Generic[T]): option: str default_value: T description: str validate: Callable[[T, SettingsManager], None] type: object describe = describe def check_and_get_environ_var( key: str, default_value: str, allowed_values: Sequence[str] | None = None, cast: Callable[[Any], T] | type[Enum] = lambda x: x, ) -> T: """Get the environment variable and return it is a (potentially) non-string, usable value. Parameters ---------- key The environment variable name. default_value The default value for `os.environ.get`. allowed_values Allowable string values., by default None cast Casting from the string to a (potentially different) python object, by default lambdax:x Returns ------- The casted value. """ environ_value_or_default_value = os.environ.get(key, default_value) if ( allowed_values is not None and environ_value_or_default_value not in allowed_values ): msg = ( f"Value {environ_value_or_default_value!r} is not in allowed {allowed_values} for environment variable {key}. " f"Default {default_value} will be used." ) warnings.warn(msg, UserWarning, stacklevel=3) environ_value_or_default_value = default_value return ( cast(environ_value_or_default_value) if not isinstance(cast, type(Enum)) else cast[environ_value_or_default_value] ) def check_and_get_bool(option: str, default_value: bool) -> bool: # noqa: FBT001 return check_and_get_environ_var( f"ANNDATA_{option.upper()}", str(int(default_value)), ["0", "1"], lambda x: bool(int(x)), ) def check_and_get_int(option: str, default_value: int) -> int: return check_and_get_environ_var( f"ANNDATA_{option.upper()}", str(int(default_value)), None, lambda x: int(x), ) _docstring = """ This manager allows users to customize settings for the anndata package. Settings here will generally be for advanced use-cases and should be used with caution. The following options are available: {options_description} For setting an option please use :func:`~anndata.settings.override` (local) or set the above attributes directly (global) i.e., `anndata.settings.my_setting = foo`. For assignment by environment variable, use the variable name in all caps with `ANNDATA_` as the prefix before import of :mod:`anndata`. For boolean environment variable setting, use 1 for `True` and 0 for `False`. """ @dataclass class SettingsManager: _registered_options: dict[str, RegisteredOption] = field(default_factory=dict) _deprecated_options: dict[str, DeprecatedOption] = field(default_factory=dict) _config: dict[str, object] = field(default_factory=dict) __doc_tmpl__: str = _docstring def describe( self, option: str | Iterable[str] | None = None, *, should_print_description: bool = True, as_rst: bool = False, ) -> str: """Print and/or return a (string) description of the option(s). Parameters ---------- option Option(s) to be described, by default None (i.e., do all option) should_print_description Whether or not to print the description in addition to returning it. Returns ------- The description. """ describe = partial( self.describe, should_print_description=should_print_description, as_rst=as_rst, ) if option is None: return describe(self._registered_options.keys()) if isinstance(option, Iterable) and not isinstance(option, str): return "\n".join([describe(k) for k in option]) registered_option = self._registered_options[option] doc = registered_option.describe(as_rst=as_rst).rstrip("\n") if option in self._deprecated_options: opt = self._deprecated_options[option] if opt.message is not None: doc += f" *{opt.message}" doc += f" {option} will be removed in {opt.removal_version}.*" if should_print_description: print(doc) return doc def deprecate( self, option: str, removal_version: str, message: str | None = None ) -> None: """Deprecate options with a message at a version. Parameters ---------- option Which option should be deprecated. removal_version The version targeted for removal. message A custom message. """ self._deprecated_options[option] = DeprecatedOption( option, message, removal_version ) @old_positionals("default_value", "description", "validate", "option_type") def register( self, option: str, *, default_value: T, description: str, validate: Callable[[T, Self], None], option_type: object | None = None, get_from_env: Callable[[str, T], T] = lambda x, y: y, ) -> None: """Register an option so it can be set/described etc. by end-users Parameters ---------- option Option to be set. default_value Default value with which to set the option. description Description to be used in the docstring. validate A function which raises a `ValueError` or `TypeError` if the value is invalid. option_type Optional override for the option type to be displayed. Otherwise `type(default_value)`. get_from_env An optional function which takes as arguments the name of the option and a default value and returns the value from the environment variable `ANNDATA_CAPS_OPTION` (or default if not present). Default behavior is to return `default_value` without checking the environment. """ try: validate(default_value, self) except (ValueError, TypeError) as e: e.add_note(f"for option {option!r}") raise e option_type = type(default_value) if option_type is None else option_type self._registered_options[option] = RegisteredOption( option, default_value, description, validate, option_type ) self._config[option] = get_from_env(option, default_value) self._update_override_function_for_new_option(option) def _update_override_function_for_new_option( self, option: str, ): """This function updates the keyword arguments, docstring, and annotations of the `SettingsManager.override` function as the `SettingsManager.register` method is called. Parameters ---------- option The option being registered for which the override function needs updating. """ option_type = self._registered_options[option].type # Update annotations for type checking. self.override.__annotations__[option] = option_type # __signature__ needs to be updated for tab autocompletion in IPython. # See https://github.com/ipython/ipython/issues/11624 for inspiration. self.override.__func__.__signature__ = signature(self.override).replace( parameters=[ Parameter(name="self", kind=Parameter.POSITIONAL_ONLY), *[ Parameter( name=k, annotation=option_type, kind=Parameter.KEYWORD_ONLY, ) for k in self._registered_options ], ] ) # Update docstring for `SettingsManager.override` as well. doc = cast("str", self.override.__doc__) insert_index = doc.find("\n Yields") option_docstring = "\t" + "\t".join( self.describe(option, should_print_description=False).splitlines( keepends=True ) ) self.override.__func__.__doc__ = ( f"{doc[:insert_index]}\n{option_docstring}{doc[insert_index:]}" ) def __setattr__(self, option: str, val: object) -> None: """ Set an option to a value. To see the allowed option to be set and their description, use describe_option. Parameters ---------- option Option to be set. val Value with which to set the option. Raises ------ AttributeError If the option has not been registered, this function will raise an error. """ if option in {f.name for f in fields(self)}: return super().__setattr__(option, val) elif option not in self._registered_options: msg = ( f"{option} is not an available option for anndata. " "Please open an issue if you believe this is a mistake." ) raise AttributeError(msg) registered_option = self._registered_options[option] registered_option.validate(val, self) self._config[option] = val def __getattr__(self, option: str) -> object: """ Gets the option's value. Parameters ---------- option Option to be got. Returns ------- Value of the option. """ if option in self._deprecated_options: deprecated = self._deprecated_options[option] msg = f"{option!r} will be removed in {deprecated.removal_version}. {deprecated.message}" warnings.warn(msg, FutureWarning, stacklevel=2) if option in self._config: return self._config[option] msg = f"{option} not found." raise AttributeError(msg) def __dir__(self) -> Iterable[str]: return sorted((*super().__dir__(), *self._config.keys())) def reset(self, option: Iterable[str] | str) -> None: """ Resets option(s) to its (their) default value(s). Parameters ---------- option The option(s) to be reset. """ if isinstance(option, Iterable) and not isinstance(option, str): for opt in option: self.reset(opt) else: self._config[option] = self._registered_options[option].default_value @contextmanager def override(self, **overrides): """ Provides local override via keyword arguments as a context manager. Parameters ---------- Yields ------ None """ restore = {a: getattr(self, a) for a in overrides} try: # Preserve order so that settings that depend on each other can be overridden together i.e., always override zarr version before sharding for k in self._config: if k in overrides: setattr(self, k, overrides.get(k)) yield None finally: # TODO: does the order need to be preserved when restoring? for attr, value in restore.items(): setattr(self, attr, value) def __repr__(self) -> str: params = "".join(f"\t{k}={v!r},\n" for k, v in self._config.items()) return f"{type(self).__name__}(\n{params}\n)" @property def __doc__(self): in_sphinx = any("/sphinx/" in frame.filename for frame in inspect.stack()) options_description = self.describe( should_print_description=False, as_rst=in_sphinx ) return self.__doc_tmpl__.format( options_description=options_description, ) settings = SettingsManager() ################################################################################## # PLACE REGISTERED SETTINGS HERE SO THEY CAN BE PICKED UP FOR DOCSTRING CREATION # ################################################################################## V = TypeVar("V") def gen_validator(_type: type[V]) -> Callable[[V], None]: def validate_type(val: V, settings: SettingsManager) -> None: if not isinstance(val, _type): msg = f"{val} not valid {_type}" raise TypeError(msg) return validate_type validate_bool = gen_validator(bool) validate_int = gen_validator(int) settings.register( "remove_unused_categories", default_value=True, description="Whether or not to remove unused categories with :class:`~pandas.Categorical`.", validate=validate_bool, get_from_env=check_and_get_bool, ) settings.register( "check_uniqueness", default_value=True, description=( "Whether or not to check uniqueness of the `obs` indices on `__init__` of :class:`~anndata.AnnData`." ), validate=validate_bool, get_from_env=check_and_get_bool, ) settings.register( "allow_write_nullable_strings", default_value=False, description="Whether or not to allow writing of `pd.arrays.{StringArray,ArrowStringArray}`.", validate=validate_bool, get_from_env=check_and_get_bool, ) def validate_zarr_write_format(format: int, settings: SettingsManager): validate_int(format, settings) if format not in {2, 3}: msg = "non-v2 zarr on-disk format not supported" raise ValueError(msg) if format == 3 and is_zarr_v2(): msg = "Cannot write v3 format against v2 package" raise ValueError(msg) if format == 2 and getattr(settings, "auto_shard_zarr_v3", False): msg = "Cannot set `zarr_write_format` to 2 with autosharding on. Please set to `False` `anndata.settings.auto_shard_zarr_v3`" raise ValueError(msg) def validate_zarr_sharding(auto_shard: bool, settings: SettingsManager): # noqa: FBT001 validate_bool(auto_shard, settings) if auto_shard: if is_zarr_v2(): msg = "Cannot use sharding with `zarr-python<3`. Please upgrade package and set `anndata.settings.zarr_write_format` to 3." raise ValueError(msg) if settings.zarr_write_format == 2: msg = "Cannot shard v2 format data. Please set `anndata.settings.zarr_write_format` to 3." raise ValueError(msg) settings.register( "zarr_write_format", default_value=2, description="Which version of zarr to write to when anndata must internally open a write-able zarr group.", validate=validate_zarr_write_format, get_from_env=lambda name, default: check_and_get_environ_var( f"ANNDATA_{name.upper()}", str(default), ["2", "3"], lambda x: int(x), ), ) def validate_sparse_settings(val: Any, settings: SettingsManager) -> None: validate_bool(val, settings) settings.register( "use_sparse_array_on_read", default_value=False, description="Whether or not to use :class:`scipy.sparse.sparray` as the default class when reading in data", validate=validate_bool, get_from_env=check_and_get_bool, ) settings.register( "min_rows_for_chunked_h5_copy", default_value=1000, description="Minimum number of rows at a time to copy when writing out an H5 Dataset to a new location", validate=validate_int, get_from_env=check_and_get_int, ) settings.register( "disallow_forward_slash_in_h5ad", default_value=False, description="Whether or not to disallow the `/` character in keys for h5ad files", validate=validate_bool, get_from_env=check_and_get_bool, ) settings.register( "auto_shard_zarr_v3", default_value=False, description="Whether or not to use zarr's auto computation of sharding for v3. For v2 this setting will be ignored. The setting will apply to all calls to anndata's writing mechanism (write_zarr / write_elem) and will **not** override any user-defined kwargs for shards.", validate=validate_zarr_sharding, get_from_env=check_and_get_bool, ) ################################################################################## ################################################################################## scverse-anndata-b796d59/src/anndata/_settings.pyi000066400000000000000000000031531512025555600220720ustar00rootroot00000000000000from collections.abc import Callable as Callable from collections.abc import Generator, Iterable from contextlib import contextmanager from dataclasses import dataclass from typing import Literal, Self, TypeVar _T = TypeVar("_T") @dataclass class SettingsManager: __doc_tmpl__: str = ... def describe( self, option: str | Iterable[str] | None = None, *, should_print_description: bool = True, as_rst: bool = False, ) -> str: ... def deprecate( self, option: str, removal_version: str, message: str | None = None ) -> None: ... def register( self, option: str, *, default_value: _T, description: str, validate: Callable[[_T, Self], None], option_type: object | None = None, get_from_env: Callable[[str, _T], _T] = ..., ) -> None: ... def __setattr__(self, option: str, val: object) -> None: ... def __getattr__(self, option: str) -> object: ... def __dir__(self) -> Iterable[str]: ... def reset(self, option: Iterable[str] | str) -> None: ... @contextmanager def override(self, **overrides) -> Generator[None]: ... @property def __doc__(self): ... class _AnnDataSettingsManager(SettingsManager): remove_unused_categories: bool = True check_uniqueness: bool = True allow_write_nullable_strings: bool = False zarr_write_format: Literal[2, 3] = 2 use_sparse_array_on_read: bool = False min_rows_for_chunked_h5_copy: int = 1000 disallow_forward_slash_in_h5ad: bool = False auto_shard_zarr_v3: bool = False settings: _AnnDataSettingsManager scverse-anndata-b796d59/src/anndata/_types.py000066400000000000000000000124471512025555600212330ustar00rootroot00000000000000""" Defines some useful types for this library. Should probably be cleaned up before thinking about exporting. """ from __future__ import annotations from typing import TYPE_CHECKING, Literal, Protocol, TypeVar from . import typing from .compat import H5Array, H5Group, ZarrArray, ZarrGroup if TYPE_CHECKING: from collections.abc import Mapping from typing import Any, TypeAlias from anndata._core.xarray import Dataset2D from ._io.specs.registry import ( IOSpec, LazyDataStructures, LazyReader, Reader, Writer, ) __all__ = [ "ArrayStorageType", "GroupStorageType", "StorageType", "_ReadInternal", "_ReadLazyInternal", "_WriteInternal", ] ArrayStorageType: TypeAlias = ZarrArray | H5Array GroupStorageType: TypeAlias = ZarrGroup | H5Group StorageType: TypeAlias = ArrayStorageType | GroupStorageType # NOTE: If you change these, be sure to update `autodoc_type_aliases` in docs/conf.py! RWAble_contra = TypeVar("RWAble_contra", bound=typing.RWAble, contravariant=True) RWAble_co = TypeVar("RWAble_co", bound=typing.RWAble, covariant=True) RWAble = TypeVar("RWAble", bound=typing.RWAble) S_co = TypeVar("S_co", covariant=True, bound=StorageType) S_contra = TypeVar("S_contra", contravariant=True, bound=StorageType) class Dataset2DIlocIndexer(Protocol): def __getitem__(self, idx: Any) -> Dataset2D: ... class _ReadInternal(Protocol[S_contra, RWAble_co]): def __call__(self, elem: S_contra, *, _reader: Reader) -> RWAble_co: ... class _ReadLazyInternal(Protocol[S_contra]): def __call__( self, elem: S_contra, *, _reader: LazyReader, chunks: tuple[int, ...] | None = None, ) -> LazyDataStructures: ... class Read(Protocol[S_contra, RWAble_co]): def __call__(self, elem: S_contra) -> RWAble_co: """Low-level reading function for an element. Parameters ---------- elem The element to read from. Returns ------- The element read from the store. """ ... class ReadLazy(Protocol[S_contra]): def __call__( self, elem: S_contra, *, chunks: tuple[int, ...] | None = None ) -> LazyDataStructures: """Low-level reading function for a lazy element. Parameters ---------- elem The element to read from. chunks The chunk size to be used. Returns ------- The lazy element read from the store. """ ... class _WriteInternal(Protocol[RWAble_contra]): def __call__( self, f: StorageType, k: str, v: RWAble_contra, *, _writer: Writer, dataset_kwargs: Mapping[str, Any], ) -> None: ... class Write(Protocol[RWAble_contra]): def __call__( self, f: StorageType, k: str, v: RWAble_contra, *, dataset_kwargs: Mapping[str, Any], ) -> None: """Low-level writing function for an element. Parameters ---------- f The store to which `elem` should be written. k The key to read in from the group. v The element to write out. dataset_kwargs Keyword arguments to be passed to a library-level io function, like `chunks` for :mod:`zarr`. """ ... class ReadCallback(Protocol[S_co, RWAble]): def __call__( self, /, read_func: Read[S_co, RWAble], elem_name: str, elem: StorageType, *, iospec: IOSpec, ) -> RWAble: """ Callback used in :func:`anndata.experimental.read_dispatched` to customize reading an element from a store. Params ------ read_func :func:`anndata.io.read_elem` function to call to read the current element given the ``iospec``. elem_name The key to read in from the group. elem The element to read from. iospec Internal AnnData encoding specification for the element. Returns ------- The element read from the store. """ ... class WriteCallback(Protocol[RWAble]): def __call__( self, /, write_func: Write[RWAble], store: StorageType, elem_name: str, elem: RWAble, *, iospec: IOSpec, dataset_kwargs: Mapping[str, Any], ) -> None: """ Callback used in :func:`anndata.experimental.write_dispatched` to customize writing an element to a store. Params ------ write_func :func:`anndata.io.write_elem` function to call to read the current element given the ``iospec``. store The store to which `elem` should be written. elem_name The key to read in from the group. elem The element to write out. iospec Internal AnnData encoding specification for the element. dataset_kwargs Keyword arguments to be passed to a library-level io function, like `chunks` for :mod:`zarr`. """ ... AnnDataElem = Literal[ "obs", "var", "obsm", "varm", "obsp", "varp", "layers", "X", "raw", "uns", ] Join_T = Literal["inner", "outer"] scverse-anndata-b796d59/src/anndata/_warnings.py000066400000000000000000000012761512025555600217150ustar00rootroot00000000000000from __future__ import annotations class WriteWarning(UserWarning): pass class OldFormatWarning(PendingDeprecationWarning): """Raised when a file in an old file format is read.""" class ImplicitModificationWarning(UserWarning): """\ Raised whenever initializing an object or assigning a property changes the type of a part of a parameter or the value being assigned. Examples ======== >>> import pandas as pd >>> adata = AnnData(obs=pd.DataFrame(index=[0, 1, 2])) # doctest: +SKIP ImplicitModificationWarning: Transforming to str index. """ class ExperimentalFeatureWarning(Warning): """Raised when an unstable experimental feature is used.""" scverse-anndata-b796d59/src/anndata/abc.py000066400000000000000000000031551512025555600204510ustar00rootroot00000000000000from __future__ import annotations from abc import ABC, abstractmethod from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import ClassVar, Literal import numpy as np from .compat import CSArray, CSMatrix, Index __all__ = ["CSCDataset", "CSRDataset"] class _AbstractCSDataset(ABC): """Base for the public API for CSRDataset/CSCDataset.""" format: ClassVar[Literal["csr", "csc"]] """The format of the sparse matrix.""" shape: tuple[int, int] """Shape of the matrix.""" dtype: np.dtype """The :class:`numpy.dtype` of the `data` attribute of the sparse matrix.""" backend: Literal["zarr", "hdf5"] """Which file type is used on-disk.""" @abstractmethod def __getitem__(self, index: Index) -> float | CSMatrix | CSArray: """Load a slice or an element from the sparse dataset into memory. Parameters ---------- index Index to load. Returns ------- The desired data read off disk. """ @abstractmethod def to_memory(self) -> CSMatrix | CSArray: """Load the sparse dataset into memory. Returns ------- The in-memory representation of the sparse dataset. """ _sparse_dataset_doc = """\ On disk {format} sparse matrix. Analogous to :class:`h5py.Dataset` or :class:`zarr.Array`, but for sparse matrices. """ class CSRDataset(_AbstractCSDataset, ABC): __doc__ = _sparse_dataset_doc.format(format="CSR") format = "csr" class CSCDataset(_AbstractCSDataset, ABC): __doc__ = _sparse_dataset_doc.format(format="CSC") format = "csc" scverse-anndata-b796d59/src/anndata/compat/000077500000000000000000000000001512025555600206315ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/compat/__init__.py000066400000000000000000000351701512025555600227500ustar00rootroot00000000000000from __future__ import annotations from codecs import decode from collections.abc import Mapping, Sequence from enum import Enum, auto from functools import cache, partial, singledispatch from importlib.metadata import version from importlib.util import find_spec from types import EllipsisType from typing import TYPE_CHECKING, TypeVar, overload from warnings import warn import h5py import numpy as np import pandas as pd import scipy from numpy.typing import NDArray from packaging.version import Version from zarr import Array as ZarrArray # noqa: F401 from zarr import Group as ZarrGroup if TYPE_CHECKING: from typing import Any ############################# # scipy sparse array comapt # ############################# CSMatrix = scipy.sparse.csr_matrix | scipy.sparse.csc_matrix CSArray = scipy.sparse.csr_array | scipy.sparse.csc_array class Empty(Enum): TOKEN = auto() Index1DNorm = slice | NDArray[np.bool_] | NDArray[np.integer] # TODO: pd.Index[???] Index1D = ( # 0D index int | str | np.int64 # normalized 1D idex | Index1DNorm # different containers for mask, obs/varnames, or numerical index | Sequence[int] | Sequence[str] | Sequence[bool] | pd.Series # bool, int, str | pd.Index | NDArray[np.str_] | np.matrix # bool | CSMatrix # bool | CSArray # bool ) IndexRest = Index1D | EllipsisType Index = ( IndexRest | tuple[Index1D, IndexRest] | tuple[IndexRest, Index1D] | tuple[Index1D, Index1D, EllipsisType] | tuple[EllipsisType, Index1D, Index1D] | tuple[Index1D, EllipsisType, Index1D] | CSMatrix | CSArray ) H5Group = h5py.Group H5Array = h5py.Dataset H5File = h5py.File ############################# # Optional deps ############################# @cache def is_zarr_v2() -> bool: return Version(version("zarr")) < Version("3.0.0") if is_zarr_v2(): msg = "anndata will no longer support zarr v2 in the near future. Please prepare to upgrade to zarr>=3." warn(msg, DeprecationWarning, stacklevel=2) if find_spec("awkward") or TYPE_CHECKING: import awkward # noqa: F401 from awkward import Array as AwkArray else: class AwkArray: @staticmethod def __repr__(): return "mock awkward.highlevel.Array" if find_spec("zappy") or TYPE_CHECKING: from zappy.base import ZappyArray else: class ZappyArray: @staticmethod def __repr__(): return "mock zappy.base.ZappyArray" if TYPE_CHECKING: # type checkers are confused and can only see …core.Array from dask.array.core import Array as DaskArray elif find_spec("dask"): from dask.array import Array as DaskArray else: class DaskArray: @staticmethod def __repr__(): return "mock dask.array.core.Array" if find_spec("xarray") or TYPE_CHECKING: import xarray from xarray import DataArray as XDataArray from xarray import Dataset as XDataset from xarray import Variable as XVariable from xarray.backends import BackendArray as XBackendArray from xarray.backends.zarr import ZarrArrayWrapper as XZarrArrayWrapper else: xarray = None class XDataArray: def __repr__(self) -> str: return "mock DataArray" class XDataset: def __repr__(self) -> str: return "mock Dataset" class XVariable: def __repr__(self) -> str: return "mock Variable" class XZarrArrayWrapper: def __repr__(self) -> str: return "mock ZarrArrayWrapper" class XBackendArray: def __repr__(self) -> str: return "mock BackendArray" # https://github.com/scverse/anndata/issues/1749 def is_cupy_importable() -> bool: try: import cupy # noqa: F401 except ImportError: return False return True if is_cupy_importable() or TYPE_CHECKING: from cupy import ndarray as CupyArray from cupyx.scipy.sparse import csc_matrix as CupyCSCMatrix from cupyx.scipy.sparse import csr_matrix as CupyCSRMatrix from cupyx.scipy.sparse import spmatrix as CupySparseMatrix try: import dask.array as da except ImportError: pass else: da.register_chunk_type(CupyCSRMatrix) da.register_chunk_type(CupyCSCMatrix) else: class CupySparseMatrix: @staticmethod def __repr__(): return "mock cupyx.scipy.sparse.spmatrix" class CupyCSRMatrix: @staticmethod def __repr__(): return "mock cupyx.scipy.sparse.csr_matrix" class CupyCSCMatrix: @staticmethod def __repr__(): return "mock cupyx.scipy.sparse.csc_matrix" class CupyArray: @staticmethod def __repr__(): return "mock cupy.ndarray" if find_spec("legacy_api_wrap") or TYPE_CHECKING: from legacy_api_wrap import legacy_api # noqa: TID251 old_positionals = partial(legacy_api, category=FutureWarning) else: def old_positionals(*old_positionals): return lambda func: func ############################# # IO helpers ############################# NULLABLE_NUMPY_STRING_TYPE = ( np.dtype("O") if Version(version("numpy")) < Version("2") else np.dtypes.StringDType(na_object=pd.NA) ) PANDAS_SUPPORTS_NA_VALUE = Version(version("pandas")) >= Version("2.3") PANDAS_STRING_ARRAY_TYPES: list[type[pd.api.extensions.ExtensionArray]] = [ pd.arrays.StringArray, pd.arrays.ArrowStringArray, ] # these are removed in favor of the above classes: https://github.com/pandas-dev/pandas/pull/62149 try: from pandas.core.arrays.string_ import StringArrayNumpySemantics except ImportError: pass else: PANDAS_STRING_ARRAY_TYPES += [StringArrayNumpySemantics] try: from pandas.core.arrays.string_arrow import ArrowStringArrayNumpySemantics except ImportError: pass else: PANDAS_STRING_ARRAY_TYPES += [ArrowStringArrayNumpySemantics] @overload def pandas_as_str(a: pd.Index[Any]) -> pd.Index[str]: ... @overload def pandas_as_str(a: pd.Series[Any]) -> pd.Series[str]: ... def pandas_as_str(a: pd.Index | pd.Series) -> pd.Index[str] | pd.Series[str]: """Convert to fitting dtype, maintaining NA semantics if possible. This is `"str"` when `pd.options.future.infer_string` is `True` (e.g. in Pandas 3+), and `"object"` otherwise. """ if not pd.options.future.infer_string: return a.astype(str) if a.array.dtype == "string": # any `pd.StringDtype` return a if PANDAS_SUPPORTS_NA_VALUE: dtype = pd.StringDtype(na_value=a.array.dtype.na_value) elif a.array.dtype.na_value is pd.NA: dtype = pd.StringDtype() # NA semantics elif a.array.dtype.na_value is np.nan and find_spec("pyarrow"): # noqa: PLW0177 # on pandas 2.2, this is the only way to get `np.nan` semantics dtype = pd.StringDtype("pyarrow_numpy") else: msg = ( f"Converting an array with `dtype.na_value={a.array.dtype.na_value}` to a string array requires pyarrow or pandas>=2.3. " "Converting to `pd.NA` semantics instead." ) warn(msg, UserWarning, stacklevel=2) dtype = pd.StringDtype() # NA semantics return a.astype(dtype) V = TypeVar("V") T = TypeVar("T") @overload def _read_attr( attrs: Mapping[str, V], name: str, default: Empty = Empty.TOKEN ) -> V: ... @overload def _read_attr(attrs: Mapping[str, V], name: str, default: T) -> V | T: ... @singledispatch def _read_attr( attrs: Mapping[str, V], name: str, default: T | Empty = Empty.TOKEN ) -> V | T: if default is Empty.TOKEN: return attrs[name] else: return attrs.get(name, default=default) @_read_attr.register(h5py.AttributeManager) def _read_attr_hdf5( attrs: h5py.AttributeManager, name: str, default: T | Empty = Empty.TOKEN ) -> str | T: """ Read an HDF5 attribute and perform all necessary conversions. At the moment, this only implements conversions for string attributes, other types are passed through. String conversion is needed compatibility with other languages. For example Julia's HDF5.jl writes string attributes as fixed-size strings, which are read as bytes by h5py. """ if name not in attrs and default is not Empty.TOKEN: return default attr = attrs[name] attr_id = attrs.get_id(name) dtype = h5py.check_string_dtype(attr_id.dtype) if dtype is None or dtype.length is None: # variable-length string, no problem return attr elif len(attr_id.shape) == 0: # Python bytestring return attr.decode("utf-8") else: # NumPy array return [decode(s, "utf-8") for s in attr] def _from_fixed_length_strings(value): """\ Convert from fixed length strings to unicode. For backwards compatibility with older h5ad and zarr files. """ new_dtype = [] for dt in value.dtype.descr: dt_list = list(dt) dt_type = dt[1] # could probably match better is_annotated = isinstance(dt_type, tuple) if is_annotated: dt_type = dt_type[0] # Fixing issue introduced with h5py v2.10.0, see: # https://github.com/h5py/h5py/issues/1307 if issubclass(np.dtype(dt_type).type, np.bytes_): dt_list[1] = f"U{int(dt_type[2:])}" elif is_annotated or np.issubdtype(np.dtype(dt_type), np.str_): dt_list[1] = "O" # Assumption that it’s a vlen str new_dtype.append(tuple(dt_list)) return value.astype(new_dtype) def _decode_structured_array( arr: np.ndarray, *, dtype: np.dtype | None = None, copy: bool = False ) -> np.ndarray: """ h5py 3.0 now reads all strings as bytes. There is a helper method which can convert these to strings, but there isn't anything for fields of structured dtypes. Params ------ arr An array with structured dtype dtype dtype of the array. This is checked for h5py string data types. Passing this is allowed for cases where array may have been processed by another function before hand. """ if copy: arr = arr.copy() if dtype is None: dtype = arr.dtype # codecs.decode is 2x slower than this lambda, go figure decode = np.frompyfunc(lambda x: x.decode("utf-8"), 1, 1) for k, (dt, _) in dtype.fields.items(): check = h5py.check_string_dtype(dt) if check is not None and check.encoding == "utf-8": decode(arr[k], out=arr[k]) return arr def _to_fixed_length_strings(value: np.ndarray) -> np.ndarray: """\ Convert variable length strings to fixed length. Formerly a workaround for https://github.com/zarr-developers/zarr-python/pull/422, resolved in https://github.com/zarr-developers/zarr-python/pull/813. But if we didn't do this conversion, we would have to use a special codec in v2 for objects and v3 doesn't support objects at all. So we leave this function as-is. """ new_dtype = [] for dt_name, (dt_type, dt_offset) in value.dtype.fields.items(): if dt_type.kind == "O": # Assuming the objects are str size = max(len(x.encode()) for x in value.getfield("O", dt_offset)) new_dtype.append((dt_name, ("U", size))) else: new_dtype.append((dt_name, dt_type)) return value.astype(new_dtype) Group_T = TypeVar("Group_T", bound=ZarrGroup | h5py.Group) # TODO: This is a workaround for https://github.com/scverse/anndata/issues/874 # See https://github.com/h5py/h5py/pull/2311#issuecomment-1734102238 for why this is done this way. def _require_group_write_dataframe( f: Group_T, name: str, df: pd.DataFrame, *args, **kwargs ) -> Group_T: if len(df.columns) > 5_000 and isinstance(f, H5Group): # actually 64kb is the limit, but this should be a conservative estimate return f.create_group(name, *args, track_order=True, **kwargs) return f.require_group(name, *args, **kwargs) ############################# # Dealing with uns ############################# def _clean_uns(adata: AnnData): # noqa: F821 """ Compat function for when categorical keys were stored in uns. This used to be buggy because when storing categorical columns in obs and var with the same column name, only one `_categories` is retained. """ k_to_delete = set() for cats_name, cats in adata.uns.items(): if not cats_name.endswith("_categories"): continue name = cats_name.replace("_categories", "") # fix categories with a single category if isinstance(cats, str | int): cats = [cats] for ann in [adata.obs, adata.var]: if name not in ann: continue codes: np.ndarray = ann[name].values # hack to maybe find the axis the categories were for if not np.all(codes < len(cats)): continue ann[name] = pd.Categorical.from_codes(codes, cats) k_to_delete.add(cats_name) for cats_name in k_to_delete: del adata.uns[cats_name] def _move_adj_mtx(d): """ Read-time fix for moving adjacency matrices from uns to obsp """ n = d.get("uns", {}).get("neighbors", {}) obsp = d.setdefault("obsp", {}) for k in ("distances", "connectivities"): if ( (k in n) and isinstance(n[k], scipy.sparse.spmatrix | np.ndarray) and len(n[k].shape) == 2 ): msg = ( f"Moving element from .uns['neighbors'][{k!r}] to .obsp[{k!r}].\n\n" "This is where adjacency matrices should go now." ) # 5: caller -> 4: legacy_api_wrap -> 3: `AnnData.__init__` -> 2: `_init_as_actual` → 1: here warn(msg, FutureWarning, stacklevel=5) obsp[k] = n.pop(k) def _find_sparse_matrices(d: Mapping, n: int, keys: tuple, paths: list): """Find paths to sparse matrices with shape (n, n).""" for k, v in d.items(): if isinstance(v, Mapping): _find_sparse_matrices(v, n, (*keys, k), paths) elif scipy.sparse.issparse(v) and v.shape == (n, n): paths.append((*keys, k)) return paths def _transpose_by_block(dask_array: DaskArray) -> DaskArray: import dask.array as da b = dask_array.blocks b_raveled = b.ravel() block_layout = np.zeros(b.shape, dtype=object) for i in range(block_layout.size): block_layout.flat[i] = b_raveled[i].map_blocks( lambda x: x.T, chunks=b_raveled[i].chunks[::-1] ) return da.block(block_layout.T.tolist()) def _safe_transpose(x): """Safely transpose x This is a workaround for: https://github.com/scipy/scipy/issues/19161 """ if isinstance(x, DaskArray) and scipy.sparse.issparse(x._meta): return _transpose_by_block(x) else: return x.T scverse-anndata-b796d59/src/anndata/experimental/000077500000000000000000000000001512025555600220435ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/experimental/__init__.py000066400000000000000000000031601512025555600241540ustar00rootroot00000000000000from __future__ import annotations from types import MappingProxyType from typing import TYPE_CHECKING from .._io.specs import IOSpec, read_elem_lazy from .._types import ( Dataset2DIlocIndexer, Read, ReadCallback, StorageType, Write, WriteCallback, ) from ..utils import module_get_attr_redirect from ._dispatch_io import read_dispatched, write_dispatched from .backed import read_lazy from .merge import concat_on_disk from .multi_files import AnnCollection if TYPE_CHECKING: from typing import Any from .pytorch import AnnLoader # Map old name in `anndata.experimental` to new name in `anndata` _DEPRECATED = MappingProxyType( dict( (kv if isinstance(kv, tuple) else (kv, kv)) for kv in ( ("CSRDataset", "abc.CSRDataset"), ("CSCDataset", "abc.CSCDataset"), ("sparse_dataset", "io.sparse_dataset"), ("read_elem", "io.read_elem"), ("write_elem", "io.write_elem"), ("RWAble", "typing.AxisStorable"), ("InMemoryElem", "typing.RWAble"), ) ) ) def __getattr__(attr_name: str) -> Any: if attr_name == "AnnLoader": from .pytorch import AnnLoader return AnnLoader return module_get_attr_redirect( attr_name, deprecated_mapping=_DEPRECATED, old_module_path="experimental" ) __all__ = [ "AnnCollection", "AnnLoader", "Dataset2DIlocIndexer", "IOSpec", "Read", "ReadCallback", "StorageType", "Write", "WriteCallback", "concat_on_disk", "read_dispatched", "read_elem_lazy", "read_lazy", "write_dispatched", ] scverse-anndata-b796d59/src/anndata/experimental/_dispatch_io.py000066400000000000000000000035021512025555600250420ustar00rootroot00000000000000from __future__ import annotations from types import MappingProxyType from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Mapping from typing import Any from anndata._types import ( GroupStorageType, ReadCallback, StorageType, WriteCallback, ) from anndata.typing import RWAble def read_dispatched(elem: StorageType, callback: ReadCallback) -> RWAble: """ Read elem, calling the callback at each sub-element. Params ------ elem Storage container (e.g. `h5py.Group`, `zarr.Group`). This must have anndata element specifications. callback Function to call at each anndata encoded element. See Also -------- :doc:`/tutorials/notebooks/{read,write}_dispatched` """ from anndata._io.specs import _REGISTRY, Reader reader = Reader(_REGISTRY, callback=callback) return reader.read_elem(elem) def write_dispatched( store: GroupStorageType, key: str, elem: RWAble, callback: WriteCallback, *, dataset_kwargs: Mapping[str, Any] = MappingProxyType({}), ) -> None: """ Write elem to store, recursively calling callback at each sub-element. Params ------ store Storage container to be written to. key Key to write element to. To write to the root group, use "/". elem The element to write. Probably an AnnData. callback Function called when writing each element. dataset_kwargs Keyword arguments to pass to the dataset creation function. See Also -------- :doc:`/tutorials/notebooks/{read,write}_dispatched` """ from anndata._io.specs import _REGISTRY, Writer writer = Writer(_REGISTRY, callback=callback) writer.write_elem(store, key, elem, dataset_kwargs=dataset_kwargs) scverse-anndata-b796d59/src/anndata/experimental/backed/000077500000000000000000000000001512025555600232545ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/experimental/backed/__init__.py000066400000000000000000000003461512025555600253700ustar00rootroot00000000000000from __future__ import annotations from ..._core.xarray import Dataset2D from ._io import read_lazy from ._lazy_arrays import CategoricalArray, MaskedArray __all__ = ["CategoricalArray", "Dataset2D", "MaskedArray", "read_lazy"] scverse-anndata-b796d59/src/anndata/experimental/backed/_compat.py000066400000000000000000000010071512025555600252460ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from ..._core.xarray import Dataset2D if TYPE_CHECKING: from anndata import AnnData def has_dataset_2d(adata: AnnData) -> bool: if any(isinstance(annot_df, Dataset2D) for annot_df in [adata.obs, adata.var]): return True for annot_m_key in ["varm", "obsm"]: annot_m = getattr(adata, annot_m_key) if any(isinstance(maybe_df, Dataset2D) for maybe_df in annot_m.values()): return True return False scverse-anndata-b796d59/src/anndata/experimental/backed/_io.py000066400000000000000000000150311512025555600243740ustar00rootroot00000000000000from __future__ import annotations import typing import warnings from os import PathLike from pathlib import Path from typing import TYPE_CHECKING import h5py from anndata._core.file_backing import AnnDataFileManager from anndata._io.specs.registry import read_elem_lazy from anndata._types import AnnDataElem from testing.anndata._doctest import doctest_needs from ..._core.anndata import AnnData from ..._core.xarray import requires_xarray from ..._settings import settings from ...compat import ZarrGroup, is_zarr_v2 from .. import read_dispatched if TYPE_CHECKING: from collections.abc import MutableMapping from anndata._io.specs.registry import IOSpec from anndata._types import Read, StorageType @doctest_needs("xarray") @requires_xarray def read_lazy( store: PathLike[str] | str | MutableMapping | ZarrGroup | h5py.File | h5py.Group, *, load_annotation_index: bool = True, ) -> AnnData: """ Lazily read in on-disk/in-cloud AnnData stores, including `obs` and `var`. No array data should need to be read into memory with the exception of :class:`ak.Array`, scalars, and some older-encoding arrays. Parameters ---------- store A store-like object to be read in. If :class:`zarr.Group`, it is best for it to be consolidated. If a path to an ``.h5ad`` file is provided, the open HDF5 file will be attached to the {class}`~anndata.AnnData` at the `file` attribute and it will be the user’s responsibility to close it when done with the returned object. For this reason, it is recommended to use an {class}`h5py.File` as the `store` argument when working with h5 files. It must remain open for at least as long as this returned object is in use. load_annotation_index Whether or not to use a range index for the `{obs,var}` :class:`xarray.Dataset` so as not to load the index into memory. If `False`, the real `index` will be inserted as `{obs,var}_names` in the object but not be one of the `coords` thereby preventing read operations. Access to `adata.obs.index` will also only give the dummy index, and not the "real" index that is file-backed. Returns ------- A lazily read-in :class:`~anndata.AnnData` object. Examples -------- Preparing example objects >>> import anndata as ad >>> from urllib.request import urlretrieve >>> import scanpy as sc >>> base_url = "https://datasets.cellxgene.cziscience.com" >>> def get_cellxgene_data(id_: str): ... out_path = sc.settings.datasetdir / f"{id_}.h5ad" ... if out_path.exists(): ... return out_path ... file_url = f"{base_url}/{id_}.h5ad" ... sc.settings.datasetdir.mkdir(parents=True, exist_ok=True) ... urlretrieve(file_url, out_path) ... return out_path >>> path_b_cells = get_cellxgene_data("a93eab58-3d82-4b61-8a2f-d7666dcdb7c4") >>> path_fetal = get_cellxgene_data("d170ff04-6da0-4156-a719-f8e1bbefbf53") >>> b_cells_adata = ad.experimental.read_lazy(path_b_cells) >>> fetal_adata = ad.experimental.read_lazy(path_fetal) >>> print(b_cells_adata) AnnData object with n_obs × n_vars = 146 × 33452 obs: 'donor_id', 'self_reported_ethnicity_ontology_term_id', 'organism_ontology_term_id', ... >>> print(fetal_adata) AnnData object with n_obs × n_vars = 344 × 15585 obs: 'nCount_Spatial', 'nFeature_Spatial', 'Cluster', 'adult_pred_type'... This functionality is compatible with :func:`anndata.concat` >>> ad.concat([b_cells_adata, fetal_adata], join="outer") AnnData object with n_obs × n_vars = 490 × 33452 obs: 'donor_id', 'self_reported_ethnicity_ontology_term_id', 'organism_ontology_term_id'... """ is_store_arg_h5_store = isinstance(store, h5py.Dataset | h5py.File | h5py.Group) is_store_arg_h5_path = ( isinstance(store, PathLike | str) and Path(store).suffix == ".h5ad" ) is_h5 = is_store_arg_h5_path or is_store_arg_h5_store has_keys = True # true if consolidated or h5ad if not is_h5: import zarr if not isinstance(store, ZarrGroup): # v3 returns a ValueError for consolidated metadata not found err_cls = KeyError if is_zarr_v2() else ValueError try: f = zarr.open_consolidated(store, mode="r") except err_cls: msg = "Did not read zarr as consolidated. Consider consolidating your metadata." warnings.warn(msg, UserWarning, stacklevel=2) has_keys = False f = zarr.open_group(store, mode="r") else: f = store elif is_store_arg_h5_store: f = store else: f = h5py.File(store, mode="r") def callback(func: Read, /, elem_name: str, elem: StorageType, *, iospec: IOSpec): if iospec.encoding_type in {"anndata", "raw"} or elem_name.endswith("/"): iter_object = ( dict(elem).items() if has_keys else ( (k, v) for k, v in ( (k, elem.get(k, None)) for k in typing.get_args(AnnDataElem) ) if v is not None # need to do this instead of `k in elem` to prevent unnecessary metadata accesses ) ) return AnnData(**{k: read_dispatched(v, callback) for k, v in iter_object}) elif ( iospec.encoding_type in { "csr_matrix", "csc_matrix", "array", "string-array", "dataframe", "categorical", } or "nullable" in iospec.encoding_type ): if iospec.encoding_type == "dataframe" and ( elem_name[:4] in {"/obs", "/var"} or elem_name[:8] in {"/raw/obs", "/raw/var"} ): return read_elem_lazy(elem, use_range_index=not load_annotation_index) return read_elem_lazy(elem) elif iospec.encoding_type in {"awkward-array"}: return read_dispatched(elem, None) elif iospec.encoding_type == "dict": return { k: read_dispatched(v, callback=callback) for k, v in dict(elem).items() } return func(elem) with settings.override(check_uniqueness=load_annotation_index): adata: AnnData = read_dispatched(f, callback=callback) if is_store_arg_h5_path and not is_store_arg_h5_store: adata.file = AnnDataFileManager(adata, file_obj=f) return adata scverse-anndata-b796d59/src/anndata/experimental/backed/_lazy_arrays.py000066400000000000000000000171751512025555600263400ustar00rootroot00000000000000from __future__ import annotations from functools import cached_property from typing import TYPE_CHECKING, Generic, TypeVar import numpy as np import pandas as pd from anndata._core.index import _subset from anndata._core.views import as_view from anndata._io.specs.lazy_methods import get_chunksize from ..._settings import settings from ...compat import ( NULLABLE_NUMPY_STRING_TYPE, H5Array, XBackendArray, XDataArray, XZarrArrayWrapper, ZarrArray, ) if TYPE_CHECKING: from pathlib import Path from typing import Literal from pandas._libs.missing import NAType from pandas.core.dtypes.base import ExtensionDtype from anndata.compat import ZarrGroup from ...compat import Index1DNorm if TYPE_CHECKING: # Double nesting so Sphinx can import the parent block from xarray.core.extension_array import PandasExtensionArray from xarray.core.indexing import ExplicitIndexer K = TypeVar("K", H5Array, ZarrArray) class ZarrOrHDF5Wrapper(XZarrArrayWrapper, Generic[K]): def __init__(self, array: K): self.chunks = array.chunks if isinstance(array, ZarrArray): super().__init__(array) return self._array = array self.shape = self._array.shape self.dtype = self._array.dtype def __getitem__(self, key: ExplicitIndexer): from xarray.core.indexing import IndexingSupport, explicit_indexing_adapter if isinstance(self._array, ZarrArray): return super().__getitem__(key) res = explicit_indexing_adapter( key, self.shape, IndexingSupport.OUTER_1VECTOR, self._getitem ) return res def _getitem(self, key: tuple[int | np.integer | slice | np.ndarray]): if not isinstance(key, tuple): msg = f"`xr.core.indexing.explicit_indexing_adapter` should have produced a tuple, got {type(key)} instead" raise ValueError(msg) if (n_key_dims := len(key)) != 1: msg = f"Backed arrays currently only supported in 1d, got {n_key_dims} dims" raise ValueError(msg) key = key[0] # See https://github.com/h5py/h5py/issues/293 for why we need to convert. # See https://github.com/pydata/xarray/blob/fa03b5b4ae95a366f6de5b60f5cc4eb801cd51ec/xarray/core/indexing.py#L1259-L1263 # for why we can expect sorted/deduped indexers (which are needed for hdf5). if ( isinstance(key, np.ndarray) and np.issubdtype(key.dtype, np.integer) and isinstance(self._array, H5Array) ): key_mask = np.zeros(self._array.shape).astype("bool") key_mask[key] = True return self._array[key_mask] return self._array[key] class CategoricalArray(XBackendArray, Generic[K]): """ A wrapper class meant to enable working with lazy categorical data. We do not guarantee the stability of this API beyond that guaranteed by :class:`xarray.backends.BackendArray`. """ _codes: ZarrOrHDF5Wrapper[K] _categories: ZarrArray | H5Array shape: tuple[int, ...] base_path_or_zarr_group: Path | ZarrGroup elem_name: str def __init__( self, codes: K, categories: ZarrArray | H5Array, base_path_or_zarr_group: Path | ZarrGroup, elem_name: str, *args, ordered: bool, **kwargs, ): self._categories = categories self._ordered = ordered self._codes = ZarrOrHDF5Wrapper(codes) self.shape = self._codes.shape self.base_path_or_zarr_group = base_path_or_zarr_group self.file_format = "zarr" if isinstance(codes, ZarrArray) else "h5" self.elem_name = elem_name @cached_property def categories(self) -> np.ndarray: from anndata.io import read_elem return read_elem(self._categories) def __getitem__(self, key: ExplicitIndexer) -> PandasExtensionArray: from xarray.core.extension_array import PandasExtensionArray codes = self._codes[key] categorical_array = pd.Categorical.from_codes( codes=codes, # casting to numpy (string) maintains our old behavior, this will be relaxed in 0.13 categories=np.array(self.categories), ordered=self._ordered, ) if settings.remove_unused_categories: categorical_array = categorical_array.remove_unused_categories() return PandasExtensionArray(categorical_array) @cached_property def dtype(self): return pd.CategoricalDtype(categories=self.categories, ordered=self._ordered) class MaskedArray(XBackendArray, Generic[K]): """ A wrapper class meant to enable working with lazy masked data. We do not guarantee the stability of this API beyond that guaranteed by :class:`xarray.backends.BackendArray`. """ _mask: ZarrOrHDF5Wrapper[K] _values: ZarrOrHDF5Wrapper[K] _dtype_str: Literal["nullable-integer", "nullable-boolean", "nullable-string-array"] shape: tuple[int, ...] base_path_or_zarr_group: Path | ZarrGroup elem_name: str def __init__( self, values: ZarrArray | H5Array, dtype_str: Literal[ "nullable-integer", "nullable-boolean", "nullable-string-array" ], mask: ZarrArray | H5Array, base_path_or_zarr_group: Path | ZarrGroup, elem_name: str, ): self._mask = ZarrOrHDF5Wrapper(mask) self._values = ZarrOrHDF5Wrapper(values) self._dtype_str = dtype_str self.shape = self._values.shape self.base_path_or_zarr_group = base_path_or_zarr_group self.file_format = "zarr" if isinstance(mask, ZarrArray) else "h5" self.elem_name = elem_name def __getitem__(self, key: ExplicitIndexer) -> PandasExtensionArray | np.ndarray: from xarray.core.extension_array import PandasExtensionArray values = self._values[key] mask = self._mask[key] if self._dtype_str == "nullable-integer": # numpy does not support nan ints extension_array = pd.arrays.IntegerArray(values, mask=mask) elif self._dtype_str == "nullable-boolean": extension_array = pd.arrays.BooleanArray(values, mask=mask) elif self._dtype_str == "nullable-string-array": # https://github.com/pydata/xarray/issues/10419 values = values.astype(self.dtype) values[mask] = pd.NA return values else: msg = f"Invalid dtype_str {self._dtype_str}" raise RuntimeError(msg) return PandasExtensionArray(extension_array) @cached_property def dtype(self) -> np.dtypes.StringDType[NAType] | ExtensionDtype: if self._dtype_str == "nullable-integer": return pd.array( [], dtype=str(pd.api.types.pandas_dtype(self._values.dtype)).capitalize(), ).dtype elif self._dtype_str == "nullable-boolean": return pd.BooleanDtype() elif self._dtype_str == "nullable-string-array": # https://github.com/pydata/xarray/issues/10419 return NULLABLE_NUMPY_STRING_TYPE msg = f"Invalid dtype_str {self._dtype_str}" raise RuntimeError(msg) @_subset.register(XDataArray) def _subset_masked( a: XDataArray, subset_idx: tuple[Index1DNorm] | tuple[Index1DNorm, Index1DNorm] ): return a[subset_idx] @as_view.register(XDataArray) def _view_pd_boolean_array(a: XDataArray, view_args): return a @get_chunksize.register(MaskedArray) def _(a: MaskedArray): return get_chunksize(a._values) @get_chunksize.register(CategoricalArray) def _(a: CategoricalArray): return get_chunksize(a._codes) scverse-anndata-b796d59/src/anndata/experimental/merge.py000066400000000000000000000604621512025555600235240ustar00rootroot00000000000000from __future__ import annotations import shutil from collections.abc import Mapping from contextlib import ExitStack, contextmanager from functools import singledispatch from os import PathLike from pathlib import Path from typing import TYPE_CHECKING import numpy as np import pandas as pd from scipy.sparse import csc_matrix, csr_matrix from .._core.file_backing import to_memory from .._core.merge import ( MissingVal, _resolve_axis, concat_arrays, gen_inner_reindexers, gen_reindexer, intersect_keys, merge_dataframes, merge_indices, resolve_merge_strategy, unify_dtypes, ) from .._core.sparse_dataset import BaseCompressedSparseDataset, sparse_dataset from .._io.specs import read_elem, write_elem from ..compat import H5Array, H5Group, ZarrArray, ZarrGroup from . import read_dispatched, read_elem_lazy if TYPE_CHECKING: from collections.abc import Callable, Collection, Generator, Iterable, Sequence from typing import Any, Literal from .._core.merge import Reindexer, StrategiesLiteral from .._types import Join_T SPARSE_MATRIX = {"csc_matrix", "csr_matrix"} EAGER_TYPES = {"dataframe", "awkward-array"} ################### # Utilities ################### # Wrapper to reindexer that stores if there is a change # and won't do anything if there is class IdentityReindexer: def __init__(self): self.no_change = True def __call__(self, x, *args, **kwargs): return x # Checks if given indices are equal to each other in the whole list. def _indices_equal(indices: Iterable[pd.Index]) -> bool: init_elem = indices[0] return all(np.array_equal(init_elem, elem) for elem in indices[1:]) def _gen_slice_to_append( datasets: Sequence[BaseCompressedSparseDataset], reindexers, max_loaded_elems: int, axis=0, fill_value=None, ): for ds, ri in zip(datasets, reindexers, strict=False): n_slices = ds.shape[axis] * ds.shape[1 - axis] // max_loaded_elems if n_slices < 2: yield (csr_matrix, csc_matrix)[axis]( ri(to_memory(ds), axis=1 - axis, fill_value=fill_value) ) else: slice_size = max_loaded_elems // ds.shape[1 - axis] if slice_size == 0: slice_size = 1 rem_slices = ds.shape[axis] idx = 0 while rem_slices > 0: ds_part = None if axis == 0: ds_part = ds[idx : idx + slice_size, :] elif axis == 1: ds_part = ds[:, idx : idx + slice_size] yield (csr_matrix, csc_matrix)[axis]( ri(ds_part, axis=1 - axis, fill_value=fill_value) ) rem_slices -= slice_size idx += slice_size ################### # File Management ################### @singledispatch @contextmanager def as_group(store, *, mode: str) -> Generator[ZarrGroup | H5Group]: msg = "This is not yet implemented." raise NotImplementedError(msg) @as_group.register(PathLike) @as_group.register(str) @contextmanager def _(store: PathLike[str] | str, *, mode: str) -> Generator[ZarrGroup | H5Group]: store = Path(store) if store.suffix == ".h5ad": import h5py f = h5py.File(store, mode=mode) try: yield f finally: f.close() elif mode == "r": # others all write: r+, a, w, w- import zarr yield zarr.open_group(store, mode=mode) else: from anndata._io.zarr import open_write_group yield open_write_group(store, mode=mode) @as_group.register(ZarrGroup) @as_group.register(H5Group) @contextmanager def _(store: ZarrGroup | H5Group, *, mode: str) -> Generator[ZarrGroup | H5Group]: del mode yield store ################### # Reading ################### def read_as_backed(group: ZarrGroup | H5Group): """ Read the group until BaseCompressedSparseDataset, Array or EAGER_TYPES are encountered. """ def callback(func, elem_name: str, elem, iospec): if iospec.encoding_type in SPARSE_MATRIX: return sparse_dataset(elem) elif iospec.encoding_type in EAGER_TYPES: return read_elem(elem) elif iospec.encoding_type == "array": return elem elif iospec.encoding_type == "dict": return {k: read_as_backed(v) for k, v in dict(elem).items()} else: return func(elem) return read_dispatched(group, callback=callback) def _df_index(df: ZarrGroup | H5Group) -> pd.Index: index_key = df.attrs["_index"] return pd.Index(read_elem(df[index_key])) ################### # Writing ################### def write_concat_dense( # noqa: PLR0917 arrays: Sequence[ZarrArray | H5Array], output_group: ZarrGroup | H5Group, output_path: ZarrGroup | H5Group, axis: Literal[0, 1] = 0, reindexers: Reindexer | None = None, fill_value: Any = None, ): """ Writes the concatenation of given dense arrays to disk using dask. """ import dask.array as da darrays = ( da.from_array(a, chunks="auto" if a.chunks is None else a.chunks) for a in arrays ) res = da.concatenate( [ ri(a, axis=1 - axis, fill_value=fill_value) for a, ri in zip(darrays, reindexers, strict=False) ], axis=axis, ) write_elem(output_group, output_path, res) output_group[output_path].attrs.update({ "encoding-type": "array", "encoding-version": "0.2.0", }) def write_concat_sparse( # noqa: PLR0917 datasets: Sequence[BaseCompressedSparseDataset], output_group: ZarrGroup | H5Group, output_path: ZarrGroup | H5Group, max_loaded_elems: int, axis: Literal[0, 1] = 0, reindexers: Reindexer | None = None, fill_value: Any = None, ): """ Writes and concatenates sparse datasets into a single output dataset. Args: datasets (Sequence[BaseCompressedSparseDataset]): A sequence of BaseCompressedSparseDataset objects to be concatenated. output_group (Union[ZarrGroup, H5Group]): The output group where the concatenated dataset will be written. output_path (Union[ZarrGroup, H5Group]): The output path where the concatenated dataset will be written. max_loaded_elems (int): The maximum number of sparse elements to load at once. axis (Literal[0, 1], optional): The axis along which the datasets should be concatenated. Defaults to 0. reindexers (Reindexer, optional): A reindexer object that defines the reindexing operation to be applied. Defaults to None. fill_value (Any, optional): The fill value to use for missing elements. Defaults to None. """ elems = None if all(ri.no_change for ri in reindexers): elems = iter(datasets) else: elems = _gen_slice_to_append( datasets, reindexers, max_loaded_elems, axis, fill_value ) number_non_zero = sum(d.group["indices"].shape[0] for d in datasets) init_elem = next(elems) indptr_dtype = "int64" if number_non_zero >= np.iinfo(np.int32).max else "int32" write_elem( output_group, output_path, init_elem, dataset_kwargs=dict(indptr_dtype=indptr_dtype), ) del init_elem out_dataset: BaseCompressedSparseDataset = read_as_backed(output_group[output_path]) for temp_elem in elems: out_dataset.append(temp_elem) del temp_elem def _write_concat_mappings( # noqa: PLR0913, PLR0917 mappings: Collection[dict], output_group: ZarrGroup | H5Group, keys: Collection[str], output_path: str | Path, max_loaded_elems: int, axis: Literal[0, 1] = 0, index: pd.Index = None, reindexers: list[Reindexer] | None = None, fill_value: Any = None, ): """ Write a list of mappings to a zarr/h5 group. """ mapping_group = output_group.create_group(output_path) mapping_group.attrs.update({ "encoding-type": "dict", "encoding-version": "0.1.0", }) for k in keys: elems = [m[k] for m in mappings] _write_concat_sequence( elems, output_group=mapping_group, output_path=k, axis=axis, index=index, reindexers=reindexers, fill_value=fill_value, max_loaded_elems=max_loaded_elems, ) def _write_concat_arrays( # noqa: PLR0913, PLR0917 arrays: Sequence[ZarrArray | H5Array | BaseCompressedSparseDataset], output_group: ZarrGroup | H5Group, output_path: str | Path, max_loaded_elems: int, axis: Literal[0, 1] = 0, reindexers: list[Reindexer] | None = None, fill_value: Any = None, join: Literal["inner", "outer"] = "inner", ): init_elem = arrays[0] init_type = type(init_elem) if not all(isinstance(a, init_type) for a in arrays): msg = f"All elements must be the same type instead got types: {[type(a) for a in arrays]}" raise NotImplementedError(msg) if reindexers is None: if join == "inner": reindexers = gen_inner_reindexers(arrays, new_index=None, axis=axis) else: msg = "Cannot reindex arrays with outer join." raise NotImplementedError(msg) if isinstance(init_elem, BaseCompressedSparseDataset): expected_sparse_fmt = ["csr", "csc"][axis] if all(a.format == expected_sparse_fmt for a in arrays): write_concat_sparse( arrays, output_group, output_path, max_loaded_elems, axis, reindexers, fill_value, ) else: msg = f"Concat of following not supported: {[a.format for a in arrays]}" raise NotImplementedError(msg) else: write_concat_dense( arrays, output_group, output_path, axis, reindexers, fill_value ) def _write_concat_sequence( # noqa: PLR0913, PLR0917 arrays: Sequence[pd.DataFrame | BaseCompressedSparseDataset | H5Array | ZarrArray], output_group: ZarrGroup | H5Group, output_path: str | Path, max_loaded_elems: int, axis: Literal[0, 1] = 0, index: pd.Index = None, reindexers: list[Reindexer] | None = None, fill_value: Any = None, join: Literal["inner", "outer"] = "inner", ): """ array, dataframe, csc_matrix, csc_matrix """ if any(isinstance(a, pd.DataFrame) for a in arrays): if reindexers is None: if join == "inner": reindexers = gen_inner_reindexers(arrays, None, axis=axis) else: msg = "Cannot reindex dataframes with outer join." raise NotImplementedError(msg) if not all( isinstance(a, pd.DataFrame) or a is MissingVal or 0 in a.shape for a in arrays ): msg = "Cannot concatenate a dataframe with other array types." raise NotImplementedError(msg) df = concat_arrays( arrays=arrays, reindexers=reindexers, axis=axis, index=index, fill_value=fill_value, ) write_elem(output_group, output_path, df) elif all( isinstance(a, pd.DataFrame | BaseCompressedSparseDataset | H5Array | ZarrArray) for a in arrays ): _write_concat_arrays( arrays, output_group, output_path, max_loaded_elems, axis, reindexers, fill_value, join, ) else: msg = f"Concatenation of these types is not yet implemented: {[type(a) for a in arrays]} with axis={axis}." raise NotImplementedError(msg) def _write_alt_mapping( groups: Collection[H5Group, ZarrGroup], output_group: ZarrGroup | H5Group, alt_axis_name: Literal["obs", "var"], merge: Callable, reindexers: list[Reindexer], ): alt_mapping = merge([ {k: r(read_elem(v), axis=0) for k, v in dict(g[f"{alt_axis_name}m"]).items()} for r, g in zip(reindexers, groups, strict=True) ]) write_elem(output_group, f"{alt_axis_name}m", alt_mapping) def _write_alt_annot( groups: Collection[H5Group, ZarrGroup], output_group: ZarrGroup | H5Group, alt_axis_name: Literal["obs", "var"], alt_indices: pd.Index, merge: Callable, ): # Annotation for other axis alt_annot = merge_dataframes( [read_elem(g[alt_axis_name]) for g in groups], alt_indices, merge ) write_elem(output_group, alt_axis_name, alt_annot) def _write_axis_annot( # noqa: PLR0917 groups: Collection[H5Group, ZarrGroup], output_group: ZarrGroup | H5Group, axis_name: Literal["obs", "var"], concat_indices: pd.Index, label: str, label_col: str, join: Literal["inner", "outer"], ): concat_annot = pd.concat( unify_dtypes(read_elem(g[axis_name]) for g in groups), join=join, ignore_index=True, ) concat_annot.index = concat_indices if label is not None: concat_annot[label] = label_col write_elem(output_group, axis_name, concat_annot) def _write_alt_pairwise( groups: Collection[H5Group, ZarrGroup], output_group: ZarrGroup | H5Group, alt_axis_name: Literal["obs", "var"], merge: Callable, reindexers: list[Reindexer], ): alt_pairwise = merge([ { k: r(r(read_elem_lazy(v), axis=0), axis=1) for k, v in dict(g[f"{alt_axis_name}p"]).items() } for r, g in zip(reindexers, groups, strict=True) ]) write_elem(output_group, f"{alt_axis_name}p", alt_pairwise) def concat_on_disk( # noqa: PLR0913 in_files: Collection[PathLike[str] | str | H5Group | ZarrGroup] | Mapping[str, PathLike[str] | str | H5Group | ZarrGroup], out_file: PathLike[str] | str | H5Group | ZarrGroup, *, max_loaded_elems: int = 100_000_000, axis: Literal["obs", 0, "var", 1] = 0, join: Literal["inner", "outer"] = "inner", merge: StrategiesLiteral | Callable[[Collection[Mapping]], Mapping] | None = None, uns_merge: ( StrategiesLiteral | Callable[[Collection[Mapping]], Mapping] | None ) = None, label: str | None = None, keys: Collection[str] | None = None, index_unique: str | None = None, fill_value: Any | None = None, pairwise: bool = False, ) -> None: """\ Concatenates multiple AnnData objects along a specified axis using their corresponding stores or paths, and writes the resulting AnnData object to a target location on disk. Unlike :func:`anndata.concat`, this method does not require loading the input AnnData objects into memory, making it a memory-efficient alternative for large datasets. The resulting object written to disk should be equivalent to the concatenation of the loaded AnnData objects using :func:`anndata.concat`. To adjust the maximum amount of data loaded in memory; for sparse arrays use the max_loaded_elems argument; for dense arrays see the Dask documentation, as the Dask concatenation function is used to concatenate dense arrays in this function Params ------ in_files The corresponding stores or paths of AnnData objects to be concatenated. If a Mapping is passed, keys are used for the `keys` argument and values are concatenated. out_file The target path or store to write the result in. max_loaded_elems The maximum number of elements to load in memory when concatenating sparse arrays. Note that this number also includes the empty entries. Set to 100m by default meaning roughly 400mb will be loaded to memory simultaneously. axis Which axis to concatenate along. join How to align values when concatenating. If `"outer"`, the union of the other axis is taken. If `"inner"`, the intersection. See :doc:`concatenation <../concatenation>` for more. merge How elements not aligned to the axis being concatenated along are selected. Currently implemented strategies include: * `None`: No elements are kept. * `"same"`: Elements that are the same in each of the objects. * `"unique"`: Elements for which there is only one possible value. * `"first"`: The first element seen at each from each position. * `"only"`: Elements that show up in only one of the objects. uns_merge How the elements of `.uns` are selected. Uses the same set of strategies as the `merge` argument, except applied recursively. label Column in axis annotation (i.e. `.obs` or `.var`) to place batch information in. If it's None, no column is added. keys Names for each object being added. These values are used for column values for `label` or appended to the index if `index_unique` is not `None`. Defaults to incrementing integer labels. index_unique Whether to make the index unique by using the keys. If provided, this is the delimiter between `"{orig_idx}{index_unique}{key}"`. When `None`, the original indices are kept. fill_value When `join="outer"`, this is the value that will be used to fill the introduced indices. By default, sparse arrays are padded with zeros, while dense arrays and DataFrames are padded with missing values. pairwise Whether pairwise elements along the concatenated dimension should be included. This is False by default, since the resulting arrays are often not meaningful, and raises {class}`NotImplementedError` when True. If you are interested in this feature, please open an issue. Notes ----- .. warning:: If you use `join='outer'` this fills 0s for sparse data when variables are absent in a batch. Use this with care. Dense data is filled with `NaN`. Examples -------- See :func:`anndata.concat` for the semantics. The following examples highlight the differences this function has. First, let’s get some “big” datasets with a compatible ``var`` axis: >>> import httpx >>> import scanpy as sc >>> base_url = "https://datasets.cellxgene.cziscience.com" >>> def get_cellxgene_data(id_: str): ... out_path = sc.settings.datasetdir / f'{id_}.h5ad' ... if out_path.exists(): ... return out_path ... file_url = f"{base_url}/{id_}.h5ad" ... sc.settings.datasetdir.mkdir(parents=True, exist_ok=True) ... out_path.write_bytes(httpx.get(file_url).content) ... return out_path >>> path_b_cells = get_cellxgene_data('a93eab58-3d82-4b61-8a2f-d7666dcdb7c4') >>> path_fetal = get_cellxgene_data('d170ff04-6da0-4156-a719-f8e1bbefbf53') Now we can concatenate them on-disk: >>> import anndata as ad >>> ad.experimental.concat_on_disk( ... dict(b_cells=path_b_cells, fetal=path_fetal), ... 'merged.h5ad', ... label='dataset', ... ) >>> adata = ad.read_h5ad('merged.h5ad', backed=True) >>> adata.X CSRDataset: backend hdf5, shape (490, 15585), data_dtype float32 >>> adata.obs['dataset'].value_counts() # doctest: +SKIP dataset fetal 344 b_cells 146 Name: count, dtype: int64 """ if len(in_files) == 0: msg = "No objects to concatenate." raise ValueError(msg) # Argument normalization if pairwise: msg = "pairwise concatenation not yet implemented" raise NotImplementedError(msg) merge = resolve_merge_strategy(merge) uns_merge = resolve_merge_strategy(uns_merge) if is_out_path_like := isinstance(out_file, str | PathLike): out_file = Path(out_file) if not out_file.parent.exists(): msg = f"Parent directory of {out_file} does not exist." raise FileNotFoundError(msg) if isinstance(in_files, Mapping): if keys is not None: msg = ( "Cannot specify categories in both mapping keys and using `keys`. " "Only specify this once." ) raise TypeError(msg) keys, in_files = list(in_files.keys()), list(in_files.values()) else: in_files = list(in_files) if ( len(in_files) == 1 and isinstance(in_files[0], str | PathLike) and is_out_path_like ): shutil.copy2(in_files[0], out_file) return if keys is None: keys = np.arange(len(in_files)).astype(str) axis, axis_name = _resolve_axis(axis) _, alt_axis_name = _resolve_axis(1 - axis) with ExitStack() as stack, as_group(out_file, mode="w") as output_group: groups = [stack.enter_context(as_group(f, mode="r")) for f in in_files] _concat_on_disk_inner( groups=groups, output_group=output_group, axis=axis, axis_name=axis_name, alt_axis_name=alt_axis_name, keys=keys, max_loaded_elems=max_loaded_elems, join=join, label=label, index_unique=index_unique, fill_value=fill_value, merge=merge, ) def _concat_on_disk_inner( # noqa: PLR0913 *, groups: list[H5Group | ZarrGroup], output_group: H5Group | ZarrGroup, axis: Literal[0, 1], axis_name: Literal["obs", "var"], alt_axis_name: Literal["obs", "var"], keys: np.ndarray[tuple[int], np.dtype[Any]] | Collection[str], max_loaded_elems: int, join: Join_T = "inner", label: str | None, index_unique: str | None, fill_value: Any | None, merge: Callable[[Collection[Mapping]], Mapping], ) -> None: """Internal helper to minimize the amount of indented code within the context manager""" use_reindexing = False alt_idxs = [_df_index(g[alt_axis_name]) for g in groups] # All {axis_name}_names must be equal if reindexing not applied if not _indices_equal(alt_idxs): use_reindexing = True # All groups must be anndata if not all(g.attrs.get("encoding-type") == "anndata" for g in groups): msg = "All groups must be anndata" raise ValueError(msg) # Write metadata output_group.attrs.update({"encoding-type": "anndata", "encoding-version": "0.1.0"}) # Read the backed objects of Xs Xs = [read_as_backed(g["X"]) for g in groups] # Label column label_col = pd.Categorical.from_codes( np.repeat(np.arange(len(groups)), [x.shape[axis] for x in Xs]), categories=keys, ) # Combining indexes concat_indices = pd.concat( [pd.Series(_df_index(g[axis_name])) for g in groups], ignore_index=True ) if index_unique is not None: concat_indices = concat_indices.str.cat( label_col.map(str, na_action="ignore"), sep=index_unique ) # Resulting indices for {axis_name} and {alt_axis_name} concat_indices = pd.Index(concat_indices) alt_index = merge_indices(alt_idxs, join=join) reindexers = None if use_reindexing: reindexers = [ gen_reindexer(alt_index, alt_old_index) for alt_old_index in alt_idxs ] else: reindexers = [IdentityReindexer()] * len(groups) # Write {axis_name} _write_axis_annot( groups, output_group, axis_name, concat_indices, label, label_col, join ) # Write {alt_axis_name} _write_alt_annot(groups, output_group, alt_axis_name, alt_index, merge) # Write {alt_axis_name}m _write_alt_mapping(groups, output_group, alt_axis_name, merge, reindexers) # Write {alt_axis_name}p _write_alt_pairwise(groups, output_group, alt_axis_name, merge, reindexers) # Write X _write_concat_arrays( arrays=Xs, output_group=output_group, output_path="X", axis=axis, reindexers=reindexers, fill_value=fill_value, max_loaded_elems=max_loaded_elems, ) # Write Layers and {axis_name}m mapping_names = [ ( f"{axis_name}m", concat_indices, 0, None if use_reindexing else [IdentityReindexer()] * len(groups), ), ("layers", None, axis, reindexers), ] for m, m_index, m_axis, m_reindexers in mapping_names: maps = [read_as_backed(g[m]) for g in groups] _write_concat_mappings( maps, output_group, intersect_keys(maps), m, max_loaded_elems=max_loaded_elems, axis=m_axis, index=m_index, reindexers=m_reindexers, fill_value=fill_value, ) scverse-anndata-b796d59/src/anndata/experimental/multi_files/000077500000000000000000000000001512025555600243575ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/experimental/multi_files/__init__.py000066400000000000000000000001531512025555600264670ustar00rootroot00000000000000from __future__ import annotations from ._anncollection import AnnCollection __all__ = ["AnnCollection"] scverse-anndata-b796d59/src/anndata/experimental/multi_files/_anncollection.py000066400000000000000000001047001512025555600277220ustar00rootroot00000000000000from __future__ import annotations import warnings from collections.abc import Callable, Mapping from functools import reduce from itertools import chain, pairwise from typing import TYPE_CHECKING import numpy as np import pandas as pd from h5py import Dataset from ..._core.aligned_mapping import AxisArrays from ..._core.anndata import AnnData from ..._core.index import _normalize_index, _normalize_indices from ..._core.merge import concat_arrays, inner_concat_aligned_mapping from ..._core.sparse_dataset import BaseCompressedSparseDataset from ..._core.views import _resolve_idx from ...compat import old_positionals if TYPE_CHECKING: from collections.abc import Iterable, Sequence from typing import Literal from ..._core.index import Index ATTRS = ["obs", "obsm", "layers"] def _merge(arrs): rxers = [lambda x, fill_value, axis: x] * len(arrs) return concat_arrays(arrs, rxers) def _select_convert(key, convert, arr=None): key_convert = None if callable(convert): key_convert = convert elif isinstance(convert, dict) and key in convert: key_convert = convert[key] if arr is not None: return key_convert(arr) if key_convert is not None else arr else: return key_convert def _harmonize_types(attrs_keys, adatas): attrs_keys_types = {} def check_type(attr, key=None): arrs = [] for a in adatas: attr_arr = getattr(a, attr) if key is not None: attr_arr = attr_arr[key] arrs.append(attr_arr) # hacky but numpy find_common_type doesn't work with categoricals try: dtype = _merge([arr[:1] for arr in arrs]).dtype except ValueError: dtype = _merge([arr[:1, :1] for arr in arrs]).dtype return dtype for attr, keys in attrs_keys.items(): if len(keys) == 0: continue attrs_keys_types[attr] = {} for key in keys: attrs_keys_types[attr][key] = check_type(attr, key) attrs_keys_types["X"] = check_type("X") return attrs_keys_types class _ConcatViewMixin: def _resolve_idx(self, oidx, vidx): adatas_oidx = [] reverse = None old_oidx = getattr(self, "oidx", None) if old_oidx is not None: oidx = _resolve_idx(old_oidx, oidx, self.limits[-1]) if isinstance(oidx, slice): start, stop, step = oidx.indices(self.limits[-1]) oidx = np.arange(start, stop, step) else: oidx = np.array([oidx]) if isinstance(oidx, int) else oidx u_oidx = oidx if len(self.adatas) == 1: return [u_oidx], oidx, vidx, reverse iter_limits = list(pairwise(chain([0], self.limits))) n_adatas_used = 0 for lower, upper in iter_limits: if np.any((u_oidx >= lower) & (u_oidx < upper)): n_adatas_used += 1 need_reverse = ( self.indices_strict and n_adatas_used > 1 and u_oidx.size > 1 and np.any(u_oidx[:-1] > u_oidx[1:]) ) if need_reverse: u_oidx, reverse = np.unique(u_oidx, return_inverse=True) for lower, upper in iter_limits: mask = (u_oidx >= lower) & (u_oidx < upper) adatas_oidx.append(u_oidx[mask] - lower if mask.any() else None) old_vidx = getattr(self, "vidx", None) if old_vidx is not None: vidx = _resolve_idx(old_vidx, vidx, self.adatas[0].n_vars) if isinstance(vidx, int): vidx = np.array([vidx]) return adatas_oidx, oidx, vidx, reverse class _IterateViewMixin: @old_positionals("axis", "shuffle", "drop_last") def iterate_axis( self, batch_size: int, *, axis: Literal[0, 1] = 0, shuffle: bool = False, drop_last: bool = False, ): """Iterate the lazy object over an axis. Parameters ---------- batch_size How many samples to put into a batch when iterating. axis The axis to iterate over. shuffle Set to `True` to have the indices reshuffled before iterating. drop_last Set to `True` to drop a batch with the length lower than `batch_size`. """ if axis not in {0, 1}: msg = "Axis should be either 0 or 1." raise ValueError(msg) n = self.shape[axis] indices = np.random.permutation(n).tolist() if shuffle else list(range(n)) for i in range(0, n, batch_size): idx = indices[i : min(i + batch_size, n)] batch = self[:, idx] if axis == 1 else self[idx] # only happens if the last batch is smaller than batch_size if len(batch) < batch_size and drop_last: continue yield batch, idx class MapObsView: def __init__( # noqa: PLR0913 self, attr, adatas, keys, *, adatas_oidx, adatas_vidx=None, convert=None, reverse=None, dtypes=None, obs_names=None, ): self.adatas = adatas self._keys = keys self.adatas_oidx = adatas_oidx self.adatas_vidx = adatas_vidx self.attr = attr self.convert = convert self.reverse = reverse self.dtypes = dtypes self.obs_names = obs_names def __getitem__(self, key: str, *, use_convert: bool = True): if self._keys is not None and key not in self._keys: msg = f"No {key} in {self.attr} view" raise KeyError(msg) arrs = [] for i, oidx in enumerate(self.adatas_oidx): if oidx is None: continue arr = getattr(self.adatas[i], self.attr)[key] vidx = self.adatas_vidx[i] if self.adatas_vidx is not None else None idx = (oidx, vidx) if vidx is not None else oidx if isinstance(arr, pd.DataFrame): arrs.append(arr.iloc[idx]) else: if vidx is not None: idx = np.ix_(*idx) if not isinstance(idx[1], slice) else idx arrs.append(arr.iloc[idx] if isinstance(arr, pd.Series) else arr[idx]) if len(arrs) > 1: _arr = _merge(arrs) _arr = _arr if self.reverse is None else _arr[self.reverse] else: _arr = arrs[0] # what if it is a dataframe? if self.dtypes is not None: _arr = _arr.astype(self.dtypes[key], copy=False) if self.convert is not None and use_convert: _arr = _select_convert(key, self.convert, _arr) return _arr def keys(self): if self._keys is not None: return self._keys else: return list(getattr(self.adatas[0], self.attr).keys()) @old_positionals("use_convert") def to_dict(self, keys: Iterable[str] | None = None, *, use_convert=True): dct = {} keys = self.keys() if keys is None else keys for key in keys: dct[key] = self.__getitem__(key, use_convert=use_convert) return dct @property def df(self): if self.attr != "obs": return None return pd.DataFrame(self.to_dict(use_convert=False), index=self.obs_names) def __repr__(self): descr = f"View of {self.attr} with keys: {str(self.keys())[1:-1]}" return descr class AnnCollectionView(_ConcatViewMixin, _IterateViewMixin): """\ An object to access the observation attributes of `adatas` in AnnCollection. Created as a result of subsetting an :class:`~anndata.experimental.AnnCollection` object. An object of this class can have `.obs`, `.obsm`, `.layers`, `.X` depending on the results of joins in the reference AnnCollection object. Notes ----- Nothing is copied until keys of the attributes or `.X` are accessed. """ def __init__(self, reference, convert, resolved_idx): self.reference = reference self.indices_strict = self.reference.indices_strict self.adatas = self.reference.adatas self.limits = self.reference.limits self.adatas_oidx, self.oidx, self.vidx, self.reverse = resolved_idx self.adatas_vidx = [] for i, vidx in enumerate(self.reference.adatas_vidx): if vidx is None: self.adatas_vidx.append(self.vidx) else: new_vidx = _resolve_idx(vidx, self.vidx, self.adatas[i].n_vars) self.adatas_vidx.append(new_vidx) self._view_attrs_keys = self.reference._view_attrs_keys self._attrs = self.reference._attrs self._dtypes = self.reference._dtypes self._layers_view, self._obsm_view, self._obs_view = None, None, None self._X = None self._convert = None self._convert_X = None self.convert = convert def _lazy_init_attr(self, attr: str, *, set_vidx: bool = False): if getattr(self, f"_{attr}_view") is not None: return keys = None attr_dtypes = None if attr in self._view_attrs_keys: reverse = self.reverse keys = self._view_attrs_keys[attr] if len(keys) == 0: return adatas = self.adatas adatas_oidx = self.adatas_oidx if self._dtypes is not None: attr_dtypes = self._dtypes[attr] else: reverse = None adatas = [self.reference] adatas_oidx = [self.oidx] adatas_vidx = self.adatas_vidx if set_vidx else None attr_convert = None if self.convert is not None: attr_convert = _select_convert(attr, self.convert) obs_names = self.obs_names if attr == "obs" else None setattr( self, f"_{attr}_view", MapObsView( attr, adatas, keys, adatas_oidx=adatas_oidx, adatas_vidx=adatas_vidx, convert=attr_convert, reverse=reverse, dtypes=attr_dtypes, obs_names=obs_names, ), ) def _gather_X(self): if self._X is not None: return self._X Xs = [] for i, oidx in enumerate(self.adatas_oidx): if oidx is None: continue adata = self.adatas[i] X = adata.X vidx = self.adatas_vidx[i] if isinstance(X, Dataset): reverse = None if oidx.size > 1 and np.any(oidx[:-1] >= oidx[1:]): oidx, reverse = np.unique(oidx, return_inverse=True) # TODO: fix memory inefficient approach of X[oidx][:, vidx] arr = X[oidx, vidx] if isinstance(vidx, slice) else X[oidx][:, vidx] Xs.append(arr if reverse is None else arr[reverse]) elif isinstance(X, BaseCompressedSparseDataset): # very slow indexing with two arrays if isinstance(vidx, slice) or len(vidx) <= 1000: Xs.append(X[oidx, vidx]) else: Xs.append(X[oidx][:, vidx]) else: # if vidx is present it is less memory efficient idx = oidx, vidx idx = np.ix_(*idx) if not isinstance(vidx, slice) else idx Xs.append(X[idx]) if len(Xs) > 1: _X = _merge(Xs) # todo: get rid of reverse for dense arrays _X = _X if self.reverse is None else _X[self.reverse] else: _X = Xs[0] if self._dtypes is not None: _X = _X.astype(self._dtypes["X"], copy=False) self._X = _X return _X @property def X(self): """Lazy subset of data matrix. The data matrix formed from the `.X` attributes of the underlying `adatas`, properly reindexed and lazily merged. Nothing is copied until `.X` is accessed, no real concatenation of the underlying `.X` attributes is done. """ # inconsistent behavior here, _X can be changed, # but the other attributes can't be changed. # maybe do return ... _X.copy() or _X.setflags(write=False) _X = self._gather_X() return self._convert_X(_X) if self._convert_X is not None else _X @property def layers(self): """Lazy subset of layers. The layers attribute formed from lazy inner join and subsetting of the `.layers` of the underlying `adatas`. No copy is made until you access a key from `.layers`, only the subset of the accessed key is copied. To get `.layers` as a dictionary, use `.layers.to_dict()`. You can also specify keys to include in the dict `.layers.to_dict(keys=['key1', 'key2'])` and if you want converters to be turned off when copying to dict `.layers.to_dict(use_convert=False)`. """ self._lazy_init_attr("layers", set_vidx=True) return self._layers_view @property def obsm(self): """Lazy subset of multi-dimensional annotation of observations. Points to the `.obsm` attributes of the underlying adatas to `.obsm` of the parent AnnCollection object depending on the `join_obsm` option of the AnnCollection object. See the docs of :class:`~anndata.experimental.AnnCollection` for details. Copy rules are the same as for `.layers`, i.e. everything is lazy. To get `.obsm` as a dictionary, use `.obsm.to_dict()`. You can also specify keys to include in the dict `.obsm.to_dict(keys=['key1', 'key2'])` and if you want converters to be turned off when copying to dict `.obsm.to_dict(use_convert=False)`. """ self._lazy_init_attr("obsm") return self._obsm_view @property def obs(self): """Lazy suset of one-dimensional annotation of observations. Points to the `.obs` attributes of the underlying adatas to `.obs` of the parent AnnCollection object depending on the `join_obs` option of the AnnCollection object. See the docs of `~anndata.experimental.AnnCollection` for details. Copy rules are the same as for `.layers`, i.e. everything is lazy. To get `.obs` as a DataFrame, use `.obs.df`. To get `.obs` as a dictionary, use `.obs.to_dict()`. You can also specify keys to include in the dict `.obs.to_dict(keys=['key1', 'key2'])` and if you want converters to be turned off when copying to dict `.obs.to_dict(use_convert=False)`. """ self._lazy_init_attr("obs") return self._obs_view @property def obs_names(self): """Names of observations of this subset object.""" return self.reference.obs_names[self.oidx] @property def var_names(self): """Names of variables of this subset object.""" return self.reference.var_names[self.vidx] @property def shape(self): """Shape of the lazily concatenated subset of the data matrix.""" return len(self.obs_names), len(self.var_names) @property def n_obs(self): """Number of observations.""" return self.shape[0] @property def n_vars(self): """Number of variables/features.""" return self.shape[1] @property def convert(self): """On the fly converters for keys of attributes and data matrix. A function or a Mapping of functions which will be applied to the values of attributes (`.X`) or to specific keys of these attributes (`.obs`, `.obsm`, `.layers`). The keys of the Mapping should correspond to the attributes or keys of the attributes (hierarchically) and the values should be functions used for conversion. Examples ---------- :: { # densify .X "X": lambda a: a.toarray() if issparse(a) else a, # change dtype for all keys of .obsm "obsm": lambda a: np.asarray(a, dtype="float32"), # change type only for one key of .obs "obs": dict(key1=lambda c: c.astype("string")), } """ return self._convert @convert.setter def convert(self, value): self._convert = value self._convert_X = _select_convert("X", self._convert) for attr in ATTRS: setattr(self, f"_{attr}_view", None) def __len__(self): return len(self.obs_names) def __getitem__(self, index: Index): oidx, vidx = _normalize_indices(index, self.obs_names, self.var_names) resolved_idx = self._resolve_idx(oidx, vidx) return AnnCollectionView(self.reference, self.convert, resolved_idx) @property def has_backed(self): """`True` if the current subset of `adatas` has backed objects, `False` otherwise.""" for i, adata in enumerate(self.adatas): if adata.isbacked and self.adatas_oidx[i] is not None: return True return False def __repr__(self): n_obs, n_vars = self.shape descr = f"AnnCollectionView object with n_obs × n_vars = {n_obs} × {n_vars}" all_attrs_keys = self._view_attrs_keys.copy() for attr in self._attrs: all_attrs_keys[attr] = list(getattr(self.reference, attr).keys()) for attr, keys in all_attrs_keys.items(): if len(keys) > 0: descr += f"\n {attr}: {str(keys)[1:-1]}" return descr @old_positionals("ignore_X", "ignore_layers") def to_adata(self, *, ignore_X: bool = False, ignore_layers: bool = False): """Convert this AnnCollectionView object to an AnnData object. Parameters ---------- ignore_X if `True`, adds `.X` to the AnnData object. ignore_layers if `True`, copies `.layers` to the AnnData object. """ if ignore_layers or self.layers is None: layers = None else: layers = self.layers.to_dict(use_convert=False) obsm = None if self.obsm is None else self.obsm.to_dict(use_convert=False) obs = ( None if self.obs is None else pd.DataFrame(self.obs.to_dict(use_convert=False)) ) if ignore_X: X = None shape = self.shape else: X = self._gather_X() shape = None adata = AnnData(X, obs=obs, obsm=obsm, layers=layers, shape=shape) adata.obs_names = self.obs_names adata.var_names = self.var_names return adata @property def attrs_keys(self): """Dict of all accessible attributes and their keys.""" return self.reference.attrs_keys DictCallable = dict[str, Callable] ConvertType = Callable | dict[str, Callable | DictCallable] class AnnCollection(_ConcatViewMixin, _IterateViewMixin): """\ Lazily concatenate AnnData objects along the `obs` axis. This class doesn't copy data from underlying AnnData objects, but lazily subsets using a joint index of observations and variables. It also allows on-the-fly application of prespecified converters to `.obs` attributes of the AnnData objects. Subsetting of this object returns an `AnnCollectionView`, which provides views of `.obs`, `.obsm`, `.layers`, `.X` from the underlying AnnData objects. Parameters ---------- adatas The objects to be lazily concatenated. If a Mapping is passed, keys are used for the `keys` argument and values are concatenated. join_obs If "inner" specified all `.obs` attributes from `adatas` will be inner joined and copied to this object. If "outer" specified all `.obsm` attributes from `adatas` will be outer joined and copied to this object. For "inner" and "outer" subset objects will access `.obs` of this object, not the original `.obs` attributes of `adatas`. If `None`, nothing is copied to this object's `.obs`, a subset object will directly access `.obs` attributes of `adatas` (with proper reindexing and dtype conversions). For `None`the inner join rule is used to select columns of `.obs` of `adatas`. join_obsm If "inner" specified all `.obsm` attributes from `adatas` will be inner joined and copied to this object. Subset objects will access `.obsm` of this object, not the original `.obsm` attributes of `adatas`. If `None`, nothing is copied to this object's `.obsm`, a subset object will directly access `.obsm` attributes of `adatas` (with proper reindexing and dtype conversions). For both options the inner join rule for the underlying `.obsm` attributes is used. join_vars Specify how to join `adatas` along the var axis. If `None`, assumes all `adatas` have the same variables. If "inner", the intersection of all variables in `adatas` will be used. label Column in `.obs` to place batch information in. If it's None, no column is added. keys Names for each object being added. These values are used for column values for `label` or appended to the index if `index_unique` is not `None`. Defaults to incrementing integer labels. index_unique Whether to make the index unique by using the keys. If provided, this is the delimiter between "{orig_idx}{index_unique}{key}". When `None`, the original indices are kept. convert You can pass a function or a Mapping of functions which will be applied to the values of attributes (`.obs`, `.obsm`, `.layers`, `.X`) or to specific keys of these attributes in the subset object. Specify an attribute and a key (if needed) as keys of the passed Mapping and a function to be applied as a value. harmonize_dtypes If `True`, all retrieved arrays from subset objects will have the same dtype. indices_strict If `True`, arrays from the subset objects will always have the same order of indices as in selection used to subset. This parameter can be set to `False` if the order in the returned arrays is not important, for example, when using them for stochastic gradient descent. In this case the performance of subsetting can be a bit better. Examples ---------- >>> from scanpy.datasets import pbmc68k_reduced, pbmc3k_processed >>> adata1, adata2 = pbmc68k_reduced(), pbmc3k_processed() >>> adata1.shape (700, 765) >>> adata2.shape (2638, 1838) >>> dc = AnnCollection([adata1, adata2], join_vars='inner') >>> dc AnnCollection object with n_obs × n_vars = 3338 × 208 constructed from 2 AnnData objects view of obsm: 'X_pca', 'X_umap' obs: 'n_genes', 'percent_mito', 'n_counts', 'louvain' >>> batch = dc[100:200] # AnnCollectionView >>> batch AnnCollectionView object with n_obs × n_vars = 100 × 208 obsm: 'X_pca', 'X_umap' obs: 'n_genes', 'percent_mito', 'n_counts', 'louvain' >>> batch.X.shape (100, 208) >>> len(batch.obs['louvain']) 100 """ @old_positionals( "join_obs", "join_obsm", "join_vars", "label", "keys", "index_unique", "convert", "harmonize_dtypes", "indices_strict", ) def __init__( # noqa: PLR0912, PLR0913, PLR0915 self, adatas: Sequence[AnnData] | dict[str, AnnData], *, join_obs: Literal["inner", "outer"] | None = "inner", join_obsm: Literal["inner"] | None = None, join_vars: Literal["inner"] | None = None, label: str | None = None, keys: Sequence[str] | None = None, index_unique: str | None = None, convert: ConvertType | None = None, harmonize_dtypes: bool = True, indices_strict: bool = True, ): if isinstance(adatas, Mapping): if keys is not None: msg = ( "Cannot specify categories in both mapping keys and using `keys`. " "Only specify this once." ) raise TypeError(msg) keys, adatas = list(adatas.keys()), list(adatas.values()) else: adatas = list(adatas) # check if the variables are the same in all adatas self.adatas_vidx = [None for adata in adatas] vars_names_list = [adata.var_names for adata in adatas] vars_eq = all(adatas[0].var_names.equals(vrs) for vrs in vars_names_list[1:]) if vars_eq: self.var_names = adatas[0].var_names elif join_vars == "inner": var_names = reduce(pd.Index.intersection, vars_names_list) self.adatas_vidx = [] for adata in adatas: if var_names.equals(adata.var_names): self.adatas_vidx.append(None) else: adata_vidx = _normalize_index(var_names, adata.var_names) self.adatas_vidx.append(adata_vidx) self.var_names = var_names else: msg = ( "Adatas have different variables. " "Please specify join_vars='inner' for intersection." ) raise ValueError(msg) concat_indices = pd.concat( [pd.Series(a.obs_names) for a in adatas], ignore_index=True ) if keys is None: keys = np.arange(len(adatas)).astype(str) label_col = pd.Categorical.from_codes( np.repeat(np.arange(len(adatas)), [a.shape[0] for a in adatas]), categories=keys, ) if index_unique is not None: concat_indices = concat_indices.str.cat( label_col.map(str, na_action="ignore"), sep=index_unique ) self.obs_names = pd.Index(concat_indices) if not self.obs_names.is_unique: msg = "Observation names are not unique." warnings.warn(msg, UserWarning, stacklevel=2) view_attrs = ATTRS.copy() self._attrs = [] # process obs joins if join_obs is not None: view_attrs.remove("obs") self._attrs.append("obs") concat_annot = pd.concat( [a.obs for a in adatas], join=join_obs, ignore_index=True ) concat_annot.index = self.obs_names self._obs = concat_annot else: self._obs = pd.DataFrame(index=self.obs_names) if label is not None: self._obs[label] = label_col # process obsm inner join self._obsm = None if join_obsm == "inner": view_attrs.remove("obsm") self._attrs.append("obsm") self._obsm = inner_concat_aligned_mapping( [a.obsm for a in adatas], index=self.obs_names ) self._obsm = ( AxisArrays(self, axis=0, store={}) if self._obsm == {} else self._obsm ) # process inner join of views self._view_attrs_keys = {} for attr in view_attrs: self._view_attrs_keys[attr] = list(getattr(adatas[0], attr).keys()) for a in adatas[1:]: for attr, keys in self._view_attrs_keys.items(): ai_attr = getattr(a, attr) a0_attr = getattr(adatas[0], attr) new_keys = [] for key in keys: if key in ai_attr: a0_ashape = a0_attr[key].shape ai_ashape = ai_attr[key].shape if ( len(a0_ashape) < 2 or a0_ashape[1] == ai_ashape[1] or attr == "layers" ): new_keys.append(key) self._view_attrs_keys[attr] = new_keys self.adatas = adatas self.limits = [adatas[0].n_obs] for i in range(len(adatas) - 1): self.limits.append(self.limits[i] + adatas[i + 1].n_obs) # init converter self._convert = convert self._dtypes = None if len(adatas) > 1 and harmonize_dtypes: self._dtypes = _harmonize_types(self._view_attrs_keys, self.adatas) self.indices_strict = indices_strict def __getitem__(self, index: Index): oidx, vidx = _normalize_indices(index, self.obs_names, self.var_names) resolved_idx = self._resolve_idx(oidx, vidx) return AnnCollectionView(self, self.convert, resolved_idx) @property def convert(self): """On the fly converters for keys of attributes and data matrix. A function or a Mapping of functions which will be applied to the values of attributes (`.X`) or to specific keys of these attributes (`.obs`, `.obsm`, `.layers`) of subset objects. The converters are not applied to `.obs` and `.obsm` (if present) of this object, only to the attributes of subset objects. The keys of the Mapping should correspond to the attributes or keys of the attributes (hierarchically) and the values should be functions used for conversion. Examples -------- :: { # densify .X "X": lambda a: a.toarray() if issparse(a) else a, # change dtype for all keys of .obsm "obsm": lambda a: np.asarray(a, dtype="float32"), # change type only for one key of .obs "obs": dict(key1=lambda c: c.astype("string")), } """ return self._convert @convert.setter def convert(self, value): self._convert = value @property def obs(self): """One-dimensional annotation of observations. If `join_obs` was set to "inner" and "outer", subset objects' `.obs` will point to this `.obs`; otherwise, to `.obs` of the underlying objects (`adatas`). """ return self._obs @property def obsm(self): """Multi-dimensional annotation of observations. If `join_obsm` was set to "inner", subset objects' `.obsm` will point to this `.obsm`; otherwise, to `.obsm` of the underlying objects (`adatas`). In the latter case, `.obsm` of this object will be `None`. """ return self._obsm @property def shape(self): """Shape of the lazily concatenated data matrix""" return self.limits[-1], len(self.var_names) @property def n_obs(self): """Number of observations.""" return self.shape[0] @property def n_vars(self): """Number of variables/features.""" return self.shape[1] def __len__(self): return self.limits[-1] def to_adata(self): """Convert this AnnCollection object to an AnnData object. The AnnData object won't have `.X`, only `.obs` and `.obsm`. """ if "obs" in self._view_attrs_keys or "obsm" in self._view_attrs_keys: concat_view = self[self.obs_names] if "obsm" in self._view_attrs_keys: obsm = ( concat_view.obsm.to_dict(use_convert=False) if concat_view.obsm is not None else None ) else: obsm = self.obsm.copy() obs = self.obs.copy() if "obs" in self._view_attrs_keys and concat_view.obs is not None: for key, value in concat_view.obs.to_dict(use_convert=False).items(): obs[key] = value adata = AnnData(X=None, obs=obs, obsm=obsm, shape=self.shape) adata.obs_names = self.obs_names adata.var_names = self.var_names return adata def lazy_attr(self, attr, key=None): """Get a subsettable key from an attribute (array-like) or an attribute. Returns a LazyAttrData object which provides subsetting over the specified attribute (`.obs` or `.obsm`) or over a key from this attribute. In the latter case, it acts as a lazy array. """ return LazyAttrData(self, attr, key) @property def has_backed(self): """`True` if `adatas` have backed AnnData objects, `False` otherwise.""" return any(adata.isbacked for adata in self.adatas) @property def attrs_keys(self): """Dict of all accessible attributes and their keys.""" _attrs_keys = {} for attr in self._attrs: keys = list(getattr(self, attr).keys()) _attrs_keys[attr] = keys _attrs_keys.update(self._view_attrs_keys) return _attrs_keys def __repr__(self): n_obs, n_vars = self.shape descr = f"AnnCollection object with n_obs × n_vars = {n_obs} × {n_vars}" descr += f"\n constructed from {len(self.adatas)} AnnData objects" for attr, keys in self._view_attrs_keys.items(): if len(keys) > 0: descr += f"\n view of {attr}: {str(keys)[1:-1]}" for attr in self._attrs: keys = list(getattr(self, attr).keys()) if len(keys) > 0: descr += f"\n {attr}: {str(keys)[1:-1]}" if "obs" in self._view_attrs_keys: keys = list(self.obs.keys()) if len(keys) > 0: descr += f"\n own obs: {str(keys)[1:-1]}" return descr class LazyAttrData(_IterateViewMixin): def __init__(self, adset: AnnCollection, attr: str, key: str | None = None): self.adset = adset self.attr = attr self.key = key def __getitem__(self, index): oidx = None vidx = None if isinstance(index, tuple) and self.attr in {"obs", "obsm"}: oidx = index[0] if len(index) > 1: vidx = index[1] view = self.adset[index] if oidx is None else self.adset[oidx] attr_arr = getattr(view, self.attr) if self.key is not None: attr_arr = attr_arr[self.key] return attr_arr if vidx is None else attr_arr[:, vidx] @property def shape(self): shape = self.adset.shape if self.attr in {"X", "layers"}: return shape elif self.attr == "obs": return (shape[0],) elif self.attr == "obsm" and self.key is not None: return shape[0], self[:1].shape[1] else: return None @property def ndim(self): return len(self.shape) if self.shape is not None else 0 @property def dtype(self): _dtypes = self.adset._dtypes if _dtypes is not None and self.attr in _dtypes: return _dtypes[self.attr][self.key] attr = self[:1] if hasattr(attr, "dtype"): return attr.dtype else: return None scverse-anndata-b796d59/src/anndata/experimental/pytorch/000077500000000000000000000000001512025555600235335ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/experimental/pytorch/__init__.py000066400000000000000000000001371512025555600256450ustar00rootroot00000000000000from __future__ import annotations from ._annloader import AnnLoader __all__ = ["AnnLoader"] scverse-anndata-b796d59/src/anndata/experimental/pytorch/_annloader.py000066400000000000000000000176131512025555600262170ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Mapping from copy import copy from functools import partial from importlib.util import find_spec from math import ceil from typing import TYPE_CHECKING import numpy as np from scipy.sparse import issparse from ..._core.anndata import AnnData from ...compat import old_positionals from ..multi_files._anncollection import AnnCollection, _ConcatViewMixin if find_spec("torch") or TYPE_CHECKING: import torch from torch.utils.data import BatchSampler, DataLoader, Sampler else: Sampler, BatchSampler, DataLoader = object, object, object if TYPE_CHECKING: from collections.abc import Callable, Generator, Sequence from typing import TypeAlias, Union from scipy.sparse import spmatrix # need to use Union because of autodoc_mock_imports Array: TypeAlias = Union[torch.Tensor, np.ndarray, spmatrix] # noqa: UP007 # Custom sampler to get proper batches instead of joined separate indices # maybe move to multi_files class BatchIndexSampler(Sampler): @old_positionals("batch_size", "shuffle", "drop_last") def __init__( self, n_obs: int, *, batch_size: int, shuffle: bool = False, drop_last: bool = False, ) -> None: self.n_obs = n_obs self.batch_size = batch_size if batch_size < n_obs else n_obs self.shuffle = shuffle self.drop_last = drop_last def __iter__(self) -> Generator[list[int], None, None]: indices: list[int] if self.shuffle: indices = np.random.permutation(self.n_obs).tolist() else: indices = list(range(self.n_obs)) for i in range(0, self.n_obs, self.batch_size): batch = indices[i : min(i + self.batch_size, self.n_obs)] # only happens if the last batch is smaller than batch_size if len(batch) < self.batch_size and self.drop_last: continue yield batch def __len__(self) -> int: if self.drop_last: length = self.n_obs // self.batch_size else: length = ceil(self.n_obs / self.batch_size) return length # maybe replace use_cuda with explicit device option def default_converter(arr: Array, *, use_cuda: bool, pin_memory: bool): if isinstance(arr, torch.Tensor): if use_cuda: arr = arr.cuda() elif pin_memory: arr = arr.pin_memory() elif arr.dtype.name != "category" and np.issubdtype(arr.dtype, np.number): if issparse(arr): arr = arr.toarray() if use_cuda: arr = torch.tensor(arr, device="cuda") else: arr = torch.tensor(arr) arr = arr.pin_memory() if pin_memory else arr return arr def _convert_on_top( convert: Callable[[Array], Array] | None | Mapping[str, Callable[[Array], Array]], top_convert: Callable[[Array], Array], attrs_keys: Sequence[str] | Mapping[str, Sequence[str]], ): if convert is None: new_convert = top_convert elif callable(convert): def compose_convert(arr): return top_convert(convert(arr)) new_convert = compose_convert else: new_convert = {} for attr in attrs_keys: if attr not in convert: new_convert[attr] = top_convert else: as_ks: Sequence[str] | None if not isinstance(attrs_keys, Mapping): as_ks = None else: as_ks = attrs_keys[attr] new_convert[attr] = _convert_on_top(convert[attr], top_convert, as_ks) return new_convert # AnnLoader has the same arguments as DataLoader, but uses BatchIndexSampler by default class AnnLoader(DataLoader): """\ PyTorch DataLoader for AnnData objects. Builds DataLoader from a sequence of AnnData objects, from an :class:`~anndata.experimental.AnnCollection` object or from an `AnnCollectionView` object. Takes care of the required conversions. Parameters ---------- adatas `AnnData` objects or an `AnnCollection` object from which to load the data. batch_size How many samples per batch to load. shuffle Set to `True` to have the data reshuffled at every epoch. use_default_converter Use the default converter to convert arrays to pytorch tensors, transfer to the default cuda device (if `use_cuda=True`), do memory pinning (if `pin_memory=True`). If you pass an AnnCollection object with prespecified converters, the default converter won't overwrite these converters but will be applied on top of them. use_cuda Transfer pytorch tensors to the default cuda device after conversion. Only works if `use_default_converter=True` **kwargs Arguments for PyTorch DataLoader. If `adatas` is not an `AnnCollection` object, then also arguments for `AnnCollection` initialization. """ @old_positionals("batch_size", "shuffle", "use_default_converter", "use_cuda") def __init__( self, adatas: Sequence[AnnData] | dict[str, AnnData], *, batch_size: int = 1, shuffle: bool = False, use_default_converter: bool = True, use_cuda: bool = False, **kwargs, ): if isinstance(adatas, AnnData): adatas = [adatas] if isinstance(adatas, list | tuple | dict): join_obs = kwargs.pop("join_obs", "inner") join_obsm = kwargs.pop("join_obsm", None) label = kwargs.pop("label", None) keys = kwargs.pop("keys", None) index_unique = kwargs.pop("index_unique", None) convert = kwargs.pop("convert", None) harmonize_dtypes = kwargs.pop("harmonize_dtypes", True) indices_strict = kwargs.pop("indices_strict", True) dataset = AnnCollection( adatas, join_obs=join_obs, join_obsm=join_obsm, label=label, keys=keys, index_unique=index_unique, convert=convert, harmonize_dtypes=harmonize_dtypes, indices_strict=indices_strict, ) elif isinstance(adatas, _ConcatViewMixin): dataset = copy(adatas) else: msg = "adata should be of type AnnData or AnnCollection." raise ValueError(msg) if use_default_converter: pin_memory = kwargs.pop("pin_memory", False) _converter = partial( default_converter, use_cuda=use_cuda, pin_memory=pin_memory ) dataset.convert = _convert_on_top( dataset.convert, _converter, dict(dataset.attrs_keys, X=[]) ) has_sampler = "sampler" in kwargs has_batch_sampler = "batch_sampler" in kwargs has_worker_init_fn = ( "worker_init_fn" in kwargs and kwargs["worker_init_fn"] is not None ) has_workers = "num_workers" in kwargs and kwargs["num_workers"] > 0 use_parallel = has_worker_init_fn or has_workers if ( batch_size is not None and batch_size > 1 and not has_batch_sampler and not use_parallel ): drop_last = kwargs.pop("drop_last", False) if has_sampler: sampler = kwargs.pop("sampler") sampler = BatchSampler( sampler, batch_size=batch_size, drop_last=drop_last ) else: sampler = BatchIndexSampler( len(dataset), batch_size=batch_size, shuffle=shuffle, drop_last=drop_last, ) super().__init__(dataset, batch_size=None, sampler=sampler, **kwargs) else: super().__init__(dataset, batch_size=batch_size, shuffle=shuffle, **kwargs) scverse-anndata-b796d59/src/anndata/io.py000066400000000000000000000012721512025555600203310ustar00rootroot00000000000000from __future__ import annotations from ._core.sparse_dataset import sparse_dataset from ._io.h5ad import read_h5ad, write_h5ad from ._io.read import ( read_csv, read_excel, read_hdf, read_loom, read_mtx, read_text, read_umi_tools, ) from ._io.specs import read_elem, write_elem from ._io.write import write_csvs, write_loom from ._io.zarr import read_zarr, write_zarr __all__ = [ "read_csv", "read_elem", "read_excel", "read_h5ad", "read_hdf", "read_loom", "read_mtx", "read_text", "read_umi_tools", "read_zarr", "sparse_dataset", "write_csvs", "write_elem", "write_h5ad", "write_loom", "write_zarr", ] scverse-anndata-b796d59/src/anndata/logging.py000066400000000000000000000031761512025555600213550ustar00rootroot00000000000000from __future__ import annotations import logging import os from .compat import old_positionals _previous_memory_usage = None anndata_logger = logging.getLogger("anndata") # Don’t pass log messages on to logging.root and its handler anndata_logger.propagate = False anndata_logger.addHandler(logging.StreamHandler()) # Logs go to stderr anndata_logger.handlers[-1].setFormatter(logging.Formatter("%(message)s")) anndata_logger.handlers[-1].setLevel("INFO") def get_logger(name: str) -> logging.Logger: """\ Creates a child logger that delegates to anndata_logger instead to logging.root """ return anndata_logger.manager.getLogger(name) def get_memory_usage() -> tuple[float, float]: import psutil process = psutil.Process(os.getpid()) try: meminfo = process.memory_info() except AttributeError: meminfo = process.get_memory_info() mem = meminfo[0] / 2**30 # output in GB mem_diff = mem global _previous_memory_usage # noqa: PLW0603 if _previous_memory_usage is not None: mem_diff = mem - _previous_memory_usage _previous_memory_usage = mem return mem, mem_diff @old_positionals("newline") def format_memory_usage( mem_usage: tuple[float, float], msg: str = "", *, newline: bool = False ): nl = "\n" if newline else "" more = " \n... " if msg != "" else "" mem, diff = mem_usage return ( f"{nl}{msg}{more}Memory usage: current {mem:.2f} GB, difference {diff:+.2f} GB" ) @old_positionals("newline") def print_memory_usage(msg: str = "", *, newline: bool = False): print(format_memory_usage(get_memory_usage(), msg, newline)) scverse-anndata-b796d59/src/anndata/tests/000077500000000000000000000000001512025555600205105ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/tests/__init__.py000066400000000000000000000000001512025555600226070ustar00rootroot00000000000000scverse-anndata-b796d59/src/anndata/tests/helpers.py000066400000000000000000001114711512025555600225310ustar00rootroot00000000000000from __future__ import annotations import itertools import random import warnings from collections import Counter, defaultdict from collections.abc import Mapping from functools import partial, singledispatch, wraps from importlib.util import find_spec from string import ascii_letters from typing import TYPE_CHECKING import h5py import numpy as np import pandas as pd import pytest import zarr from pandas.api.types import is_numeric_dtype from scipy import sparse from anndata import AnnData, ExperimentalFeatureWarning, Raw from anndata._core.aligned_mapping import AlignedMappingBase from anndata._core.sparse_dataset import BaseCompressedSparseDataset from anndata._core.views import ArrayView from anndata._core.xarray import Dataset2D from anndata.compat import ( AwkArray, CSArray, CSMatrix, CupyArray, CupyCSCMatrix, CupyCSRMatrix, CupySparseMatrix, DaskArray, XDataArray, XDataset, ZarrArray, ZarrGroup, is_zarr_v2, ) from anndata.utils import asarray if TYPE_CHECKING: from collections.abc import Callable, Collection, Iterable from typing import Literal, TypeGuard, TypeVar from numpy.typing import NDArray from zarr.abc.store import ByteRequest from zarr.core.buffer import BufferPrototype from .._types import ArrayStorageType from ..compat import Index1D DT = TypeVar("DT") _SubsetFunc = Callable[[pd.Index[str], int], Index1D] try: from pandas.core.arrays.integer import IntegerDtype except ImportError: IntegerDtype = ( *(pd.Int8Dtype, pd.Int16Dtype, pd.Int32Dtype, pd.Int64Dtype), *(pd.UInt8Dtype, pd.UInt16Dtype, pd.UInt32Dtype, pd.UInt64Dtype), ) DEFAULT_KEY_TYPES = ( sparse.csr_matrix, np.ndarray, pd.DataFrame, sparse.csr_array, ) DEFAULT_COL_TYPES = ( pd.CategoricalDtype(ordered=False), pd.CategoricalDtype(ordered=True), np.int64, np.float64, np.uint8, np.bool_, pd.BooleanDtype, pd.Int32Dtype, ) # Give this to gen_adata when dask array support is expected. GEN_ADATA_DASK_ARGS = dict( obsm_types=(*DEFAULT_KEY_TYPES, DaskArray), varm_types=(*DEFAULT_KEY_TYPES, DaskArray), layers_types=(*DEFAULT_KEY_TYPES, DaskArray), ) GEN_ADATA_NO_XARRAY_ARGS = dict( obsm_types=(*DEFAULT_KEY_TYPES, AwkArray), varm_types=(*DEFAULT_KEY_TYPES, AwkArray) ) def gen_vstr_recarray(m, n, dtype=None): size = m * n lengths = np.random.randint(3, 5, size) letters = np.array(list(ascii_letters)) gen_word = lambda l: "".join(np.random.choice(letters, l)) arr = np.array([gen_word(l) for l in lengths]).reshape(m, n) return pd.DataFrame(arr, columns=[gen_word(5) for i in range(n)]).to_records( index=False, column_dtypes=dtype ) def issubdtype( a: np.dtype | pd.api.extensions.ExtensionDtype | type, b: type[DT] | tuple[type[DT], ...], ) -> TypeGuard[DT]: if isinstance(b, tuple): return any(issubdtype(a, t) for t in b) if isinstance(a, type) and issubclass(a, pd.api.extensions.ExtensionDtype): return issubclass(a, b) if isinstance(a, pd.api.extensions.ExtensionDtype): return isinstance(a, b) try: return np.issubdtype(a, b) except TypeError: # pragma: no cover pytest.fail(f"issubdtype can’t handle everything yet: {a} {b}") def gen_random_column( # noqa: PLR0911 n: int, dtype: np.dtype | pd.api.extensions.ExtensionDtype ) -> tuple[str, np.ndarray | pd.api.extensions.ExtensionArray]: if issubdtype(dtype, pd.CategoricalDtype): # TODO: Think about allowing index to be passed for n letters = np.fromiter(iter(ascii_letters), "U1") if n > len(letters): letters = letters[: n // 2] # Make sure categories are repeated key = "cat" if dtype.ordered else "cat_unordered" return key, pd.Categorical(np.random.choice(letters, n), dtype=dtype) if issubdtype(dtype, pd.BooleanDtype): return ( "nullable-bool", pd.arrays.BooleanArray( np.random.randint(0, 2, size=n, dtype=bool), mask=np.random.randint(0, 2, size=n, dtype=bool), ), ) if issubdtype(dtype, IntegerDtype): return ( "nullable-int", pd.arrays.IntegerArray( np.random.randint(0, 1000, size=n, dtype=np.int32), mask=np.random.randint(0, 2, size=n, dtype=bool), ), ) if issubdtype(dtype, pd.StringDtype): letters = np.fromiter(iter(ascii_letters), "U1") array = pd.array(np.random.choice(letters, n), dtype=pd.StringDtype()) array[np.random.randint(0, 2, size=n, dtype=bool)] = pd.NA return "string", array # if issubdtype(dtype, pd.DatetimeTZDtype): # return "datetime", pd.to_datetime(np.random.randint(0, 1000, size=n)) if issubdtype(dtype, np.bool_): return "bool", np.random.randint(0, 2, size=n, dtype=dtype) if not issubdtype(dtype, np.number): # pragma: no cover pytest.fail(f"Unexpected dtype: {dtype}") n_bits = 8 * (dtype().itemsize if isinstance(dtype, type) else dtype.itemsize) if issubdtype(dtype, np.unsignedinteger): return f"uint{n_bits}", np.random.randint(0, 255, n, dtype=dtype) if issubdtype(dtype, np.signedinteger): return f"int{n_bits}", np.random.randint(-50, 50, n, dtype=dtype) if issubdtype(dtype, np.floating): return f"float{n_bits}", np.random.random(n).astype(dtype) pytest.fail(f"Unexpected numeric dtype: {dtype}") # pragma: no cover def gen_typed_df( n: int, index: pd.Index[str] | None = None, dtypes: Collection[np.dtype | pd.api.extensions.ExtensionDtype] = DEFAULT_COL_TYPES, ): columns = [gen_random_column(n, dtype) for dtype in dtypes] col_names = [n for n, _ in columns] assert len(col_names) == len(set(col_names)), "Duplicate column names generated!" return pd.DataFrame(dict(columns), index=index) def _gen_awkward_inner(shape, rng, dtype): # the maximum length a ragged dimension can take MAX_RAGGED_DIM_LEN = 20 if not len(shape): # abort condition -> no dimension left, return an actual value instead return dtype(rng.randrange(1000)) else: curr_dim_len = shape[0] if curr_dim_len is None: # ragged dimension, set random length curr_dim_len = rng.randrange(MAX_RAGGED_DIM_LEN) return [_gen_awkward_inner(shape[1:], rng, dtype) for _ in range(curr_dim_len)] def gen_awkward(shape, dtype=np.int32): """Function to generate an awkward array with random values. Awkward array dimensions can either be fixed-length ("regular") or variable length ("ragged") (the first dimension is always fixed-length). Parameters ---------- shape shape of the array to be generated. Any dimension specified as `None` will be simulated as ragged. """ import awkward as ak if shape[0] is None: msg = "The first dimension must be fixed-length." raise ValueError(msg) rng = random.Random(123) shape = np.array(shape) if np.any(shape == 0): # use empty numpy array for fixed dimensions, then add empty singletons for ragged dimensions var_dims = [i for i, s in enumerate(shape) if s is None] shape = [s for s in shape if s is not None] arr = ak.Array(np.empty(shape, dtype=dtype)) for d in var_dims: arr = ak.singletons(arr, axis=d - 1) return arr else: lil = _gen_awkward_inner(shape, rng, dtype) arr = ak.values_astype(AwkArray(lil), dtype) # make fixed-length dimensions regular for i, d in enumerate(shape): if d is not None: arr = ak.to_regular(arr, i) return arr def gen_typed_df_t2_size(m, n, index=None, columns=None) -> pd.DataFrame: s = 0 df = pd.DataFrame() new_vals = gen_typed_df(m) while s < (n / new_vals.shape[1]): new_vals = gen_typed_df(m, index=index) new_vals.columns = new_vals.columns + "_" + str(s) df[new_vals.columns] = new_vals s += 1 df = df.iloc[:m, :n].copy() if columns is not None: df.columns = columns return df def maybe_add_sparse_array( mapping: Mapping, types: Collection[type], format: Literal["csr", "csc"], random_state: np.random.Generator, shape: tuple[int, int], ): if sparse.csr_array in types or sparse.csr_matrix in types: mapping["sparse_array"] = sparse.csr_array( sparse.random(*shape, format=format, random_state=random_state) ) return mapping # TODO: Use hypothesis for this? def gen_adata( # noqa: PLR0913 shape: tuple[int, int], X_type: Callable[[np.ndarray], object] = sparse.csr_matrix, *, X_dtype: np.dtype = np.float32, obs_dtypes: Collection[ np.dtype | pd.api.extensions.ExtensionDtype ] = DEFAULT_COL_TYPES, var_dtypes: Collection[ np.dtype | pd.api.extensions.ExtensionDtype ] = DEFAULT_COL_TYPES, obs_xdataset: bool = False, var_xdataset: bool = False, obsm_types: Collection[type] = (*DEFAULT_KEY_TYPES, AwkArray, XDataset), varm_types: Collection[type] = (*DEFAULT_KEY_TYPES, AwkArray, XDataset), layers_types: Collection[type] = DEFAULT_KEY_TYPES, random_state: np.random.Generator | None = None, sparse_fmt: Literal["csr", "csc"] = "csr", ) -> AnnData: """\ Helper function to generate a random AnnData for testing purposes. Note: For `obsm_types`, `varm_types`, and `layers_types` these currently just filter already created objects. In future, these should choose which objects are created. Params ------ shape What shape you want the anndata to be. X_type What kind of container should `X` be? This will be called on a randomly generated 2d array. X_dtype What should the dtype of the `.X` container be? obsm_types What kinds of containers should be in `.obsm`? varm_types What kinds of containers should be in `.varm`? layers_types What kinds of containers should be in `.layers`? sparse_fmt What sparse format should be used for sparse matrices? (csr, csc) """ import dask.array as da if random_state is None: random_state = np.random.default_rng() M, N = shape obs_names = pd.Index([f"cell{i}" for i in range(shape[0])], dtype="str") var_names = pd.Index([f"gene{i}" for i in range(shape[1])], dtype="str") obs = gen_typed_df(M, obs_names, dtypes=obs_dtypes) var = gen_typed_df(N, var_names, dtypes=var_dtypes) # For #147 obs.rename(columns=dict(cat="obs_cat"), inplace=True) var.rename(columns=dict(cat="var_cat"), inplace=True) if has_xr := find_spec("xarray"): if obs_xdataset: obs = XDataset.from_dataframe(obs) if var_xdataset: var = XDataset.from_dataframe(var) if X_type is None: X = None else: X = X_type(random_state.binomial(100, 0.005, (M, N)).astype(X_dtype)) obsm = dict( array=np.random.random((M, 50)), sparse=sparse.random(M, 100, format=sparse_fmt, random_state=random_state), df=gen_typed_df(M, obs_names, dtypes=obs_dtypes), awk_2d_ragged=gen_awkward((M, None)), da=da.random.random((M, 50)), ) varm = dict( array=np.random.random((N, 50)), sparse=sparse.random(N, 100, format=sparse_fmt, random_state=random_state), df=gen_typed_df(N, var_names, dtypes=var_dtypes), awk_2d_ragged=gen_awkward((N, None)), da=da.random.random((N, 50)), ) if has_xr: obsm["xdataset"] = XDataset.from_dataframe( gen_typed_df(M, obs_names, dtypes=obs_dtypes) ) varm["xdataset"] = XDataset.from_dataframe( gen_typed_df(N, var_names, dtypes=var_dtypes) ) obsm = {k: v for k, v in obsm.items() if type(v) in obsm_types} obsm = maybe_add_sparse_array( mapping=obsm, types=obsm_types, format=sparse_fmt, random_state=random_state, shape=(M, 100), ) varm = {k: v for k, v in varm.items() if type(v) in varm_types} varm = maybe_add_sparse_array( mapping=varm, types=varm_types, format=sparse_fmt, random_state=random_state, shape=(N, 100), ) layers = dict( array=np.random.random((M, N)), sparse=sparse.random(M, N, format=sparse_fmt, random_state=random_state), da=da.random.random((M, N)), ) layers = maybe_add_sparse_array( mapping=layers, types=layers_types, format=sparse_fmt, random_state=random_state, shape=(M, N), ) layers = {k: v for k, v in layers.items() if type(v) in layers_types} obsp = dict( array=np.random.random((M, M)), sparse=sparse.random(M, M, format=sparse_fmt, random_state=random_state), ) obsp["sparse_array"] = sparse.csr_array( sparse.random(M, M, format=sparse_fmt, random_state=random_state) ) varp = dict( array=np.random.random((N, N)), sparse=sparse.random(N, N, format=sparse_fmt, random_state=random_state), ) varp["sparse_array"] = sparse.csr_array( sparse.random(N, N, format=sparse_fmt, random_state=random_state) ) uns = dict( O_recarray=gen_vstr_recarray(N, 5), nested=dict( scalar_str="str", scalar_int=42, scalar_float=3.0, nested_further=dict(array=np.arange(5)), ), awkward_regular=gen_awkward((10, 5)), awkward_ragged=gen_awkward((12, None, None)), # U_recarray=gen_vstr_recarray(N, 5, "U4") ) with warnings.catch_warnings(): warnings.simplefilter("ignore", ExperimentalFeatureWarning) adata = AnnData( X=X, obs=obs, var=var, obsm=obsm, varm=varm, layers=layers, obsp=obsp, varp=varp, uns=uns, ) return adata def array_bool_subset(index: pd.Index[str], min_size: int = 2) -> NDArray[np.bool_]: b = np.zeros(len(index), dtype=bool) selected = np.random.choice( range(len(index)), size=np.random.randint(min_size, len(index), ()), replace=False, ) b[selected] = True return b def list_bool_subset(index: pd.Index[str], min_size: int = 2) -> list[bool]: return array_bool_subset(index, min_size=min_size).tolist() def matrix_bool_subset(index: pd.Index[str], min_size: int = 2) -> np.matrix: with warnings.catch_warnings(): warnings.simplefilter("ignore", PendingDeprecationWarning) indexer = np.matrix( array_bool_subset(index, min_size=min_size).reshape(len(index), 1) ) return indexer def spmatrix_bool_subset(index: pd.Index[str], min_size: int = 2) -> sparse.csr_matrix: return sparse.csr_matrix( array_bool_subset(index, min_size=min_size).reshape(len(index), 1) ) def sparray_bool_subset(index: pd.Index[str], min_size: int = 2) -> sparse.csr_array: return sparse.csr_array( array_bool_subset(index, min_size=min_size).reshape(len(index), 1) ) def single_subset(index: pd.Index[str], min_size: int = 1) -> str: if min_size > 1: msg = "max_size must be ≤1" raise AssertionError(msg) return index[np.random.randint(0, len(index))] def array_subset(index: pd.Index[str], min_size: int = 2) -> NDArray[np.str_]: if len(index) < min_size: msg = f"min_size (={min_size}) must be smaller than len(index) (={len(index)}" raise ValueError(msg) return np.random.choice( index, size=np.random.randint(min_size, len(index), ()), replace=False ) def array_int_subset(index: pd.Index[str], min_size: int = 2) -> NDArray[np.int64]: if len(index) < min_size: msg = f"min_size (={min_size}) must be smaller than len(index) (={len(index)}" raise ValueError(msg) return np.random.choice( np.arange(len(index)), size=np.random.randint(min_size, len(index), ()), replace=False, ) def list_int_subset(index: pd.Index[str], min_size: int = 2) -> list[int]: return array_int_subset(index, min_size=min_size).tolist() def slice_int_subset(index: pd.Index[str], min_size: int = 2) -> slice: while True: points = np.random.choice(np.arange(len(index) + 1), size=2, replace=False) s = slice(*sorted(points)) if len(range(*s.indices(len(index)))) >= min_size: break return s def single_int_subset(index: pd.Index[str], min_size: int = 1) -> int: if min_size > 1: msg = "max_size must be ≤1" raise AssertionError(msg) return np.random.randint(0, len(index)) _SUBSET_FUNCS: list[_SubsetFunc] = [ # str (obs/var name) single_subset, array_subset, # int (numeric index) single_int_subset, slice_int_subset, array_int_subset, list_int_subset, # bool (mask) array_bool_subset, list_bool_subset, matrix_bool_subset, spmatrix_bool_subset, sparray_bool_subset, ] @pytest.fixture(params=_SUBSET_FUNCS) def subset_func(request: pytest.FixtureRequest) -> _SubsetFunc: return request.param ################### # Checking equality ################### def format_msg(elem_name: str | None) -> str: if elem_name is not None: return f"Error raised from element {elem_name!r}." else: return "" # TODO: it would be better to modify the other exception def report_name(func): """Report name of element being tested if test fails.""" @wraps(func) def func_wrapper(*args, _elem_name: str | None = None, **kwargs): try: return func(*args, **kwargs) except Exception as e: if _elem_name is not None and not hasattr(e, "_name_attached"): msg = format_msg(_elem_name) args = list(e.args) if len(args) == 0: args = [msg] else: args[0] = f"{args[0]}\n\n{msg}" e.args = tuple(args) e._name_attached = True raise e return func_wrapper @report_name def _assert_equal(a, b): """Allows reporting elem name for simple assertion.""" assert a == b @singledispatch def assert_equal( a: object, b: object, *, exact: bool = False, elem_name: str | None = None ): _assert_equal(a, b, _elem_name=elem_name) @assert_equal.register(CupyArray) def assert_equal_cupy( a: CupyArray, b: object, *, exact: bool = False, elem_name: str | None = None ): assert_equal(b, a.get(), exact=exact, elem_name=elem_name) @assert_equal.register(np.ndarray) def assert_equal_ndarray( a: np.ndarray, b: object, *, exact: bool = False, elem_name: str | None = None ): b = asarray(b) if not exact and is_numeric_dtype(a) and is_numeric_dtype(b): assert a.shape == b.shape, format_msg(elem_name) np.testing.assert_allclose(a, b, equal_nan=True, err_msg=format_msg(elem_name)) elif ( # Structured dtype not exact and hasattr(a, "dtype") and hasattr(b, "dtype") and len(a.dtype) > 1 and len(b.dtype) > 0 ): # Reshaping to allow >2d arrays assert a.shape == b.shape, format_msg(elem_name) assert_equal( pd.DataFrame(a.reshape(-1)), pd.DataFrame(b.reshape(-1)), exact=exact, elem_name=elem_name, ) else: assert np.all(a == b), format_msg(elem_name) @assert_equal.register(ArrayView) def assert_equal_arrayview( a: ArrayView, b: object, *, exact: bool = False, elem_name: str | None = None ): assert_equal(asarray(a), asarray(b), exact=exact, elem_name=elem_name) @assert_equal.register(BaseCompressedSparseDataset) @assert_equal.register(sparse.spmatrix) @assert_equal.register(CSArray) def assert_equal_sparse( a: BaseCompressedSparseDataset | sparse.spmatrix | CSArray, b: object, *, exact: bool = False, elem_name: str | None = None, ): a = asarray(a) assert_equal(b, a, exact=exact, elem_name=elem_name) @assert_equal.register(CupySparseMatrix) def assert_equal_cupy_sparse( a: CupySparseMatrix, b: object, *, exact: bool = False, elem_name: str | None = None ): a = a.toarray() assert_equal(b, a, exact=exact, elem_name=elem_name) @assert_equal.register(h5py.Dataset) @assert_equal.register(ZarrArray) def assert_equal_h5py_dataset( a: ArrayStorageType, b: object, *, exact: bool = False, elem_name: str | None = None ): a = asarray(a) assert_equal(b, a, exact=exact, elem_name=elem_name) @assert_equal.register(DaskArray) def assert_equal_dask_array( a: DaskArray, b: object, *, exact: bool = False, elem_name: str | None = None ): assert_equal(b, a.compute(), exact=exact, elem_name=elem_name) @assert_equal.register(pd.DataFrame) def are_equal_dataframe( a: pd.DataFrame, b: object, *, exact: bool = False, elem_name: str | None = None ): if not isinstance(b, pd.DataFrame): assert_equal(b, a, exact=exact, elem_name=elem_name) # , a.values maybe? report_name(pd.testing.assert_frame_equal)( a, b, check_exact=exact, check_column_type=exact, check_index_type=exact, _elem_name=elem_name, check_frame_type=False, ) @assert_equal.register(Dataset2D) def are_equal_dataset2d( a: Dataset2D, b: object, *, exact: bool = False, elem_name: str | None = None ): a.equals(b) @assert_equal.register(AwkArray) def assert_equal_awkarray( a: AwkArray, b: object, *, exact: bool = False, elem_name: str | None = None ): import awkward as ak if exact: assert isinstance(b, AwkArray) assert a.type == b.type, f"{a.type} != {b.type}, {format_msg(elem_name)}" assert ak.to_list(a) == ak.to_list(b), format_msg(elem_name) @assert_equal.register(Mapping) def assert_equal_mapping( a: Mapping, b: object, *, exact: bool = False, elem_name: str | None = None ): assert isinstance(b, Mapping) assert set(a) == set(b), format_msg(elem_name) for k in a: if elem_name is None: elem_name = "" assert_equal(a[k], b[k], exact=exact, elem_name=f"{elem_name}/{k}") @assert_equal.register(AlignedMappingBase) def assert_equal_aligned_mapping( a: AlignedMappingBase, b: object, *, exact: bool = False, elem_name: str | None = None, ): assert isinstance(b, AlignedMappingBase) a_indices = (a.parent.obs_names, a.parent.var_names) b_indices = (b.parent.obs_names, b.parent.var_names) for axis_idx in a.axes: assert_equal( a_indices[axis_idx], b_indices[axis_idx], exact=exact, elem_name=axis_idx ) assert a.attrname == b.attrname, format_msg(elem_name) assert_equal_mapping(a, b, exact=exact, elem_name=elem_name) @assert_equal.register(pd.Index) def assert_equal_index( a: pd.Index, b: object, *, exact: bool = False, elem_name: str | None = None ): params = dict(check_categorical=False) if not exact else {} report_name(pd.testing.assert_index_equal)( a, b, check_names=False, **params, _elem_name=elem_name ) @assert_equal.register(pd.api.extensions.ExtensionArray) def assert_equal_extension_array( a: pd.api.extensions.ExtensionArray, b: object, *, exact: bool = False, elem_name: str | None = None, ): report_name(pd.testing.assert_extension_array_equal)( a, b, check_dtype=exact, check_exact=exact, _elem_name=elem_name, ) @assert_equal.register(XDataArray) def assert_equal_xarray( a: XDataArray, b: object, *, exact: bool = False, elem_name: str | None = None ): report_name(a.equals)(b, _elem_name=elem_name) @assert_equal.register(Raw) def assert_equal_raw( a: Raw, b: object, *, exact: bool = False, elem_name: str | None = None ): def assert_is_not_none(x): # can't put an assert in a lambda assert x is not None report_name(assert_is_not_none)(b, _elem_name=elem_name) for attr in ["X", "var", "varm", "obs_names"]: assert_equal( getattr(a, attr), getattr(b, attr), exact=exact, elem_name=f"{elem_name}/{attr}", ) @assert_equal.register(AnnData) def assert_adata_equal( a: AnnData, b: object, *, exact: bool = False, elem_name: str | None = None ): """\ Check whether two AnnData objects are equivalent, raising an AssertionError if they aren’t. Params ------ a b exact Whether comparisons should be exact or not. This has a somewhat flexible meaning and should probably get refined in the future. """ def fmt_name(x): if elem_name is None: return x else: return f"{elem_name}/{x}" assert isinstance(b, AnnData) # There may be issues comparing views, since np.allclose # can modify ArrayViews if they contain `nan`s assert_equal(a.obs_names, b.obs_names, exact=exact, elem_name=fmt_name("obs_names")) assert_equal(a.var_names, b.var_names, exact=exact, elem_name=fmt_name("var_names")) if not exact: # Reorder all elements if necessary idx = [slice(None), slice(None)] # Since it’s a pain to compare a list of pandas objects change_flag = False if not np.all(a.obs_names == b.obs_names): idx[0] = a.obs_names change_flag = True if not np.all(a.var_names == b.var_names): idx[1] = a.var_names change_flag = True if change_flag: b = b[tuple(idx)].copy() for attr in [ "X", "obs", "var", "obsm", "varm", "layers", "uns", "obsp", "varp", "raw", ]: assert_equal( getattr(a, attr), getattr(b, attr), exact=exact, elem_name=fmt_name(attr), ) def _half_chunk_size(a: tuple[int, ...]) -> tuple[int, ...]: def half_rounded_up(x): div, mod = divmod(x, 2) return div + (mod > 0) return tuple(half_rounded_up(x) for x in a) @singledispatch def as_dense_dask_array(a): import dask.array as da a = asarray(a) return da.asarray(a, chunks=_half_chunk_size(a.shape)) @as_dense_dask_array.register(CSMatrix) def _(a): return as_dense_dask_array(a.toarray()) @as_dense_dask_array.register(DaskArray) def _(a): return a.map_blocks(asarray, dtype=a.dtype, meta=np.ndarray) @singledispatch def _as_sparse_dask( a: NDArray | CSArray | CSMatrix | DaskArray, *, typ: type[CSArray | CSMatrix | CupyCSRMatrix], chunks: tuple[int, ...] | None = None, ) -> DaskArray: """Convert a to a sparse dask array, preserving sparse format and container (`cs{rc}_{array,matrix}`).""" raise NotImplementedError @_as_sparse_dask.register(CSArray | CSMatrix | np.ndarray) def _( a: CSArray | CSMatrix | NDArray, *, typ: type[CSArray | CSMatrix | CupyCSRMatrix], chunks: tuple[int, ...] | None = None, ) -> DaskArray: import dask.array as da chunks = _half_chunk_size(a.shape) if chunks is None else chunks return da.from_array(_as_sparse_dask_inner(a, typ=typ), chunks=chunks) @_as_sparse_dask.register(DaskArray) def _( a: DaskArray, *, typ: type[CSArray | CSMatrix | CupyCSRMatrix], chunks: tuple[int, ...] | None = None, ) -> DaskArray: assert chunks is None # TODO: if needed we can add a .rechunk(chunks) return a.map_blocks(_as_sparse_dask_inner, typ=typ, dtype=a.dtype, meta=typ((2, 2))) def _as_sparse_dask_inner( a: NDArray | CSArray | CSMatrix, *, typ: type[CSArray | CSMatrix | CupyCSRMatrix] ) -> CSArray | CSMatrix: """Convert into a a sparse container that dask supports (or complain).""" if issubclass(typ, CSArray): # convert sparray to spmatrix msg = "AnnData doesn't support `cs_{r,c}_array` inside Dask" raise TypeError(msg) if issubclass(typ, CupySparseMatrix): a = as_cupy(a) # can’t Cupy sparse constructors don’t accept numpy ndarrays return typ(a) as_sparse_dask_matrix = partial(_as_sparse_dask, typ=sparse.csr_matrix) @singledispatch def as_dense_cupy_dask_array(a): import cupy as cp return as_dense_dask_array(a).map_blocks( cp.array, meta=cp.array((1.0), dtype=a.dtype), dtype=a.dtype ) @as_dense_cupy_dask_array.register(CupyArray) def _(a): import cupy as cp import dask.array as da return da.from_array( a, chunks=_half_chunk_size(a.shape), meta=cp.array((1.0), dtype=a.dtype), ) @as_dense_cupy_dask_array.register(DaskArray) def _(a): import cupy as cp if isinstance(a._meta, cp.ndarray): return a.copy() return a.map_blocks( partial(as_cupy, typ=CupyArray), dtype=a.dtype, meta=cp.array((1.0), dtype=a.dtype), ) try: import cupyx.scipy.sparse as cpsparse format_to_memory_class = {"csr": cpsparse.csr_matrix, "csc": cpsparse.csc_matrix} except ImportError: format_to_memory_class = {} @singledispatch def as_cupy_sparse_dask_array(a, format="csr") -> DaskArray: chunk_rows, _ = _half_chunk_size(a.shape) return _as_sparse_dask( a, typ=format_to_memory_class[format], chunks=(chunk_rows, -1) ) @as_cupy_sparse_dask_array.register(CupyArray) @as_cupy_sparse_dask_array.register(CupySparseMatrix) def _(a, format="csr"): import dask.array as da memory_class = format_to_memory_class[format] chunk_rows, _ = _half_chunk_size(a.shape) return da.from_array(memory_class(a), chunks=(chunk_rows, -1)) @as_cupy_sparse_dask_array.register(DaskArray) def _(a, format="csr"): memory_class = format_to_memory_class[format] if isinstance(a._meta, memory_class): return a.copy() return a.rechunk((a.chunks[0], -1)).map_blocks( partial(as_cupy, typ=memory_class), dtype=a.dtype ) def resolve_cupy_type(val): input_typ = type(val) if not isinstance(val, type) else val if issubclass(input_typ, np.ndarray): typ = CupyArray elif issubclass(input_typ, sparse.csr_matrix | sparse.csr_array): typ = CupyCSRMatrix elif issubclass(input_typ, sparse.csc_matrix | sparse.csc_array): typ = CupyCSCMatrix else: msg = f"No default target type for input type {input_typ}" raise NotImplementedError(msg) return typ @singledispatch def as_cupy(val, typ=None): """ Rough conversion function Will try to infer target type from input type if not specified. """ if typ is None: typ = resolve_cupy_type(val) if issubclass(typ, CupyArray): import cupy as cp if isinstance(val, CSMatrix | CSArray): val = val.toarray() return cp.array(val) elif issubclass(typ, CupyCSRMatrix): import cupy as cp import cupyx.scipy.sparse as cpsparse if isinstance(val, np.ndarray): return cpsparse.csr_matrix(cp.array(val)) else: return cpsparse.csr_matrix(val) elif issubclass(typ, CupyCSCMatrix): import cupy as cp import cupyx.scipy.sparse as cpsparse if isinstance(val, np.ndarray): return cpsparse.csc_matrix(cp.array(val)) else: return cpsparse.csc_matrix(val) else: msg = f"Conversion from {type(val)} to {typ} not implemented" raise NotImplementedError(msg) # TODO: test @as_cupy.register(DaskArray) def as_cupy_dask(a, typ=None): if typ is None: typ = resolve_cupy_type(a._meta) return a.map_blocks(partial(as_cupy, typ=typ), dtype=a.dtype) @singledispatch def shares_memory(x, y) -> bool: return np.shares_memory(x, y) @shares_memory.register(CSMatrix) def shares_memory_sparse(x, y): return ( np.shares_memory(x.data, y.data) and np.shares_memory(x.indices, y.indices) and np.shares_memory(x.indptr, y.indptr) ) BASE_MATRIX_PARAMS = [ pytest.param(asarray, id="np_array"), pytest.param(sparse.csr_matrix, id="scipy_csr_matrix"), pytest.param(sparse.csc_matrix, id="scipy_csc_matrix"), pytest.param(sparse.csr_array, id="scipy_csr_array"), pytest.param(sparse.csc_array, id="scipy_csc_array"), ] DASK_MATRIX_PARAMS = [ pytest.param(as_dense_dask_array, id="dense_dask_array"), pytest.param(as_sparse_dask_matrix, id="sparse_dask_matrix"), ] CUPY_MATRIX_PARAMS = [ pytest.param( partial(as_cupy, typ=CupyArray), id="cupy_array", marks=pytest.mark.gpu ), pytest.param( partial(as_cupy, typ=CupyCSRMatrix), id="cupy_csr", marks=pytest.mark.gpu, ), pytest.param( partial(as_cupy, typ=CupyCSCMatrix), id="cupy_csc", marks=pytest.mark.gpu, ), ] DASK_CUPY_MATRIX_PARAMS = [ pytest.param( as_dense_cupy_dask_array, id="cupy_dense_dask_array", marks=pytest.mark.gpu, ), pytest.param( as_cupy_sparse_dask_array, id="cupy_csr_dask_array", marks=pytest.mark.gpu ), ] if is_zarr_v2(): from zarr.storage import DirectoryStore as LocalStore else: from zarr.storage import LocalStore class AccessTrackingStoreBase(LocalStore): _access_count: Counter[str] _accessed: defaultdict[str, set] _accessed_keys: defaultdict[str, list[str]] def __init__(self, *args, **kwargs): # Needed for zarr v3 to prevent a read-only copy being made # https://github.com/zarr-developers/zarr-python/pull/3156 if not is_zarr_v2() and "read_only" not in kwargs: kwargs["read_only"] = True super().__init__(*args, **kwargs) self._access_count = Counter() self._accessed = defaultdict(set) self._accessed_keys = defaultdict(list) self._read_only = True def _check_and_track_key(self, key: str): for tracked in self._access_count: if tracked in key: self._access_count[tracked] += 1 self._accessed[tracked].add(key) self._accessed_keys[tracked] += [key] def get_access_count(self, key: str) -> int: # access defaultdict when value is not there causes key to be there, # which causes it to be tracked if key not in self._access_count: msg = f"{key} not found among access count" raise KeyError(msg) return self._access_count[key] def get_subkeys_accessed(self, key: str) -> set[str]: if key not in self._accessed: msg = f"{key} not found among accessed" raise KeyError(msg) return self._accessed[key] def get_accessed_keys(self, key: str) -> list[str]: if key not in self._accessed_keys: msg = f"{key} not found among accessed keys" raise KeyError(msg) return self._accessed_keys[key] def initialize_key_trackers(self, keys_to_track: Iterable[str]) -> None: for k in keys_to_track: self._access_count[k] = 0 self._accessed_keys[k] = [] self._accessed[k] = set() def reset_key_trackers(self) -> None: self.initialize_key_trackers(self._access_count.keys()) def assert_access_count(self, key: str, count: int) -> None: __tracebackhide__ = True keys_accessed = self.get_subkeys_accessed(key) access_count = self.get_access_count(key) assert self.get_access_count(key) == count, ( f"Found {access_count} accesses at {keys_accessed}" ) if is_zarr_v2(): class AccessTrackingStore(AccessTrackingStoreBase): def __getitem__(self, key: str) -> bytes: self._check_and_track_key(key) return super().__getitem__(key) else: class AccessTrackingStore(AccessTrackingStoreBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs, read_only=True) async def get( self, key: str, prototype: BufferPrototype | None = None, byte_range: ByteRequest | None = None, ) -> object: self._check_and_track_key(key) return await super().get(key, prototype=prototype, byte_range=byte_range) def get_multiindex_columns_df(shape: tuple[int, int]) -> pd.DataFrame: return pd.DataFrame( np.random.rand(shape[0], shape[1]), columns=pd.MultiIndex.from_tuples( list(itertools.product(["a"], range(shape[1] - (shape[1] // 2)))) + list(itertools.product(["b"], range(shape[1] // 2))) ), ) def visititems_zarr( z: ZarrGroup, visitor: Callable[[str, ZarrGroup | zarr.Array], None] ) -> None: for key in z: maybe_group = z[key] if isinstance(maybe_group, ZarrGroup): visititems_zarr(maybe_group, visitor) else: visitor(key, maybe_group) def check_all_sharded(g: ZarrGroup): def visit(key: str, arr: zarr.Array | zarr.Group): # Check for recarray via https://numpy.org/doc/stable/user/basics.rec.html#manipulating-and-displaying-structured-datatypes if isinstance(arr, zarr.Array) and arr.shape != () and arr.dtype.names is None: assert arr.shards is not None visititems_zarr(g, visitor=visit) scverse-anndata-b796d59/src/anndata/types.py000066400000000000000000000012721512025555600210660ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: from ._core.anndata import AnnData @runtime_checkable class ExtensionNamespace(Protocol): """Protocol for extension namespaces. Enforces that the namespace initializer accepts a class with the proper `__init__` method. Protocol's can't enforce that the `__init__` accepts the correct types. See `_check_namespace_signature` for that. This is mainly useful for static type checking with mypy and IDEs. """ def __init__(self, adata: AnnData) -> None: """ Used to enforce the correct signature for extension namespaces. """ scverse-anndata-b796d59/src/anndata/typing.py000066400000000000000000000030231512025555600212300ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import numpy as np import pandas as pd from numpy import ma from . import abc from ._core.anndata import AnnData from .compat import ( AwkArray, CSArray, CSMatrix, CupyArray, CupySparseMatrix, DaskArray, H5Array, XDataArray, ZappyArray, ZarrArray, ) from .compat import Index as _Index if TYPE_CHECKING: from typing import TypeAlias __all__ = ["AxisStorable", "Index", "RWAble"] Index = _Index """1D or 2D index an :class:`~anndata.AnnData` object can be sliced with.""" XDataType: TypeAlias = ( np.ndarray | ma.MaskedArray | CSMatrix | CSArray | H5Array | ZarrArray | ZappyArray | abc.CSRDataset | abc.CSCDataset | DaskArray | CupyArray | CupySparseMatrix ) ArrayDataStructureTypes: TypeAlias = XDataType | AwkArray | XDataArray InMemoryArrayOrScalarType: TypeAlias = ( pd.DataFrame | np.number | str | ArrayDataStructureTypes ) AxisStorable: TypeAlias = ( InMemoryArrayOrScalarType | dict[str, "AxisStorable"] | list["AxisStorable"] ) """A serializable object, excluding :class:`anndata.AnnData` objects i.e., something that can be stored in `uns` or `obsm`.""" RWAble: TypeAlias = ( AxisStorable | AnnData | pd.Categorical | pd.api.extensions.ExtensionArray ) """A superset of :type:`anndata.typing.AxisStorable` (i.e., including :class:`anndata.AnnData`) which is everything can be read/written by :func:`anndata.io.read_elem` and :func:`anndata.io.write_elem`.""" scverse-anndata-b796d59/src/anndata/utils.py000066400000000000000000000347551512025555600210760ustar00rootroot00000000000000from __future__ import annotations import re import warnings from functools import singledispatch, wraps from typing import TYPE_CHECKING import h5py import numpy as np import pandas as pd from scipy import sparse import anndata from ._core.sparse_dataset import BaseCompressedSparseDataset from .compat import CSArray, CupyArray, CupySparseMatrix, DaskArray from .logging import get_logger if TYPE_CHECKING: from collections.abc import Iterable, Mapping, Sequence from typing import Any, Literal logger = get_logger(__name__) def import_name(full_name: str) -> Any: from importlib import import_module parts = full_name.split(".") obj = import_module(parts[0]) for _i, name in enumerate(parts[1:]): i = _i try: obj = import_module(f"{obj.__name__}.{name}") except ModuleNotFoundError: break else: i = len(parts) for name in parts[i + 1 :]: try: obj = getattr(obj, name) except AttributeError as e: msg = f"{parts[:i]}, {parts[i + 1 :]}, {obj} {name}" raise RuntimeError(msg) from e return obj @singledispatch def asarray(x): """Convert x to a numpy array""" return np.asarray(x) @asarray.register(CSArray) @asarray.register(sparse.spmatrix) def asarray_sparse(x): return x.toarray() @asarray.register(BaseCompressedSparseDataset) def asarray_sparse_dataset(x): return asarray(x.to_memory()) @asarray.register(h5py.Dataset) def asarray_h5py_dataset(x): return x[...] @asarray.register(CupyArray) def asarray_cupy(x): return x.get() @asarray.register(CupySparseMatrix) def asarray_cupy_sparse(x): return x.toarray().get() @asarray.register(DaskArray) def asarray_dask(x): return asarray(x.compute()) @singledispatch def convert_to_dict(obj) -> dict: return dict(obj) @convert_to_dict.register(dict) def convert_to_dict_dict(obj: dict): return obj @convert_to_dict.register(np.ndarray) def convert_to_dict_ndarray(obj: np.ndarray): if obj.dtype.fields is None: msg = ( "Can only convert np.ndarray with compound dtypes to dict, " f"passed array had “{obj.dtype}”." ) raise TypeError(msg) return {k: obj[k] for k in obj.dtype.fields} @convert_to_dict.register(type(None)) def convert_to_dict_nonetype(obj: None): return dict() @singledispatch def axis_len(x, axis: Literal[0, 1]) -> int | None: """\ Return the size of an array in dimension `axis`. Returns None if `x` is an awkward array with variable length in the requested dimension. """ return x.shape[axis] try: from .compat import awkward as ak def _size_at_depth(layout, depth, lateral_context, **kwargs): """Callback function for dim_len_awkward, resolving the dim_len for a given level""" if layout.is_numpy: # if it's an embedded rectilinear array, we have to deal with its shape # which might not be 1-dimensional shape = (0,) if layout.is_unknown else layout.shape numpy_axis = lateral_context["axis"] - depth + 1 if not (1 <= numpy_axis < len(shape)): msg = f"axis={lateral_context['axis']} is too deep" raise TypeError(msg) lateral_context["out"] = shape[numpy_axis] return ak.contents.EmptyArray() elif layout.is_list and depth == lateral_context["axis"]: if layout.parameter("__array__") in {"string", "bytestring"}: # Strings are implemented like an array of lists of uint8 (ListType(NumpyType(...))) # which results in an extra hierarchy-level that shouldn't show up in dim_len # See https://github.com/scikit-hep/awkward/discussions/1654#discussioncomment-3736747 msg = f"axis={lateral_context['axis']} is too deep" raise TypeError(msg) if layout.is_regular: # if it's a regular list, you want the size lateral_context["out"] = layout.size else: # if it's an irregular list, you want a null token lateral_context["out"] = -1 return ak.contents.EmptyArray() elif layout.is_record and depth == lateral_context["axis"]: lateral_context["out"] = len(layout.fields) return ak.contents.EmptyArray() elif layout.is_record: # currently, we don't recurse into records # in theory we could, just not sure how to do it at the moment # Would need to consider cases like: scalars, unevenly sized values msg = f"Cannot recurse into record type found at axis={lateral_context['axis']}" raise TypeError(msg) elif layout.is_union: # if it's a union, you could get the result of each union branch # separately and see if they're all the same; if not, it's an error result = None for content in layout.contents: context = {"axis": lateral_context["axis"]} ak.transform( _size_at_depth, content, lateral_context=context, ) if result is None: result = context["out"] elif result != context["out"]: # Union branches have different lengths -> return null token lateral_context["out"] = -1 return ak.contents.EmptyArray() lateral_context["out"] = result return ak.contents.EmptyArray() @axis_len.register(ak.Array) def axis_len_awkward(array, axis: Literal[0, 1]) -> int | None: """Get the length of an awkward array in a given axis Returns None if the axis is of variable length. Code adapted from @jpivarski's solution in https://github.com/scikit-hep/awkward/discussions/1654#discussioncomment-3521574 """ if axis < 0: # negative axis is another can of worms... maybe later msg = "Does not support negative axis" raise NotImplementedError(msg) elif axis == 0: return len(array) else: # communicate with the recursive function using a context (lateral) context = {"axis": axis} # "transform" but we don't care what kind of array it returns ak.transform( _size_at_depth, array, lateral_context=context, ) # Use `None` as null token. return None if context["out"] == -1 else context["out"] @asarray.register(ak.Array) def asarray_awkward(x): return x except ImportError: pass def make_index_unique(index: pd.Index[str], join: str = "-") -> pd.Index[str]: """ Makes the index unique by appending a number string to each duplicate index element: '1', '2', etc. If a tentative name created by the algorithm already exists in the index, it tries the next integer in the sequence. The first occurrence of a non-unique value is ignored. Parameters ---------- join The connecting string between name and integer. Examples -------- >>> from anndata import AnnData >>> adata = AnnData(np.ones((2, 3)), var=pd.DataFrame(index=["a", "a", "b"])) >>> adata.var_names.astype("string") Index(['a', 'a', 'b'], dtype='string') >>> adata.var_names_make_unique() >>> adata.var_names.astype("string") Index(['a', 'a-1', 'b'], dtype='string') """ if index.is_unique: return index from collections import Counter values = index.array.copy() indices_dup = index.duplicated(keep="first") & ~index.isna() values_dup = values[indices_dup] values_set = set(values) counter = Counter() issue_interpretation_warning = False example_colliding_values = [] for i, v in enumerate(values_dup): while True: counter[v] += 1 tentative_new_name = v + join + str(counter[v]) if tentative_new_name not in values_set: values_set.add(tentative_new_name) values_dup[i] = tentative_new_name break issue_interpretation_warning = True if len(example_colliding_values) < 5: example_colliding_values.append(tentative_new_name) if issue_interpretation_warning: msg = ( f"Suffix used ({join}[0-9]+) to deduplicate index values may make index values difficult to interpret. " "There values with a similar suffixes in the index. " "Consider using a different delimiter by passing `join={delimiter}`. " "Example key collisions generated by the make_index_unique algorithm: " f"{example_colliding_values}" ) # 3: caller -> 2: `{obs,var}_names_make_unique` -> 1: here warnings.warn(msg, UserWarning, stacklevel=3) values[indices_dup] = values_dup index = pd.Index(values, name=index.name) return index def join_english(words: Iterable[str], conjunction: str = "or") -> str: words = list(words) # no need to be efficient if len(words) == 0: return "" if len(words) == 1: return words[0] if len(words) == 2: return f"{words[0]} {conjunction} {words[1]}" return ", ".join(words[:-1]) + f", {conjunction} {words[-1]}" def warn_names_duplicates(attr: str): names = "Observation" if attr == "obs" else "Variable" warnings.warn( f"{names} names are not unique. " f"To make them unique, call `.{attr}_names_make_unique`.", UserWarning, stacklevel=2, ) def ensure_df_homogeneous( df: pd.DataFrame, name: str ) -> np.ndarray | sparse.csr_matrix: # TODO: rename this function, I would not expect this to return a non-dataframe if all(isinstance(dt, pd.SparseDtype) for dt in df.dtypes): arr = df.sparse.to_coo().tocsr() else: arr = df.to_numpy() if df.dtypes.nunique() != 1: msg = f"{name} converted to numpy array with dtype {arr.dtype}" # 4: caller -> 3: `AnnData.__init__` -> 2: `_init_as_actual` → 1: here warnings.warn(msg, UserWarning, stacklevel=4) return arr def convert_dictionary_to_structured_array(source: Mapping[str, Sequence[Any]]): names = list(source.keys()) try: # transform to byte-strings cols = [ np.asarray(col) if np.array(col[0]).dtype.char not in {"U", "S"} else np.asarray(col).astype("U") for col in source.values() ] except UnicodeEncodeError as e: msg = ( "Currently only support ascii strings. " "Don’t use “ö” etc. for sample annotation." ) raise ValueError(msg) from e # if old_index_key not in source: # names.append(new_index_key) # cols.append(np.arange(len(cols[0]) if cols else n_row).astype("U")) # else: # names[names.index(old_index_key)] = new_index_key # cols[names.index(old_index_key)] = cols[names.index(old_index_key)].astype("U") dtype_list = list( zip( names, [str(c.dtype) for c in cols], [(c.shape[1],) for c in cols], strict=True, ) ) # might be unnecessary dtype = np.dtype(dtype_list) arr = np.zeros((len(cols[0]),), dtype) # here, we do not want to call BoundStructArray.__getitem__ # but np.ndarray.__getitem__, therefore we avoid the following line # arr = np.ndarray.__new__(cls, (len(cols[0]),), dtype) for i, name in enumerate(dtype.names): arr[name] = np.array(cols[i], dtype=dtype_list[i][1]) return arr def warn_once(msg: str, category: type[Warning], stacklevel: int = 1): warnings.warn(msg, category, stacklevel=stacklevel) # Prevent from showing up every time an awkward array is used # You'd think `'once'` works, but it doesn't at the repl and in notebooks warnings.filterwarnings("ignore", category=category, message=re.escape(msg)) def deprecated( new_name: str, category: type[Warning] = FutureWarning, add_msg: str = "", *, hide: bool = True, ): """\ This is a decorator which can be used to mark functions as deprecated with a FutureWarning. It will result in a warning being emitted when the function is used. """ def decorator(func): name = func.__qualname__ msg = ( f"Use {new_name} instead of {name}, " f"{name} is deprecated and will be removed in the future." ) if add_msg: msg += f" {add_msg}" @wraps(func) def new_func(*args, **kwargs): warnings.warn(msg, category=category, stacklevel=2) return func(*args, **kwargs) setattr(new_func, "__deprecated", (category, msg, hide)) return new_func return decorator class DeprecationMixinMeta(type): """\ Use this as superclass so deprecated methods and properties do not appear in vars(MyClass)/dir(MyClass) """ def __dir__(cls): def is_hidden(attr) -> bool: if isinstance(attr, property): attr = attr.fget _, _, hide = getattr(attr, "__deprecated", (None, None, False)) return hide return [ item for item in type.__dir__(cls) if not is_hidden(getattr(cls, item, None)) ] def raise_value_error_if_multiindex_columns(df: pd.DataFrame, attr: str): if isinstance(df.columns, pd.MultiIndex): msg = ( "MultiIndex columns are not supported in AnnData. " f"Please use a single-level index for {attr}." ) raise ValueError(msg) def module_get_attr_redirect( attr_name: str, deprecated_mapping: Mapping[str, str], old_module_path: str | None = None, ) -> Any: full_old_module_path = ( f"anndata{'.' + old_module_path if old_module_path is not None else ''}" ) if new_path := deprecated_mapping.get(attr_name): msg = ( f"Importing {attr_name} from `{full_old_module_path}` is deprecated. " f"Import anndata.{new_path} instead." ) warnings.warn(msg, FutureWarning, stacklevel=2) # hacky import_object_by_name, but we test all these mod = anndata while "." in new_path: mod_name, new_path = new_path.split(".", 1) mod = getattr(mod, mod_name) return getattr(mod, new_path) msg = f"module {full_old_module_path} has no attribute {attr_name!r}" raise AttributeError(msg) scverse-anndata-b796d59/src/testing/000077500000000000000000000000001512025555600174155ustar00rootroot00000000000000scverse-anndata-b796d59/src/testing/anndata/000077500000000000000000000000001512025555600210235ustar00rootroot00000000000000scverse-anndata-b796d59/src/testing/anndata/__init__.py000066400000000000000000000000001512025555600231220ustar00rootroot00000000000000scverse-anndata-b796d59/src/testing/anndata/_doctest.py000066400000000000000000000005301512025555600231770ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Callable from typing import TypeVar F = TypeVar("F", bound=Callable) def doctest_needs(mod: str) -> Callable[[F], F]: """Mark function with doctest dependency.""" def decorator(func: F) -> F: func._doctest_needs = mod return func return decorator scverse-anndata-b796d59/src/testing/anndata/_pytest.py000066400000000000000000000076321512025555600230740ustar00rootroot00000000000000"""Private anndata pytest plugin. This file exists 1. to allow ignoring warnings without test collection failing on CI 2. as a pytest plugin/config that applies to doctests as well It lives outside of the anndata package in order to avoid importing anndata too early. """ from __future__ import annotations import re import warnings from importlib.util import find_spec from typing import TYPE_CHECKING, cast import pytest import anndata if TYPE_CHECKING: from collections.abc import Generator, Iterable from pathlib import Path @pytest.fixture(autouse=True) def _anndata_test_env(request: pytest.FixtureRequest) -> None: if isinstance(request.node, pytest.DoctestItem): request.getfixturevalue("_doctest_env") anndata.settings.reset(anndata.settings._registered_options.keys()) @pytest.fixture def _doctest_env( request: pytest.FixtureRequest, cache: pytest.Cache, tmp_path: Path ) -> Generator[None, None, None]: from contextlib import chdir from scanpy import settings from anndata.utils import import_name assert isinstance(request.node.parent, pytest.Module) # request.node.parent is either a DoctestModule or a DoctestTextFile. # Only DoctestModule has a .obj attribute (the imported module). if request.node.parent.obj: func = import_name(request.node.name) warning_detail: tuple[type[Warning], str, bool] | None if warning_detail := getattr(func, "__deprecated", None): cat, msg, _ = warning_detail warnings.filterwarnings("ignore", category=cat, message=re.escape(msg)) if (mod := getattr(func, "_doctest_needs", None)) is not None and not find_spec( mod ): request.applymarker(pytest.skip(reason=f"doctest needs {mod} to run")) old_dd, settings.datasetdir = settings.datasetdir, cache.mkdir("scanpy-data") with chdir(tmp_path): yield settings.datasetdir = old_dd def pytest_itemcollected(item: pytest.Item) -> None: """Define behavior of pytest.mark.gpu.""" is_gpu = len(list(item.iter_markers(name="gpu"))) > 0 if is_gpu: item.add_marker( pytest.mark.skipif(not find_spec("cupy"), reason="Cupy not installed.") ) def pytest_addoption(parser: pytest.Parser) -> None: """Hook to register custom CLI options and config values""" parser.addoption( "--strict-warnings", action="store_true", default=False, help="Turn warnings into errors that are not overridden by `filterwarnings` or `filterwarnings_when_strict`.", ) parser.addini( "filterwarnings_when_strict", "Filters to apply after `-Werror` when --strict-warnings is active", type="linelist", default=[], ) def pytest_collection_modifyitems( session: pytest.Session, config: pytest.Config, items: Iterable[pytest.Item] ): for item in items: if "zarr" in item.name: item.add_marker("zarr_io") if not config.getoption("--strict-warnings"): return warning_filters = [ "error", *_config_get_strlist(config, "filterwarnings"), *_config_get_strlist(config, "filterwarnings_when_strict"), ] warning_marks = [pytest.mark.filterwarnings(f) for f in warning_filters] # Add warning filters defined in the config to all tests items. # Test items might already have @pytest.mark.filterwarnings applied, # so we prepend ours to ensure that an item’s explicit filters override these. # Reversing then individually prepending ensures that the order is preserved. for item in items: for mark in reversed(warning_marks): item.add_marker(mark, append=False) def _config_get_strlist(config: pytest.Config, name: str) -> list[str]: if strs := config.getini(name): assert isinstance(strs, list) assert all(isinstance(item, str) for item in strs) return cast("list[str]", strs) return [] scverse-anndata-b796d59/src/testing/anndata/py.typed000066400000000000000000000000001512025555600225100ustar00rootroot00000000000000scverse-anndata-b796d59/tests/000077500000000000000000000000001512025555600163135ustar00rootroot00000000000000scverse-anndata-b796d59/tests/conftest.py000066400000000000000000000136471512025555600205250ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Generator from functools import partial from importlib.metadata import version from typing import TYPE_CHECKING import joblib import pytest from dask.base import normalize_token, tokenize from packaging.version import Version from anndata.compat import is_zarr_v2 if Version(version("dask")) < Version("2024.8.0"): from dask.base import normalize_seq else: from dask.tokenize import normalize_seq from filelock import FileLock from scipy import sparse import anndata as ad from anndata.tests.helpers import subset_func # noqa: F401 if TYPE_CHECKING: from collections.abc import Generator from pathlib import Path from types import EllipsisType from typing import Literal @pytest.fixture def backing_h5ad(tmp_path: Path) -> Path: return tmp_path / "test.h5ad" @pytest.fixture( params=[ ("h5ad", None), ("zarr", 2), pytest.param( ("zarr", 3), marks=pytest.mark.skipif( is_zarr_v2(), reason="zarr v3 file format not supported with v2 package" ), ), ], ids=["h5ad", "zarr2", "zarr3"], ) def diskfmt( request: pytest.FixtureRequest, ) -> Generator[Literal["h5ad", "zarr"], None, None]: if (fmt := request.param[0]) == "h5ad": yield fmt else: with ad.settings.override(zarr_write_format=request.param[1]): yield fmt @pytest.fixture def diskfmt2( diskfmt: Literal["h5ad", "zarr"], ) -> Generator[Literal["zarr", "h5ad"], None, None]: if diskfmt == "h5ad": with ad.settings.override(zarr_write_format=2): yield "zarr" else: yield "h5ad" @pytest.fixture( params=[ pytest.param((..., (slice(None), slice(None))), id="ellipsis"), pytest.param(((...,), (slice(None), slice(None))), id="ellipsis_tuple"), pytest.param( ((..., slice(0, 10)), (slice(None), slice(0, 10))), id="obs-ellipsis" ), pytest.param( ((slice(0, 10), ...), (slice(0, 10), slice(None))), id="var-ellipsis" ), pytest.param( ((slice(0, 10), slice(0, 10), ...), (slice(0, 10), slice(0, 10))), id="obs-var-ellipsis", ), pytest.param( ((..., slice(0, 10), slice(0, 10)), (slice(0, 10), slice(0, 10))), id="ellipsis-obs-var", ), pytest.param( ((slice(0, 10), ..., slice(0, 10)), (slice(0, 10), slice(0, 10))), id="obs-ellipsis-var", ), ] ) def ellipsis_index_with_equivalent( request, ) -> tuple[tuple[EllipsisType | slice, ...] | EllipsisType, tuple[slice, slice]]: return request.param @pytest.fixture def ellipsis_index( ellipsis_index_with_equivalent: tuple[ tuple[EllipsisType | slice, ...] | EllipsisType, tuple[slice, slice] ], ) -> tuple[EllipsisType | slice, ...] | EllipsisType: return ellipsis_index_with_equivalent[0] @pytest.fixture def equivalent_ellipsis_index( ellipsis_index_with_equivalent: tuple[ tuple[EllipsisType | slice, ...] | EllipsisType, tuple[slice, slice] ], ) -> tuple[slice, slice]: return ellipsis_index_with_equivalent[1] @pytest.fixture(scope="session") def local_cluster_addr( tmp_path_factory: pytest.TempPathFactory, worker_id: str ) -> Generator[str, None, None]: # Adapted from https://pytest-xdist.readthedocs.io/en/latest/how-to.html#making-session-scoped-fixtures-execute-only-once import dask.distributed as dd def make_cluster(worker_id: str) -> dd.LocalCluster: # If we're not using multiple pytest-xdist workers, let the cluster have multiple workers. return dd.LocalCluster( n_workers=1 if worker_id != "master" else 2, threads_per_worker=1 ) if worker_id == "master": with make_cluster(worker_id) as cluster: yield cluster.scheduler_address return # get the temp directory shared by all workers root_tmp_dir = tmp_path_factory.getbasetemp().parent fn = root_tmp_dir / "dask_scheduler_address.txt" lock = FileLock(str(fn) + ".lock") lock.acquire() # can’t use context manager, because we need to release the lock before yielding address = fn.read_text() if fn.is_file() else None if address: lock.release() yield address return with make_cluster(worker_id) as cluster: fn.write_text(cluster.scheduler_address) lock.release() yield cluster.scheduler_address ##################### # Dask tokenization # ##################### # TODO: Should we be exporting this? # sparray classes don't have tokenize defined yet, see: https://github.com/dask/dask/issues/10375 def normalize_sparse_matrix(x, attrs): return ( type(x).__name__, normalize_seq(normalize_token(getattr(x, key)) for key in attrs), ) for cls, attrs in [ (sparse.dia_array, ("data", "offsets", "shape")), (sparse.bsr_array, ("data", "indices", "indptr", "blocksize", "shape")), (sparse.coo_array, ("data", "row", "col", "shape")), (sparse.csr_array, ("data", "indices", "indptr", "shape")), (sparse.csc_array, ("data", "indices", "indptr", "shape")), (sparse.lil_array, ("data", "rows", "shape")), ]: normalize_token.register(cls, partial(normalize_sparse_matrix, attrs=attrs)) @normalize_token.register(sparse.dok_array) def normalize_dok_matrix(x): return type(x).__name__, normalize_token(sorted(x.items())) @normalize_token.register(ad.AnnData) def tokenize_anndata(adata: ad.AnnData): res = [] if adata.X is not None: res.append(tokenize(adata.X)) res.extend([tokenize(adata.obs), tokenize(adata.var)]) for attr in ["obsm", "varm", "obsp", "varp", "layers"]: elem = getattr(adata, attr) res.append(tokenize(list(dict(elem).items()))) res.append(joblib.hash(adata.uns)) if adata.raw is not None: res.append(tokenize(adata.raw.to_adata())) return tuple(res) scverse-anndata-b796d59/tests/data/000077500000000000000000000000001512025555600172245ustar00rootroot00000000000000scverse-anndata-b796d59/tests/data/adata-comments.tsv000066400000000000000000000001451512025555600226570ustar00rootroot00000000000000# A regular comment # The next comment is actually colnames # c1 c2 r1 1.0 0.0 r2 3.0 0.0 r3 5.0 6.0 scverse-anndata-b796d59/tests/data/adata.csv000066400000000000000000000000501512025555600210060ustar00rootroot00000000000000,c1,c2 r1,1.0,0.0 r2,3.0,0.0 r3,5.0,6.0 scverse-anndata-b796d59/tests/data/archives/000077500000000000000000000000001512025555600210305ustar00rootroot00000000000000scverse-anndata-b796d59/tests/data/archives/readme.md000066400000000000000000000004771512025555600226170ustar00rootroot00000000000000# archives This directory contains an archive of anndata files written by older versions of the library. It's for testing backwards compat. This should really live somewhere else, but it's here for now. ## Directories Directories with version numbers contain files written by the corresponding version of `anndata`. scverse-anndata-b796d59/tests/data/archives/v0.11.4/000077500000000000000000000000001512025555600217375ustar00rootroot00000000000000scverse-anndata-b796d59/tests/data/archives/v0.11.4/adata.h5ad000066400000000000000000000400301512025555600235510ustar00rootroot00000000000000HDF  @` TREE0HEAPXHobsvarobsmvarmobspvarplayersuns Hencoding-type Pencoding-versionTREEHEAPXH_indexHSNOD89@9`;(+@+`-(2@2`4@<<> Hh ..00557GCOLanndata0.1.0_index56789 3 4 2 1 0 string-array0.2.0 dataframe0.2.0_index10111213141516171819567 8!9"3#4$2%1&0' string-array(0.2.0) dataframe*0.2.0+dict,0.1.0-dict.0.1.0/dict00.1.01dict20.1.03dict40.1.05dict60.1.0 ` H column-order ?@4 4 @_index  Hencoding-type X SNOD Pencoding-version( Hencoding-type  Pencoding-versionTREE)HEAPX_indexHX* H column-order ?@4 4 @_index Pencoding-version(     &%$"# ! @ Hencoding-type 'X SNOD(Hh Hencoding-type ) Pencoding-version*-TREEHEAPX-P@+`- Hencoding-type+ Pencoding-version,X1TREEHEAPX1P.0 Hencoding-type- Pencoding-version.4TREEHEAPX4P@2`4 Hencoding-type/ Pencoding-version0X8TREEHEAPX8P57 Hencoding-type1 Pencoding-version2;TREEHEAPX;P@9`; Hencoding-type3 Pencoding-version4X?TREEHEAPX?P<> Hencoding-type5 Pencoding-version6scverse-anndata-b796d59/tests/data/archives/v0.11.4/adata.zarr.zip000066400000000000000000000146131512025555600245170ustar00rootroot00000000000000PKZw.zgroup{ "zarr_format": 2 }PKZ1\"".zattrs{ "encoding-type": "anndata" }PKZKCC.zattrs{ "encoding-type": "anndata", "encoding-version": "0.1.0" }PKZw obs/.zgroup{ "zarr_format": 2 }PKZNH obs/.zattrs{ "column-order": [] }PKZe([22 obs/.zattrs{ "_index": "_index", "column-order": [] }PKZkkobs/_index/.zarray{ "chunks": [ 10 ], "compressor": { "blocksize": 0, "clevel": 5, "cname": "lz4", "id": "blosc", "shuffle": 1 }, "dtype": "|O", "fill_value": 0, "filters": [ { "id": "vlen-utf8" } ], "order": "C", "shape": [ 10 ], "zarr_format": 2 }PKZ*]FF obs/_index/0366F 0123456789PKZ''obs/_index/.zattrs{ "encoding-type": "string-array" }PKZzHHobs/_index/.zattrs{ "encoding-type": "string-array", "encoding-version": "0.2.0" }PKZJTT obs/.zattrs{ "_index": "_index", "column-order": [], "encoding-type": "dataframe" }PKZY+uu obs/.zattrs{ "_index": "_index", "column-order": [], "encoding-type": "dataframe", "encoding-version": "0.2.0" }PKZw var/.zgroup{ "zarr_format": 2 }PKZNH var/.zattrs{ "column-order": [] }PKZe([22 var/.zattrs{ "_index": "_index", "column-order": [] }PKZ)kkvar/_index/.zarray{ "chunks": [ 20 ], "compressor": { "blocksize": 0, "clevel": 5, "cname": "lz4", "id": "blosc", "shuffle": 1 }, "dtype": "|O", "fill_value": 0, "filters": [ { "id": "vlen-utf8" } ], "order": "C", "shape": [ 20 ], "zarr_format": 2 }PKZ:j var/_index/03rr012345678910111213141516171819PKZ''var/_index/.zattrs{ "encoding-type": "string-array" }PKZzHHvar/_index/.zattrs{ "encoding-type": "string-array", "encoding-version": "0.2.0" }PKZJTT var/.zattrs{ "_index": "_index", "column-order": [], "encoding-type": "dataframe" }PKZY+uu var/.zattrs{ "_index": "_index", "column-order": [], "encoding-type": "dataframe", "encoding-version": "0.2.0" }PKZw obsm/.zgroup{ "zarr_format": 2 }PKZ Kn obsm/.zattrs{ "encoding-type": "dict" }PKZ~'6c@@ obsm/.zattrs{ "encoding-type": "dict", "encoding-version": "0.1.0" }PKZw varm/.zgroup{ "zarr_format": 2 }PKZ Kn varm/.zattrs{ "encoding-type": "dict" }PKZ~'6c@@ varm/.zattrs{ "encoding-type": "dict", "encoding-version": "0.1.0" }PKZw obsp/.zgroup{ "zarr_format": 2 }PKZ Kn obsp/.zattrs{ "encoding-type": "dict" }PKZ~'6c@@ obsp/.zattrs{ "encoding-type": "dict", "encoding-version": "0.1.0" }PKZw varp/.zgroup{ "zarr_format": 2 }PKZ Kn varp/.zattrs{ "encoding-type": "dict" }PKZ~'6c@@ varp/.zattrs{ "encoding-type": "dict", "encoding-version": "0.1.0" }PKZwlayers/.zgroup{ "zarr_format": 2 }PKZ Knlayers/.zattrs{ "encoding-type": "dict" }PKZ~'6c@@layers/.zattrs{ "encoding-type": "dict", "encoding-version": "0.1.0" }PKZw uns/.zgroup{ "zarr_format": 2 }PKZ Kn uns/.zattrs{ "encoding-type": "dict" }PKZ~'6c@@ uns/.zattrs{ "encoding-type": "dict", "encoding-version": "0.1.0" }PKZw.zgroupPKZ1\""=.zattrsPKZKCC.zattrsPKZw obs/.zgroupPKZNH -obs/.zattrsPKZe([22 pobs/.zattrsPKZkkobs/_index/.zarrayPKZ*]FF fobs/_index/0PKZ''obs/_index/.zattrsPKZzHH-obs/_index/.zattrsPKZJTT obs/.zattrsPKZY+uu "obs/.zattrsPKZw var/.zgroupPKZNH var/.zattrsPKZe([22 Dvar/.zattrsPKZ)kkvar/_index/.zarrayPKZ:j :var/_index/0PKZ''var/_index/.zattrsPKZzHH= var/_index/.zattrsPKZJTT  var/.zattrsPKZY+uu 2 var/.zattrsPKZw  obsm/.zgroupPKZ Kn  obsm/.zattrsPKZ~'6c@@ [ obsm/.zattrsPKZw  varm/.zgroupPKZ Kn  varm/.zattrsPKZ~'6c@@ P varm/.zattrsPKZw  obsp/.zgroupPKZ Kn  obsp/.zattrsPKZ~'6c@@ E obsp/.zattrsPKZw  varp/.zgroupPKZ Kn  varp/.zattrsPKZ~'6c@@ :varp/.zattrsPKZwlayers/.zgroupPKZ Knlayers/.zattrsPKZ~'6c@@3layers/.zattrsPKZw uns/.zgroupPKZ Kn uns/.zattrsPKZ~'6c@@ (uns/.zattrsPK''scverse-anndata-b796d59/tests/data/archives/v0.11.4/readme.md000066400000000000000000000004661512025555600235240ustar00rootroot00000000000000These files were written with ```bash uvx '--with=anndata==0.11.4' '--with=zarr<3' python -c ' import zarr from anndata import AnnData adata = AnnData(shape=(10, 20)) adata.write_zarr(zarr.ZipStore("tests/data/archives/v0.11.4/adata.zarr.zip")) adata.write_h5ad("tests/data/archives/v0.11.4/adata.h5ad")' ``` scverse-anndata-b796d59/tests/data/archives/v0.5.0/000077500000000000000000000000001512025555600216565ustar00rootroot00000000000000scverse-anndata-b796d59/tests/data/archives/v0.5.0/adata.h5ad000066400000000000000000066055201512025555600235110ustar00rootroot00000000000000HDF  P `TREEq0(HEAPXHXobsvarobsmvarmunsraw.Xraw.var(   deflate0,`[@TREE@```x^{\׵ ! %P_(B(!PCw|FKXj-h|? H(E%OJa4p)K o޵ݙ9sH>?o~'+*-*ae1kOZF8>&#l*ϙh_+ۿcgoο4w@;f Ϙ@F#_C i(bvES@?Eݟ'l'vt`U< di^U{gj]1c /3\_Ƽ OWMFV=ӧ5 ?oֿzǨ פ?פK+Re  a.ҿda l Am‚!XRd{C3Ǘ' Yh-C!1w1Z_ w785 ͫ^ȿ' )c^!/ۚ٣eDGK?1aeU;SKË5/%k Ϻ@uIg?c"S?d=ד>4)+_.q5߼jz9Kßˏɜhz1/xFP%#YɃP!!!_0\i-=k/1 [?0sL?'*@P7PjX?cH+)&);ץ?RHrN14]SI/Iiߒ(y 76q5d6? Mro1s#=d1#|-68u*@VLW!ܤ5RLQ˞7:?%E3+yAWOLna<\[_W9}H}s@gcT+87Y?iC?s>'?l!{f=ӓvmdQm^uQj9rO}r8D gWJe5h=> {HynV2_ދ,/ҿ-1gI>G?O?ɼD{1/iO`fVi'De0?k@7G߻S/#[IJXOk9*=Y{Pi7s15lOhq?>Y}mSbsiE;p>ЃuMϋ Y˞+'v7Zߡdy<;'N''9w܋oG.'f7vwq "ʉ.J\b~R?6}^Ug[\382Kx~/#HvvCV?^i?_I2ER*gZɚ/9`"gɘG?r3+͐?|dHo=O_x- ?"cWW>{ovJ?{QI ji>Ѓ7Txq9QOTw~aMO֕bFpޱ俤ffЃ^9N 9"GA9[&`MY]Yx$C_k^/α߫17pM?#F_ka?nl'l%HghBig_?2Cq/DVσ/90jQ3v?a'd!q=8'OO6s*v?W"N'2d89Uwju?K1&rI&oWYq_~76;D}?xǮeFG#kBf5{[HAq~1c~$nu՝ Eɸw+[s2gk<Ǟyݮu8yDGdǕߪpj@Z1NƂ^M o"!!oZ!(;&bz:(Wf++S&79l zvu * =XJu1CJwZiBsD w ziHޅEC0-Wh":+w?ݤ<տJ=z?û~R/)w "oHH6}U/y_X_W^2I~\ Hu͉c?";vv?4g {u]B4̏3aLe7z763nKmwXj?C^#'w57ds{Hj> .pEg 7YX+ b GcI8 So7WouS7 5W?'Τ_lO?rx/j-{ ?)B?Nj/̎I2sje_[.fG? ?ܫfz5xxn#&{ xV.}_06Z6CcRgE{ל?13ޡ[OioɘCV ۤ %"5eP\>r,73_~L?Dsަwʈ?Co^zMueZu>_9E̟h1/yG?TN|Q.rDgIm'Z_*[^_@7+i=0SU#3 OR /}7?~ݷ_bp7N| |,w?4?uUE[SfHs{yS1Y-{l 'oL[ڿ6!/Pè.s6ϟ?/(Wj4 /~BϿ{9Q?b܉n+~wAeG LUWy' g1?jc]):>=Ud>e9gIfXW Xh?m'?^ '9Z14gbrfυ)rӴn+h}<䭲ʟO֕-Ŀ`l oQow_P}1}-@?g8R9^FL o?FL G샠\3}4+”a ܪ%mqOVHLnw{UCCge>ބ? W3;c6qg(򟲚. U!? o^[Κ{^?P{i g*wn~˰ryro)T#Q?uޱf]`s 04M!?:sfbI(x8tY)̶zd>ClBs%uoKo7꿖+y?+og?Eϟׯ'߲zskG#FqVQ; kdl> z=iľ_E M 3T-ek8E|W]!xNqNFgt6&h~0< 01oʄEPӰ2U|Di쳚?=>]g@>{Y=p7]b_Z;Loin_Sgw!͟OyȘW3y~v9ٲ_6 7t's{|7;y~{g3 |e'2ikl4G';$/]wE]dAކo_5>HoDi ɳ&[c 8^?/q63ܛެSjnٛCɇ2q/>>/gנk_ARIh%GwmCe;7{Eׄ?-ܥϋ3>$J5cg8_=߀k\_#JYc {(}7By)Fi8:F"b߾{-Gf 2Ol':' gѿya4eF!umԿgc ^e˾šdn\Ka.кUGD5Ӿ{,Կ1GP% m/oE{?*+:?>ז!J9%m=r*A^T?Yos:ZN?9fAAaD/D`:UuCe_JorZ#qv']_b=WG>~ɣHmu"SF׀_#5DfN"g!xEVs)ȗGH(Cp r(?uז_OLudϑߛ'o{ſz~w)>9%mκv00`Y'51Z7e:MWHQC7et2 +6gnl@ [/jebGEk`W'YH+m;c6qS37GMU&/>ukp e?)Up7xeu(''2+ߩ.OnXsG% )ĔcO OK@PQAPS?#1vo}~Hz3:2^n?ZS/O\M=DpBϓw4T?)?Jo-N91ITu{K+a]cY> DKS/'AQTXb㼆op"읔G_2" yRW("JpxYߨ컱}oG{]2(j i|ƿc&R"9:Mܓҿ{5f/I? E?ޅuYz7W?R;WHK +F|&c7̶'Uߋ1oZϦ֚c箭2WO8MNQ2o pri}&؀2A?ѡ('o`l8oz {^d{=\$׸ڑG{AS;^cQ r>!]_t7Y{eZ4^U_GneWU7-mRҟ#π>^?75ZWo]ќHs `,r6g֬k4zy 57j,*sJz1;l{#ybf{gx$ R%MAeᯥiqX C̰*~$m_%C1VIOoZG1a<>TxNT?w՜@dOޠ3Pz.?33RhWWWk}Е?~\#j /MEhӐv{{%*_ѓFXBq?uG_MvmMc+gTxv>u ?>ZfbY$?%g]Z3gSDo)ٚ/&ߵ&m7-A[ҋ]pF(}~̐,1l{{ԿE. 591Գ*OdS oWΏc~S柝s#g]¤O}.0Wy"w TAkP)$o2Xg46cT?V.rkI>;0O5CJTwZ6-[6o񇞎]7ܾɉ1Bޣ5Yvϩ:A̍ȕiT f-8ޛYj?Š"`6;G1/^ }?<Ҿ'cT>?ژg08Ca5_5_|*[&gScO|9?k7=Ϛ nZgJgOnW~4yCe εޮʟcO?RpbT?Q0چzwqf9k,tw`_1a=ggyɸG?/of35Gc+.@qfOx=̝\9< 9k UW w̦4fgߌV!sC!lS%xgt{Z,BGlb}Z."Vғ9[ϛPwuC%߼뽘ا79DEO|u98WNّO< I`e=Q;w>r@O`1p3a tSR}j)Vߖ͟#28;@-"_8q<ӡۿp]wKrv~%l{׭413#_•A\Bs}ށYvЃY&?"r =G%>)d%TOJ^XDF5*3wSۨ{AUsG?d/of1 R31_>h}GOl+ꯋ/,_Sj;p/U76-AƝ7߫a ˇԿ0M},Os#6 ,@ށi&? _Ϛڅ+n?PeG{{`PxfFsQ|?sgX?d#hfψ?~t3>WZsG{ R<uϘ-Ou(&؀G+K[ ^ k תwd?b9?S2/wDľ'/aglEJESQZ>p~_ 9g8qC:02GUQ2(gQzEI){*CaǺv0?V1&G`'' +AmCi_ؿX#'>p-ϨLP|(~\vGTp)Q8Zx9%<ςJ_gN-7j^?A_|چ`=Rז5Ùse?AoVǽw++ii+ c?pn/Ql=ϑ?\+Kw#G'jΟOWu9YԿr_>Ţcb?}X MH  _oO`U̐oND¤,\CFPrW@Ī,/LZ c_d/?H_{QK*Ix1 O~I;Oc *RcAO|m)rB?D(o7柿]?RG]c^ϕi0A_?<Ϭ!Z_~=JCP9Y8[GEpEJGLxYv /C˞#SwgnoWohjG%ǬHOsQ_QA)=oP̟GojW; _??{埌D$Ml+?}HsJ]<ˆS'3' LJeiAḰ;~b/ /o];Z?_lھ{l`JNmbQU.=n_gi_[qȿ߯2_%?hMpy3ӿqcaU "`P*.<{[Op"wG0^2}Z?6xq}+by=_Yox8*vΟį5?G3S珐Dm?=Q}p*ݲ"s$!Ug:X1.L^W;EWPyzٍ:x[ߝ㼆Ӥ/jhE|ܣA1v?  [Pg's Pi#obf!?4p+n2e_6xF6eyiqow܁kuщNPg:?d6o}τl <_:+v3\w9ZGڃv1*?8ǃK*:W?j?3\O>v 1ߟLw;/xlsn{l>=0xh!Pu6#A]Jſȿt^0UOO-%sQ8-H? }i_:W5/?ϪI1Ji9[w_9ncx^y\#RDJ &$1fH)y'bIH1RDJ)5YM)"RD̾=)""Hɾo&44"d_<ߌ3sM~.3ynU•M܏?ߕòAv<aApybS;.??>ʍ,u_gu!⟿3^7C"=D$:BW3(&犂M~\ܦ{W9≟H2'V(kwſ0Gp_uFҨMwO7A/6,5ECOW? 0m9c@i(Q+vʋ]eăO$+s'?,iƚ>4W4SoDz)#WTMN 'M(=#yb.@ N%#O_mFF,ض2Eo~| &E(ƍ۬o4~ٗ;vJ;`,Mת#. E=b}z'ѽƨ=>O^QFSweb2z-?!e:alINȔ;^)oH+'~&w:'u ?2J:Ohx?XH'޼(Sϊ=ݺɉ w]@,=P'yDg"Y3v#3*Ɵ+WS!\oHEK*ry%}麳WB+ȳKzH(BL<~[޴F5?>1Cc7b#PDf~K%'-'!\voN$SOQ\{{u7O3 '.Bt cMpI\@8A5ܔ?.HQ(4n/sd?|Cg?d|^d!~1O,NI??6IC*vq<#8X2Tc܌}^{OKdF[JFȄ?>֯[IK\בMª O M}]zf7?]M}@u_]٪ 03?M6c>#-R[ Ȓzv'P7rU |K>&VkK9>Z- Yt]T#Ϭsxp ȟq ,Hn/̒~=r?XK^/o|PPQ=~^7W;?[`?=Oa,݆A@KF4+r>z퇦OKW/.yU??o*'OMm'kod'=>I d?=kf3/'<+k(NGk7r@kSeW\A9ybbn̡ CE%0v)6&|)a٠hwoOM7}K,[Ǟ<+ߕ sm;OqӭBo*==]מ2?r_+#_P"0%NfvI>Gͭ[9S"? (%C`_'LUm}. =T3[1٤ G>Iۣσr6?Dea?]M?S]BY56Byf?I3F["I|?ߛo*wIiQ&' dai^>4NY+T$e^Z??FtTs<.l$7&ϕ/Q{?5uosop>mDw-h}IkXbϒ&З݋0oO9i'ו7,|#>*?t*F$SӸN tj)矐3e-gQm+9j'c>I!ZZMKRb91G=oCk\F絷o>j蟼jps|}nf[.$KVnj V[/bOHG_;@yAyX1?1߂?'ֻ?Fl&߃o\:yM̎tRȁcOo7W4q]6b6Q߯B7(J~Vt|5FTL,_4D6ƨd5y&JV`Z5Ҿ`c֭-csow9qU:)ۯ*5Uze+.2vkn)?˜u6@?\SR{zR9#xک&_d3_?:l_|ݫ9?O,l6Q!oGF!'tβ+21/kR)"6{^$τAO>yOKx Ͽ??ݫ^ lS/$?Sl#TYD?$ՏX/ רȜˢ0h[= ʵ'h SOySry4s:ϟo'^~tv:iVsK{I,;w+e\ Z^ZߪfGjj)bk=<=0 Xl KObaޥbMZLꊿU=knq&0GO5c\S;͟Ͼ|D\$Q_h3/[9H^eʟVGVY l풥Oe?T5E<[N<9 /&=8!"c1} KǻId4qi^]ğM?s:~xQ$ǞnAkh輙bOg; Uxx&g䏳 O07?o陉c7buK?'c]q?֛PCה?џk::S>p埂wyeQ?Z_ii.sQvԅozwX?o}9p]ڌ1}^5_a?j$^OG it^ ?] \R@92coН=پ?22ngӽ uߤ,~Y  )5zYg0ofZ?ޠdn?~N릏Re2JWRz@z fFa#MOkZB}ҥ؍]}_k_ȃ륀z3`8bG ׾g5R_be[tk2z̦D_Crq?O\#t/vŨL35Z9=Z\_l}0OdIRߋ׏يc&w }XȺkWPhKs}C/aSMcs3d 3h\f9gSw ҈D9E? eufcgbDQWt"?1Sz$?U_B;gt: ]?#y}ϟ6IGO:r`ޏLCuYc@NJgzmM8nQm~+/.z32iL~4s?z?\ќe=e\}zf -Z7577Wk՗l1,0Kq 3Bƚ;x=*OK,RS?q^1Ŀ{ Z[mV;g'zO/3R_zgQ 3@G /aŶ171@q=faS?hik,oGȷV׭|w8_1?u߸X}e]Ww4W=-'y?vcCL⣞+?!zT.Z+f*giuL^1G"KLj賏Q^_}eⶦaOK.fco>+խu??&bΝ7Q&N=( O\_9>ۈsFoNo '!? 9 s}_QV%8kN#ϟ1gq+:~HoeS>p?^}pPO?־ۤZxzz`KE 0tխte₵(f7^8{ʢ"uʃ]w%? )۸<=ӋSݓqX߿VuD?;+ʻxpqdl׷p>l(ݨ#&~o|So ?0c9^/P'͍]_zu?iOg3Z\OOt{^y _x1?:'WZ`F|6 <u1i g7z pR4яs{83V^*>[9V):'\'yX-{6S,G ƣ277N${g{WuNHa[|Ehs@?i)=#H7=g?&.{p~ǪWotc~Rzd?KL4.zM]?Ήn|Fo/Y t*?uV9Y?w]87BQC]NgeՇ ‘\c?я} /Í_V^lw _⏾?|FU#xܮ?>a|ΚCVg"s8WDl/1*ߔ::[.?fE%s?,PO\IsRJ'f?ߓ0)^q;=??e+~-3>__7y]dMw-=S-OϿ?!Qacw`~ q:n7>]4Ƃu?Mu~!߳T B˟rά9eSL(\;#ߘVLQ~!I.]rZ* %cE⤾WaN[I$7O_HojI?DiwW^!7RG_2H8ߌnG)bj猣3sgqhN^TJut K[sSDO¹jȔm8R卽/ 2/ 'O]$[ՔNw>k_7cߔ*E;?^a?~Ox{wfp2P1!,K8sfZr`|Sߠ7V_ߦ1([g叾{˥D+"s)}?~24G08kU ά_7ن}?+/|?՚'&#g}Obp}CtK=+ 1x^yXU J)<ȇ"!%γRJ5,5jB!Bpc)H({u8B)!<|q~k-O=܋I=wwﬖsr3Y-#!ωhfa-My#6=XRސZ+^u\!)yle.giE9#m[AO3Ζsִr6s|}{6y|{~yIm ok2玶m:be{'gɳZ|lݾa= 1-r%Y^d| wr; ޕ}bc mN7Dzf+{Us u^n~WJv'l1+?ϐIFT)Pߦ83w堄+nu; L!o1?<U&g*#w}}' 7_a3wOz!c|8; K??Eikų/%/ݎZu`E?fLg-sț Ks+FTd婿DO?&rKr(Q?:sG | ?ie/p,IM&/oKWG{9`cM^\a{-׆~#1ޏϡVh{O{,2#͌;Ez)T 'sn0?o!?pӻ0o*# U1{|55 7 o'WW}I΂hvu5ztȿʋ#̹[7ҿ?Lό?>#?Z9Zwծf0_LMp(ԩ"OeF#!Ś7_]_")EZ\U?j.\3$G?2ٳ֌&O.LIiC?!{zL]{ǭe#iS_?IoQy!! +;gF̨K>|w藤Bxwj*Y3!!'$x6k?p ak3'2mǤl"6N~F6x)³0.͵wu={/ r?[4GC *mH$\לkJ>{u?Cd?c Q5/WN\3Z#@Sf# yfo_%}@VZIȄ^nbw&s!x6V7[+ Zε?=7/-F3?dS刿G)yKCxΕbšf޷ߵCdNSeݭ\@N߼'"RⳭZS{ -#I~n!0pMɑgD}_øܜ?c~J*V'vǿe}eo?Z{o/_5VG}GL7!?eZ}n:{gk*7k7N8s4z%7tj.Fofc)yz|7+37RꈿVZ9L#-͉rc p_G|@wO͞}D;4V[e51M֕0Կg+浞Vo5߱{ZљUl˵]?'^`y6=)j%gm֙>OMG_5-^5OhcQ c}7uvzˠO$Z%"+$5;qGw:!d*,@߾̕3rT94})+ZHj\-'D5K:iZYG ̢OlwY-RL"_hӿt1kx\[7*3 %37Vg7Mu+X5Nw2n}?J")eW'#IS~c?@g5 #&8v=S9D<5}x#O=ܣ:{u8AoWnj(ֱĜy+|뿄#{kU\[I\GA'Y)G/Iak_Ϥٱ8zr_w&/o5tAV*"?} yO5>RyvO`:o7)Zx5KFov9H>steiNjP@saC?܇h (aZbf7\?8+Zhfqlil#ɯdSG]";@Σ3T/Z0?l wpa _]~f#p_wgAt?pL/rdVRV=L1c͟^iACl7@w013OE?wñ?Ć_o(u_?QAy$w>/nn'ř!?HI_X͟h,}z ,hz[a4 Yp4ϒGmo}.=lJլkVwV7sY!_Pij;Fq}$Cq3?]~G ]U}dt-ɹ?mA G% ^c.$7iQvHnsBiZ'YnERu.C7_tȠ !~.M}[BJK^ڊ'8׺IU+jR˄׈i,F7ӎ,?#ј %/pțSsS<-MdSqȐ?JoѓAǴwj-;/YlLSK˳6û=@!ܙ?}T!sĿkݯ[237 W@%9'$$@OsVX;ΌOZTߢ7!!e*?C1ҿ6` كCjSI<>_ ْ&0<+^*3U[Նȿgg4ߑ;ӸZMP^ߐ? /8E)CxG&q䲯hs_w9ewQa)]u{¿-^_z2\r '. U_Ȧ˟..)QȐwP.]QGB8/R柎_?PV}bd2?j1sݟ^;?ߊR߃(O6'da7?xWjG\"qzS"E[L_r)Q'1JO ]twIb3?nS6h?TK87:s԰uZ%uܛ3BNgܟrE[.ϛ'5:2/I|Uii< >PZLؒCyy{%k}/=?fWֿ:쿐?;QCWfk`z{ qDL3y:TVM#$9L?Lvw)Þ1!=;ў?q?M iH]|;$_Z=.+qF?d_'/͍%یWr?IrI;}3GUݡsS3iQZZ9lK(7NG\jMUe`8A]X2/I^u8/xRj$2܋BKGoJKs׳$=7$?XCýEv_7޵CCS)7gΟvs:GoG3~kD ?fE ggO"5ެ&E$|.C˟𾼤ϔ\]{5ʟo?3Z]ѳ_ _K^_ %ú-?bS 5s=jh̖&x/=׺ /2?R =8N?XI?{r߯+x>M(aMb9WzK6bVg>i# 7JLN )_Iǽn}8$/0/G|N?qn)i_{ǥ"hi"K eҜ0%ЮšNEM=Ϝ j7Z7&_/&8 n8?vn{˴3oÐ??~o|_7來6/J9A#wܛG8EP#Q?~ |#i~wcVAc/)zie _6fyw G/Ҽ\jŶ6BmH&v5OӺTJ{Xtt_Vsv|[Fjm^ 8){曶O M]#ma-SؘlZHͳSoF+;t}Җmy{*mnkÆ^;#xV6v^@ۗ"VkIZöuI]+e2]3tkaKk5m=ׇ6.6ONhl獕JѺ$ۂŶݷuPy6sɥ%KC4V8ǟ{=?T^+offƿK_ˏ^[Oi}l>?olfe[;k- qksO@ sklm|#Q]%Z?89ʠ:3ܶZiePH_tkK46-U/~>GiſsmWmlnb-+;~.ͳيQGj N'Y?9,,Mbuk ~t~oHד`?7mӛՠD񯜟gSʉ*J3^J {g}̣NfPύej37w\e,Tgswp/ge,^7Iq$cߨ^?~k<]+㢚7wTS_-?(d/Of/by%r-Nwz'& p&W-SӘ?aS:Maq &~!qO;ߌfdՙ* g[k.MH\DŽ5k`ȟB<>R;獛 g\\ޢazÔ˓+_X\L4~_BIuJIŨD{_6WB~_(*j|?u27}U?ysg6{E3bg[ϩ'[ :SIVOc}^~/g*5i1꟮&E_F]oeyY8-L?9eU|C0J{R37U)-/cC8Ck k?/h57ݢKyO?o?zh1VNCWyX'< ;x1Cȳ}S2H9 :|-,WGgF}΁P;ߴZJnydED z-<V71LKlnׯg͸B #/l^w}ȁ?uǺ\'&<.a+L7xo?QT2LOEF*{?Yw;* XGzѺCL?[;EgGy/ZRQS?®%?y{ ?)wl5pɟڲf4qoM*xfH_=O|ygWS7cc==G_5b#jύep?#_zK׊}@ypg7l2O?dt>*omd0U?Q5N?e3gz0⪙>>ߢvݑ?0Cc^ϪI٣U)h<QmWs& jc#N1?&"/fY9Sݢ%yl'v/̷=ͳyHV<VsW cӃ;QItfVӛ꿂}?#r-7=cpF?f-St Iui1?\OLO߲)2- ?kFR*-S8'_y^CeQ^3?Tr=FE%%)w|RvՀ<^6bw;wܵW%¢qa?P񰑏*:tt$Wc/ c9D/TT S;ECV,gɣSg/Ҙ?gkVzJQ?99Gě?Tol3~ŨcK#2<-co3d~G2"_]G 1CSϟ>2qg6UrTL:$nO] ?kJyyYOf[\mv7|U?kvZ[_+'4NR=/n]NjSg?u?ϟ?;RWc ߘpȟв1oM\QG5mmD'4Z&$16}U¾(!H\/J!rR->Po9/ߴOYEF聱#Wö'%~h>Rh?f|KyR1?/`~/?H<'&|,3OFM9V>3꿣w]X -kerC78y%{uhHge;SOǪ?7vGy6a!M}נ;-_I5bҬc}kgȉ 3LguM ;* ru߯:.,G_y] +?4S;y/$pŬ+YSxLW+z?%Tw}*?vMFb?um[̟?cQ/]/f8VpGRA!<6M[xQ=Ԃn fAVAlr-_C^T{xR{TqGg WG1 Y y4//FgKZ3>x]Gp $]_-ӿNϷNks"?+v_8gC|e7||O6߆'\0K)ӝWp,%2= O=!y9=to f|4P_s8㌊Hm;xwB$ jx?6ϸ^p4wrxiU_'i?z?g1}ͪSx^2yg?~FuS6ߎ.sr}Ntϒ2iIuKugwǩz8FO }\;[ԯ2],];KgPoQ2L*{/k\zZ+e?^?vc?swvjpIvp]f`7$ӿIs)B%y]?!^X ϊfFlzPm7 Sߝ#fp!?gs_^* Y`j;&kW4_=!qOOSy?QjGEJϤ ܍m*YT>χE}0 1,3OM*%炼?>1p🖩yIxCYGWĊ1WmN8Y'G=]}o?YT'8+3QfS-rz8{e=X|^dz}Nũ`[_I;'=f7o&Bw>4?v?2k > oI!zm_>0ۏ35w:qdz4*8R43~-R ?4rH2#FB&x{`vzn?Y^wޘCMV̱cnSBkpffS'oYrkp 2|yH?h7#{j#Xc]8*[IטEzQ>pV3:\U?AL-*;#E S&qZnP~ߙ)*TCy0=D4p?q8̟RsgGq )rcߚYBwD_C?GY71H}.~oY^7tU˘?ύG&QsmwǽV<'|W}Op6_enSg/|i2Ok?<1U>h߁?2/o!Rw v?rOx Kc?5 i+dkIܼu#O>t?U2:O(f? I?F'M{sv f??8_F-?{yLUU#|"!)+qM~}f3P#ϘcHq迎.Tؔ?mǷN Pg'ӛcl;o%_Ζ\*bE<.c3wɿ-VowǿRBx^y\T !J )PsW#Rj 1Jݗ% !H .!Kk\K,חRq%8s;}s݀.ZS~e"*m_ةM{WNϴO Wo~ꎃڡw]k;kˆ Ҵn.Vu|6fݣ״iA]զZ>҆F?8HvhƩcڅacݦi؋kijɀ_ c `0"Wj:[U?w܆k,-X9oS7m~Gj-Jh~bqeqP[E>?k'﮵,}ymgaR#럱]D߷=ŋ&snkfXUѿfn mxǢ^S-eZ:=8=,5Wh[?o0_Q-bv|h~9>Rf-Iystǎ?>sWJ[yg^X͝Ӛ?|>FLʳkJae̯'WbOtRikv*+K: b,b/䨳pӗd%W?*߂S;x6jqB=WI_к}>Mlfggo/gRWWaA;Rb[gV5}ĘYb^(S#::Ѫ*H~ϩ|Jƌ#~OO*_к%Ԋ?[;_;߇e۪+™?] p~B?r{ݙNKjĜCrzjf?R?/G[;F@?*?Vwo=gĺX{a+.ްKE׿}H/K7+_(ymj?i[+Q$zը5=4=4+OZddm.|)kۉRn o^C,Gs?2[Ə_*%O٠=B۫ae)̟g q_GJ/e/Y3N;z#"4k(VU-=ֺ0*???jD/,迠u?VƟE{aV>\U8p*GG]_QXx>A ݒJE/[*OISTW욙 T5OkQLZmWQyzMY.COɀ|;D)y_>Z O5JH Cd6#o3?;]+I 񿴙3i\Uz"󧬿Tk=*Ďݐ ͅ׃Zk֔YE"*NZFߵI#ҖCP*VOfzWG=~0_/33x m+Nםdj',86q~?*[1}@Ib\zbMd^j ?B*R^3[ȧ(IEg43?§?J}cY IwNO/RT"ɗ5n_)M#﹕>{xt$dC 9g?]/<xH_W>I9dMF/OܹL{ߢ$\oWCy]>ֿ﹧&7ɟj_MOH4{xjj7T?s5+Er36p [qo3?}[Cg݃A*"矒hJor!R>Q>G=G3~?m>S;RTf믟Ļ-ë8qv?9Xt~J"L-+NvTgΟuEey@^Oˆ\wZSu.R޷?qoށ'K##_۲3;xkfoX%ߪji-辏S] e{J@?to( 5U^egkWP;3K <5MYiV/U߱ 4Ov_,0$)kt\zj '>'tɼ̩iM?e344Pн7s|m/\$bVZfelZ~y?~?s/}nqI~p{_% '}[)I;eLղJc+zFs\_utpӂ跨VOkN]߿$V |`EWnSbbp7<儃'W>BJW ZC5ͩ_Jع>M;௭u?@I_}+Iyc إ_?2r:?]^>C%y.3q//L~mle ozZ,v矻km?cZ:yo?RYjyȣq==̓~w_s ϼڥ_Z Z }ӢZV3i;:AgW~9-^oZ;]s'WbzO5p\3ߨvrQP9ϫ Ɂ5黝43[[oR_/tV=\{Ћ}m?WwwU_OQ]ώ/;c3u;TStSzh3vVSW]_+Ҭ]ߢ;zj3c.td23mqNteaZ?XMbP#Olb G;?+k}ɀAρ[>%S/J>"j7}JVT]3Cj1~K'\Y)05صYë`Ks s!- Z?+vRLxAn_[fy)~7Ӂ̂DzsyFc?ݗn/J}5fߕt!:߆ ˕yk]J{O>1>V_!pf`_ȿQ_T?K.ߢrO->3f}Ǟ?73Ɂ=?8 5sg8̟^ ;0+m^f}]ԙu)#s)c~? ?j ӵw6쫌uzK8;f8*c ˛;m0jm8 #Io?cGgo.:_ΟGV?D ύSm(2'+(u]_8_9d[?dXg8||юtO<aIigg^_u3bzKߙ]w)܏ &V]4ts-xږn?}O!W=#sYoÊ6ۊOOWV>RuOkKE}(6kgs& y0!'*S-iB?l諾 :JY-1kz) 5tĘ\*BGpIrA_)GJnɁOG/ )YhITEɗz&)Gdz?Jǹ*#'UjfS-YTOn3gy|j'NwmI8WqKmIy]?z _ix^ X !BK !w5.?R>B 5b F"!B u@0RB{*j\pGK(%J#s;;iG}+wf~<5Q:^ng}-i7Cvƿְ 다!G%Ϥ%-aYV7IǴ zs{*2lI'U 8,4=Rlnw'g-^`⪏IsG[>4V&4_Hm7Ƿ!7xUX>R:ϲ㊔l!mi85gafJ \oS/Dbo|ش_nuIyҸIωMYtlpq@Z%i$m(Ψ62_t!){6LaZ6 ?Ldln1ٴG]kkc^84čoqY۸a)9ĩ@o=C篗g)aK'B/[G_!Zc/KkGg[ Ӡ maaJig?UˍX8b_/C }E_:3--jk_[sOȚ g"2e^"׿Gi^sၫD? ːT,3sk[PGȿ.r5 LCw*v֐k 3^,eQyL1珺FR'k?/xt8egGg0?}3f 9m ꀰqYqn?As;ZC~ApIrX;=9?yĔp^ey3- |/x9n,C-Lɐ~Ɩ E58`ݛ٨jφϤ_?xE+CY->{l;޿G?qIЫFvР .gE=#AS?p6+οtodpdmku_N>])[PG#ϟ?JZG~P_n ̼.G:YR703ߣSL?QB}dzyzhh{'mN?f0ORd??aEY?|GcPc,27?mhhҿGR1"?xX_e ,s \`Lfa ?vv?< F;3˂xg+CEnpp_{_VD̿}@6bzg{:0m?Zwǔa([("y~?EKZ%"ӊ1Y-?pz?{;  %2`.$ dZN%U/yO {ln9c??TӕMdȄ=/mZ,3x"YG1-LυIg8Z_)o3iWvMwmA{MΟ!N'(`veaY?RU;NZ:G34^˟zwdp&M^AhٹVy5-ΔSvc>$JbwZ?U0ӨQk<Zjia >ŌNg?{`^E:?j =(yy[T74ڈ19xߙBygx=c?e?3w :?/}2+ y=:¤u_?MbpQz>pX?v?G@]>g'OL9!rF`XȼJǷ:sISN)';e104rWwɦWݕ2> 3f?9kJsAaVϊzQF}zW)zd^7Ndvv$<5|g'!!Tz?ߓYnwf甄lTNH8b* pSӷgI̜?葱Sr3g)&;FcKTo%FC'=䟝䑺WӇwp 9|./L 9szǙ>Q_2('4"хIi,| җX__1CTD -; mqN?@ӷ]mŬ_BR??k뭿 oZWO5-?X__Z`PMbGA-,_X9]<gk\i?%p`&x!<iP5R-md\d_q-,4N SH`*myNSAaV/@3<>ߙ뀠EV&ܢu*-V5_/OO%9<5__r@ ܯi ?/2?.d43\KǵP蓐-ߑUb/(5PܵGl?\ֿ-dsvo%lY9ߺ{1~')q}E6/tυIϩ`8Lf_7`-Ol=dh+3IxިkongEG&Iؤ؇WTܢ@UKZ$kHG(W⟶N(a5//;T?\;#?؛*oc/uu}/VU&RN?q6±3G4{!ՏT B|#_G?꿶0r:z ?a8Zeص/cK$?y?ZCc^}hw,Y.CKF'U+NpTW= su 8weJ!ɲPeׂV?cJ ?jiJgg\g¼d3ߓ'Nǖ T6W |*n} vvv=#D=e>܉?_|Aq?\ѿנ3N%\G?/q -8/VBZz71 ?׎Uln?f)mO6I{%\T_d񠑾gѨo2E?J鯉CC0- UAǪ y?f2d8)T$Lז'~*?A~brXC}Yw <t;wՐ? 0?I6޽ {RmU s(et^1)x/cD~ + [r_~~\&Ĉ16]~)Hf,Tbɦ %#ѹ^(?{dfTű}tgۇ2qmr}vRZ;ѫ}yU?jMJ_;m*?-k+QnqRAolvf)\VG?+ߤ|K8-Ǔ<% ̪_=S{(gDT |]WΰoS@8|oYkp|䏳XUpH,謹%נ\"-|'>]c'< :_52^:kAPZx-?k7v4*x{+{I'%vWC(*f?FWO>S?]ϧZ{H? c)o-7xSV&zM^K!ֿ5mxV_В`圆 +迤E7[vQ6bi'|?وo\I'jp{E>//8wv/>DtG 6EO3gw)v=$#6xB3Q-jz _,ݓo_QT܉ѡ#\@$'HxOjgk7q"y ^?))I_QqVA?jQ i%?$.j~GulqmI%SG0 R5w9O7|?!vO} )dro[c?'k҃R%Mw0_2~Ri?d|jsf77?IeL}ݞ2O tj>QD<3.sM73"ok:?ô;.\]~4d>Iv؜? OEl EWު?E6+][̦?p{gQqmXp/ڍYª'coo5kk:p5:4]M?; R#go N OcX矉3Ο2@]{.kf)hu:bE;K9u7ۯO[ dhLAos'LG+>?D(Wcq=,[;8o?0}ֺ_l'=a.?>~Qa߽h ^e?z!ڍy*/jYhߪ̺uĘS]پο_l^K/\7t| q=5Gg01oIйt]"7]WҢaV5/'?{KS>@tɟc]?3)&oa6R&?9w䟽t5G8-yMbiܠ?s?0 cv27{DRj?6vՅ)+|'R|WH^3% +$;l)<NW Ӛ_Z󟉵#e_w2|=?Jվ'tlLA?m/#0eM?xi?:wo?g0O$[t^?/CϷ# l H.gM^PcL[{缽u:?y=8^Y͚!{3!>~J||)Ρ`٣U榚_?֌a#Bvϋoj@_wJ[7??ЪE?W۰uCAzgDn"U[H MCcs *֢ B?1Tα`OyIYQ!_3z&PëP&+b82.؜Ӛc^1q/6n V1zX12v?$&}2"WUFTΟYQ<`Cj͟V!*?39M5Ϛ4U4QOْz.\Y&v=q|1S~_p?[uu8?S{G T*IzV[=? ?e#(y`+[+1IeZ|Z's~U%rvW k4GAVߨ}?݋'WA/ӱafO)9aK:5(k;!oOE+t7xkV'6:a׽?֐x9_x߇w߄QtN8k^{ƥr  Ozee.l(ܛ?K=qT>Wa>8_ҔO}M+c?:-| ?5U/˗7|Sqm_󚟕U+]#CF?WỢyy߹-fQ`{x3$?߽3"gZG#Zvz  >Ao?>WO1͟9ko9:tj2X/Pk?V/؄{t6J?8]d~麴SG~[tW{Z!/ frw~kbM vyR;OJJb7Pu|iO?OCa.Y;U π?'6FS;FEyؓ6C8Px¤?Iz6]_Z?-y""X?Y:%3|nnk(Tivgz u6s =:j;ӿ^y;sWs؉Λ+|=b(Av5{0k?UX׿$tc?޺DIѮAO_4=cٻtwco1?ʖʞ+rxjvElN揙{{FAھj-SGS%;2}Ϡa'Iӧ]Sv0۵G9.n.=jټ):o:ˡKn.^9?+K[ӥ?ӹUw0u\Ku¥ŜێNښջ'爑>"EUEgQSޤsHjɟUZ@0m;+ۨɹq鿰YYlD,g}<_z{?/=/. %x?d [7-Ymw0ϵ.}xok[y;ĘQNKqi؄xFxXhO S?Q|j:Nyo+v[.g }ݺ7Koè&ϖǨ5ԑ>?co>0צ3}[|pj]&swU&y8o:οQ)ka߳Q^?`?w$cX5T?է-[8֖a6?UZ'gwg/]ôU<y,u<% Uur\wGTo\2y̟?Wz07L{<^K[6{cڪX1zVk5N86L{wUOSO?~_oo ?(ejoYOmdڬ?39W!O񎖕?er??ָ[`ԧRwO7WOx?yRǺR9grgxMטuQDiy!f%z' _gnxû/hюuc!b/ ?ybd)uTK?QF~wHH7LS;o ̤,U?ă/zvxeM#wVk ~yS>V 1VWt :Th}'Ϟ_0Oz^[k((E3r QGeӒgd;+V]VsP-~O/[ÊcT}UI.~J)5A?F[U|܃9:?N%&ckpRzGfg88$/׸%8O>ϲ{}(}g=1'ÉA޴̋˒/%;0ɻK{>E䍗d?VyQq:ܯH)Mn5쫓ș-RrͶr٧HlwtT87NN08j?D>ev)+@ؓc<*QPJ`kvJM.piX) |q>Eo;t G k?VEϿJc;Z^?ׇlp]9exzV2|߿,e89V !â:,og{M~P9.k٣s|~j8Oz Sz sLo6HEҬxLR06ȼ./jYo ^/`4rgy!f?:}&9qpxDjOR?˜3 3tK{?u$t Z}v1s~~~Rц!NaEqYOť9v{ ߹oZIg_ {tNk tKĭ ~y^fq I8xKG?$U7ǷOF8GՓ!{ֽnVwM&JF7?%F?3GǛ/2kMߠvZfx-*s}AUVG#JmJ78֔[8>?ϺГpČx`-x/uRʹǩR&!Փb É˙MQEu{oִ0 Dt #-CnJ?ؘM iӳT`c?VՓj?;׭dozڌ|q~ ˛ol]kwn2Ep|;=)C¹rΠM5??x7~Fs u$\bH ?XK7S䟴zi??^RK6 Q_^ޞ+Q#+ȵi޴~Aӿ5MHcr Tfv T_eAmbIMɿv,C'|(&|&Oѓ҂K.WۇrwnTqG2,Oܜ'jӿ5?|v m?M" L?\+ӿ>mI?&4{EB&0(,'?Vt?f{ÜGS2a!ɗ6W,?prʩ]5w `.|+VPDb СSMk/ύÜ}T_2L߱ϺZ¹JJ78vc1OSթԿg[aގHC>׭aOza݇5WN.7UUj\D'>&ZkےKxnP)xSYki[RUO̽JTߧh U12Ԯkv_:=/^+^r3Y^?.kcYCK$];M s:?qsSL'/uX2pPS6EGPL½kzoMku/z8PL8\3E >ԿwKo}AcWkM 5[-\ZF%0g5_v 9n'$DfEZ.w-#lξa@ 6vaw+V1./faǿz*NyVk;/a_Ϻ|gWryO:#=x_p_%O#I.I7;﷉XG?g m;5b%ow`yq3c}Λi~%>),mVw(?w0Nv vLX#ǚV%,(tpz~("qQ?Γܿ]gET߉Z(oxR߱&YU'Y96! 今\73Y8hǸH˔ `.,e ~oMDگ_^9%cxҢfaXa /3p%hҸ;:Q OKZx#sYou+m.]7TOܨjːZ [-YdLoL!M'K] e¹*x}҅'|(i/9q 5lco!-^_KXZ ?=?>3?#M,Sq q *#<^~ȹ{NZY]Gg{VO-)S#]-;5̢o"/0c,sV |GhZ}#߿XjO^[k{~n;Y9bW˿`lqTݟ> ݯ yiTߚ++`Z553X&a{a{=S/τA"{/q- c,{~봇yqS_:~XiYw9kl̑hDa]$i6^fԫZ'sϋHmGklA&4#p,~h_\E]\(XD6^=fws>O$gX0#LZ[$Y5Ϻo%ǚls2w:j/B }UtLַVU#d #oM{KVDU__{^ͿE=1=$b_Y9! {h?P*g['XfB'k&X(qO.C2+*%BOpig_`C~ekE) N%x>߀?gt<#M ZesQ??FOX柼܎Tɟ _W3ʬ?p}kEPg/qOOEp1^M ȟÎ*25u>Et4_O|W,?5-VT zc TZ/3<ޙ!Q,F1$%cS ꩛?qExi?q]cKP%fAGֿJ?qZoOJ.?-쁶ɱ*=>݈ EDX'->ObJ?~FG3V/?}BQ[*agǾ6f"Y#[0X_olO5+kbkd׷;}@woOͶTg5/QN$@,ñY=HZtJ8'/j1<YE炟/;J™$%Y5ZßgajuL^C$K\l|yʀnIzS˦õK/bg?۵^?(ߚ`^!˟!'cd*߇DtA1F eG# `ɤ_o?u{ z'>V\?g'2j\Y"!X/¹ TG/&CǙ|"s_Fi纃kr!q8 kgn"~9 k y5k˰?缧7`j9c,Z̭J?78zq}ϗ=?ro?S/|F-Gf"ʹ-)EgϿ[ˤ~'su?8vI]]R|+g71اG,a`vPs?_>EeB eNaϿy>N_FY}縔5wj0&8[#7kO0̵_m47IcR]ľ3auif(<-JXӛb\MuzT0Y$-/Wll">R?j1'Ǹ`>',[{Aެ^T ?i8Ȟ?g5_x[ ~('wgZ$u9|>KsG6j#,=[0߫柯}7$kkBʛEPC뿅ḌȖ$?Ǜ%Y3xִmd,j%Jp HJ3Dzw̿.bx=X_e%)k8P-^_{8Q?6 q hriMŕF" ?fi|Y)yrF&b!iuEgz9,ZrQÙw?ܯ{g~O\ Np]%G]1?΂YiPg>4U3c>5W~WOO+7_w!>٢*Gf~d'u}߬aQX5K~ub=Гku? `~?_zry<f_s?z:`9_?8=-o\j1?>7ka\|ٮTݖⳛwg5>ܧF"Un?V50gU}\\_oFYXOM.'n~]Q= ?U̲NରqnQx^y\Ǒ """d%YFpL}ORM)/E4 YLb1+!.{gB}W҈i}~83wn|bw~{~s4}IѮYzw]p\̨f+_)':A$E]݊wxAq2XKR]H֏yRMod=.~|{#@CR2ן]]ں渲~PU/a"tkTEy <տuͳ,}IqBusۥ;}G;5/mmk_MʤƓtr?c#}IJ策`tm _SӬw~m3>TY?)SR/FyOuA?Y/ ;QfߟmTLͭsAI?v5cW%{V_np5o9R?u+jD)RyVmrQ]m3_wcHdz qwt p?6y?X>X4@2^|35Z+T;icRZJLSj<Ւr\6DɪoPD6̫+2;/d[Wr&6N[ћj^64Ć,eze~[%{ȈaldY4x0!'] SU?vt!S33h:(A_\<4韩F'EV漦z' .T;T0Z׶z=AE?)>CVCӿ_sY ܤ$iu2Dpt#튎AKO%Dvf{Ÿj=\q][9g2=kGIt5ygq"Gǟx?tU%J_;}Lӿg&9"J{FF mˏO9.s_C ;tl [ƿ7ްlO6Ƶwds!2?+p0,o5#Ty\W7N{_>e C/3=bc#oPV?,\#m=dfyݓ*zHyZKiɟZ/oiTi^Ҍ%"ev^ς?^/̟~OpՍQKU{KX7ꭃ#%otx ^s˩0OD~Cz;3+yw}YοLSp|(T<{QB~ZFybQgċo!xy)]|i\ްh?nj'R\=Iy,/B+!<{>bj7Qܬa`N133"ˢ˟ os'Eob6ŋvJK5V ˏ+#X3x"?+ ?p?O /iF*G0We`ʆȈlMRQ W5K{PZNW!)=Fڻ*]ٲ ?%l`A۩n2/?_~}|woO54J;e[埜׮9zINoIw'95 XO_rgݏ=i0'TfsKZ|W]A?VC}#3B Y;cB NNrϾ^ДdPC>aUd?(.IæG?5+kVRϿ͛7Sko7??#SǜzSok~E}}z+YHi=3*7矉 xRZDYNMy+=ֳ}*GQnؔC)U~K3_}SU2l6W1W9:?̿},'Yo)cZm3Old Ͽ,󏙿qԇ3_ҺJ5g-yP)ૢ rfxhGO/O=f1,{L+:,ܺ?_v=ݜ??$utbBmiS?WHA7#S.a|zJFz^PE50Aq jrBLY搜?ݩxY^fqV_?>!f}9gtIE#dWd-gʂksj/usaoGK[xlOύm0J^uE}cV?}4ozyrF!A*+Y}1ꑆ8om:韅ʂ{0/_yD??Nc?qֲ\{P?&mwY Oe-/U(zIx&u|U7uK=z/ޑ/ǧgQ:|NR9лaM &}ɧ yCbN >#0KgUūx?-*埥Hz:p >,L}K ̿!W$'$*X?lON}" _?h!?FIϞfONXrZ.=AU\on CϽkr\?Oܺ.o-tӐy2Q5\? MZ_m?gqa\ZZߴE [ƃf :'ӬP/?]!y~п_}1[J '&Z?3Rٵ<mCO8;|{`~-繓?I:I}ǘʂ?}KU? 9=7-\;b?I2}0|+1(?>.vOXXAS%O_'/(OϾ!MfUs%!!c}?+xoɯOQyן<\I^Cgr`WyKy|+(φM;ZgyZ%~Kxw+M은I[?G̨D&ۼ0Ҟ؁//+%TWI{Av/a}vA 9؋DZ\-Qg,$w㴗yd}O\agx]''fRZ̘[?c۟Coo $>R7cG}).v$Ãu]]b+d8 HOfS;GG[0Af;?fD6Tw\L TM9N?co(<_v:Wƃh?O\?GX_k/3֘/sKe1ثSĺ+aw|B?Xטr]A;`m.}fIN*P0_w;]X_S./ O0HeɎSQ]q0#{ q'TGqor)r;taRDvCd̏\nrkޗ1!t2d;`#]D{kۍazH/`?+F-g=MVg_ 1?sߏ[f/z=Ì~_x<+uL%\$?#%͐?fczqEOY {O~2O$]8طgwPZ|~φ}={>֮17OiHΨߊKyGMYfHֳyx=V?k?a |YǾAsG[OD|YbX O-W݅__YG%qܲߦoalv 1 )-byoxRA#=hǞc3̟kGXOǧWOkN__'6<>×z\oߟ]ϢKCDe?V;eG'Ii>"j?`GO0amFJ=בy=ke~Lf?D'+kVc2OʳQaZ/aFAHɟLvo 7fOj Kư5GdƲ#_ Au>U_ >S OxϢZgz&/Wp{wO=OؖW_IRwyR!R/;迭Ǘº0eXꫮK7?.E?D}K5 }uYeWX[p?jRh]^:Eذϟ>({W*z׏94Ɯ yϊ?zLsGk!}Hɷ!dO5Ul揵4EA&SSŻVWc䟞2棂??2_ֽOky_/L0iqtI -O~E^^|w*iN(êw!'nfgYsD¸ }Pl g=>9 v"#T?xH2ޭ?سl5eem_,诚nU4\F?0t/?c//1K*dS؛?kzxQTBΟþ4)LCqcx^y\Ǒ.!C]p%C(R< ƺ˵RK^5-B !R(5K樸hBR஽183g?QΙ}' UZlg U zZU_`+u^XxL {_+9툛xG NLӭҦ }}0-9 g/=ݪDrnH3e/3ܹikeڥM:6cKeNбh=j;8ncAe:1%ƹjs V?*SKhwHt &󴩍iY;k^:Mߙ.zqZ+Zż}o]͎pƗ rjk[/Ɩ扻[.k#)g['+%A箄ϧX107N.϶U+)yO#"?VdLy)»JZչ.tξ>۴ Yk>qg*1>2]6Q?,@֮_8%Y?l+b : 'sy4z9H5),jo}"nFR8c[vSvlLHfժҞ2uw oMu3sj?V®*vYR==_'8M?rxt99MO?gpMW)?d{ #]?7jqnͨz@o5i?s`)U?u#UxP뿴˓:$Q?8mCkS\wR0UfLJO~Cڕ_B@Ί_׳l7n9~7_حO5?6_X4OpOUɟW.?)Q6c؍?FgCQko &6+aa=d~EV;V$(R¨zlPF_]PuAE{:Ԛ :'eWCSBV.`VпG?U񠕊$V'{xH4ɟ|U,? o?]H90Gau=?q/|Nr?dL'WUicjd)[E+GW[?L`x{':$ivg?IIOk[#g|Jϋj ϻ u=b[F sSıa|NvoI5xK nG^S| [Ū}&|CVwٴ߱Bcֿp.Y2T㖿'ỔP6bg6ttl,Pg0G&ʘ?[S /)N;[;F^tO=_o|Ο]~#Q࿧h]ʉtfy(!Ͼnu{S/E3̥.K4?0u__WkeDakJ_99:3 uS-Q*GgV 2/CHK)?s +??iszݸrK}W{S8&y<Գ^ޭ4wGm?ǃ:?5a?0G*Q;[yzO.~_?)dNDƕ K cZ'fSVI5뿮~_k]/O^z~#nLʦ.,>iAHw ߎ.Ss]?G?/ ǚOCK;!@LkM_ڥ:d{iqe|JutʷG_6?hkx|I:sYҍ ;x'b^=qkߨ:tPi\ۯ.:6`tʩjK vl2#?TzKEŁ9y/ WVIyʔ9)K0f-Kժ䟾>?{Nia8odo]o~gܘz@(z;FSFb7T3jni{< ~-C5z[:7WύOS7Οv3q_|)u?iLC!g0ȟΑ=nb1gǰNoOUG'4? ?"bKΘX"%o5_?PQKVQ2/qE$V7yvLWQ9Ǟ?XOu/ I),sV߷Gϕs.bvou/_?P[o$%mw?Fg6 o윛.k٧^S.znGN;sM;q8J뿠~{D~1I*zmqǟ(#De.dLP*}ġk+VЇO|躶]yL/}yk]tKO9кr;(yef)ʣo̿MJ>߳SQeS3ﳪCWkO/%+%a^s{̮& f/yNmUxdPV|&CEnq ̿&e7*=ų5dw[/S k?OtK1v<̥.6Ζ;)_=[Qg̈.,B\ wr*yϗš?-O@ T#T؍C΃ٶ~^^DMdX?gFiӤOg=k]s?hk͟v?")+'ߊf*_lxgcGOb2zRښB.Sӿ;Ҧ\߭ss.7#X8 `^Qר/\ʭ.>k rgu=?}Cќ?ϵ{z'>@5dtj+?f=jU=Գ5PT+e?= fã3›I{T_қ[VPd%uɹ=oc=}}p{Fmd\ V`7M9n˗;WHq//ʉL2noښzzV;[FN:4_1y2Lu?Mm&ȯb}X#'9>o?ė5wS?z&Ϙc_/rnL)߀rm["ѿT%cE3~KrbG~4pJҙ;E?s~f?پE&;ךnY yP}Wl#O_WiM) (+yjWQ iMS;?m _ԛG~iz-1|*Pcy^7ϪU]%f<,6¤d\wH`^ ؃ߴԻ_]]꿲}@=`ʉ#-ߊf|Cy돦!zmHBDnh俧$j)bFyEᅥl[oKW_(y]W\c1˜V\ϑÇY0FX+'BLmRv-7r5ж?a]dhX.?0w?t[oN._6r1OJX%}y{z52N>|yn1Oׇ{?g +[ޣIgzmǨok=[0柡dZ 1hZҿnv,cߧ5Z-/D?xؘ3_-/3ڊ^޿;Ah mQǻa꿺b?ryaaZWwlCȒInoH}1cʳIԖ55br_ MrڱJ'?K?b?Ȗ?wO&bo?5G|ߥ웤b?(=:?=}Q?M]$_<'ެ7nߪUX_XB]Ϲde;ʎH?rcePV_rzlԶ9<lR9ژwdx^yXU׹)! Rq!RC)H%\bh!PB !ggB)PzHGMAZyKA66&g^Phe훱?6vjod]>C}}󚖐LP[{ W?qmG勚>ZϧlDDI& Ld^GI5OQH-; 'D1w9/3A.Y%]WkF֙\Kh*v_pO&rB>nãF͖Q9_8cL,8_13w;5 ;vѠi)A{7'M4Ig}=gOTH Gw$?0{+3n.'[V.C q7V[?,4ZSkFs??]RWZ:`c BG7 )U=Xge}bH/u0+3=xyfo"֟ѽ(fL_͢rQZy,#s_Aꭾ4kxs7U5mEGVv -sسW~BM׵{-?0y$mf?!ҏOnL/0_ onֿ^I5b23wW+eF\P.sdn`]3-kn=Ja uL_YO.z huzcֿ?߰mCcf??YiV?۶mcPsvfūIꆵB,[ ߥuw__/_e mkr6RCw #[[kq|YՕ_kQnFzVzr ˎ~o5f3s)٣䚒:7y_Nܝ_=e3{SM+'iWO(M5D,Ju/;OzsYnGDסN'?/7dG=53'3qH.+sVsKV蟸9ʡ*z-^f˻,}ER'./]󟙙HzU-b?BMdX0"ʨ)U ?C ? ^,{q!\nXҷeW#]7̈́?X/Ol3'V?q ?Gw}^i*)B5f]֙mCw+dnz[wg:zsV}(Ye X?]et"yspϳSugѺM~D'SΎt8߈=;ߑ7;OxQ? gGb?)K}(>gS3BFk\˟?0zMn[?YY}xKwT$ fW9]"L˨gW, Yڕ׮Ycv-?cr<~zr/?q^c}{^fR'ouO? gYRT<٪b_Cgh?wƿȝ+nnt- CZkEniȃD:V?SÒO˻ق~/Lk-7:h_֩׈zJw_>tzbFt3?--AvG3z ?;h Y?Ya|]3YyG.?5/!y;/ef;󟔨nT-=?Z?֙y MaE}rЋdz<#\KRxK,FOH =R7vF+OnꧏYhk`B87{'ǕsX'&J..?SfOlseiI~ $me^?Gh?#Cțvz-J e  ǜR`9}\}m?i>ჹ&!jit37zx b/q; 3;\H5#ޚS盇l?=+cplsޯ?ߘׁm8ȉ__2:iBshZñߢfYfm)4:=/ta GM<%5w/gfFIO',U`rwO+g^|ɥS/_YRFXe!v% #!e<7G钰\Ocvf_?d#e?~#7f'mgߑ赅Q1O6_{/p\Ts )/;!DU5[Q$F혧YY9oY@?~Km?*7)Dø.o&։<^%_K]vSfާ#c/w;D?-Y"KAvQų:R?Z܃^>sGe'ޣegΟO3y!T8v{%tQ^`t*@StOn1t|K *?7~71>ْ"֚j`ɍb \n1{lc迪f?{#O5G?i֯Ӭ[Y4OL1Iz埪ٗ^ˎ+?qK,/Wg+/f Bϳ#?%P䟕OjOs?gGrֺD*ZǨ 5617v?1S+R>t鵊9 #nobY;?HߴN EjkGT7cn F;9TW1BK" D&tא0O:94 "7?tw/vQ~R*/5L tҩzv;I&}v "<bv?G]?+;֪;j=u7e_K_W"blN #l٥|bt_ەtxXMk3ҁ^K~*"iMz;A1A؟pRwq{E&g\'afWuj?=DCΟ|m:sƟ֑c;Y5?^E?س#^gr7`'OH9`-ϖgzΌ78R蟲%QG߸ 7 ܯ8K?5[<fKh̴?3/ ΟO{w=|b6Oiٛ%"R?` 5m9pӋzPt^<󟨁ȡʻ:Q2.HryaV$HYu?sH{a~~?'fw`sܡ矖?O!â){ rP_->Å༯䏌wg럤l4x=EJ#}evT.8o[awT]IX#pϐOHY ?4l8;mRb_2Ӭ7Tg#R+9~O?qMLxB^bߐ jvT7޷U4~'zJ[Mǃq63aߐ+Z]l*_uSA_%i{uᮘΫum Hks\KqP#XK/>Զ'ws{W_.Zdѡ&a~,M,ԧ<'?8sK|8gQvرfܹ qg\_3+ߒӿZ˶N ᒿŻx״FQu ?4ku[Mi_)Cp?RDS_t/WA?oa~yxg!gg?"a<Nf?*sXm$=" L>X)]^E zŒw-FYkJY5D8?#z qPOA7?,gU'sstjEO=#晾SN:f8wss['k,Hc]8 ę<4_'}H9Ɵ879XrO3*ߣ^v:??tqFzlI){2e[j#?<#S w8 OxJT.ݟg/3#I'r3jbk2+;|pt\>F_Y+Ŭڙ4PyQ?փQ qIN%q埓#?Iz'z'/vw:Ո)a!䣿F`Hi9{8O3[ҘO<@= ~ coOgP '!Q5(X-?VE*T%iA'iRb+oYQJI虊rMty(Ɯr2]k_-'v//ϟBaHϡ1@#IHky%Sk?q v/6,8k}ű3+8c W":?VT2?>L l19sks_M:#oNG--f#"ZQ.zUVŨ?;ΝO5TqV۝rgY6ߐqLT&OM Z?2OO: y/YkvÁ?r8^[{[) _D8x^y\Uulj"""w w0 =߃)k1 cq4M}"272"b{=901jniσ3眻˽>y>ސ8ٱ)mvK/g\1ii v$&9wCt6W@k#Nإ%;'拟QߋG i}w{pBsҙxgA«ڭMh,u.ym兇΢9־ꜩ=u;ys0QYM{8գ3iNY+iC P+>VL pL[ǹmG!?&7cw/SGõV&h;OEKO4+ɡPxK!gOo"hVVX3 1-;)'ǽ.M6>Q܋p&2?wE`&'œtݽ_-}#"$'Syp?}>ߓ+WsAF.N3B}?'}NΦ9jsA75)}ըo`՞k'UI7KٶO%s/SyO99}qUG v/π~~WbVYqB6[GXEKa}Sss5*` Bq/[U/\G;;J8[z]rV"7>z6S=I*|ѓMST/ڹ7ܸDGGbT `r/nxHI 22FgI}SFڭ?!?#E3 M\3G%"F?}7Twb7̟mVd:D q6>'&_":Թa85埶9'>n?STL/to T e+3㤣!w"H2OGONH1io # 7t;;V??Wr)9tY,XܯC/X+W8G/bȓ۾O?8cU#bl:ߑϯ^Y?}?'=h'#+A柟R?<۔ͭK.t 7VRie 8EVƨqU̟rPb..T,o%-&%&?]R}!g0z.N߈<9?ۊk}ЗGS?5ގuSt)_4?/9Οkucb7ͩb)8ִ\?H_,T~TR 8 ]T?]KKTs3?l1?D9V׿Y;' {Pb^ [:iQsb~-1|'B@EwZ?>LtNy=?d}[{G'$E_o#ٔ߸??z,!mH}"x}_xC~ l{"6Anꖯ*o;;gБ +Z*z&k]cnt%bM3\3BQ&}?'jVV {gh iף۾դD駟{1heos5=H܆vBa?'yKKVF 鵤ޝ^5Q5_[?wNõ);çc#MKϬ-MOiT{ xb 7&f|OT$+rwWsTw?󟎶;*@O"[zE)R_W_ 0|r6 `C|jC'&m? Gv}&k_!M/6;|*LGP^0gK-<ÍV]ZnۦYv 뿧%fo3Z /m,~P_/# ĺ)g[S=ڭAtFGw7_i#feU'1$-."nb{7VSmI%:k4SG1^gqGet ϱ٪4'`:sf[K?L F9$PŔPBw;[[ ޓPon>'ʡ<L<|Ì'7r,H*j Un ?abɪ͗B5_'/Ak|^I?m[[GE]EW;t[w9jn˔x)=8dƗ*سQ}ǜ/nd57pOC"+;3?i9*eY&#['Oqa]5xo q`wE{$#w;jv̟x^8G O?ntZXuiN'/;+lw'%'U -p)%GX[OluV-}]\NRtl$QF9\1caCpo?zcOg_NJPOÍC Yi[pdeS)>ˣFĶZ?%GT_k} GP[S_]#_}b?Өׇk4_oxiJfkY[ۯnd~Az-_#3scaf)#\?c?:_+MXe.c H?-}oyDNNf8cM7?l=N/)I|UKshmɪw'm?RRԗTp߸+ݣC׊?'7 2+ ߢDD[U{zY!"sGqu91Ki zi[gZv1kĢ/^qn/bV*k%M]1Bo otA_! ʀ.NJi=k[pOggmꄩϐ3U |J򏎾v{s2'?)7R[YߗmO/f6ݯQCitqO'3'<<&ߤG[z_#TZc~zm}0y=oS=$E+']7m¨WJrOyV_HpuRßa.gQ|3)?ﶵ_L CRs!i^2tgP]L_x,2#C '9ޏw_O~sߏ\%GGYhLZiqzu{'_~IȿsKZ'_qAe>!_$_Z1NT-rܰC\?xת俪C?揜3R?'s?f&xgz{x[b:EsnR87nJI;1-_{V6mA.YMIXkn3wsc7o^aOo[Ȇs 'i={ϸqu?q/X_tr-$y*Z}#zozSpekFt:i|G Z4v17v`9S6D%mحS8<2{{{C{9|=Pϸ5|j۾8!8MӲy>-uOlʟYtJA9yO[;Fݶx23r|0j g>_/k,^g NMyc0?Csoċ|/ykI=p"TXݟkE"դϟ%6 ҇>?&9R%nOV?iK _+R=Wn_Oj?F^%U{q{۾O?]qĹ2QsYGqC5=[ϟ)Ang@z),ގkN=._E 7$>3Z_Xi<HtNrh eN?_?ӆ~J;cr|p?`],G*矃6~.-IS7W{//1?/g?1؂m/)ؔoﷄJfE\F-yf?C:UJy6"ޭK/F=?qmToL1mtI}ϟի\%,%O>a:$ gq%Ug}ULr,F#zHހu+t<Fvkx7ܸ}g%8OO/.;/,ֺ8_&ثUЉ??˔(s@';G~N`/?7Lp)T?$3}fr39CnCeS|u>n̔19'7saݲx7IkBϟVVotL fSML?u=ۜo/3|taG&j_-?Wy(u[E=]iSouW:5^'VuSd묬./lGossA Qn[<ߪc_Z)z x^yXU)!$(*!B {b |>J)|B(!)8ϊ!B 8ϊ"Rg1Z3]<}޵>-'ؼ"umW|_muSٱ?W6K%#U-.o\6+*N1y67ǧlrv+=7O&W'9gж?+J_o&ŸײuEj)m[Yt[̾_v)_/8W;p%|ƮiXr ֶIZuIRe=fk<xV_x9#V>{CʀJ?C󥔔6'yR^Cs~pT.L-|YnM-V{m˾~5{i}ܠ lޢ rޭ\\m꽵AYUSfIH9 mbC_u?o\>?48Sv05|/.L~6aHl R >7a>H_O^W?-_>ZRq&?̆]ڤ}{>ÐMi-;ڤٜsq5 ? 8q))12[NqRwppxW?.˝:P,ŸO`aJfO1dT ;ǿ?s~WgoUvYӟaE֝M6s_z:U-P\ة6^nHKӎ?:Jw?>\3.G{KTXl;QP! ;pݐh+QTP喉_'D%CMs/328ʃ 3A?gQ+c Ȝ?#Yoٖ> _PgTܺp iNPO?zRTͿ`ws*QZ3C޾n~EZ9 HBb;C }h%h뷁x=!l|V0P3g=B/.EU2s\bޤSP/MZ9ˠ岾Y5MoOBEa?L:b;fjC yf&MͿoSƯ<)o'RwIJj݇=3?GF}<ر2)f]S jtV~7fv^%y|ѿY\fo9}C ;22M צ?_/8jLg4󱰰׿5M_5jQ_?joVi2 N߫,P6,0?$by+?F~.W;gc̯k94=7K)IyۅgY^Q#s&gfQ))~NCA}rYZ|6㟆eoXjA"cAF?(.M.5x~9ቐWMSO*n*V_^ֿk?,.r5_Z?Y[㓩n[/f86t~\jج鿀U-2[9eW A=P ' I݇?lOn?!CPG6f)aiXh/`ʐ?榝W?}?Q_ğO搳W] 1PSlFPϏ ?!GbZ{MX`di]/L5H:)?_Y/_z8C'kY31Q*"`}צGdȄ-lJu?zt;2Aw/2=7\Mes?pe?\R^s@ﺍO ?V5cj ,NڰN^9ndfUiG趭 w_/qD?ǐP\Ws8@fA.m[EtazG'\W[xPuoƹSG! 'o0ҿKYɬBa-RHBOуi' 4֗_?ϟs {GakNj__/(_VN_?=/?YW'?6_s_0 w)8"[OI]@Bv3vosz#,7#=gRd{/`I[f&iMSC>y@Zߍ?koQ 4C. WU;vY6\9`n%K{!E#M{9$?}xc݃^^vܹMY=ۗS &,kGg*Ό7Z?fcRR_>b^O-_z"-2_?W|[[3NS79r?b꿐?R}AN1=zkt1CgSfLb{if5?6Ҡ\g\o=j_fu?0 `eA%k8oЇo]xAm4k4wiA1 kw&- n ' q%‡/Ht?_0[ 9?'Q |Y-3ׂ~'ş"fZpz^%x!s/S]#ۇ#qnԏ#kz/+3 _+4o~ {w۝͟!3E{[ c.*|(`߯cGZ9k6c7L?ugr>=Y<n[>@ Л(A.G>d[fZ$Fϟi >+/ȥOꯞUp4L[;珹*z&[faZZ&}`?^`?~'oC߂o48h Xkwpv,4_j[S!GwFj;gPI 7z_MҜ@o15d?Ep'䰫h?^s#'͚I6|,OYd4^ 5_28sVl?^zs\䟬 gVP 4}]9A)<M1ƐG๎ R][ƴuu~7Pkq{S3Ht7"7rԄŵAWJ{>nx&Uלp=9a-CVW^5L7KӬ[DOJ{rIjE]JYE ,VPM-?go4Ӟ_%A~oۺc&G8 ' .#V?sx g?7ZfM?-_?7l;jxKx-_?C9.v 򟓺4{(zk_Ba- Z!73f׆eZ/ʃ-\`cnk'9?-_3֍G?D=+w9jWr.C ;3B%L?oq{q/02Nh t/>OgT!~yM\}wՀ22IeFCiW[fWN42 _>"f|⮳]5,C "';|CQLj}5Nw'_Bel! {lfn(DΟI9jcZ_ty~ 1gh|ł S4c51S[*2EQ_gkY낙* zB󟔔mS,kdއ.`H߬Oh 㙸?Q+#}7wg&jOͿol _9%CľNԂgeX9,5{!YWk/я 1'řOl$F&p_/!wK~ku%P ڈ=#:/*?yL.Hr0ng>qk`Mc rqbCz^/aO_p,_kC!]_/}E:/C.x筸$XD #~dZMr!ydy ?4>ocCe}lj~kSi௯wOmIGzM&Y䵌C &?X[ӟnAcMCGqooʟ|@_nOG豜!?ލJ}M[/u)|,`Y2,K9q|tΗ4-'/ 4}8\?ud|R*g27'rak1a?=\$)s R}|ZJK`FmPRB(!k-3sAQ9gw~[,(P7Rb~nQ[I}=Xe+?d{YI|frWoWRW<J?mJn~ؖ謪66Ac]d ;XB{}MbT^Zcug_ջq25^91RZ|U5y jVY{Hy{}o%0Uwt]'&SF2ZDYh;X;l5aWUʷq6_kQQsۓjHXZvmP?k俽E+Yq ۫-gg1ο~ 7O" Hi}yB nQ`Շ̱UA]SϏ(8ضZ]Y mLWvB2*1Sٹ=-Y'o\Zt}zb[_^S37O&8'X7[JX.-Kd#G,#S`'cKnJFf]7 >.S}ȂT,XrR/f~@ ?#uioʸs+!7۶!g.Q'8=T\˸K}ː{YԌx_b>rԈJ?2\6 W3- -T?׿OR`` ]/Dg b2|,V~_WiBc(@:YđU p b*]VpõHzh'~1ʪg뺔XY Ԓz"?b[}1JxF `Lw4LjfG^/b?Cb`zu `Y&Đ?ߪZ%6UI#,/eΓmw|j_ Y&k'_?zV?q7PK \տn 8Ɛ?cV kz\ 1Ͽ?%M$CluGyGYD>k-󦳲[/CH78fȿl=^*h'00Ecʜ? jbS9rܥvɡ(ڢ< OȪ5s8cȋ?S|1C?0),- /밧۝̞#w(~S? r\_y`V#IKgJ˟oYWW< > ׉zET'MȿxLW_E[_:OZ_P[;9%_ֱIL@ض(ܬ(٘i󧍍g YDOXMǨKKߔ1_"g*xYZ#,*lr3Y)s q622 pBS"{Fߨ!:y s'ȖguLg)a׻fX3?]dk'$swsRuξEϭaf2_hr-krTc[*XZ>V/*60׭_Io?\GK3A~>YM#?ȓYh8!ZHo흅z5PVfU z&q_yn ^@z8e5;>zoXʡw6_z.CxOhݠ?-ߑŒGOr[QYT.,MaFSD,?y7CލֽI~XJ?"MB>Bo8}}&?dx?Kx_b{?!͹VS1@uN_}?cم"N\!? ڢ`vF{ j`rurHl g_9j?>Og\Q,6/$j߫{zoB֜t: \Y]25AκFк'KV^zRE&'3eΛOBfza76Qc[>dZEO1.6MwEFCt#<hwͺdw<$/1cAo.?If页ybBO9ȟJS2-J?ouV>AsMg͖7*ƭQ+ۋKY GV#Iar No~'61<`?҄q&izqϚL?F򏍍tXB7ku]IcP"_ mCOlv:7Q0gOS""+;E^}KzB5c%j.LC_Xt'~0Q;f柠Y7Gw+ac^%Ɍ#pZi(ϙ9a<|iO,1ǶՊỦ݆?1:컝?~|!~'@Qt=72v EKVϣNZ ʟVm_k.$KGA]ނmB͠j2?B gFsxVϨNk'.s?dlyއ17m*,#i\@}Ǯ⬽֖glmEW`}?Iн6| ,}"uʡl%DӒ\ s=ֳKKz*kG~Q/zb#t_EgA?cy?l׿\zppgO/FoVu*`G5c?u#fCߕ,H m'pzBPCCuN[<*D.cfI!ϣbjU; GW|%쟵=ǭx>kIzƾ>[Oh'%]N әC iq%,8=o0v>9"gi'!{3_?j3}/k/.l"b?1?\OsA{=h=uVq#9/~#gifgeQ~O?a]E aVL#Uܓb߅kb Hzfye|' ,=_94s]P9c #|`MgOtPm嘊tS Gߑ?z;GoY/N:^E72?x6\vkNh=ҙw],CR/?%͐B#ch66R0gWzLi#w~%=&?IT+%d>5}??7 ~a3q)kHYw+'se^:t=7lInoΖo5w,~]/|_^ ?0fxђAqʽ@F~s?w[ƳZn<܊}hܽ ]lusxȔ1OŸ=zoMd?1Bگ%<ϧ?_^9j-gF_{?VSqFAO7'㝾f8U_-ʟ\cRj8w5'S_ٜ%2m?qLXUs޹Y\Swz:o@ba6o's|tj?q?m{Ẃ7QE.b*:,Do?H-XE]o৺3435y; ӝ? 8 YoֽL2mli9<ڟC''+HsOD:q$OeެDgjjƶT?37 VKȟtgg[=Tfkw=gvH3A^.({53S9|W׾V?@^ĀT<#;.Lm# p]2G;K M +t sVOb PYAtEp+>!!=q=C4rM/"ç$CluP;Q?z(C%n6뿌ɘS߇v)ϠmlvZ27_SfE}d&gTe b]?$O܄su}B =Cgjfʷ17r&c<;JxF?q]?gjֈdA1B&,vgh)? e|,_V꿮PE_m y4xw;u6Y}{ξfo?Z#Ji5?CsLWp6e远 2}[EOH?? 7%d_ʟ;E3޿_p>wi{A_eabp*<{CZNω dHu',4juv?sEm,[Cȿ8U[!"1̣F=7roe{7y !5 [9 c]8f?ɩfgkԝǾ0{m3<迳1-Pˋ{RG~*3gVV2?>=%7뿊<pNpWWh9@> 3 cĵU[{ErѯQ9mip p?X]C!/eYs?\7T߀; S|>c1")6*#50~TϓϜ?[kjG1AoS/;3xi#Â$K>64.}%6]A*$^kvȾ7 +t8,_u^`L(u4-ll,"=w({X8'^m,;~.o1<`Ps OFC ܀K(B`P'ݗ- ϳ̘.3EJCۆVnFM4gM76L<$ύflB8ol35"hBßZn=bG;QR{="7yɜL+xߴ=˔GJ.AKRҗrf|B%ΐL_0* t4OCrQW jwVWp-d{}קKFR߶l 9CsҸ'R:p=[yPwFY(;qbF "t^N,#';3[zM$ZT:;95Ej[ )Y&zOtNB';Pd Z&ve*k?)c-bo/!;C_lKu翞pJ,הJFw%R?DO G:1?Ngʼ*G+ڔIPx \gn'?9x/ӣeZ ^E:a}z o>Q?.[/%/P䟗c ,bA.Lx4A z]O;w홻eoKF%A@&ɭL'?3?qVWbג?Կow?Gm%/vA}s3XӧQM?U2/˥˴suQB6=d_?p z bhs{DM|A :_J:7b˓W-'.)Ȅ?7~dj{wXf{Od,_R='?:ʶ*sBYZ7͟rQ%݃?f j?yVwYgg_'+rIr9οt޿_25?S.WHcBm}s!y(loRςQωab_:7?b9_O?I֕H*X#a^TtK鼹lrSk0 9Ե\?F9?Vk,Qh?x/$>EEB)TOrwV<_A.?7/OL8,@v ȿhDU?M_?K_/zwJm'ӿnqG2qDǿ假ng>M9iw}p 6#g;ǂMe6Iԋ?ip\RnB-$Z<7;u_}߶*Zg:$WjL83y"= xAſhL /Q>ά[P%ك?>+o*Ȥ۝:hgǬ2tUUw?W:Ă3"e"[mT\JЛIgfiQTv^__Yn# cnfU?aOmIZ&\'=}B_gzsB$g*?e/ zZٻ/!GLQ}# Cf4wu3(U&e?y \'g`?un_ӘlTaw?_)Y3?HYG9ٔ*'hOſ#OZ> >"8|2hove)?..CSm4~*]q9'4ys\jط{Dl"b?y"+z/U?:ʟ_06I):쌉 ;`۰~}?qǂR%yKq1_jdŌ?950A@Q?Xn5o俨g g'pmLQ yGnL]E/?}Z Bp'/'?nQ[geن7X tV+A6;RfTzQ%/UйNchS3NW hg=Lb_5R`Yٜ7W()'4ٿ`F͕լEs_KS*/j?sq3H$NQWUIDA0?D?>[gCiE.$8PVLV:0*vڃy*2*U7tKagcf v n.Ü(=򿖿 z "XmMӺ)N$%_eOwLϝ#k7iޫGmB9DAX>Z%:@6tDV?<Zގ'T1A'/3I߲uϟ ^-8G180䅿g?pΑbYw?J Y?-lpU-? ?x>{}eC?6%X3 ӧ}dUgLZ?#?Ŝo\c"ǂgOk3tg+rѽ7!ɅĿCؿ/v,CwO{gmbނS-Uҡl{:qJ.g{JdVϊwsspzn 9r=}toCJ=cX zR_<;jn\Q%>zou< Voٴ7 p=%\ִGgOZ}#Tz^dOZ=\%Kw٭{[q|s?aI=a3'{{&MsROXX?j?N/K>>6=kgȜV:LµNmM/H<4q'O wI'l_Jnj\b[{Ǚ? _"/ᵴ??uRwmoD9D _<c:ԁmiߓSs}䏽<SUhmCrgHI5*Jʥ'˟zLEy?2 NW_$IaK F5_X/1 8%f|luM?ZY-03`6]SQgz?]/iyxc/zߴ]QǙ?ߩV6s:$͟You< Ty^PÃh`G?dyfx^y\UeljȈTԈ!y]s!2q]"#""IT\bF0D}K<9sTRzϹy><7eb9U>!ŧ-[crd?#C).rG5Ge,.)3P6;vjTȸ;RHNiLf{)ЭpknK=jCyߣ؍ #OҸV>8?ܞ]rt7q%Yܟ,x9N>=EAsbG <#@>+ZdvM9n.ʘj93*3?oWW_߭ݔ.bTX'4A_;Jz6PT%Iqoq]rHzd/Rv_,((k;MUΎVT; 0M̧ wR? X,d1MljU&OG-XFS#:ɒVK ;fbT)ّ\z2x}H|2+YĔ֮`b_ǂCߨcV{)a?ᶬx CYfYmہ\ZH[ Qz(Aso:#2/_ê_U%I}xLZ%7uZs$d'*ZwUr:RCz?t A2ѿÄW_Ov:õ^'ﱪ_{/+^KL9`!{"iZ=C^9GR2OYC9_)o I\* Di:oSH!oSS7[g~-w`xTR=eU[/T?w?g-'6#V,\S2XDžȟOkʨۜ^'?+9eZS;o+n<)[Q?%n~ ,ݝ9P 7H'RޔqxV?v$=6n S?:Sim\bY Ow J.MX}J_TEuM|ɡ(?F:/OY.?iF*!oTN!ɛg|Xxn5XRz%|XPTZb32M,g{W/qTOۮ\*#UUm%!`PgHrHg6UІN̜.Lړ?Z՝2Og7>*3&?ʐ?G W^hܯW ,j>3הWEe?#a''cIO{nDWM@]ՙ'꯻;L$RCSzGfW2ǴVڒ-gjK~ *Ol-])WzR5"a]Q pa̬[lhO趢c?6,֮ƢD213FŖn#DMk|!N4S*,ToOѩ\zfRRUFݯ?2k{/abE;&i|ߐ?([.ejb9~!L?2䖨?} wlgsTO2 N_CT{_Ǣ9to!HH!= 65>׸EB[&QyJuؐk|^Q_^fȏsV$^Y- sA*zfOȐt-,*"M >w5GoT zP ILO-?((H'|HZ %ZWwwB5ypxZg㿡}zOXeȟ%;Y_}Ta vq??ei57C:՞"N {g~SiO)KwK3V1G,Q.nck]'Ϭ2{lr6C59BlƊsxSܞEK=UqWze/ v?TY阦z W|FuA[?23(KG 2 >2>rBz!΃p (bG ??:?nE?;kz{^jv8+\~< 4|_1 C>;3HH{@gxŰɴv{*%8GmgM\O,+޶[w_ <;鉫s{Q')KWs\|G/IV_qݟsVNg䶋9ET}_ њ Ok?^g[w!'ߣ7mCz?x}%KYջl_?6=`.aOzcbj7B_z[@+wgqe_H߈~?<rvKO#UK ?T?3I6OZo -֏0KW='Cei󪘿Zc~1ujo{s|J?>%m G?*9X0}߼/F׿ Xyp+.^Gۏ .[5 |fkF7Qw17MmrBg_./t ?vcƟRZ]mKi"i-dE5:h$ʵW[2eC̿5߳cټh0(͟J,7^h5w_L^gz'C_4/?eΑo^yh3havOhϩm;shB?Ab Tc-^3p֣+?neӐIbnq1? iS^>%BkzӼ)m?Ğ^OlӦz1=HYT[v0ERb]y͐V,|E?~i?Yɼ)Z1xКJ3QR~3IqIl>#?oJ)WҢPP^ܬyM.#P<9BaȟWNwV?.Vǜ󦜔)%b%kF6j穟#2+fhϘf7a)uv镒w)m{fZ~$$M?0ߘCY [6/W‹Gc2' /:/4?&s$cʑ꙱bQy- ڮ%x?ܧW?`Lprb< dm/d6}{UGkV|2m?멗jl&||Td{C=)/2*/?2g.J{Wwo?O5?PVL,`9^?]׬4os],WٕVƕZEY7a;̿N扸. y;YG r$'qf`?]n6{tφ3_)m^_F P_k+:%/G]hM&/ bqVS؇kmGu_gtE pjOw-6ƍ X_?m?*S25G?,H?u{I& e3ٍSvP$Hz]R ُ l4'5Pz?1Yq;?LZ6HxBe8w֒Eϟ֞]{xS.zpMo︟?ʮ$rr._?s{$T:UY^1_8C][y>Ǟ>nDnrvl?+?2&c~h]i/x*Xo%3w%I?IeW,bl2eVe#O,wJ9zB8~y,,X sߑ_k~>F\`fmBomfG/Z=Nk14䟅l)gg z.6|?o;?O Y?WdG|6?eU=j;a?$򧾏4,dO*WٕǞ=Bl0Ot̯ECtt1;L|;p1?8ȟ~ '6/<Q#=1Mn؊g?>CQ\ʿ4AQ'n?`n߫8Mxh͈?=M|$[ L{2Ug6ߠ[='CVpi{,Ϝ?oM 6}Y9N-?V?Z㏜ u=?]Bt/ZSd-ZT~v?;Dh/륓G>*cHW_#C>nX I/5YU柢S>QT w2xO,ypgxpyX}{z#v+?@&u;Y1J2}g9. ?Ǡp?z(ObԹ?m^܊'[?8#NbnmֿN2;h?m?~AgGLu6CFh_D >߇b&$m=^(YpG~CΟq6^g=Rq۵YYv̟<ۢ?iz' * .u݃Eso6+IQ ;sw~Pfg6Ֆ=aCNS gOkk~{ SϜ_ω[ӿ0|O̟zx?*kC-=U]f * ܫx#^8-Ӂ?i];8oZ˦?IfwШ6uֱ Ӗs̬•q"{ Gg%6?a! C__viM2ib31GYWL&^a}XU1<{K[ 3^ȒPwJS#r}C_Fv?rΟ?) "ⴾ 91EutEb?z>ggr%'E4DOIRZ/3?=jc?b3=ʟ!#&Y3;=wϰE;,![막'c2_s?ݻH166=W^XϿp~77j*͟A}J ؄ƥ"bqT{UcB4C?Ǖ*F^o4 gަ,pGeRf1Auݯ78Q;gl(*49PzИ= {Ǖ=o쮆sAO}6ɣZyOHz_nֿ ?푎9eDi/o]j 1QSϠx"oٱ/?Gߙ so? iBSrrZp=x^y\ RB)nAJBn RK(^Jh⾅J7ܢ%35Fq-Rb >C9ss#ʙ3$CSBc~!Mcg1MӱX膵ŏ?5wO(5})w''Y?ɏ-7b\}ԣΦԣQpfy݇r`|\2-ɻ^6u>`%9}Y^0]_X%L;Nڙ;x.r,tEw s{ [X1[n|`;;w9YuR\=k^+#ErS;-+SPgimT噋?ֶ39׍58fS1 vA9:pgKƛXp;XÃM{NyQn/r4;qo˫efbt*V`\";䟒τ_#߲uW~JdL/Yˀ /% 򯩮6"߲Wfl?ޔpN5GYFX3Y5?!FZnp?C~kDGSA/XVFSI˿=G&x ߚZ $yla˿kEBw2W{Lo=# ο2Fqnn_Ñy0oGJ6!Z Z҂ViqH6CN jy \oQ2 ˈb+\5 >_CZ{Qp=BpeQ,Ϗ8'+WR?1:9b#䇛gjp@@*S>8C<#'^ [zO\9^W:VS=Ѥ?p5 l'Ӯ%>髈;?Վ?2-\Bsyov h-7eµUB?|m{ɡߜ\e@#KJr wl4 |PFAcJK&g7>-%ܬR`88|\fSR%sHä/ޣqǏ''g`̍kg~˴wPao?:q-H7?_w< ?qω_OH(_̞eղQ(p#J?!?ӛF7#k<|(izmҿ/ @vCLj:7j#U?帒/_uVV9wr?z\!*[EGGc ]C9<{}풵 <$=6'nM>t/7D3 9I'?b;1ʁh昲 8GlaLPe6G?p6v}ʑxBCldOܳH7D'P'wI3QJ?l,#x(ĴQG=gvL C؟SϱSTK m75qq̗? ~Eǯ?j=_7{F~z%+򿖿Zp>#|lW2Y~@鿊eߥ*Y" gq? {~\å j3\k&(^|տ[JZ3Ð?isK?dz1" K{K9޵zZl^{tȀ'0rU'=y)Yq %&Cث[x$!|Q 3LqsV^if[c_8'9ܷlLwnRR?cǁgv(zFx?򟓵ŀZ5\RG?꿶iڜ|ϫ(dۊtxR!EjbZwZ?!M&{H6_>k% yȿxz#_O%r^KU%"m?蠻I\ԣR bǜ~D&ߔ;!ϬEG^*_SLZ t__ֆ11GItuMh=F59YAJ W-?:( _VS?m-Ľ96#OLGOhtCwQ.{A'Yk-S #9Y%P_?uHjsHʑ|e/2!k_UsH BTF@*w?'ޘ[a-j9q][1|\)nd썤6@2GHI9db49snXnp/nL!YJ(~dd('j[7so nv>6.hS}vv 7Ǚ <ӧ#ӯ'gdr̟A?d)װߓS~j`#G3snnN{I*ө˻R^4UF(ng~M7<鿼#bmHn(όLV??kK G8_ WO8G#/XvaUlPnr6UFV+#KWP#sKϱׅ? lZ\Ζvt387{t)Ք?vTKO$eE3zߖ#%BNu9Ĭ6͞B95G!a 'aUVu?oxgA+-%b3\9:Ap| {_;{ , cA_@_=G:|g_ǔ5|Oz+Ƚ?ɵ<AϊpD?8'P>WEZP!L$;nRy|.7<(4[ g@ڮu|bOͼl|/+A";-!;^7F-՛i/?]ρ0+dw?ȿ|w(JЁ}տ?  jGl[fk ={ X51Oew`"_z3U7 m[ᘔ?U|Kl^ovGg8_{me 7s1媅٪/F-hkJrGWRROq L5Eg$\5o"Bb3,z_в_Fث_BpS|ý"Z;=/S.)NY柖 Bӛ6@l#obSJauCz5i"'}s&g0Lu_cg?>9BOSFR܋]CkF#כ?ap55jI} {V-W agZ C.$us]*#|8djpӷܠ-7籎)"Sx 2?ttfSX+9n5OK+:(C-jõ0!Ysǂ_zmq,)&S~dӛ\C(XW/Z&C>k>\[qm/,~* I9+mfrk#;r'W&&J݅[fhw-sfN%ҿfZ M+hN=-`miR_?r38oG5V+RkYq?rؿp}gE^"u.;{u2?"Q |WA_?߱*uղ&_a}YEi7ׂg%̵#[o~OZzΐ?GV:q r\|,J~i;y({[@􏾥{g{&ɻG5J{ߗkq!C(e_;J5 :￴ˁkKfV*$xZU|7-{E7jcRcgG?ə-ߣ7ĿqL9\+垺h z#dZ!(W ?[;M1EM=$ehozY1@vOάb&jV˛u75տ2qLJ{q_O8d<AÃe${t_JY2bd՟Xp\ y e4Nzeg[e{? !޻x^ TU׹lj"EP8#J [4%J !<B!B#AA}B"PJ,H)uy@M6ke\ac3hrBM|j)ͥlKfenk_z\ N)V4?wi pkӼWLaM BKmMr|={Yө]$=X2&y@S7vݛݼ{LgȫFMF ge.SlM~ɦQ_ #MMq&|%޶/_fe>>_Iױy9chYGZIP1a\_KM2%pnY&߿#mT6IsyMJJR*TR_8O)kH]KnB^3,rmR_K̍ץp&dT"?JOWB;^ 0J~wiXchJfDK΃l~M'{lssD[d"ꯇM y:RG6!ʊC˨ЧgDQ-߮.G_*_kWo?Zg-$~yB' }+]^/Ssg_dCPG2{/Tu{O*\lš*qye58ND]y s\^߯2)1߿ JV{?Wg%m| #AFVz[w ]/LZ rme_3^;D֒=lss?~o %^˟W1Ipdxq57{RaMv\O/HpVCI?i?+3A?ül=,g\οd_?xгK z9d3%}}?=?"ܴEgo9W lxɮ.,,v.!HDV5sl1l~v?򲨿Wbv/Hl@.܀4H5 ?S*lZL7@uG3xM G:w?? ,Pqm?AϘȟZ,sWY39??.҉ZPXo[XO!0Jο)Sep]o?^x?f=ȟGY?8/A Ztbʇ#{}?%T?wf\T-ÿNR? #_c7LfJ^ߛO3?']v>SB^v3Z?n ?*.ϑ%bxPmbN~+CDֻ]{6a'Z8)y?M˿|ț"^_;?FHȿv3J?%p^YM %ENo.JcVO{@u3'QCaqԿ.9G762=,Ә NkyAQwloQ%XYil?FߣlϸynOjDUm(ɳ8SLv->cDp 8[Ϝ~gVP_):;:wUoAqV@O?XWοa 13-dqNF5\3{IX9d_ ~\qK8\ !?69#f-c=`@Z;oGzN?aR?6B}-ΟY ^,g^s?QQ-_^37|H}?p`Pb/i908?lq& ޗZv29xr<ȇokxje1C %AW*vQ?id_?ŒD*.yș#x1(j=Cw㨖Nw?Â$r.}?='R> s(/^w)ňOeR͞?)=@jvnabqj'LxL kXdNZvu_PA7;<?.gN;7oEL^+Γ8ݼ2Č,oq -_EWRz|!?i.1Gi.Ež?ό_x5Ĭpt?;_ ;JwkW֋_'7ؽ ~.Wg1ýEN_oTW֔G?\[u׆ $_? O/>8Dɪ^Q]6Μfn,bi{:o6]K@tؕ{?]7̟q Defcx(`ӿ^}-7Ki.K@9]3v[1K9y,i'Rp#)1yhxLXLw!lTs '8w܈~?[K#mo6+<'">0!?[e> 2u[Q-DI IUG{Bo4*/̇B{ܫCR^p?BswC3Mf0s?"U) tO^?)^j87+* 8vz C~eG|q2T?f䟼Y66[?s\GtΟwB+׮BJ{@1zNgWqhoWX!;Cdz$lڹoWi8twmvਘ?|;b8U%|5R-X{'s?2Y??ƔUkzoO~=EJ{m ku<`.ea_D|,g㫄ugIe>?3p\z6x,ec|tܥchJ/o4%.k0ê+a{M oE^2ƙoR%5YC]c?!1̟*k-Gsj_Ѭ~4)~? /~/gLtḋwXxMM&5,]aØ`38%U u8bq>OYL5,ڵ\9_vaT3JZ*ϟײ9-F0 E?Id/x>À+ x@}6;rx-$/')A޳(5 F?T/<N84??">@y -s?j;9?:wUgSOКn`iUdDȿx _?O?-(p~"/>)`7%]܌!fH̗,h=%^;Ɩtkg*%^ntOd"d>eg<+>p'/AOtݪ7ꕾs/;X?\g&r7?>>>쐜;69e?Uf,՝^T hz ?(/dyU,}u1,!CU&͟kI˶0B?oK_`EwS;7 vu_z_K~Kb1AoZ#ydR<6Ew7hl.Be=3Op; .><wZJzY5?~?$)~?x[%! ?s!U>iJPۙdb>kBZ+r{n'ɶ/j? k\? BJn%Z^?hYR=clb YH>l g ^˟J/xF7ݼ^7k?;C1$y/Q-8Џ 7/Eo / Y\0_?_"6(^kDQ- Vw|Ia_zws*Z|W宅떴_,!{cAY?Jpv\o\7lze ȃL!3?0R[6ם?gqsA\;@ 4E^0`jgj 3޶kccÌ#dՇo=|nf'x^y\\Ǒ R=)RDDd}/\ifUDDDDJfb)9EHȢILL)R\nR=9 E2s;{~>V^ػ5e#gNoyovnꙑegP&YKna_z؇T77z8K҇gZK{MkyW5zf?T?u\mj=":81p[UZ?}Cz~j se=-ܾz>x=5YlvүEj=짼FP6JuA5h:UnVT'u,5rzfXGuF_o^[}NM?ؾdg?swmGV^5/ kjQ |3oWo{y?snwv뱳ȍ }¹ߕ?RqR]_ԕl_t ꝕcze v=+lN7?}U$Om`~,/R_R<ܞ:Yu|ObǙ>o߫4&/(\>'xKg>+ޏ{ЏI]QnQ6^AY&½dxU?cuYӤ vOW?omڷ7w?);?Zj!~'{fDI/aT/)NacOzvUuh+C x(70hcc?qfgف:W7^y>8qM?8 Bt+7~@/g`x.Hz_Nw_CdK&+?cWgB>ò.3͠UAP.6-Ss0? '4[2/s?eo+iZGGظ{ yZ?>z:-}+&+ax-eO13?0I!_n3 ?xgm2?Z?=e /jbP5*^ɿ&xC7X?ՏFaf81mq6k^{ Wwx\%poBϱ_o2Vz3oW@gWwYݘ>U=N<7Fo3u.NS7O@!dbxи$whmگ%?E1kGSlhBJW+ iǢ~?'OK <<&mZt&3qoNon[3;vvT:;͚ .Y^Q⿳Aγq+z~5QeQozO^R͐?)i7;gXE=?W:X7?YJ۴csgwԓ\?Q]EG }4tǟrC{:O| f7[#X1C^KJ7'uwӭ-,*11.w cz_,?rL.N;>"X0?Ӣf 2 mXbMntп8K!ٷS/y o,(|Z\9qvÚ5+g3''-jytФW/R?K_~">P%'ݺ?nnސzWNf/囨7M_8 e-6HݜsgsSA=GWx9y?#O+8{d ァoCWIفE\K6k/qpf?y?2gkDDnj՝om ^&؋o6HWtdVqnӷaSCgA4cΪO{ֹ~"i(*}9 !ǎnyal#?T3pҥ(}~ծ}Cօ UMD'VbS*G_+}܋3ʟJ'P}Pvֆd82Qoپn濂Ur3RZ|$61d=^$Ŗ$\W|i?zǬ?s-3O5n{̕qpq6ךV`οX߅u:CI+s?oSp6/϶͹Oj'e=c왐?j>b<b?W_3?Eg3Vw'!%WON1i/=Io$ -ߠxfDR- tҨwş* sMO}6ݬS"-|?_ѯ{ݺW^g*yW?2 ]K?Z`'uWr;^UA/-[omϼ4%bJ{1?Ob?1'KeH=^st3S-OY0_]=P٨bާ96<8+OzKw=U"_kyc?k0Oظ|J87Ob)m0Q?KS=f^I?v߲AI^d{W%FŊ:@W94at [S?^:Ov`,wsz۱wݧ'?f$C>/]?K5sEKb\_f>TN۴_yn3_[7A=6Sk`~I\򿺣WH}0"sƟ?SΟKpJAaCel?Lj/vy}((a //_d]B5x-KH nl?ύ6)}>\?̓K<_A_ =<d?'>%]z ?4i"7W'^|rZz swkzO q4J[IJsD)pl>N?4 sts8E\=fg1?-j=G7/`#cVm/Hg]Q? k!/'}8IAi)i%o1fizeu?l_P8oo4eFuoAY;+cPc_z[?1M?}6wX_uӻwNΏ|?%>ӣ0ߔ^~)ߜ?)[&q9)Wxj#usQ[󗝵K+d_ݑJ=YM5?Z^LTImH?܈O#gtLnv_}CYO# @9n8/<gAy]}ˡϿw{W??2|a`\!2O1QGF} {V/8)NȚCO sx}M|C ͬmZ'1n@ozi6ŰL A8H8PMtO)FXoR ?v<&2?!%qv(f1,ix^yX)4"An\B߼+*PK(㖸 nqc-5 ܗG-p8p{ԸyǼl?.f7<; mE׷is;m4m!/wDzϴ;uxWI:wŮV ]Ug?t&DH{~V0}_?gwkǏ[U]3Zrc-m2u k35oj;o~8xz :ƾb:oevMgs\ӒnkjhW*tS:Pw۵aaǵe:sth(h/튖7^-'pw\2"t[HwbUm@‘t.gvn_T½zmWǥ˾jBDuC7-GwK[+Zrq]\U;<=?_LFy+O߫dz*5%?;_lOR^/##~(.ToH^^-u +ge,ki{EJ,LU?*Wܷm{7R%KJ~'f,&+ӿWݒ1wW5_۵yS#6E~0{ bCɿF3VqVwsSJڄB߂U7栉ޱ[ŨfF9]lj{w޹n0c?y?XM t<m @|Z?p0?Zϟvߡ?s1tTF,p̟Q喏){yݠcnsMYr`^`O ^#>/WTWںz3fn" f6;8u?4~o&9q=Cz:Q_CT%,<@ϟNerg .r]FWw F#i]bu=UM:J?Ug;07OS'#_?y?`/'6CT W㒏q}ҿ [[usT8ppC &N7߃wW?b'ΟLx^1ӿc;Zd,fV8:;/;2)Ŀ?%AveXFw݀c&2^4hkCBp8#i?ԃhsʳ,xE<:_ynz&4R1qwY믑6&qdSqU)WL{gΛުUR/Ͽu?CzՃw'VϥzS%?9mݟx"o?3'cK6-^3$\#zCOϣ Pߩͤ%a)ωi^fS_x^ΟF'gsX)35迡e?s[k//v^Cqޘ '{yzG(OF.?!b+tT"-'/?3&*߮S%”柞OEd^}_Ht:/h܍PξĆB?ip?F/+[Ο_ []V:>d?s(?fԒRQHtmz|m?羝zT[C]O>qMs{^QP!?ko Lk嗣OƁRˣD|W|;̿cY8f<LE'銼87Q)[Oa˟mS:u?]+3eTc>4{c>VXULxyN:'ȟ㚞R6Ql? #{c:37F5Hvt.7enUNG_-{-W35M[E[O 15';۵ yE9h5_+{cUn2 vf[]+<]%h|Jƶ6AvmmF/AsּP/"VLz63-?lϺgtOqVoDyN ']?+JBosZ2 "U]z&rkq.}_?1s90_7?Vm/1IR߮3$s!F௭{G_Z'aa;XN5][O#6(/?k u[Eɦ5fbyEb@=y{ҟeVԏw ~7Iڃ!#bNC:TJ6EwO#mAm^xWB[BiY{wi /z쮪ѣ9b?QU9RQ55hp'<_ NuXN/>1l9&mB}S 9I+<֐ODH=,J?g?%uH'MmS"_?l.35o>>.w[rth O4frKKsip]G?6J?"˴ GXoylO`[߿"5(o{>8|'֩`NX71s-cZOL0H58f5sy{$\;#mg_)#SJf[NdNW$d_ߢtnu,kn):?&P*66*tC\(P%:;6CfjB`Oyye/VwCCǾ7ya7Gܓ_9x}<1 [~x\@~KSoMuv4zȞ*{ޤ6Z;G?ji=mܩA< ku>c\=PsYgf#l]}A罧y🄈rWŜi=t\:ʉ [_O-aS^_fenꯗW@2 >GM.E<_s'&юrNrW:_qUf1^)3%?OhOɟ /ZY?Z"=pjc%'y?VF:/=]P{|}&!Pw Xt3z+H0p5OfRUgue?` KIO u@ONlxYXiX%c$_nc/4E?W5+'43^3FzN!BP%V/ސ.MC}b^2?7W<^j?w<ϟbueaпk^ux@?ӌ?m1eVΛߚ= d)[C:/qtC7^os+?!'$;uA:X <$3H¤V͕uAH{?3.=TϗY{`H73ir'B''Y^ɗ#>q[<_XY\'_BҰ3#o$'MkSp ^T?~%W!ߴc>:5A_| k+Ok>,х3%LZ[u/ޗWooiru`k1ODH=X7uF1q5YsqV)&!&W|/h߰Ψ\Q,o|%g~w3x}n ?ix//5 7}4(Ob?Ჭy9 {07{`ӿާ矋޿;:kݨϻV?{?P~z2//Oo?W?5)ϰ|x#V /rΛO>G_喽VK9i⬥?u~ixm~)e^mQx?埞3(KP^b.ψe]hKj۔k9RTA-4coܮ4 6x?k0s 1DZ(Y؟?>8NP_G++02 B^sMBL|'ﺾ=hZ^Fc/\~?MR!@WnyO;SNǞ)&`m;f OF'ӳFst_LxGN|Vf+VIP|I\R&l\ؖ1+W_/6% w]cզK#n T/zrϞKp|>)ؓ%>7b_2f[ŒyUyoxH_o}XO(/Nh=KޱX ]2SLUQRCW 4'BŚCl_Zfbn?, oյ=V%_u sfz?$L1PA~\y[C]Ϛ5og\XR ֩ P/OE]ҟc֬XRKc??3b=_>bI6qS{ >N{yDkÐx7 \Ŝ?+J^׳$V`GOZ8ͭ.N-rWJhf*S=(յ6='_>#O1lEssrYοX:?nې?'+~RC3I?X`#ƒ?~VR~:fa%iYG^l j^ s]ߣGSP?g>7'-Ȫ?2S)o9|.g\ɻMO7x?qr>$W{Ǩz/94 ? +cW?NA~^ Ԯ?|>Ԍ;Ŀ-U/~o?#8|e*+viϭ)b?b{2,ĥP2HOf /9?5=;sg_k Ձ?Ƚ[/ߕxx תBmq胸?^gPCboFB,׿9K#O{ՠ)^E+W)z8%9o?<uc%j   Sh>AiPa+_ߔpC݃ޛXt̿U?l0P u>$ϐǣ#/9Ԇ }N'OIJ=ndP />7uIvn_XKӖD?fAp#Kq g9{f:CHic?%0b6?h L+u7?rsk/̟6?^n'vf?g?5rt̩< k/pAX&f0slu+J ')7kѠou:3#>OZAk0?TFs *s7'o;W zqLL#zHW, ]4HE Y*p;Ց/|Ύ^*i5=;7V+7= ^y9zdxr 'p/r`fX~_H/xU8/2 9?8l qjʈc)_?.. +G{J=dž>C?gO~xV!?$ൈ;y-vX<./ /ő?r4Ϧt pXg\ы[4Mf|fe>i])+ MѿӳF_Haq { TqnJC=dǏ~3d{ɡ[{n=S'fG{=7oJ,f.ޖzYloV1iWMKW|=?=pQ~๥ ·zH'a\;*@ZsP@2 ·#OCvX!,_dr$z_XGgf^ ? i`C۪?AЦVjפ3#?My9Y/p]3ss5!{!f9[RӟhPcB~?86.; W|ٳ3OD/z\1l=0"Â?cxᅭg&5ŭoAo^j>I{ӝ-gY:?Q[rCݫϖ c `Q+#KaXGC]ݹy~P ipFZ˽ _ᵣpne p͊~t!.gv4E/Ƕ"ulN z[j~q=81_3п _;̰+Yw?u^x&_Qގp-E?EM4/Y3oMu?׋ ?a i[wy{o69[ї8 -?q?'8}x3Y}__ufOȾ+.',i䯮9o^? \9+1Gν܂h =;0{Z?{ZqChSe0T7QymdЇrT[w|=ˠmc6rKU:̞?/శóo١ye=,o=6lGP ؏gO7Ur=<bK\_2/`3ϑz`f'ԏv.^_ dD eF$М{Ü=!ﲱuOIckò&4%bABl93/:=Vo<8?;?S\7[BI"+zEˢ\o WX%_d?mP&UxB7Yzg/aMwO :p?7ͣ?^?1ac-믹uWHUk >m?7w̳}rV1:㯟?p60{1ó嵣fkK0_#`jÙ^p'(^Ms\%r?Q?|C > ~+ܺaNO4J(Φs8~Nkn@V/_}3Q|xVِs/ubm#޷ߜ 3ڬ/:|l&Cs"K~/n6 .=crP̈ y "4<X_ߥEvzYi< q?LV<۪J冮c52sq,J.Ǒop.?f[/"_8D}19 qә>9Y?)Ԫ?<'AϿhZ?F݊ѷ7E5?T #!+|zR{uaW@?ܿe4sLz{{xh.?_ q/|`ks!g-?nTAkﺔm?~o'*|{/^}EG_cJu/j;r9yl5 ƃt'Gx;P/fǽ =^m[؅Qm^roΌ Wnw*X_Pzb +IcMRi47g;xfì`Ac^'q6_9d`@44 |7 =8sB%C?~wdx6?h@)ַE'-'Y=C5?.wI\k>f81" @x^{\eljȈIANɈKDDĒn,xûHKDȲD9Tu%W~O3̙sW9gf>E₈i~BXz)ǫ$ q c[I̻&ݹA믉EԥӋ+Ҩ"Sb{L]L 1kGeoou7 x9PS?w0<ݢ}jc|.E2t,=7P zCL~X*n3q,XX9>YYPmyj'9B+ib $%L d2Ww; s%ˌ#izդrfO{v"Xo &?eo"AU^Cȿk/A >9MOu"ܻp^Ϙo߷O%N'iO]sRN֝~W.ޥk{\?_vu^-?w)a ;iA,k?>>^o V;obڍP; "u,;K?ߵ(__Z%pNx%W?*k'g?DȢcwfBM?fə q-fhMIrTuC9Wʟbgǧ)?1 e"cj_5:LȂ/3i:G KOj %Ni)_G Qpmtw4G+w+'hPc_^pk!Q p2J6V\0 w;''?A$_WOd\2&pnW' =Y?d{E%ib)P7E#:9'sjGxouS3;)ZKQk3Έ+K5k,c /]w ,spmw2P56@c߫U^)fm9¬"W? #fr;]{x,_3*LOS I@p&/dTYu4?c;{Ktk!WO*o5[Y6f\@'> pho k/2+zuѨ,?Ѷ4Fžr^99ע[ٝA9;)v[{)?lS=!MAл,Ѐg!"y<@?[osI{쵥?gs?0PvOd*>~ޯ/WhT1+xrY3\8F3Gplw׆Rid= =?iGJٻXQ'!Ry0%:E3}o=^?^3]1A'[!.V?!ۗE~[O8woNUXw#dhf>k?-a׿'Laӳq ź7Hz@fKJ6]z_ߒǔEqq&s:οhlxXz uÚ >Fcsa#柖+)?*ゴ~}-U f?s[G4}A? <pFϩ%_5{$8(W_^u'Y|U+BpO 4-쿢+œ\3/r j=P-. bgOuR@8FںSf;]x4PoHV?S<7?[oi, \K,p,_\{pK)xIΒCpf?oΖ_Okw?O磜}HzJ5%1?^+Y҃cD]rO]R?߉ H'?QCQ'"]>??P?ve4ߧkHxr 'JA+B:awJ?=؆^8Vߵ`esIa3s8-y5i\psEul(ex1MS˝ӒX9 q45ZԿ<j?5 }5VAGD&DqDL*I=+G.LŬB~}#Qid<垩-;Jg^h;㯥|.gރ7yo|Џ`Ɣ_c#1E{>;m1m6Dp?{JOb).?I[IOkqbE>bLd#pO&hb#ַw(܈4aw($6[z]b.-Yc?\k.Ijf]7~2ԅ9Jߚ>V9ܻ9%%H?+zkk dX?x/^=I?2`3lL=&sE8){?8ȸ  fcqZ1CܡA͔?Lb?,ޯTy9+̵+?n:?GCVsl#3'B  gjy4KGȿxmE HG݋]!0!e*{IQm}PO&?nM ]տkQD})ץ}}6{}? rG`#?ǔ?1u-\NJiz> {]$垡f/<]J#r(7R꿰JUϫ5r]6HBךoc`~矢Hꑠ9?a=cFlPy0M:!Ri⽐_FJo =\ἀoPn%Y=~-0 O b]> \8waTL!R>? c|;ǭ@]GK6T|ioO, ث|*U*G>\uV(RkoՉE|uC ȿ#ޞVZF YΪ&lx^QJE$>8W+?^lH)R4wYMs=r_Mߢ1R;]VqT4RUɺC$}N^(.R|(IZI2&tdV'Uͳ,B^a?_f11Z^_gd`I[ Y!O+̿`ߓ!N]"M4WڑVY萷"\?Wu-?27IzVg»0mS5E fM"?< 8]DϬ;k^d1WWV?fjW /?K+jJYf 3 n[~dv?ExVe[Ã_t>-xvVOW?yR5io??gZYOOW% LRz?v3S2"3AY[*9-v?]͏XÃ[ yk0߇Οީ?UOes\Z,ø fQ~H'Zx)>b5oƯZWFQ=6hmR3}5&zk_ ,nTotܦؼz:zv3 e#;)i>\ t"O/{ɵ!i6sY~a=՛$'_>=O#[S;T=!j(gD@~*ytA:,q?\\r 3.gōqiNwsO'QsT$Q0;r?5 g|%/{Dk׏NxiQ[SLzDŸj\M./)@?gTj&b.S/k׾~#} laqMR>抿?gXclcΟC}:{6ꝵxp#vp{c_,Qu hMu!Sh}LܓEcfԷKNy KY?z:q܏m"ݜ;F<_*?CΟ457?_xer֭ -l,*GGOڰ󘿥L;sO=ӳ)G翻U]O?eu;ee6bE`xf~9Ti۹Sf||럮C8>a?j_xh@ 'f+T'͠˪یwy'fwMFjiؠ=?~@"v ? ;?y xX.1%Bzl*rxpvvuGD,b}3c]㪻M?ն}V{,6GgR-۰/c3Mmޙꟍ4*J kgBz'/ݵ3~GY_?y5j^굆32Qko=䨔/Kp SWhm{wF(<_i;~Oa_fa-O~/迈 柎wX-J3?gIW?Ãӥ_bzH~ +*5-R珚KTr?W%o1)jIohoBW5cxvt<=n.Ì韊<P'߬[D}\)q^T|xYI $0w _DtB^k#eR2_/x4Ͻ^4gD`%oZKޜFd-]X 8Y3y<F&Ou S?yѫW/cR[(;F=c%/aO C Nn$;R~S3©j#>K): Wg|ɘf.{ P7׌Iׯ _h R38Sq["Zǽ_1q?&T Ϙ="&&ƨسSͽ$FDA6o:WS^apIQ੮*>cM#,}ǿӔJfZ8LB˩f*sO h W/@K1W\!e\e@u?|h̟q۸D;QAȘ?_̿CbO)gLQ]ACȿ!ڢB?Wf?g7±̿xӼXezOKGKk~Q<j? _ >̓lȿצG,|2BZ,sj?fskJʭkX;|`OjCb8*Qrwzq{JHQ߳[?enTD<_g3=vIPfm'`qzUC%Oy?+nB)sT0'k⎳s|{/OH#F;"cJQxe΂X`Om䟫wf{ ?1Gtpad ̆԰ KwRj=R,u!/YXT[YoO,q?@{"pUk={ϛu׾epÖdzڗ7К^8w@KurD[?YqK܄/]W)?^K0cMeE>lD^͛k3R<'%Y? [#,RK͛%_/y zsPOv\3_q_bLy8l *?gqn?- KI;CdS=)z'}?{q?s3{baezCAfRigʾYw{_?jiM/##켶A?o?Љt7ZT܌Df7iǧg/lU:OGgF9aOR,?Տψ?K ͟' 7li Yߐ]?d萣" sfQ;YjS'XH̘S37s/^`^W?+7ᵩ-kE?*5`6Ē8x.@uu|'3XYqߙԼ+݁& 0?M 9J 3; _fy/[p,rHDxjcZC'p:' ۲%jhdqѿ>[-~H.?|G,k9E\#L.y#5ubUԥg 6W[tW7?͘(?/&2"z.˻O ֝'0ɜ>k_]2")s <3Oϯ`ϩub>ȟ a_Պg {5Uo7_Gν=žd';O]__zO<E}/z3i36q:s<^+z?I̓$w^Po?nc~ap?\O5Dzjw~;'1v+q%w5ƨF; -?\SwH<:$ƹy?[mԯZGSskޝUwؗg/.v>}=]| ;gF p-Wr!cvz~ޟ,Og9}*['&&'ꗝBxa=J?U¹}]uݷ; g~g7foQ'm\۽g鮎FAcCt_/HRW(6d-VK̉h0[{mQ~i}\`_?O3z 8=v+ys_Z o䟓rԆ==-{>'oe1coHw3ۓ3CK]O^y3")_i;^Nt?smo;~/SmwcFV5OT#ݝ\.eiOX2,+:Of}-B,3sB7Lq۹Sݘ՜wl"Gdz㼙wY؝6Ml`הFQVBğ~NJ-4v1/qgzG~k_A9fm?8xr߶ʳ؟t?#ϵ׼<w;?+P6?CkX`[~DBkQ~?ot?dnҬ+5^ ̜ǧѓgue ~| bqLQ6;=\d-Zd;,qCSk%}:3JM8 M7h?UK;tx4x^y\U׵ BQB1Zb{v(C-%<B !ـ{33J(5#<"FMZtaQ={~k}xVglYO*s>Ρ1+^kUYw[ejH}o򏪃x!!!jNۻyi5}ΔδΏK+>PƔT~΍sTobWĸ_8C8>_Z<߷ó ?ᱍ>} >Q7z8\5ѾX]`{GtYjV<x7UVտ6~SX+os̥r6R_S>RZpJ9{6zQ~RpE/Z5:]B늏ww:3硛{O*stҟ]u5j!` z) Mkp |0?nCl_jY!͛3|ΔN>ʘKw/p?3cvsag >Z?)S *ew3f"xp(<~Nk-SŐk8'! DA=h]˛j6CMwٹU"O-R1뿮QtwA%b+@N+?[F}йTf]tR{z^/úm? j!ߔ.zmÑ,TPwӞB?^A>wQUW?OP=Ѕ#CHG_?Pۤ_1nvq|w]\ yF/+ Cg5Ke?+WHF?<;Mۏ(B ۺeQcL5RWqJR< 9ĸAo_0m݂a*L_1X.J8@d':k}`]\H?2CjmUy /Ej?i2L?eE)*!ϚN ^N&ߛ OoMo3?ҫ':렊N/}`=#'U?>~=:CܐK<5ے9]~;ҿ?ϚajXd?2/7B7˗t uekVpI>$5R7ϑ޿PP?d 8Hf_[ 5|n[_>֋@cܠ `bz<"?f1寑{Ԟ^ ZӞs)_{Iۓ׽B?P R6|(FN9?C쏐B7%|;QOȞCޠ337zNZ!QEw |a)2{ꤱ)!I_2E0@gG0#+S^$Y;4ǏϾz!尿:խ3/d,er"CoCfz'󙙿AR/yO{%dS:x>T,Ř &Cc_n^?#bws-IN_fȿWj' ̐d껦t>5d_+`uғAŧ41 ɡrی??2A{o_BYwMA?zi߿63gj9:<)!/6o/?Rk'{4:yjT-'/9gVo=􅻤cauv_?iE2H3g}Fc1埲)8?nޗC?GNwkA ;/?=_mɟk߃4)z/ IsА/ې?{!gLyf_ܯzsd]\n/gTطQϵ~{Nƿ0{ zewmOȾJc2O:: =ڭ?%#}NPuq#xK\_ 'h༁?fYvL,G'\-/D?p_wO~Fm~=>a^ =\Ͽ_Op5䟆]̋f !7{O ֗CT5Clwawq/D,6G(3?_G#bB7y]hs%Oq1î+1S#AVM)?>_G(ܧWfmݜ̯ha1w{>Eu+ !x_S{m2G9]_a%o[4 ]ޗ{B;qEA!!;.Q E3?oQm}&zAJ/_?n:ZD/Ok'}x,dgbz:S HOl{Zم齃Nwc|56=MC5WZ?qr~@J _=5;|Yp}!O dz / ֪ R G8 ]1&_[/ @IMs˾m鿞"+ O MП~G=y _Ͼ oVe{?R/g9giW7e8/cSI޿`G;_Ku r<3Ϲ+\ཟ6۬h#?8G_1.Ty8aDxMhEOi'm 歠 dsV2M<0'ejYp!Bg?y] ~1s'9p{6-dnx W,}Y>"U?W;/~^ -φ`Ƴ}\/&c_=O1QǺ,_Z&H8S_jxiz԰\{^oY(+itIZWW??dkK#k߽rgwh~dИ;{_Qt}g8S<%{o{s:`gnsYo?{wu\Nq'Kl_zoG?L!߳_1Οh? 3Zw~~0!?f~?;3?"eJ%v϶o?j# Y>{YOopݛHo1g̾ i/˜eѥG?E: o2!I\Eazp3:0`&AIuRoO\Mwb̴ׅeѿ{:ϾA6Mz2]?gǓ5#1npnܦ<3S̟#4r !s0{{-s|ig-|{QXk_pݕΠZr3/'+a%I}Z G?:OS9>_O N[qex٤_]A[o^{|lCOkVEME4z\3۟)}$r0'Ko=C٘K|_S`A6C۝'Y>3^xh5xstiQnz050}MY_{}_sOuҧ !(yLq#c]ǻ1Ǵ7'?5Mo#<-?>F1u?xSzFGE>Y/)q?`vE[,f{Tf[|aWߜ fWO-D<#I?;1围d?|Cc*aϞ$D_{n%y>R) =CKC7V؞/|E:|޶ϵUI"u|Me= ss7s>lIˎoTZ5Af/J?o|S~O#€o=v ǜ_iO~E lg0ϠRE}Gցi:zW6N{gKĚj?˛Ei}_? B7PY#TG"r8"/Jk^':S@6_?ޡ[pWzsMFO?7՞nbw:s'I=+dyYSAOAs t~eJ2<O?q뺷~6gֿN,LfA2>_h/!!:9^Koؠ_+ʟGzWLG=a@$27O`ӇҺ]x}X ٙ~Ϝ./msFTC?2FW; fVqF!ꭴBI }>Ԩ@"*F6P!T8o'J{ܓ U?Ơ^*~0k|ld:ҿ'g:'\_z*2ExV2ƆS?iV'oɱĸ??fTH oO^&=Gdw^/0_^/v_ґ\Zv?Dg}Đω?QF2fS兀hBkUGs?[DV5d?)3k (bv?fix^{\Uelj%"B"Լ+"b3cA-5c"^bFHDDxOEE%蜽߯11c+:Z˞ǽ9i?h>ۿ[k=Un0׀}j AZUrnBmU;,nՓOYzW׬Tzu..jZ8}v\;ZV~6}UݾU(O3N>:jӳ;:xH~5:~NUmkAj(7mp\],2%KOj'Yuemi35|mf,ڍǮ߻f$d?=].6 7}&}Q} so}].e/sxo}\[j_~KTtjgs4!YtY 6;A?@M[ O]=Y|.Aff Iڥ &Wi5ͨO_5y:] fs`g ?w\W?o ԣ#! EGW&3?]m(BW`#[КlH |/*;G`?hnv_;?^{i5F&Vd\Ql[;Wo#A'e޹n&. C wr#l@EgϲzpjJ޲ҋo5:&Ox#˹d \[p@G7A_ͩ6?K1s\;! VnC("Y=}Hl-ȿ{;s CkyAE)ff Ġ_kĐV9}}_[5#YY*kr_;P;5"VG]!?%@})g^x?pgVTچ~kY N'䋧ɷǔF3g=m 4dS_:jPޑ!>#eYw#aǿ{{[A%}ٮ35kv@U3?..Q?uQ;I+(4?&_o_IS-'z x(Sg8QXZƝ}SO2VjC-+˹3)m3;}8ɐtz ^#j : /2/ /QwtO?c#Z~ z.ULQcLDKl+x&ʟ"/Mk9+~ 77GG넾n6!$_Ƞ)7ٚ9Sh}s2ͨ=v } R7\ʵ/Z~S{p)^nп#CMrQ1߁%9" @m8Ǥ`bA!7C qc$jEPo.vg-o<A~հY9@ŅL?N= Lw*!'[V̛_l y_d/eT 4:o,FǺG?z?zjv?d"Rs۸[WM.b. O{N^ZEi5#cU>Rn; Z\x8x}RYj֌JWoY04SJu{N+ȿڿk{_M'uf.㴡Q|sˊ# vϷb7=;CmuI>__mO߂ICzqecE[g zCIϾh/YP9zF|^%"%#|o3gOZq5_ C'?֙-ߨPC n2|,l/G?\_vC%c62_Ru[AyҊ,EgM#7?p=S|0{BfAkͨOP[ {/~z7xz;g 7,@IGg#[YCʟp] w>[{>/8g/R6d8N6)"O}Zmx_Y!mk>3^ŜDK!5 m{y>^KyKL}!p֏nOǍrYy䟐+p(x9h ϳ?P}Rmc14_[6gOϞ =Yju;05tw {Gp:4~t_w ϡ.pV*>?o H0pe4z r0#G!=Hȿkǰ(IA ,N%w?KٖPG4doF s*!h==J15?Kg򲬿fo\r5_]|g0v/P3/4@ɡ~;#a<ϼжIwN59_=U?p?m[ 6wW_0>Nӳ7 å/~u?/Ú_IrAK/!'>xߙ?.. W_[2?@0N<]Ⴟuf$+"](/|Ϫz|SWu‹vh0\3)µto9?2ҿu)wdd9; ;}3Xhq2"g̟}u|wUrc?A:o GݐN<o?v[)7yrM-Se|Li{6 dS\JﮃwKB_?.~F/o?vxlLwBF,Ń!/q=+ck/v2B`^\[sEV}*ԈQO y$ܵ=y @2ًYYY@Jes7vewbȿSP_@Fxt1+o9sW!os6ݨr<0oSwb 8GQџ:8_YE?>?݌Y >&G̟7\jn}þ@oEjIџ'G KՉӤیϜ?݃{B%ZHVq!?#|϶}îYYHǺTy/%oYeUo!#KI+)ۨvԿ?A_dz4kz_T2#?KGENpv᜖g1ܝMp%':;Hu,5#,IÞ͑Ttt8F9 S='=!{_pqGS76q o f\(8s{V4>)6؍Q/4: Gs q^`>\8Kgx&]w2OOzӳ?z72d7^'9`P'ۅ/I?'s_χZBSȻxf^1WB;[g_y}_ >WBUuA?eZ?XC]r/y< A!fI]yϰSNSGWS|Y?6d2NEto? ?xVN':2d3ؒącT\z8Yz?a1 =Gm4HSp7a_( H?9Wp3 *"9/ؐ?3G緍 Qq?ooR|l?ng?|uĿQ3ի-O?/?f7F:ߐwIA7o_uZ?l]_Ȩ o?3_;u Qގ:( /3/Y4kSOMP5D25}LgYNKGߣ"GA/9g3!#_\x~^b;ӕ_U߿S# !ߋ C(vz DŅgهVKǜ;6큜n]3rP/iȟz;?]v-%԰BvCV=0wKϿ`Пa}x]fYc Y)>okVVpM {}E6߭z)n{?$d> u.@J3waSx^{\uljE"D"P]beY<wc͟K.f޲Z""2=K%b%^tΌsyKfσ3̙C9g{>y>}NDCӨl:Od/+S _Z]>21=DRNk5kz*GQraJ\)-ǾVN}A}%iʫYLlw]󎷔 (5*۠|)9ZN8AyGy߸L'>O*#'|F3cWOUWyT RSg'_Q=ޚ#*T^P}DZ$%r+<_YIOnKT W U|+WJ/߯GR-KwT=~Ցg^ kjV:R5/S||+j^P(YٲB 5okd<r1)yj䟳C>{"w^\BgŰy;݃Bw>>Tr}|-Uߴz`~T\ZCevH{CgMRRu=柽r?26@~)CFF Ot1I: Zs? MT|lb%0^?>~ 7?c?/+\sqqRӃ›?+O*_MIM$?>zbOh7R ͑{w7.A&__9 ϕ{?guŽ&LEs!UWIU ߵ Gau;o;Nsؠ@Y?l!&U%T^Ucx.4O,w-SE}Lῃt\_?)ђUznz%+xTgZiɿv_ʭOIOTI(k%n#osOrnP|A#?<д9QbkF~GȾQKԾSc'oğ2GQ}sG%hoZ_z9RX_YjSoP:F&W,,Z~~)_ Rxkb~Ye׍-Q4nlmkUN49?r{w]e$?c߸o%NyGCޔ}mu|||҄`4{qq}u{w-`+蟴G9 OD٨5+=;?͑MsŰeH!KYaF9|b@፿t)H.;vL0OϭnV\?赸{g E>?o Oҡc?ekm s }-5g?ia?FWvjP*kE|%y})?4(F~5u=_B03J?i+PQZ5oa5S9jXJzU d6 zkV'?KVRJߨ+#_y^7*YL"]"N3BdM9@pr&QόM٩oFFβCOh]7{&>{_ٜ:0E߇>/?D֣MkymԛG9ާl?>!_ϟϠL cXu=eJɳtw-%lXe*}jR?21_k&KRwŪAD񩁶\X58Ml4sz!nFT?þql*co?h^52@2{S?"?%Q?Ż2fYViЇHJ 36g/{~zbeQcM{I[ MuiT1I?.7Lϟ#cׂ9Cf"^KgS?*T* ˱>ohg=H*qӫkl/9<^4o\R?j9pU1S?R^S¢F?uYlb8)z^b)C: l>j3Y\0?z.~tVȟWc^c3Rw^ ?^?7rT|`$5o<#c)c?z$[)д^+=#22F pXaʟw3'5/tJq?[gWY۫S"=:*2?i/-dJp@7n3'mHx?9zLfJ[5_T,cSR@Ƭ>&BYɡ[ٲ)pC3zs|J>~nc8'^bzrTZ}?T3 g-Sl{;GZ8󐑑N~sN& Ck%\xM\Lϑ?S*}#rƟ2+_Cto1_#rh}6'{.QHpySEE ֈ,4?? is[,Ykv13O׫em!F/(WwJ$o@Ο LN~OϦDY5oyra?$[1ӽV+̣>Οol_<8[7_f)σ9?<1J,~gT98Տ0?o^홿1,=qN\7>% P[Wt ?6r}]=gzbnVku#S4LH?×Ov}eul9״! ޼?[کdu]cȳ+o=_sJƿr 3f!/lϪ.R΅m߼`Ogdl_5MZ\ wHG_Y!..4MR,U\VO9BY~s~u, ¢^g ɺb<S__Fg 8Oaߨ<™ *~\5e<lL,d;'"gC` ?|p_]ç|v?/L^iڿ%pw'捇iFڶZ/SeNb?'mCW?L y79ۖ̇AC)o,?ؕ<ŵ? &<?!k$5O( a6{EC쏂\\3v|įXkOl/D'cb^/pj?dm(Z ́_/l3f{W4SuJg?|5#kw'i3y1SfqXOxU5玪_Q O*M: :/LkE_b"[SlͽN` 浞=HTm.~CAn1OE|َEvOZmYƿTsR/2Y;/f4hvzUq)`Ng S9߿x.O~O-g捫]'s!hfE<{}ab [WdO3.?f{OOO ?'"2MjUv5Om{Y?yοk\QCX` '+na_s]]1۴Ož5wx@Nto q0\q?>VS_b#CYQ>>y@/O<ո/G-iO bq /Ď?ߚ$Gǚ?:{_&S^fw:5Qߦ#| [ xz31;(w'e;ck?IMƉ oI#MP`g߶:S-_׵P g9hk}&C~&6U;goտoT?rq}ei|f |5΅߲~fkO?˞}矤xsTNG?f?9sS]Gm6>9 =#{y|iGB2SFb|B_<U֮gmhJ.`sa̟t_)w߿ sJԋk55 sai IMK'd*3]vY}O Ǟ?HϸKg S?e߿<%bυ>c"z,B?y9k۩?U_#i󇸸w?߸RzIÇut16c]ۮ%;?O/qqE"A@j/q]߸x2JƌGw~5\Z5AYKbLޮ)DDW_ {˟v3'XǎˁEW?~ զ^]࿪!q-OH|CFt6aoC?qhx^y\U׵y !RJ,={qB)J-T8B PTgK)!R=8SJ-%P<潵'gH~9{~k} 뾐J&>N#?d&ܕYH,/sUZ ێ]w#FH_ass|qHV2 z/=Sߑ^%W[v2Ecؑ!13m9WQmGxwmu;䄄,Hw>YRRuއ%ۮJhCfža,oNdQLׄLL}岉GƆiLԽ`3r& -`NdCx- GϨdlz_+ns䟹0#+A?tcymt#UKozDM F̆uy.5qF{/^LSVb$ׄ{'ytM|! IH=wN߲Eװ-򯚲І`,d(J$TX,KTW% ψ03?gzJ'V=`f_ZsBB~A?p4?0=#Df?-ϰކ1hq+oZ51`K1CU}H޾>MWv坧w?bKV W/cˉk#5&^S/<ނ %d3AM=0GaۮcjxȿCvWj?}[@c_5w_/?W?88XeyCz\_r#7B̄7$=x3=c׿6{Pˬ܌NJ;:97յȄ_ч߾zJu23?xo֦?]wA քNozw#ψ?-76ˏWM3_zKe[Q5G 1*~q#nR\qZA!Ysf~3* I?vE\c, e ?eQqk HKȿUn.81 $Ա@ sյLFڜ$_Otd;ma.L=?#>3m9E?#~/&mˋ}󶙍 O1_?K7gZ_QDr-ZfmJm#'cX 'N\?Z?P3_߷!7R2_f/;Mɑgj?=Ñ7ÐFpQ^?Qc\.ZFJO H522g~_K]HT3CT#z} 1_ӟtX<9+?c>,&/@N }LiQ>~XaEf]GL/hgK=п㯘Y绿QfGK_?!}CJ+Խs2$\4LjW?^ߌ|w؇+Ǟ|~h1 ޏԽ\Y>qjBn9R^POsȐq Xɤz <( ˶zs3]ߢGԟ5ҥ{EBv?mLWd܏5 ?S=ʹXڒ"w4GF7BkQ?B{l63 j]Z~%s?0z8C>5.xoߜoTko1?x?zi>1}>I?oN$ n)gd/O }յ6 ?ζS}mJuj'n?^%R'~0~r^k-*%qq/}D/WkC >ds )ls*db?^%of":{Tqn?n"3ToޓswLk5FD wp-5`hyZHCװIr1C/IfSkj764(k\?\|ϹeN̟!32PYde߼{iq!ۯ0ޒjm/HBָr/Ӫ%3Pտ?m L%3z +?<$ֹ,V?#]15Ngi_̸iUfqa^g'C?,!c\KB}PO1 蹕hוl $=ߜn3ga[_9Z$5 wG]ZW`Vߌ/$ݩAƆB?c@4_Vs3 ?1_;7U wG* Upv3 >BMD3 ?ֽ1K|fagemg:3NRϽ(@?=K0{qN/gצeQOIJELH+>1_OZs*)؃L +:.aRl59a |y{,81F]# 5̌rP!6|BI27+%҈%3I?7'Tq+ j iTդǀ7炯%C c_x2NK+qO__3z3 WuCywi~?H_7R\HW`~eE-Qn:;H79fJXqS^GHsǡ5a]{䂿ks.tj5sF<h7O#ʌk=j^ArP|OWاOTb-o7߄ {]Q8O@z2Gh>qҰq?kUgZj:a[fO}OHl'Oz6G?dp4?VE :)wGUk6S?,|:чG'A? ϼYp ?^]XA%3fFSʠΟ+1w 4= 2+48n?XuX9OyEkJ WN g3!O};{kɚg?;_?S;xos O5 "<6gr3ċ^Jx3?ld#ϵPʟ6_~4ΐ` %?ajvRnq~.< EJneGɤ=4,u7;J}Lm؅i7ߌϮ#? iMYY+ Jyϸ-?9 xIWs3U=k? u4i\Oi=9 ?s*cIxMͿ= ǽ43y`x?Pʐ9<ǂGǜ?fS?ۮa]Y/g 73G1`wG=&_xm+g,k9X=8WX@MU?^ݧ*&)]MwfPT_Bx 9tŠLGa]ߥC⿷wPC(n:'j<O~O<; 2!1H^Go?ȿl+H'Iٵ˭xYo㾟Ǟ7磸q?uRɤ)Z/y_q _4>hW;O;jꯏ .P?oy| M~);5^%Ċ煺n8f2~P(wIsՋ zC[?e 7>{/b_ ֍ d`Ec4Y^ű"mp_4wCg`]H!O `OY7쳘K5BK½y֪?\5?g?"?>~\i YóQAm=m篯O熤iAqRǬ.'~H!'ع*sQ%&UW_Y?\? ,`[#?m(eRR{L"wpɟ)~Ym5a FשdRO<ÏL*}@xC3=տӾ֞ngC_'v C`t0?kuA/DSL(!ol+76,6w/{d3}h%sE[eu5E ?[ xo$eJt<J8Mm+ߜ)#yJwiVao#Cx^y\)RBf%%^DRRo"R4MĈRLDRHi6;xofKQBޛ{ެMj^jX~vZjo#>Kر>ld*5k@8iyom3%*K+n5ߨ]pjoX@ĿĿYMߪNP??}r`wCemw>>-|uFd i.9kW3T?7rwk8bxZK,x!EJ޸'4nD+T?4eZw?CۧxiCSkK'sc4dR9ǎgj5?Pbz[A6*?hJ+<"o%W2~j` ʚ6+ڪY+p|>!ns %g:C|غ>-XSB3yޥ7e>/}EI ܻ-p>+@&f={wex@I?<gVSG3"(ۆ$_ԇ Zx[BjYb;uy?FNA_ ote0\ϊ|~Hwy=Rog6c؝jj~c[xooH{FaCGiQ,^&hS;,_ڧ$7 S^cnRլ5m.JVn{?Wg" x ǯO7,3fwB^_w {s:ojk:}v-]vQW >K&grPob?^wW]a^-oZReҕHugBag|LVRc^(pm?6ڳoMUosw5sIe5.KP?B]=c\׷ʅUQq!Y1t?oKJ8 6o:k?3֐?_dsnN:snBY[uKDD//j3ȃ!*zu_77 [%7رc˿0ai Y$陝9\,H_Ñq̊??7 }6󏍏`h߸?d.{K_?eo|AOЎQ nDQ?OHM[30sJ^6wX+ XdRX|xCA2{23lepb׽ WYr _)fIʝXR;KAY;# ЇZ}˧½fT]"%?k< ׾ʍMVvz ܊ra(_QwꠡgOе#Lw noc u 5K_ {~g/Nz%GD|1~xF} PYBw;c٭Ð;'v]ǐwUAܠf~e)=.YG3]ORGPߤ~Cǒcj=܈A+HϒA{S-R_;Ue*>^$=sik We#""4e?33_9#h^C_uQ/qҿGܻJSLM9ZK'B:%fn_n'%G/O7^i^"ϲ>f\TD7܁KW;fZ K'gZQ{._4F7}9?d/M=9-8p$GNw>QSI^w)bW㾃Umo9< IBjonΟXwgwz]r̹fD)im?؟O+r0 lx / Y?ȿ~c[94?se?W]nw'?1p-l8Kki?߰-e*#_Ol}Jv\C]bA.ߪ%O:''T\_|?+KI,%:כw+wGƨ^?Y9̐/}?*x?yA+PSVod ok7yřcaI+[:sԿ@3ۧsb ;[;s>׌g)z77H{5LA9[Mee z㕒~ÖN(%/:C?0({~?L#?3Mjo;_+Y\MOkVT!.7ϟk?[п{Ba{ft׶/rLE2x6} W_uIO{I¬)(>O+:B_e\ZQb.WfV/otٿ)__Wyy_;Ϝ$kCx>߶<зbO$_+_xm؟ ۧt?BnnoKXBxw@Y~0H<^s3WGwss~~cۜДR*v 9%gLN8??,zYu1qT+92RD_?o\#]m{Ikrc{zNeaG;:^oI]< c}z$U&?rNq "'qj3ҕ->_ԘߒY&&~r9SNyl<_n:矿;埇{,}?V+4/~8OH]\pgR]wClNSM,xAw/ ! k\|8[{UWpeر1Ǟ8Gg1 [Uώ`_C>"'!f pýT;'8[{Q%9=>߭f7#+aPӗ!W4x;WŠɶX|; oQ,$_-}ߠ׌.s"3_*#jG~/^?5)탻?'s-J>ߓCSmÙ^8OGaIG1O?/G 'z tE4A97fp =POHMK2;v-5@&Ϧ+2t(']3sŁErPqAK#nyl=C"ճ{g5m> W?3NNM;'Őwc~Őpb<7NƨzGʏEP6ŇF";oCLgX0!sYj :EiǬ?d.gf./|TOԱNϹ-ԘZǣ'?#n(V#࿮~d?\tjCQ&υ~ClzG7(=--|i{f.rOpT#[7{OLm0zPط#67x敬}RϚD_3 I(̟'o倯 (w3({/IgVM7![͟ýgb#JgqB^b_aPgq[ύQ! w9Lq؜tWtaqۨuRZY9^-K1f^_ /3 c?p<83Zp/~3kdR՘xv.x<8UdKNԘ`02_F,r^߮wk}ds\ܿ.[3a?$O|MfSkֿ?2OQ[eg./scVベc.q+(B2=JK|, ?2[[2XyRWr݉gp.33>~@K+\ oWi1Yld61)snJ"loy!{#;̷ o:<7bGzk|g,kŐ梧i(7ug' eU3(Bs s|m|fV| =/N ~ee)-u}~esŜoUq?cBٯ+8?T~} NȿS|qfbg=!yq=U7-? sT87 u2:RN< 7Lf_[^rs?/ 3mH\kh We?K++W=37}@`m=]#gfy@NK9p/~ Gq)6u '۞> c,n3w%_ro?ˍ]CiQke>+;j G&/5'OWֆ+&%?[-OR<[{g3: Zx)7.ؑaKo=u}Ib? 1Vx>0<${Iǝ%q{Ľk.u?m=1C VpMi=u/w=쳏M[Zy|}~8~Yc?%5fS3Oʉnp YnV5'! \c9wUx)?IرANk?}v j |~Am |Az7?N!ON.#ϐ/!>8~B3Tf)?{w>?|nE.C2?2Br$½hͣC?NASx^{@T׵-AB R***B,!B(m)!R.(5J)"_hPʥ2s&DC.RJ(~䮵=̙޿b"|[kF9wx8݃N-;4քXb~/>/!V얷OK䐐oJ^q\n9gf-*6zYxcťHk;߳|>[|UEjyW-B^Xeig>if \ <9Rus!CR z퐟%ӲY3Ks)qpp\yje uXvfNIe%E^~^)'I ט/ٗ#\~".Ƿ;64I e׻NNBdw%g^v ㍵upKOri|*OedӚOJIIwajriL*ߜ&GuԐ=W5{Ik#!!z{J}&i{]+ڤghIrW[&ڢdXSK@KIzuO; oQO3}E /)i K0S-?wH ?[PD`yxd%V%13`gWGxOڡ47:Zu$?-tVf_$?w+/矵-[e"?7?рI):lƥyeT#%గp?o_0p 5So$yd;˺ J*#{d|? lzz͆(+_%WIY/ ׹Ќ /& ޗӻH3# E;G[~+UEO?wsDVUX{DsOĮOE9?\$οl>).y#szYw|G%|_3%h@ թÀa>3I)k俼 #Q?z-o?$úϝ}Z#eǥ}L?  %E#<OJ;fVd/S6N̨AgK(xx `P + b?V?&$GX=/R-G?n2Fп!_.9*7_H]PcZr2?%9VoOƠQYUWkxnhC,K'RX| eVcףRV:Lt*P3T8ПJ+HcK~*oSE>]Oa6cﱟ]oq[ugКA?qG?T< 5~G*O&?%CX^3f?Z*#@?yR>9s3HF0kO'BG>嫾$3uf{woZ#PoUGa?Z+p\9?U4m-iI5(Nwݴٜ]Ml 1&Ԩ?T?8l%~ ]W_?e$_/zD%ڬNY?Vw%өᕾT&6cD/IΥ\ %俉e:"Z|kp&߮Tv5WSGgȿcCj}&_DjY*E#*  Oz,۟W꿠ȕlϧT&Zrɏ|2z?{Z>%Pkw[GPc 8}+\OUߥ&Y=_?=1]r.~A.nIX{{ М3RN}Vkzl(w{ۖTXa`w "MxP3C @:6d Vdl6Fq_:oZ}^_ٟ0}b:)s1Hzx]A~,:?3,x1տ\|e~D:vJhf<%x*^Q/¹hhFuϫ͜? W3f#CsWY?x!={N`h(V{H?~a*!==W^_߳<sz:r?eOU44@P ?AڵS8ֳcY:?%??ɝ]% g&Vٍl!'},P/t(J!74/1C^$Ѓ3Q$vm؟_[DSq-,'Z_鎾u265zVߵE_?+b3D=oHZb[·#_ɜGY"!3MbwtwW\s 6ٱߴ[l|?U=fq:?zWgV? 169ڜ?kB:ya Gȿh_3?^}=Zg,Wv>Oz~q>":X:Fq#wZ]U{thqMgbfwH?k1}?d{d_P(.Uu%*~OшPlx]Ő?~@źkKx_OK;/x>7ddPhOU_Y?ZicU/Y3O~*'6-ώO /NjՒj33GS&9$E]/,0 /yB9MfiGRxvXϲt5&N}ǝ8Ȃeܗ*-5dw?^-c)~C[Q[(0'P/82}3,N$R?[Jj7onT?Wϣ׷ سěD`gWܵ=YMqY6??Z?0Pww alwj@ǟQO[j/뀟 t&cfINYlh4 ꍕ?&&l`O%3m_;D|@2=dO##{/g Zw nvb(gbÿ5Bi=?sH+X}m˿q7`9Wjwzodczln,x?ݪb.oȐޡT/)FX ?%n\gſoj޾G^~n=G({EuWZY e+`v 7w+8W\h?VC::'}LkbEU?:W"s6dc'=snJD~ Ƹ-"\_ӚOqzU,#f}?GxE9>|i6?iIx^{\UU !""#oxfF䐯6yKC0d%ͻb"!)" "0Dd Cwox"ax-Z۽'?N콿~y:k/GٵSIP|}f>jQiJL^ӣaI]h)F=ni_+nJc|Q <+.Z. }֎RXCv}r'5.TVWֹ+w*}W5aGU+<$uyCTBʕ=Tv6S9ϖ{c)-Zk980\t݊GfrwUuSS )2FT;'Z7*Ex>Dr Da#r/zzjinNGxwu̙Ay/:KFwg0eեcȅJޅlZ [O6! ļK)7?.礈FWWgsosͫ9ٟC{ oE'n'-c#%ws?WE3d~TY|+T6b8uqrj&5eV\HUJ? P$o-;8ʬ!Glejy[6/ Nx !x*? &mGJNa++hMƠ#~^Y?O̥Ȋôn'~NNƒy!/ǎ~,%잠 _hcOr?Ժ7H+?h##`]r?r~A.E+}˝Vzጼ(~\*7o&fkGIǎI]ݾdcGgŵ]{EftD?OwGދ aww<:ߞrrԋiOI׫#"ۥeӘ?*{%CNymLH!L˸8+V.k2 lJjY?{%k# wpX%űgd7? Iq2N9/G[MLcZ\gH:XCZƔcҫؕ_`>!kyB-ݧMWѺj_T /d~T? bZޛrrKߔE(o{ǖ ϐ$ 3*q d$Ɍ?e&/n*oS*5[/k{4GϯLI]l?4>:e#$I6㟶Gb *mo:_ӗV|AVY)9vA/ٺ s|>wɒ0K7Y?/gv7Z1KQOZϵ^~ekGYں26$2C& oΑwTC^%?]G.s.@(sb5D.O?$GPOj?IӚG}kSϟ?,~?2Vv?n_?t"ݑG&6RnߋNaA- rA'M<g\Og{@ο}s/4)?vck1̏;'|zMw׉( ېx^c_e "qKؽGuto+L/Jg_vbgQ#}gGq7FϝYѭ2@AsOꒊޙA6wߍgyEB&w\c5O5VpvecރQ.e ZHa@/ݔ_R$]74Goq}YCkAk!XrftŒ%T~O|$kvIWwjn&Ag 8R AV#_ 7{gFfc;>7?J'VF#OzЈtwdx8SNL+Ɂ3Ǽw.Yϔԑ<nq矄Zg۸ן<5Lb+_Roفȹ*ڭ3 {zR)һ?u,cl ~j&GȂ?g_?7Yg;ɘX}y W_etӻY[e,>9wNOb9!? iebAkv*LkKkjZ!?zh&1x6wzko ! } Em-s;z#E5Q ЏdP6=dn V/L7пS ?7T~6ҳ5UJ͜)/mu)c+~/I\)"3EbwQN_5z^2II8e4i{oH%c'gÁ/ZL}.%\c/Cгs􏹜OJZK*o(S˯Oユ nμ믃Co ޓyTO<\"b}FO_CVD Ag5"z,Us'+75s}؞=+"ϟ tSQ矃wzߴԓII~'n8Q_ɝn2ٷOu.kګ^#:em wA\3c<ܥR?ԗGQW ^W߮ ?::q OgڭM?'M1e?=?S-g?s?=. b3_%ӵ/yv]EJJml'_[MQQ?9C`.*'O_$/!8ז'aX?eV&ϟf?4"I?;1{HrOƔ3 4G|,Gv#jCZ4_e԰kfN]8Ps.Xh>I{EVد>gʈȊ*dtO 2Qώá#%᱔!_;spSOKwLB/Rjzf\#^#1+'HC#r1w?z+2!?aRvȟ 3 "g,S?83+b< 3Oy>l}Kпk2S.Tv^̯ YYqX&C[K$\E>׃Qvk? ٶ( =s# [u?=)'MghD|'G՘̜c,ޥ&G}J9|)N1o&EFswE_ o?zOߋ. k'N6/8l2 lf~:Ҋ˦g]>/$_3֐S3?ag{Y7SOshsZSE/7p Ǟz45ӿSR'#OZ߸?4ך}?0m̌We4=]ʭ} ұ{s2/~*SRZpjy&SڭyW[`+YCk?75;.iMozd~[az׿TNKGmwK909ϰw$#_03_Y?/a￶>c 浴{_v?G1dV_̀iW0YT{c=zDSB2:o?{oҀsɏ`rScUW#3!o"8" 1TOO%cV1H3_6d=%cJWz&C}?r<6Qa Pυsm6pЗ{s]qXJ>w{%<鄼ek-w9?>@=5~ˇx-yϟ49_)Xgᬝ^Si'}h'U/-f培$9?|,\u)yH<_c彽e{۹5k]ڋ'zHg%⇙~|qZu+t+zWr].~?[Qτ$.+GT:ݻcS,k#d@d>&c/t\WCpi +4}};9b1;*7Ҋ>^Ԏ T?G?"q w {B#"]ǥl/Bg|t-?q&R[s`O-fȊ2¼K=TA`:dZGߍ24ӡ7nгrG;؛Y~M^Đ5v! CY܇lW<:S>"\nQ.Y8?#"sx'b|?ߛ9bhBql!ߔ(gW_vʾ ~qvI;4gg{K㐃LރOC3?c7OraHϞgq)s9vy~V}gS_B^?mi0ȬsBꑜىVs.gOۆoK[scه wQv^ +y͐R3HNy#?6/GvṂŰ/VҸft_fcJLoJ?fpC͟Bԏز}83hqg3yu/y9fOFa@?џ FM4)kh _.9l?PC}=-43%5Sߑ>W6mwm?c Wߴ?օqkzR5Ok[`ZߐE|p>8k??}+'#Ӕ=!n{i{1w (_")8G~To_O Οqs暽<{&x^y\ !H %q RB3cR^B PB `%K(%5྅RB)9s/D9!S3CU"jmO٧g۔>( ]n_l}mJ0/J>2-sn%=r=7}W)u?Tcʔauoʞ6>GEY^;ʀ񖌙1=䒛6EĔe>ϲNVMJu2rd˴8{ʥޟ*P|Cn()+3fV&)ۣJlsǔPKK~EX?{ϙWag8ؖ]u,J_,]b8-=?묰Y0w7>'U_tX϶GBۓ~o2==8roR L ;W0b qc> s*{,wE_|D(UR>=X֡Ve\ m:fhVÏ=-&=w }]^0-Qgf݃BkgV2o8C/GfߗOZ?XOow::Ѫສwe'Oܮ1Wv|u]?3ҙ\37Xu EԦR厭=K'umvek[^~HtoMm}ϴ[a`O2τYxg?^)&$]Pj>a8c9e?进Y蘿݃=_$?aMu(5'V&-}O뱀ٮ֎f)p1xQ13 ?BSw%x?]Wz_Zc'oP- ~Knv';̞T2˘}ѽL`oB LE^⽁H/ [pzH?=:Ux Mz_b g-|A?-"=oiRx>?_!o3vi:'.0⣫Ry ?;BW6}i ,oib?%ɂ?#=gn,_o>Vo:V󇟢OZ?S630WWIÌ_/sfO׍S5v +waBW2e,GL:O_ǠҮ H3u~]Bq̙E23a^>v-)}t uԷ%-% e#6{ˮgkYѰU; &S=i^bZ|Tj^^fKT#_ >VggT)ϑV%ʏ!kt_fimp/x[r3-f( }/r}.cOJ.yA#iG3l`F%7-?b}fV[G~ϴc?1cSa6*$Wv~$d[CicceMk#_ȅ<1$AHFv Ro_3]+VeKGߚĺ/qWyE 'u0Tޕ,S30ՏPMpM=y4ZDChZ9=_kBtxt_sM/G K]_I,[?O_\[;n%ifoia4!??$WX?v&6]w-0/5P5?Iߣgg MɼOEӗM/4bOm _?xǷE s1Gege'x%ԷewDUW̟'j-_^%3j" T5ᙸܩ"?>Li,F+lyo#jth=!wSxTu f~?k?%o=^K_`JF3)l7uiqoOX_-yNoa^3aVߛԓjj'$mxGf֡ۖL׻}C8p'_͵#~J f*Ou lBtQf{MPS\v#7d,~v^cʾRgxk 3ZX?vL1eq1t7)-wssWG G)]sv =^' PI,#KtX/c Ksu3xF <ڃӾ&h])[>+ ir%t|gr?ZjkO,rc,05s>O{F:no 9%+=F@ Ogוϸ{-? G)lnǺ?cm/Yֱ/GrT+=?&šrߕoLOS5ge#NKu|e$]-_ /QVKCȁ,VOPr\>@%>SjY27T&?pi]<2o=:뿠͑Z Z.l MR 'U1,?}?kz_FB$h?=*gmGߤŽo /f4f׎DL/zQ-iq?}.{?h\͟g4q97 Yǹ]3H=5k矷lz~61_\TmF7OU>4u?tm~Wu_Ӿ/됌G?ޕ=~ LlyR9?3z?=SvsϼܿtTŒ?6߻<珼 =ꟅǴ*y Cww;q|90AOf7)矙Nq'jT#1\˓ϟX3l g8͟ɋxuC&ZLc$RKКHWHgkq9YO\烙_ X)r6~ʟg{4_3?|2zT;X?ԏ˂wf17>77?Yc - qДu(~^;ڥ;Bu_Ќ$ nqnK8/g: ZE8[rӢjX9s3Ǽ}{Ի?ySՑ2O sM &:"~Cٷ9_ ͳQ \Ľ#_ْOL%̨u% Y^n.+ȏs]ꝭgt{~_bKV_Zb@ӌ?yS>{쌿ne`tqL) _|VFx+Soi3 r>+[?G?{_+:q{&_n?>?>az1y],6cF >_ cyUϰ7u NP߿pAiA٪XZ?$G~R5g6fl܃{w-G?+UwN7Ԧy'5 uj|S?t|Y˓:W'`n?z`=GFO,\K?֛OggV7g3|ߨ?3qءgyE6ӿڦ4+oV1t φQ"QoC÷f_ӑBqȿ'~S3%e?xD𧜩\8_uj m2KȮ1oR{(ӿa$W5q~bleKG? OU?tJmc<ZHi`ç涆D n:YOAPl*1$ {X'f37O7>L'sM?;a_lo+<_,TʺI9ۛͪN19)۴bkȏZWqw}-1,6=̒mi|9pұf?TcfON?Y@U M\W9T?l,՝?G=#O_ZIE$dfwvY.x+siGa* ae#^u?Qk޿+痣TwKԦ lN E\FͳfirEz Q'Ƀg|?ei0cFo?L,]/L]YTccט. ϟ8L?ǿ#{"?r'2Y^YCd7=K[eO=CBx^y\Ve !"Wwm!b9q=4"#%u2׈"Wܷ!bxq,5xɌbR3~s<`yC.s]cO! Z?L+ВgNOAf{釯nYCO<0o[3J+ѽ(W"]wpǺ=l) #:7/N_-olwfxAe3{ewg~ӽ2-o;>gv+JUonϠJ<ཤtov(LNЩ̟|jQ;+߯KCÍ7Q+gR߿HiR%bD'(̴cڷWU)v.NtF8C?IJېvΊcjI[ϐ.ۋ]QóA[UsIw2_ihwlP$e?f{,yh._ީn0O;ͬK^br|M?|u0J@Wgz镳wS?!p6~Aפ?=y?W?koeR^^?Gx?u_x.Od:^Fr5Gb5v?5/IW/r'm^)_ ?SUӿY ̘Kt~*/?lzB6מo4]@u|5Gh0Sf?>2E/EŰvҾ9luԄo{_lڢV-B{BV@KR}dk՝|~; K'WYV \:sVڜTC!Gm\q* G7%K;_m~NŦ:QBcr2tɟ .S=5KSwWJxv'լ)|j{5o;s| ~ 8w?%3csuzgp^bg\=K+.ɏWÇ6qު*l*7Yg/y ?!׆Ku})B7[k(3J]XO?gD/8;ȭ4U[?JOjA.Gox*YΟ_3c>; wuWԯ9)zjxDF<;.?Z9N#c*_6ђ?o1bϝ\i<,Sx[*OIcӹ83f*O"JFM&VLyPw,?0bO.l0SgԖ7fGKϑgA&xODI!S}^OQ0#c <GM1~9+}UiɟD?-t8O^+19$V%x.ꆝ?Ys~*}ʜ;geW \: ήf{?0"]!#?:J{O oR]3ӳO|UZ3e{:z GT)vu!#cr,ԿwN'?8Xߒ|e 9xSPm#OA{Z5Ϲ:?iY!.nSiZG(_MW1wg w?DjyFO渾3 Cs+29n]/?dSϖA΄<Rԯ+OwWok[KDsswΟsL+_-v#cPsA^Q7_/_G1W\٬|;UnRHN^> +'='uaԢ;.R_m鿊G!?t:XuV$w0b +=mB{'Z?%Ʋ7Z| ,Sǖh\h]9Pvp^_=3BQ̟z+g,{G=3o'xZOxn=/R w/ߪ`EJ;K9Ÿ7:=pYkbLcwS)w278B<5'O{ɟue>gtF^)q0OR?on_.4`V?=J!+խ >{/ TFƌG؇s_w?)ߟ?B;bj<o:rɚF'M|9s\Pzj^eY'Qj'(q;?oYktV7dEEVq/mkbm?YuZ 矂꭪}O_<ϺOh#d,R/ho9Se?vzgUO8q1qM wo(?Gsǟ^:Q?gKc-ֿӹ:)RGB?ν crv+X1?Q 86?fg|m Iv6'?:i~E `wkNCF!<?'?fCz4zE"~=/8OXu?WA [yFпJcf k7Z_pcKQ+UxS^gx)ו$qWB[7OYV0e]$c7"VWߚrCr1wt#`#1oׅn_V׌'r߸?qBh\EF O9&'߻0\'F 77[_~-_aEfEɚ*RnX! iA}}P+HG],9b~Վ*%:&fT5WM7Bó} {s>b6?؛ۜK (*籧լyQ׿(KyPGgw̿sֈI'S_e?Sc}̿o3gzߣ.̗o1ʊ ?%ӹME!.>Vo/8{{eK᷼B=' wNF2Ovu[OZyY7Hsw;0drzh˹.`3wWo8ڍe/о(ǖO+a5<=sg/RHi8ӓ3w Pso%2(H_('QfS!3ܭ*?u]hLmfNmk:]埅b?sE!3)WExߒDO7C k_t1ff|~^*؛>}wp=n1.~S{@?mS xF?ci+?mUnoY;x7 |CF$N>I?WD s S>TVWlgze_K=ex~q$7F41?n?[]"vHs4`SOދccwݹanlgF5Ayˮ *5Os*|ڨXsӞP׾'kO/xyy)Ow0v> n_0?&(3eW1?>S f#ˍ>ڮwo=PG^w{^['O7kךfN,9']ձ.'&Q;/sJn thjxej8A玒?*j?5/ĘwzJU(;Ң%"Njqa1cAUUi} 193#.;7a?vX!g`?|[,?Vccn俥+ xY3/Vb %1"ۜ}~wugt!Λzת'x?BO,'u_Dra}Q:XZ/q69"xmOS,NiѿH{-a]Pwθ*QoSJYXџcS /W¾RW &O&iqI]z巜+:d̟K|"WclQ~\xLX*?zYB_Adkp  ܞ?V0GFZ)? #1lyʲ23"zU{O\ukoS߬]vKymfx^y\T !B1"nQPJ^wRJ5bQB!RK)!q_qKRJuߍJ;1{?$>98#f7ed1sY{D̈9]]-#غ{sw L vOyunЫ'Bϸ~IݩFɁRs7jhZS=˸Y?xgt7#!xq7DLJƀq%ߟ29i.5]/|Wǵ~g5k$U9"۵l.z^|J3?~m"gL+--g^+h\ԛ޿h1( k<{cѸ#}~k+OW?&"B螏ӵ * Ӎ6.nW.Ĩlz535W gƶZA=FFU\['uve5YmZlO#׊+pWdghwC/^L?&STnbq%t?lWYhG?G ?!wUfzyM?:Sɣ\Pٞy=zW;l9!ɼ͸n9*=r ?GW\yQ\F ɩ-g]/G73!+a>['ZF 8k!Cӈ0<5t*_g=EVuРް3/_ŕ:CW/Uoӽ6@B_Gystmbu!bZ(R*T<_?{{yhF#ɟ1Ni]?}U)wIx':63p*kHۏ`SE>?fG,?t?ݤ-4SK>ϣKy?/zN jп# VtJ Z.<ςWNpzYĒM+5\`-gkR4.z\`D.RԬ;H3B?_>|I濿?S &5咷$tT3ЩCO NҤe.ީ?laI $,~D?j1CL?0ODczDr Cߺ俪u.R̿#x/6.uRaɿ)?=j MHk?'VJל.|A/5'?EX@G*cey(]HZDjqON7;Q_҉ idۚNɿ<y?m2r?egCVΤ{fGG09n7.OuE4__Ҫ{xB#=t}PfO-w7[jODlvw?<],-URFX!Fiy3_Gr3ww\%ȪazY'ɼ.%u-载/Yc\qa'W\g['wPR ''r4CNͰkC s {N7Ķ/ϱ;T?:Kcw^ץow^{/'Ӎsqx'#kw5埇q j?r?K )* YU:>˽lczRU; #&ߛOϩz>B'}kya'y/ABI')3矖r?z{D1:%T'F^C݅O&Л91+8fs]XkSҢ3DbXE&|9c3"'X;^3f?}T/'?=K*/YGd/^>^[3T(SRyc^Ol5!g^sĶVR= Te^'p{s [88Mc9`ѨQ#C2vv9܌٭o8K rT_R(zc_OΟ}BڽI iGt7[ЀYyqYM8UMyHoij쪞U"vwjFqzrGY?E@wyN',ʮe35 wu9n!KуΘNBOdqO?nKoXݷvcs+o-֋zZot}O}2!oV#kD=.#6:PKя\F߭0ɩx>2 HQztt@08:]Xt \Ig|tcj GQCR\crͯ-P;es9{"/P'?-H+tr@9I{örLgٞ079[C9i}y8w/z[fu3Ƥ'- Vҵ|c=HEW)%}F תaz^}?qra/e򟉎LC qW_|S>t-);i[KVx-r}nڿ'?e"eŬ;E[=rN/<`w }5qS>ӍAέ?gS<ʒ?{(+ ?i5;?v3O/w)ls)>xSް>F?Ws.,^GZ* %˳s<&^&PKo<#̈83kzTÜbt{ 3!/I&~?:7v$Eh( q?{ݡ)?בU=/fak%*>-n,gXMO?S >3>Cz{_'d3<3GL'x?{L-lpOwʳN??t )#\O|o.20w5x0FG[*~mIA'MquDπ_@?f̴?1ލbPʟkt>]K_ݰ_Ky?vݛqe|vzCgv/5VמGne_oػ?;?gLo zC[k珳}Yc}r^?K(z慯,χ.gYݻ oLY?󯞵/fRϽ ^矛MΈoҡn':糾};v }slv[ߨQ|kF Oݪn|YpOI _:O\q<< ?%1菥7`~8 -O5򟝇ڲrٵ?{Unwbk7"tX9YC=#lϘBn&O)o;=in1]?|W(whۄ[Iea]R^OU/3/JMO NKjt45:RQ_5,9;??_^ }_ ?rtsHz8Wo揌!?xc>moX0O\o??ﮔoJZߛXCS YU->?1(1yj+s1on=W3zm!?It={g*P_T3$㪛}o:bGV}`ɟyrgff^Ho{7u:hY;ޣ'jl?we#("@?d?ں J}k`J#]߲L.C-NEL(Ogtp?~:?v/vx9?5q}`q-#YON-xrSogR;PuoP{D]< ~&_s;]dRYcX(X/~3tC~b?*򌖱?sEݐ9P[5?@nGgtt%d#f> e1˗Y| 36|!?=#jʇ؊'N/ӽ߬&qu[j|'$C?gVr]X$d-hցs@eaIpG? qą#??vwufO<[d5y& _`;]e?o4 f?CnWON]wnĀS-膞&R+5<[FkƟI;4^7:@L-H%|׮xT<LJsS OHYϵ2/sEGO}I }g?K>/ğ7?}?;v~5=K_J'c㾜'flw??D.DzGߜ?1ogX{s?J?vfRGc=ëon³Bួq@b,U<J!P8,j sG0򌵔B8q[k9s'*w8{~~k}i̻tZtX_Kۦ1sԵlMݶf Wg߀êKԇ꾶튇6'n/r+ڴuknq_$Rƺmhlx:,v ??kڗ~7".e]|Vycy Ԭc99`2,9ֹWnOCmaۖYKb-,,}S_ֲ5*ḏ5}Ġ#sw9kG$%Noc6fSrՌ?,-xņxOIοWްv NO*gkoPf^ ?EG|mۚ֫ߔcp-) 9oVy6dvcl-6_瀊+mvF!y]k> KgCzR3mSJRsSӒ3h[IwE?-9Σ\LK%vWnfb'3#-q=lߧCE4Ds[ŎSث _9Үіzٯ3_3AԠ8o5f 5LQ6W=ކZS8߿"?cy 潣"2ԿOqԪοi/bk,?6ob_ ~G>[o=SfRڐj! OboЃ*-G&TwЯ/s!1vZJk?3?Fs )qrWEFvgVǬ6.=l_#%!2Ҵu1[غMO7}[C/;l;{lcԿYy|#iЅ͑x<&'QяZ!]Kx)ƒ?_HZPsuc_C8Nf =cPgd#0nc?/˝R^0=Gy??cU}V8̟w'o[fYU^s:ʟfń4CO%%HTO¿|g:[Pv?+ϓkSJnw+ي?i"?صW6pXϐ?d.??Gx {3z'@wu/Uߡi.gXlq^yVM?MLz <:1ZUwTETsuGFEj9OT1϶UD==əaN2rW c#SmTu} zW s!Wp)'Wk) xOAĴ1lL d}D s=ӊ_߽P?Gug@m9ўW }LOOd$Cn-zMn:7]-^pVI_9ip>v/+8 V!̋P#^gSOOi? ɳpdy{Jq c]<1R\~`(#Ғ'? 3Q t'.\BZo>pZp^6YU`eUj4 i=Sycnភw'Jj{?x,7oCS%oJ~Kj砝PPu#>2q33ZBPsv3k9yzn kĠN_L>?S88?x!/)ӓtQ/+9~\gކЉWϭ=hi:[M{g/ұ"c_ ףڮ¬͟_>(7M:4pP},7_,ȭ߈?:v 9<{d ~G0c]d`?@FJ_=bػ7櫓Q(']{KKη s ._y mx 'g`4L_,rc]q47E۸rR(_~c>mo3=?<@9q/_p΢<<-KJ7w8k/?wiGw+ MRu=:׿l/ p3/n$wMLO->wt/Fy[ZKX`i_yhWa_:9;Kz\A}kFZzGyiM:qe>s:^k{Տg BcBis\ 3r3i?N噋<D* _9jqIǺC͟4DPw+GSA2gZZU$37X+IIǰ_ӿY-Kp /9kp\W|V []I6qgPN7"!Zj_⸟X8O0g^ o"<}G!3Eia?GڀgOZBӚi{T coԿ6/0K_~Yw5_?4rvA?4m=F/uH;]2%wjyq!ͭV-{~NA0of )/c?C's]}7cXyo~YR=>wge ?31o!mTp 㾲g"a)U _ֿn -qG5;?ȓ֩vac5L8GbOq(YOS_Jǎ[fN~lܶf.FҿWUfԿ xlk~l یcW5=Ӵqi8.r͜טo@!Co_ޛ ׈Kvb=A~oR腔piWX +dgFG^?k6_%ޗ/FF2/F #>ޯb+m]+:3UBoFŨ%'))⺨;.]ec/8ڝo{N{?EWZN \tF??WC.LVp^#?׍jqc"+a1;SeJM?1wSpPGf=3A"5V+'vsadRGG_qo ]Bm\A돂Q~2_.+ OE'?Wۋ|QGxK;>%?n&[ c]#?xS+,ýsf?g|Q_R#ȼe Ukprbwh v-kC\QS-ICRtSg܄㈿?f~[˿~Up+F qQG?4#ˈi;"M1c>V9u=̜ _^B!רF$?'~,'2AEWk|N9O7x_s% ZNSqevn  tl?6>uoo/OcC`k4E-ZGOr ;1|0/1?Isd.Qe{o+2MK{h|UR6_䲩U?N9xt珰G/xPI 9GϘ_WʅC> c.P } dȼ"⟷])柋KiWqReSi/{3x^qܺ32 ·gۚ? {Qe 2Gc?:+ ]4j$aM[(}F|h[E?oo? \WF/N=r28&V Խr5q>\??79]yK?Ͽi/{v%V? p9Q^f?M[) kjGsl)0)x=? |~רcs?8 M[t.I [å_Ng?"> qF L^ovGpi!?ks/<_p/x/׿ gix^y\Uulj$""7\EE Cp1 C?qs$"JqAwC;DDs5~1a.ny~;{R8{s>y߻e%EӠgeWן2/jIi_VBJfs2boRܷy*qsޞfOBeXLdfӿ${2<ǜqwٹQqO@qLSbr~xw椼EʔenR9E6qIs;REnʂ^{Lrt=-{6,%7%rR2&ό}!V^tyyr}̿J,%pos͓1{a=xOp%+\}KI7䬉Vە39 5+ ;Sb+%}&/gvoeˇJ;S^ \ﴊ^-v/L22zDwIgB'O2#O?Cʜ̜q?8ﳭs13me>fwu|V&o:{˜Vx3#G0m>x?~|BmX̐Vr#weTq/c_+2矘8!eysm[YͿq>/cw?= ?9ŽwPk}lj邿݋2jߖ x{2Yu2ĿhJ{u!+y!eSY͞1ܼģĄ|3_%zUA˟gko8Ee :Y_ޚ8G?4IµV!W EsC5Lz`Z/s8MM&IZ=4 De5lw 5p~~j`i?:-3矘_q%W'(xjfjgM?0S?%lַERW5? wlW5Y [liacp56^jCeqM ٤YfIa1g@G3XpV<|Y Iɬϋ[g{_U3p fpmSq_sÚav?*,̣dC㚦Q_5h*ty>kY)' 3nf[U8TLoﻃqc#wjϢ 4Oe䟘8> ߾ZO=/k_=Kj5Mf?A3ğm?ϵ ~?]XC]xev ?R?^?fd uk6?f a &kϻPmGߟ>q_/ ?zfCk^??p?݀'W}FwF(9>WpM>g*GՂpop$'unoNiCGt5?&2=4gZaKLpT?~lKXh`<6!w?հ~ϟKu~yJ5#yQ_"?{eNH>PG)+`jaAG){(-=Ԑ35yY-jȟ̽r̡9f?1Rl巔֭݅\S?B^tN/;FH9ޠYƎтx !XP;X8T2ezm픅P,d _Uڶ!j/Z^:4Z]p@g4@x~/+C9}t}?V%givzleԤ/E0D+[_Ϳujy73?~ƫ?SM5G_W׿#dBXp?# Qx?ds{͂?G_?")WG? QɞȟO񟨐RB(챈x5,^p=o,|JnJpMSRf{T2Gj ߁x7XQr'"]!#^Ѐ%1,?g%w@Y2h% ǁcNOBI/׿c}SɚK3m8QR!ƲŬh] 5QEei6UͿu;AmZ+Y:7 &&}*qMsD?m^)BB8^i$^:)sk8⇢%?罄!QЇ@ m5a^+4pe-C؍5afT=?˓'+Y4>H@ci?iPT'+? >)ϏjN̟eǶGF RC_PߣX#/7x xGEu*{?/Y W_:߹9п2ƽ 7,)ڶXc%ǙZ5hSVP=rx zށgm_!O)/?g5=B)M~e=q/?."{e pfg!JaXǭs r_.~5qi,<yr#yr\~z@}? k_w埨C^=+=O8S#^:9QuӚ[gPʦ2 LzF5_1ҿ0-˄3=;L>]J=%Rr* k93g׿*gt[Cw*b+DŘY{"E%eӏ? :7 8I.;&/?/i/+ΝyU?֧eḵ~$Be_X7|*ddK2&>/Xkq=݉_7*/o5:Q^:@9`l_#]d?msMn»d_?y{?buC~Rl?PZ_a# >d'6=2A6o_'otsK0kyәZa'e ŝ#xNxׁ{韀wX-N2 R䇂?9h~4#3z|3<;?%B Nk ELL|UN5{} ȼK=:'^Be9ݷ? rkV>˪#" /]$*78b2ǧG'eT!wd9&`9D¿Cp]qhl!^=$oֆ`G_="ֿۤo sZkt?&WK<\;]úl]5! ?1W&笹&!']!r8 b~ol Coj zOHxfJvv_njSr{:f_h\ z۰XsGlݢ֊3`<354B˓!y7?g헐p@BY30_5[nHlWGU<c+me4: *͐Zs:Ο@?ӂ{zxC yI>u?tvF~nҎ'vvtm8eut~~?q9aPoܶec*euڻ?[qXXo(CUH8OZ._axfV s>)_?d;鿶 ?'?A~Vx[,+K?[p}ڶ"ז.w287/?x}zB1p3i/|mpT~̮xo),"L.u_ Ph]]목wkZbќ}@[κܴAHB󮮂ڝ+G_|V[[ryG{^t%G?%c63|>x?o49Rp^Y:Cz~=l`\nPOqaQ=?2Fۂ?z_g5zb` f,L6^L׋ Sߚ:_:ۯ17BCs BW@-oڢUģ A{;$1]7 wwUÿ_2G6_\gH}sޡt 6Gn _W GZ5Ox}G?Q{\Z-!?{ݕD'g,?=;Gw3g#3Z{J >>[;`=iػD\9lT]nZ.ޔ/Q;oӍ:jW9/[=h1$ݘ)U/MAc1cuT9)ߜT5 8yj5 DI5\9Owq\#Eȑ9ei$jw5NHu6O "[&?^;U")]@{Ww@;}>EŃG I{%7bT#.nllZrJV?[G#.hb|P?AW0;IlGd a ҾkV?;JM7B9!Zxur'gY(͟YOGco$OQ XwF3FT27"<Xd!?6K=5g禑RL2[נU? ʟ+ȿz"wxcrWJ0$&=.P+?j2"?Q S6N#1uT=p3l[(/Gw-n7?誩[^sE? QC>2+gd˳0Y`xec3jOƟ?/ ?Eܖ6 6noR3~F_xA7lq !Ʌ#m"CwܳO IdJ /q{VNqVls aKexpd\Yo>DŽ2qE,ya(5Jˠ|Vei![G[bcvH6Lv\n;a?"% c2% >iI:jMP'LU/<sk]n4j~Jw]3OfUf/83Pq"i?Em>X&˛e3>;J꼰[޲9s?)AU.߹i"Lp~(gMǧ(Gwk,{?X]&yV~ڏc_Q'g bOl|"*OTȄ$o;nG&$][6Szh8/<&z]8ԗWMybwȟZ=7&@zS%Wc\aK&ȟ c}b?[9 II<Y#?55@6)ms$=Vt[#}emsD1ۜzS+"7x\^| kZ~??cDYhGU?3Ěhv`<'"l%u^`^cE͹\%/[]Vl߻}ӷz)Mvs=R"Q#?ܧW/lvyy8u^9Sک;F$E)}?fOֿ?{NH70{s뿱Lx|W?Y.8ixUw|9?%?K'T#/hUa%SWAO Gʿ"j'̀=F'^ӑӥֿ% =VUg ?}ρKm_pyN>xS|^5%:ےc23" _a+!d?ZAF+뼋(kྙ;f'<"ێCc7!,zeIs5ۿe,ߵ'nE bʜ*$tf_~e>}U#}:o /jTgokt!n!9+mYG=澤A_Y͟uO7Y&co9ϳGz_ӞAWךmgLDtObl8\.K3p|ӚpZ}k}`'8SOYlOTh.L7g>}MG?B[S?©_K_䏙;锪Xc 8c?"`_W\fS[ ӥi8#2YS3$*?;" £oǽ\9/_eZ_¥P;GN+?$DWZP3pﮘY5~:{s-' Kj*E.i|*ٲyUj1*ogoX0u."^hՔylM(|$@Vz1WwϮV'=%-~ K U?lZ[9OwW??M ˟=OFxŞ`N6V/Ulw V7b,_?3|?f[?Kƚ!Vsd?5Uھݕ9_◺7-G9C򷱵S{sm,%uX_S{%GRǬӹijEKNDD]q~͟05[tGq(;|Tƿ;]EqvG}k~ģj~TjUWO{kQ߷? ng;/ }Iv[}ߜ??a>Ϧ$5U+gϔN ? K<O-Ty???ϳ O~u'p{Ao̼?ג}I1|?nՠ%Q-Gs^g*{m+gמ*̏N=ZkPʜ:CfVg%۱kB* b;6: @QهaeYO˿w+=c?2e ]qپt?`_J|[X`w&${S .ݮ# M_,GOmw_գkA])pT?G9ݒAg1ȹ?Pv"ȩ%dB$ܣ yp# %bLpo/Ӎ͂?p3L>X ԏV^E_a 6 CkgD?xtRLdU6-͟1FoR?\ZKWH1qێSߘYL?0TMy"G>}9٭Oƅl>bG(Y^DMسyH`japPdܧWxY"ܧϔYFZOv;O\x?4t 믿c;uޗzz]PϦum1sE#[0߆6w Rx^y\$ uq%C|9q+!ZB%wѸ ABAJ%ܙ=.іPj ᥔh5}߱g23?ޙ=97quc?eBBM:mK6.xPv:$aw\'j\{vjlԶI\{XͭʐڌƗn舲<#sG?MQ o٬x` nɭS=rL[32,d...|^v{+׋]]ʮ+e Y@M5;sD*)1^t P=ٞ*35jtI={ۤ/퉫~QOj1>[*Q47T6%*cKiÚz.j K{_s OxJ kpXZQ}G5d f|.fzZ&Lu[Kvz*g ._aod=O?2vF0|3Ѥ#k_HWI^UQ6uЅISvɿ"_vu8yq6glK׋7[%l) y̳7tG7Erͥq𿰄=z_4b?ɟ֯ XOmyܝ3X7ֲ^4iL??yW*#c;44&Rþ#OƌNnQ27S^UK6hf-.,s{> uFJ0ZzҚ[]U"% jnvzO~|S98v!;gųHA~\-jrgi_OS >8J{KQ]2_̫oiNkg`To075"g/D9p*|I% Fʟo AAAB'ks=込^DZ:y6a^<ܻ ~r^[=΍;"T^| gryW;7?C((|DYk8I ,֘Wke򹁺_7_o noM~? ,C]b{$jRދ1Zk6us[ 7F%>x|~PU|5>Ie!2|ʤ-/(F#x*OzVɣgyDOVa[CyV+O~m6gOҩ>f͟&;G7԰Kj˻&6KCޡ@!'tKU[+3JfeṂ0TϚ֢`?(S[SO9Y$KŬ9=F'q?5R?OUw\04?s=_C{&)S:,Yduw _# %?÷/gPz=.aO9'x餃fzg RXӓ.{4=C??i>TxI]Q%q?g{ LK6M1柣ӹ\vIE#W~N%u .ť;3V&lG0N̺~̘џ^0ix/ek?Cm\N aڽ(aꯄK6AAxjXH1?-1.g]ӳ*@n'a"0k22!˚]/ `+uFFz++k/H/\l2wO\m?8vVl6<ȟ"_9Gz&_?-SGŵQTN%g}C=>S7Ue Yc͕[Mtm8?Ph< G\zȎ:N=uI5 ܣ?~O_dKuoQ ;M.??(hܫ(ޅ,29Nwֆ=)dÓc;S^Qߋ5ڢ|1klZT_[F/Ph eڝO>29biQ_gұ vΑ S=^?rO_c귦2-X~n,;җ >lpl+r/pp/#|+f٠$}(ȗ )t]3dF#b z߿s68eD^v譕n-9sid";'gۋk= tX3\JO.O y?գ$2zzT]?`$% Qi vNnD}Ujnm,<1LCT+Vr }I0{ͭ>Vhm0zqIH36̊{ ?ݫ<~_K|.4-ueR?>rO:Aǂ[]o^Pf43-['rjJ3ykC^ը9z;|~ gr4r԰k~(|__6zǎ czE#OQya6Y2tdz,U?3Vd}o8z`3$o \XzpL4Y?|Rm6l"ϛݜ"CD+ZǵAUOp?ȻssJk<#?cYo<31Dm9 Fdz2oӂÃ?cVaoˮ.τT\y~G^Rvt?V\*F' ֺ&?!Yw?I#"gϙ5?\sEDyҔ?)o/?ԓ2Z@ņԊoYydXwK~]GM KrT'={=džEM\ݲYwh}pwW?rZE約>x9c :67O`fhk3_ϱyUST}7߰gN+jX_{EFe?(Y";/Oz$R|3cϷdE+躷۫Vb߂ |*=0xQAoy[e?1s?KGٌ jnEDˎ?waf)[-7x^ON`aa]"ʯ9 oA>2d 3|󠨿w)s33s3'>atPӆ{SezsE-@?z=G\Aؓ|o]-.%! jy ~|>- +C3lYz+ 4NYi?ϑO8??po%!9?g{ q?1(p`M؛̨_cSH*K\k}#w,\h-G A툆cOJ8ibZo6P þO y^K6t%r]S_?oN0 6@1^+l$={_?2Lri_!W~|~ lRֶ? u^Ο|QXi /W@M*soYGUw?.R.LǺ_}uGQV1s??f2k)TmL˩O]ICܥ_عṄ{)bHdy)R-ܳj9}}l{Fc -ja^M=8|jṕb0utJ6R߱߇ao_]h1N9eX}6?Fc_ hZ =PqrJ̀0/]8ZTQ֩CDUw8z~O?/?ԧ;_E&-}g)~nv2To8K;+ϔ^<1kpo=ԾUm`Z"78n=ratP0oߢ?gzJ~>~~MeeLY[pj:G ߙ2??ڈχv)o͝,qȟ2c UeOoNiiʟ΅x|]Ni;p/\u]N΋IKϞ)cC6)x^8_[‡gM}wWQ%I/Tϣ3vRޣlYK {7FQx<۩'?l:HUǞv2}S9R~3?iyiLjK̥q.o>._{l࿨iÞЧj/Bx= X=9bNK׬jOVR|L .BUQ>6w`Ψ䠇cFiܩO.WYߓ>W<j:g\f+Vypzz;^--aW('Sa?'؏EPWm=qNz:?] @gssS}~Zy=éb<2R130 ]K2Odw3kICg/o KTk8ڽ3?4 c6dO=c|TmQ3rg>/Lͭvq{җ=ZkZ[sJIF"ʴ13ۓxgug?FZBv4TYԴɅnŐa <7T|"aFnk!Bw[M?SK=l }H_Ǽ5( mvK60G1/$Cg?ށWpO+//xW?MwϿxn'^;1;{%'ec)p?u<˪z_pyE5#b*O,[86Ny=q5i+UVC?RڥRkցse_;s.4*+FYΟS ^Yo&#)+$?wQs//c|"|^pO(GdE=|_8k+x?8ρ\ .&߱pzվz&H(GOFb'Wpfu,_UTF fޔ^ᘅPoF^W|cVu53某 pK wSi8J2O5|tX ?*ʌeO]vш/,фh}mVS/l؃Se3֑N5:Jc+[g$y~3tM]ř?D?BBψp̎?)1dgg 0j}/ܿQn3/Ms1?./3/.Sw88C!6ޙwٜ?ur?(g ýCU;JϵZϟݞ$Klv7sJZqз(E?$|@7;Dﰀ?^(+7,K=pSb-ZJ8'qV$B!FKR죈8S)\B)h18ֲ:8g瑯w.'~&bZ cn ˑ[ qWِoZ}_BR˪KrV&7O+%Gp-oXª?g͸r!lzK,^c3]ΐwI$/+~ڐl[V&̜DO[@w2w.Nb`[o ?1SRS߲p6/.sQ͹`%1l¡t/pM 'Mp O01 [_w\7>X4==u9ҿM%x1_5;7-߆% _cLG0#LlQ7$/?֬'Ϗ5/so.o0} h#'-5•?+fHz+UO*'wCbAP#8-+bG?pwIIokϕIWόj-T_=#_-`NsnZ%'"Jcҥ.ww_v/#Wu~_wG/K:Y\rk~JO\ SgNтxm#DOqxaTM~D o-z;? Ct {D+l^h?пPW=w4?\`GcM?w`c?^퇗+)"f,;4w~xO1䯯 4o䏿ZܿJ㯆Ø .Ϟ>[XeQkR_cИ?WfOYC#jk/ x4oja _sT?C{g_ϹW9bZ6_%1}ȣc0Ri?ӧ<˟ӿGpc?x8f`[}eCgeDпmפj{=O8.v1K9'jw/s)]qD^Ow<)*^RBp~?C^,fS۹_9 տ|{;q7ȦkA/omwxȿc,$ܻA˒Sq Tyk?.+>IԤC.eNvCؠE@7d{ Moq{/xm^pAeZ]˾5U? wGl\4?\U]5 _X+ݿVӻK֧9ok-oC‘?nȺ~˰C6 n3WZ|bJU6w3?=G*8.6G>')oKS@-mhQ7L; ~'`,O:7!ZC߶%;?{I'Oy?mikO ߡw5/3sEd Q/uQ9CXa<O'6q T}DyNoqP\"Y3:vg&__QgdY +M >L'n7ϰ*J@?q]HPd_}}Aw?|q^soX5/N.>^5ϞyaD?ѳB{\׺\P'_܋S|!^e| '!{oG?fxyo3 ?k'"ܫ!/}ϝ~ S⯓S 7g+CMj⯓"#3w78STO?2RKWv$q=w ?P j7?pqlǷ?wCzC㹱Wж7fͿ?LqHO_֗[⴯57"j\^^s'zڲЋ ]?#45+_Wv3?_ܖ|ⵤ6G4GȊK8}],㽴]b`?="ws>54?9/?x?×P;F; .F*Q7SEAgPfg|f;ejy!w?kk|s#?!vϿaaϕw'b|Ec_$z^_[?P#R y}'蒟eq|[ Wd;ZA)Ϧù>FtXi޲j(_:>%E~OLyՐvhu)pC i]ӎ=<}COJ#)nϸ\6w[N+Wvx@\U hrYw'cC_a_o j'Ӥ0=qX&؝w? c]܇SϿJ:i?釽?\#gQW9/BaǷZrFZt0̟nwu }, ]!??:vKfjz})g}h B eV.i+'O?h$<]9H^C8~f_9R5O| vh 5 %~jqLϞykzo'⯙^??)̗jnJOk_]A2'_s#ok; R_{P_߭~Qѿ/mu_zObx^{\UUlja!~C@3a^+%za"QRKf1DDD(y7[C 0tCK C0̻oρh?~B9{oxH:U᚛_S'~_H/V-87KЕ#/eGl_[>cK>ʒUOԵKjŖd*/M?ڳJ]PS? ~J]qEV֫3_bohG&~e~lۧ8TDF׫|.D of/klSz-5Þ=iލ#Ydrvraⷉ%Ϧ.sl߷y7c aڦ=jVJzFzx U][;AV6c}Wsod|:ζ`cJa5n,qJjζ7ԓ?Rv̵wz~{hjd /`tZBzo2ɿ`OUœQ߻ˣVy`}m?=uW0|?k[3q| OUk2{ db]y46«=u /Z9G=FjxU [k.%7iA]){uT~懶$Y ۡIE;.)<8Lr~񾜮-هVbC+=*aV슥fjv-hF6X6ժC?~Q/\k'DWNJ fÇdm #=iV?E8W>7l˔%\fyiO"W:j m ߞu_''hV0GYJͅ~B% ECg0?ucrm9uf=C:`VIz$ǝs o1-riNםk/G84v ] 7?>CeB]\E2'Z/x-?؃V!OaBzs6$J^?-Y ݨJmS} gޭl>xkNɫiN]jL#""n,oGQ-}"wffϬ^VR N!1꿶nWiłydcjc{j3,!QVHQ 姧&0?Mœ3h,/mFqe95*Q7]K?޷6 T;KzU+!'t/tBE;tE)Bg}k?HSXmUXyVfu ծVkl[ο.?ϑp$,r1iuY Q]+Z/\vVz(ߓ/?F&]W!:?AMVJ.%zVB_OZ\($꯫›S;Qd,bj4O&ovD7͌jګO#^K#%>?}~?4>ʂ'x_6XzOSb7?terlsmӜrH+'Gݦۼ7ypW_fT5Zz*A=0柊9J迭 &w&oXU+!ʅITOוêV)/byvŤHnxK)$3w/oOy@򋏍?3&?)~[c >igJ]䟸6M=lWwӤ$bV[sY׿G;Bs?m87{Ng_ګ/🺱B/<'ٕ:y67>=CK^zIbwWS/+RԧkmkAs늑?? C/ާ#??|~cuJrO'xPL"xS??i,(K2o6϶I:@ Bk{k9xk=н(XkF[G?M/|&$_l9Xy/ >1_G'1[??NM2u5U}BcjPٟM{~GZ;(jFu'DXOviK7:??vƩgR}I-p?1-N*>ΪViC;^E?r sAMcᵒ/K2V?x],̕ eϡbZZD8tU7>zR]IyL\7Ozq7b0w7#Ԕ.:_?u?oXQMj5iqMOE1%xW[vH=o9/?O/?6_(3{xFh?`(yvbsyEьIJ\~} ؂J߿I)Sر%oϲ?+G:ߠ*sgȟDu>5fпgv;9x)>eG舿=c**fªGK-mT25V]SkV?K8t]S\\ ;< -=-= ??-燙oHI۬τ|q_!;;"E']`l@UH?B#&_v+?-ˬ~Z>4,˼]o?pWKzO{zj%},ɈuX?2i7pӺP7O'^_Ǽ{?P{+@ /Tɴ5Y`;̘?hxu$۳rneEF?fNOa417jsmUzpq+x*~y{ܗ5# VȟEInk*G>ܯXW+ͦ^{f/Ooo9ퟋ5.?q^<)E?F=,Ȏ_;Z} J?~V{PES9tzS"Z?r%.}F쿸W%Kd n%^cq2u?)~ $D?*HnքmuBQ׍`ʟ>]O=DWO_U)]~\¿g[cܚ?C<[R.S#=+gQ_Sxs=TBx?{4OYcU_m|]?T+^F0 5S?Nǘ#< KNś:_ORxg?OL﹫k?iNq?ȥrO)9ލ;;x{<=Ce3?=S= o6͟%{` Z"#3}M\̪IF?>Gok-P_8_wT#/Ou}._ߞ'?W_ cg sJ?3%D|9 cpiiп)as#M &92}b K=6=r0=+0ފ|.'!p%[oqU9ƴ1蟘+W1K2{7ާG Q?O)cxEe6E~Rc~#49xV!Nx_ZOԉ̔lF\[C_p@{{M%=SM󟤺|͔:򟕻;Y 1[│;r-m1-)OX|2l/xwY.} =?$ϻN!N'3'gOZҳd:-G/09!٤K> W47Ww'ĩH{q>7 S ֯[ζ*nOAMүx7vi.ɰ&<"N6_꯫蟮G, 簤M9S_KcWgOM}$&kQ+VT/1aǤ7 M ?u35(mTf.?ؗaQ'M o?s>!|](>sg1 f_9_kϘt#: \kg#.i ^iJ9' o;R\e*c!;MOzJ&erW{VQkG柎=B q~֨?&Oܨu?^ YeLJ8+9"pbg~M#,E{m1-ϱEW8X ֜+"N$Mm}XqמuI8;/)7cZaЩ+礛οImzg2[Bt]zb(v}BXX=xT]"ߘԫN μ'D?j<ge".tY%UF*'D? Ŵ:('r(?"sėz1D?o*'cg&^W0O>#w gbO?:ю2OsR1!]q?r1:\O\f BkE?0Ϧk*44s]JG%qoGpO pЕp43mo/%c 6?x4)_43=+#?46Q5GD|][?Z18C_{VI,tCą;Oҋ=nUop'f ,`볂?fS i _ ?3@!䏺/&4{ybUZC_{MeZVaԿ+|58_E?͂^nqODEUi 'K7KM|>OD .pR]'_l s-ii6ӞΟ:Rm ;\uaM,U jEͅ?\?zjNuv'ԘnV3o/ G,Ls~<_}Br %{#Rmu"cH@Yo,%먗T>+Oר:ZCY?rSM7ҔOS&t?/3fg"bq꿨?t?o9iQzDH?3Kw%XduOsoޡ3 kB8τ1oB,-E$]9_y]?Lґw?3O(TeLԋ<2_eNߕD&Qo(X !-3?svUм l[bCU5[?tCX7cZ&K2֟OHg0IF#!,Rjgq,Nժ?23_Pij96}*4xn? 5֔] vߨŰ??q$T)i4w<ǀYq7kgzj{F2}]_3TE;rfa)BPs{'eT/?טb˻O"wkDqolwf?Gwiw6}-eRz\[~QkuKo0uNLXbJՊ#OƟUӱ޷0mT]Aסi+Ov~U/~-@O.,/Az񏍍5Ǖ]R+W{/:Pƿ,kwkn$c!΅:]e龑~3_jUk.iyrAo+?%cX706g5?8jn_y}B V=WݏU-hMOvUu~ůu%ĸ;;|CH=ƨH_+?*Yx7QrPzif*;oURLɈMHOӵOHZTy!q5C7G}7 ڪVUk>մCxo T]j7~آB ;~x?$qX~WӇߵf?g55_y=@l*O|%cƯ _s`Gd?w+4A,(ua#U?tR@_ѽqnO+ZAΩO~kw?RuؐT_ڐ)7O\iYFխsAS2 %Wύx!fݺpGHS_R1']Fe ]sۈPC_v=Ǭcw [;eC4VM joQ}~*F1zT?Z#bSJ'_%NX!'HzK: ϡS|so 6>(Dջ&/kf#s0tE'vDaHmKUu"o\~ŭ4BUMݬlg?߿VI2;)z?yURoqb&U1]Jb_n)-X/I?60'}sƶDPΦjgDC3ȏ-F[/3/ɲO6\x3F yχf*)tnifw6 g3?OdœznFп`lqonj2mSԬ kFb(YS53xG?gOEӽkepΟM 7OI(,^H쥁՚osﱎQARY5'٫+_!G6` z݌Sl͟ {=%x3U4z?}2T wT_*PLvyɟ&^^ زT1?eJ:wclOob3oՃ8y9夿?b?5=ON^'"_`8l1?T;OP^1̇qnůfkX?ffӷif~d̿=G3]a_w}^M +z@:! ;kd )O9_*o'6_>H![{bUҊo%U3y~WO.?&` O" 5[s024K}秼t5s=1cOn>6pbp?kgm _гX|y181㏮3g\-_>gB?yY+<˚yP9S/sskgn V(ba 駂?|?5#<'F?Xz P??%5X`t=pvYϘ3-˞h j<;(46p|C=Tx[c+n/̚c_:Gt 'Ƿn3vG/O}qPnzTg&?I}A5vv8YMR#fqNBq߱$SAs-oG2K_;Ɂ?` ?0?XK*ȕݒeh'"E?ѯ-e&O.uEGC_jבsy?9c8=cʹ-d9(=˽ա⠀?eZ ]f?V:g2sljc X} slrsqfSwc܈[? <#f؏3%]?%k-K:#bJyLSOM?z;9s̟ύHT6fc_f62f_X5Ild?=MOɟ>W`Xe?׫?s,~Zv'v15P,?`ۃcuY>[#;i=[օ䏵F;b'吟и7X>DU׉bMv4q[TI <O TS\z;Y?F0O5>οj:bǞ6߼Ug@ϣ-i1|*G[B3:/y& g9FxEۚ/C V߲ϸotI~hObMfomup=oYEs{T`u]= ƕ USY5?=?Iu:W ?f v]%`451VIUOO͜jѭ/b?쳅aA zzkT=_Xְ%?:Sŋpl%=7 Ry_ T|B[F}|dםM/KwuQnȷ o}bο`4ֿH=#Ɏ5Vꯝ??1;rO0'LW]ԭ{Z5.K79=?qa5Φ<8$p=ԬyA\1\Jz1owth?!S߽o׃ɘF$2y%"=V(b$69?hlqksnG?s0ܽ1ٷ*y?Azc#ϲyBX`VlwԮ»f)""fD1EH3'YH(R>Jn=83~p%goywx~(:-sozvCr~׶=PlW-1u`amԂ}ת^Q"sj%/[b_3L4+~%><3В+WvjB8nbZ?ǽc[[ؿRDq{Sd.R+?joiʴ&mN&6Iǹ-E+{~Q[h}vQuU9B?tޯj4|?))ME ;|"D?:pZϡHG?AY@eO_/Z)Mok95'j‹ڨ+v߄[Z]QG<2^ZUW ؾSkK{1<<0{T*Ңp V~ef8&_g+Eөw!e9y;"DZ:zWM$M&-bpGЧmnߵfc u>E/1e:kNtp4b~bGfܓG9pn#.hˇ9?,]l_&f4G>J5'BU?88g:zq_?1!pIaל9gzR<[ROse0ICǏ=;`aKwR#=BM?,s|d8{CqL}LGڗf,;auLfu+!9O8 X_ZSo^e"wF-A~nstl?C#gZ~E #Ew˯ܵΰ~=3Y;@GQ1Ty?Bzp OTo T%161uEqi23q {߿-{ěmHVfyTWs̽u3aO1Q|_gߕH`j1׵fsl_ߓW#FE >b?J?7,?R܊Q}JJٻbO? Mmն^uxޣl?|' c}T?kop{WI*Sfª%}UWoU/R-\ZG63>fB)|ҸmMU:wcU6ty?/ zm2ΊV't0:i$K.g*ͷf#&=jBRBzZFUJbmz7?`t/ ף y*X%+?rꂛcAzl?пaBlmOC _I׃Nׂ#{Lĉ3&Sbچ~t3bJESQv/TߕVp+KcU gMz5qgCxeE"nNmT8e]2gFm9qYqljwê{߾c{ɿt݃t{&sMڜioW0mBCߙ PUx'RW0}(ҋ'zypy60y}saOU4#N3Oըoн(K wvmrDr< S^)RFbskNs]T𧚫Uouۣ䍇oкcclzwt]%x{;uԛ%CW"`לa/ߤo0 Q_¿I_6URe*kGKʫWqm_7OϢ߲qΔΕwu#_mC@P?{=M'Fջ?_Su֝ҖQRt>ʙRifܥqͩ[X&WLItN6M)y M5_;GiI'o7r U+W?ߪݣF!fL.d}^i7*}#[OB4KOH@٤+AqUO]nE4hU?]410,gMb);cFi eտm֞_xzU.y_X ϡ*4jvEG#_lKtS6T=Ϝ?fW?uy5H%a /cnd;~̇뱵;[{3> w}wLUU/D(8ҊͫxU/wr鸊RcOM3$*_5SQ <1_+wnQ7b0gc\7/kM'fZ~ǻHcq?u1);͟^vmTO7,oڧ{zz,u0Ky`k[ǫX?Q}~^1DI;zK )g4$]ȃR;Ss<_YL?7Ug.N)dT׃uOU/PT{%n O[ؼCUV1+т?%#z$BOiMg(ό?r;?1ĵ4'M*]'8Sbly>h+m=f4:?t#IR75xW߫y3n#\`| ==[=C/[Bٜ}35?~#05WWTYgOǬkl?y_ fGzWC%?-c7Usߕl[9~1iz{ǩOpJ41t?R+,o?돨gRR}S,/W/N%":p$Fn*]I!Um_TW_>Tn]aKܨIyt=U?i2]7իVqT05 Ҩh چNg4EbP,?_ٺeL38w\OU( e[O9F1?,Fzr6,|W}m_@1_;f?y4?4!/S{Ĕ?tݺ;Fjv?[-;S1O!pFz^KQsk[?/d/s~z WO*Za-fO5MsϮ5'8wQci'ZL*K#q%䙁aop7vX/XPfWҢ ~̠4,G~,"5p7T/$ ? Q[G$W*&Wpo+yplVCn(%G8[W5Q0lS:koH酃ub/ʟ='E ?T/L#__yAyRo0fa ];sͩ[LǪa)8iHg|fJAORA*4JOgeU"-XoђNH<t<[zӛNys;!5X5yZi?E&\KO>/&^_CߟxT?|)6 6ꕝ/*odU?=u97Y; ~ar6ez<ה'0e.GE@_;?g7Q4>.͈?ճ?ԡAI6#=󶯜#gQ!‹ǘQ7US3B?9uoG%Ooʟ;aRnnt_]\_ՙ+g>Icm6w8WO:y´&{(MU]+C=ݷV? Epᙹ6Oۢ?SU‹[Ӄt>kfWPq[-}W`acPS-]G`Ds?z_tzGmy<?;udG$VɨlOu<;}`bOak险2=uuZ.f&omcWu7ߟwSyg/O?#Ugܩ\Q?sOC?ʜL}י`Қ>] Vc?ٯvі_nS9v)P9f`=Ǥ_:nQ7XgoWOZK4,?q4kL; eLSgߕ3{C|T3FQe/T5d3_G쥆/R]?=cT? ;?:~[Ԇ׸X?""[b0hRW&GIgtY{~_էRLS0RDuCxx}O=";x0% Dή\nn%<0L}I fhwb`Օ[QeIs?`OOnw˔N; >uOZֿty?TL# :>|C̗o5ڼFqsۣܜڨՋTWT78̰!9۩rYM[?ΟR?eR;;4rwF`_96ɴM؀?|^ }RogoLTCD䋯 _igk:k9Qe.*N3']Z*{W!q"y(w{62$v= NO#h!"_3j͟a^NO;yﰔךszk?a+tk?tLrK>{Iznzar HǺb[W?]k#Ϝug+_' _)cH5t?UWzi\<ʿ~b>G6RqϷu_ޥc癸MCr+??'ɫo\?H` 5O /[/ VootCo %Wx jtDK}6mN$ [qyM5C5?j V!Br1ssq[nZ/P/Tg.Vo]rVXﺨۻ>+?1{pY{%%0y!-]_7jT/`)Zo͟.G#1 t___iT*ֳw`ҿN>G Q*ޞsļ?Ew]زKIY5Diy+[n_J`).iU\L,ݬ0TI H_-]RgWxլbÊ^fo[bNKUF%#[st1E)goTڅ؞=GkaMQ?xQM<U옽|NIV zQUNx]=6+rbceSſGH P /yQ7V8I7je;5ͪ@Űjx}Y`۰:e) S?W4,ߑ}ނB-UB3|`_ݘ;R1_uO-d]奬MqO=PbF7LuB?5KIx|yXړz;H>@Zss|F(XÐdb?xp=CU H?OmƆf1+䟲YJoxolۣo YJe f䟲*W/ YMcJCȿm-_Ibf بpːڙ?%kMv2XH)IE76n"!+?"}7j7 cR8oF袅h=IOw|jk)!Q)?KgI S~92럨L/"|rxkz['JZ6\dF\i8ЯOl_feȿ}$\b '.` g! ?W +cGGg^5㤄Q ?sKzy~[ Flc?sF3ѿ$x!':)U)>;R_\RV*Sܾ}9tϽlL1C2^wO&Ngf??/JPF݃~r_ZNOh|^ƝôcT;~9S?ŒvjXN0_rWyUSv gG -v~N>-iMP'J2_/3 ^1BQ0kUiU[}ë;og##q]gIlrыָ³"/oN6ΌamxިjGl/Iwj?&sI2qSHڙyJzfZϜ1BG[ t Q?v⯭)!3e~+[~=n|9fxxC:M6m埴[H/ #sꁗUm4ޤ3P8]_GަǔK?Efa~@}^5< 3%.c؃eθ~zSVQlX4v~R0d_*[۬Zó??ZǸn2mDeSs#?l1[5X$.yB$f+r3!tO?_s` kƃ1Z ̀|^Oo3kMVQqK}t?eKCM(=Կ-??wB?\݃|e9OYuAUZXwק9%KlIIl\,rXΟˁEĿL߬l YZ'xs٬_@zl_8yL?˧ 63iTEw1!ܧȿhYf!9 Z1tOV[Z{ܑ!߈uIcF~;wSZS7sy>3g_al߸!_ط zv!*8aIL#%~"Ncd?Knab2SO?f?#s⿵[4j"2i0d[/D?cgwf_>Cg?Rƴ_=+&zU]zAB>d3hP5T!o?{q~ّ޳5_ʶD-$1%߸T🤜?)n?sƛ>PGqgЅ.l x?4ӅpQtE't?sfQ+1ri\9Mߨ Q_06$|;_7 yQb g eC d+pv{*?^4?jC'笨Mw- [?Ð?hk~&'*]jVrke{Qq _}7;gx<6!cٵ/i{p"o?ZqY?{'kܬʂ.MFJ|C_kL绶_]€mS=?>3)ȟe-ɶcýqSZC~5J ͟U#H-Y)yDgxXй`ٿy6< ߸6]'L|JZ^ڙߓ"#h"򟌈VmĦ'dM˸)n 4o쿴߰KNsWe'Vlۏ.}Nwv#OyR64S|DO}Ogg!I{P;4/ln'7vk`xS5$/D}9?΄/bf2oITs׊?>CaS{vyr/+G 8+tgrVEwljx/hWO#}7 Yb}li9snQS~!G[}e1X[!0'] G?bbd?h$"?a?pʸIwd)%6NW瞨V' U>tI+/ȩ<"pߧu9sw(ߢM_Y6ßHBN=?UQ?p;B/Ѡ  aj _!X&BsIZ2}1\]>PZϚdt#?$@oL 3x}y _I(*v ͐v_B5w{R+GXT_i0-}}?N v F5;fJ.Giϓߖ0_pcDT IV_ʞLK?`45㋱Vs!j.G!xOΜ?/ ͬ"9X߿6L?13xuLwg@Џq,Ioܗ;@wncJvҿ[_!D0ǔd_{瓞ٳ8A?4g2YퟋhӰh|urvSMϐ*8˨bͪ.Vr1ʟf\+VB/_?4O3߆EiG?'=ݛ/G G?4#Gǔ]3XgW٨{-?hުŜ_|5xWɖw?KWl<ŠRN6')?W[o3Gg:Vذtb`뷐{0rt*Pbo+:K5?翰c昲J7y^>WZ ߗ'b_+?ܬ"gN_#~RW$si?`KPVczzF+AͳGg?ϟ?~a`?['܃>h eNSONwuF'6rMwp?R]=-G75X?׻X?6&T6w;m1ؖ|38?ڙsp~+J3xŰYC *19=Hm O?9I6υ<o[릈FA=as,f;y7l&3w +@W/[b`r0|/WXD?8J7E&?6^ལd[_|Vugof[ΕuwGٲ^m7i }gS4=4%N679.u![3h:'qƝޮN(<N/_WT1w8IfKxw8)nkM'6Ǵi[gJ~.Q}#tsd2n91!K Fqg Gc)@SmÓkXSCx^{\UUɈoxEy1³Bx#"yk^Cx"DRxqy:D1w'<Ϫ{}}#ۿ~KߺN ![<ݛt$wt[o3"UW:ΌQN-U 3M.rdlnEy^lhx.7mY&lGsb2:vu?m5uNr[ag_BBex7!;*OIj+)[8V<)-6XnP sy`<_&$3W&QTi)! `\v_8oF7v cN8γ|p֚K{9ˇn BJ]CL6~-aSlpQ$t-pq9gzjn=&! qm?*j v - !rd^7\qTFMG$Вz"?gGH݆8x=eS0CjxٓNIN`M({Վ=&=Z'!9¿🉚`QVoQCG\N|Gو3 e#u7N89h+WOlpEo5'ʨk:\plj$KBBfDj#(,~p 4N5В!?ZijWO?r?z[:LO:#quH񟨤\ĭ[4\=2@=~)O:*t\O0>t_-E92OfUM'EYAMĈeg ^;jJpOIIaH1[=Q@womD?e5kX^0Da?VY' %%"pqA5$k97A}7r2;qJm17\y 3_6k7c|?y "MϤ1J$N̘??iJ󦭔!)7LYyY?YBuSKcğ\MSwvOED9+3_e=q0=1FZ*vK?+g5\#όI".U?;?#9S?2[ϛ_W9v:^I[ZvQBQBLQ8Lo;VO#juޣ̘"P1 _˟5P^#_ lfpv(E>io3 ȼ #5w{?VJr=//&: '1PAǟШ)ذ"u= '1bsJ?]`&O[{[=r|tb͈HTĭL%7%}@ȿǞN.?rf/Ew-J0W\ZoCJj%r2=:~T7N?Y47r /Y|xdXg_N)Gv=or7D Z%/'!#KL(AݞXo?a_B4ubW|iE|ٳj&DKH{;bM0Q%v *U{t5,?F.P5y6_ůĆqL>5wXs:1?agDy/Τ% IV?}7YgF24ށ"oX_[=SK[?CYkY?/ ]L%ŇF/;Q58FLOی޴'bFSdˌLAmIj)>73׏?n+5? ݋eT@FNл\_}?$$Dj)s!!g+P8H_و&sgd(oT?tS-3^@yNsHHU<ċ_ = Ćsc!?= nf3.%7L+G{~.<-oړHDZҿ=2sX Y^]ǷrHF1f Hojk-_?r~o!UsctᅟX{nuĹ jA0+C6f28D׷gyA5u"8uj!qy{bW5c-6orpOg܂ eNr_jEs)[_?%1Mn"{F#$`_obǹVig*?W/ h?LF?V!M v1<9sw7vb麙߰gg'hW%I88UKVE[6[qg^ /QnlRu aש8 W|anφoĞc{+OtKwx_ǏL.Ԏ}b#fCvs#ӽ!?=Hg? 14{WꘒGA+Q^?hNZOmHs: ~Wcƫgi!URP? 32tgBG8o:$6-P}|RZj5=DpO#NMXkYn?`⟡ngiˋOޣ{ ]*6k{ ~"UIGi'4_b=,sI#wpNp[o2L2R[տ]qLs8ˢWbHK!bꋑxgOJwmwQrq#/T_]'8{@n%&Z?"ذpLyXq*ׂXꯇ8eoh"_C?32kbo^8ڱ-,SSp or?#]s׵oZz7?{\r_*?`w1??7; '6c0?$$hC7A9[:Ϲ'+3sȟ~)'"ψnw!׿y:4Kp6%I|y־?1:OB(_k ܡC{_ X}\Rǽj(w ?.٢?u1،H# f5SX Ɉk; Qo./?qV|؄O2Ug&r43eDڠNY<RW)R'o8qT ? C?pZG5K:'GFxFg6ᜥ 7Vn)^g//&lv4YCwTA뿨?"f's5~L(AgvE?׎m_'ϟٗo4İU?|0^?hTLA"[%Ll88Iowl?u3q#Q_%AT7_=mrh-k$칹˜Q^ v~߶̘g))]̶sJmwQ?cx"yϬj# ]ڸkh ]4T?=[m?>/{-dp ,umt$;Y'C=b/u?Y pRiY? -P/@yA82Fk_%Қݰ"-o Xl77"OMTee_@e7b?8;$t}פpݬlFP1WZ7>3oC@fÊ+DBN8#wsTN5ϟ{$|*qr~O7ҽ7e9-?;PosDZg焲_.o50QS4e+?ۅ]8KM7 [5^??fj|-_b_kr{?6n ?>/9Yh_?FGBPz0M׆_^oG /7 F(?ݣznoO,P1>#_6Ү:G{\O0#EВنJ/@[+XHE??d^C]&_lV܏3Kƞ҈?ΓA+uR=:7쒤?j(93W=e"?=VI?#6ÿ6|؛t"MZqWI)lSw5>x^y\VUǍ  7%c!|9kD 1KnCDDK Csjj1j.ij.~y~{~~y%YJ2gxBVs-ypʩ #isZ^oh YW#CImN~VwbV:;H혧K3IWg'#8;P\lkdQϪKfZս֥`Zys1eNsJ3M15i>)i'믻xPNMVjChkkykGgIeMV::>W^`F6Tml73hcn/h:;>Q֨F|Jf}SUu' ^Y甇y5)irTsz/6ꗯvz_?iS!:+e5 cQuVh:0?ߥ?n_-C:]vkfP3f{i7?;MFa7`%J`!W]T;7AwdC:5,so?}Zx~nR5$MƥO=;-b[Yű sWNÌ=n4OƊ|Ev)؋|vۥ&ŝz0#N=5Oű`'Y?_u"rs_:bpt+7a3v8! #VKwFudPyy/ z#X||O~ kwJX7 lL OvmRC fg}t.vOt=ŠMsdc_>0T#sc%S}&ԾC? =*!E,?7,g~_5?ҦǺ)2bd3dHSMZ6YfQFIh{Z+nbe~:yTziSͬcXzUæ"O;,}rdׁ՝?W?3Yl֫Ҷ??'Xs {uW Rlb: !/+_iqӟswu&p .A'+l!ESp9Z 9,HS_w]σ:b/n_<^(FW${'Os)K6I~*)G,IA5.~VNQk/?0ߊcn<#NgIbii@(|`+'dbۖeS;g|?0u$ k/]Www7k7Z3e*XרwV^eV9_?3o?O#I9DVZIoܜszrO)e[wiaM?>k)'(SӘի^7ߤeiMaXuڰϰyoyS5[-i?{wE O  3[Odu =Hx~Z steS DŽ7P);3 Uݗ30PwzU+$q5F֦=zǧH.CWnu`ϏBtB63(Ii͂E=Jh"?-ԝb?6Eϒay/űW~C܋UgyM6>cJtQ7Ll?6z-| z(S3]sוRoFt*#JZ$cnr?}Ryk̝?}r@<6VdL3=/bn? 6cx@ sH;P}vS@k?&vƑǎ_Vp} v.m}"ֽ/|j?bBu%'͖Z>ߓ]ت\??iQʤL2LUw<*[) 3HOG?ucVowJGsEZo_Ī3]˗c?є{\=a+0w 7'Rԧ3ewmgX궻SJT{^쫣Gm]?=7: tOu[O?_z,pSGеAlIm?U^)µX|ҧ]Jb f&j{WʛDx$-(?q-?\Of~wكOcr̟n ghu?o8tљ6 e]KMsY;~/0Ku0QqiAHѻuϓܻۤXZ )5y7OqG:)iȟ|/2|jO~S>x@NZFRL2J?=?K+=ϜedhOb,?2xȟ =ukVPJyZXLI))/4t?| ;6742i`Ӫ<rU~NfK??i!7Gr:mM[ޖ?_T10o+^"CFΕ7H9&/m2ޞG5 =dui`ŬfrnEXw)To^M/\BVHEOX}RsoVIOY|͗#>qK2i oq+Vu^Wet[_7,}MMO?yj֙/?3rOn73FS.dh[HQ$ǻ,DO)n)\;z"e~kgSM~<&>#BzwP,'ܽOs{YbiS66L9U^͹m5՝?K+\jS <7zTpD=jR" *OG0'q/kyV?tNCg8F|wg#->fYv=ǼbO=/'ss%1Οub<.8(cZsWF^y?G {xLIp'9tXߤI$^?V~O筑/{t+1S(x Q|S1W ):Fx]P =28َ#:իډ[/SFCƊ׹qwAK 'jP{}זJ36dVOqW$m?&7w-kƙ1W4ɯKɱN=e\˕ ըu/e=7_t(F͟?:6cb=;7Q?]F+>!ց{-柤Qv;S2?}r=ÙbWCN_`w!\5)Cn\vsy@U;/$Iغ_x/6=OnY tRS}"c2AzcRmk ozmdKx]݉'xLC%rdCM[|sl]-#_Lʕ+"ϟ8eTQ BFS]FiaCE13RΝ$:М9!?i%QJ+ޗW<BوҌ_:{P-5?&~{^ehM7SoNvsL)Slտ1!z'qgz"hFr*@ZԤPW/)1?ܻQм'~l5 >_wĮ']V!7)RPQSp}0铛2RXccn Bo"Wty"^IZ_(\iYܠw6ǽOu%+(#_T;Tbw *tRu?<8Xu{'sq?XuG68"00ZKoq3sgq3Ri8O܎_͘.u8?3c:szٕ3u*Zg?toW[[EGnԿ/vtO`Ru(a/wb [Oͦg<|"wNHLu? }eE̡c,ĭuy;FrZs9 6Y /y?#CRo՘E8ߏ1ǙTG3(A//8iHQ$z);=`_,?nIeČPj?1sW|H9s~l[t1%\uU}hR;f3gf8+sϳj?W]o3](UmteC d{g27wA=>ǡsF_8O^3Eޝx:#/3_8 .tRpE_]N,]j/߃`E94ꟼI?mwwߵʦ @e:_({1>i:1j ZűuQ  t$2b2 qܙQuNFsY9ʯCB_''V?Y:r_wj졸gF7wg'7G}4wKEM UnHm{7BXKC߯G/}A$9{O;~}i3&9[L!Y&9?z@rUe~h ZP[f:icJ웪Uu^ONS< s/?T?CGSz{[]OO/d3ۨMFT 2Bc? ܟuΟ1{rܜIZօy&AAӈ}Xq2EZ%>/y\_dw~6u q﫩W);ԤDN3R+柙{"7 F&5|}Wuaʟy}0Nok3. 3~D=?7 8esz+So126g5"?uK[ucqssq^1G{(?V2\{_#+u6+éfP iu,S nkrԻW[>O ^4~}Wue:{RaXwۊykxڭ9v3Y94Ggp^sR=\*;78 8b?1TN ^T>fmzx^y\ !Dq_I C;/qO)Pj)} !PB wQQQ.sEqE>!>̜9z'0==yޙ|ʱWzQ~|lF>`GJiG-0w3ålN[l|/ؒ%vgGJsvR'Ymr}o%:#egwu`N|_cf_)Ft8uLOlƷmn~1q{([J \FVqޫOƽɂ-b݊&֮Ic|zh,Q71q6lgJq4)-Bf_ӳ8^?m}Y?}JLg[B_.G! |dP'6N̙) Ko[LUZ2c J=#;~Q.W>a{\hos_`љŬ{ʟE'V{*c3?’hu?*tKV62?UAl r?,hmZlkn;~5/U࿫s/%JFCK_7u&ߐ? E">y/Zψ=R?t)lIJo,cY?~XyH`huqpmҞ./_ߑ񎯙OgB^/vwOZQCK'K rPrnn]?VBj~cOt?Bq,8<կ br'M{K- ?9Gt Z;Y?Ms+2Xj&=]Gc(Ou  ?!ӽq .?ZYW_s?%M'ЋL^Vˎ+,q@O)l) }Fɂt>7e]\\$iuv?({[QJCOxN0'O۶O-әqO??K?|J/,iS?nVטRӓ_IC:s;'P%G^sޭ;h?oRJȿH=e.L?#'"?BOyxZAxɑ?nhʥ)3poe_`9KzzHϧ?dW17/?&s^2VCՠAo=ǐ͍C~{4Wr{tqq:p$C-~$2N?S.VwT¨zר'nEw'R^K.a?0Nv%Z(Q7?t:9N?1ei֕2OeZުaZH En`6{_2?Z&SzkS֐Rv|j??zOU7Wr_? ?d}ߙT(~2X[Ho?e+{slU^_gN:xSu,S/Mm-0~1#e8C~1  ȳ pظ3sl,t\k_%P6ϺR`??U'y$(G]H ?fv:w?}aGz]˟Ǐ}Nw_ddJG3g%qS37wZyz'v&埱PT̙I{_R}C5ʀ,O>bo:ŸoaSuIM^6\n&dS[J%19?V>/ WR's_ b¨S*7?(;X¢oT8o]|Y R*{]v"dY ]a[,4lf=i>,?s\!*?\S2&M-e]lf_|G3Cj*<]/m`kPE5 黲?5#;M|H/E"JoYT?#`lUd3AloV?{> 1ߙ#D[IBg2Jx{ԹmWΟI*y68} k-u[).XC?wXu6{572_]s7,{m\t򞿪QS}RS;cǬФ[?]?Lײoh#k\L:Ska g%`NpY5ڴBs!|H+wx&kݚ?8}OL˕qakS?2]a+_z_.vq1?pJ\OЉNfdVi969p4H: bk9W?+ABZJ~rf@r&cmtr?H`^e>Ro1OI]?'M~?zucS6j\g5p:O-?[#]'Ys7p"23+|k?xTQiL믋KktK=)E{?(O_l7]Z;? נU.Nw \f#&d=/cu8.d OҊdkxJ>.g 6Vܟ_\ :i~ t 9 阹r +VߜT̵?Oy_=){uN q.¨x?GmΌt?Ⱥ|dnOh, xtH(2[텪{WO:_<#..5<yo\j?AFs_>h˪,_ `B|%Uj:ڤzk7ufW7\k'k/=3v.O i~Oiտȟ_ubqE62{NN?'Pgrãӳ_ȫ?X.#kn`98gܭhN8$?\+5f]?;MI;Tϸ. '߰ wKfK\N'd]y]{7p1ϟ^ήZD=T9mxŻt=zicД;=rf]l73bYuwuq>X/e8+4KG",Q[2 ?6~.oyKcٝimߙKo-ػ?8C*_6?*l+w'կwߟֽOqxٶn}Z)KS([{zP2nu_,9늵Mmx$]]"<_!I; ͪ K?3,墙f:7ӿOU_;?I..8RE7׻uq^7bFwÌ s$=o8cƿ[$?8WW:q-ZQxKy;)+=;Gw_6{@'< 'ZKxA>+?fo>߫7)/{3v`׮DãW%'JȲwOZ!=m/$?̦x^{\UeM݈Ȉp>|aᐊ4!""!"yK0 C{nZ!ä慼rZko.sӧpo{~󾫁'ujϕ̎ڎ+7TRDž/iQ97C-Z1=QvR3~S66/W{V=ezz;W9? `ǎwhk~>^QWԁ X@fbMm맵|H9b "y Z{jhTٱJ}#S[9-젺|hx_>06dmM;7\󉾥9"*=km6Z;(C]cMw}V-㡝OO;06:H^I9LVl\/^_g {‚ٰ 7kOܵT;D}~Q]ZC\ǎ+_mUY,=ϞSiJ,~(gfzU7C 飁ɜQݢS՟^JG6saqSY8܇$7*ln) zJKYt4AL׫,lw k߫} k 8ԝKշٶY;п[9opډߜZO2O7*RK϶ooQK[^ouRNTG#N(=(?/s%]ilc/߻ ?&Q]u[2/]%# s[}7ܿ'% *=$;Rs+ ߴ^eEVgoUz/lݏ+ '_9+7Mg?ԑGU]t9+f*OL+<'.kQ|\P.'0⠴E7nRیuqFa/-eRWeN מVrӶEOM8|]vCų?_ Fè^zWb.g)Xݢ2v}@ύoNշrxaYi?_h}8ͿGkiteɬ s7ի`+'{e=i_Х.=rz6ק \MTw-=SS}=FkYi9/c=X;'rQ ')2Uƻlz2ͩC2oM;Uu;Gm5/?;SfI6ߖwKRE:/-YGlν&қϭ|?@t ʐ>lUؠ?QHzU=?^Is?Qk/RwMeٜKuQIOOȟqrrd[tRyߤZ&zج"?=U <)"0_}8a=ȟ\k4'Vʟ1?vS8?"Nu?0Ϛ>EYb To6(' -e ?շ)$Z"gpE_Jϰ>5͂J_<🲃l᧘[Գ̕gҖ})PN&6ϭ"\_]ܾg2RNWmo3⭬xo?!CS[_ʟWo1^)_<ο~s43/;D?F  ǵB̎O 5{uc5:5V$gZLc}-ʙMɓx)r|vor^/-f|ÑT"cY2 vX=95ԯ8\"7= P/Y8S%g;w+[4W}CFE"?Oz@{d` Qu;tA9SĀzZ!t-OϳCi]&OZ ?Qޟ)ۋ=W6,4KIo;Uy4B!]֎K-Ϡvq+4y3hUR\pRTp?-,Iӟf~ԟl/_c\PΐT]W?ӖfI:2|<@?`?06K6ϘDn?  f?1e,r:/>H[Ax3vndpcnZ;5^Q(*+g^·]ok;y8'{ -4=bQ;DuxccN<͞Fg5ϳܨY,)i9&WEqA랦!Ev?6eȆ}VtP۩~E1?fԒ? 1,${z=xG&|-O9`[76.,1/)QoKYS*^ȅq;ĈO=8ք5?]z}@ʿn#sU"O̟񺘿G]hnJV}S7 }\ e&?vtȟXVW^W0IJZ"o%#C&cF9Ś"` ]ٞ),O=muwqb3o2T&_?㯅"ט+.ezz:?CkNI s~~g "]+2o2 ;k__?)=}z@v3›c^u\-;/?X\pz=$FcW/e:bdkNoXSOprOq#ی?zF>54|4#69μnoE?O+ xeEvӔ7;ϼ ?/L?QS[ .;;1t-79'G_tx@f\ZEݜòp+bQ͇yow??4_u5WVa^:T߿?dyXm[fʟYxdS5s?ryO│/kmCެ%Aq/ >CIQAbF>)ODD Lǎ+S9[J]O!Gɍ9.9"gv|\?֟?=ObU+ʖ },yc?ywM&'jխo !~/'O}{W*?){Z5XUɩڤ eOg_ ͕'ǣ"CgvCyifF/P{(fhwC?7H-KW$kwW3EdNByoR]9vԍ[ODgX˝?$1m757>$?IVS*NZt>Ls =n">|1cRѝ!J3 +(2˟V-z ˆe)OϟVu"%^G Ar{Ui`S o1tſ8%g?g=w<]S UQ}ݥ>s?fEݎcEOXjʟT9i_}K? ςɓůD+ϻƥcsߡkSڤ;-eq![Dl{FO}XϟlO[ō_~WZ}f /l(ČS12έ>CI /L~_Ң*)UP/L刓"Ӄ?]$3={RR9qOjOM'Ϙ_W/y`nY#RF^zWZ'K8kmgmV߫_}Flon i=ѽgJ >xtbP;}oc~KWTKu7K~@S9Gc?I+wg{,Is>5B<OH[cG99+3l8g<3d`߯x)}GX';@6Uꟲ=@'OWo<1b8{vSI[uG6>M,˟2Qu/E\S&μO;f+ >b&gB-әoI`k=}=}t]ʙ+UO?Q]R0|6~~7Gc?ǜjpoD%cNbUcGß߿xΟlek:?+<~}W713r' K^ R}ĜFj=o׎MBthߛq~W_m/{ȩ"ȢU?-_MOHmõ-Xs2n:Ǽ:,4cOɟ2_;Q[3}I_z]Rs׻i)gaVÙ?1# #bo;EƿdEcO= ܪC{GKǹFٚ r9k˯L̟巎ጣysc?aq֟d[fx5LwpA?܍~F;_+ݢsWȟ*y^}zf~#?]ϟ8W$oiq?xx_A;rYN$7N7H ?g_AշsDi׮쉻?&/=(]>c."{:]ۢ71$>24c\TV''0tجǴ+g~.-3?g#Ƴ\?_n%2߿gGW }w f ?s=7jҏGm?]C xG OkoŬx^y\Ǒ$J q+b%ІG>EѮ,qT6ΞqkZ|ǚ;ZlkUo|mQ݃w ܩt2[T0yn5mE]:][Ui;%v=HLLv{Q+hH{vF*ٹ_Sz}5 ,YZR֧3q.:0߹L[Sh}NW޶$԰Rqnfs}{Rf;̟1*79יȟ4ؚDV*{F7N?7$8fi\oSwk>>aVs'F剁79szk:w ?>?OSx*9scs}p|Btlp?i\OOHU^7No]=4IG?iBcvH_5PAWvpBWh&ck:OuF?}7/6rM3gv5Veiu GCX`to ~NO':>ğ^/@CJ+A]3HikCDXFmlli俺ߛa0 {AH:FƼOǧJIgH{].B7 g&&xY$?%:*3yG5:?1P8E?8{Lqz.BHS;^({?%D\z1OB>vPc\:mX[tF?!!Ax?Or\g?V?[:ȰH }_yזٖ_qɟ- AףZg&Ҍw+7$iݹRzH&PV^vA1u] ?Vӵ/f?sO#Xt_ӽb_Zx ?S.Ww)W9lyaMؚ*<ϙ3!9sK6e_^}['+Kb?RV\lnϨVoA9Jw>4ȰҒߚSEUn6|_ 0v?Y>aČ7鐉ܩvȿM u奂]wL3!!_ H5?Wsp>FH(Ջ[^lץg|MWM="Go?/0PՑfaZRk(w?,yΟ2 ˊou*LUg 'g?]j~dX3z!kqK+V?ykF?4qz6WJ׳D}J=so7?/is~ZmDДՊ1|hʟv6_:^ cO_^^Eu?"yP?7{YG[ziaɟ4]"ks&YԒt ;慪vi4&Vޡ焧C9X?OZ:R &/3$_DOC^/j?ebΟkԠdՆc+N46υnc?@61?Уo=׃H7F|,S;BUVI612k㷷-_-{Wܩ+?O)^9Ԇ:_S2U?.WΟO`?6*Vi%C&)z=)[?w"ȔR8oaES=_o=on7+\ߐ\;ZgW3ʖ)"!a5~^~L/5_@Οs6Oښ9]@aCo#ƪO:aR7o)_R]}PgGK'*ns#W݃VDoMA?x?movwlMYnߕ 5d^M92/O݃1oK5?C#w?g5(3a9, ~.3$ pȇ̿O dGELoW$Z7}ps6߹=En?}L:mY_?AY?>&o+V/ .Fg)-bTms ŶQsǜt̟o@ywOiZ`f:]- aT;_>"wz^^?tA% ,|&ʂ\IH@Kp=0n?kަw*QPh3?͓lzK?(ζHZK7f} ?f{B;a =q`A~W/9OI?FS}2otUqavWGڑa OҒtT:g!Mٶ& ӿsK)ˌ.M@ WGSkfʟ`.u3ϳ ϒ?ebS`ߔ/>?VwzV{pAqj? )_U n,ƹ.l8eO` Ԗubӵ5w(feu϶ e?Ϙ=>[?ևԃV%f'F!JK*/OW?*o͝%ᑿ̟c &QLo=ZZ0gy1y _|]}SV} MC}i#O=؂~p ?u2Œ o'i_\?]bG /ߠc=!qUҘwmeBK㖞w8zvggLĤLA?&wMscNѵ]թ+F&k\Q9D%^|EyEu,U?q@=B)t`z[{|+&U ,ӽH'-󟀰bgҪTUUaJoQyU9Ǭ4S=ۂ'&z"83r܍39ܠ:GzT󿩿M +<JzmL*\"'Ґ N1Mk&2PI#!z6/6C?1Ȫ>X󨤚O`5 ;o:?X?_>Uɿg\8|kglRWS?/$LqO]L?wjsAT[?㜱iIeѨu_SW>b5bdC}^'NIe6?/Eb!{GĺiuJ[f,lP?uE|uq˰?BnxJSf-WOBB ILx/  -b)vz?//\3Zӽe뒊^K? ,+(H@aV#P_|EoN$VaVV?35ʟ*5Vu ~|d+{iL+`Ov>S nTݭcNڽ }&dfZ\Ԉ/} *,efKZ4'TϘkA)Oɟ8o#O?iV1Myjי_:'?T?fk̠Eha?ֶW^~Y}+,3xo@<+Jf?`AM,i-+Fqq=c*LestS^|F? JiI 2mW~Ӛ?}z[pJcs*3*Ϗon8C\;8]gtZ>;=Q8'j|+>2x~YYOSA`ӹOŸC`}{k$S&\c?]xA)2k2s|6PbWK_ʛ1o.'m:P#"`65hw?h0 Q}ٹ~?/'$ =O0*kOC/w+T;3a$25]wX;aMC L/G] kAMۜ(trzѺL B\wO#`Kg1 {U$:>?GZ1?c-ձ_QyI !WZ?Ջ?'}o).К'~W~3 e8FP?7r ,Ozw3Z݂y]s[s=ʼn]""9AYOˊsqz Otoosuqk>0?ooKqshwbRm~;Rֱ,bn)X 2دL?~9[/Mx?qH{ ?)ogϟ bם-8?bǒT%{W@KD,N;zX(7e &N'f78@#,o\z1?Sd3䟖&WEJZ[?fj~{7)Vql7q {|&׿^%:vq?uRq%;z9,wEzSOwfS3^s[!ҾUlpG3:g{{OuZ3eV=jըR~_k[$!>guA x~O(xVcYhJ@ ódǚp١/wO.[ܡj9{ϳ~ǯ\ER+(z G]խ37xߺֵXޠ8Ľ0Wx?ꟽ[M{^.6Õ3k`RB wMZiy`~WsΊZ'6 }.c*XBI,BMz-m@ELLϋ:ӯǮx;vqqj~J\ؤm͈tMߪ [MuFX51%9t}jJJO1&Z'Uվ}x//?krEPMAx_ņvU֩DmT{Vl;.ڼr_w[Pw^(~X|TiyewVaY2Γ椦 WJuAIw6fKwX~BkOE1RN7<+9gzCC~goze䟒"%zy! ?;x[@Q{pFU>o?=8S_rª#Z MwC1ICo(V: tMU{ݙBo@LJ&t\R4OVY;tJ%>#"Vgp?)^}Ŀu(%FVS0蟮/?;J.wSw')MUUjo?iko*OPo3XD FMφ(WIz+V䗺?&k?Q%o*k. u?Uf6. ~='JRM1ΤfFMU_+EX; ?is񷭘_=zX Z3T*{·OPw?Ýɡe'?WVތO|U?G|9=*J.20K6YglV~󇦇육Bo̲rX7=OPglXP׶Sqrt}gZy)+* &N8?ceI^^ggB1K~Om}8ꯦy!^7X>(qj9s߭v^q_1iɬʍto'_V5??ӸV14V)CvLrqr \ˆmV~&3q!oolc7||-߰G&QF&$/vOx^Gf8a#ژOR<4-)" ;P%wEQR93SĵϜ3oJ1!?yFJ=o~;'ceTOW#+z/CuG]]D&ZK9_~sRf} t}+ٖ-?uğST%/_]>HkTk'HF?&F g5Qj?T6_8Ro-_z4$( &]F!;5 /#!ϛ?:o߇rOS~N(ۭZENJfmgΑBvW3p葨ޱgt;6(v߯Mm#k^+wc|c#L?TSu;QO^l?POsI՚+)q7\_+um @dznǨMVSo}]t緹ЪcoǓҨ[ +)h|iݳ@2}~N#׵ W<d*nKOo?ujS(Vϙ4TQ<+`ʟk.?E?vjyIP UyIw݇wK-bkr)*X锳gA fwz-^@_承U;/Dܻz#-?G Ye]z?fsWFWz2dm-?8?Oz\ۼ=Sr&o>qȇڸ'r\i'-sO1_5Zo?G&s?=bq܋EJR~VV:=_g3|i#GObY+=?3|egUL~٣5aY{oB+G_uŨ̡G|2.}$wOz0?7_\9] y]?3Eii,foVVq;Y ט?ym?9i÷gMᅡOKkE݇8De gK@^^}?'+F8c}uY?=/?wy2/Xх_a`Y"6 s7E?LYaCmo3R[?8'@rnq-2>3?AzuQϚ?ZB' w_ ņ2[I,{Dx]zm/CSmW?6Ol%cxLgzwgɹ?у'7ךmG⇦Y!Ȕėck`≊=؟i ò'nFsbWYyTO?YQs,|LO[i-_D.t/o[Wiy^@` _i% _@sݍBO<_Q9<?}׶f3wuzo?Z< =Ua?<·"!,0Ϫ}M Q{EG[x 1?Eiw̭Ӄ7?+3=n^/ApD &_mI?{mo͟5G/kaWqm]_blܴqoVTLd{w/X1o=4<_j_u/osDx:I?!o[ ~g9?=.[ΔFɎY'7=J/ކzϘOud_X{?kw8^g\rb q7v:DR??eFj-:\ƟjMUɬL'Ή/8T3׊;"dvL}C0`gzE>?[Z7^Ǯܨ"zQ:;}3N+T/IP|cޫ7ގ36@&}`'WsPkZQ|>ҎA(zS><״s+-'Gvg I{spDk{ECv>7=x/4a4c3`}`brhzoATBRMڭcu?O_q{$'57'>_yJsh?x;kĊ3GciR Ύ}׺vylLnk?ښ厡wjs/X1k6Gk+9@N>v;\1gSL(#8ZSWb?oͷ+Go٠G@{"qS{lk*>I8<~҉ ^~W^ſF5d[KYӿ4ROIH=n?Z<Om|6?3?3w= g_+)='F;q9sp=K߿cO ;#hq֪a"]UIJG8S ɑm{aѳ#"o?eLwX=s(ϼ,5ڛ!1t?ū/8|4+&vLl^9-`3[?/ߠ(S}"/W4-~\_ #._#ڇ o-ʟ#$ct?Cӧ.q1_?Nb?S{[T!ʌo_]ҩC?RWt18ܔ??:hp|&FgI(94J"_gg?%2 >q[se 1z7Ѹ/:g"=_= kz]4m}Bx%=ϿQ[E Lz27q9lzD#Ե{X $>2/iX?,+iиiǿQP_$6V(9bo %Fe^r?\O,4YO?+K׶N)`r&5WPϠ}K^s~1goc9Y<=^k4LxEA5ݠ: Gns~_]=_a2Qm3tK=hG%ux҉_p||TGĝ/1#Ez'Z*GIMϰG׏f7ik^Ҩ@ygOenvi{ {^kn%3=[?qwbG9?(O;n??]~qsV~f? s_KƩ?PŚ&bOa~GnU^W:mp<>x_̈57o`t-zSm0ƣ=TZy,|W1?y yz*pnk^\g3aʏ-.zyx J^?Ǖs%>#:vqg?[-oIJ>nqΪxha椮-;vת̿Kt.W{_5̝oٱ;Jpfc/[j?Y[*tͿ4 O3,vc;wVJxO~toM>5G:Ln~/8Hnw2߁IM>krs4HD/5 yNnڑgi,u~XcǟzYQ_|n}xNm9Ox>ӪW0҉JrPbk?7ӳ.Wq W-d}]On\iTn VUƕ^_6(3?di_s.kGVNIY&ZSUS+36:%cq<*Ndw LwxCSUWE]weLrBG%{uƝOZT9]_OjcPx^yX\UiDD$ L$FDD:d24"b< "41s34!uL!4hDyt佽79{oUW뮽>.j\MY=X yo{kX/.YbZN,ݬ~;Jc|KpZգeg#mSsc-]ߴ m eHfZݨzTo7vNQ l ]FfU+%eӖ昄w,RBJyWMrd5qg*WgD|g\uذ-sحCr6vt)6'iּDMLfV?m)X_z67,Rw4)U=Q`m>l0ejrdӲj:I9s\/z֜iz+^o t}^-)/Yʑ?`@H俧b?/j1OyX$d"yrioIAqC-އ\O{roWW3 ߳p +Cx*KO1-e?U-"ggkJLI >ζSaçe kOk ד^cF.lHR.1tƇ/jYÐF;kEM-CC_zԪ#"gϫ/&R?i&?ci2euŞ LgaL)ȰGN ׍Ŝg?Oʹ\n䟲Eכ953-vi+׭%7)s)e#`xύ~aZ#ɐ3+6?f;2xX|}?CC'c_5p[Lc}*V00 \7l&e)#`f{ggTdZ_.w_J)w LWgV`o}HAf;/Z%^H ?k;CYʈ2uDÛ\?܋7Ϻ%A~S)h6$u$#vG_Yj!JαQn tG g[WI7$7,ީQ-|~X qQI#'NGKjG=֡ov?EyWMg*%"͹kދtRLA [=Vw[X YJE;)zӿ?x 埸vk{7L[w\kG\Ӯ!iI_r8T`4Zx3GFOO` w'GoL ㏵|J"YQ̿p"[Y?҄Rs?wNib lN߉-} +7пuNΗ*F Ps-5T\ cR\ϴTo ^\q Ȭt ~IgOyT%ߑԏ?Iwm$1G/g8lߖ8;lOuOAq?+?euOt4$sGt&~`k̩&[ǞOJ  _.mc7҄h.G}_eܧ̑_7C*NYz@M~®QgȿF5S > s俧bÑk(? c\^2.ct@͆euaQ&_ 7fQނs.?7a%?AO? ?K?w|fU{''954NYKh==˹Ļ?,1?!GgN.w޸W6IJ[8S^oJ~2,N;$53i_rr{Mf}m(Y#޲xD{J}U{DXĘm#]eEU=Ϲ<#)뽓l?c৺)yQq6Wv"wQn8G!{`Mf}/D3s07ϺO mXӿ?|FF-i͵v)mzbߑ@AAqMDϺ7?\aa |o2M'IzAi?x=? ?r$J}{Sy52h ֲuӋdi*gXӿ?c?k/3?m̟o[gZ=r#é-=Bp6q[ HOȾ > L9Ӭ<:BU5_c?7_- Ǥx\%+d·?.oZBȿG?&xsE&%~*Vc`")j{;m th%J~[3HOзkۃby2߻?Xȇ[L/}'J?vQK}/5ʟݹVaSK LjX׮Ӛl 0cȿ`6n}/QCCӚ(38hQ9Io>rcY.0]L"vIi:FOt痝͟/GOcMX?umwŌ{QW?7_ð"AqtC_W2?}%󏖿{v̇?pxlowN #pž]|k?u?u+K?l#yje M/Nɥy=h`?I? w 6Iga5_`"=JAٜޕdUr3#y8#SI1gyu6_A03|=m#y?w< )Zb>#9AvaqM>Xk-IO󇬵ӡM~z8ܰ>OW|o7yJYOdVZ{/;BV5t8i~,_!Iv/,gE9WHur#JL;eM_{oU\a^ /ߵ";>#X_З\ qg5Ht*{b&=iXM={OO7$嘥L,].TV?p_Qs{}KE?WOVGŇXt@w?Q|YjQXæ۪jge 7|g9UMwDk+ty+K_ *ǐ?]9x&O܃oFǞ$\?;L7e(_ ;SnKa3뜲ڤܻF`/?@ T 'r?,E<g? C?ʡ_8lUgx9v?|nn1R?FG}qY?ocu结"_~%Ey3N3KqMY3G؜?,CΟ?:UD1 493H+`$o: gaQ.'Uh K}i6_N"N%^W@˛IvW$wsX`g6C ?wpNR!cULh w5y]Cmy|BOPF^6I>_pΈla̘6_m&{*KGfu,1"sc&Y|Z`AEu TfmY^z (UR8F8@ 洋!6?܃r\2?!V774 @;&7Jhؠ砸NdZAg]W/J?afYwRxupz{;'Z?y aas~5w5[͠?n#U/G7.6'%]柚SWa ?Q5(?TC1#N4:??Y*Ff5C~^[xVށ{QꟅSwӡqW j[)QDŽgW䟚Skw|!`x׃k:_z&/h hSd˟5ILw}vW{y)Sm>Fk2`O W3c`YG9_<{_X$ߡ'Fxd]H?43OdnQ6-Q=_#ݯ!Bl!g?!sW4GLõ/M)Ͽ<~avrهU{_zڪ gў &Ŀ[N~YKG/>?ѰXM~-BCWC3<ަ8.,}K[~X/(,P?j~ ϫ xYI?4Ԝ oZQNQo}@-wٜmXO{o;ټw[OА=mu#'?:)x?+YqeB<;s_S;o:ϰ7FӞc5BxPjL_zYIݯ[ߨ3/C-g<\v.6A5c?Q'Ɛ1/;8uN{;|HTҸhG(V-&A߰V2Ǯ/n}^7 Ey;EuC?WXݒ?v5?>K?I?xWH3r|Bz~=Ny^]\N,#(zHK {y?f|׃^(Si)gY?K3VSsjz<M!LtgF;x.?_doF~BsN.xvVNirϫMnE?كO%Ib_xqf?e?2~o<(?^WfϗY+%ɍCu/X1<+hw: ?W곃=n!xR|޶#oF-1?kVWpvμ̰³cEuH=:ѡVrNQ:kgڜ?'X_x@d@+o_/|683DxJOL\7"SMGqWexIڡ*Rf?א/ ?1sA?L`Qn!<LpDzVGB/\.L-gZZ~gPt٤'_R"֚pgR?|TԠ-dĽPdqU>E$?Y-1篎~ߵz|r??gr|l)'km"rf0HõW;gz͟xN𯽷YiΟpX?|x^y\W $H}ЄCӄ!Cl]."F$DEq140W}& mq_p>{S佪֯~wν5vy9,c'd|Ty6_˹]ȾE?5Wfw˟}*<,W?Z.O&Ӭ^㣎%^m?hW#7.F;Kc~jV9ql]Nw-ǧzko޸_>c5tㄇoc=wVX_))y7%R`bZ)=q. lm|kOL]'dž|vI#=Տd) /ke;y,pЫCe\uv!0坝`s|7O>xNVrG5KB;{\ 9}GEU!]#cDB+'{V9AM??{: wPsٖYEׂALs^Wr:w9.ژg~VNYD6;%?p3Lg[>ԿOr{ަǛD_ 7<" XERh'lHd7hJh}lFϯ"S/o(w|j'gMy[9,7xIw%-xkQ7ss]BX9xOƟ?P plWTWO<=)?s}N?? T#*ﺆ*I%oYA׎kGo_FM%vKNoΡ+!x'B?0[8_U?gxj?Zњ># sZ Qr1[){[(pAߥ뿳/Ec?ɯC??\_lngϞAxSˏ2?I.\<=XFIWWCgsk&UEscK/9\V"ZU?\%ZŌH^-sOޫc)y~.g†R__QC?nsʟs',e8;{>0@rzO?Lҗgdbi7' cgkZ VG '];H'_I"X鿦L޺;dm?_ޑ/ߑ!' jGzo`Ŵ'Lx [64?O?IߦCFRߵ`6qw2w(/ěn=*MVLDDpv'2g҂oxY? 6#Gƶ߭rwj?٥Rs;oLs&TS]twlMy+<"RF]+IW +B"/mOK,cC{!#cZe1d!523F__/\ 󟃧)Y:Ww"`&㟓ʟWgI 9ZQi8?kqJ7U4n ϚrOq>I'ڴǧ NO%o$5aԊ12+ :CcuĆN xiS>f%no=B*"Qcxs ?F*'7iL?'7Y$j?[_!e3G]ͪKۼ28OWO+> 7<e_VMqy/16Ԡz('yU=?}7^G3}:stYOjFQǖܾpY οgYdxξ}!#>[;J[e#ngiG_j* ͟szR?_a#N>X5wLgtPjܾt9zKU $R 'R,P|#YFRx`?52HJV:-C_K]V8gy<[VL 91nѢ>g:(kӠKk 1,܇YL?i!Xk?ߵ y"6dƇ1A|&;;%ZS\o<^L\iRY/q?2ɢ-xoDd.S암=. !SUq=uշ#6?+C϶Lo`J./_xy0H!tOٖwoFgZq_bIٳϦVpMp_zSkԳQđC_-Gh8?0sGgF/ܸ ?tfi\,n:#77)\0].__'d?SB8n$?12|?_vV6 Y|^yZ{R_;[RdlQ~xlj{Zea1~/$l'ymݓ|LqҌWYi7??D}}cKnи{Ԝse Y֯cWߐqYG H׻2Y=$BK!G !;w?.hFX'/s"uRSqR |?Ϝ@W$οnqտtqޮmR?`^@{DA7).ݦ07׌u׻N1%H'lh,=s=5"s]U#\#ai#D^=8,9Z?Xedh~WǞN9+O*?1E?w8Gm|w30*/*zwtЧP r]CD#ٷ#O<9ix<,/Ip.3: { ,+3ǥ_rы sotGUQ޴-M,b-n59TX{I~("~yˆ Iipqaõ13|_@mY njcRZЧ2W?ڏ}}.D? tN#d{o_,gOnRǿ)G1YT?/ m#d'Am*oNǭ% Df1~ M3?`)]c[n5`{#ڋRe۲y_m?f|qoMB8R7?JQ!\AŹb}9>PFmٳJ?8k(v:l31͟/?Lgz~DוK*%kFs1)#6[(׊RV~?4†K2DBTr9ϸ_KJh-&KB/[3c?!KJ󯖿rR= 'p[E0ψ`[CAnp XNyjgFW5fufg#lfJSnJ.(e_O6#+?{3UgIw #B>%wZ52-z3=-gP-2042ݧ_7\IL, `m?n$o)HY{Gϸݝ|$ІnGWQs?{z3f[N5aJa1CEyVO?%źi1wx8Q"¹R2b _˟ZOa?H0=Ʌrߩffu$V{W?ٳ6-Z8Fybü@ob\ХQ g~1G?\Z}pަZ7D5 0ץ`S |:_uԏ.p|U9m)W-ڙB&]QJ6ZxDg*\8o%`0ܸ :O3=M믶]8'Py-RBPW߷xlG{Xq/q-&#ݛ @'QmVVoi]#uc{b /jI)l3lF| 1?&Yh>b:js=\:ڣP57t2x^yXP$). BwPg !RJq}$DFqPKEkΙQQqWP?B(5/qfM%}'eUs*r3K0ȐaQYaKb/'I6'3E̼&X>Ofx<~뤭fJɽb\=EyrwKf7{Хhd& Қ>g57uanOWw~-ndiWYHF9\'Un]y[r Ŝ3wױ沓Aɲy/iWk=Zʣ㾡ZbK,xzDךu):\&.UҥyQlױ" Z궃L":EȈ^K=M:pBC)*_ZAU!M^NJxO\ [1ؖ;?@rseל?¨2E`@=["d|*[?,-?pл_\%-f#{bVbbN3qt\+a[Fvi޻U>bN_Y~M tywKr˲qtg&K =C~k>l-u}3_=2S"MgZ_N_7p8sD->KߔCE>2Gf60fA>?2[r0~GfN ,x\zo3p(ݧrq^=[C-'Lq\<-D bLB= cCk~W*?G#},Gf~DW 迡??Es ݛf&6{~Y|!' LTC49ܱ7eSkOb?}/OH ]aY]E(y9Х Muo5ׅGcs,gNb;`v#YE?$g:LJu𷸯%o?=Bw&*E#٦=-wBX;cq: `}y{ bFT u.[G?k6VXLZJZMe rU wl'n=dW_???ٗtj{|Z࿥f^?Γ|"G5gCYԷ|F!&a~: ʲQMhSe3 ~yΌ?΅ji6n͌JK{3J<^GɖK]c3OM ~o gNO: hS_"(vswȟ1tgToh< cں4A_ $]p*P~P?c[z =+]uIuO zOO cIWu_yv5Z-fvd͟ꯢ0#ceY">Oz6|YuJG/)cPP.tWu3 B'0Ս_Գ V3);eOCϠ+V/__]1cMc>_^߿2O% L̟>,7z73{Ptk-?Gf;GG?eybF$kH},2j9ٹ4R]}ۋ?kjWnOYʪsAgT{}OĦCHF 6?*"Q:lWxٱa?䑖GA 4]l Z+⼷E3fج|9_?aW?>oOq6[?ֳ觀8+\5:AƹWغ?&>FAs?wFYF=2ia@)W>%~=)4{lenV^P\[bǵ|]4uYrɏŽB7T69"hN>u^ROҼaϙ?l+ kgHopWq޹"Pϊ9ʿD5'?4HStf%E؟6|XCm䏹^D)fO֏73͟?iO -5 fgCªf|Qia6o|_y~0-ϨPCW ;GΚI7 i{Uq|OYqOgь?4ܶ)<2ex&b3tw`uZМ:9u}OhO$õ;OQowtOc{lXYg7y޺-tf>losȣ?~~][vqoWK;)S矑uO矸^֚<>;ZSoBو?k͟E\f {ȸ_E+%c1 &󇴫)TC?Dgn|'_m]JvQVHfL/;ͫ=J?PPL<`R b.skqğO?z:2Im?(c _Cz,o8y-cFm?=Z[yv'Pt՘v ĜQ⡓1Ꞣ¿~FǢfOӬ5Em}G-'lIe:Y9#Ţ[;] xC5d|ov5Py-ŹRH%w:/mVot|* }E3-kO\0-e+?o=/VO!!aqig:5}ufᤛ(cɇNFc }5 z^ѧr4ycNi٢qϟTkt OCyb ˹"|K˟2^̂M+.OQF "`{lv_zLyBm+q~}dVaMh"$''Avn?=>G9{}T/ʾI?C3D|c~ȿlN?S-~$L O9%`Y3b?U?} _^]S~Fӿ=m#?RV2cMwegN"72v!C\=p!C oGb_&3m]݂ͮ K?o|:?+ε{e1kF I(C5ff{PxfO73"v غoJ։54)\4_ TQ_n,Z[{c@{`Uo:b?1'?y1dEO=* ݻz#{27tnWQ?Ώ,1kڐd=A_X?>gT8+2叵_הVaF4P_f7蟴ٺo}_Aw-gGmE4ONg^n NFe#Bt|'t~4I/y3"Q?Xdl\-Λ?ذWuMY,k_0>? ;֧X~rN ܨwPD kRGbWa ϓtSȼ$fs%՝?~07vacŨh^UaC/wV?k}['?7?"fҮr yWmqK߿o(aZϯwڱhpQqw .|,D pґ[ϰ? i=^ώKwr/j^/5uE|\ |/c5 Xګ35w3McO*COp?2ʈscyZrS*GZS>OoN}<_BmxGO==3lS]/{|Q6J3wźr7 /H5`-REvg_؃G_'>naA*zc;pF0#d?ϱY!1+xWPO<ΟU{c[}u9ixFRV Dm6|7ʕ»ow,6Οww3'?ا]/׫:7oﰙO#_9}.Q_u{~E,RY"?ۡx?Jo-$]otKQ7_ԿϕG9S?ցiˌvOj_#3Gx?2ޕ}rq/d4q?F'ݛb^/#w {&.{êkFx^ XU׵-J q8PB)gop@CWgkX~^ؤ]ɆV5 ?Q Қ25*0,|6u.'4 ˖9km.V<,5VǦ]y^o-^ۭ5,(\؊q;(]O}7Ϫײ{o3Tžjp^׭#~mە (K~aKHp}v=*:ϲ1}Ɔ%U*RONsn7[{_nkh1gϢ|m].OW_}jIƒ]͌ Cp*i}q~3ަcGS^.kqcOmhF* M7Zˠ/T3Wn3n,1+% e l?k5ap u%6ygzwZǂO}ΆhAfTn3o~׊ ^CGQs}tPQ0b_#gOy!:o>mțQ,kI`)ZÐ\GL-06B]qb\- $_b47fzۊƜa1~ *? OA s+3g !6?9Nc9}/W'aU*@k3![gg3<θɊlrzWGgf\gr24?Ȑڕ3n{Ϭra& +jܟĿ/ ӯx>fcn=j{XԊ)ƝǙ#\oG#UC3mOt|߸3 gh)95v%!G_cz#E޼Wk~aiw x-H<e_uTfӺ!?]BSm'-14O]~1_Ԭo|M#WHH*+dE5LʅtG_z$o,?řkljWePw.BôMto>i5nd/H!=Y8X]ñΉuO8?Gd._ڧ380WŽIe[[νG{p}3- GG}}Z.)fz}U[ظVn_pU|=I=SӅ nԿ 8L /?fvGH_k3p-L, j WG~e?.V@'8, wkϹ?MwO-ן8m>Y n0o:=nR#蛉? 3/r#`W>*ߵ^ƣ>׬;uaoا/=ˍƵs.1Uz{]:%ilr79p_?o7~!QЧ2^Bgyj'U~?l>ءc}Üt vt ̑䬮U.ϳ|_2 ˍG#?gz/xTq?}2+]ߠ ߟ@,xoژC kެ|N= u+eiWtr2 dH?[ah.R.tw,ڙGݼB?<5:5R=[g 9AAo\[ɐ^f'jk+ljqi9?wĘڂ5Qqg3R9)o{LYSŝo~o[%vJs'gKE֛_:\kC>Q dzWؤ7vkXXΏo? ,ٮo䯭;*#L[8?#򧨿 _7g?s-qy'h1_[d vdأNZ 4??&H3ְn7'̶5%\+!9א۱oV6*8?A[Nq竓 OlQ}tMOvOH8V?{J?_4ׅ/ߑGeVRB<t m>9nͰ|4^d|k Wϝu7duD9Yp ǃ?LW'-s#)'7,ӿY!2\9YOG7mB{Pׇ p/鍣utpvA1/ Ͻ:߰Rw/Oh:'/-;k;!g{sdZ?G'j'8,{EO]u^Cs cEWqF C-]>Qqqw}Lt?vN>c ?/T`7ZU7kE-%R\[g/\ty-c<sW\_jkIZ:7U_0i{~/ ξqR !jg2wH\8p9^:bmFEןLeq?qcgt_ۗ?.X<&*u,]z}0}؆hʟg]/ZgL׀N54ßo>/,2EdI/)1avO}][KSϖ[għ7$g/2q? iE: {HW֨B;}U_eS4ᾳn~!Vlt_vϿ4IKwEv;\vʊƄ39>۹'L!{_W {FeΒscL.Z k` I;N7S:}f~OsX H i\TIn$gyudCtCdy,p!f No/?@^܁dÌQwP-#S% /#F+'?'/9!l_1.__#nOnL(?/&fK">Y`?zv r:?ߴ+P gsFY[3G֕JgvΟ^Z[ac_?W}l?em̟|Dp.Cv/CY೾́<6[gFx_e: ³*x^y\U׵y !H"NH !RgoDJxkh !"!TpK)=(RK γơ]k}rι|½s͎GqiYrJ8sͱ;HlSx{\{syl_Gc#'rb 'oy}OvmR^Ξyoڛr~d+;gx|}An [[gOIMGcBc@'9>[V5e-Sͭ'4_U<>e'J ʞ9z)R3=f7'_?=%s_5=n;p J\=`pff$K萾[?e;*6yie20yamvm˭S^1dl9`ȿcm#~-r`uCRo638Jl]OBFF,%4_6CkYcv_0ex'g =͐?^ӂ/Px f?-Ì3#//}ٹ%?k]\wvͻ0`6? oGQ)E2~Cއy٤g1ݶ3~[1Ӌ?nZ"ooF? 9͐`q{AS \cOU_13F׾PН+C;7f'>Hׁ f{䏺L)z#R&wRG3߼}"U C[u-7R^'UkF2G:gNN1 sKMKEP^BWڗ'#Є尟N"WK~MR~ 3}qaA|7 %`훻o`z:1XlgL?$ $UOM?#]5.&@${ݕwT|鿢VIzOr=s}»AbxM? =/FSGE+Q ZM SPD /#om#H (-jX3Syh6%t:'WRus=\ֹRӠ?R{Ķ@J)v*GGR&:bl9-)jaMjko\LOc^~/+̞=]VϡҨ-{AR/KuZ聆ov? |/{?s?f_`ng=O53/r|yR}#֝?k n*bmMg7V/?%>{ܴNFRsmW Fx._U4ȟjG__kMz?-ϥ пS)~ۭ3hϭG5?Pɟ)547[^ ?p44TxߌvI_>_K}%% ?i+@EFm_AWlzi>3YJAo|3 iMoyC __sdP_̄7/p ;z׿{ k*AO>P IWէ%gPf{*{Q ~U`b]Oxks:h)Aͱ@U|9b\rT@ >fj8?355Qe!wzpA <8S_YZ4OwBsxrS)\g9; O5h5'g {.e?#g?ohyv? e?+"_Dz5ǎ_O_B6k{J0⿜C/K:xۅ3Sy )uW{ۥz~C4zh :L?3:۝?#JCȒ0 JS Cs?9ZqzW?8?BqZQaOͱU?.^!Pq|#E3m#njqa\*OjG[ʕ "O_z>T`Q3+sxh0 ?&o-hW5Oj}N~{On?fG_ۈu} d1FySBøSy?~=0_RgiT{Н?OlPF=Ԧ:*N-Tupi}_,?Co= ,8oޟZh:U?ORLXm0_m3bmwٹ]xkz-rCȻGSǽS럢y ^#F_k5YTeֿ x z0%+/3:r59Iޭ,1GP <(dCN&HWu3? 6_G ?dc fWjz-fޛ(uZ?szC̟Ƒ\qύ3fgM?5B?~ ^\RGOMP < šιO8B!Y_3k7y(o8{ :Ϥ+ẇcsF8OcW̜ /۠ҿ{wɚbn}l?d~sAS{e2@w@!ώ3 X%&Gz5ϜC6eEkEn3jY CM71/^ Fg :sJ\M:%$yC}ſy/3Ԩy9T՛h6Gu9o2q {Yz qfF@5$ǔO?<{?{sW!!\?<}n=jҊ:||ߪ76"柸7*^|? r)3Μiz|P?Sq.gɡseY/Y'J`vSvh%?WV_؏㋺3QqF/*fi/0<{[=FS?H&=9疌'Qsz{ug=?=ɯʓTQ\83+kSfzƿ=:yոcWQ۶ck3Z?[Nw}gD9b\`Z?3?dfN}ߝe 9C+>!\_c"㳂'D5՛(?pl ܷm<(/F[N_X 9MT_)Ԧu~@)08Ȑ?)!<[Nz`=ДI Js{vP wmޏyT(!ߧ˸$g_vh(pxk/zڔ2AwT{>=Ҝko.74PoffEaz }i?5[q ZZN}oI4T|9E9/?ڭ-EL kRQg۝C8ކZe6[6 _~޳g_#߸p7Zka9_(3ύg _:c@GcR!Apox^`@M7sgR\ޯ]e; <"߼C{{yByRɟK!Y5g`+˘gf3p+4@nG? OۇGɟ|ʐ? #,柂?곤ZhBi[5z)oz!CV"gGXyG7am?n(K0w ۣ8[2VN]{ܓry`WkFU}sV櫌/>3aN5|?:Esqk{=W+yw~Eio?A1p.>̂;$jYk@^]ޕ1R?!W*T?h_g>TS57~Da}Z=>aiC!4|s/2?q,?З3?r+ls5O;ƅIp?[?7̟?9#zXaqg"_6jowߥvx^y\ PC{T 1HR_rw^k(%Pb] ! $qwX^K(]n{~i0menZ11FtwzNr1j擘})qaHPvxF }b@8~l5{pۗZ3s-֨#-E;=dbmPϴ;i#Փwgg,Qm@ԧg}iYGzϟ{VJo25!Tj~%m#*\ԒM4Rֶ7mvA;!YZ&_NU)-kvm-qI3ګty-֫lSfj-uZ7w'oϡ/k4\ͻYT>'Te5ާB+kiS| CE}`-蕧^Unf!eXKLb%u34X\\?ٻ??>,S{ӳu3_(Yƞ[.1*NI<'$cg!ps:Jv^NHOvV﫣(cߢ5!:NK4]Hk6ޣN(S=9*?6ȕ0 >DJTC/FkT?@;풖wsof7҃ .zqk*eX>-YլZ3nw|H Hj.0yUo?,lYפֿo$><0*_O+^oKI\[!n?H?5m?]}5^YBfW7R$ۜS'dbac "0 C}_SJs>I-%j }n1_L ^f?RzNL*\BIyww"&Jqr?vgٵcUW@"Ā\LAPcE'#W|vި]=TڙB!?>G?nkլ9ǏoENRiRts=7M]THPe60+>[?>[ɦ\Kn[HCt?3>FgݛgTo.RplaѠw>B8OPwA{ĹYO߯d7FO 9\ME}wc]c O&՛B? ;SRs*~R'Fow<&tOZaWrb'։5Y\z Igf!Džo6_֨f&vATWNO Ս}nkfwZO^?ȟQq?߬O3ZSSfΟCыOo=MeQq)aIȼ}?&RkgQ#vΟ׶s vO_cbΟA g]?둿ԿWkOwt5_QYgq w?vդ"v?|+p&/VZk'4;=oK D#Q&Ο{ke矾Q cW?0'EǺk*nwlvmCI' r7?O 9v˟t{?ȊFL8? %:'y|=)v[ZxUرf"d O-?eGSaQ٥HiZԜDP} W=/o^*IT%?i_y<_?G Eq3)l=bџfOy^E>gjUowLOLιkOµb?.T]~[xpON`]T_]_18/,vqZ.̤=fR-h]o؇?W{ ykq2/]W7o*럼E|% XI84WT%OTߦk@x?1W_0I_S{j?qj i,?|wUgn&76S37/zk?~(X`z~{7o=0z |[?>/?ffoч3R^S V_gYۥZ_(pls>T~oN޳k[c G uI?=/<8g'J׺|9g5HZzFe;%{+A_$׏{A0_^펃Թd{/97+d{ϧ"j1"Yd5~ep_5epF??N{FKu(5{ 4o6Sy??-WYzV䋉?.܏^;ʟjTۄ_ɦ';N2i{d}"偖9(_m4l"U+tw 2`=i.*;`H=שe^q/x#o\v Syjiq{yYyM<[Dg]/Tq4Üލ}i'$Y;UNNAd%eS` D\[<29>Q]~<_O5]NNo gjdcOj_'?J&C^tWtF F/FG~qe+f'Z'.12GOX?[W/VQQc+׫ws>jov4ߵW;ֈǾ_miK׻?7t} =w?'a)5s\{S>c?~[&d'Z5OBBHjU+f!g||_ӆ̻wCF:z~;?ꢑQ~A\]~S~ hmb>o?*O1/'~dS]01?4<}v$m\=yC!ʥb\T#Gɦ<~t6߱Nw?&}뛆h#+UrpGb?=kYB=2H/;5O//]Iw{Wfs^>uE*g0g`wd#+*1S?>+s=nx[ްwz)LPSa_g(O^Ho3ڣ wb/@bZO{1J9ה+gx~?q&cEXMj?=U,Eo? Į_;4c1 e6E?8oBFP_oՓhgᏳTf qf=bm̟7>CB6y,I*O8߃g̟ComQ#?vcUc#rеK߼w96 o_WΟc%Q{Dމ?PPE*x]=mi揹OޢѵzkmR/nᩝTj*`tXsLk'~E^5 6g+ GoWF֬wU}Wv>_X3,Sc o=0P,Ұ_:X8vwDSvq{TKU z~9Eg|gd2<O$W|\O?8eY@< F{Gnf?k{[!_]opJՃjv_U|̿d$0OiIOLAw;P9[=Ο-T?-_s3U*#нQE׏,3s G\7}#oե#y_I3F9*kxelNIۥzfwG2u+R,'~f!=rgl;) }6ӊo:ϟ'JTr9 R%F vn_O\RlsvOWbc)?K|g OIy}Mb,׻FŹY꛹ʎ oX|LOÞ/?#b9ϥ/!f_?4ޏ[M\҆O)L^?^g_`T<Gz4 YG40?_CY:ʊ\'^_|69*^5%pӭ  qϴ?d}KYAw?s_wpl}_%/ؓ+~g?⌓6aw򟐲Z&Ei!fn_<׶mFOYm7r^ Wv>'K8_ +W4<hoQC}_6οmG.o[Z^eK:=8z p2o8Sgo6+p쿁?>"`?xpE9ƿQ[4t}jOӳ篢3p;/LǹɿgrJvG$=caHVC?8'xCz̟x^y@UǍ! C } a1~ 9JDDD Q1cD5wTb!}{Cn{~>9~XGqNO?˻fdw yRS٥f< <;[0pd3ņ2 ]~I:yi➺JndmvMR.u@ޱ~{v+-/~c =9w -[CB2{"qS2ɝI뿲4^vvQu~JkHF/rz\-7唏V}.O~Kιdί"Up)K(rDw<$c2lAl2/eCjQ/Fs_UsI{W?6hxLF6tmj#KM#A;rw&R.';,M-(珟JY(3}#G{ ݠKȿO;mӿ㏞V1{A㳘!}$e&?%g03Klɖ;?ucCB2)C.kZ2Ֆ@ oe-HRfHy4f'f?ҝ_ qJm)ERϸ(ǨeӃc$hdI! >yۏN(w???O,y`z?{b?ܷ wy?OhH;U?Ed 5gd#1?j~jNQG㰾G7Kqzmayxk/'IڅSR]ƟXrÒ W7?чO{?՘SH&G࣫;Wȿ-ɨ3:V9KE#Nw$ӛ6-' zJt:74Bޙ+u/?C[3!X?|^OkRI+jS$#bwC;Et^fƇ$}Y2 f]nT&C}mf9\S3cѕ=tqC$8RPgP.-M_}/5r(An?I`B}o{cҍ'_sɑGIS\^jz͌9G0o?;Yg'so͸)KM??FW3Dn@O8>\1$kY?/R{Ns?\JZN/rzM!\?S7;OO-K#]?P>N_azmc?RVV-/`=n7Mr SG0!\e2CD i~OA?6C?GO7O(AK7~ʞSe'gHa?#9{+ n Л_3c?IVLY%~玈2yYRr4 w&y?S-?h#AAE(~Cy7wkPmwU>h}M!Y~Л* {+L z04xͻ+Fٞupp"]Oklyqς֫,7dQϔ? E2Ow]+p2/CFdܔ0VӰ#? 󧃟7EQY\ ٱm"Z.D>8qx"Aҝ[?b8qXpvGf󷮎1zK꿫 6pJI).5li6c:?&?E8x:?Ι82oVO!_[9UblhEXGS1Ğt@." N:Q}k!v`YX7|')? nQoXR-ojn3@RgqUG俸y?>,RG0˟ד.V?rO?q} IHcsq`E{U3ggĞ6x*5HzW2?[?=VI?sv䖪E/SgAAA:9y{"?8∿0^TAA3?蝢]w(σj?rρQ˼\ 2q#[2-;}jwoJ&gcsz aJFOrv??܍O],sP C}`%+^]-Osjڰdh@EөSNLBO/?,Od۬Fܔ?/_xNgbWzWgJlDŇ]ҡP%<:I7sFmB)6y6ULT?{߭ȿcBM-v(7htJwxm>eo5oF}W8Jܗj/}d8J7zR\{nj'汶t?0_w*# >qak7Eg`{ciisyP. `:W)60V. 2Z:Ƕ@~^(go57_.7+\̓8˟{n`W@][?^wqN=?%@`FouOlhf _л8<_{v@3=!Q8Uǿa$}C/_=b]A-SYż| /1_y{.e'I8Gڗw+Ο9IlȂ_joSou ֵm- iK"zu7]yp37_?_vȟho?Χ`߿uCZtxU/5\Wܩ])K=Ʃ_%# $ us+TίފH9u.*d1bGlXY?dz=?x]4>L1e;T1&~bqD8?!sw; *{o<\ts}$5UT? Mn;g_>%o 7q,7ȋOTUOl{mcB60 _}/?I{N\QpS7 jb1ޠ +` NǹQ<זgރ3S+#U{ttlvRQOŨo ,~^as.-SS2V+gPs?&fԣ EyAqN6T6O("]c[pVH wO]U??ۤJ$ITlIQ y`8 Dbϐ̦f<[fdkƻ OFWsBxl[{f [տsW `Tms#sĴ FH?շ>[3F?wa?֭; M  |?ˤ '(ɍFº0N~R3#{$? C&N< ^_])_hoi7\o OеPZb_/Ǔ+x/ն7ԑ1>tJ<-k_|g!CP[˹HMzZV1ߵ;NgSAp}xFCM?{`VV5uXMc?e??N}yKeAwYk<ۢWCO~δ1?y?ܻ<^`7 aA痎g.~Bb*_Կ!|3ogmaKW qTNV2g43/C#cR+ZF7XܙfAG Hn?/Hn K}]gCKam?7o2䟼^~z꯿k7_&OŐ?>s׭Ӵ"?a CFܳ`Vl8?+z8 ?~-s)Q2fa 005Mcw f=kSl;%lU\#(]e-%޶!`t[S0"9 .! Op~>?xɤZzx E ?xNQnw(h鰞==p.gmIwOB^g\cOf?xF_|ko/>!Z'cc^O74#."?]әOWIO?'Wq$k ?1Y#3쑱Wá&p& ϑo¿[?CkQ!bCFH!;g*_Vh4ZkdQP_Lר쏑 /0EqoއMy]6 ֡Z=L4oZ)t?}6l|/s,0yIGsVG sjhYO3%P~?+u"se#k{2ew\?>P^0ٖy2AZOPZRrcgwNT?ϸ Y'_4l?Dsv2,܋R!Q0rEL_'?'To{/ǎwzX!o9?ޯ5ZʻNwypMݾUM;M3-6Ԝ2[?3.ڜ?7? շȔ7ݯK/0IktٓBν.oQoviI y~p7dRm_;;;3T4S.d;a5;*8&RA(`"f)b?z4Gw&7QEG1)[{ 1)E?[3$Ϻ4qބ=?fmK5"Deoc; {pdXi<͸FeF%7 A{Y KD ᮾpC(7tSIͧHy2k} \oSd\:C#Pe{D?{}&sO$oUjřCtW𛒑THGƬA~_i4e1l:G_n]VS17Li'(󷶰Pk./R.>kL^{8% ˽a?E =h=N62) hLQ貿O:O/O~e#KwQ$A^iC.~טw aJV?EOuZ9,JeG&1T o ^5e%(~ǔ@qxo [?q5rNhzz!܋=9ȸ:g2 ytzwm‚_@>6k[aȪ.#~?(}@]5KF,{߹vL7j?8C&)g ?"eKO5ZA{Z wmu? /8X'Pm?2ds)x߆Eb,#Q+ ge]/Ԍ߄rkϮ=N74˟{:&O$ wPiWO2q\T1N?"ޭ@=Y_RP(ߖDU${yȑG-+끑-ެ^ySʴs4h_1̚.[q tε-xE/Cq'O`t?n{#{r?約Ql3#oi9LYwAX?"OpDkrYlWw{''N.6o@=}-/Y;Cg)a+4})7_3R?Ovwп#gmi 3VG~: #eޢV5C=RGG E85,=3?x0]買tez>ǽn>;ƻP.{,-ߢMcPJ8`}?1/Q~)zot"\crF'{]fUy_ YA3#=2οl|5/Mog㡭le}<uonڝ,o4_0f/؉꿳AaPk`ߺ;M(ߨ?q_g+7#W1=;ޱ0x;տǾ\OTWK؟?Q|9*}ȟ>])}4RvhXC--ʟ_kg'SH6 ܓ.a[{>ްYpP>H~8|wG+qOma7:_4~Ǚ-']#dzuok_D /.^<-?ORpYuOZsr :+"Cbxzp^L,GqF7PnoZ)x},ﴑẁ簆߸-atޑ#d[ +kNP= dStaLI2o- T _{*0Y~x FLY9qO"!WZPDudt}Ǩ<Zpc,Sw)eŌ}`*CMU gq' ;(|aʄ\!? ȸ^ߖ" gxcxjմ3@ sZ͸eMcE7ύ3hb?WH/?F8/G>2Β?j/^0;!{i 3lY8d/Ɣ3*w=g>2O1?Xoi?-ϙtĖ/F-,YTOg8_n痍," w=c7spƨ E̘_Ff Uw։?v[4Z1bog7?9>P?7b?͌?gFkO˽c6enbS=_QoKt?^?]GǵqF{Cg|6g;L/DH/}[Ƴ{ZwLj,Fz'oSts2C?Қ떦H; ^ݽJq*/~t1?7L'"D}A/<8et7lg` f_k:S?j?FkQ3 gF?q[3 g͍C? qL(ѝO8]*/<Ϫ1}ud5m43Lg7VZ}Փ}%X[/ -Ys F~C/.ˑ?VkW0Z?r.?eLk7}vWf=fi]e]?!'#xMOﭢ.p=xoJg5%wO^*'Qkc wm?2/v?mg?LUg+t}Y##.CEqX6 P=C; .`נȘ_{Cn`wKϲ4?ߓh)Ln[nvQ{w3J?0ޅG_]ikm_55p ՠd1oڹ,i?ǔ/DogCz⿚fK&[;cR3f~)>V64_ZuKΰ8u{_9{oFUʅBĜ%;WgC]wJ lɄ4l_/ܐHv@nuM_Nf_ҤDwW䆞R~WP rvo|R4+Vit=k>dvۃz2+_/w#Hr2ޞ٩t$(-DJ{L\6|Ilggg.Oybc>pk옴Dl|<ۼnm<޷1 [yՠtBGjt~[ \xךRZ^6FܗI`\sjܰV'˛;a?D6w9͌esx3?˩gK3ܒ+ҮIؗJ26jCaٽ%:#|6uNǷ 9iM?Q|Y7]i}S'/K;}_lFJ% _'6VF1w2- 0?޿ӉL,JPpQ(j/LP-%Szpvv*gwLNΌO֥E2u`âS)~'0m6!OuMͺ$/>B¢}O6gx<9qO]MI׫NJX,p\FK3Hk__rUS{ϘrF;}WPrJIeLyKz/(;(]#~]B?oEgP׍B]yr'{P5oI??<֠?㽚,`=J!#?g߱z N'zS>;ۈo\0vԾcVC7w"L=kް-y/oWZ^.ɞB͙gAmVg>#)-u)\_t? |LzrDoQG}dB6՛zg_rkcbzQпZ^MIxQ #ʌj/&ۅ_Ϛ?yb*(ɞOҲZwVAw"F6~ A?W5#ewpJip6f}9!ȿcG?AG^"Jq1_w yVltIck;F]g\*`Hw5ѫqMȿdZz/>ҋkQƧ񟤕,Y;!/a9g~iS'A8W*O N 5Pw'%^`Ú A]C]ǰ {4_҉ρ[˩C+e_zoMrBM&NҺsZ/.K\Y颤V&vdO?zs„;>j+GYSA9pD?iǧ#Ԭ&(2]:T|Y c} k!lCΰ?m%O?ٺ5+5WW꿠[O29&aTP?<Ӎ"qcz[ m3Y8T|g]QGܸD~A  &m4O1Y{/1쇸Lb7na5X_?s %sQ|ǺnH(բVxFcJOkz_|*.n#]?􌦾3$E㳯_&yL&͊Q/X %"ۼIӕa_<g!xW5?OkzҮH^unk?LZqAbJ׫.=N[ZOPsG:pˉ9ٿHYPS(^&?fղ_~n,vKe׿Y' t=}^%A/F?&Ƒ`oɍw+w7O!oѬo.\~*E2-iKsy?nN8atL/SBKJ%:Ccʺ:?Ee- Ю_2!.$Z.?[JTg#nVwlzklt"Y ȟUD?wދE5ntbiwҷϊ?^7tuϐ9Hx>y/)nI@c.1Mc?fspm4E{tc!5gYڇӝ?To5PAP13>c74赫7uο{ħ ޽4ukK9KF{uo3+4󇀀l1(=\ ' c뻀ҮogΎ'8P`_5^?eGmO|,}DOG?i/{0S&7 ;s9u .N8~jӿF~6F111S1/[% '˚%AiIV\ fuk`_Jy{ߎΕpXVyOi9#Ƿa}B?H[?>sϺHcI Wo?ܵjAO,'u\|v2ENa/&YY߹B ik{b')a&ǽ"οpXwℿYC?~/8gt]yZC+Zple TҾ,Y̪OB =OۜVx }7obS8, ki#ck_6"C!"[:1MW! rxB @4[8,sZl?ܗ^!$&Գcɣdlpl}xa;8Hj[_u;P/s8^Fj<8W^I/Y˟gQgZHMY Y_3ݙ*gF kp[mI¢uZ9Voj/佯[a|dM5: XG}SעJ s3bc?y}}$5طHω_0;O9K?b wt N!g=-g)s@GV^KX}33${z3L[&YDZe5ӗb=UGúgg?<*H^7nf<3(<{"O$ 9 ϴowKiaߖ埆Y /ez w| '@8K!E&?PJx /dzJ)m?걖.\W藜?h?x?/8/&s͚zq)g+XqeX) ||4Y ' \4/ܛ.uk\VoWx3Z?íc_xDTFH ,GJi阴l_f#~:nT{_Q=sa[W%;mVY9*rq'Ү8_/)8+ƳkH??NaJOھsYᵙOpnx.ϿAb/cD5\:IWX](?"I+0㻜)[2(g|?j#9}>=+'#dz0J/Xω'_OKh_VX?CKڋCŸ6e/ +yA?:m9g|j^S=>RY?w:OpoIxZ?{?hV}?+x?w'e[?YH΢SVVCV髅 V|m)UcC˕-ukІzV:6E~oyv{o^W-U3KmV=X% Qq3ؕilp,iJ&r:s+;6ܱNNlۻʻ_yG(..{Փ'hQQtm]Y܎WFTQ׮Yck]IP=zjn>SY7ُkn*)U}~7jrgt)eY£*{IP]\QN.VΉ]S[iR:#yd %Jt...Jќ Glvt,]9ۭG+4/tz?84.b=F\TOW?.Y)֣[tvcos]y2.%h=B;yb޸wW*C=; :3VlYxa'LOĿU@ 1Zhy§@K*O o*wU{*/i?΋j.F/h%ٌuxWXwP؝}"To33:EJ}c\'Ʋ3sl?P/x k_}=i<yGq#Wxc{LzN/h;MyRs'yҿ'2)Qc j~ˌVCaI%}XC?_wvi ]?)E'O e}>g y?zn_(W;VFTi7g9IGt$ló=;gР3Whyʿx __u _(6榽 xK_r{?:WMc|Ҥw?}7ώuQbqQQ]َ?uC?]ܟ ?z_ɩf mZfb +`gY[Y>+p>?um 3OB 8]ns?S޸J;?5SnQ5nJNebQteU>æW GSzl:AߴI!g,Tsf[ɪkN^2yhQxҬOl5Z@YA?7mg׮i!<tr3;sr̨%l?ZuҹKcv|>c]{9u7TKƌqYrGi};kcEs/AB>_ݝlK]=eKť7.޶壦j7^mDfm4V-G62jojP636<-}Ƕtgaڿ$׾cow?IHq[/ɩt17^}JϏت\LzJyPW{Ɩ4eZ<0[Uͭd~YJl΃ F_ˌtC=S3𿖙_]Fj*NOT[QL+_S dw;OM@Kx1ip)j*e"ߑZ /Ў p\db?wU;⌿@>_r)!SV'?W>ܬ",=Eg-o%7.9OqΔѿ .sikBo M δ'>bƬo?y='GT-=]QFF˟<^KNuAvt- ?nUzqw9'wSܨ?Yz-IA?iMd]+F}2wr&Q'&G;[sܴ"2@Ot?ٍǏ]y@_Wjvx myɩmz˿Ok:qP7)FffO ~ݨWtg;~w&ъjݯωH;+m҃յ'3=٭3cݗBƵTc}ROD/uҔ?!E럞Sw'D?O)?_|h:O?O+^V_6K?p7?4?]Կ?y2'MꟲC522or} SF9iM,Fa~?i=!ӟdC~uO5Re# W"8`bwDER[~n߅O>.#?MɿLN૧z\?X,CL=8yKk3!ۤ ^I5)R d?;z S73Y}SU20C?OxSYX2D*٣"?3qr:˟AyGKGN2OB]29ۍgR~e k+޸'$4]Oά"P+jsM 3wkh= Ӥ2?Ǥ \=7XI+̪v~ } R!_,^^RР;04:sCU#N*+m-~ ~eTym3?]pZfW) ?X"+#Į&ɕ#`Iԏ;,kGuOEE sۈ+|]}s#ܟg?+K EuzCgEY(FTڃRfT; d#<4Jow{ ?Gm%_0p ,'ݜSt4VR~/a1m-|;AK~גS{qr&b_m9/[믞1MNƠUo.r'oeZ8TڂUm(묔:L>q5ȡJ6qw2>e, fʟ?}?߽q^n7̫?t_SKzm.OZ:*+c^&i7l%?g sCI)h}.} ?+ho'U{Y!cWe6u3mCt9C=mwsO5mqw׈z/]a!t~[9?V=_l#޸wߩt?D!/O\t_4'v^`?0kTK|ƫE1{>=zSƓGf Z??Ú?X"?zC^_i?=Gƫ%d= G=z et^ov/aDA&׮YPOӿ?Ypʴ.ZLїSQ=&mb)* MR}4YyGE>f?#\}#v0cMWϟJrs'ŝ;?Ou4sޑyK[TBZ!RHkWE鐓(T St,POSB;[k=sSqwTIuCCV`d?4ݔN> t:g\Po7_Y2Οs zg|e<} 5zDNmtz6>9}7͟n:?O=/z꟮[1Oi(_lV_Nnk?kt> zfу/Yi~6nz4ĺusgK_p/%q1_8W:>/GqsrQa~exrT%/ĥOOMoO='/':S 斣D"wCNH뤼Pu6]ޯqsm?R1kh _AXOZ,5xQ_8d?UDx5aCÈ>K*9畐@H,'xsy3d7[`eR/g_FN?|1dQTw?`Ҙ ywȟt///R~_uՓ&Org/֋?(h)XJeg~DgX5=Cc3'4m͉Cv\oL/}#ikΟAF8뀏9ت~Rb@M?iJ7+ Uة&ISb S?>{x]z>]Wl=awRޜv_ 46S,ivG4p1lO*}/s a!L7$ ^M]ZYʝ+ꭿV+?y)ClX'g?kg%e5;IS2n!S?4OTTk?cC ։Xum0Fσv2G7//Lgp1^ @߱<fNFp=ĎWQ?]KYHOĎ+L[cQQguzCVZ#Gv_)`TG'vg1Oc!OVN= _x|*qS[r{Oՙ2;AtO|Ի^kZ?B&sr >,fO{pb*z\7[pZ2x0굅؍|T079e{645c>gOTWK7^%gg|w?,W?+k,co?9~@$ƒiiDӳAe==pUnU*a!=][gg?'wp|R>`ZX4i*+hϚt0@ML]>q6c-#D=u٢ο|}vtԿϯv~{_npȡαOZ?;:{P3Y ? B;?Ẋt7Ug|D>np4)d=x^@^0)߭C'O3c?6l >/Ě߈RY:䟤)eI^7bBFNN?{043 1SޘzdZ<=ȃ&Dk-2QkD>ßͧ^~dLGӊnzUӚ? {>ʱ\ckzF t Zi2)35\k׬[tZ3"*w_dXsZOĺ{*?Oo6e1x6ytTwt'ȋ'MɦPNr9ϲ%v*xDHi Xƹ )İ?$T?] G'͟YOB ߨ)8KK?x3rG#`O4K'V=_ɰ'zIܴ"p\\}S?9Twz}G~'?0=( Ԫ7wk1_gNV-/iO:/~L\!Ϗ' S/39_cШ{㾕7?) ԗ?Te9:ow#3+sk.,8Y`=fuPҔ_Qp3:XOumPP2:}u1lX>nڜ9T_寺{>CϽ,fpOu􊅯>|uv_~ShyпOCI2F =H _壾&mmC}7knw'ls6o'ZCCw荏oyOW}/ #p+?Pozs+,O}~̝R=B-`hum.OSk]u/9c=1 rvqF?j7;?QY% bX';=¾k^nj)= "]E]i,SH'?8ʞXd'Oq**ґ+|Yp+Si Xsٲ_Luӯc>>]S;50?`?}wZ믺G}X__r Gz_PӕըwS{_?gq b97c1P/r}y:îYRre؏(K[A}xor.?x^y\)B 5nK pX gA\/Z}h w !%3 4nr 5.^wfΜ᜙o~yw|FK |ڙ|Ʊ*䄋5?qNz|Z})->ș:-({cQݽqswe|¹o'q۔5J~ځFqZ*m_:geL?GڬM:s)Sv?8'PQvK3Zo1h9>m2sYw5<4uvu}Dq5۹Z`--{v31,%GĪatN=?d{=GpϷڨIa4.ͿV[ty0ھ7<]%}F̪T8oLb g/9-z8N;zX}G',鲘MᰗS?lv5(-'A5oqŅQ {} c3œQkW_֍ ޑCC~C?ΩRw3"NJE?6vᛥ!3zeX?~#'?3 yIo:v[~?YPw]S<_d/1S~%/&Ww;+Lj7AV렻 g_iE{9WdjIuNli@o?urueNcߣl,$)`G?gzw9!f7 =[ĿY^jA =OE|p\1E>zyVq{c +n?Ju?y&W dNNGbfB&6.CgӒVcr f_;{ eߣl3a õ\3Ԏ>?%U'eߚ7uO禀o+N+ Z ylӍKY _sJϔ?qk7̄L?pY=ӍhUNFKrV:<<(3O/?RYUK蟾S:Bi?矸xޟrA~*?/i g%~ìGv~J7ݨ#wPMxm\\δ͝ǬM7vJ4:/U^S/euzcO+qϲՐ/c</Lth)'o_q;j Z7>~?xo/{Ƅ.n:|TCkG`9OYIyROT|c!99ٞϒ(zuB+e!O?71?}5ur==G ڷ'?tx BaGKvt.JgfGmMKJ^߿п;o9O#?:~^OjS^C1:?;|^. -EOއf-Io#lпg`TEOo"ϝG3,?]|i?qU'Kx_VJ6[;'xE;AU?OȳTwO 5߬`Y-gleW?y?z`z C3_?9a 6+fق\/Lʟ꤬sp8]o,/O]M>U"mܪf\_ꡉJϦa*]H'ـ+"f^3wvQ~Teߡ>y R6OnES~_WGeX3}[;חS\-q'GO柎>㷁i3C߽dtϔhvzz ݄x2?yIg$Çip6n!amG}|)`i63M7^S?? }*ϲuR3 ?VUɑwY%glߔ+u|Q[r._:\1NOIYN,fuݩ{G6I^bϹRh? UCa?|(aVOߛEx/?>O5_:CqYCu1;LE+Sfrq*\yeP6su/4?"SbjYr^Y*9fy->`?ytֹi=.,/35sp577Y,(U s? q/WKmiP1[B|9 ^,T2_3_wˉt0}%-8&~q)7?ϭs#LKr&_ C~ckfvݩ;:G7X_<'I;u(~* \ͪZwN~zzoW433>ZVoNr|RAPioKYK>7Z˫KJ?rKG7gRzY+<'~f?Y䟢_[>˰ YYmz=sR!O9DoOQѣ7H =D[:6QToQj?y3,'?>)c߱\|°#sNjŗ#2Wn?wIOI+ .ߨ}\?~>Ð? ZȌw cc spU5i4SGϿG}n6!חtx>FwnOUN54GUŪ8ibUs3 k5%?ŀyae?=q.!ģYOߔk̹38Sg%C2)?Twϟr) cQ0K9*z"uk׮:6f9}o^9O>?(/,_zG>s2 {n@>i1gj M+VFyEkٞ rk|ύO=̟;C)KKhq^kSm?yAكےKtRwGpğ!{CZ-<pE5G ^%K ?_!u9::xGol"'4N>Y##\X! y?KdG;?KӱQTǬ}T~߿6rjt\_gv`q!fh.eKuj di~?1G?O_|_]E+Tw3?|9nѣ@uwS@:fIϿ9>`qy^WeۋL`?I/ml>t3qL{_c-_=.?7̟CSZvm>)mge7?xa]݄.;sQ}']8M-sgxaRa{T<꼢vH>ZH}n<Ķt;e?sc_Y~ۥ`΅܉k7^=>P/"3r}@|ѲBWJDZѯ8-\ayt/Q &G#?׭/߿ͬ[@z3ϟC¦y?V\^\6Un%?qrmm*)QJQpEk%*y4UŬwevn./ߖ󟂃*6PPW ?)z׃L#I݄x s]ꬂP.A||,y!;WXCUSϴtҚ wn_=P8(xu1H'||pI\J3s1OdWvͱ?=.:V^+_B 戮/!/Z?}Ο_^f23.e{9#s0gYOrɟXL1Gk+!|_ s\/Y_+Ή<]VCn|g*V7·Qqc|uDmoT!fQr3 =ߢ> ?G//R+ _V(h Uz#C¾a0 mtM\c+s?+?޹1bF~?ohݲm)'&|斿sϩf1;8LO9 o}yۧ~F‘<~_czUM; cwӍ!&DvW I=evSm\e%w70>Y? ㎿ø'Vf`ޑOr_cT+na{a꺵L{[wVA-?_/)+{?_?3 =Yǐ?u ;|1'ė?ys i_I"ϟz>FW? ~geOYח0/t_/z[ߗ:V_׭}ᛤb߁?`LFoVOyP}^oﷳ 9Z7ݸ{5-31wRɪ/Eer~F4"Y|am}a3#hb{{ǼCygC^2ڸ_xt+~)Op?w}ȰNݖ_??.%/p,|.7}?n\ 2PwYzѭˏ|?R6i.~?oFx^y\UenEWMie;GOZ-zͺ` ueU$p]C59ciȿq@*C^cUw~V*Pjsx#/{?gmSپ O"u##;e>V? 6ӣ=C/ ?v-J}i*nǡu W݉"IJy͊< 7^IC@M u~s0lԫgN0{&l79W  ט?<3}N] c[|?!y+. gvAn|nBu9 in~V6o%;[&MC]G=#[w~UE5- ~{p&ulnN5}]uW[{{+xkyTrEGCkPZ>.CIpOǀ*OO|,ҿ_?9|"~[E!}'orPpMPPGA$4f?QPQk>ebɿlYԄYVaF B~O0sh\JXV\R„{UNjoaҿuU6G 7_?sCmX ௠gfDGezc~T:AOB9h)?.ƢKS"#kOt/LYdE]}WH?ca{ڧ:P_?r딡;qlXȞ1~Ё4*I~?s;s+臢E b;G(ec[nx.J֌t߂?χ#<9#5jRFw[?O??|hH5 c?Cr7O6wϑCTzD:Zcuǥ.EgX&_3_ߘ߿ړWXHA5_K3'?/f8 , ?E:oAiqx!TSGߌ2~v?;*ul\?dݫ)gA\}\ _mNs$Hek3/\>?x/$W<3?_zučc/o#˅L8F嘣?<]CGn X\G”:|!c[g|#9ܢ|?lɟ0h_ؖ/us/\ UޜGFZ~/ߙi4gC'G\ܽν*:nܱ?M^?x~+dv\-=|̟ПY g3~爬6Z3IOl N?,qdpG:h,+4<]NO|1%5>x'ErcΉ0w"?ާx*_e_ݴK?\?9:q?a?uUAꙏ){E B'V]wDݴExÇ%5X-> lEciJ6OG0i? ݍ']k]m? *O2]3Oe/?9ZwOws_Q ~U)9ozG>#I:?s~"ySO%&b!*Uz hXW1GUG・_!rs՝ݔO{FK~?#B^ݞv>Ǻ61? zC~ɒfϕgȿޖ2̲ 2ϟF#XRCܓ]+V$A{G_.t<.KXʟ k&MgoYbom1OD7,!7dE_իtW\ټGuc'SлD/8-x>ϖY AoƳ}q\nEh2?E="Q6^* x\p WRGv 9 S= 5ɤCw1*nQ#^ Å)q6n7֤woA̡Qۦ?Wb+3\KS`6 5`2-JGǧڛ~Y(*-Nqk07g-~"F#>qvCFnuyw|OgZ^OoICm:'jt󇦥 f+dѝAY?ϯ(&'_?@=!G0s#jsKע'>#`2ʘڻ?p&7|ʓk_ʐ?9?}6?ycׁQ0vN Q}/?_7_]{o;ۿ{f?_''AXH5?1vս?{C}p!eٺkG}&D^xV>@xVoM W\"{=i<'Yg~o? QQOHS0Q9%I4k%_d[yZs=B%^Ʌ@ &81©NiMRWt}|Y I"OLA~H"{D~z\˺wm\?[V&+o|ֵqvgi])hӽ3V-chIX~"z1=>ALgy!GE%G>ì?[uJ_2@\yKx@ں5SE0yߨ?87n?x_:矸 :{?3[.C^˪wCR ܛbRM>P? #O\\?T?][&F'wϑMHKIm&jO ךkA~z[歂}>kqxϼTɿ jWX"ȟt& Ic' 2d\Gc=s+OlԿ\g5rϢ<%βy/ .$e/·h=5G%/~Iu?2: iQo)䛊O>GڜkW2_w-M?kV?fҿeA5NwUwsa$ *w? bvg=OH~/=un1`ן)#Q s7ëݣo@hgLO +_}'~L]Bϧ4Sp?vSG|)%*TU} .ON9i,^_չl;;&~+Nx!tg@6~в>( C ֬UO({ݥGtU==[S72Kz)#t(K9ROf'__*qުop\dzw#wy.}֞&^b@+V7SN)? LNw?lLӿ_*2X 2~0n*P'F:CNċ>>~7畮_'z5foZesra2l{_d"uy"Sos'oy.Sې0O_?VQɕNOw( ~aOcwa3rqT?R`H^O3OsZ*Sv|FaRWF`gAS9R_;j*g-PώqV[o=k'ci%ϱ1SM_O=G>?1I%+ /R21)?#pFYٔLي{wZQ8Wm}^>_(࿱5 ocM-_64LCRy&̝rCEJf7OԨ HvFC?1{ګP0ӣg'j O3<ßeCw+M')hYZ.ޯQ/?跙?j-7D>Ŕ?7F)y?4ۡm2T6?/+w>>9zt}fu |T̴ҾR"?%kVJQCnO_>qYsfFyjOUqf;t_Z^wo:%Jxp?7YO,zR1_ #}[L;UJꉅ)gTM4_L}U'!oA+˅̟{~'Pm=7*.t^w?ɳXK0ob!Gy0Fjkq|;pk1U$G#oj^e\'?Y#}3M󟫋;qN[JL014ef[J};[9Ҫ+E&~UJw'լ95# OgI7uSS);rmz?yi@*7Vyğw7@ښ0_OGoo[5LWl?_/oQ.oMw?yn3P?b6_YMr _c Ir!??aп_'9y԰OZcԞykYȁF+(:4+?Le0oZ7pjS8}(ǿ~ Hk&LV?klɕ=&|&U7O\C'C{ϟ+fgOt<غ]4%<<;?e=N,aϭwqNbgL~\qԓw~g?R?t}˲0F6:=Ro'oS[Vc?wR<{BóS^̟w^qNRSkv|cGG(5C,r;0$澑;=&oLOL8t_0? \~ =άvOpO;VW5ߚv1_/u#<某*zOD>_ԏװn?#3C>9˵5wn߲п>/-pW?66vَzfjEd?3 WGy̟_}m+?w+ɱl?xOt蠚l]NSrMI$/:T a<[Gݰꟲͪ{*ޓ3?}]?/W"Oe>fO@ ߬6?cω\/+EDMo{'a >Sϟ +&3^jGe?/Z"cS7ިJ[:\o!)_"kچ#2? w̟z?C9G)OL20'S%pTZuuTI8*ݎyQiϥ/x?ߪ?}ӣ`s?+ݮϑ?$FU9pD;dqVٝ? l'"BݶM5gy u'9?fK^'BR[ӚveoDh=jw'T_\'s]m>q&V[0") j 3(=1'Zآsfu>wcӽ~9]x ?21}vz˭k_3o)q?zV-|_a ϟ?g|b[x?اoQ1ܤ^+;s߇m׬^I5STD<84UbK\+ k3Ci{O߲<<#SuV[ϨAx_r]8hx73LC}QkF\qV3<0?'G?xo?i G~,Zٝx^yXW !B *&&64:FwдMh}!B!+nB Cܺ}ofq8-|ϱn]>([yΩ9EWjmֶVsstNڥkQj][;z/ݭ; 2;Jo<7KyrsLy9bvje-6gl{:T`2+v s\vMlŹrVg,wC[VY6⦖Yi,6}- _u5>혾MfuEkhl>KUOvnnY>ܗ#~*W7qZRZd _Az_Ǡm^k9mXH&fزjڑo8^П *5]YRpLjoY>[+ Lavv^_Ǧ)jxG _ܡߺj |o53%+l?!-6#i4{Qy;V4O1|pcV[t}2(';:2KY=Qvĝ3EU?eY*-:_izX{oy2g$D<dz J o=b|+TK4I`5]f)4=%(ˋ Qk-t'4Z_ں7Tyt͌oF98wL_3+RR|pW?*Ojة/1=p|kY:4ĝ3_O;fSF^c!+ yLڳoCLW#2WLR?YI;le^Slz<є:0݃^T?!.9~Yd fZoխ?1=ss 7K*z_v,ێ[ ?Į% U&/OVVOJ1##'V j+VS>_sF/\m^sTR{?|VDgz2hcہ'Q l_>5%+2OY[žW]ϺͫCں_zjv #=}൮'y%DO#[0@ . 󏙿^ Z/!#߻?'hMl,[Ѳ9S2}1>RVʲ?K+oLBSv/>ܙOF5ɿh*>)*࿾ d෼vWjw?3[d]߶MdCjΟa'؂GÕԫ[#-W7_UԏG>Yd_' u?~WóI;(35̿hy [d m^|G_T*.b%Yܡ>|~G>$A#&p5Prknnnb9\!!b]azxpυiIoY>@%e,!?_;͙sڔE 5ٹ\H3.P6O23՛O8C̽|/rkgp_z~fx 'ݮ G\OI%'eY?Ϳg{PwK߲j{̵YOR${ϙ*dN|! )xV^҃4 hNϟ>M(ڗ/С}Q;maYz#&Msk#x2sLq^s|oeiO5󷪿A?n?9G/hUcː?qovҡ/.i)O7IU[7eqo9,kj_:[5OL-S_jҝbAcI+w=viNO% /Tr#//Q>/s*ڦ׿l^|?.},eF/?B& ^ qoO_?;Nsyy6,=[=5](#Oߥ}g7G//\bW&ʼy LҷR;?fzGW;#~zb:x\ TS+VcΟo!kf_ B)~Bd ̟2trzY>_{z4;@8FE_QUc`L_'QVrKjβ+\O3 zlRCՀFiϫB涐齜K"'D4W}Gm]Ba_~x0Keہ/) 3͟1?_җO$<+ *K6td,\*{6L,N/9L4:]g/c[V}ݣj?`uyc)z\VdEn"v/4SwY_👴A=!?B;]Uq' Twx9Ȇ4KoPSx`?Ϧf?{ƞ@A$Dk4~A6 .iN;X֞[* E S}sN2?6s1?_~r]'}Qw] 僽9h6837q-,nquҺ2#l?-5?TrRD 8&g\]ߣ$2+6eg;k3XCc ?#9DMoUńiYr}93Ø{x%CT&?˗p ᅡҗ[i_67?cꋂS?B;yOOSӶ\^p9'ēICóT&lIٽT#ODO1>̊?}ۼr 9u3C6P_ph1,?3m\3Ub3 }/r躹e<N q?aek|e};>TxQ {ڑ7Kۯ!#j?]FsP?akddwrS_]&G}X*_C Fs%l>Nc}- ?"_/m][*_v:?@'9{=iko\e]QmY'맇[I%~Qۼ6/I!+ rqXOYaO[WB u|PꟼjU?yH=ϠY5/gLqMs 7(QM篬CX_RU> MA -̆%^?/ dVxf3znnn<_?#P&V6MΟ?&yUs&%[ʼ*bKO8'LQk1f"y3k9?W _[?'qg})G_t/ON򿺵<{?}Cy}rڼ[d\"yG[[/q/{AhM/yn\]'!5]?*3W26%-_Va3^"/aóm,_?!b C?a_y`B^Dk'EvexgT78|{ph*DY}dJPf~Fs|0-6K _b }\db,i]KX&ϟ[<0좀=/|h_?_swşj?3'5TS͘._hN?#/^?SʼGϿ>txIiۿQ&;|ijM>?7_@ 4I'I \?Oܐ)\yjNռ<ɕ%#wyDj([JC㻪?#^!gQoxfaX縺4'{DRΟeT2s_7W9٦30?)_6M]$GԎq ]OIf['){KQdi<ӲE_WO_)Չ=)oHg9SA) حbq~(uu~(ln?{ss+u8=2"~*￰o 2H\ZÇ"jmyx~Jӆ_ZWS{Psp9qf+&S7_YH:4ZB=_Kk L<̟ o}/q׷]gUf?Ϭ6jʲ|Ϩ/'[Rv?k#[\Nx&c}{U|,wWG< @?=W?]䟔+b?.tBϟ8y~;9U_'>5ޝ?11~Kr9p~}W[r#+G݇q.`N8O/rJ'ygj6[? v?4/[{,HMAa@9~ ?fn;~~83f97'i{xV{`ۤ?r}#Pu WcGex^{\UeǍ""(x!r½5"8pKjZ*!"x5!b`m/x[09019aZk~}l^w{3u?\rFIu+ub~?yӼ'dͳ^XK5Fѭ}5{ DL.=wnxN.,3__Yߤ-ѶK?U /ik뵠-C]ڪثXϋ?|ʎY6g\e`9wwYֲPKNe<b^kࢽ#>׌5 5*70O Dž¾$/ qj=6 ]>|b"ܿъj׉Z<;-O#wj埳!αNH;F?0eZ{mt-%&YmTeVx 뺄% ܂$6|Ni |[E]&oY.ٕم=TD?0S p$?#%y>#Dp毭9.O͝f- Xeh3|[VגG g]}~-$1}YU1^n w y.C;=o4LJ*п3Yy+jvo> 4~~[wLf?.msut9gS19{7' '&js_r+{kPtL xgt0(=*;=5)?M7ω> ]+'ǣ+w[9l G[[%FdV{O'KGicRT?nMG7gOY~M3qo|?}LK3!>4T5.,9 ձ_jY}݂g8}Ϻ.k3}Qoփzwo̟z]*uS4=vRJnAI !=v)L'3E]j275X㫢tdrJHq$끱 SOSW'O!] Jj~ߣڷ;CvzH:Ի''oʟ?9aN߻l֮RQa3yL?|Mc?iv*FGGn~37@O juɡDг?C:مgZKK;jg3'okKb= J['ZsΞ g7?;)Eu]2g~g'+~y?_{zn; #5ϸEXLJPhZ)fQ|n&gvց?3~%־g]6>GrKYϤ +t_=5W׿P-]FIN@5`矉ٝkk.rI~ {~ `mK?[ϟ~d[dou&i pyw)x0.?1Orl zuVS{ gBqxCL?wBϟ[^!'Tɿ@CgU7'7̟ᙻ9+Ϲzk[?(8S=lo ;sYy}SQ>y?fenjΪ'7_T%wD3KCoisS6vp}.ߨ-{WNf/xc7Bu&_ ߴ^):tX kby?ia?:%#Ybb9t "?/?)y{t| m7Z>(?^nYO.+<'rd:?I ϖIEQ0z!4'=P^KV -(|* 6N gk"9?e)SbG}`rv뀗!F~]Ot?_ "L>11zfmep?K #!j1u3!Cn^"sgfCUXk55^wn_='okr=z}+1= l&}MOg?c> #7m^msIٖFqj=v=Osd]VF 9Ov쑯?I_%^q>o/M鳸oܵ~Yx)2v7_{puk=wWIiV '(?W?OEZS]Y㌿<7_{9?s:0{GۃRw4/xFWEPFh?57XT9u_q>zp4Ӧêv?з {֧?珘Rbψ!jw&qad_Fw8o*Do𾿗UQ;GjOg,|25w iS{smJ#n2_nA2x#;/Lz-!([T{ LڎST_r%yjITb= a#+YVYw⋿Wߩy0gG\;߳~hp߯D.O \AqOY3㉦GBcC5/X0s'q*փaH5?zGSm /ro_?=ڡr_O|Jgr9_QצمEQm/~j)wLEi31&~ߨq9nv:WO6M *'4yx6e3L)_V`m/=wuȳ]&7_TEn4Dҋ?zǩ{xt3Q0C'yK~._'KO}-hGs 🧭q'v<aO:1pNG-1+V8j?zq+8zk5hu9Gd_g/u9修ˣ*p `b 8Sm|4trb8z-TcFcߚM$;r[gML-4g̦᎔Bم?r&^;ڔ?ǎsyRI>oc~'/',3?60sL?Ki#O΄=_$ XH$эn?(n?e%=.Urr\*e _p.?]>u:OU8=Hg^8/sxj;?~w\99փٮpb혍bq;ΡI?YoOu?QZ݇^'c֮g -JFG,Ur[8#u( _? po×/ˈ0pӿw̟jsO<ϲ=Y/:IA"?܋`ӳ!<`?<C|D?OC~'k}V!G=?D5O^?GOOIcO?ǕLn-zafr8!_z;6Rc'8g]ψ"7˔ Lޏ3앤s?^nA8`CFz@9nR{ ϶!Q?e#^qw,}߻l-Ny[WN~+o|-6To5S8O[KtloN?I]1oH3H{g_ʫvGrz ?f'+A(8}]kw8pv8IwmqjZoxߴ/_[?Ƭo0-(Cԟyhϓ8UMCC?'z ̖N}/?.eA.Dcgг9mHW^}I}m\?ozuv^Ot:s8/t]{k|^0x^{\Uelj E#""8xa!!b|ͼdDH /yCѼ5Kp~o0 1dDuλ~=۽t?Q8r\^qՒl(A3YP[ʶJIJЎuJѥ3J5JhU7GI*ҫ}l7&} [FneeNao@le]\ComŐ ؏O"SBl+b|!UϞR#{.o`ym׶؜l%”/ǥ=?lS\3[҆vd;s1zUqvkSR|D9ׇβQY1$W߷Y}K=i K$6-߆mZ;ٴ'Iﲍ?6Kn{_tiپ,%uUGTlGT^6OnyR R:⿡7t VR*A)+پ~6qJ)Bښ %m̕}J{Zf13Oޣ@OxAW:m,9?kʓL9ez6{;L?wJ 7%#\_5#śYBjܸ?$gtRb~u'0Oh}ʤ9`Bf?SHbdȮ ,1rI:j OqO͛YeO)x&hgU3]R9[6UF:[aOЎ{]ߨɯE/>[|J;?=3tǧtWwoCYdU\G1lzkJ8`<房E2+Zn|b_s$yCrZE^$SU| XI6ga;O$;⟐執ӖUYw cfWҵc9sa#F ]6S)03f 54>r̳ڽW~.q:c÷s9?Kf9%Γ:z Y`fiiic33B~..tq+6gIU#ż~&􏬢ӛ}7ZQ?CrbE?0旌IR?(wr*Sz~,߼ϪWߢK|?xN]Oτ隥IIL',=x{?v¿t -w{X6O4 <'-F/[ϰ/yG0#?qS~xի"gGom{o췅gu^q9Qk{ [y T)wLrS5'S;Ņ&bFIUHO%a}HWS3$߬1_IO{S=߱뿺bYfv&Oϡmzx}081)W k9ޕ8uޗ矼|9aZ|| ⥺/v@V/GQ_?ݻ/^M#yE^Qw__ʾqe3,c)&K,g ]#ŏ0ZRl#2[+'u#@Z$ƟSE+՛/77"obtOs sR~L?=꿿C2uϥwV5iL\Gv?)*,5;_2s-< H,O5@G |wLɿWgeKj>+,wc <+gf?ҿ#zB21Dq+XJfO?<g l-:[ܿPNDAhhSzyFH+.J+4p9| O)<ҵ\s , ȾFm*g?E-!yz$i|shp?IZ!ﱛ?uX:ըO[8E=fZߘ3xKG%L'i/?*%ZE~G?,fϥ.~g{ 'ז5{[CJ?q,5^UW\ig9WZFWinpd#ի^S#e-NU KWYOcw^;ü:Oi7Y1$R7w ae_:bk믟gUmXeBGAijczHQ_=7XDoء\S3#KNxa#Ɓ?"?<~qzgǺKRx R"e)]1Ox˚w(s:+ZtЬDϟO˂Y2]3?]SQZIUG_pvҞ,~ 3!/@^^m5X'W_2ϟ+ u簤՘[Gܣɯ\7ȰZ򦹒Vu2EHrmT^:H5TwNI}U1i_e>/7%Gø#;=t7 E8݃YX|S[[3luUg~.waxu"?kD\޺8JGL;sDpw=gn L_po[&ǹvkCZ?l }pzUHoY}G8?2A#t2R{GRyEz>_0,#J̗JJ?3DʘY*?!Y*D?ReZ|?iL:?8C(+yVd?}N'ӿƄD_Z!.v{7?vVق屢 {W?γM?ɇ?knA'_Mmٹ(?矂/GĪښyL7=.2%y?,}Woyqa>S|^~r}&>A0kknSfӿkfZ*{ 1|Cam(QN6DWLxe͒hz֩?Eӫc;n_?h|.}]E(ql:Hg#79}P˟24>o$Y9Ty/:VawgZ˔+XmZ#􏬉>🡹S9j(wM#-\qhnL~ 2gd/. E%&VmuQ>" n%ݥJD ^jښeҊ!f🰤O_[3Hh/v{hms)/y]ߌ-0]/@b?d🊫0>vdCM6?:{ QO;@Bo\qv.RUɧk,Z!no9\X( Bdb?^חls>A6| ";)V{2P?30_}1_GPVr^*뿗 r@A?9,ؿp{O׭EkLݿ2˟RUs47=ds37?{??~9n?z_-C]F60Uώu{dzNpwN  1ӳ K C^Őn݂ oG'?gF! ugv<#|G˿?ӫmQ+pg}8=QwWP`g?Գ=>+oGG?-m{΁3m#Lo6/gIU'dϝ1/2aտsAoml y^{in s ><7>?O-5eSWv8kퟟs?CG双 &--My=k?B=] (>N<o?,OC'!vKrrw^O] fΔz~F=\ \ڇQQܧEs&"q~QJ۔ig/'ngi]cRViIZ\IzK+=6o T9=(zs>5-ׄ.Q߉1^="D\ey:m#/+b}xJwfӥ"v7ڬ%V3??:#=VP>U$9Z-KL)!N#-92#9etq%q%m>71Ж.ۨ:H'`oL3s Y?Su:'$#v3vD!f!o1sd/6XC[Vyu|~mz@Aٲ?PoW`wYKųt>|}LlHzFzK$axIW ̟?u%U?*_׵$oW%/[Bs虮ؤ4-﬚/w_m}{3?jBqU?w%PϰO+6~ 1+k  ><㦖;ٖ?|??e. p/4?X~1#`s ۨ_~:)[MO6lbAe\RȌlpNϢ浊=T̘-jNn/ndPY,0s Pm-W MHӺe3 - ~^cUk5V]AU- {T?)dI5RWh6Y!%X<#R#YPߚɷgs-/kҿ~f'w۠@W~sΟ?)xj;o C_Z;9Wo~^5_-O[XRوgL#U8k( ;?TCh}z d<}լxpJ?IY+zo1'hY}oWx]J?[_3YگK-Y!y$7[G3'ޛKXgJRhS.]`J?,3J-p[t?e}p>  Eqf-I1Gó*īC颹GQ]_P`>I{F+[(?ޔY ?qa6o<;qS͏տ)}bկ4\zsݤ{1{[Y762wrK3hm^TחO9r??֭+#zGftT7j:ɟjx:>LQIo[g]kY6怜)<0¢OCGT"}`-(t ވ #迮zq\L;/AUZ?|I1J|-0s?8uy3 Kȩx~Ȝ^1gL5 ~Gѳ#}/ GOJP0O7c#+A良t "ͦe%䏽g1{O#x?꿜ߑ?&΢z ZA}ZF٨f90˽P=qTS6c&Fy yJ cKs=6ƖoOǨ~sլ717߼?1g{?Σĉ/OwC~i_]G?>>gd.צ|MS?F./xjXاbHzuԇwRn,e_1ΥsDɟ?ruyZ8;4EGfKnϬfJT3_j̿`z<^d/Y)ns5}WqP?yKC uug"z2PNanW*'sMĥ|sBTU,OSG?K72K;dN)f??=pcW3f/RHߏ q??W(f{z_;1ku%]X?<뼔558>UEpz\%LdIfqmQ4ǚ?1kqw\eYb?wtl~ ,N 7Tb&oh2!;;Dǿ.odޤw6>OGVݓe|?ſ󧞎FiV("q[?7Mpht߆-3O/d/X A}IPP@guגژGyJ6iNސiRp{ިIR ۩nA\66SmՕ?[0ߥS]U2j5ϓj^2C5!#igϽҖ)h;Jט?Cd_o#g xA6rٹ9QpTϤWy/}QߙІ2kڢmȫvV{Sk/ckW CM~6j_TW(eG Y`&r?ՃJ}Ude޺?M3՞iK7M||KZ˓fĒ?ݛC-C 87g h M:_HPOل>PZ%\67-)){R(c}YcsRml?0%hV2n0/,s࿮ŕzG_'zw^;9U?Ri:~Sc?GOwmf?<R̿{|1b9/}z@A6?Juy qV!5's[~Q=;5 \5 !M 8~!GaF˳0/'53RO#'>?9CWbm `2ՁymLj;8x̿nlOnF^POZ b)/#CO?T!KJu->|ASQq@`k?[ ^-zVBt6ܪOouq ;T^@ȤOL8[}DϴݖwZRݜw5ҿ㽹Կg,=n'ҳZ/ۦ 5/;YYߪd4JwwT+U_f!L3>,(7'?ui%֨yPz_ OH?^'ަpF_v/Q;@71G?oW؇Vc[|T1A[95gi݌ɳȴ۩f/2ܬV?%|pP73:E禩 3ɧiOl ?q.`~mG)1Mߐ*JA԰hݮ+=ӒWg%38OCkrޛK4>ſ.Ud4'=Iٯrf!>~z5C3/Z?J~gv&?OtQwzAc:sMVq!ӯ O fo([VF[Y;\> )ϫy705,bP{o/voY塶zH7tMWoWKi-)fSx?z>m?cIOXX2i%?JW5?/erFY|GO_ws[j^$^z~=!*z-?Rq5#5&?~QGX_{Dj*O`fo\?URz#g-AU+~SP7z"(ԯM 6 +WoOr&{ _?}jп_fWoN%e,oG45cH-?<[܊z}봴V]΄эR{;3z?!:_s|Ü?3gXѓ SNOD 1 68 8=]@a077q4(0 +indexbulk_labelsn_genes@percent_mito  n_counts  S_score!  G2M_score%  phase)louvain* deflate +[0index n_counts   means  dispersions  dispersions_norm  highly_variableFALSETRUE deflate-[p&X_pca*2  X_umap* ?@4 4 deflatev,[7HEAPEx^ XUeǕ!""1{}Kx79jy-K-""aHӼo~!b!n:_[3QsZ}oyfkyNxtH=51'iH͑l"-~~߈Q?j^%Rz+ZHʌ[wZzR^%U(T \'^h1kӪS~%;+aڲjUkӪ8}^ =j:Z2^osPOk60)_{Qr_{Yjt΋_ւko8nho%#f>&B"KsDPlA?\_zI[_UQ)UR:HKL ҳNOo92&z%>+Y1'=G |ZkBLjƒ^_+c (V|Uҁ>1iQ:{ykFx+OE(AٝFg&u="2y})tA@;9pٝ)4Y wO߹п9?H5 aW3dvryHghv*_]Xe#f/(VW{ZWoE$q.gbj$˷=Ub8*z {5XϗR' 7OР^Y9Ss~ H? }1$ړN{r]7ǵ]ؙ/G5>DR8:FO+?YC~"?p_/̟tƒa˷7E"q[^Y.}*q˟QOϵ˷r#UKO>޸r񟄘R-m^??6?]$g3ɈSmo%\'y d Sp_ki.ZmٝpE scouO1?s@G/I4{׎W/?_?!࿻5GyI;'mWFi=1>t$'Y D":'/G5/3ʞ9*k|>7Tj7>ɨfYYf]+cC*-C_[-r/2&}y#s\7ϠR6U<g4=Tk1cc۵Pz|N<0_6 .|?C-n!'-Ov<[w5^7⎪oBM0gϘ!_O뚾RZ7GT-Hg&UXf|ʣb^aoO?o F<~S'$;' kOW c>(߷>o`ժ^Y;Oߑ3{OU|}^"z1J;լGUI"gZCi}kqC(S_ew4z"?%Ue3'kSUKRm\Wm?}_|j$ ߐoW/7LZH2/]%֎93d?m|9FL<<ٚLyü)ETÇ<)2tϬB ye8Ëkձ9gwSw?W{3Bx1Rb!{$n`>-OO&'?nٹ濹S-7qfnHZ/[ure[71//OwJy=fOrU{?F.'H,?7 5??KGE<{tys GZU=Fp^PNc,_?>EwrTnQ[O}j?8^UƺO3v? 7ՖjʈSvjHgs?W迹v2l?g K*|\`?gQQ)(GMJ E7oľ3tk} 1_=:'fovz3gZ\?G: s"wNP.>Jk0]d"*-<5\? Ο=CfU=[Ǔmw+?}wȽhuJ5G0z1Ċ|(ڨWƽshG>^ekQgBTh_꿰'rLn\їxp%u:h]-eW1]>B[zR1Hg4u?jqq<wBkx nIg&hwBIgUFPo Hi/=v\0˛Ǔ:G+좵Z*4s0S'ST+{f)Ko\Kl_E٪ >vK _Q Qe!8:,$ <Fg!m=t꽶v?kϓm'w?Y~Su}췰\yH%K6GGH?[oXK׼e1:c;\S?ާ95%-x 3{TvV6mdwc:;ik}⟱c: K߳g.PbLzG ק 3~#ܧb39(wK_UZ.$U|L ޱfМ,GFFcbj,C/8¿罼QJFeO?Xp}s I+z_3^=*صڹ?:4G͖jFIOǽc>u3=?*T̬~[)iii?fhzUCgh6 |!sďcyVSו(6M~j͟cU_D}f8f"Wc!V1 cf߬(sr g%$ߊKNJ:_'w^!\_Pa?|\ؼ'1S۸bWlըYY#8fw ՚ld_ʚ.DkO;=<ΆL*俻w)iqOh'x\'֜? >Gʂ̿[*/{kJ:=P]6s^|]mF[s3W?Σ~_3Zӎ)Ak'zgs!<>pKOK9'|4wmSjUS/[Ly_fKX@OA 1XSj@I~K7_=Hu,gF("j5zPOZF#c/ ?ٝbȓ&M?"NGDqԾ~*g]_p&K%F;U?C&~B:DzbWƻ7lʢ}3<֥+~yeyOmROszax4g So+˥b$OmMWq8`V?ݏSG{bv̿V{Et71Cgy,6misPdi#FA^a\oOcaqTz9^Nujׁ^keG柱d֟(߲U#8lzmֿax^Yc\8AyϿMe.IgZnOk쥝zebއ5?36ZO1vyнbkye vRהy GROVVZ!WoqfvQqu[/TR8/IϨ>H3<🸊)OO/3|EY_ԇ)B$W-mrW/>I {_1AyUyd9i?w4<R;$/t-8\I`~ 7}bߥb3mnO64?{\h Xw'pM<'W6Z;Uԋʼk/ߚ$av x##]y g&mHkc qs9n(e2O;AƢ5g38ωZ^YH5z-?~bon{?otOp̟mǻq; C? ?mu0ymKOܘ?pfدsț~X$W5 >8N6b>vW[2?V\ힿ([oKG݆^X ̿Vxl}Ͽeb>Rsfw<>@?cv1;B s0 1<=g(ǧxS4f>{%Lo>?j.Aҍ~cO?7:?|r 岛>ׄ" ۠XGxQ##cU.yJq߫f=PTg3{>+XK(?+M^k1O? ?ªI?3KO̩C" ԵTPC\'}*FS'm~5G4rܻ[_gu_ADƒBR3sִ0{!3Ø]}1I%Y1p6\O2%{?=x緙s<s3mϟ?s}?/g Q>X^Is"ϘY-n~/T1eRv->e}RSڱG]Y.zOM?8;>3ePS" CyU/ -".9%!^!~pTߵ@Z~q7bhy*9S{g.R;3Ӟ^0kZZ{G.ڦNaZ߄.^LkzL93{jJpo"[0;Op_?nX#ˆk7hr[ϾK ~uzZro֖XWx5; ddO?Zahe;L"W8B~ ED7{QNtn9Sߧ pXBj}q[Q1ڠ"cw/hFOg-?[o_k譂@Y{9݄gđ3 f<sɿk6%ok[St y(+,. JzLYmOȿGI''Ttӌd 'saOcjב;I'h[()Ok3Y<8{W]f͉Q?yUg?v"]Mwg'*;=q?!*'MڋW(".M/,USE|=Me}zj>'ϫJU=a=1DaJǎVjh&f.`KϰKJjH Wś#IIqT?B:uW?&_D5PtLJYfdDC %%i6lqDȟ?X?Sg3NƓ+ Lmd !_y߁?<+6F^YT# ~ç% ]!rc籬ϟqXmoaon WoO|EiNgtRJsXjXmg/v_cGtӽ+Th\ýȿQspTw+W3?,gř1:B#<{#ow^ :?TW\njKQv6=&2/kSr?j5KAjw;M?YO5Q[-2_#!g,qNהHG?/.&KӬ,SY1?)5NKG$G=?O Y]yΏ=g4Buĭ sGQMORf" 2} T֘#\|Jh11w j ϊnOٍ#*Xjh"2Wm.䳽T۟5_ `gU&MJ"՜OZ͟)Q?\i*߫&) S?K?sn~-FWR I]╭OȫtاQ e1)ǹ;?O/玵3Y ءuF3߽7v Gsds?멞c'&Ŭ,#6ҿY6u|S_0\S/c[%_t~'XϟVqoo{]J?8K^Οrg]V7w?mǢ o.F X*EEL"U(f#Zy_ *{xz@t'&IΟ;j:8 Oq=ШcG}[Cs4rI}4jZ8A$5Kk?w|[9yspeO5fcX 1Ý?q ӵm ';1ʚ̟ rߑo Oʁ9)xQ_~{ -?3T. /#sG2O=0_EG SLa/8N?qOa]Obe6]LaDx 'O{M$\3DH p_'"gG3ȿT+6HbdvOk4][*^-ǧE߫5Y۷UAc\.EEu5c{Rb f|f__ka)OP~ZA-i8|0e>ri2#wK}^V۟{vm9=)}ft"&y gݾ$zoIo/U,2\?@szъS6'!??e"\]t=5~zn=pXsK<`t,etO+i?o Ο~UGf5ϻ g^-I1Jϟk;>E`f"?uEQΌIVsiזS=bCG67#}rY̟x{o8L-xa*@v700;-7̟U{+tV>vʹsIϟxIRm^qz/y;__Vwn>.1y!AB]wI 宴㏾ "o?" yFޕ<\1ߕ}Oo8B4|z 'nD}q}̿wG:q̸ӜAWMö6'ԣSY@?_?8{[~6ǚ=i7RFb}{u?C'>OGw8#?f藱xY&\ߠO/IP2}F:7ϲ8N?K?|Buǟc=\;.)?fr13+8!Ym=Yց9`9+}a}ҹbӓ2ĂA/¼|SUx^qp}ȧmy ^aH|g'Ѽۙ5/BEn*?y<Е\?4P?^]HңT!? sZWS]el"Q߱zڎ*KyHv13T?K1XrXqLͿOpxXK|'o:O=!?co,/|gzR{cnG[CB^?k`w;bo&N'?rvhERL绋+]CyywFeXi8;úokN? E 7(]kf4er7H ?8S.z~xԍ݇ya-}w?1ߕ?}@3po+'c#|Q+o?G\L2L'򨏅\OR&=/בI Ì9Q̟k-|_wl=o\ /vN/"8?X'?}j󚆊6˖s5w4cCq͜^~V?^#u?cYj.Qn);<')ȟF ;LkTkVϡ9ϐ'T,?c?v?9̟%wS7,?,{UTUqQt?tN ms+Si"qC=NA\T瀌~oyc.^?YoDb'7), w^~,l{*0g}#?irvpJ3ΰoO+xXSUk<H3xC{Z?W b4kYoO%Q~/ݏ"/b5soY$UaM-kc6a.|;;nk| 럲[Bs>~O9#[ѹ\J:tc]]>@/]kރV?,?[81{?9E?qo۠Mj-0Gߞ}N}Ǝ?Z|VlWVm3/'O?ffY5؇3UnOoXh51Z/wO|q?K? m'kyoӼ?.җj:$:z#+( #o>կ8:-q;ߦLE/iN5Ȳ],ZMb5럴jAO yv9͘x^ \VeƑ}7b2xsTr_b215}bTb"2=Gs]&sE绯۞y{^u~~y뾟IײLNfCzz@hv`m}c̈ѧ;kԶ <ܚ9q]k(Hx^,~LN36K9ggo+֫ȁ9;=OtGZnjhI|3[^y8]Oy\/H_4>^ݼ瀶Mg}M0{|6t-?N{)ㇴU7 /#r17˷A^zp?OlUSX_5VKR ,Ok~j#迣Hѹm`cmF>H .z]g1jG; /_?W[?.i.w zTUUCDc$M]?;_(kwdYW Ti!X/N J݆{S\8]A_y>@9Mﱝպ[DbPNLmJCb|e; NwMqJF@-FW슂nagNHsEUwof>dgVv+\5oZ,XjU֬c-b!ĺ(h|4󟖜_3,_/.Ѡ؀O+1=K5䫄ܸ?y͐ޤR3>/ǬzX?i˸L RszT.P']ó^%VG$ +蟼QH5a?9疃Yf_\(߿"CSw:kv;V/We*ubV1/\OJevBDCaC~5|ӻ؇3P'/SR{G$F\?!MYZsL?SZUu%Xy*} t GPC I7)hͨ 9nZҳ*U3[oPXޢNqIXSv7@%aOH2HM!o?;-U@39Bh.wjȭmhmE 𷫿U^)}73'|.Gf0Mը ._?>g @23#gΏ1*>~WI #3Ȍ*}jaU چ7ۥm #bT4?Xg̿{!#Ր<> i '|@VosD3qxIwj3:|3rw2/ '/_cA}3#:wmɟ IQBOQ ªtB]vPԑio({gZ?5Cgp#F%=Ģ^/Ǫ?/Ce 7?-n[_Ͱ, ?K>U7Ggakf'ZΚa*I/lzn7\7yTWj㞡5Sn$/Oz _! }0'v˧_\,jgu??ӿ#?sur{࿻ݯ੬GP🥵k? [-7/w mZr8ۓw {yO&kni=HHp)lHwߨ+ fYO_kEGV󛆪Yj|vfg{oZd}3h=DǜrzލBDYKe7hr/}7ߜ _xK6pOkEY-_uAvO1I&#Q3r?$_>=WH&ɗgNp!xQ/n.j?#NVGX*}Qޫլc_=Z%ٽj}SN76Zdo?Xƺ_ʿUC U' Bsh%BW=_Cs[7Qѧ_T%Z ]M ʰ?؂?FÊoޓ"Ο]^U밂SГOMX5iտ?8QLx3Ȍ^9+_r!/d~4<;;k"Xy\Í[v)K?֯vZwoHVȟvHWRê;Ϙ3<;ixj7ar(_-nSy?GlWvI -(g yМ?\8W,??g?6KO5t@=1ٝO4y5e/[ΐoy.)?xC*3В!j'F_kn\!7L~87-ZpSOl?NLDW=rmJ,x-6jn_up3{;K7QgK'Mó{+Y&j2y3wNW,NF+o8xw\:ξ\Lvm,nTO.9l?7̟WjXTM6퀐1\amΎW2~Qn6CU7~z?e9)<< 7$Yva1vjfvH;E*[ߟ//2&]'SoYawn?b. \?s^[WDB U>g _ܜCW=Ο^9_1V/dy]pjס쿯iq/?41P Q}Sfv*3, BNQA_jbٓ_?!?g6! +~/oW O7cSsKS/$6  3=ҡwR}T?S߿b LJ&3 ϛTZ3Kw2BU?n..}/4>nPvZO?.ݎ6ώY +Gx/OzJgv/r/\QpǞD/u |A?ظ⠺@Xv ze/Ο=??[V/Bjw: ĺ/w}c7@^$Efcoӓ_3.g?.nWo.GVߔ4W'KWD¿%|gr?u~n8T}b' ZYMz/Gޚ5q؀Ǚ?]W1;߱-bwRn+/omO$^kxW3˖)8`OS|X5+=W8Iժ_dF/exЯ^^c8_˴]F?MQC>gm?su v%0_OwyS%<;e o]2Oa9$<#2 w5uZ[1G6b:n3ٽ[EKTwٶg5|o"nO??HJ(lXt)sgE:d7S?w/7i(!$OZ}g1e= }"տ]7[W_8)i>z>'1p?Ϝo= O:.r*h?s?ozڑk';QVZCqs _EN^5JTwcjt!*5˫^?Oڣ#G}j_D|-h[ߴmeZr򧘡@$t̫|SO>#tY9\:3?;a?2Ss՝.g+wyR8`nuO9Ͽy:y:G[=kQxPa:#*Tk_3gN}j͟= }̟h}B>NCWgm&ʃC 9K%$]2fGԧ۝W6bfkqv1eeps??.YOW=9k?!ʘlC?qv;Y]y;'qc+i3lM3ϟ#R#|&=2+k9R&']`ׯ1389Qk>:s[ysqjn 5JL>Ly/[d;?&_5guu?e#!_YKlgLf9INpO˰Gm7~?= |Tk}*+]i-O \Z{WݷB;8|OY|YO??e2}S_8{fsgg}a;~ȭ__n]矁cp?ٱzIP[ Sǿ%s͠Uahm}le3;?sn\+χ@?z&?a1FcqHOnw8?1p߾Y>u-2|?k4q}Akm4?1ey'gaPQ..7Ι/Hؗ/=Ϩ(t?v{iCnfTzv>ɨ 5!w@g>ֽpQ6Ln3/̿K)n/O{M+/q?_EDfࢦnb>K2Q?Yy}q̬Mݶ"k'`?=>#,O9Y%a:r-׍?h^F0ǹB ?y+jN'̿g>Dfpѿ"͔xfi\9qO>ߝ1_Ys=]Pz-փYR>.Dyz|Mpo?y7ߓ oа&OV(x^ \#""ƈd!{BRSdȞD)FEJYWEDDDDcrΜ\4R;o&L̙߯?ms3,5w8oEcw-jf;Iϫ]LxlzAuꪷ/fu'_vmyoecU"gL?Gb#\n7zyW/ W>NL.xQu8˱[~9l/,ovỌu֪'S=y:AXM:czک} ʢ8|5:8"w &ajŵ3# +B)Ȕ-ℊL&L]P"-ڥJK5"w~_A\^pQݛ61Aߟy -+`R9NN.$ V;g g3PO6rBdmU11U]&>ʆ+ hK\`Nl_#}@6ovKEoP¿kX75?=R*>ȿfO;i3ӎj`$C9cG+fq-п_Bu1sƿ|o<8tKM-RF/T_b<&?Ma?7irT\=%?o7GZ צ RVQCJ[)FɾI2z>0Ud{N_VxOeOgBUc\^ۗtgZQ{Ȕ?2/nHD$/(C??h%|\cOeQQqv?{m\5jS:_ҿ?7п;%{D.zڮ'\>g )+J[ZƿȦJ@V+Z(F7U};h?/窷jvt)u[5wMjQk:Oj?LߡL>n0i]U[ԕ}tfü_RC#g?+ RF+ݷi5qn?DvDd3ʩiTv;Y{G!j-=sg oDBu٬m;غȅ?gPs񧟇Kke?V?iP+7_վKq5 uӻ`[Nc 矖L2=oM$矄ȗkvgJC}Yvg\ +_V?:>:P -:Zs9ry& M='?-}s鿠NʋV ȟֿ1hKHIwN],NY %";Y.QQOװ%ㅬE\;}+ލ/H_iy.R1o[D=Sӽ{Ɵfu?I2!#9BWѶI_lRQ2{9xR$IF9r :<_?eGVyya?F?5OS=3h#؈l/==_^ƲR/]lc|[w2j>g>NW`M=U?].POLG#a{)[JMXHp<'aT`3˝Jˤ?*mFz:?p\13Cq->h¼pOS'E?򱎁T>@v#O?8+)|((? dĢƟѣχT\@%lD2_RCj`lq#uߛBO8LL(4;n6nj4&H>'%eu>*Οy'۬?y?zη$4NQH_՘MC okҤTRoǻ ONR \;SSvkoRcvq>7S ӝ'ة?,;V_qFW~oM]+Dϟ'?֗>7/ *R[00P) 2# 0[_eM>_<_eO$o37^Kŭg/Pn{_ʙtBGn=51/S2aةM_H`7rh:/*?eҞtcXGҿOe7av1J ˝4)}#1c, !$c)+X-"6) =Ě?-YAQJ?g}ӡ}l{挩֕? w'Ž{/2/kN{QE J^钓ïOq)4w⿫})POѕN/O {a'Nl=Q?!c~=?X[V@w)Wܒ?|/@9t㛅p;8~46.KSn8$Ȕ@gw K;19ֿga] ]wqSf+'/c/S4S_,m6$^'qbOUrx^7O\ڐϮ9d< c dl`#:̟{KU3%NbYϚŞ`g&];OprOReR_{t2 jwKxoN/'S븘O^߲v?r+PmBXK]o]gG: KIj1gJo!rs̟~-bcX7Og㜅C=ļ?j4և$oeI _fA^T9HoDvglز34?L WV1>ֱZ =Y#q|"8c>pf=ӵdwmFw>CeeblcE=3OjM gO˿se1IY;K1̟q1?#FTT}r;yWr_DߠߨG}'HV3C矫U~Iħ'FWӧǟq-lL^7:ٰ_#fdY;h%^m,_'p-#'o $]7٢|k''g9r<ɃoJ?wAϟ[2lӨLma^z|i./q9EB;TǹX ;s%O҃#w"d=%msXc_w៚Zh_08]#HUoK.[?;{pQm|U=K}>_=}SdY?7??c2zk/SoBKAA?0Ӝ򶯦zynu+l!oD-Y m5L!ImtSW-RGm':8?g?OKg;5ԓ͟ɗsϦ'0UyէN3c@cyC8ߔyn=fKvv(E??4]|]@]/msx(nj.JcT]/Rq,i_뿈ߔv"x"Xݽ3 ??5]?6"b:5ϝX:?14Vq6z'}i>O?'OykOݽ*?= 5Ù3T[.ZP1jz:>:ҿnkK 7b4%uȬפ?8_߬boӺ+cfkZTR]Cڼ<z-]̟>g'GZV|:L͙L0 m嵻o P$Iw}߱)v?`hӿi4e!_,y|Z6H?Iv5q~gp.IMpV<fgN_z?eB]5G/S搯~N=g"}n/>eBZ~]oN4>t0Vͫvk߹=_r?'-}TݽRڣ1Ǽ'/3rH|?+$erAŬ= V(}cwתN.XRqa?ߴou6 se,|_0[?~ټ?!ri eTǁ7cF_3go7nv{/e`VdP￐WQ-矆?EQqR_cXn{UpR˟8vO<"w?# |L췜"]?mFC_5Υ:-#+p?U{>ea67g .wV/3rRgNCWpև"0 jL}\VxI4_cX :vO;W3U}G㏹bcǙq_0_1'z-O6;xNwiKpA2X 9OpV_q?ksh8Siαغż| g-_` e+1IO_)8`ۘW.c#9NՏ '_gfd YjsVCgyo8\gs[#Mm|'=K)+z~zB?օ=Mm39kyR$D?~{{?ǵpd)P9}Y=#0>oߊk<7.qJVCqxXns_! FY3C{g{lԓBQgzF5+nw?hg01Z gH?*;'̻Xpf8 W܇ϟko`jwhp2w^q~N_YUO0 ۬OM?;GOLR-)+lhm4];v{1v/Q?sx^y@)""E)fOȾ\D7wH)Fscb4IL4"}O4[R9W)7RDʥܬd$}9sy;ZĬ8"va#uQ|\<]Gl]\+UU[mA( "#g p, ͚OZTHɻĤuZ^/1s ʦa˴%kY3sx?X6$K[1OZ2Oszcn@+~Zrmmjkm6t $hu9սh}%Fد߱+*7?hh~\dRv4I}gi~a>ZΔZZGo蹿nTгh#kyΈS:OPt̏) ͍ ]ϑ@ ,x1{>Ki5X0ww{[5H]|!ha>~0m䩝5c0qiѿ;b*J9ytrX^ !µmH#~F?CN]v#[3_,r@@߲C!h:e ^J=/liޗюoz?gϗbhqyXyXA3Q㣊G}ձ xI1֛"sPwQ'=ׇjkߗ~Y }+KF>VFc= "%7ıq~oY[J:T7r?u^pmN}E5z7a?i? _[߉[KcGE??>']HϬ{50^dnmBvfC?jB'bBrq[ڮb;zvn+pF;ds_p?%8m;#74g +W^t O y}BF^l&K .wH3lld8JP~>WGҘ? a>:ADB%I_C)xƾMZCJpZc?NHfsw4f^|M?!̿%ԥCQk}BVmR-0eل^jF /p=0ƾa"<u(?|է4{Z[ֿ*>RpET%"\'O}E5|?1$ .-zNZȼ?+os 4O'vz 9H]*{x줾QV5+W/hG]qo?@⤴jRuRA=A(Trz Z?F+<ϧ ͚A߰74/&o#* xiؠIz OP Ag'>.j5\U|^{a'^6M?%h_uL )/AޚYĕ"6wn4F?ཿ(gEzK矫~#X|'tTg;?3W#Lnw??5:R5d?ׅ?e a'UCt(R:*UkvrfZ9HZOuv rh<@tsꤒ.)'58َJV;.Omuֿ|ǫ)QJcϽzꢠ5o{noz(җZRS-r`9 !^~R%їv4uRG0Ь.IπzJ% C~CA=y?9{~yK5?zZ/uTC[:0:sw5N.ֱOx:_F*)̴Svɟ}*Sb[VtLv;l$҃y+?:Luݜ/.-7O<k ?6_uo2,Xe# \v6P!lcu??I>[+B 9*"]6E_Jq?;?o /-YWgn 74bw3 w?gQ./7韴GmgQO6?2?O"/ܵa>$ zKu2y:}n yF_EumRz$]~Lh^` Cr )O4R[oY Я.cɟd7|*-ܧ;ϊm5o>5=vI]O'x/X=<]ORoYsgYZ/uDFeiKuQQl?o4DS+P?!gZ mTih_?fgXµm0[ûp Oݸ2Yܨ|Vuy"( _N]\tTA?17<ݙ?Ɋ?߃L Uz ?Y[㏟´޾g {l,I)O|(ٶ}JG=@|>FumzNgr?e,_u7pnN$?Ti-s|ZNeEM-'">(Oo!? t[Od܋=zV )e\ř;[܋-3=U?9B?V>c#3uv7gG$r4A_z?Fw 97zv#8'T۬/#`O{tiԣtoWJS\ꡦ`?@Z_s!c#"։U+s7>!{no .Q1ϖx?f|]\X!("zwI]3<'6TPDl￴gYQT/fOR ?2ISk4;&ǴK3]ӯ }HZ} ]ӜR腥c/1=ٽR=Ny/Δm;0FԌa6#/ J6..gG*vtڦ-k9" ;*'/-+OgS1?:{!H*4M}S25DS>/KpL7|?&"pf㣞+E{;u.d3_PFegIi|GO|Rqyi㏼2{>~ggS߈~2+c>lӰǵ?w/;W_fQE%:>tn_^#Oc?"z]/PmuhKk˙˯h|WƟ2[w'5d<mHxURrdϯo?Kt?tO{~{a1_z~zo^\M{^ HW; S$z._6dȓ<Ҋ?ݧ(v4KcNFasXb=hX?dB}#^ީKy_0bu]q'=ۼW7 ܅4㠵N.noΊʝSS=ޟtS{Ο#W=c^9ǙgMl,"qee?08'}ibn<~*tZݛwFW(|oo]oV3>r{Le?һ({OxU&5Gsi)VpL$TǘeE{V[(O w^Gss[G1?? Jk[ PnN9u?>[/5C4 s9?<{ɞgkڰ/Me_g?eeq6 {o qV$u)mW;\ F<L)w=otMᅵmʟ8oSgp^shuZs/ƈ(Bw @Bhڃgc&~↞?Iϭ柖?~q_g/LG 9[f/9SM!?H9&{0n+{[,Uڋ\>BkE;ɞY]+?}|{ս2RF8:WYYg=N|C=5e=* ]?u:ƕu} s ߛ·Ƃ>?:|}aF㗉f>bDTq ??ڶ\]:RZ'54LJ}ׅOR3O:/RR{9GFg\<%.\L1 ^TjQz~\W+٢t3 {o}$f^/tDGE?úXES'4Onl[KV5=h"Va~ɚ'#/M<6G^OQIc4ߞ:YkMzLfW2Z}& dW0*K^ lI־0ZGʟC_6Z69mUT[+dqn?_tg[5_ձSE>91?nnOZgQ;$%>7XHGg5O[vc+u!(w= b^[B(o?@}e?];յI*s+oʷxw>F?=?uaNlId_;AO \F%,C>0#zOu8;j#~p=R ׬_柖55k%Z^h{PFCR6!)_1M{V=-UʌW%Ee!_?%dTv,Ӗ,sk#q~m8`?a fX^y-xuOVi։{%{(_k]?!ZO+Y}fusNe͍} >rVuSw?_WܥOl8 jnE4r4_ͪV~f;q?S}b8u3eCk~ujӛok2p/YZI?<K+w\?cٛEZOx08h geUP֚1`H]ayO?Uܙ&<18yDtJѭ32?woOֿ~=>gZ ȲȺ7@iVxG~KE-?Q3ۖkMdKp/Oe#i?w=dֿ}u'է)9 A?*,g L$΁c^龌4C}R2_9;"B_EXo3n[kV?1<'*HVGʜ.wTO?EXuqhbsI&q& 8HZ w۫?fKuJTrOoZ^OǰywH,ȿą`3dO7tg3Rn3eVsUG^Ԝee}Ɵsʁlg_q./s4C>lO2n Qlf7/zouڽN0;>Â>NeK,gxFkoqŽ\yYU+7e5&wiv.R].{uW]y߲f8CfZk_rO8}OWZ~_jߤt?ڝ3bk*7E?LJ]5b&*"[?aAWZpI{'r?4K?=iVJ`@LG1#9b?>;o7 zy:&yf?gLž]ZR_d N=W$;"y#  '֑?6ֿ*5giM1>3G?`KN'qoKr5I# 1'uv+Kb_\Y%Jlrws o?@S?ftVPSX ?{_?|G_պ(N/0U!̿-G9/s@a`Ӧ)uvo?~6vK0WRMsOǧvq#c9(C)3?sd }E,%Xc߮;<|~.i=_=^w&__S>v9;J>GEl]5YY?T_eN?})?q3/=_3Ύn/??ϟMXO?Grg-?[Mܒj'-Of|(}C㑋/ǘoC)kjOF?%$_\c')Usag[/YU],SE:c~):52ԡi%>ittOMyӿfƛԀ3Y7鹿oU{%6_8&(O/u?`AZ3=_}eo 9o`em?`{ $*ʁX۱[%%]ϟd?a~ !-Ig}Ϻ 7(qS!cn X#wGW~CwŶQ?q~[Kb5Wd>Qi5lPqlm"V?浞5/|6/_5 u9?VM7*x1{~Ο3>0_?!⨹إ.Fk%Ӭ&meS&Sč?{uz F⯯5j*mG?+vzNۍ-V54W?=_s5 8̟+Ч>A١%A:grU]~iA=9?-J˷#5ʸlz|nne)矣Ox@?c3k8/icfs!9>tם.7Ղk= `\CBe*[tsiOPF߰ 1S5[߽f#'xlu_@̣5WRzFz7:q^/Jrt* o#O\/o?:wI'5LUWQe|MlSȜySP|ceҶ?Ͻz_5^/V7Sx[o?jOM?NoRk[L?_p=_U_4{<Atfv){]g5pxOQ{IgKZpexHCcwA3*xv2s[GF{Gem̃mG4_[k 5ğ|VO>S?Lc_rWͪeý# _`8n?8 xsz7T1^-Amko!w}E͌??Iaj6{ .'']j8,ݑS Οﻂyy֮H|xZ,g[?3x_;g'5d_7n*OLZg>@jgc3z#z]nkpHtOs/}1^ zA{gVUZ9dP߶c&oȢkόqKf0_Pe_x1߼n_7;DH='p>?Fuc#?t*knt|XQ!yҿHL#={_RM('?#H[.J(SǼ,Zٰ:~/=?xG#'?91Xz̟[sֿ*ϳѣ?Xg^]yߪ~+(7+݁]\'n['O)b<ʟ5>d zkCv]f1ϰ?+k>]o,Kr7}RH3{׿Ht#XNaVcHpׇ=%o~?<=??wŹS:YB{sIRq>^Y![ ?Ͻr{ yu\?οII Sfy}L|a4y*YY]Q~#. ԂzynOk G;o`F߿b ??%|vŸ\??g`o =ZC?i%J'};׈3ExM?{~ {'os7!0K״E*_I>:f_k%G.4ϿuI2pE>wqF R5YLJitNqr `TOZ_n9p}iv0ooOui?TϞ߅j]O-niۜUWt_KrPV'fumLJ!=#k8O KHh=Ri=ܽGc+9!Fiɟ2]eހx^ X !(!w%n!Rb ;߇BK(RBqøĠ6%PRj)"wFB}?KpνW<2w}MSUfiՒ+ _Njب~9|X=9E<-R"Ne.{Tﺦ.5~)tp-!SOyUjChSmEDE5jLHmtgK>>.)/^n-,|N+mGyô{߈O;_gKc|ګ=7=qf5[qn }FmG}*W}JW*8#3Ԏ^WS~Sjͬ/AI+)doƫ #u%ŋմ o<-hzhmr?B{Eڼ6祉8wK0=11Kxg.3e kyX L_ǯ4z"vL81 n cW rVqſB:EM-jh%)̿k|مqʟY ne3G@z?!'Rt 'zAl <0ec?T57&~&OEA嫦k:5/쐢qt]Tlm WL?%:Io.C*_խfr6E):y{O b_3+X߰XX?*_,%z}$|z@V5]G$'p-?I}n^X**wnߠN::7EokS(Q"^s Jj2Iħtf:P)D•(V| _Lxk_ ]+]o~.wV@?!d~gCGO>X?v|%S24?_-~¤_%@[nt2OR쯐_϶Z?{X]}/^[}:\SR? ^UR\AΩ??nWAN]ۜfAIIw *WVidL|_Ӿ(߹'tn.WSU΃km#_`fӽNa_3LB9FJDTP{ϟY:]k꯮ȏ::? WL;)rkdoh1dyֿ['\Iak.>QT~EРOLxs%Ot/=.Fic?nJ5/r'_YUC$som,Q/r?V g?rC @Ea帼)@$5~(kfF t,* p]P8s~l'W.6XskG\I+F 2va#oMq xeYn^c{;-!~/POhoh gl:^\utNUΧk}ڲNJ)Boߗcjc}Zy;m:@ݞo֎6] 3O7.bm\: oCц?WϽ{JO@C>/ORZ+)c*o`yc<`MIOJM S^vt^ZgK:A}N/>W 9{_(_{t? Wxw/_IZWӆ'5~*V_dhQw?֞ Tl =t?_bԋ4rt?Ђn^4%ΜO:aqY;Q\T Ԁ?BuuvP?P?f_5i -hVOm:dXd3g~K7:b̨3xsJP@{?iOP_y宲otզr`b56吃XgXX|IA}`?JZt͝\,6̰eZ{61=!j-oƥ}4?`vA=)f ߪp|֕~RC3 g\s>tH?Pe'׹E>ϡZ1VnLl+u?]oϯm61])kXQXh98AI{\ߚ?]/TnO=KV7tCpK7ϯV)FO+uߪS_ϸp/??2)U:^u Wp.OyTz NuVzw?O5Zu7ԙs쭅DUW0=tW?V' ' k ;pE9T?q #uw~M'.]l:s+wK3uGHGFcןq|wl1.,҉|.oc Nm6 .Q?: )?twbF 6BOAG'S U7C9js b֘?O9e_s) bIoտpF_RrLJN2;0j 9;Ǫ#c\o-pvܧE!3م_R.oz<Oa0OMNK^%ӓS6S~s[LxMeْ0}mszl;ډ?OJLeNP?_]u࿧{Mf.o!? fϮI7&6o-jg?ۜY=<7S:le{eEdrv2ٛ?t,F߼w;e_?=柘'<*uR/?v}:ʷ'%0|=J0̄zu{?r){5d\ qkgPe}-{l?*Q ?is-%%2c[[6g1UuCp !ƧL\fyE*?v?5{nVhX' CsmA0;`@^2 ƠOlO?W|emϓf_>9)doIzMeL+s? uM'Ǻ,.^?] ?gJꧨz迈0G0?aɍ _.YVO=4;_9:iؘSf4mmp^-QNyzq{{9~=?gg:fo9!_>snyf3nzeB_g/\?#(O.J?[Z'hs%m;?|hsk~g}SJ3]w۬k@^s_u[[(迆U^al&sw*LߥRNVfiD ֗ ?."RSj@?s?T'&j? O5п?Эu>+yV t(`|h+v5}Fo~5jS:fOw3(x_:_ʷ\O%-K=XFCkq)p}( 8<8/X_|ϭ?݉FI/Kuk@nt1L+tL?>ؘ?G?0ߵG/ίZ&V1NJ#FVzXIQwW?.bQV+ F .z(dTkEOz5w1U_!6ކޝdϿ3?=9l O,6T]c_Ᏽ>k8oeB=?~r9:u \?v#_ |=?]yW`/Ա7vz"4d#X;/qq~G9?R`l?8cs_r?wKaaaο~lOL,ܖ7Rn.d*(_FR_Ο?+w|;+%YR?EI%ox;+w/1b*vP}C`q ߒżW3ۼwE> Ġ9zǞUc]F&] SeOZTZ&3? G}piמw_|T X,t/3gJT򺧍X}ýg_/H_}JPv[cDZF9w?w8m4gu'o w/:ʎXpioͯՆ)=Y?%^{?Xor? 5=?7sشX>x;Շ?: rջx2P!?F\%9Gwi/̃I/2{3_7?ߋ&Ì?t6D=~)]m/sR_E^KbFxW3+L`H=?>S3<#&?0F~gWi{?oO翪Q4usg$?&8y9p /еΟΛ櫺BnhBy[R,k,,lΝ=+<+! wAŧb ?'nK*|+_֏?1ҙ; M+cwev;1ss :9 s =A׹g7^+~d =›/ \Z-k}v-\h)*O 97,%SĬmekٛ"\ h#~!^%h)g+bf"_^ilgO&J]˦  -stqR/L||Gi~oH2 ȿ)?N^@A]73dPE,DEFĿ}L3k! /!/b.D-Р\ ! Q8D俺7 |=o2{t]]UC9N߳!G)7!!V/ 3BDp8]?remsb6b{_@ww*Ř|T/:sY+Q^;}Oww? oR?w3X3"n0VG_/HU.(Ls _S$qn^9  RJ.?ݵlMc96V½\ m玜$'C%K]RAw@ uc% n V ȿb7r﹭vXPIOt-Q>79yفosy[_Lǐ81R?`c™m.%{6mxW$-?7?j|"_/7?:xL꿤`z$s%~k _Nfc{ia'FȭpOn yC'\ϐ72P梱̚t?݄g4fFߓLm ܘ򗸐\X'*~:#K ;2w=oIw}@s>b̈lE ?1w <ۢ叟[\NYL?C|IS2gw|8u^?z%uqTS0"d"c^vR?*A6Ŀ|8g//l7$%xOZ_7-1-euKmGsu?!eVu©wd+2?cit8NG᭔s7]8u@v`ȿ* RpC>30j/m=G*Z㿺}+ϭfxu[)C]*p!u?{TQz1GIZ˿z 'fefS&sgA\p?O2{KK_v)y>1߭{j? %15mMΟ?ÔZ_ÔSB `ta썤gٝ53X@ \{۷}#dE3o^?KFH?^⟒wW꿥z6|rn<!W[/rߌ$+_1o<_͑@-*?^½"6"m.JJ|Ϥ#ߩ<!?fF]f;B5JCOvwY97&yrŝ]6:q3w*?bco+pP7y70!V5@zs i_^P֋ߩP[n-2q\ ?QDY?I? <|8?UcweL8?!@rOW0X:/_Z=]AI⏳p?n3"%֝?}TTsr.? Ɓzcwf@^\?}`%: {p>&߭~WGyn3 J3(*4VU_gRo,PI8y<?_C/F7>yܛ>[YG?!cXgD7q ҡ;Tur/{.?{?/Ks_ukuK=|T_`Bgmbu![{y#[' } r񬦥?#"=eoux?\+>azx3xཀྵ-yk㽟ue#* ZG8#যx/Sǚ3H^vo?R/+sBx~=w+eM&>?UA,z\ YЛL?9P_XC}?X+\!o^3Aiך^: ;HSU[Q:ԿQAYY2 dd^?wFs x%u/w-;ʟx/ym#hvu[4w$_)K+vQG%SLJf9nd6 |U4˟W;;n'<'wM.%Ŷ܏M}=L_{?|,LU57:¹V$v)/sx-X;J)g3 % _wꯒtKH)ߓww䏽t- oN;)8&T/_ίcy!}keggM߽;6zYgJa G_g@?%a()Hq@Gg,鿤xpcvk3;P ?JOgS7úI7ox00g2J[/+,aR'w?dz=rqaA f䟐cq Ug7h뚴/ki{8t>߈?_V<~%X*K^ p]!+5 ^/5_P+zCNqJ{v)Il$`F{y ƠgGCFG=V5|\%ϱ0OknX_71]]<ßE8'}* ž5˾t{< ⻋kȟ48u(_?;o mv)wz)3 t?b96M4,Gϳq=?'W>7Yu!Q?_b?ڷ@O,k3I9@;#y?]ke/U4ߒ[ݩOBž *vuwLT+OUsZgRl&Z?9"?3Ы=lX&W 8=AR~B/͟hӓ$$G@Zr `P[_r8IldJ } CE?)7rǜ%äМ*y)zUbR_jAK\bF؋ ۈipL[VX?,)ߺtp u?dNxuyFk%_mZ  @o[/~'|^*~;qgGɿhҿ[^_?yF?,` K=+/?h߯;"͛/Yr<h` ?L{"dz><#Ү?ɇ kxHkMR_ޏ$ٗz9'~&z؆250_Cɹ?7eqΑ\Ãmvl2! X⶯h~ kPǝv?~=l7(oڵhG?R /F{E7aQShR >Oc_+{ƞLUϝ-ϟ~tgR"hsPec?c;_"Swu/i)՟?|ctyW_VZn/'[+&BLzHiˣwK8q4?nْف_2'Ρl/ -PNk!󗀘IQZoy?xpւJaD?e?rtWxف?>32h gV_V5sSez_PDVyp?5cYwm:?Oԝ[z%$Q߉o] 7B3ps#μVT Z?(߱/\ @OuI"f^ǷcNg.z.Z/ϼ@pXo TG7ohgn}.c [0kK kk5 \; W WȿEx~XdP Բ)]dtS‰?}vLo^3p| 2@&4_d4/sc'guG?'ky)l1=X94(~zߜ?,)'U_]@+&oSX2|?%H{jZ?:CMuy K8$fFfQL?t2^ S9F&:GA|R?Z'뿡o`P{?u'l?ZT;lcù8#Kq&z=;1o2TREE;@L~q8,8,`;1,>, 2,p9, T,@o,TX5X`6XX XXX@Z.{Xq PI`Yd| oaH@\\`#= ][DumӐW@@`1 47RM@o)m`R S)&|@//4J!4`f44 4844@O4S```x^ XU !$PݸB!+}Je(:C)!⮨PJ!{I\#FRPRwy{@;y|s~~ J3L\<\(vl(]h/WʈXCO=W+&ҏUTeW衡3Z?^Nr.ʯ6n>ׯ[瘴oj[OVqշ#_712>id)P?ҳΗS_6'.DD0m hS^'Gu(_ÖzI+uQ>P'E8.itKW~w\%yK ܏øՋTqP?VNľA|*麞+iJ?8cs3FY;{?8c$_Dq_#8pվВ:[9B+|sj'}4#EbhOx;7Di^4.MAtwogCCNnimpXwb^0IHwv9<6n-%$c O0%E2 z=rCt V4SυZ^%MꤕI3p-Āv1+ y2r_?vn_5n=6BAc_3S9 _J`Ax'daOZT$>*WUOYB j*/;J*%L?{xl?wZ?oM o+wO+QO+&F=~}*F*{.~[ }N wz6 yCɓw4g{ahʪA:=o.Wnm>{_T-1`-[7?6s݌Țjup x[mNF3Ҧ*Kf[d~HܬBjv+fcRYO?o-BjVK_P&bD3Z(znV_;%ֿ꿴K*$ĀW ?5ψлcZ}zV5;a3dQ7F3xwL['ݫ?xF TѭCȿ~hK. ͊KRxg:XC嶙??d9ЭP+"6eǃ_E)8 O)O@sgV ?c]??_d#kjOZ7(m;[ fY;%>A@FŜ}YVª;* 矤UǗrS\s#_>VgNwnŇ 3׌?t*/6b?ՍAk]!?2?^PA>'wHh-@KyȚOCլW Ѥg${σ}vzNY?%g ωǷcK5?A}?ZQa9oK!^s/ODv?/NU$?kiGS7/Na2Zytm4Z:%-1\OdVK n';\-&?[T?y*xI\GFs:0Esӿk{*鿊5E_w{?Kk*wb__ϱڰ?{.g[oٚ䟲.|խZ>-Z_C=ETs jvC;f;/{eZKY344?To]V0Uߠ /͕?_?P~-OWzbJ#ӟ/};}ŚNqNgJfRw_INǥ5Q_C#LaO~c8T}@MYMF^:N|E—ٸkU^Q@>{}&!#hNYsc+/7_}Hӕ ?w?2sCoGPœ?W'=; MRnw?}Ù<zNAo"qB_%^O5[ϱE qyf ۽#J15M,8phD9![ݎ%_/_Enߤ9.z~2k`A{cfsѳ,)SV&G?:w ^&Fszn&{h|0+c9~}?c-{|/}{;d'xLj䏞R95GYp]qWsS9kZˋ(l[&޽brw=25s'ru9@ *M;P*,W嶱̟~)!eթj)'3gAyK[or&AS3CSC=7YX %~)q)2&nkBɟo.A{t*q ;򹏔?%{؈bscQy>55'{R,jsi?At * E|jbZ߶ʟo?Svx-_q?3MUW_t('Nj~cI෦dwB˨с?Vn VjRrkU扴6O| Op 7 բ"=cѦͶialΟ\V}yfOԇ-<<4 Ik-̟ghsZ5%?8Zcn?}Fh3(O{/Q˟qI?+0q cg{?fվQ{}jg:mA߹Ῐc?>t~瀥U^'b!n3yF<c3\Ϸ;MuR0no^jvc?Z9ɳ4NNot=\́3@򿐷I9MO[}3mM/kRp?w\σk[ҕ_5?w{_> ~&LOORq-E0+&<),s*YꯝƄS<J{b_O$ЇNc#:iϟ[ҍ 8+AHh{nG[%ȑV8?l3ºa(^?Uher?ڙϾI̻Nӧ MxOaޒ'2Sy/f۩uO ZKy+m_ߔUњOphSo4_%ƙ/ŭb}@OiOe[8~y.}3f[޽߳];%?Նcky]KG37RZrLvs!6ikdMWm fL^_#ì,^ߤ&|<^;CH]7@5GfwݺIZ Fi`F"k_VfCd4ϘGsi{FۭuG'@d_լ'=y~ASc3*#}5yf_H=uys DŽKmiCK>3_s\h?js%,DwO~^wHMlK)w=~v=~\'̟rC: 攥>;DsQ?k^~?8f{9cAX_Y7O6?pZ?ݣ"dbNCuϟl"'g}f8u"ꟸy y3d:+w'3oW?iլǯOPqsɓ'3҉',k`o?44Ser?1WӿT`^v,([S~\0|Vbc~ΟvY>d缾}op殹/W<w\uΎ?fO>&pf?|>yA|kUW?imڛ/(?{cBW3{r~?|`0Tǀ?ySvtd^^!O31,ϒ7vtH' ͭJ0U1n5mC?r?M3l͜?!f?OvFGC~SS߇t)8;b(XpȜ;&2/Qmv?g6*8}j@O3o@qܘk'쏹C5XSn϶U1/˨?S7`A)O.?󷫿\Q|zf/Jx}hI6ruVm8Gg.m)\ߘkA?qU' Yg8W5ҳ}+3odK/.{9IY5?Qqke׺OYd+N9ǘ?(}♀%Xۤg4;6'r 2{|)adc?xŕ?]/Q)нk?)3<řO/jL9f>I?896T5WrcZ?ρ WAd{҉isJ69ϟ63Qgt/{?L;˟*7/I揳1Va\ܣa`??}@Q_ gl"??}w4?^t57O@xL̀uF>ǏP45pw?5b/zN8ύ Қoz;ϡ%rΟoߟl(O 7)ZUFYV{OS|l'H4y(^k?I3}zQdĬS~Qt4yo'؈bZ9д#6eE>o~Y'nc7UG gA|?U^Nk*߱c#3Zs'?-ϩQ;@'ffk? ρn/:͟1fDߵ.^SLj0/xᎈ񚈈cnDa8Đ1aH uymoG>{{~ϳ.B9kt}egj-O?exBK4o)rv|$iÂ"2D/[LnJ2h4 V>Ӭf/itVx`S֒7.i w[SK~1puEp_,Hll48Ɋl=w%і}Đgw-@"C{ZV_#{$QkWg vɗ?.{,Dz"wZiEe3GO'F?ܿ@Iyk#f*ygvvRقὬ#M8)irtTcAp2hދC/{EJWξD!㮔?x7^$(Bj oߓxbLFur]q@j? W]+&99 L J''%GZnB81?6JZŷ|=:Q- -?GՌY??߯6L+&??"?Y8 +im;6I)p*h7 L\?SZ)꿪< ]g|Y?]Unwn_KʹgVK27P-}' J.JUp8X-.vF#?dt?p {/QO\FJ _?L!)%|?9>V_߻ K%Pοs"Y RtAwpxA7#gok#Z~A^݈=s O㾜pf0ݎyn'WNNhPytzZ;I_u5;a ݉"^#F]BUR?z[Uy_ҳWNLS[+dĿrWx)9e2a]rew 0ճ {XOܸ jd_33C%C?3b? FSοW2oV쵕,@gLqwo D?VLrN2/0Ϩ9k1W2aR 5X)T?G2G,gwB.?#-0xoF*_;rt/try3ua_[nZ-:e:3W_-['J |VŜ_u.5Rs2GNvydv]2iYus|_E>F/K$XΝuȿ:Q?dw%1Z! $4 ;gr' Gߠ'rfgg?+.kOwIɿn62X5!sg7ǯ`=gK{R6~8AnA>GPN g`]?pRݞ&6OÁ__q`zM9mb-q\ߔv'Cl: ωe'_:M6?[1ӫo|?\l?ޓkӳ]TgԜgl,f5_:cY)~'~gv?\&(%cߑ7aQ?s6lg%wfu |E5k{D,Q9RC~V1kh\'~"qՓc]VŷdI`|󇞉sUasD9HZG9;UkO __[ v 6-°,* o*cPgn]B]g7_H0!6U?$83 4ϨJ9I'R!)KC d8~|)3ol:$1#?gwuܕ?ߣw'˼+FW0?v>TC"B4r0]\@̟9xu/Nt? x?*P'Cw˼)zHU?ohJ.h;zh2xP_%?DR埵F'K{Dsa_\8+:@//OQ_ {|  #ȲpaWjkw!7 SQAٙ,+Vѩb3ۺwL-o\߼mYog}E.h⤔qT_f3'%}֧CYhjE?O{?k?7<%#Ga6?$[`K.sG?+U!yb?gvg}zNM۬{8I\k{۫ZFgM\er/jZf/]{yyGΰEw|LB9);_-#c?d.?aԴCI=u%p7GY3"Xug_gn1P;|qnV2~ ˟ |NȟEJ\+;12tl[pɖmXK2GOcwgA"B(x?DV_~il -yC]Aq_Ok΃}=>6Ct7~0_䟸˼߈ }ϰ( =M\591$/JٝH ?:tR`Q%@?cccu{vJfKZ1{%R{#L^ VwsopZ(/su➌6pI?\9TBf,GDbQe'A'{R?ϝDhK?Ο(_|Fꃉ;)f-S`x"#/5\zWuNԴJˠ?#x/(_`2? dܕMT=Xt)SV]i,AkwӁQA,z$=XC9]TcȦG!G^Wesb~-eG/s 'QJ!nrY"ܛ 1ߑ/?vA9ow4oK FU. ߈]_gpLD➓-6Fޯ^ON@N(V>X󀠮͸:?"t"}S%d7=Y~" q߂Kg'U:< sR2˟/P[u7ll}X=sͫ{tLRq_2AOj#RNp?]y$/'nL!gwNOAI)a?1e$/P7q?W46_#/}z%!qGߨ*=s?LCeϵ?82?%X 8 ggr:㳍Jw4enaHTſcv7/_ ,8a4S5z^jL3[Y'煨,+AUy?<`MAL:Οw[9M?L YN(4e x'҅̕#U?>]Sٸ? [|g3yD`S-a |:翡uw%tT17Oagrs|e{xUt<N+w=3/R؏B^/C%ܘ&D?f?68Т'//8gl:޺?/D΢E7k:kM#Sm_T?rNZj.6aupagxo40}Psl ǚGş?w6󶨿M}aXm&vZqmg^foS_TSwslb/npϯ7w?u׳͞jϠ{8k7{ `XUG?ެց۾Au{uzn&/\Y^?#B! S:S'pM|xn_;AupeU=6g?7s\}xx^y\Ǒ "R$Đ$""FJ)9'jv)R$f)""}وH"fvX#"Eݬ}~' Ƚ33Yt 9Hu.b/0S<7gmk YNΰX>AKO뾧kXkZ+^Os/ڷ3 zU/ZXUwg [CWo:7ǵr:ޘ!9y;M=m+|OmwS&}8JaUΊӄض5\黽Fhq=}ȬNS):3ΎaiEDA3{: [;^9c:}|m;~# g`(ޛNwn]P>Tsn9=X$kxC\1 '>[8?틶~7i,b]WON?Q%Ct>qn 3~ǬC#24cOg:Ì0+K]C;;rM'ځN?7L[ O?FJk}|m,t?432E}z@\{l?]ku'uɿ" ˇi15|'=="A OwFKδWw^AlT~aq#a] 2#KWVCjdΞYv =w3\c ?b_ʿ9 YiNۚtBr.]qL^ZQN?t-Ki!m#6L_jWsTS_*RìQp%47M_C3f?qOɝ̍aa;n$VR/O̔KWoENMҕVֹjR=.mݤ ;ss *]7[;Cth|wVVʋs?gm-/C䟾IFϞ V+t[)upHT4=mB7 mV oM_b /n7?C9{e:ճ?/z6mٓ3?m뜦c p[o~w;x^A.EP/ =ct bМfՌt=-b/*E#g̳=,?4Ս`΍ }"'G/AOu#C2_[ގǰ_ct+EeW5ɿ*rſ]|$51/XsD|}rH=e꿒3#>n -^guҽ뤞n {D3o$]NchVKWLњ:g.g;@3QIE)A+zT).deɟşzgcvct|Rr]&7Ѥ^˵L]ySŢ , S643K_%g'Gwq}]a?gysa u`9xE+_?I2GD>h%.ekY3.9SSNkLQ;Z?زł?4  gdҗMwW15C!/{sWN'׸Aii5a=S6\=ӿ1D$t0|O3֔ډO7U%315 _H_X] JOެK^@')k33w?7Zk;_s?/ƚzysӪuPWNiC5&=<_O?%<ȟM=NzF?NaЧǕ) 2ZEGGOW.'9DǓ|O3R 9GG" Uq%Q%CD~.z&w%iB(w8+u`~@u!ΚCϻk{ڔ?F8nm(L:oXkN)/{ ?{8qBuPf|z$Nx{ŏ#>r;n*^NQ1qV!'-jX7&ùʟCF迺dd{NI69-?{K> V=Q+~?ٓ)_q4PR3H5NjojO 1*?.|b?ĭ7?}7&g1ɿ Xk{d@a.u_? oҹZs]KvRk4biBO}.gVTFO `q#wkѵi"|xx=?w/?gc ]z81.! j+?Yz5LC?bsд6˭[䬐jE cojwK? fWXCV)}dQl+$ -;P/T:N6vxإ.{\ز/T)G O+9[.o{ɴ+Hi-_ªe'0lGng^$[}ϛ/; ѣP#M:EKm1*|rΫIЖH?W?m>?ԓ7fKq6G{U3G_bZ QVOS#?6n'0={}NuQY@݉6|[&o#mv,gﵛ-w';C+ : jX>OA5)/rwc zE,BȨwL+Y` Sɟ꬚?Pb9{Kޢ){VzÌ(T?9wWwiv?L#o`Ӗ󊘿až-zu?GS}xy(ӧk6mÊ?}GDRsV\}#qU;̰-]}\S%]ryg܃?r+wwӖ.KYqGO9}\wǿ{qHO/{{?,e`=-oFA/<0cl!}WKxeVuU)Ql/f F'7qWO:U鿩i]C,ɣzERWKG? ?eMlx^y\"!FRw~c HiL)fH)E\4b}gr)R.b{ryƙwIG?5 |<Mclzj_0uLv-:e-tei>aڨzO!u)N4>35kI?TYi'j3㵞# (JE{6()-6᰸8F\=uT]9 =Вc{>F^(QÖԧW.G#/kö^JMj7+jjs]=4W}^?i i݃ļ^?Ӗ]zRKi"n|~TN׭7 ??'~.4uTwꢿ`:-4} Mm+J'q5ڒ=^K ~oYU-i35=&14](?>JX_v`&w(}I'Iʶvu[3[3UɿNmbG9uWz?nMIWR9r-'Y{~$z=!-&G+ gtZ ۩ԭ.?k㪙?~_qwgڃ?]G]%߬/v規Hۮyo4Q@3~n M?G[ݷK[[wz*%nͬtꦂ;mMN~̟tS˿V7KWذX3Sz>Icf㷊3Sh_u"/Zr(-K@}T7u:,%MW>?iRH2T?;)O:ǵ:Ϻi"p<]zG{ovilY?CI?So1F)?,b z=D?Ђޯ)o--WgoY*.?үD{C.+4y8q  ٺ+1!?g)C1A@Կ'o_e9go.q' s5#m'3еUKjwY /]2ce?xx3'YQIV}GXJ!} NX'*OlԟOOYܑݒ?qM#4?w?]KAWuֿ俢՗oI'/g7/' sYoJL^Z#_zZu37b6pm#\c1yY9){ڨȻd8Gۋ&?>I.7Vv?.~Xj;NS!OCOע'F=i3T. bPMҝaiO)p2_ #o\q^ȼ̿cV{G YKY`Ow! 6]٪?:{U[Q9?`U_l!_smߑ QNkqUO9)nQbu)qOojc*GEh& ?({$q=E` kK?y)NȠw#OELQf&/xh1_lOј~O7O.jbָ1x͓? '(SΟö]ksZ;?EE*cvo5ri6HǾI|kd?! L #WkNؠP/f'96ky8]8 )UYXVPʝ "-0}4q?1ef4xcjOu^P[^2 *c?jKgTK'|,~Q2Ja+`X61a8s\6O5R~6fö}z#7,4 ;e]KS?iĺ<gsHWʢ8'+ԳXX8Ɲw}#?8&f\?QQQop]uW ;nQlX)PSv_zlO_ok<'3?}v~ -*?WO-zvki}y/_{OEX6|6T6tu3GT@.٘V}!g՝YXr]=3G ?.,GGO\ömܷ?=;| BΟ5;ݷO/EzwAFSIo/lL913w??GD3OJ('CYIuqi3&vQ࿪ϔGOr$᷒K$r<}?ʟS5#0os9w.av67M29MDG0rWG۬ȘOr߲t~z.DjAAڶIk/5~Wm2o3`nQI?21۬;d~;<F ZQQ*]fo?'jxVI]`&xjccr-jnkTgK\ߵ=o_řO7ۨ_z1[SS.lORE9>Im5G)[řO}IK彲PeZ+ER_yR,1-0K ?(;%δ; t/GO_I.Ezyцβqu?''.?Mo OA3?TKXћCE?o߱ˏ?S=R:!"z3ɖHA&LxpE#$>6ב~frKwyC}!gKiƎjz*u"\Bn7+?fpLlyqvA\s~}W"-U&cI^ARgYMPFw.$f)|B%m\jRݯ*jq&ȟ.矞?\g }y?`S+&1_?_E?#rq9WYg}XQ4߯_GX_?P'|FT|?-Ɓc' cNk?Fæ+A̿nO\'!;-/Ձ $$g?yp/ A][|7 U_ ?R$ЪWx'& CgWv䟉Wvb??ZGTz[g"-({\)CWΟqZ_\0, g|t_6{9tF+W>cK9@%]<_0Jsϑ{ׇs~-oQQr7=[y9 9{hXq޿¹ެc\^|?S[=RxS?γBTɎφg\gzzW[H{C?e|KUbƕSgL$9_O}>vEt,o~"$̡$ŀ?e$9wO8}Rߓ1s}Y>O|;OONFb]gl@ w*=bgz?lA٥w,s?g\P(_t< sxeŝ3Wjl+(["ZYvi?tcז)q]?5?/4?}f ?$~Gςq^#CDTԅ7oQ/9{xOE"f{Ey?X?˸7l(ek2ϥ.UsuKe&ř?ɱוw79W6_ 딓xCA:l#`;Zsc  G&1?qn?bbQP?OuϿ ? Q}/7U1G+5kWV)?-w٥[/\/@;r9sTisj;q?wξW8NՄ /3EjP yQQQ'ϒ6.nϿ$*Ǖ֥1{?%uXWF5m?GrAGK5"96Xm^"v6ƥ'~_?ȁ+p>d揳%i<?mřc|ۊdϧ?U} mԧS7?Ε1z-~=x^yX RB1Qq)H-xwTĵk)ѸJ%}Pj !( RJKܵs?f^g會~?e0g7<;_)jckҒs'.?>ZqkU·z#5ڢr?ϧ7ZI= iok>iv_/{x{<X{iBc}?k~k֑ Ns~JQ;j! )bԜy+;vwL?kQ8 ZJ5-揢gڄwe}9 wjck1.oiӓGuXl%)[ :jm\nj]I5=efO}@H= *ZwKMx_]זe ;YK^^\G_V;bCso$$uV `ft/WbtV8;I3] #~%#kEUj]7O.m0c"3FɹgR έb\?m6^ ӷ3S|_k: xӎ޽*V!8ݷ?9BJh_9CDiBDswQcUq*tӓ_RvW8=гK;+UV:S'0{|RR>fIIe_D(9&CRZ%$? G=oVuv(kq9O_.rVP \?TMjbdg;bvPSgT'Hw#m痢%sJˇ@؄s*fJO?Iv٪oBag~*>?&rӇq(cO ]c35ޜ3vc ǐ~?.JIZKοvD?iѪ+TLV/7lFm?1=EmkQ]b6}'=C>҃?Ƶf_Ɓ˾4JIt{ {m8zR)%zN{"34 \/:??haq86yJ\9*t~˪r C:1K;<텾N_![FԫR?;YKOqxZh%xs[ :L]K*EkS2_> }_?f 7VF0<]wr}sO)=DBnȷ{6 Ug>/S'>{MC2R]?VNdչMG O͹%?Tn;&h:arC:dV኿^$_ ^IO:5?8K!>~;-tBe3JoXRmvS@1?\:BXU uC=,kwefK</há{Up51U27ѶN[.v yoSޓQ]?bFj7<s,U>ԪWoπaAA훭}}dkĿp@[V78?$u1z?G/?!a*%SzfWUy?uiycӃ&O=yU7 5?SzT/kǴvoy8+W+!R\7b?PM~ϸз>>OOE>G&-X QwDm{''T?Nlm+4T:qj)e4%.jsW^ }+X{KƤΛK%6)U wWrl? R'd' ' -^XpO=h-ӿ\=⎧T?쌗?g]O7Tu?yOO>TD]w=<<<^*fRCRjvgXogi?Ek`&Vɟ_k1/!_Q!FXO ?GӴeϕQ ?,/ZURhsY8riXg8!ǧ` _G5ue@ x_?O?7PhM{Uo_yLy7<MVO /09wy0P?mno_7_9|6ǟr )h%ֿt=9V*fz9{9SȪ4drԇy#N 9s!|GϜD +8^X^`}Pɧ<s_k<}˼\Gs|c\w[mk+ݷZC_!S\w'cA?|{fߋ5;*^!TzSfWy?XGS8}[6?ֿPsw yq%]O|_.kbvT3E5O)Z|a ۟xD9w&$>n_k)e')03SV HC}Qe3yUEtː>|s^q##7>ʹֵU̠6y_ o_a)3[-z}v:ry~'>ӓtP͏>?!_x U'ZoQhr?;LPCL-uԛmQl_\S._WIgŎ*ĸgi|]1ӷӟ5-ŔN@9ylס:?e3Z^ H[O=I =.ݤ5_-uuw?z3wU|@X/Jۆq٥V;W◊c^sZ=?_<.6c-\?GNC~,/Uk{ < RlZ!F WnD?kc/,ąȟ|S>er]# | S9ۘiњ7yl6󟜊 SUC~)0/@;N ,X/".8[_'hő;r*f9vHayu9]#ݴUWL9nص#:S%n?8@V.'yhА-m{s&Ss2ʼn%Cm')ֿu+ iUO+'6a$0c>㋥ɗiptU3B}Nz _lNup}#2 9bfտPqʟQԛc_Iטk&p9cO)?+^:ʕ:W?̟ 瞿C>j~*w#~N+5]{_p_2Vwa' _`/kғNn"w?} Y'o;OO~JsnJ>#n޿牶_>~&3kV+uS)lo?]Na?V}󷤤$:c`=B ?/m[?z{oi'_& fr< MOq4XQ+?ȿzSQn_L>Wr*~Fzo~Ba-\Vt??jdSX?`X?Sq0Sڴv:Cj=?&)+gM0埈+#+BxO7yǼ#?&xWQ/;O) 凟Snfi{̟;w ɋySt': 9qTj4^hE)}X/w?Om˫߹}W-y mPxG5LR(@ D|٢EL^9|X+zX)Czt ~VOuUDmO-v⥧6Շz)Ůp./Z/ד={xL_?IK6K\|󢗾ukWFNzV@ܾVԶ/z:_['[ ڧxWܧySMR^Yěڕn.V6 XS5JiڻlSt;_ h%okںL%fKNv(l2%J1S5yU6ӿCoie NL-^':y ɿfNѳ{FU2ubJOYkK V$/9lh:SӴB)Z@_O^' z~ީ"%ʾ WCt3'`ٲǩ+OU|?U=YMcן[Ŀl,<3sp)Oߧ㌘GQGsX%Mוп? W?;fM}Pjǿf༶FzB%}VOj-FOcq>'MtzA`Hr'{U<LD9SRFDӉ/џ]Q#Ɵ yĹ/MiӺ~zPk3>UN/Qa?3KycUCHMU_1OnaІ֔/:XL]j!' *w }GS^<B̈WŤEJ~~x0m%s?'tƁt*}غ[XkY+7Ǯ褂%B??SJ?YCZ5 GҼzlA_P5ulF9 '5m!|??)!U7_]ZBo濾:#e;[?Atb=yG.7;7ʍ'"KQ'?E0WzBfBy'?&X6]U?Y"{ -8?U_*ߩ%i rv~U9Gd;.)05{J+foY5\=+}t? c3HEO3̌Pˏ~ըs"v;I/ڔ@7!?q5O̟IC׺p$n7;=z3KSzQʠBm%?J//? A]XWǚqɟ橒nKHj? [͋*;OYQآ??ѱ!ŭeSBr# >Ϝ߇zuC%y3%)ǩm;`G'77?=;!/NǝIӮ s;:y 8RǮ.2JBkK!ϋ2^Sߺ-0f_i_dtz2˸ʮNCōqqtg9S/_XJ7Au?3c]C`/cmqZze!mҳ`赌2q/Q$+6Ly j?AP)Gg3Z sr7OJ j4kMbDL͟vk.埼(ӌ%z}uOa ɿOL?]=Ն33:PhZ'o8\OӺ-xO,[PoQ=V9lDc 5?Q !rǟy'nfhαQ_c?i[kaԏm?MT?ʿjOq=Gyq{n.ǕkaTSk/ 砡'6'k>cZ#*L#Ss֨~˔;W!YY_UkVG| ::e |!ϟf?ZsX?пW qEv})ɟkƁo p̄? w@vŎP$9,{Aҽt݊Y_am yyj3y 1}WYCq=_G8:|_GεS7'q$)Oyy#Sޤ,PP}dc._C(]=ߦ=^E.cQ򚂌.G׾uI%TKG>eJ0C+-vKK[) OLJ[a._*x0h-|ͪ3]mTGa#/cw\cWgQʼn?k[ʮgay5$K~T/Wp~_k?) jkw@fFUK\*m_~t(Z"*n*.)HEc?}1wukKE/Axyd_pXAzW?3_nW.Sޞj 1" O1Ӳ˟؏5ǽi^Қkk\90V-.V[[/ώio-U_PG_׺yҴ:ǽS8 3!?r8%,s'}(şqNUoۤHOgǼskT9ox俰 q&?Nv t]8< -sK®&ןbIk#a}9TSEY@ljPv19#GO(vƿQ>S#c=-WH@t2̗=~io?*d\K?3OO^wߟ:o?Rt mw 5SO1ۘ?GL^m`_8N:/ѕ_/̟*lqJo[ݧmYWMqG^#?\n{dWTP[Oٵ=zo7[_OT>xq>?FyBsdy/':]{6bR.?򄆹ç!7/?}/_+w_3yɠh(CLy 4PoF;Ca]o?rm%"PUz|zӗx3Iu?ӿj @G>4?5y^Q`q.+/j[UHc#ԇj W_opnT:m4VMo^=9i=?J'?N&PTF?h`87iC%虭/eSZIN'W*Ff~TtW?&fmK5b Ǯ33kǟ|_p`[ m'Yƕ>ZgogXv s>{Oz//yO+Y&N]Cs9@,?g53J\_ۂE`g՘gIMm_`|!:gW<ٜF'~>8)۾wWs6\zr٦J-\竴~xl/?(eE?w_mдǕ=Lmi >,k_m?=_50e)9r_e?k/<@>/g1}1Π,^tM =3sR`?8{a#a+i_~ASO/S۾1wt\Df)30?']pGY?ɫyOt# 2hB}?p*x 3t* 2ת8 yZ8P/;|΁'- ? ]Vq}eLW{1￐GN c?Z矑)mk?hp S4m52^7ߩb!obh58?hs^t)c:KYÙ35Lz/΀oK3gy❦΍b]QŏS??7T%,cWǹj~ȉJHe\Y!o*=Ϙ?kK!Ɇ`ӫ~[JըuETd?)|S楼Zx(p}}??8Wk?odkG<Ϟ=)+^E8xq[:ck0NbDX1s7۾)ꮑUq&Ś| ].:U$zq?`/?SVo-˴?>c6g{Җ1 tɟ8 f;l5Wbz<ǹІGZ?:9Mdhq@4b_{M_|yY۞g+-yX6%ӟW׶CyP{GqQ,WMuO(9c+ARӬutDjՀ1UJԸv!V-kuB14Wc]eo-?'O k[eGdcs!|KM=|j~kjtϯ`%}|]2CaU@6%kvk@urYunx"֗5ݨdgc,j[߫6QzES^kT6/ \\| u֙*_sW\USW| s`gj c~^fpAY{9?zߐ3_Y3kkT./%wtՎGdxe\XW)q?>{`o'=-~-3=GS?.O@i VTie:k_W׹ qMW:9?;UU?;c mҚz.̖^`JR2Ox'lr-B$!_fokQZ_vcB1M&?f?O_1-Fh'[u֭oH660SNY8;ur衲ψ?y--5+"iz}.,RWgZ s-OmH7݊~t$-6Nh:@ދϼ/XVwխh+Z_s?ߧĸ'T Glz[[)Dsֳ[>PQP^6?=vYîO3ygWoG܏k~/ֹ-f P#]EBe[域Sj_?G基=w"RSMZAc2OO}5H::t'yP?{YL2% !O wtŴorO'"+(8}X߭k3HHf8WyftY8;s;Pg/ͮgFǧrt3Zu{ށG?~~Å'_th1]kM\B!J?SQ^ c8~5sEwfG暊i'%-6Nxy,?zVFmYkG1\ʎRY ]誦2P~NEOfp׍}Pp<"Z=d!2ϟYO=|ono ?FE@ϑH^,O䟫6?ֳAc91L])^9_Ԗ"!Agِ`?bo [oK\HICk)j&_J/i i/3-ṩBO=/eAOө7ōWO-v=Fы 2kN߭'A^ %,k _`+GnFs˿c cMJtЅz֓}JWӬ-&cADొ^diKs_A8@FV8D9f?gk <),!+vTǿ zY99[ءoy^6,/y4'v}ydؕa6p Oc ??|[ߐ?΅&DdmstR-c{ַz7:gO|Y~%9KZ~?2%_||J (ρk/1ޡ qwfm >i+_=IqfKk2C%뫐)4 7X+/A. h1V}J,_.i?>p^;-}2I=`;LKU[;7 ?+!qVŴ@Bu,_/f6['VTGܓӚy|4gI>K?"< ^2F7a% BT?f&Z y%Ê"+@AOS86XտR,k<_AuĿ}/SkK/cϏY?)Boή"Uhpp}8žXaFgS19n5?Wٓ|S<௝ynҚUv, JwBנW{CnP] 1_p`-wifp pnqڢ@F͟[rkwJ\PW)Afw쟖CyGd$8쬻p* ?["/|L?ӭ#Mquvdq6t {?Xvho[|^?0RՓ_p>;=_OGWن%kDZn_Mg盝|g%9브QN7?̞ :yҨ0 ?X9'04 z' ESo[himj+KX(-w$E&B>%1-}M؟? ^';7P3Vܟplu')^&{zKO[) t pf2\j)Z(jٚ֘9䠝f'6ГW |,xG>2gg=-i1VvZP[fV]9]Ϗ[_.͹CZ )52wOr7 Qyli?#jɲY D?-DӒ7?H浴?@G"⟟12ȊѺ_Ǻ?w&/׉?~?\?J7= O_JdBuKSW+Z!ֿC~N ;rstׅS1?Pz^ml~⚧ϐMi6qoٹr-~NׅsebdbpM#~'?˰Y@p =EgPx%{?l%_`sꤝn-?uGݓ^Vswޥ&ZQvd3hs-OZq%X^#_hz!pmK!ddBvaFЏ{i&?x8螺?q?[O\GqO.3,| ={lz*g}-IaO}JbQ.|T4IQ?1̢.qEy-w,@?$O6 I6_z5?!/Q?ԛ@ihOmGnhc?QUt=S;\m_p 2o=m6z9>2z?{3`?9P'i9u^4}FZ8C0~}(_Z{YPq!oE[4ebнsP7{|#Q1ȧs?b|:Gu "i=[B-[u$Kcso[|X!9|p =O\{}hȟ+9۽G/uw[,OfJ?Sf>_ғ[3~DV$?;-^nÄ(Y{Vkٗ!Rf`υf?// 4?F%N@JfnEςŲi=!7ǥ;GY>򚔟wXqsVK0xOGx}Iqbt3[E_Ǻ>9Zm=7]|h6 >8cFBC ꯵H{P/aMqϓ:"\tZXW2oug/;{X"r,UK71)!oqشS4%wB^GWm͟WP1K_V 'I//yV@~\1k_\Ӯ˟o뿭P|D?E2OZL|f#gx??? kߨNhw?#2 ~g=n~9{?zY[߮FKVw8#uxxkǦu1?s˿e_׹8Zç䔅_lɄwP?c±eCU|Uΐ9C>珌FOoel?xL?ǘZN?qւFAI/'Μ (ZyS 0􊓭s{@gNfc-@{;зFI#wa_1u?8EЊ9$yQ?QcѼ0izg;h~)C5|"qE?9h|ǢLf1q_S^=l:^B'p\}ViVZ}OOHsoa<fKk#jquxVzId[=.C><BK\6^٦\=;~'f:oB sXsV?kwS? /bx^{\UUDDwCDdpZc 1D&y ^PB2"FWIgcbѳ(C̼"6K}|nW#.t:,fe6ˍi#qv+GyՋ5{nO4Lֽn,\%.ۉ.Q!OWOљ).ƺ_'q?:ބHn?`M+!;EAYRJ!Y!YTʬԜ痻S->`._S?9k2G?3r LդS?v\п ?3:x?`X?Ax_?O%rZo>wS\9A?+\z\g`#- ol,f3/➠͟\~I{#7/>r֣sM㳫ֿgџjj_mo`]YTGGps|0?RX?GV 'T[iIMP2ˠhضGFn?bV迟ןtU!Ǜd<1U?^?u?ȿf"x_ }E@/>~GgAM,ߝqfÇhI)?G?߽`b/׿X &@k EiX3끭ZiHddf-GY_5ϛ kbV'Ջt\Ut@"bw^5? Z_rϣ fExfgm݄>**/ϟAddnj\Q꿂*QCMKYOlk˟NV?d:ʧвfoᒻd:溡KwƍrUSVO*di)|fD򘴊j? Lj;hV79`_?AJ7߽?iVG@}VRۇM^?ӡw05YwED& zYCTjxnhO桊EQ@OWvb%?#ZW pmqf=%r 3 kLe CO},cJᎥZ2/i*?ɓb-1<柖;?ǼEאn)!,mkOo9~AxߠP?ZGYU_ YBsZ-v%2Jc}$)5,4kjsg6(߼^K(XO!3-g "I/hI6g͜hǾؾ]=D*[/- M@ Y{OʟL(Y?0];ƃ~˟OPCe*uUҨY~OH<]V+OXLt!7 &G|2C^ kR<Yzg'<ܟZi,*7KguǹZag 9s oZ1Mm3LSBY Oc<:hs$e5C"ڳ_ ~I)ΡG|JsQ_v#wF+ce)h?M^e?5p'2 䖞`%Ep}{`eUIB9߮fh9x?0֋~k 6?\KI?1?ϟB|ϲdp#_aoIMKowA?kI&O%ijG%bE=AR?/g?ߠȟÌiMm><[sken?'2n>X/',Ky«>LEqۗ@-Wޅ S?qpVbZ<-ī2?ʟ-ضg^ꯇMy e_AZ0#Eb,¤<"KV3L/jF,)GNPs-[?GOlk[W81?2o\u,wƝgpz8x^P %t~6j?Cw@z]Xo>"O*>,{֒Oւ\>>':%0sA/ܧ7_?CL?Tw$q3qW?APWo.zrJzBbuKY%N?5{)YjKm3Mw?'y]M_'Yִ'8쿦.ezro&~🢌%O*TQRٱ/?o?㜸H/=og|P efVpbxDYFF_M{fWO#x&?+u c[` 6v7?Ƀ_/5W߹J?M/\oO0*\-g ,s9E5??] )&<7#+JAli? &OIVkwΟ,oo_`gs}K3.&5qyWϵr7/9kd<&Zvc.'flnT?=:h=A=qT\K#ZvBv5-w_%a?L~I=| cn \sܜ?64Qbg\p &q?_?nz_l':3eVYLxFoIy^i_)CJ>ƅ=_{3?hbh2LOg?xgV_`MFrE܃3Y<ѕ_rk3`.GοW?\/!MKXW_N~?p0; LZCF%jPbkh_R#薧]n( ŠV/Ci%o-gjJK<#?> ^ srgN?; U˟pi^-ϩ]GN 5ϿJWN6v;f3_>,9m-#Q)Fq  ip3,. kކl =j8*-MuA;&c\&@KQD^/Xg (pPȼ>b *?dz7/X̟6?{aJOKn=|%yA.>5|W?5Ф4-]&9?=[o&_,_^_9C,o>7Dĥ6Sz4ߩ?_?-zl1oaMK"@%cm gL/^fP|oY\:GXC nCm9Ķ'(߮f6E8sFIl,f}:rڱ>vy"۷Wpw'39iQ||^DOv:dTQ^ \1-Y]M3Q_^reY9HSV;!g!lpQn YNGxOb6([CZwVw9?=|"{^P?X=1lOKG_^뷉%XP@{.,?)3Ľ_OA3wV׿Ǐ]-Kg Âr\8 7XZwK ec;Gw)Jg=)pʆ;>.̷$2LH8)eؾ2K/s Ol?#:Οmo?jXWG{Ć%KG>9G_ȓ*ѽ>*~)bߗG&ȧ_Mљ+tsb՜]F ϹC_:G{ G~3+ ?.z ԋumYwu||7ߧZ߄DŽQ_x̄\aV?5Y_1+~{rl$S̟E_%O]*uҹE*STo/=x܏OSq/o,b?{^kC2бJh u=ߌeO/+y%+;?XZ9p&kƪToKQ?!Ub_GA&0LG?/eG+g5˖׍%䦃?+=sߞ )0c?"S5gvȟ>#RA35[xOôZ8-巿"ukך&nI!yF"Mj$Keg3]d'w2~Kƪݾ[VcϟzsQt{o<+zX}kPk>]kjĒ2g6>>-Ge\5培J&êkH'Fx?bxI@~X3bמ4Yy\Hns\cs&d=S0a {?t)зR?lV?7i׭}JP";%#m?_t쟳/]v07/=i]WR&%0#oj zOZmzO,@?LgW^#?)eܩG˞m&6}L/a$g7 a ꀏ SѽڶNg{O - jxO9 Ό5:,WҸS]\x݈#UOuE?)^f統[?*|+#_H?e S7O}?=B!16Mw8", S)?Р0҄Ssl8~g,F jjBuU/R8N̒+8y)3b}A`Z~3Y?z*\Dk}s/_Z{_:?eEqǽ<C)? }3ەy}OwJYn*Pfy٧<נ*+_#{?X׹;'^!nC<+-3.'w V۩>'kG\BݒuXM,'߫Sxy;)gSOղ;3t[vlw:G̍dx?4cߗ4 3;ۏ(Qx?ݶKйu+Dħ]}LT58zu,*A%oq;tZyߛ23X2Oyܟl,p?2r1,, syM1^љ3d+c_"2^SZ%79MZ+=J?1DkrZAz<>|ܲ'ʚxЗjQ5;| X/?)]c!9ʙ~&矦;%zGj '_*Pquk}7 [!eo_Om[EON\=!Eg߰XN2{Di> kUA2?G9Qoa/yJo#ϟ/嗀3G=#䜯Rx;'YgoqqoIdGumߚp?Zu jZ߲ci!*RDot3s~_5qг\K~.2V{1|\c_O^FeY㟐_Ԛ߾>4̊?M><"?}7P!8~T\ܛM[;/̳SNfY,>mXڎeN[ޯW1!zu~y9OwPEh0\cm\׹_3ei~joM=O |iUɷyYQ{9K|pL_aQ?z9'CPfgV޶R2x1O=ا9|>S嘉 wʦ ܢ?1sUiO;Il{ta:3!˴Vl!ky8?IYwG}*U#ci~$+;꺢?& M?=#)$d,{*9?cu>wŌ2$5 ;wK[ 4T'}2_,.K]־=:^nk .2OyPYi߶9)v׿/ϥjTr}K^J YV?^utV6IL/M7ӃpI,}?sOOAU軗%٢/,5< ץހOO?u@X|K29I?7QTPsﰜ?CO,Y)k aai&92:`^z3ptkY!rj̿dWbV>L?%Sg{9mt)XgR~ ?Or'p2di'tS5-Q?kO^/E{.5}*uji]]aS~DMs^]?wN^w?w?kz}cg۞sіG9j?g2^MX?aZR}]Ng}9 !!!wg~-+}㷬">}HNReάp: 5[3Ot:^򒌬b?G}?wC[j~SU翪24O2n8V#Ӣ/K;xF4[=^aM~HP)%ẘ{V^7{.8CSV!!3EzXl꿨_z2?'Ɵa?? ϫe}=iaOO{ogc)TҘ)d♺?־}13ME{ YkFs5#s#)+4R.g[.p|TG]u˗X;W(zĬJo?Kg/bGa=Our=o=f"8<齯E{(k~j4Fgiǜ*.V?:3QƜXkÙ̞W Mb?KigQ%F`M3`:Ɵs/qOr 矖 ?:fV\vn[bfJ;> kƞRW{[8uYjG{CׅǾ{?cu#zF%o ]Ӹɔ?$Sx^y\ HP%PC ;gP}PJy Ժh4QBB" RJ,\%!PjR>ctf+|\ޙ眱b=&?Y|x%%&:[6$Yk,6=%@90KMe];ʠ7eСJgkPȷ}NQ#׺[kXw>}S%4^'+~be٧+KC YQm,,>˺|xvNV ye<1GI:eM#VVQ2;i|ri]uϠJzasJ)hk|N)jS9ߺ>eSJΊ+mה ^twz5+_sfXүRLe5}{nmUƅp͕%V}xo^H8=,ĭDI}N=W&)r\+tL᧬_5ڃޠcfV3K:_iEmŷYי[֘?)J:_c7k7&4eG/YcQN=F7Kͻɍ޲P?9?ǻ^O؞[nVZķ, u`]53YVN e+Zb/ QzIV#dyyYI>LHX?0WomQĠ. +3373f?tߝݩ1GZ#Ƕzo;vnτ63u`-3u ׀ccOO7rRi>e?m)Ϩ3_ iA:̈́?q|(?}l5ee\qDe ;: VoKm_TmI:?ɂ_:<Ἒ'#,sAljc߯EviJ>dO~SO)Ʌ~]WmOsVJZOg\}M%nϲEOg)ӷ8OH4yU_`Ò'k'#Tg賸{gt_6i0O|X[I"cʒ\&\E-4߽~Sjy;aNB?! G/*iWHϠrR廒;$QۊcT_~vʐpoy8?)Ϣտ{X6}J2Z(\gC??zZHZi -\ߨSm!oOg_Q`V5#I?E(sچg{-#~j_˃8?)V?|[l:S/%65.½|Z>pĿOY9ϟ"O׀hYosV_Y?+*C e?v-3Z?.A2,Po1}toBTgj T쾚~\ Ce:$ω;uwoW}u)WG?F4?iq5}ӿ='(]]{ub?uX?%c`1kg001HQT Pe&d2܇pfr$ʆNs[2_O}4?5B׊T #)-F] M+O_˿.O<jo:P. 麴]7XyzE}?Y*!2o?O=*F}mّc=(yɽ~81˸S3/'iSUGVFe?~ k|E:v'{uo>:qB"{#4)'<}5@K->Tݼ" .y6Y22=3Qw_Gtu/uClm?kUoxrZ]exc+TU_ϰ?3s[ ?,)_״mG?&ym_kOzIL'eN*T=1?q7߂\5PF%ZiFt<;ffƟVlاXWHӧ ϨaCϟKfSThu`So0o;] a/alo;(d *_cT71߯qq g6XG?:{ʟCDs^Yқ1k왿]=͌Z^}J;s~?f~_~+H)_'!*s<f~tͤğH wz: ɩj4-Y-*w&w%Q3} 5zgOb7ux,$PM+MgnV̜9Uتf1@?9,UZUwM ;mCDžF," ` v_T,/)٬`d{Uo| ߝZu?WD?״ޙjS_?g'^`C׿;zwfԼF?i9s\H9ۧ\?Y+Ӄʏ~, meL3W_3.N0?e`Iĝt 7pTT?O̘16_{5n%;bf["S~6E~j ^e>eSǿT#'$oH3E{#F?鼛">>:c'|aI0'gXqzڬZa_ ;9}L7t7my]Ž:jGg{C ?]H~溂?z3p\,vګźSyQ5?SfYOawK&y:d3x7G|oڬ G?zm U_?yDvN3?FJ5ٷ6/]b,3:%?n}]7p?Tr'_V?Hv|&yޓZ:ciƟrM?w>v{p5kX=,n.lsQFB!n/pgF$}u?06LTYw+)Q{)??]yOmgLT3!UoirbS2{fWcKYn-QmzO?Y\:ŸX}ʮY*_OM?Wzŭj%u,h_JֿGojOcX'5C- eq]kZ_"Wc=ESFO״ 51΁X{p*鹊_?)걷j[_ $ґC8qg)/ѽ\?{%L]''<]PΟ*_R_wC>bO5or?_tVToqbݘ  3qċڝ97w%=?E+QW%ԟoTNقkxj8xSX ؗ.ww_+Vw?;U$]F?ݨuj}쓫$VZl7+ʉ?7QĄ/{}kF96͟?'3R-#QsKli 73^?RQhK ?3c.,yn?=a3g= 67b;IGIgO^N^OɲZ,_\[m/O}<]/3pڙXyrx]d-15O?]1{7oS 6Yc=Qgb>~ꐬ?aY[c*ps?P_^ϾWx~gW?d+c{8>7[6ϯQF7a.iw/ٰ|)^=s{;?B =H6nsOYhC bGaO} g!ǎǚm6p|3_vx?,iy?X#7֧캚1sRя[m?#̝u <'c/%3ǻ _!MH\Z i+6^/OH~צj??UESދ9]11#0w~ [,fG,Vw?,$>G?~&]"af*g26 ?s3QjgSo6HuOy:#pBaYxh{s6?Ke*ޡFZ_zJ-;;B5Aտu:pk7_òbկ1?&ïR.Cx^{\UeǑ)"sp`USPQ}pC7_wDF-E?0 0 p{Jhjs硵ZkO=yŢf=[O>]%I٢8Y<+dx%1s(ˑgŸbl|0~`1qKK]UJް|qⱻb[vo|}{Y˨wEWkt1-bő{=|~a?)K3b;tJU~4b$] 7ok8.Vl - +r}X5ܒ9Md ԙ[qۋ-s=cΩ+SJ[%gF8%[,⾁Ct߾$z秃kSSBp=qPlrhyep ǃ]G=13_JYKYpfk2Ov`)nVOvQr=3D-3V5u}É#sz%Jv.Cz_[)9WFܐHg,xJb0]7OpͫR{sĕWGZ{e'~Őp̿0ඝoVqÑY -O[Pp.S8GQD/6ٵBP_yuE\,|%BL?*44,\|g$[&Ā=´Ed3>?\/?q<3"*DXYQ!1|Sf=+֗쉶 Drq0܋ x .lu%"_,:8O6}80;0;|^e=M}}ęfA?3ˊRC%o0^Q#{I23zΐ6؟;/p^ɑ%e{-cX|V%oTFhYfJ _;-gnVoYi-6}só_C(z<<( 5ox ?&W_#{qI9#ݕk9XOgͫz:3L`2K,o" J@ݑ- Y=Ǿ_W#&?COX.#On'{\,Gm.ԿZaBi UN/@8s!˲ #?\;k'PY'l4$/pdJP[o^EEL aT2Gpe #U;kHX?p\ҿj'3-LſzJ[G|wӇǥO mOljfķ.?S2 *Z'P?cl$NOi铙в kYO1BUv?fU;X [*\|.CO(;Y%{?KREÂ3`ZqYWNr^U]`!'.а)Cݣu Ŝg-9bdUvG=#ϟSs$p 15+1soe7Zj:84LvstLGKBoMͽːc꿲*( xEa=Z:)Na䟔J1֏a9^{z3B? ?W?_U757GMs:=+ݒː?<L3+dXb !-OfW0bÜ>{3+g]=ߟuֿP/J/?x1<_;1  t/psɃwc/*wu|_{g{=c_$<!9_W C'e?[IQ!H[g̲ fJaMB=6ћÚ/1sńniUKTg-</_mGrQ&W֝ժG[Wxp& 9-x1UcYwz¿):SN돸R8]4cqgAFH7yN#!&4J1~uGAG=4Eogk[. OʟQj]U~ZN9,y Zow%#[g\%#(µP{+B׌$Ŀq\A)h+Qa7 ޲Ϝc/3)+c@F߼j#j?͟y o0I3Fr[Q! ~?O_g<]ћ]?/y=?t7H*>w֟7t?|%⯬EF+y R=kxF75 zW \yx. Խn0lbX$;{<ȾQ'r@$/)vH:8?#CH`*o?KAOf' ο!?-6MS?!oX-VἹq5l. ksk돯eFi V"9n1 :M@&r+PWho{{Vq?RL  ǕvK$鄬dSMc?cM'oa-1\\Uq_?r{=?{?3GmW??(_{gۖW?{}3Cvτ>]Ο4_[dsG( zȸX(W-L]eB?gJ[ƛ?SEzϒ?ߝ;qa=X}_%l?hן[n?GM_2?~\ Lo?^ '._ߡ䏳 VpI7hJyNvhP,i믤`8I99^UN "cyq %R\3U#\=qoI64J+G+$}A/ߕW-xp8kOe{!YLK]__1_S><>o/M%0[!(./3{|< G2moX (iTV?ԔmpVq?Q?-!'O*rh ~HϽ?8n J09";V5zmB@[+ap5y<YZ*U?my U{㣂@~ W75w/h#zgU-hba#oc Dc 8fwJϜ& ?x Geiê?ܯE3{{~- *[5e]O+R]@#^`_$# -x lo$_p\U~/Ŀw0qGĿq&ݹJw3|i`o4#ɮ VOodY}iU1;,X dr;?֊ksVš?d#x^o#SAL=-|˵GaM?oK???T/Ods9e ה†5x5̐?́Ջ?)8/qp;P}92O )ܗ5ooP埚jk Z#;(>e\,ɮ?/Aː{[Ycw?fǘ_i\qNΟ_UWq/'+O?\#_|KQ!ĚoQlJDYGok(fGg_.?J_5?p4|Yi2*ۏ?G+9P{0νk2gS𮍐O6^%ae.?l$e4Q=U{fJ7cZKlr?]?RY?pJY?8|JS_ n:=\7l30ΟmP=MOԿ3=ߣR?dA=O΀D/=TMWi?sqk,ZP|O CǕ]?vw Jq^==k 9p]8 | "g(~EU'\=Ai{f; kTS?jZK\JPM.$eܣW>jGǯqķ~h]9)P;-yW~/\;ꜿ9_PGs|tWϘLߕw1[ow?g*q \?U,t} /Cͫ^qIGg\+G<Rv?gnrG+4='۴ܝ+ř~|sNGijn$hodi6lQwt𧻓~e֞SI-G]$t_14>6Xi-i^^ojI1JWoqtz^Y^ΈzZ*itSjbynE-".]H7iUԖZH(w'ҧꉁŎ{hgk{;=p iKm QmzRǟK׺hOYia_׬-yE?^F{#@ cHkt)sS\fC :ug˱լS3so*f_#F߻~V3 ʿl=:Rм߼T?I/U[,?(DH{{'oMLK/8^?}/8>Ǚwp}xT' 319807P=UUϰ?$І ]?0Y>_-j V?S~5]maտˣǴͳ„'覧?+wY#3ղQY*'4k,O'睝cTsu uC?ӳ#/Z09*.>ʒiW8}GSb*mV[%=ܿB"}6jxJ*qW,ϹIvxm_YGS+SxRX=uQĠ'y/n#wDB Q"-vQyV}V~F͓;Żm| 6E7Q?;߬Ȋ33xvlG;_C$,_P/_srEZEhigCMo^^^j㗱4s{S;Ve:ۚNU% %EXr;Om|-ǜЪZTԛ8O͜=<O;n?`?8w#w"a^T]Ũ2#2FnP-,3]gյ*] ɟC;ʳN=(}}_ 1h-yr^^A<Eqbğ;xa)UgS%-?^)[Y?xo?݇B=SYG|sMR7SFkߋ|YtϬ.>_*9Kyuz%_6']o#/̟j^^=\xߊO)z#zyY1߼?Ht#̝CAI+x\L1[hjj8F5 pyfˬ0gN;I1?;=ovxF52K 9o9O1ʐzap$NW_KBΟ_R =<'zz:_&gx4z6(f[?qCcr#QO٨uOU K>Iu;LKAY3 {|woH| j~*O-圥s M1oYu??,`^66`< 7O&^Ka1OAIϩi~84f͟1E8s?5h;=y:M_vy;9#Oˁ9k߰cS!w:?< ,c+<-f?fa_6jVf|0vPp?^ 'iZhZSs=RTw e ֤:¦N,_?ژ?TyGOdFahF/ >N7gfyW6^w]pJkv?T3ܗ1n\3/̞~_a?^^Y5MAk0؎_|%r{%a(@JwN +C~ĿiFy=JNCSgo?kWk%g3G޶pUb'&uX{]o6zu1I^XrȊ5v@ )G?c_; '\gXrώ)H^_FV}[TeMO/;3/jWQ0?U3 1Ś 3k?z_3gVǜ;s"Ԙ? "ZGcΥ%f\3E"=]Q%~>_0CH8k{pnWs&xHݢlC30w1OTQ(?ѿGxfWMPns9;uWW/ Y`S[-еĉw' RO{ɂl]/ܷOءלp p^RvH;SfL[6*VnO< E4_؏"qw#1Tۍ 6 .݅!ȿ ? oJm9vʟ>kr?9;R9KI^55RiI J*wmqC?q^҅ϒ{% ҕ <_w1c=__ߟ/!zf"+Dk9PQsKCb/ST?Ι>u?cV c#&$Гie@ l%Z/ng2`q9QM=sn:O%.^fη_?fاcӽ'1ϿԑyJhJEʄkE^Je<[789ПUBXL8p sdl%lY!n+SOnY+ ˓CS:AI\eh:G3Ÿ21R䏞]aYWoM=o$?af8&.HY?fQ=`rd{Iyt?7}o/Q{'b[ʷT+AS6[?}>IY|/7OM?KVٺ;L;g̿pD5p;{ܘTUKCU)CT1Į==?^u9T}=|i9* v|ofs1_is/-<pVi#_}},}PϰC`/_Pey/𯿺֨i # %4t)o?,[W?rPww ;@ v]aqV?Hw?6Z3Ȋ0+VsIyO?cvN?vְ˞SY`boΟ ,W#OstS9?r՟gu,uj+nRgf ,$zѣC`ׂ?Uؼ#HЯ9`7@{!仜?Zao &w!S7"&ItOo/HvT`os?4N(rϰG:,kЖ3x^yTUW)H #N"HBQ,qц(rl_(b!q hbq@"Eh&4 gXsr}a[KpyS'[=)y{eJ?k}e %6S6eOfXws :8E29vQݭc9C7VKrN7G ~X\1]x>ر{᱁t\< ίl;`Oh鿶浏ƕVۤMJCu-OWZfofڂGj\qW9n;5:%z_w5ec\h2):9S4kqM4*:mCzJ9O5ė<:6;2}CoDwkRep/;%`#MkZtiOqͽFſm!d%ߵ6_ٚgg0?c;Lg`UMf'YĿZ?K?+8x^O`eJ\qPR/ƔY)ȥ0":wMe[szS%PCetqpx_I f?yO/Vfڊ{7{NCK`*"_:=?R6]I,2Kj,8Us;l$f\u#!xl!Ͽuw.]}kBF"c\4 eFu 3 ?ecW+꿬?+/hlچ&xO#ݽ*&?#GsjUIedrϘ>};oRyt#6̢o~zqANҊ$>r?>m9$!О2o[34hk>)or8v37 (Yཆ{hy/ Yϟ˼_Ʃ/K{aWG \%{LfkhՕK MO!ǕO`jፁm&B2e3=99&#y?UFZ^u 5qAC?x??26)6|[R_W<^?%CMU;8Z3ʶ+_ZDZ?p2C$ȼr{c"o_Ξ].#OJk#V.ۄ2ދ@+Zϱ{2_yկE}KjM=15c] d] E1B?_8f_?id:$nZA/d~:C-EǶ3?~LOz!]d+zAOzl<l |?Iz1M߇ᆲֿ.S+4DP?3:JHwNyj5tX*?~鿩Q-zxl{dڠ^uSg@Oɟx^+'qj<3Pw yvے {֖@J?ocz&i r?,V6 ?O_?צ !(n;sC9 o*ȵ MkOI?/?gm/\c/&D,? eq&KpuE:) B_Y#4#u?ꃐ?H}xCr.M'CXv3~)c_V6әC73+ϡS`uh K#k7O{~ʐG?K?f27GҿSO$P$?Gr]6O`/gRRîJz|'Ԧ;+RŒlM\&sU1,NyZ2ꁤF[II{Y8L]['w> s_YQo? z1?&ͦ~OѸ";ogjۡQ_y/Q\hh7ry?37 K@d˜?z oz?8/0!G{TpV_x 8j84 pua烅^9b^d?c3IX7l"' @9&1ð}vyQgmѸ. #7gnk/FS}sI =7&;?1ɕQj/d{;ZCH3;;5dQ-З.#!qg4G@WLG|nA7eE|sn*)i2Y!珳`s oYw"7/sO 8;GN\CI!qP7,a!f̻($%}OiuyG׊gK=υu XM?Œ`|nCmQ׀EbnG7#@.ox,==Q\Uex?%W|FHJOʟeIjxT,[KBϾRgl>V?ȧYiL2S?8CP-zq?h12.eȿbɫo?X#IJW_8OT??A.]5?Q?md.<وGI1qf4G=LUfvRXHێCoI3CN󇚁Ɩ6k ˔lZ;?)S5:;EyOѿz|X۰#QEa:??mN=-fP?w;_ ?i  [$dc>_S?{mŰ'C%_Fhllk?dk!9/rac3e#C|7ǖ^"[H//:e+ݔ9;_B?n=GN^a?D3P.߳~?K#gx>6Y"Dv@ɞC>&y û?Nъa\Yd?@=ڮ@9= \B7V/Rer5l i_ϟqv>?Mpv9ydöAO{G#!3> jLK6ߙ;@?u䟔it2plȲHlgeң,qE_| A G}YB')o +}f%1Q4lXBq64E_#g!74Fl5\ c)( U6,ݞe8W ?{7 t+bܗP5]V?/2"߈TO4:ؗw2 xgu#gbqNYʜjsiGƯ_-S!Ku1LIpw y&Z?G}!w~IE? cToȟzw'bofTq=5M+Քz"d<./&( _hb]Q_ș򹪏^L۲W;hP3pO#zIk9O'7Q_`w`L';jyvJZ=?ce;o9`$_a8j!\7^(_e̟H'#, gGjFh ~kT-L'c^11{M?c??HXp]zlgoO8|E_GQZ|Iͱ&(.h3G~x.6fKmL3]AZ!;ԬՑ!q?^ ~δ;9ń-3,_!{5cOj ;",ӆd/>1#L=c/JDRǵvPĶE'i|q2'2j޴kOL?s<~(GGbxPk=[sG%%\8Z7gm9_/vyǕCy Ko*e?hJp Yϟ?ѪY_hBxM8|/Ej ya:V>a[Ӄ 6ce_@/S-&oyj3#W? MhAcBs=W>_O~iQxTc?oGϟ\/: )Kk%G3埀s6 lo43cNa y4Dqp-?88!! 5/w^i?:WG 3?ay6vZc5^ ~nkJj==zz;{۔b7}C2kQ]kGQɺts&_?*m8R˼E$LHPkf3C@^%}ז N}Mb '?߱ѿ?0wJOh-S٤n gȿ_ڟ{q=ޱbIv/?O?scKe{綳 z._|1ҿ=wo4 ʟF>k[o+1Hw/03M,2 u?FX;%u 翆-4 ?q T=pqpOwp Ө6l/Q ;8BKlخ~.4L[ҤЅ`W<\]o@ yRpNIDJcl2hl y˼]Է?xfS ;XF_ Uwϱ{.,2}ܾ8f7f7 ?8fhIum8E?#W?|^P wܙ-fų@2GawK(y.Tyس?IرLj?y4I [O9wCɑ?1^31|2?RŸSxx^ XT !$q]QC(sk\P㮥R5qgɌA!F؆MF\؆n#GG?9}Kߌ`<8SĴw'g&k4/1_Ԓ}8hg?0=O\f7v?ugd_l"[:E״Uv%~j?XϹua}76QiF]m{9:jt/uVmx~a+2w = lޫQv}#!cϖ}l8W:Ȱ6m_)A/9.2"wRq>\v3#0_s--6BC}ȯ|8e:kn':;F3>ğ>1,^c0p]?nō!NW?n_3řBʽoo&L䊞mWmM8F%?UF /oS@'f?ghK/oIgx]ҿ}_[sw?fNxiƷȻY/9χ?7QX?3?rW]nDKflUsxM'w7kY? N{u?Grq ޔw_ !ن;KG=V_r]˗g\=I%PoױD[$3wO:IT]DXe;w7Ge*S@sOxӺ IQɟj m\ ׶L+OuA=d.~K{TÞԡ0 Y!WOaYb`uQs wW}3q׶Y/:%S,־-dF9;^w/YSy9W5Vi#fz3S3rm(#Gec:н"&yKG$q]X؆'gf3d-%VwI JTkYvQy_?>]X޵.36gW){3gP}C |Y9gqd5:E|#)rɟ|Xp-Nb.3!KƚC7.?.F'MgDvǾ7߰i.S<\10soMu{Fsߞ>F7럴(F5mXf˦C}鵙7cs2':97 )o`>@LPݭ3)zKhZڮᔫ[z=vx39}uzy^T?~kȿX>G5b!r,?u{%o]^./erܿ>sBT?j+oFDvh==_kg9/L{鏚VT{k}#葡?'m43T0=)TWcUZh̟4TWE+U1V2~\6ebs)?<%I3䟹=gn^U_a{Z,7S-;Ӕ5lO5UZ_ִ o_s[Sf>s5&Z/l*m`sqKzfEP #w=;L5p ='c>lOqã#^ۥ jhoo훠?ϵUdƿ~~Ns^"f!?| ?lfSJn&MwԂa??/dn?;gv^Lͱ Ϝ%'=IN ?z-mFB͟c53@?q$Ԃ<@,GCRON.~;n_7xj{kJ~>\Y y./f$f#H//LXz ??[\Ig<1??;JWmο 9?k{3+1Y\7bU~]fXEPxmм?95>GNb+|S'Y_>G?χwҿzS?ƕ ȏ )C[*ۍ}>4iĎ)#ZObܗ.7!p}KI8/c޸SX?"O'`Q]5O-#*)w;I?v m[d%.fAcU!?}~"uW.};p7mO=Y:SQ7bH[韚&8U .]Ȃ_?q?p yO4I | X/aǞj,u.Vm _0}vΓءa.5T 4/أ-^z=gƑj!?qAq󟃳 aؿc:;d!>F %q.JSr1[0ru qk՗o wY+[ΕܴUWO=ǼwZ=>p/^a8:1hK n{䟠^|ooE 3'zۓW4uLU'ߵa݉ω0#.WOQ<cRmw?6*;w'M HUN%mt&:9o{S!y*:Q'à6ro3_U7·+kKcU tt?s\οHGezSFT?P`cKu9o?2$c1F(NMffjФQ[S7C*dF"_-~UM?{Ps`L Jʖʙ&W?>ó3Ea)!=U0φǛ4G_믙?f?V2#U[?%=ݹʯܩ-r2HЙrEOW?g=1Dtt`";81_gԓy%Iyd̂O>+[pO : /0-@7[S-w߿|т.?ϲmإ$p.*(wCc8Y? \myM?;e3s{; -тH ,K0z([yCCm?*le`߬M\i}k<9;gB̜ۍoC|w` Y0CM?Y>ss.kR`VRxǹ'?cq.ʽt+Vg ܃oB8!k (ICzZGV_u/3.O{?Sm5@ 14':ej~甛 ?S7ʮ \;w?ۊoh\w jlJc6ko*;V}*?0̢qv:_,Ig5}?h}A_5Dz^9N>C_v2q~g}ǜJTg8SncJ`N :"uދY0wg/1tԿ_Ƴ^/! C;V=k?=3C_4agR;f?y3$Ճ?ש'+g|7쿙(j9}bSyNϤO}^! qya!>F<{&5{EG_<8S=¼!낋cO\_–{x^y\ǕXH1}C 5Pg}߯%ZJ]1Z$PBJRB{Ը %륄d^g會Yf{ ;oگ|_}YoGj=)SyVh㊲ô9bZ,ͳ{J*.i=)}M#9޴aId3j?h $h&5ojhZIВ.՚4ٯU;X$|N.u(3m~fmqDU=+w^}?#fM.nN_8/N,޶̛awOHUi)Mh!)]ڈj؅@?xKW+$ӿKk{Tnɞz#gJ>N+`ҏ;*v2q'Z=Eωg8T {^]v]_AE Vg #:U~@ϊZz}{-R'vFC]/dIBoLMtbehE>wjF)l)6.kĠ3?% o3!)X] ~?AyϊT5j/e1iXa0ROym4?ޫ?aAwlv]D}B_pOܿG3_?eJOm^E#n[t4v<߻Vwg;C~b?Y2k' ]7[OJD{}$cT'-&7›A1ϧ%QtLR2S&^n'8k߳w)iFt=\3eCy儰4WQ"uK\U?Z#'\l8Qlݪ֕/Ww /.N*StО?#{婅? [>Đ摪3/;ޔeǻ}S/Tjߺ2۔?Ǥ%s I=}T?·?_ݝ׉Ӆkd tN0kZ:C E׼nYx3K@tM9f檸 (zPW~ xG•kיr͕Gms]p^pmT\b꿆Jlɭޒ5S+t;oB2R-'_տοIUJ[+ Z0I|da?$epߜpZ6O8TMnV|,/60{ɧcOZo\!)'Gp{bsYձ&ⷨG;>SLmi?rZkϩss&WEΞ11)QuvO۟rt (Z-N)WC,rDI?']P"$VKz45YČ?6,P=o=rP6I3Jejkf3͟u\ Wc53`Vg *OFuOTɟU]$Igk?w3+OբAo?a#JGuҜem_tZcO]x?q>QLG  9X3ceb~* C= 'k[VtJ</fMĜ&tǨ'-3((S?e?sO}_D͒`HP[' GrT8WO}g}Rk+} ̟>q%x++[\ /:ڲf<'XB{凜 C浢~Z @-p?.V<R<ٲ\#9Bji..?St?kJTsJ?gcG8k;)/pi7av?WO QsYV*i?jyUPPJ2ߌTW{Kɸ\B?$;J&5:h__t6ʔ7T?Wʔ#'ckذ_y -8&s&tsS1+tƟw߯*/#궠W{et|+bԌltqoTO7eaA#+ }8MWtz,۠rn5ZI\ƨw29$Y@rx?>nO@9+7?i՛'Iu$@OPP\Z??yVB&?bne4O#\KGhUtML1*Vu?8v]pĕg\ 0b[Z3fME"mu+^O:rkfZo쫽8gX^{7's5!޲mhcǺ?nPb5ߊTV0zݔ9_k\G|LZz/wo_W_z_=-!5-U_)oe8Aj?W3qEUS&3zGr>r .Ӟe }]kst:?֭__^'~Z m;.{'oT;'cBx9>f{x*kkf4 cYXC~=I?Q[i SySذ^+'owVHX-Oyը!CtPp?oR|Y?-'4Jri9YpwcyjO@M4ȴ$?N!0q?[%b.x\bP6{e7aBvѹ&H}&&<3› 9~3?jdL'ל+KFoI7xO)yt3oe=ܽRyWwUmxJlX9Տ&OZDA@s+K;Sʹ9bO\MCZE)'Ol~)ŻSMe1St?)oJi֤Z??<F)Pp^aGnذ֪_g踰AOOL1w*#ذkF?fSֿ]~ú3-x z`mV)W?3r3[#a^17YC jz]?CKQY!H!Pt%J"XsZ}Oc N]),qY#e?R?_Y_WLZkd8\YQbOfk̵wk- +O}*$!\f,Ǖ0OսQm8V>z=O4kOt2_>v`OԿ u;!5o^G~1izs﫜?o9ΟǼ߲M>^<623.o^f٦>aYEtO4|K7DTѹsT2ꍤ?K~VgEşKyS1{Vl 7ϛO4y3x~Mb=W;uڞοx{;ѻxX݌3+5Kso?!gW'&g/ԃg-2b,ɿ}J?&m5OW1O 9o]9rz)uW7::f0?봬п{F㸱AOLO!O1Ba?JYgRz;5=1>k jCg4}"TL3;M?ObQ }0ػ7޴ l }=|vAg?'?bz2?ƗeOMܳф=: ,L)󢞣ic7WlwS:I( mr?>@Ϻ?vrgm`ga?Ó9G̠uceS9x ߠ<Ο邮h۬i^B?iz63.&j2E8_OW<[{i?x֢^kfU wx(8h]ʴH6?ZO-F&;65Ϯ#-8+S>מ_``d6P̿As7:f6x&=^ۛTFH&il:pKuoLz7IgՔ?iɜ{M&1qEœY5Y5 #ux{}1}-2./Xs*6zIT~@h=?G8?rF}?%b?W{R? 1mx^y\TWǑ$ i ݋DP1c!4CҌq Tj&HhZQ]ܚf cW+&8JӴMFPp9_Cz;%}HH?Ȓ\jie'!x7>0_[~q>aer˶unΖc, {;Y{Ȧ, |dz0:&5_X{W-/wR!=߲$Xm1=˻KIӣįx컯%AjrA>}999R5K mk#m4>%%&/f9vv!0k~sDK#̦[$(o? jrA̴/ɮA|\N M=ѯ[MRr?3ZR܀ik/x˩_I>-=%QᖤPbVA#méîƍFvQDf,RUK-eygn%~ /vw")N#,zX<c<*gg5Mx^7>#=})mf俿mv/ğ9'ocsm}e #;Uq9#fΚX?"Ru5R_$"bMωW#ʶH:h3?3F>sIp"4lKO{8po䐿w;#g?"G?qS0'?e ǿ 0*7 (Ox$Y 9pAG̠% _EK \bB{P_/dzqwhڼj_␿dd q%?ŏ'!x_#D?!í?ߌ%=?cg6K7Jo@EU/꿱A3*?v6" [ ]QߑM+2 G7_ў_Q馉j˩S7O*d5j9)wf3{M߭N "yQG&lRߕm >yށ򍤵n?܃ 78s߭8?( Wuu ` xw߉,cgUԜߚk^'PLK,WG:*R?X埝_BlO}syF1-EY\ӘxY]Ox?&_YA?^G?=:- \e':_g7 kGo&o)CS%~L)pZTt4e CD33[J'p0qIzwlyS?~5VE;5mVYhxf&G?ҿaPWK>u)ΟS% /@=gd?|> ϋmnot {]RU5OkO|1K7L'~%5SPzK{_ReF't3G#qȿ3V.%87LMR=CPrrPO\WH2E'|rr.˟y]9e?| uLMs+3Tiefٶu8?Z7"#9^2g!f:P?txml>_k&"` R'}g J7-G#a>ݠ%f%H1DV|%f^Um}_!Cc;8='XG/@2CG!n^b_ ,K3'Ͻ29 EߖjS]J?ʆrU{o2?꿤wנT*:_J=k8sY{d7T38ug PSXhPxE?[9 6l?f䟒?zO'?vLRJ "؃Ͻ]L洴yM ͫQ[X+?dmPLg)sdGN/QG?rROAos8+U?3@m4;oRp٬@rހ1o`aUnUۡVKp&Io!x??5?cN +?5h0L?@67u/_X,$;#?x%j7wUOlҷlop<ʟ8 ?zXO[I|/<[8ν=/~!gp3UG+~RsstAoPX_T_Moc ˟P?3˗߮2똩G>C ?IJ1N+ЗWodz"?_vO? ˟ņ z1㝐gTYnw%&Tt.?@/)?ֺDP5xB3-x5e"c͐5Z!/IZ-_TRM}(f s(;q?aI_Xe ǪgӐ !_EUN+=^&?|WNdz+r}(?=zdXhAL#F2h> fui#Jv3wBsAZ[;`ڷ(ibg.w:qٰ׆6Eqw 3|IKo?{ɛE_KpNߤ(Z?RWT?0cpϪM˘3ws#}~Ν5D3goz D-,+UbgECFP)|~!#'>גVH?%Q_iOӫ،KOpn>ozT_,Tu0AVJ -Z%9-pNg_s_[ P7NxO 6q'WYݿ+!BdodWo_,нD gw-L^0@?WUXSeA,kW#Ȧb@(?.R5wUcB7\c{Jo ;QܡR)9GA q\O{2@3#)B/֒7F3 pw]R_O5?S}>?I K??<@zCQn^>^'TtpƯ?Яheۨ/('uley]2#` I85{\oqEпw?=DR眒IjDCe;`[ƋIP-z3?Idz+=) ņmά?1XpmCwb0JGx'ٝ?^ccC_~z,`D;z쉞_P j {dϪj_.霖bGx,DWzlcB4Ye_?\fUUR_`o-E='OH(?M6GROhWYB[/('j3OE?J+r{CC36?) aUK´ވ2j6־#= iwi=Wـ :^Ynx^y\Uu A%#Br~rImbq\3qȈ"!2b{ { Ð?}w~s<ދ/.s<~oʂyEՏkYbڕA?5Em/g⟵P*BҚ#׍^_&55ڰBEjWY/Oiy˼4]U3ʎ[UuZܙEm&.WfЖ.)Wޛׯ^?S;zH}Y^Go=i~aZؾ>7T\wӾ'FvGJjQu~g 5l^h6o=eA%*"V*պ{ED!1p߫G ue* ~S_{ش0=8uK \?1$LW K7hfC2#%~rx#¬‘^aYP0*Μ|;[zhf-}4R{+.6 U}ס*5 ōl{a 5[;^N8)* ۼ"G(cʜ7_+/*F!tg3>b~k~ER7rȿQKr\&;lۮ.y}(SvY6Onk[+'1-O(aK\ cB;{Ik}u_{_0A[q BvˑvNL^ko7e~-]6&H܎(V^]#:yKxXPE98IJWpC/zr)K[*'ɜUታu56C3qp¶?GoN/8Nz1NwתfY*HR7PEzB0ɣn S=?"go}}(iT^B[er?IUV2I]Y_G:[toX6V8x%Ei3qgFA_u'ݯ*Pfɿ˜Sg eA>{Rl^evWT֘Hk>]C*_?QI+_?xF み7';RN'R=K/}co .\`dgcoWœ?y}{ GJ>13NUE^.L}RqUJOu?=׻J'!>ȢK\}CCۮڥk3}z>U?MDUvUL2NR翦G4@5cK7vXt{X:s?fݑ?2Fq߯..YL$sxu*:O;~U/J]YYXvc;cM]77lOLLuP>-M!cI3$3L:_-7kI#ROug˟Y?կx pʟFNNMeFl([7~1?7U{58S?C:얽vHt\73c{2~K}?xMURo!n3^Ѝ#J~=io=YBR XeG?og oVHڐb_NZϵ{9~x#\afN> q%3$?4>c0?{'UMwzLX7R~Z^ʉڻMX4dpuh=Ek1]((?^1_xUE+8˜cN{FOIPֵ_?#RG {PmO>doӯX~ye?{A^gKY GufX1Gmڳe.#KI2yS؃؏ ?Q~6R_7̘c\Ƴ)!yOlS?w?_?ye̿GI9RRBzyC򮧤O苖Y}c?RLuN%QR1\42A1=E}p4VX7GkudOlԇ9[Cc-;_I,󷼾1FfXd1ϿFT2̳uq#* }$Dvg}K=O#C}c55?]7ɾM_lޔĦ;_91v{t2U 7ߩߍ^)3:㳒{O83u5Lop"5/w?6Og=¶{/Œ',]?z_Vnrp>"dCΣÍg{_3G?D_5t淚8`pHIo {=f-G]y* ?Ggb?9F5b#ߦ[ۘ?:/'ۥVP,x;c8K_pNPM3gmu?M]y Oޅz1%YU98%z&VaoUmNѰo mDsv1ǑWϨ'mЖ@٢fp&Xb1693Gyy2?"a;:lmK18o72tr{ow) uOs_u:^Wd{z貵-챌j쿀$Ԁȟg.<;^/pt?g欸?3G9J`/'LymtPjk9&K'[.ዅ X_`v?s9weM;(kq-m[q hnpޡcZ"6^?5w]0ZG?%{{T v_q=GϷan:;xǃSGdѿQ݉xfjݺ]UrU{&W\|C⬫)[qewIrkUYWY1 ͯtܑy%nLzn?=ړ.e+2)pןjN>ݴWD%*n9sL7G?17'ҾĿj#E ?>cz{jl?=럴?9(ݺ=cɟ:K-m3^8zsx?lcFͷNO{Z]:,Vޔ?oX=>ks<@64^'Yč ]79ʝ9q=X<g|>?Q?>s5M>_wg|R&?P'OU~coSr)2[_4_~|ʕ`V?I[e~WCtE _ tkeCͭi0 ̵vos?z}=z'xBˏ?ћٖ+sх?a#sCr97ǹc%ɹcrY+!i/IosD#K\Cc~^Ċ`/٤ 8z\zbTu^-3*TT0kP߿_#?e7߽94xgSφ7'#?#?Fes =[_ͬtS޶C/9-$ PrFk?z= Wk5#cwOY yמ1޿-ÅY-x@N|Vq svTcre`?b*?L=D}p\4]N?dT#\qx23jc σ_QBxUs|#f-;#uЯ*sY_A3HxΘ?zgv- l>+ cs)Qضrgl5ٞK=O#eޗu&'}O Sp%d|vDL3/G icn=e~+I?Ρc;#R3sy+Gc9 ޭ(/b*?ね]{w|gJ6U3Np̟p{nhQhU0V%&17OKSgI.MB?kx^{\UeǍa"by~! xe 91D׼kYDH "1Hwrx"%Tn/C~xa4M]k>ywsVFVSǏWX7UZN%lCʨK*ŧlա~֗} N@˜g,i#,Ziۗ,x-J %r`ߗ:;U ӟ*O.S.οtRTsMI>T_?fI_\8([1]5]]ZzFȽ }UnuŹdRy}WlsK]2Y!K|p ;zsVgN_<k(ܿU(N?%*]Erۮ9ɑݐ_[v2G5R-Fr~aQ4r[&C1I]NV/wpL?}wIF|ʘ=S-7$խΎGHrEOM_L˝ GKz.ͅAߥeiY U$͉[d5]ElQ]fxXHu?+;߸EiO <+#+F{Qg$οl{+ _/3wG /Ώ$g]Y[{$οfx?kn__G U䟾?ZaWw"/W=^}L1s5kd?tXi:! s_(TV&<&LUPԵ1꿱)= ^₄kǏ%X 0բ "Cor X֥Y'7[P U_94kHOȚmAd=?){^o=K_c[Wjzߏ/a'Wb(+quxYr~^ Oo#W:lgj|gO٨.P\`G穑|WgOếʶ*mL?Z C[x3\` {PFQp3ТI?Oh?UQ🶹vzۉL?$9Xm??jȥd'?o|gz-'?_;Q'~7se5)"q;Fg8_a)zK.b&˜?|mYOmt.bC' s7FG?9Yk/o9n?.OY+?)g=*WXC \%OZ Zx t3#sb)GqgaȿGO=17ɇސ9JǾc?(܇hހcN[X=KxD]HR=,gkVrO!jzLq_}*OkϮ'1Ns{꧜K'}M))'Y2_Kз#9?< ?K%f#ůI-'o? -P ,=0뇔?]Bk .ͯ KITd^y?S] zl'nIqA! {0ի d&?z?O 9EsNl ?g?Dž` qCCVc#V_M'aZ1 |.Xȿu1X#!~"%fN7N.?go.|-xoY_Ɔ &Qד S]CvpRɐ*?)g}1G[5}KCd uZ*1{S?ީ&YYh?#d%pVOj#)K?42);Vփ4T݇|$ H3__i~^x#0 C5#f[m~P_S?=r\,߾!KĿufCEɅd4e|&_9t[A?OZNd" sri+wV w @|D2t3Kw[9|Cp}ZN?swyneޟ]>Ѱq׿5A=/NTJ,?cvs2S?< 1MMnvQME]nAO/iwj[حpV/?{T~I??ڙ ?zǽZX9ʡʜ=b?U$ 0k3#&! 5M.VWS_L?}J_#ot1V,l CgLȐ^ec!{@G/_3|5qxmߏoW=r8j.R>`?{H?;{OCn":t.'ɺ} Xg4 Hoɐ}%'4i?Un.&G/1lwY?*C_Ͽc_ћi{skRM?{Bb(cT7꿂2 Og _Uv_]md Hg+"׮q.f?]S y0 __? _ES;S}ʞ$+?ejPO cL 9ѿ ;x p S~b "5&_TGgO_P9f՗74pgouGȸ f?BAmtpG_HʟFq; 5"PRSR9?zә˝/pc_ ;>Dkl:$?פ@ s-Kse8u?=C>gR˶Ľ 3>ο}^꿀U_/ϻ ɧ^n#zg߭Gg ?-toWa+3cH,<ڤt͖uqݩz(?/A?<:?)^Amaĝ:Kw?{/\ O\=g bx>or8L.^n4( :Qjcg-2!k񙷬g~bHӖ8-d,]?8^^o~J4,ğM|ZAΟ<OϞ([WW>@eQsw)ü 8cvoXE.dUGOݯ9Yοe䞄~:j=R>CViCL$q8Z=03!?d'X=%ٔHN֥j xXX?.p&`?$nt?YkWO\YdI9ׅ,Lz̙U;,ZZe<;ҝM'{9W)㹮]C~+O/ fVQH MjȞi 1h[/'xU]M,pu(Wh%|?~a)ڙݲS(oTV 7o3Ww<[K🮱Y}4^/u!#}?~+>e/3qHkߓpF9C?O_-<͚w?QkP>uƆYv?*s2_[{ϞP/m"b7Czu6:EKԁ~~γ4OegN ?I?₄#Zw˟ *7ysY٫̺4U3C_Z%הO_xb:?7 N+G9J}?xImXb?oms̟w\y.E{Xv\II~Ro#?!2sT1$O\5LS͆Y*}V{"`ߊ36?.%ީa7sQ_ ?@Vº6a=@'$x=)31?~v8,Ɔy_Ð? Ɉ?p¨'tn?5(Ԯ9ɸ?/gz^x=1H)wFOgZV,-&C0Iߒr)k? cb{8ݝTw\)X0q鑹8@ީ[?Ίs3 JKOC#Ax,*#w]\x6B4q(;C5?xC]qB?W˶ٞc&E=ЯMx^yXUƑ H!DyJ(!R>ggMD1ZB-JC$J(AS%Rj%RCuY}fo90Ia}޵>}cNjy.hI5TZ;1+!Sֿ&;g#8 otta;&vl?xL|1S_7ԱiHggY5}ڱjoh[_f5ٚ_k;tkj5/׋ڪrm~UptEKȌ`'\v#iQM;>עüԊ{c{:RZkNӵ{e)o@a>&:.cCю{Ai8|ʩ3QޗԮtc[9>jGmh,ݡoiF[9>h?qNs/ǡWC3l:U笶G!.#6kc*9~{oQz|2KoY&V߳{뜿Otp6u_^ V`':?w ݢX-c$4T+mG9Whz7 [W_xw_e!V'C Xsac3Q/>+Mp?>7L׀E?&c 8OM oS>U7Ӓ7?e?O~ ǟc#C[z2vOH]6s(7Z4Z?JnASjdbFmzeaULZ! E/?%׿uv/VegJhiֽbWgى,3JvP5v9'j?O&: [BX]D}O+9 \=6SZ?&JC˵4̿jg mm8Ae}<Ԇ_z#MU|Fh`aՀ7lJ6 I8@P:yC&YO\6_;iXL^%}b/Zya?>*vU?> kll@G@  Z@' w?Sx#Bmm?!q~N{3/@4d+iW:auYLJ;-,E~)_~A56i_r+})T\{T\Ǔ;챆H]"Aa~mC5?o|]C*6&3?}TML R/ONϖ+ëFsiKzO̪-+^SPSrFy} N+{~u5k^U=僎1?u ٿD$(⚈]6]\N1?o>׶|A..ROW̃9蟮՜zA7AꯙOt_uV!6ᶠOi=p sVT=^Bҿқ]RZ3#D^z^WtaoVKV9)xؖ 7) WC_qGFq|߯_"o,S?!=EM_⪁?1cf!W\R("OqĸX'dJ* >??os؜+jJO l=%_Ӽ(kg࿈ 'K'B /[L"S&=꫚谟#8Ng6=:osЮJO?S]\ ZO=^+Z ʼ|@}MϏ? |fc ?fAg#yJ NOMk:ϼaRn?&c?NϞKn9=T[Y"?7௭qMoTI:ǵbSjwǞNو?k'z'30-L&d~&'n?x W>fVxHa \*l?b?_J;'wW_BomWS+{|l=⸂H CuezP5矤=R$m@%lJjm9fjş8CFCy9zog?ROTںM-Ѓ?/e{rUw9Y+vkT\[U??Ny<+ $p|$ :j e-I[{O|~f5M_Ox4U48ʟ_?򈗥gg#C)+9M gOYf /W\ 7G624ՐꯕSOGC7o7/sE_۩ټ'##^ӽ)qMC~A%z4Gla`~HsBuf0ł?> TJY nj# 0.8?hq2cfmzSٜ?8 ^<}"V5]Yx_;PK_8Km<3 눾믞hj&w#sOptLV]\ TɯB)g%$7lgvd¢6ּ$vu$+ۼ #gQ>˹\5B?v7H2]2]mli?@1q8CDYz2toa#DIeX Ot6w*b ۡ/hin?z{.._S*M*_"~0tOt?p?{iziS~œNox ?UuxϯG|V{?$_,<\_7GZ:?C&gkKmzz}e,ֆp HZ;Y 'W9߀9>+8\(;Z c9_Č?nRs ~ydw+D CYSq!rѹh99nS? {ҿ0x"2ζO^GyTyrjsġCOް+?{b2,h/r^/'d fkRmsf0LxW᫤s0V= =}|8͟ߛ_ s*O??W>VOy)..{,d|sB_~bj?=[_${cod2NOërv.\g#\>p7AZSC7eWj(8&i"v'1\Ya?eϿ0\z^ԩ7lcϼa[cPj迶L+xUxc-?yyJ]xB1=57?\-Z5{O+f읣Q ?[w4|d͓!ùTxO{G?,<ډ? O+pp߭yAsqM_.\￐Z;;2 uuV&E'ߛuP'2;@?E?_@oњpރ?Ґ4Oasy;AVc/xJ;qVM"[?+d\>"xUn^լ~2gukbk~.P]C>ᬘtYbszgT_E?I)F5\#}к:?oZT̆c6[>蘜p},i x_ԇC4wG5}%gፇ?|qco>?tg>VWlM->~+έdq>Ѭ@6*Ȅ<{G僞g2?cmN1SjDwwiM`>m??ajt̟t6T`Oںia+=rZ{oOu_?ڭ{f_),|Z?z#T pyo ǜ?q\8dn/5!_}c2QqNޟ83q~s8ۥzyGfö`L"gNbH0^Cu =mC^tl`l -kV'_ϛ.Nߙ%G7Wy?r.OK|ϚF3s\w^ó_C [el6aN7繟̟s [I_-Ր P?S_b9WvqI,% Ƿ~?W7i\%2!QϟKS«ԟEgw c^ٝE,P}ae1+Oz y͑%W%NOS2M?|׿wLFN^Pd% giw َ:1w]})ZF5og|O7n0TMމ矒e4?5!r*;? c~ޘX_L&'GsO蟲2_;v+N0}O܅y>^Ht"rHxIƙ(mj"mN_l#`K(=TV\ٌ65R˾V[}ڲZ.NʹAQ-vYƙZP۠Gl6j}lӴĆ57w_ي7X:dsnf[Sne NUmyUl^EiZi0^ (kkcF7~/5ԕiU6\7veqWG=ߵ|Pu-Oѿ=ol뾵GO۰ֺtMZN}tkwzۺMe;I5w6c6?5,z^g׶{ A\?1~Q[""ĐJ JAT#V^a{?8ze#}{c'},?srClF[ [C{KQ8rm(,UCK~+|^׶d:nwZt)E53ZO%3'HH!SC9;ϐ"X=Wz龄a_G=dU։3[8?\q~[ }6_Ϝլ9Jۜ?% :?9 ֿq&)TP3-ψwAg!3Xq}שn%fIMVvVH+v￱r%"]H頋z{L;K9@^ /˚;i'V[_3%'~_{Ο{oU E-KUT#^a8Eq'ΕcIg R2h#*{?c9'6bɾVS߻*o|$!gcAsmX7?5߲HH/Kϟci_;{ΟUdzi=P\1q_nUtw쿮,RR:7OJ͞WQ'5= ZC+sr^t?Ĕ'襝Q{C?C >gS{3z<9[_{}WZ?~'Ttk۶rR7u3?CN{hzxRIt[ʥOY_T~_91=#s'ߨP ]/ՑG9So=?|JnmȜr?سz/I6v"?u,Y;F$tT-ލ۵V] c&- UQ7S>qG9N?ȫwy?<8}z엑j gڄ)P?,UYA1;^/qtZCwZҙz P1 aQf 7!3=+:]:NzyQR{)rOJt9?։CK,\[Ɯ?3q.Οi_{Ͱ/2MjFFq&T16O??e/[eHj?sJ;:,<ѡW|CT~Rp}k< ȨsP 9k~d2wȵSpV($owvkٌ/j`sg^㾍o+&}F`3W('}gҕH]k|k!cfz?B>g}xn>e jG{sERtb.]xktdF'σJ¬$ kg+-9;JH]Ϯ6B֣3ڼS?PzC T~>מ:#H9>777 U~?`Of8wM]C5!K/h*"K] w0rg(&xWYƙv:dǜ\x=Ԏ*'uc'9٨Gi?fo/ThFo WW9ӟwY-VVL7gg;ȫTɿ*4ϪGjϟnO`>xm[Oo9!8OL #?o赊?Swg0^xW3O<ӗz9?;?3Cx6S9 W*}t o'ugIǔ} j; qX^PMm_FT[=?;Gn1M%ow9q>e1srU%n;KXC_P*umqgS:_Wbfq*|Q>̊'Ero:gr#T'+KD?l?%KLΓx[ S&Ոu~m_?.}1>Yɩxm[^C?+3_ {[1WQK&OllqAOx/㹁mx׭.*F~M.^}kWaS_?c>K//K+'u+Gpſy4?Kc$٦K=r_x`9'{q D&2q bO%UhʟV{rL0ϱ̿T~O\wlߝ'o?Y{"iW[?CxnO|L_'cO+Ǚ!WOn}\cy~%[^HO2g#=i qv7/5}uFQ>?wJ I]:?ך |_yttz0g^g׃gN n:)cbxn?1Y;^>8Og-p2)S׿gꯙ0/vg 9<a:C}o?K* gwM_=Β&T߯SHPK~{=8?$p qV78U1|? %91[_?m[O7?k~բy '&?!TWo_0Om$ըOu7?~43Og+ب껸{U4^""/0G/C`{N+O'SķPc?8*R=mf6fi'ƯXm1{@1W#asO8?Rie&]?._H+u|ULΞjؔ] G+l e.ؓ?f9')kǾwU|/;NWO??Pus&p^q~rϽPĄ^7_}R+㼩Ǟd%Sd"X\no#óYl"Wp~SꟼE+ g/o D^jdJMOeYJjcg?cnػmCcq68$.?]a< S69N?8rȟ rvw=?vsI\e?+#)8{o0Qг _0Lϟ$y8δz:?FO]?f|Jr?;5{KGt"O'α3#pծ:IGFL}UG ϟѯ?_ٺw]C۞_gG^[:x^y\]յ(%H1#IBRED$ #/}ͭm[p5Ö]j )g[ٰ^~mr]4clS+ʆ\T䟛U*5>zMox/cK#?1?SZ!,Hw2u{Sfq:B_Z1e撟G>Q82GܨMֱx!׍3=.- ƍ}KgW+͏!M|=SQ!^V{}oQ_]GVЕb9H#̓J^~}[[ڕ?E}X > :r#3s7/0p?g|onRټ,U ׸?/S߫@p-SRe0HZMDS!'l[$NY{aw)r٫?n 8CGYQ?eVGĽ R"q 'sZ:Cp?򡂚^nf^׶ƍC/KmSstL&Gs??>_}tiEpzl?g#ʾxe߀%qXzIﴘ E;RG~0-SXԢ;ת| hjĿjw _]/ 3]?OOPl.hx7F(-K߈?pgx߹'REnm\֜?S柈5 ;pC{o|bC.^eϝ?: BTf VO|NS eȭ3EaAKE4*\61B]QuyKrpv<ceԿ#umZW ?ҽ֓rN?:f!?)yޒ%7"/>;WĄmg+˨̑gHF/n٢z*vpAQB^?(fw_O@'Jz.^}z;0O!gZP[?e?y߱C޿C3?<Ew\FU9ş~_qط(>U|&:Lc-uK[_)kReea3#??d`-|i9$]ֹ{uW18La vG*Xo{P7i-,SU˿SJ^yhýE7?~OSoqfe#kS#?([?3Ҽf3߀.x ʟX۝Soq])7?68 2x]%%6ﻵ_Z1Y}̓P[go# $?jhJݶPY󟒞áƼ/xOwa=IfG?@mv1lvFeQur3(&t?. HƜ_75YOX. v_?ȿnT߿hi_"?>C??Fx(EcYObb&]]9CILbO35/@??;G):> Yg{qv_ҳ?G- n ]<2N$Uۯ368M}-Cw\L'^R/zo-;ӧp?>ZQ7%=Wx}E??&CUCVQE{s9*(Gc-"b:y <{qv󷏩VQB4NY#gpF853\G@7?j13E_?#B 3Foviԫyп~ zv~1-X/麟*L௯1PǸLV"m~bWׁc)J?2?>cEǙ=I]tO*0#^3t󇸆Y_|+0/?hHI_ʜ>_q ?pbd8SAcW&?V DU46k3 x߰yЬgQ;_6<_5Rm ސPvL?36`}wʟ_=KYL!!7?bVQuC/MK3G'tBqN Cy{g2x#w vw,+x8';W?ܳ'ʟ p!=[PM!o[d z0lA$O_#˜fi>XQ xK3 s3aW$?ЯBf8@#?׺Jp)&T0.\ALFRs_!w`Ԫwlp]=:#OTȫr_ƪϲe}"?2J )_8DO/W +pXjXh52zK/2qoﻵm9i0CpmEG-8uΎnN1|rɷ j:zGTcG 'e_.|&"ip-:8̽3GrNV91=jP6xq*Ri6[8kZ7\={~S)>tGm+m͵;]E?wa^tĿ?k^M{c Z"OL*(A1J)kC-Bvh#/?&˵V#(nZ1&f%Q=p%>E MC㣟š/Y̟9_0c'*8oZqޓ{;Oo\3Ywej8Z?)kV^5TnY&Z0"#}Jd3, e\Xc~['kXf>ϼr:#[\wͶ|%kQ 5N5Y  ak;`Կ#;0F##w4ȗvgslOo쮛aѹznר?>/HU4trB/O/}L<P_F9R\/}-,Oᜁ|Oc9(RkdI\I7 b+Pw=&qi?7nK=?eL!ϊ +qOE|+"+J)ن?sϔ8D3Eo`?`>A?hvis ?9Jm>oF#??o1d%8qEEg*w{N3*MO[[c 1ӻ ꨂ=>L? ^Y {vj%=X?뻎_^H߂D#?w'˟h[Q|l#hYf*#cPu3ʾ?qg%[OHqn!'e[ujqNWsf?#7xv7}␿K~ t8všd/wWCG)x%=wH o|h?q_emyMh?#5?\_Οo?y'p[Z#wʑߧ&_} d!'9#f~{)O[m:vt_|_ yjVqݒ2ds\O/'!7 2?w?+{ Xje^O_n=YwQgG~8k~| O}Q/L?Apq42p g_k1?<e/7;[Z?;Ov69GE=Xt#%؅|΢H1-V͟-UB/͔uo g#Ue#<mWrבsis  F>O+v+ ~?*تտF;oF8C\ [\AKz! ym]9$_]*jc/E~)/dAqK>Pw%=lusg VпJX @cYykKb@ ?ރ}}DǞ??}^:>F?){:}?OqpVok!BM+=+./5)ޟ]Q?y)3owdg;F7{'#nT/_)C!!U4C}weĈo; /;z8@vsͳ}squ_1nuOjbD٭#U ݶOK7_>NzO#Yh䩙 6uHBOgݤ5k;_e8ǩ? Y%{[;5SG _߷zS0?{p#nU'#v^[!Ǻ.WOqPfkd»<:Ҫ/|ubhJ0äw?puPtxF)FiS7B:QSwtҧSB~&Y\cOu_;јlZ>\POVAS?u[zOӄDEq&OjR iSyXЏɛnR wO6?Fs_) z{U6Lrp^(QT:+{ɟN>#P6\6II W)}S0Ȝ/H蟼Ih{II=2:vw O pW]h/?ػX6?qO{Zc;?+\?&qQw0_\sмM2Wck?]b?O(%/xf?犺%|S4X>T!~JΟ~m)F*ҷٌI쏱@1mV;zOUͲod[&lWb_O_RȟҥGjʳL<ֿOE7e/OE2's{Jy__NpɞFg?=]i5֘P_ o+PM_Aϟtm</1%}6eMc/BWџ!QW7L9ӷ‰,y}L5{=,I=R\v}+rՅzv<eߨLFH?nU_MH?\m#H]jF }_V+#r{W= dǺ ='εcmOϛg{*5hl{O^>>'iAhz!<]ܞ?ǺFJcr-qX9?w)jo]OuQ?, x1>]Z7OW#ԧR4EY:0'?0?en?;mfOJ3x ))ZOFYShOَ)cNzXŝW^9ߔ;Dy_ߩ-Uym/2?om7$_d&/?mD@u(Oz?X?I9=wC\H7}NVszrwQ-p?<Gn?ߨ9OJQ~mR 9?gp-My:ϟFpg(>o?>ȿн75/;pB? ݐ%dmq?裚ҿoxͻLת =Y_3J~ 3 ה?W^#զjb?S5=.[[Uw_D^KpÊ?qWs`Y%*gj͟F9v_9<1R+_wob7U.X~Q?yۆOBz6>7/4O[8.ώ fw/C%) :,.ƿ y?ma ^ϟɦk/43>T'ߨdB3)=@KA3㹃.  5W?ުƟ%< /Uw?cD_P/9wS ]'ǎ {\پ%׃M)1?={zZh5=쿢rڰ ]~C[3f`lQ븎V& dYG1e*e/cQ6?Lo߹~uY}=y^e)!Oh?'y 4wWn/=K,B=M_4_-L8> zݭ#;~ͨoLUwqܤc9?Kn{Un+}Er'²N3cQSs_G˟Ի`'eʟl?차;#kJDDuIIxNuT'w\mqTrRKkSqǥO*0(}cI']p4n?=ΟUT)_?rc_Z/<ϖ-P./yݲ@ZZV=ݿg5_>wJjKKϟN>ǔ;pZG&1Do1aOMNkoEt nuM =_ukKc:OGzO{񖢡d=Ƃg/t7B} 2y ]yFQ/D5 ^MtZk4/4?b=╟_O#m[/{??;V 4ùBʇۭx-S݋ޞO}\mh]u!<bdv(?2ͻydۆrh .^7gF8#e;Ys?4΃Ě?));jM\, S߿>-/h;;Iz%iKL%EZ=$Ήh Tivy3vHbEC[ݦfը~Lb>Yo#7VP?;P^﵂u9fYg^PQ[ iyݑu^o PXV7|Ǡ`zȏ>8o`䯮e_޼i2#^޹8y*;$sAF87Bwǚ_4%Y[>ՖM+$)K.ŀDY1k+p1z['آU/9=}/*D-?S=36|q6è` TmGKPϲ&^adwbV2>OV;?P*>bM3VSߊ??;-_wрM~}ݠR'8Q9gbDw:R]`ڿ{/+o?,D܉zSlqm93tnogäFjx1)57 @zk'Aǹmx^yXU 1J(!g'JAJ}P(C)B1 !b#E%PBq׬f_)=U{} 7҉!gMVە W•\/uGkoZw;Yq oY\ק鵉k-2߰~zzYɐSq0-qla I˿nQN} kyc+T#ߩk/sr=j-?1|_-;;X"TYG~N=9#'խ! ԩqȿ L?IBq.{q3俚LpgUZRKq-?k9h:CkXa̖czUeZKLã߮/ pQVs~OC<4Tߓ笼KЪU#АdԿ]lNYՓG@NilfK+ݦ$-N5??W޷?#UZׂkj9ؔ*<ܱgN >όO\;ޏ!YυTQEH?>g15Β`-γ_=zN,2I9rFfe?%K>C^~ ;kĿ,U2,Q$[Dg.kdȿ|`MEȟ3tTi8#?KO3iFNQrgzRg?O #?Tl i]1߷|xFAW tm?Ъk?\++ +OPG"- U̘o\m)ϖ%A=+!wO&vvYߤ:6]wl+3? {יV#ẞFg3;L90O%I?NcfB6']re{lLU3<쳀}z0U(dL:Ķ[_fFSmKf?0hcr@^1ߘEA~)gT|7h6nRP?>ʱ[Ax%6gxǝ?XG-jp6;>-#'y w0mQVX]S=TUȰΌ?,"gQ99[-K-=SWRXA-osu?TX QM7{W~>geYea^\W8'PT^Do/%OHW?>@jϬU.Ol ~f1}(ǵsV}Ϝ'JdzF!B~ 1QMo$c}_=9E?ost]"G#[Oæ[Nq7bٽQm ~gIn2M @tov3.TO}ȟ? k81OrO$j% IYFO eQ/U-^ήYCo?oj}0XFop+xM#䠖zGtoy9nΠ!Sn3Fp-p^ojQ *?9&\+3+xEvi1?8ϑCeԿ{y\ߖs,#+q.2?%柠@È?d'$LIʺ?pRA߻;^wMuot]g.>X(T jףe?k`{Ki{(ЪrȟvvvTLMB- BV#}ugcO\wB91,h8ӂd3OtCWq#s?{ihp6#{%?K%2 Ԛ?{32f_umW2O\{QytWm~B֥o?zZ58`V?z^~2F63Y-vhSog1_?E!_u^U}*hJ7I%ݡwOoIЪlPh&gAVH%?}޷?{V~_-5V.L߯-j?hAY2O9H'?9 kzBz3yj? 3G{$WR_FDʟD\++$COu✏C_[-9~@T(p?hߘ? V]Ʒ>?dS%_]-!oݤ?|uY񏩙@R`GKrQ?Q<;xhv~:[KB>=طm?*4>4cCK?+ -j&Lq|,nhZQf_[1'oyꮝn[L?iQʟfψuW?vlgfӺb %'}$uk_P8.orh꿠Wk!O&1 'yS8?Zg}[HN*ͤ%Ou|AM7wǽgd?!/Őg}R[=%حrz37s{!Sa9Ǒë«[F Cf{X:}&ksϓLR6Qz? [WT>t dЬ,#8z1[OQ4Xp{m? 7N?/-jO٘8xh}D_LG!gW-0ꁑs?Vo[Fn:"ɜ?f[=0?xg#o},̣|>߷Q?q?gn{ݜz(q;XpoԿ?f]oggeaCOor"e890S23&[k =GM϶yϟ>[{?Q1gvy?Vaf #Orًpgն#ڊǷ: xpt͟AԸeVיX q^ا[.Eg`4zC^wb2(η܏qٗtq?R<_Ou?7=o2q'?G?g2M򇞊j_3ԑ1oUdAV_qߞA5?W!+iz35lpzx:YDPZgs0 vv!W=6fB{ܱ?õQyq=,י_ {{s>dko6gƶ5ߞk e"?=QzX߰/q@"ڊZ0Ks]F0ͤ 88"[ |`ϩ6Q阹?@ϻrc;~O`em.o@3=8]bT p?i`/z%ie:(VIiVq?>&Ο>GYp+/r3CO}Q6_eu>[7Tyj1G_Xg?>7E:o;5?9[y ۴uߧ?xF ["Ö́wiX_6gew6L Z?_bK󇑁t~n,l-kdУ, )i?qfytϮYρ|Oj&?pm6?4%C7j0t-ǹ9M E{i/0ZW40\8Yǹ?nX{A\{!<ߤ<`ϟjUJ䎝$?pC8+ʧĶ"nL_x6bw3 kxi({ʛ,:$ x;=d Ы]ئ%}ɃtA_>Wu?~'ތ?c, {"yZV/U E!KVq;&',٧Ǚцr(j?hǹt21gy?~}+VPa е'ON+<$?Fؑ-zZ-0 = 68#qU^JO{7ux.眤G?L[5ȿ,,ˊ?ϥU-}ۙyv@&+ΟpREFkdRIIIumOl?Zne=ds6;c?sX#*CWg'PxgPGKdjYySe]Ώ8ZEwMimW5M~NyeySGDZ6޻hx^{XUպWHFHHD*bDHKylbsC/xK TB"nb~_6qCDD(}oؘg^x<֜7~1'ƞ+Fc'a}ICbIo&ѱ:6mnSW@.OyMev$ Un{9Z7_r1e_X]&ŪBkv<ؽcqDx[&Nnm]>X78q!>QBL5rHڞc+߶F )L&kIˏt.!k[Ω-qqeZ:=Exӂsϊ_&4S[͎o~KZ$0Z95_l,kb?{ߌu'1%=ԜZJ;>oϙ!mt?De?k-;NiXHo]hqUɃ\/vNϳrܜ~u#;9Q]~`o>('W\*mV:sCA u5$4 ݏ|0!j~bĐU]C.-#)psui FG1RWE5Yk^!?׿x}ˊ_ܗ"S~w忄8YJR) k@ kIp{B%w*P"g&ȿ o#\{pp$ngAO@@'8V9^$Sj9VB$EP U#q_~V|! [:o7)4?W]i+%!͙քg׮j/x}"SD-? Ss(uǺa7;f(=CiV_vpѕGE9.b0-X'=C{G Q_ԟ 2<Ks욝;T!up-+Ԝ#w%XӌZQ\ӟ_b"G ÈrgV%F3k?\=!;mߝ_̏$6 \gdcĿw=|wC]XEqMk#pL~cT"XN"翄F/?"stz\"܉0-7 &, g1O?yׄ~K-5c/i1i)6?819>7 O]*hI= ,v2] D?ژNלbWxv́!TF '?Hz~ r S5fD I&?|U{NɎ9ae!EL]__?;_TlSws?>jk&7*~'gPd DWd3'LOmJ<Θ,r)3ܹ@%W"諗꯻}4 M$CwKgBhր6p;}"3)` bV|[iKߨs?tRB{./wAp$Exծ#BoOV[s 2k~1@W5G43,;?_S8.K< R~!rt yG8O[/\w rZ?Z(p[g&'O|/!B&Q C2ts\6Cl_x|q"ϟO?!C>H- |HWwPy31e*_qc ; cJԗYb\Yaa?DұN oN,6 o<?\zڅ_`O;͜~!bğO8:??0z`ANv%n/a5>{fFǺ i08JitT/L;wF?}&k7~4$CTs>f0\d; S]"}Wa@DyA@Y ݤ9(㟂- dΩ_y{@/`+"+>6}'Myo`7/qqX@3ע/_g5 Y'6/x_в?Ϝ'(??:9)֜G~&zM}ƿ~R+d<Aźo>ptPI5 Q_=qUXi|!)sxiP'pNG ~" =?N\%9>NG8CGm}^idNŸ@3z˫6_^P𯟔%s?!@/8]CS>=A?{RYk-m+Y:oO?hE{{09Lc4rE x.ysYyY骐Z"L52~!LϤ[Ff3?֩/Oݣ$I[[z/ F?9f41D?fX9)([qâѿ-/lfĿwO;Xw,2i}*uRDTߢgy1Ex}fz29C'S9ul󇬵}.9ėrT1CkJu?%B5"A~uӤC嘏&VFi 5D=X χO%>B-p/\?8P mFM2 ?,r7,f?EcNٌGW+oUr0=`߼؇O$ӎKr֛6iBr;?IO瀀"= ]8'Yl?/OM8$IuO=\pWG_ИUt$s5/69.\*YFn|lj5r䟽 ʫlN}_Q <247})hR>p-F>= Ұ~s(O]]H*n[?u@/aL?gcOoe[yYGi3D2_Ý|ƌk%"I}u? Y}?m[GDh-Sҿ\? Qx<ҵw?$6]=)^c_]L?>$_=&4t&{3E^0 .V'qR2ZF>M49'sX{(uǽ )mSYЋ^/Q8gB>o"5m69U_*_YF#c uby/R6VOE an )=WPxAӂmR#MgVؤ"$rymnGЇ8I6Fub>?a{BֿB.r9$P4/LKzXFp߯S!;W =hǎekyMAlԖpξu?mXgyѿ̓sΰ#ڻl9'~/dlsLBP gYۊ閑?_?T'i1;AԨQ\FǵT=JN ȟy+ 8ӫLSGdQS/?1?P._Ԉ{ ?i{[^8? ?Eo>XFݰo^G/,rf g_TBFUм(q-OP׎{8SŔ'fZё &_Ojgu }}aTrvq/Dho?{pe -{;?yi+YIX?:ø|;oIciW3B5W,JqwŔ1g_mPէ?oo|V [a?"?[g{z'M.w|9ٗrt?ɵ`F5;fG[qf|$4{أ/r"`퉜p0}輇jNuj ZW?''ߏCi{OĐlkoYfٱ+{^Q?D]-LU+=CF^}HiKlj[pg\O'PpML?o}OF+j_Pߡ$J3_| }iw(πHk:Ǿ?30ʏ_.g!꿱(\jzbWbϋxr,W쿂 I5MA=ܨ^E;;D8#mOW Q/qe㩘Cyೀٔ鿺D='|?V%pV?bM]ǽ2/ ׌϶4)y]iSuxw[))m>W C =^|"q/ı:͟Ý'hSfC*qcfHr~-E{ȚqLn,kXOzGߌnOS<֙K8ޥrK-ט6qw^?t221vb?0)Wͩ7ov9S=H?j*H?..QWH3?Q?:Lz]VX9ɵaKlçX]oFp,M3H)EJ=&&""!=d!H""9g,f3 I)ȇ#Wvs3sx9yIr/Yo>%+!}5FLqXIKC[n؜S3Yi~!8ߓnzi<){-za+*+W%9MzI'W }HHӟeE,k\˛*Sfi)N_)t;i^{Y$ ĚXu+):MII?{jYKRz)Yfq =Ӿ6 `c\^$MRܢVi]0|'M>-Lo"u%\nCW}T*q m)a<,b]cxr&|c #s?׿.{7u^0q뛭+et}]PjZǴ~rUȫ P81W/<シm.%dߠQ}3Wxঋ]%!q߱&-5¿&uh,SmELCvs iYp_3 fPuQo<%l)l.".Oe ʹ om3/iC8? qko0HL?tf<=X6]lS"גͿ3@Cupʇ~%^T'x ґo ?=QyݝŸMO$U(EC߭Uь"rm֑Y  le"q*\'?8)U)yKRetQ\K&s S WG|?g:石 hSOk~);z)[<ɹ(}ó8?0Z ̌23+{p¿oYѵ#\ɹy\gIIdҿ/8Kwö3yE)U]E~'B~sВ{i^*!2ұPw6FV^ZW;x7 ?&V7:p!nOm&?!X\II]E?q|éؐOύ-a-#𑕲Ӧ 3ó17]9CVuܿWHߨ53`ɹ9 CcLJDߝ^87_EZ##{"FEuFy>@(o|~o?ݧx>Oީ˟ U ;sG*=%a8ԣbpTNY-- NHxu;pGՒV3E?yt"*[fokgHsX+o48 {I+t}VTKtgJ /,ode|^G?k~;w_iGUD@B5oF:;SObxelZNċ󯿳EG>2?iϵ)Ƹ#-$M_$%8yCuTGyLȳfoǯ?f"i2o8H~ʴQ{?iʟf.a߱F1)s-T򳲀SZWԡ.9wnYq/=?>v#rPo O.V"kOmϚh?Kyꟴo9-│o׉'?\Bhf.ռ4Тws`7L*1HXt_c۔אv!fA!$W@Qrϐ1nPb* $U9Eosx3Gt?][NYFQ¨&oۍ_׽j"Tv<5Wm:jo)S:-sqknLW0KzJ?Sv״1Ʒp6Tto-5yC|{ AP4oj7]]VKI M|yW':;lଅUjOCYvZ:a77=/?OT~oP_ǨNuZU#Só+Lxᬆ`k}{ u/nf#x&ᱡv7->C\+{(צ+>5b}տOvirQw1#4Ѣa@$P/?qk19O a;=a {fҹ\kroW?+WnVv^g~A4h -L%ґ[Vh.7Cc>J߬*V_=ԣaF^5O߲)>)Uq<e{F old6k#c|F h}`IC%(~Eܗ$b<4&pЅϟ6]9ܰGW6'Yh^$aޤu^¿hN?>1C?'źB:? fgӌfsY AѿyS-Fq ȋﮢ{@9?OSh?}.'gAq-ށ3|G0sw}ǞPvμ:RiZZգWR/X\?M?2?~SmSbNkb;! f6 glҨyE[ጂvqfpOC 92?:Bݶ'v߫W___RϿQ??Xov]'2Wg#ueLRߠ8b;ȿ=bsK{2_Dk@JΝa? ΞbOVoV8%bx3}D'&P7su7:;1ӿ3\ 쵇D]Oνd,SuM~?Dž/`}W\!eXb3Yy}]u)-{ cLǙ=?}]4 dt$*:C룘&_͟52˳?+8!(q.|R/1o?w_]u/>vpnu|qbyR&vb?4>]?#D?PoO>oOfՕpߨC?3lӕbsUۥӿu͟Ə?g̋E*88ā?dMϳ>LL,T?o7S ??k&wT&%9f7?ƃGߠdFܓ+6frɚ6*c_o5Y/c (_5}nnc}`k%B 7uFj{OyFCOgf<2ŹǙ; -r;W۬P&bZkz`і~I@CEZm6?ns#-ޡ\-ipC8wmnOϩӳW`]~35Q=lx W %/3Xu=g-'`a11'xD s;Äs!بc|{hْO?)[#Zj~b^oq>'>$1n[yD?i_ Q;vZ8Gc?f'gs'`3WYBQwĵpzU82S*zÈ:u=#5P-9|NS,$S8y9Csoc[eW'ާ.? U8iMᏳDde0j!=\-NB]PF0𧬯$x$8]rFJ8Mc7OKju0!T3·c'V/\OuJ=>=H7yV.4 O?7?;3eb^0;K]_vᜯ{@y*矜vFI tDoA{+?q?fOP7E-|{W7-FMψ-գ׿/n=}^OϺfW}=ᷪ[W^ P&$]W}o{8}$\#c.cux^yXUƩB(RD!5\B \8pǔBK(qr.(8b <J Rj(:&ӵvt>>m;~׻NF~nKl?g[;99նď#⟳_K$weq7VHͶ뻎CPٺ){UGy$r>{I'䶥j?c|}߳r }/Rް,*+3e~koga䴺y侮eʝ>w?r/Z,~!ډښ=~!筻Jяi'=F>+y4nU\J|Ӿs'Й콛?,'TVR뿪,{ j-g\v3ˑ#"Ǽc#Q? iLO:e{\_)ҕ?/565g%$McWdfQ#?"]23W.k ,j|J,65}E>ϐuM&hS;3e3KBC5T^d?vL?'a-C$k'z%]\\O%C"9?o:3#mӈWO_>G3t1vM'T՟ >ׂK>=; wÚ,"ɉvq?olI]{$xT7/nYsfs.C+W5z3i?iֱ1KJOxg~|2|;iyCdo?1%OSs8[Z4]_ȿk ǟW Q]B!!dr`?FP;3ޣaq5J1s |_]~gIόZyloogaV6*q,a %}-M>8߸R.c3jV*-F|͐CR޺`+ا?':> x(K͑6cpV1G]c^_W?s;iQw:5K?oQ?sWD5#Nk?0$(ec߸Gw/HeU/{'yg'OmYO|~,5-?:2S{K[_vWNI=Q?O"n9Aox挿sqG? S4IͿr1[<-,l%j wM)u-zC{C I'T?d|&_;!tmw1^a y\Puswx)h0c~WBPv5_-Sѿ+>_j܏Ց+Oe"⌢;'`ߪC-?sqZ}S?[OIN豨N}0mcnJ}u 7|a/. %$GObۼ|ec_?Dwg+O?`;m<5ng!u?W6b_`ːX';4"P~A,sUTz :_;Hp⺖Gr8e_ w8wD#0WᾩӞ]J6]__DD(WZ+݇ S >x"g 9i+AoNBC{2}:.g2_2RxNi5-o_xAXw/gO Cuhb#V@c!?q{q]e xqB+(x\ٿ@?j7 wOb5$b|R3'ca 9Uކ^P?]PWO,#Sbt8Ee[3}^AIIE/K|F;҆r; xwkS'fU)=_?? wu׎>)wx6_1SpKS NM׆[GyIBp-L1w7=V'Nxn4334VpKaW?_X!^A?PK#D9J8埈jwWlXϻ "&R_삪¸ܬ ȿa[5̇?)?#A?d[߳.Y xϑ#e RRR49b5QLH/<#jjoYvfk9Պ b9! &|qu/bX<VRQ xN Q\jɊk ETSR =(NoE6s;,S_X/OX;'O(.g'Y<(xx0}0֩5ߑ 9FGϟw=,S_Ǚu=pv+|z2= Ǻ?3EЌZ Jj_f[I={Oɟ[wy4A |L# _Ƃ[}K_y_[ƀv< 3vs_!{u,,(r0;V3W7JxU'y` +WUPQ~C'h窠;]OB/y1`Ҙ\T˱_y/xÿ./nV~ތ#qOgd{m [M+X޺atJN};k~⪜|pϠMopM}X:7/g{Qx.A65 k&ZpEŦkp/쀳fgf]2; qPsVZP>ةõ5!Y+czM[  Ԥ@v??kMqrh4Q?G~c=?ϲ's\\qM[?v?%?Ο 3?_1II9YE[G㐨P)ߠ|Gxou;+teD|f όAf?n-N<?%e6+?ς( ?JO]tP[ڕ>4nK"o[6|"cݬ ,3UP}I߳Iw|H3'k'z n6]Ux^1>'?;jIo>r_TUbY;qx^{\UUR"3 CxZjx 0C0y#kCD(^^""B>WT2 C[j^>[{s?9g{uy,{d.ZyRߎJn`Ӓ.LmH[@އ,zwOSيv< Sm늶WQu;WKvRG_W]^;6Cu.vMzaBz~kXN(ۍmS'SQGg{P;VUyߖء$]{/ĕ^/ѵVKnZOgmdJeZ2Z q~(B@vyC%og_ƤkCg2?ZfK /LR=-?}/uSWɕag>l?]!΃^YMy/GhРiXg 4ZQWlWI? i{ZgcdwIO.PI'{5?,2P\?n綊z| Ke5^N^9B˟7DA -w_x>CГL#OM…wg1?ESms 6Jϡ1QG?~֔L/OمOp=M]a]_Y~b>>WW?!??О7W-QݶD?t_!|vT6?IJ)Gg$qӅ$?\XQv]֋)0ޮVҳ}2ՀyY?caϟ ˜s_Y.+ϐ?3yeAkaCtBwTJ[LΟ.Lȡ]"EeHHGQctUW7̟;):5׵  7^gw\a˫E"̟z@őQ40dR٨5Zr q' o]1ڭOapkŜ/{f&6̵zuҎ?_ԎgдdU[kziﰈm"Psɿ4V Ka]_4﹢py@{uE#_.1ɟ/qXM:W/ffKs6 \W=R#^_مDYBc z<0rAa\ƒSgNL_%4waCgo?#U? ,8jnrA19~h/y[ njE_ZgИ"E4PpY_ߖ?4u}AGkϜCٕA s&?:s7pņ%AC27ImPtח-.'''- בe\۠/z.b? \_ nfy4~1@9}af͗.(kxTx<3?rYuojb>`ҞvccEa9DB1lM'Sݦ뛣?OکJUeا?7]w,?#TEv3럴jᅝ/5 3Ͼ??_r?1I㷾d?P^?kG?vEyk(ogzVuS91">V kj Tifu*[ҿ?~~ɟU;rM̤{K3ݗЦW'ϲ{(d`Tb?95?[F2=83?'Lu_=Qml5?Llܚ{vUsan9[V<9Q!k_ IC7ѽןnhkgsbDKQ7o{$rKR$?ΈHSEq{OA:N=L$.OjBsF0.gˈq-#~3ͧv_w ?boOygs#=EeE?o?ݫy< C{nfq;g,H'Ϡ H{Z&"D\:??&;l50dt=?6K#k?ehZGI!fS__(;KuZES;Q2??ZzYerGos_̟׮ ?+D%~E1Oe.lοEaEr zq҉w3zc,Ww9YIޮnj!]'3sHO?xvo6'ηM̫?vrI՝#}zQ>[ qrYHk; -Sgy{qO¤v;}+{k'bA1ww9<\G\zȭv[FVŹUo6>wE<8G͟?fnW5c [QƏnGՄi3q}vk;wrH:|ZVSuCg?{dO,iIk34 ʏg)r5t& t?^8;ǛC۔hkO@^&O?3RA!ZE-t1f 7KD)EM%?,=W[fS]Efٝ?J@xGǹ#ɿGr>ɟ~.rDjՊ}?300evsqXPh__SX$C^]*3sg ݎgZrOMi4/B:z Z_v߿xpCFWV'㌖? /a?c*rҤV/L*uew s䒛<G3f#7O݉SW=2ȻjZ'\"o~H?omw6fZ}xeZS'λ?ffO//im1e"v}Kh|?GQZIOK~OǞBu'obt,c|v5=PnIYVvg@bTGvuhkLq9S2t{P{0V׊_}s߿xC4O/3?## w Ù;!_T k;q͘WSAswj?z-qן?!k=;y/Dݓ=2dinj)nF.=94/ Dn[;w|'ϜS}Rei>iy4\qTL?91m?3GwvQ0Y|F koq m5 )oɟj{XU?zn~Z]]Od) /'e;cV8G='ofy i`7 ۃ}xsw^ک2 HǸq"єHAW_?ְF߁S o8BW0?2 o%,gZ[Z2Ǿ |Kߓ}>[j7ک/G7DߋWe[.L)?Mv}^FZM?~_Xӛ2kS.fD?%1X ?ƾl75ssC+,_GF2] eҚEweZo7쑯s3Qw} :OQ7QOڋ{/=g$Nnϟc3s.WO(oov7{?nOnwveǿ++;O>PNjο)yB䒀M韞Vq "x^y\U׵ !$R'gg( qD-B)!Pbg%BBCp Bpuh)R5NqN|osνW+=[1ڔ"8A H1[:S~ Q",;;1:-1_DP q4ܣ Hm{]%ZiGfki8A{sbVt GuH/ǖN^-f>I药+'kgƄ;/}j} oiZj󇚇[Zb̳83yꘞp3GKZT{> +֑|L٭ɁZ'9Z@k|9Lu;C~F?{ƁyAy{CPǐyAtMMZq]N{=NϵWL5VZ'}m'Ĝ>? Luſ|K >bFk_?--BW">GaOfwwPrjN_[7mBLuJ6H{wc5+w-{CuR߷.VA+vtȣ8W]9?ZѲV 3_\SwJ`=NwpVP ?U(w/ ⧀7'Q936Kߴ>X񂮓OϟRuH}n;_Qf! 5?=ߨz-+"۫ǻ#̢6?T=S5<(/ZJ@mY,T;؂%vOUdè=T/,zzkR*~!ҖuBjpGcrE-/XRIz8OZ{y_NXwL1KBHf[VO}Q/fJ蔎&?z0WN0D:r[s9_\F0?(\|Q]_g&I?P]QQOOwxsbmy>Ak#:x}A9zc"#?)=KfH7Ԭ9ɖ[(jO:m T]JBπ&{3^/U{Yt2Y0磌? u7Xvw]_g߽'8vX9BwOV߸/CO_?~/gnKRIoŦ{>9ߢ(V`hsUwNZvq A;m | AYA]]ũ9OQN?%pr? >[TC8LK^_s볥8o+;_YTclpD@?.G"g?'(Fh;Vq/:G~(<)V_`g*fsgV1?X. [B/? h}sI柑QOyC\sEkWPj! \Dm絥>:%0.PnʟCP/zgª82^b>t?.?1&LqP'ʿ׻NZo]OX{Ns&Ǩ*={T?qG 0,Mϟ)5 #/d{*%4R%j&9zؤ]贱b?#{xOkF!oFMٺ^v^a-)JO/s/VezڜJ+.̿bRu+&G웗S6)VqOmpQF'otG_u/U_[-{t(?j"t []Wz/pa=w\\-#d7^MKȧ)팟*+^Q?&4w?sҿu?t=6d)o~&nYTxz_I16lsϸQt/tɻ.+s?=G*9'4ڪ8 ^i^g [ Lod՘?)v_O yWL[?f7^ Eyt'Vkt;5q"?_?<&?E-ZNPe w_߈gmXsa5ԏ8Z?e8OgSHaYND\CשZ@uᆐ~)Pߔʿ#OUowRϟ6J;FpE~>O +qQNl ||{f/>O>|OCNJ(_ȸji>5UgƳ#7YA著.gn*=ʓz"?0G1O<V}jwu?+wog鱫+'o}vQ } 淘F8A}˝K6?_Ο?L陯+Fӽgvpa/OmBG=s]^6 ɅMCX}vAQdA? #7'?|*~w̘6:I0JYD}{W祓OpDϖ>z;x#Je%'MS/M͉#W' cmi?}+4~? q?~G9?{{?^qeϑ?5dct19zo7,埮TsVQw#y#Of(諬U+]ݓ='2>;xePy̸bQ>̿w͝]F|PҢߊ.?هw/2X9RӇzrP)o3Y1'\a?]7Or c/T'4-+=6s:=5N({>>@H-NCOϒzo/D׸yCGNCmp!6z&z:M6zdr"ϐBժ_yH}5|K Si>6NuǻMvk=/9uAU%3c ԥ?"5Hzq^-͗?2 n# m?7t;/yc`/PN?6zq]ӷnu`?e%qˇnw<0+F{4UY?Vc' dVt/ܱ0o޽WzTSOW<Tfk^+wNk]2_uɣ+'Ęugi1: MV W1'y_帚#{Y?%eXm3?f.8CI;wf믑P ??{U[C43(Y'ЧPNyA5 p^=1[k6eA]>}q`OM]Ahi\1#_PF+ɿfӨwzz?гD`*W|n~nOSvǽ J[ՑPOͩg?]H+/el+EET_;PN٬ 媿8_-N O/1ٸcu+L̍ A{/c*W8Rf9:W`nERO 0ռ8S1=>G rQgnz??.ݗLi ?W(f3FV+xV(/ F/<>n|<#'qa͟/|H=t#QZ@2vk-U0_5U]o ' kkiZBOsWJJ|`ϟHJZl<͔?%쳁?8!}?=jMǬuM;`?NOߺ/z3ʈA#UY!l '-~lʟM`#\D^'֮CϮ߬#ڈuS>un~bԚsyP%|VbS7<%8q =-S߷)T ~M/|ZI\LEs.LCeɟ;(^ *~Hk{Z!7ΛtN2=3g$kI?icr@w<|9g%Ng5R18?|vFi4{o4kW]>}hxV_OL8d1A~EX?i Hߴ>MOL8 uV,40©q__# ଔ?S?4^+դΟT]GXKXh,Y+vd'7eٜcdgE<'f=eߴUS{3*G x>#[7_g5py!U]5&OOх-!*x),^jעg'?BoOKY?\u>;2( ìl9||,gAS뾟;𧞗/Bl6 ?])_93CWCAp^x ?i^j]No%Qݦ'e>K?N<,q63<N Cg  /ghYI:x=8hZ2:%]/py3t?|Kq[B)\}gdO}׶cόq[) c>re=qwCtJS,˺E׫ly{j2;O?T ;g.?gi\EEJg V)Hx[8tugB+m?l⏳ q#Ԍ .#(6zο[8mSV+MV[(??E1|'[:y<{w >r\rLɎp~c; m-A|c)<jv,ˇƫ?{_l⿥ӯ?W?=0{=,!?kJ'_Q{>\;{KBs5?,͗8zQ?,'I? IäV/XO^z8cv4[{t; cf:[7_SyN*VGσ>˟VVm&/ƙ6Wpu/3Vo>.Jl;`$?x_0?q%[?F?{'KfHL$᎙vW#s?2?O Bq32B(LQ22BFY =5~H[5ϩ=zc-umvC@GqA軦V9A?Sŕ/7diW?Y%rUu0y:=Nb/F/+ |sN~QI"_zS鷨18cO9% l~k y%~;l(t}o57#;a#SMs?tOJ =5 8X;-ԏ_E6_T&I.aʟTl+>X?xݓ 1?*X:;͟uk>'/R QUˉpD8ᅵ]gz^$l/zR=?}}?i tt_ڰ=|8v#]T> =Rz 88`% ;~v SO =#L;)}_jmZ_7_c'~D}P5G`~u_##c;߂%|L 6hwx^ XUU"Eh1xZ])aqa?y׼2 CR2 >~!bxHGMJ2hglzT ~:ӧZ=ǫ %nw[s4St:u{ìdP}NoK?gPZܭƫ>ӧN=R~fH}]z9XX\3gV 87Э=X+]Uu 3[51eVquG8l56[x6Ҳ%3Wo:]+WǛVxWԝȬZq?{k:63Y}-;eB %%:1?jDjh7aX wS#ohkˆ8k?u{Ь_Κ-ðL/_iE{SYƌ?;k2goU~.;?n~PK7N &#Ĝ}Ŀx`l+]>l u_Am]nbCSE_Vbv Fʙ~p-[%[vmEǢ-_yaCcH_'Q*YP ,'B"'LeFg\82_9U+BUxegUa5[߯)%0pCVӋٱ 2kR=>lJ=&L;gzs՞Őp~u#NF Y9 {NJx_\T5sd77~~ֆ_vl7Ore[_D 'iVއ[_wemx33k/5R5{kAanWZ?U_~%>#˹_?Ǘa0nX.[\?E zPE{9mˎg|F u2䟐F/|6RO"EO-?uCn9p-֎Qu7 z"}dz%cZXg޹~(M!okfbiY/DŽrTW|Pz{CXC?8U n~7>WX ӧ+'&[?cUֆbx\+5m_1`7]A߅"]COf]/+fռ8i,Ffv?%x/CE^ywJݲD1_zq?q-Ha;ϒѷ'<7Os3N&?ϤZoZQCaTG?%?rF,@0?G1G>Ô?ť_ک M?\@?R.WIWo\#o]-+G[͢ >=~/'?pb7/O׌!+4V9q#(!7G~⟘(W-G?![vߋSǰkN/O.R+BE0Go:Gy\\%Gg@_>6I}h?"C Î*PCXwOYn?!{KB[]_vi+fV3?F#) ?Gw_[νq+k/򟌰8T/曾&j]L#fフ cA1!衉T?RƹݭaDNW=RQ%.~ ]81ߴb̟|%֎~H1 ]-ZdU%qfK?-+(c7_TgMis3u?#so?|)cX/c9}*vAΚYwQ~R>רͻ: O!=&f͚ r+l@)ګ؛i>|`o/ýKO 94߂kCv-oz#ae>"~N鑲r?ؗk{{A]_T-/dz|B_0!Ve| ߫n.[ObG/ Xm?@vwF?jH?pm{ S)݂M"la'LNq2_,G% eohteRK!W-ε[]~E%!0R z%ӦzVϯCʜM`87˟3 zuU6;{gw9]?[cfv[k;xšGvA-͌7"X_{#OBνdmpr$WgU->5oQ+B>si^?8]?0Z"m'$sh7XJ/VF߸ "?`oo?|ߞ{՝!?ϦAK?^T_ߔ?jn_~sƅLZ-zjo7AYS?qb83L!Mosh.JϟzaΚcĿX?-~$7?zHs~5" #c!MOrΐj?[mW3>̥&9ZEoY i?lw"q$9͆~^ϧ?sUc/q.`?/ߺx3^Gu9PPş#Κ⚁wdj~(_d|?Ǘgժ[~eW+={fVBʎ)%_KŮk3-`gT ^VsڠY.r Rz?ΗSX2@hnߕwRyz:Hy v%F<M5=#Qđ?vn~t_ڳU9 N??2ș_' ~{dгN^^;u'm}ه֎6?w.Y M~y=c3T+^?2ֵwE횽ׁ^mPpWJl?ίa"}jrn?q*/{u`8neA.IZ9O,tR۬,8b?ۮ4LhrjqG8Ki?%89[\jeę/-y8_ tQ$Lb|豪g w=#c0Qϛ=\jUK#~^ ? 2cj?3O tcԿ2? GH>z`?,SfV zh?F]'ʉs?? {Ԧw(q"_P̐?BǨMBýgIda/;v;E05#}8IzR?f|E=F<[)_d&cFDNv32xR6kwt6Q6w?Oק Ð-ձC._~6 v9GA٠ǑW8/MN?ZGfy|EO" IHxؽ7*a_)]gzO˹ݞs.xg6_.3 aFޞȠeǗΒ__?OY+։G ʺX?I7 o g?Г֎{St? ZJڃqobt=3\;x ?we)1*,W=3Y<&[Nc Wx䶏qԃqI!\S3õѳl2@Ooq 99}- $tŹhýE3b}_ 5Iu+K*s Й/gQsZ'$?{7`wk 91ڜzϹ3~j*XGNzoZp>4x^ X ""bDF4RpŬF"Mf1 """"{LBb)R gN}_J)ik6/&}/~9sHϓ?7zhK&k*Ss,_hhVe.zLmܨ?11D;rV5ַV bW"` lǤs4huw+I;"|^uxVq|ۻW8_9&> }qttg[W&THKk;DNwT%c潇X!µtMک{զ>ǜu@tc'~%:@ǫQ}ĎYcԊk1gZ+b0R_麷i '9k3Ni"|qi85nq%^x~h/}D#kYﯵs w5?#R+Ukӟ?gmzz/ǥcӊ}W /߿S͛΋rqtʏZHi_xZ +[>阹GGh7mS$hV<ox{`:&25ʎ*FW8a?a)\#pH;fi;k/"߷ ?qf+{ +ĿiNK׌ Sw 4o^&?ӸuQ9Y?Yt_.#|eʃӳYښ CGD)^4x7??§OzVܸR>O<`&,gP5>nuv+AJqRot]_?VoX/U̿hI}qlfzQI{"=|2?\c0{"=o ,/ϭ#Tv?)zw_酫HXUCVd.zM-CwSS{tjX8w'o["guã?Ļ|n6Os>J[Rzv/NW־H T_K[[Rt}˗E33!-4ꏲ)YV(x3q~Yۇ:oޛ vښq !Px}fXUm?8Q̟{.pߤs&)F#_%]_| ϖ|??v1) NO׬/?m`OylXa ߖx背l?S^W76Ka΍}"\zղ^j_Zݝۜ3QE\7բ/} ̘_UCghӵT1蹉A;L~?g7U+>+9_w'Ya|dJ?9`n=CI WOg':y~ߏ?sgCU ] 1lO~~}\^ Frf.Moc?'D &7n bz?KS;N'gGs>445+aHKK#oSlIjx?}ЯgAm%Og_>ʞ۹:gO#⎳-`.G~iŀXާ_OI繘UC,?2a̟&tМ2v;/zybzHsm7}cǿК?Cq\3fmiitWKܩ6<ώ?oVؔӜ_\AO W6ȏ8q¨ZUO̧99N:7y=\/r[Jqjտ\tϠ!KO t? pk7;gPڠRvyWK4sqiw8!m/:yt紏Q?7n-'<\Q-?jC5JWlNU1CՈOݟ:rai%(|B_/[ũ'ZHK+S%/? }:azk?4y俧?'`K5f$EU/Vi?g?xc_5X`Ms0)M/9a7>c?Ϭ̿O3\{Eg=k,%X#;Sal\EĄraV돾5y]xnd :*j$ߔ_?Ա|Y>_2B?n[6\,~Xx eFg|!'*w/3L'Ļs9kkT~:>3O:מvx1r>'=tr6*N9Gi5=`nh6>{P}щ.8N?R'.osaSDr՚)C:bf-a'ExkMޝP^x{bw^׿0ԑ>I[yk׈GZHgɟr2e-mTsCgp/j}M"C+@vYw_E[-b=$M+_?ڬ?>fY?Οmk??`=6#λ߄v [A-WTn9$^_̍p?#RSN 6KcƂ8'RkV@Ǖ Z_]__rI@GB.7LO/P0?|M+tI/qboghoّ[I_W wSOؼ?nO>Cat_%?zG?1Nm/W\{ϥh?̺XRZDp)+OI }6OyE=칔GY}=ie|k(gm梧T@_G\cӊ@~p1ȣb鹡ҙ>Ο*^9y(l%^p~ػc*+y>\XԊx>t K3u)$sw^Vɟ~׿$՗??3+ץ_a'r0{T#zxgJo?̟4_pn ޙ]޿A8Hz0:0%?y9sl_qJ‹Ob0>f%Fv?}H,7{{2v~̩`]Q'mkz-[uoA8c1F"|񶂾!_紩RT#P_@yS%م/072OC3?k/kc3yW?-?Yۅso?̍'NMsswv:?)X:=Y ?=rxϒ7)$8Ay/i:GxXT[[ԀԷ?f)F,|RP~ w [Bu0G5&Ĝ-`4n?wSmϒ?c$QO5ӽex?]TciUj}NV X(~J~"-3t?c^BDSY:y?]'izYɿl3.לo'S=bSY$?nj]x^y\ !(%o-!ByWZjҸ.! !DE RBͽ3ZB)!Jq_Ѡ}9y{?Dy%˟\\ՠ8}r.{KSځ=U9*9]ia/Usj-f+}QvWv_*p.MySt,V6)L*֫EdWBwV]|%̒=5V(y#Qf5%ZԐ:ƧQUz*i-ozWRt>-+%(;HQC2%=OpPbVۊ{^[d0^7%l2iNKCߔc'3#ȿwOy?'mX?XF R:o.xAU'18ԋKSm)iM^TRoZ-ĿIBە}=U\ H;qk*?,/,eu=+ʹ(ZԬTˊ7Ls ?_eOf70gYtRwF@X)>g+l؍LKBo'آ`nY'ZϚ]K?2M_l?gJe[A\uu1:d7WSI/ #?S+V&MP/_?jb{>CO+O/sWm/>LWQȸ4\K6R+fX݈G߭zrGFNK>N-?IGp9P܋t{j'{e$_<s3?2?#om GP;`\ xI?<:^bţ-f{ 05?O0d#/>xE.i<ɨK v-dli+'%{)%9%g_(i4#3ۗ-\Mڡ $n&=U~ʎS:^,mZ w}A " V?2l+NÎ<#?p{cFsG.E?Mo=0]ʵwR-#H!uz. CͰwOA2` GM#AN_!'* ϴVG-iSa1qM ?c NmZZIWٳp~Fb!lg *r;R>ǖc}ד]՗Ts])5OBc]o BKJGP[E߳!ݺ; F'{\ʽdͥI:ׂ-z/[?:{̷ʄ"l?Zo4]Wq=? 5';P0:n?{8珬4Y?p]Rq:觜?ʨ&o=W?0A=yĆ\I!A_V dym_Ыˣ8&/0-m3Cː?OW\W.cXY30 P󟗇|`?OገD! eTmg njȿLg$s dIf{9+̑? }1=PBX'Hf?_w &YMţ]d? 39*\n&Ūj+,p\D gvZO{`s ;A&1s⌽Vؐ%[oto'f'+:?q!n}I3DCex^ѐƜREq.q~wpӻV8[L6̃]C_.zbC*E#ː˥G7KFE=0g_oU[a}v+;p(r(^k&g5e"j't:~cDv+ #2]Ti=0 q.2aNX-gA| 1thn$#u"D G?Ǵa=&sojDa6埪t2MZ{!Խ$߹h( $?x>@0VrloD-Gѓ;3b $][{f3vBa7%>g/kϫĿǤ_?pI2 .YdFkxԕjA=d?C߰y2׈+g |B~i_GI ?gm?(/4^6ߨXF6ٟ!B-߯s.-A.+o̟Qe??8_[A:gDJ?:Z~} ׌8+*9_yZX^6 *4#{'gu)Οp[PA7,)Z n̴Ij?C=./g2׺#lȜ/VU5*`|7#e\ǹRuϳ^٘s/K) `ioId?í;xE4k?|&?VSy#\5Ԩ)꿴},g(.m>l5.|gA|!S/EBGg_iB?KIO7;- N`{Gds~/pv|M=-AEitW˿p ϝA`βVxf?"wbfNE*?+_LǽU*?8_m _/LC^iB|@!#7q=WZ{np5G}A?>[ex?Do Ik9Bp>f?d|91WۥځN{YY?ܨL~Zgulѫc]<=^?&%{V,;J{r=x^ÖUxҵWomeBn?s?f0m=Zqq?HxV8Y?#;;Z5qAhFk3>1#z"g+"Kk!c}nO}P/H\8wasxPy~ |:lw+7c|ɡz;I3-!gS#,Sы?gdji7?OEˎN?-9A6i{Zͮ81dA1ak?,B38t!GEkzR٘/Ёσ\+S&e=v\vf{FEVS}S+уw_oUȘ?O5_ 暲NbjȟJ 2 N xM|\]/VT-=ɇF?=xYUm+ݨ̢Y2/dPm9 g59_~|*q_:İgwp r9Qy#%?1H)ި<  :Y2U"7yc½;-Q#dP?[RFu5q|?1/ ?#2_a87{p'€iPe s׉o9Y_GpXjJO? iΠ"*ioBitZOTq;֥fZѾCGjIF< yZ{7y-V?<",o1uw=B.%A2> {_1]lϵ'gCYT=&ڜa3ƅ"[|r OWEAn[3(?>Bk@3vw]/ԝ!s-{ɜJ3˲oY9mԿskvo7:̐),<++%<m?&q4Yl?zb^;0.MiIo4JBnWҔNo4pko Ig)__yߏŕ7ыsE~'pdxŞD#{/\;dZ'U~"w$#Y? ϸkv矜ZK0nN1? ?U_\7锒?Pm#A}Z?ga:dq3 cx}wʵ?uq EYصrQowjBeN],y<Yv??a&Wdl8\??Y2_7^0T߱gU7yx0+zG=eoUi-C ZZWo}bzG{Gݢ;AZxΉ+}=?}Qw!u{8HQ/w.?2>ZҾ]jt_`o~7?g'*6I:@?M?6COO8bӿv\ԛ?N91x9!gGgm߬W$Khjz:r{Ҝ?fl32\3W9{_SjG80wO-=;xpwէzآ b9xwx~zazGKwkI cjO?[t!:* ):E,V_M˄NפEwizܼw\7ʪ_ G'șӎJ .N7R7w7AȥJKc"ZKmRZd>'$_vBs^;g%6cH,\FgEo\n>yrpsT-?aD ےӏr1^ _Ex/y@|&r)lk_mEZں^@Ĉi^_tG˳wN:$)tRAj$%xLTAhS6p8L9\L}Y8 Q{ ?$Gc_Y5ƒ_K?ں1JYMnsߚ1 7ca'}!}'Ϣ *sZiIKOI%'(k2 o1-2"$Z"s#wv/%@ _MW PonQS)-^DP<Kfz/<* 6[/翺mRzS#|ҌڽNp\ \ՃhG$c"CV6%Jؒg,7c,#V=C@a%$?džTMaAoB?TéR É]<)o Hޛ/V$eU?@̜J6`. `RAT.$!~ _x} ?1CB^SX5kߴcLj}kC#z_?ZJ)몴2d@w2?r/<[$qڴ%#O".d #ȿ`oYK?66?<A/\:9R97*$ Χ5k,&k]JODVM.E7reں ?N3Uсv/l`GgvSe*oZ#<3!Ϲ%!R?{oɇORvP36g@BrI|pr".( oc̟??*NJLUR|nNELc3{3m<.$"ZMOߏ#9ȟugZ)zTG=Bf}@׼rcO_I˟X[?PǕJJ7p G֓, ۮ?!WgN6+>xU[ovxA?}֩͟Yvo[G0iwA5Z~xIofܨRvΤxS>mb`-p؃k4=gE۰ܽc_dGc*3ԝD4%D,rHU.˪4J7 gjS.bi|/uGn_G>"i/?^?d?Lx@*9xmoTj/QkcKq8'#JQϬUY SQadY841KTO3h{_~BUX-'S'6_?zŴ[0õ 3jV< `ཪ?Юh}?$n}[TSU7ۯb{0juOȍRҡcc3j!/?2w(oM9qȊi`)Vfzwk)> Ms ǚOJ4q .sA˫_3:\k]~?|P8|1iQlowRonN9"]v#cwCS ؤʟ7$t湄ߞ%g/=G9]+ h(?z/IBWY5?w_ܵF OCt/l 43_5WAbb|"-.aLpZ;1ߓqրC`#/C@?5#xe%Y&Am57~ ,3+)?SֈƸIcesugSe}G?l6+I u_'OKꯡCOƸu7P4h"+ey@0R>WD[οHNOumGt/9PοŨZ1g_'OmLS /TǬ]_ei~ު)49"ȊBY `r|Fӗi9|bl-4ӈ?j-g? IR_Oja_Jdx??b|u5457q;ZcµE!o xw~οuȉnGx^_οvO+fX/t?#2lAwse'COXOU(غYR_ q?v'6ƸN)7 ]q.oxfqY7P.1dz"k+df%οzyo f8ϛE:F7+% U<@[tQOw'?O. Y|{QOȓo0q)ח˟?;FG~+]{/a  &Wr-?b¿rnvw3?p"o;*'_MGfZ([y?5s/)_ Mbv_t(>9Rpϐ?ΕgP2;$@_F>u#s<蔌?): oJnFRT!*Gɿ=j w.s,U`S 2r뿐ŴQƈ;o5Dѧ2DV16!y|߷ƾub`1oQ#y *rqH7gm!պc?ZB,Ì$]dn/{a%ӕ_3P ~/UL.؂زtRC~tbD<55vbTqw Gc+-_S8_:=_-_U?dvT.X?~~Px?KgG1VY>;jcIiD{lGlZRZj61$f ysR펿͡n$O.?z'Mo"@>9[e;:ͬ叺7;eG!S> mlLt?#P[½뺳?e;ޜp I1_sL@;_^o/XgJgN'D%JS_ߡvҼeY<8#oF/ߢ틾UWd7˩*C뿬_<D+)vYS^~uJ/b6v[ɿMVMaa!qgCM:*ϩ?-OS60cJG>][9^>Gmݭ-L*3贔3=bY o ?*;Ԧ47P#ܯR?ڔ-Y;Zd?z!ܯAx^utȘ +5.䮘ۅbkf+N7e,9%򟓾>8IiER?<DkE t5{[G`oA|MdT"hS|kZ*oFUQFA/:ƸJ: ?g̓pk>c8'2_hr8;]oEGY)3R.)"d/?|\?(;8F!΂]{9G}Ym7A`GG#YqBz լ/3Ll$GЂ;Fik3&Zw&sخn1nSkh/)[0J˟L 6f߿σ~c,+O~3`͆D|$cR6'Fy=*ojJ8G5Au?j,GxVt(*6c,x쓕VnOga?]Twf9QTPOV{rY<kwK+*gTg9_x |jlއ'&o7|CSX-wO78rl,͒syCz?"⿝~w3bL䅙9?WOzS7Mx^ X׹ǑDB!pk%H(oW܂K)K ݸRB! wQRJ%3jRB-Ru7|&s<<`f~s& ]ͻ\ImySq&G=;꘰h|V/ꆫYJTq,ˋ'GW )w4o|HM+kj[u/ǏcƷ}$Cw,SHo64P}ZrZ ĽӹmWx(mxDdAZ&5mS7u5?=99ʫSC\SkQmjb)uhNS3;67B7݂(3?b'L=jk?'j]\^Վ;f]O:갺em:Qv`A EijVM+HW;f_%k;kKX(3ciK } 쨂?qSs Yt\j:!RF6CIMtR{f[(_aYjx:bŪ#&=QI^NKGLٸ|O]>S.'0K:'̧d*%#1{Q5|<-h=/6M^xQY7?;v ]3?rw\?<5n=Fso{( .#7ɛϒwu?w]ŌsyA7we0,k>B.ݿQ7U]$_\ӵڎ)cr~&'z9gUP{aF͛7|,IŨ?߿~Cl~\ךE- BB҅7mf C~OdO[믮yR+:/Y/~i?s eܨ+/neFW Y׿O%9VWtФ? 6u kD%V3?=W/(O?0ӕ^?~[6w'3;*A _?iUA_=5$BnT33j3N'\uĿ[ЋܨJwt7? qa))6Bk+V}̡N~[ks_5](G"j/y7;yT}xɈHQ<5s?t[S:k$iz(?U>_͔D4?U[TؓgrUM#%T:j|8G]Y5`]3𯞓X,w?gIM9K#vYQF=HΉ-lMu={ȟE!ϨߩU̎u5|є?ڪ.7fI+IC?gχl@BIePר??aYw&φ?xz&E$M(zs)A|K'篯kZbj˞+Q3.4qَL'+ܮ\7c+ [#=Omm19' JΪ?.|ʟg`/f[mFSl-U :a{y*9x6䄙<.mG͇g8,?1)ם/'6oyFTyڑk^SEDO:#~v)߈:W=Ee櫑ϧ h5F}6lnz>I%!w ?ɨ?;pf4% gO9I"y.7_)y=ͫ|t|+v=?k??۟j L=v [7 zޔwj(7O|3}S/+S+Sfpl`=)2kZm@ i-i z~r1(V0OWO3?V9sMKlQ>hn);~쒜WB>aV97?-K,/? A=7XI:FMb53bw]f-=F1a4k?_]:AW\8C&j[O`Jc ͑ƻ*͇>v1#@iKVV\o~]}ƅ{=ȗ+9zK#,Q\WԿ_:J_9ɿi TO:PoW*/I?3g'EK|N-?WM 56Ϟg#[[`jL>*~Ar?;t ?SsQ'oj?|nY_7_߄;Qt0 ?_9'*tO7>Y_Ue+Zeݘe?:9;7u R|H/GݸAq>RE3k?niv3l 2#eGdEG$((?/xeBL.Wϙ(?c1ѐ7r?obЖQ+* ?/i+uQ9Ϫ+s5k%,T~y%y1?OJӱJ{@܊p]%#VWl:n?ƮTO tgʭW4CjԿKJnK;sGOy^Ih֖?G5B,WE{=J}buxp聬7\$LaI^Mw]w˕3ӿKlG?'U݉?:'5q|v:߳dj EryaEnRͧ6/n ;m_&KZ1F6z/|ydY5N?\Jv{:O:f?,tB=&>)`?Qra3?g{8>$'~mJs ِWT 9S,dVc. 3|0/VJ[g:ՉQ [inQ/:X-Tޕ+?*q]뚖A^eʟ^__q1<8:26OGG ߞdV$_=e7)5 TC}5pO~ %yʼn \`ӶNj crZHR^]b_>3~{?Ge;eE3o/ot^?oe?}>/v\їZM̰އTE?~|xB;=Nj.9!䝭byx5 q+E8s&b'];<4?e߄s.-[fԷ0Wn]/Åώ"S]؛sT3 &U/ 4$ػboPZS7&aWڙqIľ J?-o_gRS_<}?&,˴v@w m*~î\꾮2@۷w>h[6w[+^а𼥂`>bD Hxh?LϒZ]]/NlsQxhbaϰHZ5p?T)yEϙ/Ս_?*1'CBB\RQ|y|"Ʈk\l_t/dtQ&dW?ֈ?!CĜW_qߊ_jtǞ r?cvڭ~V1wBL8'JZͰ {fVS/[0'?{@kby.5I_2bX_Z7;7b׵OK~/c PsmΟBeg04"j?_wb57}'IC=l; ^^q{"'`N*Xk3YcgE2e} [?4tߞ4A\av/5#IϛYg|2X{IsFV4t=?ZgdciVtG؝QB8=#v]??A XGo,:A97) ow2n]sDS/ɤ~gO܊(cSn7Ӛ0df#yo|ʉy`Ǐ& $+rG=ԓeث {j%}dȿ3rvՍUp ⪜?rT:&:qG?ڽ]}։1kvEuN=4ݭE?rn",@ #h-vT@$NӦ8]7Z qS;چ.Wɿg:<Q/Wq?U/[Ѷ\>e.@}+nijkMʇ9Ϧ>'fߜkX_תoGw{5+#?9~!Wiߵ7hWRjy /ĹhU2Gk#I?qRDU sW%bB\m#]?) w]o;q܈q^^ ouZ> q~1WczvF_,cz-іNȪ,a蚷tߞJ+'!w$'?O*-jJi;O"8?ME'Ev g(*KUP(ݝ3Oׄ׈}3(\Kǵt)讂|I:=[eO:jD :sCc)X7"wg),vZeBODX.^5T%O,x7D S%cӱ\פoi_(#~٪]]ʉd%uF}'^ǪFdOc7:}5siRs󇏣]W o KտɿW"&| _?ơ^R?8biꣀ蟾quDV S$u=?8" k;!]R,V?0..Q=4ߢgӳGCcq G}w}\i?g^*otj)6z -G]FSb;?INܞY$OԷRLYکIqOm'vB'eGW/?D/Oנ@ĸ^?ֵQvK4ZkE?${-_0TG6Od};ŭֳg.(oY{UqXWj0w&I\nn4c!zխ=dտtWZ%& R+ud`A\ީAc͟F{4ϙ]OqGرRþ߿z6?S__Ы<=:c sE\ȟ>gO9}B5/nuzVǎ 7y%J'/aq)ܷRW?ۺ[V& .Ans{rR4Y\18==Z ?6#}ռɇM3K@Gfp4___>:5@0W(Ę/'PF3cK4_:RnQ^&o5_VIJ/ jJ7If}v!Pm_zu^~B'kzˤz%}0\?gޯ{sܐ'~z:mWVn;J۵PejZ#ÇRwkNZCTaN}2,^h?`n1 LO#r͠&V2~֩UzM7OEty? l{Ӯ?grS Sø]SJK'&iaGUk'R'c|W执H<ry$ywcF lz K?`GZ4韞' >  kqno̟FQ+\צy@5" 񇧁zp6:GGQ؟?O@=l;Οn\<ϳ:[O/qY҉#E3oEհzI HHS&ïXo:UjF%(_MQ>=A?ɋU&~6c;T>+NLWV:Bﻔߣ#~NVb|!$<}`=m~Mj/]z\%}T [O :)g\?f}\>Y5qva-o#o_%n_F>wnR~ā ]?j|}R~vRMSO=F\7_{g(Ϲf?y+n-4x?3(W:.;xtwIC/L5rOtU!pt?ȟxԛ?/m$Ӫ??$vD]cnu;܈'ZcDBt~WlgC= Wg\?-RO_e˟>_MRogQlZ_9ҍ@CR[G~Qx\J=[]/&l''[ 2?C;ς3T!+Oݝt7j/oJ?#u_;cMݚadM{e3.:c/x3bF&1= =V>7?:` ?Wc< vc?ᲂ^~w܈bapxspMg?TSUtuw]?|y_z>)N?đZMsw{quf37/.'0g ]_\y )W'Bvӿc/?z-bzѵ:R;vI ?R {?ќmJloɜ V3?{=A1V G) Hb$ԼIA9hO/etVRLzބïuk#GCdV[7:v˟]ˏVO?HMO'c?~WCOI?Y|$:ON螰—\^hQ_3yOZOkP/[ϓWS$࿸w>/JYǵDOLmMNɿkRF7jj䏽BᏙo{ggʧn[gei9|_'GX7>)PMOyCI^sD,ݜ0[XeO :^Ri!ߵ|H#fzm6D?3: cǴF=2'z/}[ua1+'rQI zMU%NT-cmtMbSِ[?W}e>QȠ.n.';V~OH`b+`es~1r?rx gC.kb{aqYIq6+?-W}e[e>GP;\r%iK'g@]łuS38` d'GφL7 ?^CY;3n_wgz 5+fG3KT99$vioٳ$IjrMH{{/?0Ia}rIni?vcᾅꂤݪQȳUM`gG&忢?%Re_ ?zc0~P7A^$/O:CW|e?{:3] }ğRO BZH˗m6ʧ˟Q?w1@? S5]L>Ek] ,CϒIAOZUN`?<St!p;.{!ٮ=?= P7COr?k޲?V':+ƨ6?Cb_fG7pN@ƚK?i{"_?cXcOSk/֓/HZqUb` yqMr3KaO_O'e;]>*Md!)7Msn?,x_> d?'/j? `L=Ae£M]?^ !)o|IgX߳ߣ?O;R`mèIq?G?} fsy{&oї;y $g'CkȁV{9]򿴱aB]cf#d:껆ezywwȣ_ ZGF=G{yߺH_z_=#TQϟ] M6B߫\s}Dq?c>}Aٺ?>̟[45^mΣ?w2?ew)WŽXPkN,_֌VIqOV?l߰|k*wCa/!ύj?{DV:Do(wxMsŽ_5 cʹǨ-Grg0P__+'r?dSx^ \T BPB 1sW?B!RGB(!P$*+n!J)wT4.jDnԸ|{N3g}>{xT/|FL}/~yvPFr>1#(1=R?|落jGG]a~lUzB=\f Gݭ [ؠ74ooGrη!g_W]6rqGu?shykG+'b>3$mӌGi;>l^a^w^#U-]eh)”,dq:q~ u}db^>O8˱x1f]7k>}{l[lmGmN׮؝s4b/Xz,t{Ù&.CÓζ'W6_=]}DGG޴8',bEj6cūG9~TtmAli,]Owߥ~u푊 KuiU eů8:1֪֧Qt#kc{?8a?]Hʇ3µ1S3geۺvW}?a?a# NϘ-ӸwӚw)}luHYS*޴x k1:QڸCvIxmBr׏:405߿w2G0׆yxLgCZ!tMX֔F-,>II1!&<;?}2?59.AU^+ʟõF&g}L?FKw?O?jӺz`'ЭG/+1}44\{ωk_y3?=>q{xxhV[Ga9K:g?:vHձ̷=/)8aE=2OLc?|3iGG*4X&n! y̕y2~}zL}o.M0ϛaKwN}h静 W^@b"P]G=,^4Os?vߨ+,^ ˗?2чKū\)[l:'OF\+ W%u>*زVtW,|hFýt1?^7پFkF؆K5Ƥtw9ܵݧwN]ٸ3/my-M.'焳E˦ii;ȿ{w0'f)s24c8x٘?e>V`/ԕLh_>tizп䟜˒b:3tv4]çI3\3ZebR9|nSpVZI/p?sXZ3)_+'rxL7U?3m?^_4+OWS೴V`ngfmU__:ZW^?]y妇Οt6?w]?'oFVc?1ߺ9Ks#M3Z3Ogik Wx{?lʓԿ\ߣ76;u>-\ΟG9IFJo|tnqWq.?׸c;i> S:?V&!T_/܄xOQ6LOωG2kZ.'ցgڞ}Jq:/VtοFԏ2uJW*9C?e E~n=fRg)k cϬ9Wc1VGL]oF'Җn?>`j4aB^2W;L | γÇ΋AL q/1?ˇz^ / Jc8b 'g2C?ys .G򏩏P[+V)uXnS O`Z2?WEޤ1N7"yL\hՂXgägV8ֵ_5[n4xeGTϚ N܁jտARk8y'ԗt?gQSS㊽>jމ)w@;w:x8jNf͟FhjV~i79KW]Qk-Ty8cq_?s?xal.m/z ֵ(oՌO׮?qb gSS?zJ>s_h?֌ >8)V<ΰh1!__OOa*~Cee ?r JRmegesq\Wpxs^KU}cZS?8-OLgϮީNsیPrş|E`?۠'Ov2?B=viPw?81}/0Sh?32];迂 #_{JXwKǴAvaG I4:`?xx{l9KYSsa<+H./;{YuW'{g&RT,p IjLJ'7A'Q$)!B?l0 S_蒭\< bs36z?[TNq.Ǹ̽c)kcG.Rby4f:&Om-'-5.#>"}VKZ?B^U? CO.5x-g=?Z5r!d6G*_KWW;J5Xڿ:m| gXh9NC+-U['9yZ|Juj(աD)?W6|x;G7?\ˇҊOFCor+]&Bg RD3UE׬ ?08X_GDauԭ'/կP|TOyܩmUw.>Ro2v__#s_IkLk7 Vgߐ J1 qW[zin_7ln?|~auߐ)5?y_5?6kO<A?M=I?z,U]wU8\4#SǽK.;K/|?2|\? 8ky wϨ?WNq:oF&&IgX rVO?='f:cY̟]G?篬CQ_D~RC?Ƨ8zr*62JQQQ.g-Mb-_EL{%|pfGGjL5URoYKϴu"TmȯrsdYtͪr9v6O(?%.t9) ]^9ʟN77| :LpʟMKUwtqk˟gόX3ᢇr0k13!x^{XVeƑ QMDDd |]y9fba5C晈8d 1 ZMSS!qa˳fy ~yWk}l_+ַo_HL5L'ž #!Oek)i^Y9 b~ZFq!~_p掖\yDˋ[,N]=vx0&j2cEE>zI_S=C7_7ߟnVh%U+;X+}cYVdY ȈkO3lU3EJiZZx\zdgʛZm=ik-}\1wAڊZΥ'=S?R|ӾQ4M=1}@Rm^YI}ohk%hek_%7^-,Wr1mݵڎC~ [6zsGG%\ޣϪe9Ѿf0}~UI<#NnZž~?ݷ]g4?o}f_t%V52m필r;ݟpWYzN}:s۽KMHt3IWe'6~Llj]ò7oS GПJyBL {7r~[S[sC6(RuwǏ6? / ~_'WUםW )mGf?)BzXMwI @߾Y5QO daeWH+4c^uo)i1BJ_BYKi':vLCG}Gfh"Bs;yqZ?5 fYst Lzϝw:GU/C |K0}C.v +Et3p&]'IS?;1Vl|bal]旅?!ﷄ/2p6NHlxTޚ(yq*?y3)̿`U 'd߾fsUwDk#K=w;]W9uc9?mC?l䟘6C}YO.!kf|O΅W*m'QIǏ]<4fbz RQ㫍E*g4GwG_I]׷~ +;'Wx1=Vz1/W>cqOZo[[ vZ ?p3JUE?XHo _dO缷b#2yJf BݧTG[HSӺfk͑?_Knu?=c?eu`^JCw hU3O& 2ҾҴ3̪Z-\'?Ѐk.\JӻWT.?GfCK)ak/#ZRaf'Ts+ΟvYtZLXGuFp0Gs9W9suTi?po\Ķt}!i-U"L??Xӟ/q5vWXK9Vm_~˛z?VQ?$1?2KeY=F=~i!o|j? ?8h2é&ba}?0/+222'ۂ:`g/~$-g5=SOӳӗw Jl՘O OxA=}̟<ȟoT|>wAW, ;EfL#뽍S3Tط?=4rZc_g$Ze_EW7_<_v矽HSy%,O`uwoi{ P/F oxW5)ӫU ?2 ?SES,ӱ]1c_zXt={7Bt[c qssxn+?iֲ~zL*7JaF/dAީ?Ӭc,je;n ǿ-ʜi;)jek-UgK![~ܻg{*pN7?i=w9dX-?;%,?oETW^zȟ4柘3gOOyYo+T 'ΖO^y'7WJK ?2%o[=yQw( ae,N٘3hAS5g !矎' JIc]?]cW_7~wn?2h1O*gs5\1ud7l߰/c6$Իh />ħwz #>m?{C܎=8}?Jy?+xm?TdK-?ғ+kzi=˽LXzPC}ok˥.ty_N︂6>Ε5 Ŏ s;b?/̳vga2{x~Kx8[3 E3jRa*_-g5rE m\>o_=ᒿyZL=_'u1/[\U@:;N?T'ǧ&fq)ik7v3RӘ?Sm3So-G"j"dd>OJsYV/V%K7vֲvV`o~)nпjEvG~gM;ZHu#X~`j SN~3ubm^>+?ω/'-kz՝#?IJfpQɕ6H05s֒/Exy)R}gYY"ʿq3ڋ}INg(b9udB6u`aOk,Gi/'%Xqё)amNuk߰2?eS}϶^fRW8 Ar{3o|uK _}eN2kXec?8 ֒kWs',NC95_GH#,ǫ oF9y߫O;x Wc6Glcv*gd>fc*?!S. 4=+'f?<{4n=?Xg(\AKq/f2OmV?Soܚ/Rm{?2C% 묂ɜ>p]i.n:G+{e)xDAφ, 81$yugֿGH &cq7Gn?4T͜ Ak?O}8l:6`kOfQג%_w-%ZK\?32NQŹ\KDnfO'Vam{Ƙ?, U,uf9HRM/Cgo:IA.?W]}\>!Om;"̟ցQԫYUsIOG?$1Xuǿl iB%_;G?eyc ~ = *gI=f}bHTr??zAW]g޵H 7,ZYoY_ܧfUoc<0/h̫qNI ]n}2@'ãń?׷K=D?BW:_/֬ma_ ~C}8a&ޣP͚(rM_aey+{z jV'X?S E:U_Hɺ߬ʼ<@i?e?kx 5K༆OS(sSݧQ 鳌{ w*8>ON1NOkiȩ2O9?219~g=Cq4J'[뿢\\>_=8OY!J꿏ד*;p FqcxC7z̟>קEWb -s99?>9I8+n̟߰p>DuH~ޜۻi1?=چzQOԿ8'LИ9/-K1?6uwa&W=vs{8 sǿdנ.63-ٍO=gMp.} uQr!ix+ƒ?gOrSmY W/_`Q4Fu?{voK5OGX߃?J|̑;xŹ]OtV3[%Oe:Rr2N9 fT6y|zu~3- [wGS=c,O1| j ݬv{׭ϋ.+Ñ f%x^Gjg>lp_ }K?S?.\Qik6w {Չ?䏳zR}z0?Sy:E/wn;ɭHt-eǡz:7nb9!k8#sIPR՚/3'΍=;_ld63+i᝗{ j;טg1Osq*OR)X{fKySӝ柎ZsKB `(oiRZv'5>8h3gŖ/?{y|'; Щ2τ)s12můx^y\T % EܢPB!/])%PJw-%HB +BB2sGqJ RjwE;;w1? 3~swJoU¡jG[+ԼiQ9Jo]ةoYM|PuH3gE?.TG=$Q= yU޵x'NVMNRw? [Ķ{7qx_vUv8u{ڴոCLjҮꞛyvL ;6/kꓴ_8}%5zC9:'5U((`p2<k}֣Z4{ǰN,8 V LM /c ǒ/Ǝsrù5=x~_e-j,f;8x'szgBkooG'n_g>I.vG ?>~oA['|w- jhJvoZ6`Onmǿ:~d`hv\<1ۏ/lm`Sy ^iWcp=\Zқr߭Ky͎wkCcFu:/cVrpLwi7[,6 ~MAO\HJ_=ωT_q&߇sfZB.Z_ M/1Lئh[<-?/kW7Od=1uCׁavdž/S%s><@S)L--{}OkZ}Ff_?@/=½.'y,#AiK\?~ﱣVտ{9ft·Օv$8e=CBqU??W]"$]aȴ{c3ra F^6М-| KyϮnT꿠s9++q:늿?[嶓?ֹm[QE^Lo_5$QLGDŽjsAinfGןULav 5=gpʿ (A?cWO:AuS [h}E_2y_)иroQ& f( u:wN?Z`-?Y6og\_jCWߍ?x #sbX%CB]H׋?Ō+F64I{g6P(0)~*<E[ʐ_׶L[:2В)+nE(?$/ ˡTBG& /']٭>힕=l+Ї 9ߓW'̇P#?)3_1_柬-:$:lfsYsZ/3'ɂep7%ֆKhE]&nWPCJ<CBqp s:ȟG>1S"~oA^ot%+ɔKsصӣHEgFgAudX$,L;e1㏯ECƿȄǗFW}DZ?U+ڸ~3YxA%9c}ίpB0?{ˆW@7lGt_FAXso_Oڴ<9TG?jTXhaFE6G+3B):GԎ!f>w䟾ڋ#EQq2~d J?3j"wtۯub?4s?x#_|nL/O-`}oP_vtA}RoY^s?Eeb .-__y rj I⠏gbxV1ebO!SIfx2̟Z nWăo~־#0?;{56)-πv/g<(Bm #Æ3 ң:\?v|t?˂?xU?1%JKK3'*_F'F6c?7>0/j' {˸?_ œITÏR1ϱ/9H;un9ZoW J ֽ_;}?|uM`CfeGcAOG]5y{Zse6)*{+} |{ow&$Eq2Os!'&{+;:c^kJ~F߱Y;ǚ`?%_ ˂?tgI_Ft])Pn#EMgn\?oe7>)տk2 /?sYF#B.|Y_OSR!УL S l9eV4_w?Mv!{ş!sqi'o߭^}X?b5!3<~_}n>̐?!ؓ!¡96<33< zR_Ӧ}͎=*׳G_?WWW Ԯ (+#7ڸ2\1J7lf3{N=?L"p}dXMO&MYz-r,,=fnk?-eȟh?pp<6g_迍zkő 8Gݖ#\ύ3WF_;.LoX,{0p+⇬֭mz@W 짫o+#5i/ۂJ&_' @wǦ')ꇬ8?40cRSz dY4n`Pn+:u݉@nS/FZ6m+RMb h__?Dt_JohwPk`zW8xM֎ω9Zp,};?8ϟ%B~A5-\`,_-xZ_ټwG~RV?~ ~N_^4+o_>!'Ol= \Bzm?f(?2wp˺ [J(hwXJ9;3*gvW-6.?v_ؙpG󇿘ΟYC_׊&?&sqq-~A]$!s9/$'6L?\8?O}G383x-}o8f=YVדe)9J?q;?{_\@n<"fR8wlJO/p{)RM_#\Q f2?3!; ?x ߛ'OÚ0쥜OpSBN]/n"w&ao6rƍϿ[G\Ai(lF2I?c[jk-0mŐyco2\g>`-3nm* ,>pQK)а=Yدi.Z3Çkoߍ~h2_5U\mCB?qb8>7}\r'Ǖٜ?? vgQ'nϡs|hʟ8;x}lYz&'?vt$?܂Ti{SU[/x3ɫAtL|ϑL Myya8g7OJɿ" ڨ {'TmuKss MW?JwML?Euq `87o)x~Huܣ@qݳG,XIwIJđ?'{ԩ+ZqߒdŘ?!綸|p֧[ /f h-xY~"gWPޱ~y-)U66_= 6(+R#Ri jQJVo8_mI7[:F??jO@;?YyUl%o8&?Ln^4{s OE#j(lNV|?bq7וIz|#O'QsIp\?O6Eol}mp/sg_m^RzDd`W2bGsic?aoo<8kivJpv:]?5zzDmS7DPx]N񅭺U{]#s䟾_8v{r/h)5oK?cpi6hWنݓ=9>*jՌ??70m!bOf+x՗4h 'yG Mҿ2*no,㵳xg_!ul6O3}il3_reZe_Lx̟Fw&f髧n KR?mzR;o/oޓZjί)JZRJϲu{骁#J"/>q?'C p"o_!lK?zDŽ iƒ=b5Zv'gF ^ U:aȿGun<ӧn7|嬨?ߔgiӒtsv`%8NnK?>#Cv+Qq^Bn-eɐ~E(h8qKs빂g_˦0'lxߑ?9ONqkfQ18g?j3f#DŽ(oS/Ʀ7mߒ ]L˽ut3)@vn^m3O|:qm7mZ $8_P{㳬#bapŕ?-1׿/U 8O|-Oxc^-aT} /%|3VQa'gbMo _%fa!!שۼXaSaZ,!/-½,gUe*9&J?dqZDŽ*QCYcg?o5C-F =yw0g/Yzg2o׽et$BG%w??z: ^3?=!,x^ Xن)"#""MȾoM1R7$1{HHRJS)MbVi"")f YHLVf3H)OCn\̙sH\$᜙{yIh,Mii?8@ק5r7D[x~64Pyvamڀk!^6ߠg S5Y_:HɊ'>G*!7Z|=e|[;{ZgĢ mLHu^ue|AlHbꟵZj,#zOҾ:}8T!-蠶}/>W{A;9[-]mOZRD}ϗuGuz妧uÔ}WF=_DOwxie^G;.<#jJYyzJAu _ 6_j _u#j?n$˷^H/hsY޶V%wd+z'??0Ict "4EkGߙnϞ7hYsr>Ř/?ћcy^ Ԛ3(#Ov_3 K뛣uwΰ?]Ry5}_l߽A J֞?gyie}|N[[ޟ{gGЬae:L{]=Ӗ:XY>]CR4 Ylɿ_+š(*7oQe]ݗh-?mi:?mQڶ)JC}:}²Ҕq9Fd1:iݹJZKm)s6TDkI/e@:le>]ϼzpMO~Ck'j>RoP?}Hk}ޤg}2x_WPԅ?O~@' ?O+ u i!y#=ԨwE.̟#'s==IUv57F}ֿGQ_ %kEySX񬇕MVIݪʹR^Z!IAџ.x)IU7݊Ym?;(u5ژA<:ORx6baDʪ k z*@7_6ݣ4W?е&#G{_ϴbfJ;zE'F#ĩA̞ A[v6K#O 9Z-m<ƛ#ZǚW5X̔٫H9??@D%zVN9=ߨޕ==ǘb6!_ɿO {U3; G-\ϴ;OW+7u5[V'duWgmTߔH?V-aS7Wk*}_[3UuiUDV{sOOCB~ e(5m?[O,W T;jQH zoPJ?f_7P;V܋zϔ,םc5h!mt=DBCW{Tgaf̟<r__u#x/V>gwCGJt3wzuqξ?mC? ?ݯBSwf֎˱Aw]y<6^!j֊'-۱& f:Zɚ3O ƆV?sxu@&h_LjPWAPr;r,fCŞUarJD/\sU?UGԊRe OU/#9nc)g|??=wh=̟fnm`_ ]7OD9uPoaov;O^ϗkguwb?h85oUщ\?fgN[4dh]6+~dQGLcwW\TPp{ZE/o,5 }˘kkb O#w~?Ü\<n{ʀOr#)cI;~>PwC+TkM]S_ޟwxP8/spBߚ? !06J$O* ;qnR?}7_󚶰Iל?G"9˟r?s@qg[V0_F5G{\EMKNnEdiw\pWrۢxo6?w9Rooae=TM?~WKc/!}ߘ۳Ϡ\RC>`?/Ho#Psyg|#g'{6'_7?n>7xv甗j?81s0RtM6\3c?7s?VL+oiK {X׺-c'4?۔\}1Yh䟝#}hɇ5^Ϙ<ˍ/.;2@v#$ɱ{W?ǻWg zW}&p9:݊gڋR?5C!5CNq=U=ɿ[C;Xi}AsbOe*u ;ps 4 KYk4'?1A?Q5 uȟg^?c87 /~aUS/oppG6_][%znbj cnെǼKu]xU=z?݆##J?/_Ŭ'Q xix+rV8iZwCcW3c^}?uK5So}ydl c!}+k]ߤ:̡,Z_ ZS O?]2gWR?. {,;kP۬n~SwKiK7^2)\lWoԎ;uQoAbԉݖUhߨWCφrl1}8v5{+ҁ$A^YKco^]Ia؇>vc^?;ūVrӣjJySO0jk?-6_sﷃ`1h`8F`oVPvt?[+L3}./?6[\? YLkd7GGu;[1JGgU_Ao)K5[5?s'"ߪ4 ??:;=Ӷ*1ׅWp&ߓq ʕ۾+̽ ; |`З?Fqa2ݶJ`Sg'~w_?nZO}?q̟j/ϰȇ&4>dEõH6; nɟv,As*7OifwR]_ʅ\;\ g^XD:_tϼh;h4<& {~iI?Oq:) l ubě%>+睍&nbqET4?X?U_[e{,rIg6 гSر=b͟g~~f?]OigZ=S[?j5'z?CA8K=98ӣ:\Վ bSGT+[4;軕qϟ8ħ~i9*×n{sl|ܱWg7Zw7Y.(w9{{q^'s3p3qy/x?og-w?Ki r^u2;qR6FUG)4 QEv8]%?+/_<ɟgfW_78CΑ>H/1acò(W?q Ӓ?21dz/xiкQ(Wm_kK_~HGk#/̻]WkO}8Kg?]3aFO3Ȓ?,߿F-qy[Ϳ,fjt~?sxQ%Ԋ+S~u 3S]y1+( `Qݹ>g|w0[οfXw~#cz IZŽ7:[t᥋~dE5gVO4,gVE_g qFǪ/w8cr 8?)-G?q#j 3w?)=ŽN?vN5V?8t3ϿЅNO_ISy,8ޒrϲ!5kޑ9OӼ"rND (I0ĘrV[CD0^KA3k-}_hzR=JʭOʹ\.]ςم "W1 ,$j[q#=exნSAꥷTs{aj+Lظn-nîDNWʂJP3:5=1?=b<ʖ aj%e&(F,NZ;J0PuIռT)+QwX?)ʍ ڰ^#Ů-{@ yR)Jr4ճx4wL>%)k3\~kλjgK"e*ǽnܰA|72-kbK/Y| fB\AΝTx 2&Y Jaz*Q]- >yBH͵CEY͗QmH3&GP7%_2wz8|q4Ol[{Vng_ 7V A?e;6߰$gz-E?.2*cZr^s^S+3 d|ޗ} (ʌ ډqq'Yq]҆zlffPP/>kφ%0C>[OץhraGyb3#?AlH`_25^ 3꼳,Nc1GϬ]]T %F1.Y?%LHC]V-;2HAV6:g2e۩\W׎C& LINFJӏ3ئC?}brI_H_lk#1%?ἕwvFs1oT,?!icV\-!eg5ߓS޲FQ~wLH9ߑ r3k1w_#:?!9|NX}3 a!o|N>i\G͟ZNހZcݧ/Xn02}&+$_ 1s#\k&RZV?)Ê"+kOQя8{TAkG`9h?/ȟg{OfvH=}?Ԝ n']۝?#'-]G^7oZm5-~~/? r#cr#3_!gAG+egj%<:&7,WXB\4?\׵sbaQoԿ;BZ=.9:+#4,3> kro^'l<D|9/ȾҙS zlp֯H?xt'ꉚ85<6^dc7?:Ds+=F/n,ϕߑ٨S1 gnQ M: KeDϳw?8n/p% ~FW_[i??@B'eg/Q,x🡱gG̲O}=jN/K@@ǽsw ǹ,x_8w=ckiIF։顿^Kƹ=7x3d矙e۳rG?{&*ll/ԆB}Ak}ZJ^+}hTMIL?gn_Ppo@?IYVgc7I 3@l_?)CjJ7ϦYlϮoXrn'?VkGt57!6s_1GsCv>mhI/#[~Fe (? =-pv"sFJqˊYI[c#4?"WbZNvu?jۅRO7t6 ycm/#Ȭ_9͟kz4kT o#?0ڂg60?_c:OڞsxB_#⣢&WEOz)AA7x? NK\q+럣x$o,;v8-.E# t <`?Fo:.8殎_?tߠN3/i22x -V/K+ޏqZK?mxP#;(1#l+aoi4@y"nWCߞ/Ln̞2d&?ea?(([Qȿwl?234?m?j/Rf˧N=WoucԿv02}?r8N!3ol7_"4k#Bra G[dK{۝ukˮz_3g? ٨?l -^ix[\?KZIǸxc4-eN^i*YW&) ?o p$ٞ#bςǮ_NwrXW%H 1~dȐ?g&W Boϐ?w@Uu$G`݋'i ?=? -;Ͽw8Ȑ4|1HfP7U}~@7zm?Wf/oܑC];uBƦA~Fx*[W}UY5dsgH?Ab pI>6;to?_.j{dO❂u ]}S_ο槴<7a? 0y\7]s#nxe?Kx?Y6SuOGe[i_?rLOl{</rC1At92=}COAF?G{3mKX}ʟX=Re4^:!? W?_~6&XYIK?bt7'=AA"h?]A&l6[1t?XqI8XgE }$?x̨p .\[SwwNyVOxvG<]c풟ed?E8 TxBkQx!ȿe_(D}?2y?V?)S`?r-A q)kZܪW>N9+ g@${Y iӗΣ9bq_`wY}*^L{OrH pd 9S%],:YXx˖O$>oP:;Ye"v38s[_<&\#!{#$=-17Z1В8rm_-atR %>8'eZ8?1X8@nDrs_Ll&i-r/7u>4:w= pD1HB%:9UeQp kI ek?0p|-> [5$,?ӲBW)6;%r?=7w%z7,f@ G'=[G_5rIIԿ-#V>f#?r> D-rn,Kv]On!{3E8 90 D׍ 8G>1N] K\KFWm3=5GMg4A@Ӛ=ȳ߿0Aj?\zq?䟻ʉ q dq:ΣȶȎuM-"Z3\VihIPX'3yY@?AMg ~So3GP5]6@ҿD?f\ᜏYnB}B;„pׇ<<?>cko|5A=hs7 CӞ[9B>L3\2nu#_3y3(<WI[z ^\DK5T? ;`3}\s*nrޙ]ll4%"cSBQwa'%ebA<$%oJ; O@2o=O9Vݱ6ݕr%& 珌}ޠG6Jgh/KxNH8UVQ[}J t}_9P[ T߬!=xJ~AQ /pE8@ p^MdG#Na\W(/ȿmLCa(_8󇶉[Aiy#Z7 ok?UUeV_?E9t?Zʛ?fiPVWsL֣IC9SUOPYJڢO8}s/j?!vQ]j5[??zג,<Qe m0?&#_D@2"=GQk!9|a($f0e#o&kOS$=}W@_L+d!=8΅||FL1NWpzIVWtfϴ&Txe(#) Aߓ,W^Yȿ=pǯoߤIvcCi/&)Y 4lJK%WW@ {߱xp𼹺3r?,Mx;Bu3.a'1ԟbF{"7*m=(𜝔Oن33;SUO z9b}>Zj^[=uv=fO0@Ŵ\孋.5̐]Y;8~)ǩTh\X=#_?2Ph >BEv=L)ϼ|\y3SL)?0Eft6MzU]5m=zLKW\`k^`יOho6%o_=Gkkz6358F%ٜpyP5G3GQ[I=h_,ɑޥB2R֮)H  α!\ا!\SzAך+c]AwiCG+\u?d_Baɟj!d?ܲtWk- ?3q}+P=N |addbx5Aȼ/rAKD7`,YG?;ֹR2t?s+Ci3_N~.6Or?ŴG;EP_oVf)n#.?3'U6sC6mJ3 #翘lW#O,põL^]k݉?X[;uzϱpCus7eil3uM}F=fN, vj@qn=:,TWOp/c6tJkq}}֦ɟ؃Y?q&Lkyo9XsE`˿t0?}#?|fZ5ot'c|_/;,ٛ&|N5hb[O=u yQw{X^>Bϓ4?Ep]VoMwɁidc>5_c jr]@3dBbH'FzNW q =6y}2w[GSoIzgb-Q/ҥF[!'P^*"p]|Ʌ1˙4gǺOH.|ຑ2~%zHO83w}r{tiA?Q?vfMӬS."V?i:ku_\/P!eSmQג}{OQ~Hg~Mg ?xcK_Y}sO>c &w{}¾_&'4m&g2'}F(VO?g5q=)AlɼrV6qz%[wp=1NnqM'8{b1hX?]jeqaJ1}28 c>^?' 澣0Ώqҥ#߇B*wq2Ț/Fwg>kkI9?F93w\SF#9TTSYAOry\97|-1g Sߥ ~ly9f?? ?$Mg?S8&OhZ?;ft8^b-F?hu/9ɕ`?gj?SR9*=^ U_zv95~l7M !=džvĿ==g>💑錿5k\ ¿_afgmC_H[i7.ܦ? c|wJҔ^֣jKMr1K 5Ϛ0n \'?ŽuQT_zJY({1q/ې'͟ƿ R9G~e{&?7|ߡOخνr1y`A,/]7nT7Ց! ᬋd쯄<*='=u)߿;t{&,$%ϸ[Hע#'oЭ.%U_6EEǑ?hrFj8D{k_Y?,&~lyy0d5<pxNg^xiv둫4; _j?ϡۿ#9N_οA6ukG?UcSUѺâƱSSQ+AiӾ7 `$g/?ę/oJl*g%TۑfOC W+FWO_1 I"SclV:?}|ޏZӿKFo*zS\P?`GڇmV3sTql3a K;?<_=ϟ 60*㻑j9E{zQ r͸X?g^R_ ZL\m%w֣ϡ_zHmf.? A'GxaL_{Yxf?_FUp_(Cӎ^p?d+wYϙZ#sWixG6@ʒ۽V|'ǽ=Kgbw /3#;,|WzK~si?u?U iı! @?"l ߎ r/h!pY?LYnFkhS:[G] f^kODjpx^y\UDZMDL0GDaD{}0 ld ""bBbvP 2tUb YD]ʪ?0i;ޞՉr2JCtVd>t|_$T}'lI$wc$7TS\d>=ƒQuK]%ӥ<;H&{X|%rmJ䎧<+wYϤJ@@=Pt~zW$~Pyy6٣[+ܕ~o|zB<ݾK>L.JLJ6.p4Q%S?Vq<8;0.qV/ucߕِMrWZ*I~݃>dž ϋ=&7 UҷKrڠe*=$g^?,'cNސj?WuS&U˳*PzFN;״n\kiˇEE=tLx!Fw;#;?MrX>~>u_ؖK| kj/鱚u n@~tL.3~o)C_ 3X`eKe)c}A<>E"TvdOpd;/ ;O:,!G=pqqַSyR򱏅g'2̤4Fp{ 'l\t>^;S<ݎD'jtE7'G"2#Tkw'c;{4dvն?D5 C]5 *'2 ?&3!/v勛rG,5M?wQ鿼b(}sDnx]_S{̑.ŗm{pGU?}t<5ّa?* UbDZ\Ced?/o~[Z1{+ܲzI5A;w Քʙ^b%~Uf^ o.ʿ*!sՍM?uՁ{~lqB c8WaG/Ci[y)Ǧv'R|+6=5mx}d;T0Er:eoۅjmt=IrVkXP(wv%ryi1V<ԙm}NJ.W/*-WGbZM?}} 7sͥ,G7=,K)P8"g:KzB- yp߆ߒ7S gߚ s gz'z ^R_`{%:PUwKiR._?kSbjg,Ols9[-'k_Z_"~r#Lj?u_ʸfxWnAOs,?ܭOZlIofe{") ud VI/vxXO<6[Ruq虰|Ա-]! {bX ؇n2}6Ձ }mOl?[W?z=S {6~2U(Xio`l EOVOdֻ6y)TnLIdr"C]0l~gC 3=pcpύfvۚQD%M^G+ۇ!0Q)C8mӻ*<[vP'u{qI'5?*nYOl aD΋5ǨW^skw3( ,)wZZD}6d_֫Vtx9{XjycfeO/o"DI~R˟#?_3𧾇j U _0M cWO}6N[0e| })UF8=㽥%}TGlCqGUֹ }6af grS}{uS_:[Bk LWݡL[g7tEtj&~uLcuˋ$mE)Vwv$9 M?_#=->9 _+/;HVǕ}*]_$g,zDVTv`"CZ,?] 7)zW1Z vae)CpI͚P`[u%ky ;^p&c:Kf;9d+'*eSW3*\6:z"뵅Bj{WoYt60a?7zYnEߞI> /:+-]?֑yL5HZ 3'GY.udgUN{Oې[qS..LZ/墨ELa?}MZwO=UZQJTCyMwMzn߬،#Y(lwoM?oE 7hcuٱ6Hlhkl~{?ogJ*6KZM{fR''e,Srn9@?^S~Z ~?U!b08򴘿1B/_ή_K3{tg \+:9OQP';u2_Ҧm]X)?5%)S#OpW\OP/a.[gIb_]gݘoLyYJ߽@ƬZ˾;~b@|ꛞ?_St稺X|ҹ?z3<`c؝N3 tB...s =J wxgam@%.k[6P3E;3bZx?6Y"?;DEG|.Z?|ӪR?zkX3w:k3_T+ģG\UD>ΰ>o`;;.R]nZ9?ޤgdz!C^?tIZCT1YȪc3{hz#  SkiAEvp.k=o?W9cI>6F5 7M'?ԱMCUf?qfVGOL2BU%G?:<+GTU)"\;_!_hzb-O.m#i#n xX:Ciݺ+uzJxȖCeB t_oo  ʟ 3;1cvS4}/b˅KÞ? -o?e7ΜPs蹋{Dlmqqo:HҸi*؝a!XƔP=aU7/l[ai~`m?; LI`891źSh}QV\s!oGqeE" ܓgB!gj}qF̟u3ֺ 3wYXVTwrXU!=[9{.]yX E]㞫g?15?Oّ^`?k*u!3o1 2-]>s%r Q_ymɟ'',#=**gM^`~K K27,c&2#b0;xcaʟz;ց*e6Zjȟ{D+㓼N}Q3\=O|iHs}?U9vМQ?a?oO22Z}=,vc USϴYTO=78;?١8̟fiYAyߪ6e3m\h5ʿsUN_Nc}R|QO~.Oru䙗tp~kF[ ϧeo/GwOS[Y e+[oOrcmYOϟ8K3F\ag악{~pf!;4\uV8-~W(_dhn|>1C*?g #]sћs)3Od7㬇9}f-қz3lvJϤf4?JM`bB,?7nwwl8ْ Gt]!<2-%R|9GIjQ,;N6-țk{2g:KOB._{1%Qw8ULEL?_MfLؐgx[ҏ A#v/l;ΝS󟵳Io?${'Wƅ}qXjkB4/vvmrIm`_:ڃ,Ws{c9N]φʟ溃#'?"PdMglsUjϢhsp+vz Yu}STc(ȼf>ow*4G7dqù2sʿ,?z?)?zGφܢ>r*}˛v'%NzKلGc?ն]oyݖH}VN"jWA#JOJS=KHd۠ ;S?(_8݂n5zT7$-%.q.sѡHY&/ܦKǏ*:hys)lq 2;剰BF>qv30;+GCYqɷ%C5iw 7״/ /sb&q^ zJx^y\Ve"$wqsDT҈%}GHs%cC"1 1|}w.Og{x?p|uX"='5+Y4)mb7.4rZ!?_ -%{\}D IIۚ6Uzwna&$K.u4zcz~O-Ӊ$=)HZ;5Ќl/2rJ?XW73,?܋qf?>Ö/]%|`-e 1[N%C7:ѽ-xlĿcvn{Q;3lh2s=Uw>ܼu wn#O.M`Jvۛ9Ƈ 7mВ0+>DÐωɲ-dZ;V*i"v{}Kpn9Gϊ,O:ŐghsN3  5#>[\O_0ojF\ClF |:5{CvHq "ˬh J)O^5yXyygIL̒AlY`SƁ9B3DAO_C+eI3/odGG/ qJdu4+Oք4mvVZ\a#߇E*3??<7Xv)b-KXF>dN' &TnVTo=>DG;&7`HCgp%&`in)%Jc0? [zs%OOz`_?Zg^~DkOQf%3"(?TjOkoMᄇ4߭ U:- G@19~Og\4<guIdX n^)_kfy'gw:KK[lo#}O->:FX5rbZ*fٻ$!K@S,a#;9#ٜN A c|aq(8LL8xnr&y ̯AmڃmlM]?kǞg+ HVfW#dO[??v9}kry ^=P4F =cg/QRR _?!qߩO_9ܙl [;/]sOZ!ѩ*!{>Gt"Pus /8Ȋglj+PӠQN/mlDa'uM" 7qyenȡ D?Ä~3yD27B}'6$~*\?6_bIgYùAXw+ JýJyiuON&xYg)~rB31!?E0+e7lh~&4>k2вhr : =K=7?'˼܅i5?>w)D:o祈6,ᑎC?׆?"gm矜?;Ab:}j[\fZKty r ǐظJ? 7N|C7S6_40ro*ϊo#*gxMʟ'&ȿc3ul{Վ<")_8SUEP!W?iQNt;L8pAX\5[.kJp\Bt-_'_;5㾉Qπת/m-,, =VJ<'Yy:҂u/FwK;e8_)CGdž?d yR%6=KG:[mW(<d5Rտ"x!,`Z_mgoq ٔ?36!U5;%EǬ7q?g_{qi |3nK_"D;{bqޛ_QNvW-OEhun$v#{Je?^߃od]< l&cHeAe m#^x_!>%QǽS{J5?RfkB7,;{*ߌ\SoJ*ؔ r:oE^G#F3|GD3"JO_=s_ep?F{84-W_CCP0ks)(`S;ÜˠZS]_^uazʟE{و"_p6?F?#^@TT;VTo=ޡτg3/p6ovLf"~E?$Qb߄IgX-}wrsVh|f ?#ʟD[qo'bg& |J&ų]!~J񌃖?X3r6 UBݑ_1cu?J?aͺא'X *jѰj?}Ei?QaJE5EPz?t?9[ ڈm_K?oL?bF3T-9C|sCJ#W\':3L/qUkc<?x;_#\?o.Enɭ/u@6M5?M?ցoCca+;?g;xA=埘m]%?z{K}jv?[̟Ag/d?#kxĿ%G6ة/;JDO Zf? n&߱ev_>?σjjRt;5ɓwŔ3[şOoV`=k?愪y pΠک_ gf ^4PDNV{)>Yȿ::"Uo=vc~Yҿ?~k۰ 'N("En9͊@>Iﳚ}WAV+dzJ?O*q|̀ :{v+/{c20ܷ27:Gƨ'_NgPUP߆篔z?Z H']u oOCLU/A{5Ql''[HiOX/9+۪O:3%6F)]waax;tRäJ8gY*V?1GU?A#ӧN %|%taqh+֯kkT[v(sC?bq)k9Iim?Iɫh[FΟ)ׄ:CM}c-Y!x}=x5z3ɸyZZ1;s-?iJ}`-ܣ¾Ϋ^I⃥EPCFWUm \_?䩠Arf?|gzU/ ۆͥn"}ҟ=GF w]5gt"XFL5k\]K!υ\zRqOh!SnNPvCT_ ǚc?|C/ltN'PkGD:{K >aެj<|j(}.r(wZ5x^y\Uu  2wT"GD |`9D CLn.?#bH\pA#$b#n/1 ?~Z*n30.[!}<~OttZ7EZ%H׆IBӨ\K-7ߥ7Ikf)>|BQuLJ#i>>}RZɱ"ϮedžC_[ߒUvvCקXn{2,T_/] eq&ms\SӺYV;.Hz ](>m-2+vrsuo;oܾI*Iv .k{sMLgJۆZ|\{/n`Sk(rچ~\},g*5=8萗|cCp|O@e){D-ۗo=}ôOl:)szתg {2?uԿ"\S6K[ۍdÂ'AIw% CJ?>좜pkWiO2YE;Iz^Xj[XΕX*ߊׅb,dd/>jS3Un p>}eYGTًJ/MbZϛl3%<gAc>m;]C~oFRa*^E뾷#uP_ '0?8MɌ/~/ _iϹRʹeXCץLm,FOu!oR9WZGv-U 0- L?gVJGKZ.g%qta/ MǟR.%PS>"F7?KL6eF,њ'ُ'&k׎sC.P[wer5 p=9Fx-pvl CGORǵ!g"gCⵢ~3s^lBp&v0q_"@?6r˫ѻHnp(Nd~;#oܤҿ-GTج͔?20,Y2:͕̒2C^?#ߡ;%矊DIYAڠWߌoegrNȈn/Z5!Od9I]o|_%LUz-Ь _q-?;Ӏe1C韑Y ># \ s.gKz{wG\3(_?ϖ66"OΕe*kJf M\ ?NlMbS\6UxE??E@o埮OhSO~卲e *oˌxF7}E  5.y??EDd/ᙥ"+?E~#*ǠA?J??p_Sڲbߧk0!&Vz3o ֥,%)Ua_xKt]:S}o7ω]dJomrKGs; U6zPA3v[R.xo:7e;C co2#:j̐?DC3_TrcoR}t| J?Fm m?έ??]n7R>٢Sِ֓I/[3.=e|3dAo%D#)'NMU/u̝/‰T󟽃\45/D5/Ǻ"s8':2۹2}`5?Ywǒ[[(MuB&xe_$!_,t͌Ż5aAWd5o/H>YponiKT9:=d&eʟB)iEaMH˿#Ia.a\g4/:D"';bî.s7_MCMЍG?wdžOﮚ^]V?1<_%#żv Msu?Ԧlcn?%Z+*:Mdz^` y; 7e}`-昄#3 wVX' Im3okM̝lyޛ{Xרּ<x5ɓ]хTsC'3ApiPڨ`䬧DSy#k'g_NZ-j矱Qω3q5 S''Pl~t7/&&-'ͧaG?cwߡd⏬?TF?r8za$]O_Qoh% +^r)7X-_?K=G~q+O?9gamV_Z\54?J5_i GxbY'G{'v?+cv0#"G#\fTdsn_;ukx/ xoqnOߑoU_WWG)! M&(>*[~uEuOḶG&ϹKqcmYP?0p.?i_DLh\j)7u[;"bN@VgLK]>nQO0{rA)=MX孇#vl8޵QJ[K WKOAW?2/pd8/ ?̰ue p/6?Ym[BpworO;E#uWɿ]_KlS(ިA3WPY?x|N]_J?J>P#?^[z'ܟzG=$*I4r?fV%쩑ϳv?>ޣþXt3\_WLCO+o 5eU\G{+UUcKl*gx}?k]~)M5IL8omh,}cjí?&oG<HB&x^:^_7@uXpLo>gk"xc6C6Q_}E׉rlmKp?vm5%r */d@3fpyٔp~byz3rW(t5/=]2eIMZW1K0?R 梛qg? ׏8ԟ6zt_DY9Rݐx/_PZIXP#Z δ ϋB&=}Z:CCl,666?=3lX,9gT:Xc/ϟޝ yR_?y'?<ה?/ƛ "a|@Gm#_]j؃/q_ 8 F|Yu&Y?OK^D7]τ!rI? =%#J:~H?|mjuG~dOg?o6ѱ C_F=Y)g譪o[Af8?uEs@Gg_ /jcU -Cb%\vglG=|?d ;ߌ,/QJ_Y?9fR^ /W\E?0C*o=4?I9 Խu{I9"ַ:S<4dz*g0sExP )_ P~HG)#'yA{+ /A5svͥ'e)hS*?~WO?kf?J ]³4\f_Ru3SsJ>aC@OvlpuiuLhq;@HZC',a}b ̽Zo<5?FHTh?)L;Hl3BLymNj S"{ Μ+"s/n`#ٯC;߼,'ER*[\]e?@d?.{_PO\闺Ur\=_78=dYͨ_o_z m #[[X*fw:5w/S^ ߧ.XߍsϨoO1qiJ2 3cҤxx%<3<(^`;gt߿K^{,۬0p.WT UF1s8DH'3z}j@? X?u \x^y\Uu qȈܷTp?ra"#RQqɍt!Kh110ď Ktιj3*1}>|^0ʝU]-#Gq:8%uXsqi޼Hymԙ?&}s>u%Vz'JK/ԦRWGYH;Y]Wtk+uOza;}o>5LE&Dd-|2,&S=kDUokdkVo66mڻl@?̖4QP2RyVct]t=jE}_3|CU׬nށ {آ*ֳ$l]{VYzͷX*ez-k?ۈ3 ;5a^Vsz_#7=l}{d[O~CoomylWA#g$N?8ozaw~~:~Xw [n {&= fWHD-;vE5'%߫g?aa#gTo6<Ǿa.2m{е  $FU?$$U*ZŦ_~bk?l]נu ̺.,ncstzu5;-lor it73 ?ft^OaY"$ -T feͷڤ_q fo9X߅ߏHȗg _kZ6 Ԥ#(9Elf{*x[Yd wguO_NgB%fU*EAà7wk`xBz/Wϋ,KͪՇ>?Nߤ㹢+;\SZNJiksT<3v ; S:1ƅr=GI/EHczT?6lg+qb%#{ =q,) e}bl=ɍ7_tLȇ~ @o[vDžT$ plws x;ҿ<п[ʱދ 9'>G:t=̑8<{6GTNlYbz{%_4bx*n sxwXg}%?߫4I7v 2_w(=J YŤQ7Ŷu{/dd=Y%7+'1GYS9fR)?؜&u mgb$L?3@O lXN?{;ZWzϡHg7эV?*onlTxE qO`ȕKSMS_1}Mʛn>K;1?\$VٚG K݇ ,+_,zϳ'SLnbn+G?)2 y5n{J,҇ȟzYڟ:YSaw=ɍo/hQϲj$ssM&_oƭkrWP aWwWm0F, [f?|'ypCܹ_-&qz6%1cCf[|> ?\3"Ki7jN*Kګ9'T}/YC?cnRͬuk9,St]Z˱ FTz9.! Z1迳JK=w?;-d'l]s9o˒z]o_xVk)wO-ށoҳͷZ/ybϟs;Q|S?+*ݾ?eJFxKQ7dUT(?o,5O W^^|4w}1N_e&P%# gO?nB$!t z;#EW3P?_e] WhC9['7> ߡƎ^P& (LҖ딟OT?eܤ|iIy筣ά?cן !'a5R_O]G6Զi5ꗒB.rt êFWq^e#l@s=ށeXn.}zDLhzV?_cQ?V[П?$4yQ,>\,r>GbYOC4o[ae\finztEf8_ܭ ?U?rg2k ytϩ ޯQߨ ~'zv^?P!I__i{9JȟP< ouGA LVGl]ahOZaz!B#˟9 _2u?w1wZ{fI,ŦM{zK埃>p$̏?%#_5mk߱?aW#)oS6RO;h/Qagȟ[C w-/s ?pG.zO_ON)-)-JA?V?1_fZ=;K__~i &%쪢?ӷ]䟓{J^ʅ˘?s7b:DrN l?W#/,c/S`W3} Pϔp.!Oi:*oa8] =o7ߣxwOv2蟮Q߶zCv\znRf{-$ Zנub~աq~m?BΟ%}b;u=A\Kϟt/..=+"ۨ'roux?w8gR#ʿeG"I:`>S?H3o+_mk|D2~sk.\/'lW =rE6'tYLl؜rqi7ڀ8%̹}c˜_ LoU5?Q'.,oSi)/>#ӛrjiv󇬎 ѐWTM-Oލz- yfI,˓?<{ ek/yzPWi_3kgӦMo%9?W~_G'>[OƮCV8^#QSIym2+Y_(w4ړlw)=güZb׺g rnRYh?vhzoeMGr>JYCw?_}h42ԉz!FQq#Yr7m?).zdY_?+8_%0㡺&}_9)sw?kj oWW'qgolb՜?__Oh$_/mRؿˡl_;.}D}'S]Zߌg헆˖YCPqB3>cf;B́֍?H]:L_q{z?z8oOۿ8 3AxOɟ|NYHHļ5+Fssx_dsk ٦ EvR۵{i{pNOzUyr=Qpcq?oƟ_#?|Oz..?P6ށ?ѳ7^˟[N*7=]"xjɿlr E/h_3#~` j)cC][R?2cHo,bqw+|Z}(]6o>.?13gڴmsh|pW雟gNJ6~瀦15^zgc  \E?H oܺH2I#QoN9L=Ԕ+5QMq Sʹ_|Zyz*a؃4#oQ$ug]ܤNb(džRRzNv88}#^˟onXY=gGl' '˒Bӕ]/q?hGkKӿ^Q_eOgK. {ff\O[%q33?\B06 2{?^q]Ow\z*OJ!- K>XxYIWz\#K:nG& mMûsE|K<5%QAsYgm(UsBh%3T=ܴi8ؖ S}U{F _=kzM}3J910ג {?sk#xݓ1 Cj_~+_L;qNiLؑcJÄ*i+1QC̟*kj#S߂q?*8=?_O\3`^.(s ^g :ڿkb~=K-VɇPVtNu6wGynR} d?"ROʡ9vї^`u=zq6VB?}^0n0Na9U#ǵ!mSׇf/3Od1jΓ 1K#8'lmI9J龉CJ(g`ICذnT{fiv!OLȘsDOG䤌{Fn~?;ﴅ CO_юD?4L5ܤ z;v?1,ΥM8m[ۭƈW_q^ɳ,񑨿^T_rƟ28 osb?_ԳyF1HMzBOYzȚNM,P?1||qݓ?=n?b91O?_5KFb,~7~hf/'2a5k?_(uISo(sa"}qق??|\\v ?NC͐q71솤GSSΨ5g`(Y?2̈ 'vG_!i׀,Cũׯ?2K˟`f⻇z? MG'4`^gz%/ gm0a31^",oڬ 2,'EKg i<g?]\\xo8wkv.p隇>2Ỗz_~n.u< =i/ܻ?[;}g?v?֤J~'7{AoK՜OO 㸧fY ?Zx^yXUƩ!B)!XJ(!RgogPC(RJ B PE穄J !wPgj-<qMsE Ƚ;[8Ժkm߱[ʫ6-/[Vk,zWֲΕk׼/&Ȇo"~Wv\vy/m{>Tv+e/6X2"٨~lOlg4Y殢cP6-b}Z̷6+x=gsK kix=ܵ=>sϊ0ۛY_0\۠ꖲ11fKkvX`w6%& 9VV84 oZ{%n~m;>;w=#V]alm W/O2r#&㽴2>ĖWY~;5a5ψ(fSV8OYG{syN?;T:r/>wݠ:VLZ&GgGW?@O*ZQ:?Ok¦֠:WUgb΋FϷ嫦->zT*l`MwuC[].YIW\ZGV*p3"9ߛЃK]PWW1~1I˛ [V,z_h)oViOU ᧔+~_nƖ (kߝ`?s^Q#j_?(4Go`!!yBL_-beĜ@|WE/[]_23焰gC"K)E=S;;A(25"w'Pӣ7?5}N3'$$(#:jϿצ_1' r҃¢E aZ9YCiڭ?G6U*#<㉟i'ᚃ&}6C47|7]'_9vwvb v/`3 (*̏RQ;FV)>uѷG#>\$ 2?!arq[G 8ϮR$7.ga '}Wv݃]t/+9&nVOLEMtKBﭫZgM_5Gh_{I^@="")~Ǩjo`I/,1C1KCZyVA1zjY (zd˒@z[b쾱ۺOo-/=_6]8,;S?"/@W F@p甕~cҊMӣWf_y*aK|w6x.<4?MqmQ߅ N?(@?2?e+ju|ȟⶩ@qXJHZ50?F5U-uID37O /9ԖVg6S^),Oޠ_>5#:{GNRf19se SVC,k !t|׏SXzۀ_Ȩ^v8 d.^߱=z%-ZL'-ɡH.YxASD:$T#Nͣ9u?\Sϫ-^2R}Qi??݊>O[)yOH5 cܤO`wq_8&/^Wϴ1$sx!Fd6YR'؅_<~:T/H1{{oբ>_ja}VMȵ_"?>Ku ʮ?+F{SiҶY^ؗO\roBsrKXeMf\? MeKT;c\\])ԋiN|PFYXܣ3SoZ_gk?QZtCBG>NZy5P-3CxQUud [gX~&Gڶvf:@6bIŢѥomSN@k(z $~1ي?NFGT{ۋrvE'_4i,N-¡7XvYX)ժRO(SGq߯bL;u&SI}y_'{8GzpL|_Qw 4Zq~p~8xQz/,/uOdWTߙk/cO:{J,}JtZĤ1ٟ@Q󟛧UBmcKfꉘ{7 ep0\#]sc*ur;"4خ2yW?+1ߑ!79Ћjx{+O/?Ì+>?q`ߑd`1ZEze368C)raL˨d+=ÑcoAOBwͮxUʁ/g'O`%sAV{qS/\3 gc.> FuW{UՕG[gdgτ=z2?1euN5R"7ہrBGkSc_g>)xNcP1a>dGMw?u=70\c..]f_8 Կ.nhrqqyu[Ak% =Q}#EVd/oa/9]O)+Ƃ/u?%$t~{tFIf gP>+1.;T9~8I3ڥLɥ\P3:Ag {㳂?毭7g/ofG+kB'W=W!i??Jt_3/RofW/h:$2(s27vj,>WOYC7Ϝ2]j/Kl78:=˟GZYy{/oGη/ǜM];_XGN.x$4i?UzS~{w!'_>Ϗ=''ev_&QM;Ƅنl]9VbEr%kmO~jv6%3+nd.|Ifg%3QaCc wGU&vD}8רO,Z/X%hn {?bk b_1SvO#/[3Ox^ \UeƑ(׼`FD{} ލR3A{UGnHECDɺ)꤆;J7,O5u?~Xi\ ?td a2{BhX9 Cəgl9s+OW!4S3OJwnhR+'CO͑#jOV?s˱PZw ;ϙ<_"~_K'{FY-H#Lyq@0 U5!ƿ]H{B*xn?ͶT}m0RI)r:QTw7zNN*VӿgWǻ>)-h! hs_UqJS  YP'?vO OW[/oˮ_7/ nKcS)'5<&këD@<ӣ_N+.ͧHZH+AeNi`ÓD$z_S+[oD/O|Jz"d# H9tO x]^??ЏdY&O~M٥ğ.]W(nl[BM5Y_l/x /FRKXEB)u?dS}Syvp> ^X~$H7-?VƟ!?>NhuM`|˚K\ݕ;v{;w&mA>pGR&ƏSxJ*T'[)-ef:E{v"OВNi?[gy I(i}X".t4\7K缦_s?j&e";΍pa97 }"x0,+1˜'֫2V \l\Nmv돤3L{'.xʭ~J&:O/?/Ҍ6[bT3/6)rο_~CKkhFk]&P{SVTqpqʜ-/G^d ŷHSDm5dտW?ԯ|JΟwLs4_/1̷0Ol|? X􄀆?wJ;?f'?]#eœ"6zw A5T\?S֎ךV8Gkٝh6Sv!o{),N?lkĈ z !\Geco$wwVp3Wbo F/Y"4<^äS ݂ib">)'U\"OE>?ktI?0y_67-KV39>2ي'%&Q_wTнsе ?~Oo+K?eDZg6L:eT? 3YY:Ni_IPfAeWӳ$}~B^{L~ $C\3Wt[}#'ʑ4$Gc~/Hiv|??I4bǛz7ߪ#7}L>N59"'|?#a?=Oxz+wSLkl"VH?@S/:L8Ѥ98Zm ?1b1ݫ;nZ\4a.ע%<? ׮͟Rwݬ럮I?2?ȟ ۋG߰5bsd _;ZM~&R]IyS7B&\?ꇖgmr&m~J'?C벁ɺ2r?߯tOOqĜ2:nFMq=28NyG6Z{*vO_yXOw2wrtb2L3s=7/5G4mO^gj(C<gwkʟ_^hۇ.ȇ{Oo7vȸ-?>g3B1pu[?[9*(k*, 3GaI uOG:'=E*?i5A8׈ / gY #jH=L/L~Eֆ7J?聤v!O\Sgz3)C TW[?)i:?:t<5<3]B#wWhVU<:b0?q9gbGod <5zN#e af F].U$fZNy/qEC \ZxntQ\v|4)m\<Ny#[j7/]?xڶ:<6oZF^S/Ed)tM_K?=Rٕ7dהv;,Ý+붼(ϛOu|YG?N0?w 7<-cƪq]q8#-B6nӺIWOSi$^2x5#~8뿰}[?_ڞrdW5ECSJ[{ d0??op:b?=_|λ/C:I?#V^L.0ߍ3dLÇ~؛Slkρ}*G5)P_?\?5Wl,6kۋuogcKos~ҡ1w)3yCۑ;cp'g)cA,򶀷XoQӒk 6S׿}[Kp? _?Zz͕}?7>e?]?꯫=D+ m/0z>oizZuwO(5X3x׷(]$<-4qPvg5!i11Ϧ)_FK+{ )L.qq~ :K׌Eπ݂K6wT"~zK(sEHT;1'gy{-‡3"6^lƿr(23=}z/Ąbߺh'a ?#8.c?ރ}1 57|λU0so\"_eWle>+CkxkOL"[܏tJv6bR~8%xC)e#[oǻqwPAĉ]柅78le=~i$?#'7ir_\sncv~A*x{gQ+e:rW)dwq̭\' 6Om ?ꌕbߍz4ɘЇ{x\s[q__Nۅ|4()?gӿީ=dJ Qe?T檿Smt5osotNVZ:R& ZU-Ow w"]ľooQQǻfP3?tWP_̮)q?Kcߚ$9?Yۋ0#pӽSL]4gBG1Od_#;?ѳCfI?qY8cOBLD _Y>Y?1+ù?jegF<C&|)W ήWwVψUY?>pgޣLj/ޗoV#>WZUqȳŘ#V?jc/,MJKt3xzxvA_SNs[۩zx|Ƶ24|bDcn ݅}h[Zlg`F^=?gD?䝞 x_oο̟q=}&u6JGmzE}),.?f_ϯ^O+˜/MUGf3Mz5*3qv+daOjoz;<ݺlEk!#gw?ޥ;<ee]!fj??8yC?wYiPNG91>3B1Q*c~=GjS'Su>6.6T_o Ni?8{..IOM _G-2&^`g%o 'zm?k|UM / OޅqY8[u:|l_ϳ䠅a]UQ\+t/!?c'zb 齃[9L؁T{oI[v-T5[+lcN6?p)e_ަx>'̭§r×?X_ |F+O/T8? l/{VozS4g)L3?ޅ'0G1byl?[j5ӵn]`3yQ.wnI :X S('5?,,tD?=jݞ[̩6Pp>0S;`] Q\q~??#'dፘ[$gA Wy+A?7z4%nS574UQ=FewxׯĨp_~.ŗM2 8ͱ'M?lagzJƞOcgB:Zz"k .qA?u;>_i{V?gqr_5j]e3۔g 3ҮuZۖ2+'5c5༶!>}?ݤDBsUJ q9|h&*GI{W?qb~d`F -T?k=,(cp5Xj;x=1 矻YfT7Nb}G[??<Ub.֏N]wSx g۾3u~y?o/_"\OwKFx^{\Ue!$"DEEgDDD{ E##qhGDDD]R%bbh6^S0 1 1w9Zkg>{݋N)ˆ)Ua=V-i%9يsX&W*Jo7kњH{:e nm0:(.jbnϹ8Gk ֡ -&k]r厓uk? 2/zN> 4,R&)jeyYv]MzZ!e~òh7Gk.Uf>T6t 틱z Z:'(̊ە'>mm` l]oޖka o u| 28 9KjN5-cC;Y_ rQfzu&}sU4WW9~guu+rc3=;Zoci7$~5\dɕ!kb9c},p) k=Ȧ2 (xjȌ/MK:Z?L/Cr Ǹc %=98l`Zs\jEkN0` !׺>{e矻b<1S-Sȿq9[L ;g<.!p}?o=o?3CGYv''d5`{H_%_9#ws##J*dufRC#zUW?3y?|[~֑c%OZR?cYjIijR#u?ט=g!.3HYދ ޥ2/ZSf?ts=U+Oh}M +cjbpo6!Vٵ8O o 4CWO`j_2)h_W*1u#d+矒?ݤU2yL}YWܟұq 1K/hjo+w?xC3u`x^E^D\&_ nubk+.lA_9Cf/Cox -]NH˂)x+7MxHV$?A2jLgV0= `){=EU"k^pmg\oXY]'uP?Fs &=Mil;Y%RNhhs=A7>OKs%#i߲%E}n?Of܈P@U&/߲A'WKqG5Ȑ0:JSl??y6 (7a '`? L!oJ3ӳ|*\GYMddw2tPeWo޳?94ÛKQdm?dM{k灍&7m_ <'ểI =bߣg88POv=;O+?2Ǣ⿌5}_A1Uv#4''om^J풑PŬ%V@ÉPC WRn3X4\?K>cXG`c?X򟛒/Q2A(?;\RZMTGmH/[vS͟ ?f5 c<!CYe{o,o _7Ѻ|ؗ*>kN''M<=JF^W0 _t*m<C#'=@ʝg5gY-pzGQ~ل!xK%IZDkr":g}{}矵 7?0o\t,?P)WuotW#ĸ/lI_#MRd/wۣi5?}~A<*ϮQ>I)fпzh*9w܍J?ŝs!k\t@Ǚ ŬBHcC;N1?Ƞ*wli'?|'3zKFF"`6?]#Y0)'_~yinJi|D6`_~zd(?-u\Wzd\_ːh N\?&KFvQL3mrgؓtP;f&{W7WJ4e-Td?;gZGcߏo5H3(-sžep@OeA?n?"٦TA=~x~RS-joJO@;0pEkviOδ7o~BرXiNT4_@${acl?HC=\Zwq!t ).?* ?Ȕ -Ts$j?FG8[orrjx?LpXtQ4Dg0s=w4}|.M5⏳YYo?ζ"ifO bgF_pe^%#Yqv} hj' Qci_y~H/P{oO <& R<gM߹?WV?Xwp$Ӱ^jSZӒd?2LǞO?ݫD)hQG>iEwpxXw Ekޣkjnj^eοiWoe.^U.l)'8?\ֳtO" wP\׿{h ?gZ-\eK*& ?Kp߱+w>1u;տCZuh ن2=|h=SJ?D~NnXիnRw+%sn5~ "g=3R2?hxc5/)fՔ,m6MS]q{yB?N]a3Vs??_ZUxC3v9z>C2-4WOS6?p/"L(ڹ;klm| l~Qg8(mx 'nCgdѩ`OTMTY[sl:U1cl0e`o߰,SZ9c4_Oj߆nguJba "cիEYiU~6/|kM޶Ʀ$?kDY?(v_i.;+wd7s\q]ղX~CsEy D .Os\^{zU{'g&UT٫?wxXܙBF6n&v_GQnKp(scML4ofF|?Q7;gIR\ KSC{k44ҿl0;sf5~ rX@Z?%:r?n2G;3za ?1 5f+K*Un{mNb} ?WV_h$M7_AJ{kБ|nBBjw`ݐuߧΟX]&Li9i8m K]Inzk~ yraQl |]֡6Є%Ôٔfˈ }C?uSp/> x Υ: X GSDz1jzUp/]_oow1$1,\^n(㺷_|FV^϶vwvSG5wjjǴ ;g6lK׽h(ȦgP,6?3 ?I,gZ+wN f0 l?HvMpntƾ@O8~">m8%s=,/MeG9~*b+r:SG97?ﻚo5HO4i~A ghpwQǽ-x3M쫌obcWv;l{ K|Wޮ԰DA}n./f򟊞oXQS sG vJK[lGZ?'g078xpD}糠7XVyn$__X79Q7o5 7}&A-/2DVt#'0g?OuJO¿q=8S8>g~LH학Cӫ}SF._?3_6=b}i[CSL迌{=7&>'*U&0[7?d?Aq"yR*)03?ߦ֎y/5szѯȸ߭ipX+<Т_wq$'bXg0!M2}')%I_񚙧o\~qC]}ÛF#]9L%#}AxYs f=zqK?cن>8OJ$:> OpĐ?YGAV=b#5z7$9#4^{F 7Cdd'Wuߵ.Z{~F?~/Ǽl?#ʿQ~Oњ=gYj9vAڧ9l N(cP|r_زפ?>(owyԒǨ)$jwq^EQuuc4?{GF:ppTt dr@O#^ ,;?\ԏ/*N A?5_A? |ʠ'~XƳFwij}`Xc?_i?~oF??rFO)-ٍt4ʟcB-]g8b=a.? &zz]#z&XpԜ?ʟњ:6'W­jJis}SοddQpp4@g&1b>//0qޚ$♈NV+\XG ]#{<<  {2~/w?DT'\οAs#?̉?{;=T?c%IxH1~?m+ x^{\Ue APrQ"юpEQQ10FD0"KvĐC ~_C qhc=yZ{-~wXʾ}oU#೼h*K+.KD5`'\KjV!cNsJDo{O]O}I85~EŸvp!??~5/g~L'ƥUtQ_OIpxGKKCv{|͇/=7䏌¬"_cFxp[͑:,OІ ){e<߼bO|!hHcԇ"٠qȹ:gA wm"?joZ.Z$!o,`YHJoT\&LGKwBD$o]F% ӌD&A /t6P?)}i?G~„Ul9!sRwt_f3 =!! \P$?"Dm"/Z#_:Fpc/XKa)[B#G84r>q[bEPe e]Q/l?}vvyum3TlS_jX6֜?#p]QKY K5_uEzIOo?J^U?Q?i:Kc +_i7>?A ]~(Ipd +XbQ^,o*½ϫOpK[Z6<ҮpЛBvS?{nMrZ62N){ }{Xy?a&ݎ.}^E.O &|roʡ?Gx|8%aV:of?] p~K\/p'XOގ gmڿݘ/GZ3*47N3=A1ϫ:?}:|@;Z?޸\ŵk_-/m15n}Q:>sP?Z_ [\Q_ضzv!o3D}B3Koݿ/Q_^U 3XZֿfFd{Q󼜩Ѝ=_o^_yZ?m%?WS%T%XOPR5_<d[wNߜ(.'xPEL&fo\?)=? nR嶹^b¿eIX p.33'Ak΍"'ڕRO{ {5ت {3jR$ݧ(+gpDU:buj@x8z?)O9W"jKLjqˮoӱ]^cuz+j~埒*o͚?V(y^U#lk%/j?q: )/?|1ߵqvC_518)믚?_;pF~Q?}ù `!WS"?#]t땜GUMѽa@// _<{|& ,o)W澨,:eO(U_ܯlz3kWߞװWWQ_? <)-ߘkϫ? RLiLN1+6?+'=lG_9MsgDnARųr{GKP lΌ_x9LCQL&k#fx em^GM&ŻR1 ?'.|?=AU܁3LmQ̵\Z6kXKO' .|hI󈥜je}gL6Z9ѓWj5k쏰*?|>wɑ ,ARijZSk]_ԕ::3OOzRJζqx]^7Nl½?]*Y[l/{( /͟ȿWS'䞵???8gi6  -jN)GOG$'m䯥Ʋԏ\P Y {h^?8/%O8՗̟ι?(9J[Y 0x`q%И!\=~{_D2x^y@]չő ""H3")RrޙCRHi&f0fDH) 23`R2dNJ)<Ҕy9^RP{﬽}{_ T^t-n/آO&G1H(uTu{6upͧxhۯ zVղ ۿODrS͉X;;RIJ-cNmS[Vi[K>qh>/h^1>wYw[bi%p'Qz~^zP*~7_ N_hNZ}[Q]Ȉ68묚4MN`[|4[i&)ک^_1mͅ"F$/6#o0#Oimj9µZU稥8f(mj{+g>qlp(b#*;[0|ͷ ,h1!&{)OUĿtFGbAYw|p֧Owv6[A{th؅cƳ*ϧ ZqIJr7ۦ?+?5%[sMI_߽j|;2#wNQs7ԥ-=SO5?X5.g_'m~P]&mC8Jw׊̍?6sȟԓIҸ> b#sPWK'+=X7r҃C@-.MA/߬^ ?1%újW2N?Lv\dɱԥH?Y!Y k'G׾TqF?=SeTD>=!_>j6͝H俪뿰O0?Pk<7E2e& <)^ŠE4#/we)V1?6\aB?a[W̝IU3դU; _D73&j"vXF;&Mk`?>3 =1?It6 K][[?Ŀki}ox~e:Z)ب6aaa&^aJe\sO)Hk9SS6cXS\VZ?Yt-sf)+7ڋ[ό+GK2tngxIOJK%)R|]ީK?űoOIg/WI+ IN\=|wϴl%h#'RZoCMvP*SS|kNVW !)Og3 oBsy?xV-g??izkq\vvhwv(SZ?fU_2,Uu;'Gu?6 eY߽Uk3&qi-Pvn)?Lc¤e!NYbw_~gLL_1}tq߈~f?+uYli  g/d7'^oSӦ SO:X/1ěCt/g{5v?}G.7' k.?x܇K>v f?7_C6?ބw6”F$쿂mFRUI3"t5ybQ'n䟶[CIN|YٴrG,MZ${xxwhų^Ghީ!|˥M_:G/!z)l?V?"onS_RbtS57_WN5/ݟxocBSNzg3g"ZC Q_N߹WuuZsum{S]jb..3?%nOsT5;l{di iZ{3oN%D}ȖKo͛ojso Ɓ%C Gf:?oy1ox?*-@lKY=ԣ'{x,2OĸIS,N5-uv"OFZeB|-s]T_iȟoGaymS}S_RTOc]PSЊQTo|=2 H~d'W\ޘ|rmLҒȟRC+8|+}rlʟ}?W?!eN;}]U ݣg? tXYc+C"s[ { sBe'ۯR?s@~zi'>x[ ,&C?`Y֔g?XsC+./*>Ŕ2Jsccbvibj?zuA0%I\01sG[_OZ Ksor~޻|NPʢeeF"9߷ eY\:* [?7Iz<<3 )g/]i_geғԇ{ VowBYSNy?1Cc[;b#jC^@K?z.}ğ??d:+:E?Oh xdqW_% }? d;5 R?knlmHc?f ϑ+BDG?пoMêos'6T\k*ՌϟunO+W;NVԣ=Ţ],ZVSEvTw9ͶҐ辕5zQKbe [qLdЗY^i|"'.5͟_&M~K[DϭbcbVy?ԧ;]$z~ٜq+wbi ?_ȅz9UE 5u>&.gW? `$%mbFsyd|՛]ܟK\lʟU&_>lvEΟWSW Cvpz6[]+?h~4F|d?K̮ĶCJoO`&?f:۳S.'ZAx7n*c{gdF0~K?\CY7o;MOYo̥\q9ېͩ><ϡ%>qg=S/g v.sDAIBI<^?w](jwOPŢ?/I#5<谘? 3u?4e? ߾ S/jGgǿ̰w|tx=.qYƳ?%hǍ>Nͷ&o?*><̀O? ?R`_ώ{Yw/4X?VoNfUK1;K=9?c8OH;h}Eu?h/1o٥T7u?<Ѝ`=`F)3ca6 &p,y(Kr ߜ؂oZYNg8GVra'~UkI7.ߚL=%6@)~g-fyrΤ?z?Ϲ׸,LLםSIͩ"& wa'f۬|?"s?9j!)['?@{xaǞQ[.[,a7)iX; Ի(;Dm+BwU1Kot cKL==iλ. ֍Q{%=lj'O,g6ݜ qY-xŔ0?P}V?/kҦv`u`k^o홦CkC̰_KeLq̇oRҟ{YRt;U1pmTD`FTx?CZIk㜣([goA_W [g+x=EBzL}O̘O|M=>{Zϟv^5gBQ-o +^Gj?]&ȟ*}0ΗtNio.g;qߚߖ {Ccw״D9_-泥N38C׿ObN}JUg?3r_Ӱg2K䟡Fø;@(H?==W)ymkW-Bqt5n|z}_bK?PUY3ˎvD &xZoSx=?Ws/{4Kh sl6ݽЊ+NwWEzi9%IРWS[{H|w-zÞlNSOS%ߠ;vF9`C".4zCUx{[ S/=z"HN?q9QcOBlWR9Q'Fx{Cd¢h]+G=-8RyzCj_TpXw oSX8sP?`ڃw G'U _o Ā$<̵3~E3R}"xb@%XW'pk1ǂVwK#֬>4M q3Q=Df^?=#!?_ZI}8+NbCMJif_CqOw\xO*eDM?s|6KY4;!2{T^<2_p0m?䏹FKi=T ۯ5Vi{X"2ԉߙux|%W 37#HTyы;؃VL GuA|e>ϏBR-_c 5/yS {#b@o?WPO:OfylW'݋Xųj֔sDI zq7|1uǿOFv2"L!e.ɹLzq>t|_)Sb?mkOvZ_jڹb܋278}||G9qoT zEbʯ[ZƩb\;gKbu_iIα,4'޸?SI!^gnO߻KYsΐ7FŽ&̮ ͱV=*Z#f?)C@q.7'`]wHו$>I'NyLfHp_ .H<ҨcⵚB(؜w~oHVd/C0ޖKG0iNHO]MBYs[Yיv`"^C`i0s!{Đp<`0h]w E-Ʃ'"~mVEDލĈ|Xtsd? 9$ UF!exmnC@+_$9?>Q?>6ݦ#еGdI?CɟA1fN,t5wO !v(t $ڍI'Zo<W?x RZug;ƖEa1JWݦ-g iv` FĈ7cFo m+?)!#+{#RڦLKФIJ+{M\K{)(ZStϦ]yw1[?/g[oo#8}&Qh? 񙲥{%?M_FR˄͜ peGg1,}jD|^0]K ftcApD_w?1 _Go:D^Y'6g((gj+?&k?~?ߙ7Q!K?㰨g/v#F|!4- {t/ /!ӲlrT_uՙԎ? z?g;A}R+=緛QK$ϝ"ݛG F]>] VwJ f]0l>ҟ?vJ%z?I0<$8U;s}{g?2UeX9G2Բ-iȿj{$/yrriӒeXP?5sFZ'٣_1ϽwDV_?CMxRJ;?:ˊ_CKsrVďp|#ZQk|f,&{39^OG_E>e"|7܈iVYs ѡA^>FGVwd"5E>󟼞 $a4N @IPWmEWu[؜'^sGpzkWwz_-s)0g0MaL{617nߥ >M?>?S?C(! SVXu5mSZ,&LW`7q`W3Art%\ ILμNgXX#!1/ K\W?"̇F7*ѣi~?u]nxם6yarv.?ٸi_T&@5l!3ǻG39?Ζ7_l#EV?y,NX]JKcB \\YQtb29$Ӭ?/Ay=HΡLcmlgBvA1܏]̃?lY̤S?\%!yr/㵴㟺I̱{6-IPOݙOLˮw\pԅs_Xk~V}d3s oO ?GWta(ϓU?q/z jq~4ej~'~pKCShw}r4A Dx?O[(i:襀ߪG3`س}!}M.T~Ap.gO͟y ,Qל?N/!@$Xe4?Zk3&ӳԨr<5G|v`[BW/6?p_ADGL'[.~p80&GV_ 'n:bs _-ϨN,Gڝv< (Y_}&dSrF}xeughs٭K$f3Ƀj矋cl%5t&KtV<z4q "U' uϨ"?7yn6Bd_d!* .O6]ϼ֯@WMdFZGdGXqm[k _v|aQxwuhcsFl?2X7CٺܣT?i- y0z&nO:,<ԟ]k6އ5ɥ:#?|G8a&G;8T\G@!gJ:{Y@[.4C5q tk5_?t:sn`3,QRl6 <]!2z)οcV?{s_ҎȝKg nC(?ߘ1*;VE8iNJi?U2}eS4?K %xhNwn6srS?6I2}CfE+=E?o'೜(4Qeg"6vI7_I!|îkUna- ͪGܷ]7VH@7|E7E_de8̱dgSwϾn/]'³Cj!{qu]v؍?{rsC>5tzM_}gQܗǟϮf?wKtVӀߥ/}n}k?%N1t7ο%߮?c+3ժ<KӨz?jQ?-p-p!ӿ[?hvYwU ANBvʟF_?oPScv)A l>=?:_WC&0urѡr^wc?$19Y#?fV62@ɪdò`_e&^1:K /M~FfwZJ#Bƿ'F}l-\9-@ 1߸᎜l}ck|G;r#2Uw߳0]y(-wRn6E7?ݝkb׳K=?Wu^ ϽTMx/pb>͝g*JiUEGgвhIgsnٺ\j_r#xR˿r_ Vkles?~~5W@ٜqN.?A)n/J"]`/ǵx&JW?nDF?2-{AC[?|әu͜?mL  E7@fN*ex;KN3Կc|6˽#kwo@FsW߹;yJvg=`?zO}<?>}_Ǩ ? JX+ ۶)8w?␫>9̃?r<;Kk`{߻ ?ypTk?>ο*4S3d*?*gG&۲C%Jͻ7|Rj6|سUZH!#7kg/ |vjb+UgYqԢԳb8-xFpR{ _:Q?d<>su󟸖iaMi-m_/`aAA,֨x؜YT/- 1G1~p7!_'^2wrg̷w=BQy`ŭwWSoH/Ǿ_ Qx/?9#’/p_V柄0oY7*:F1r\.<^-M L|I^(:+MgR?Vp4z9[]غcxyKRZ@6gR^ŗ'~ߑ\ ئUҰ,W/%z)og͑'NUH':VIsK v2KuKGv>:,2х#3俸+sm_3>1bމdv fD\:JK['C_QgL1=+S4ۗk?؟ ,?fV>ӡ=R?6n*#뭨WQzɕvPy@}>ϾI7&>C͟s#7A2f_mZNV{lZ.XTeAց.f ^ |ҿI&oI2 f_/*O̞.z^;e63Ñ%uؠ"k:%7G25oS\O?YߝNnO0φ,SVp|?|&wp_ CAͭ /KZ_ ug.UﺡOHq_v\!s 3,ULcF~X޿-̿86)sߍIE͑tQۺ>EU'"h8O]EL'U i_sDY$^6G?m5x/ʟv<wcIWZoJWaMjUgNUgaij?fc/G{ڭ9 c>lkfő?m?`Sְ]"זPs]_(m~>_LlḴ ̦)6K_lqe}ie0^uf 5<#(4ebg{i叵H}]Ϙ(TwKic:xfgK?x!0#ߩGLBmmֿ~ $ˋrP6Pi\p${q߻c?]տO JZ?OynČө#͟Uy%:D=Km]s޲QFU52h}~N?0c~i }=JT#q.!^\$ĹG5 ?ht80G(w-6xOk|W }u_v3L׿{-m88p'٘a(/ʏV?re~!}󟦾u_`]%9l߬Œ{ωLNؠt=Zϫp 8G'8wFn1XBB_Z@/gBީ7#?͑*앐U_q΂+R'Y&ROdg)#uCEUGj593Y/<^w6#'R+h{t0-nZǰ'sM轢mAϕIC8}KgZz.,f}r%_ )_㻛"?.14)u=7 DY%&YUWN?e6p_jKOLPCXq]800&-zPCPOjsdwjm?iܺsqC/ʘ;τ4E_='9K6$#LJ_)li `8GV<'Q?Jh+1}1w<1 ڬ?FE%H;7ùO7?,kw N!r:6`J.E?m`W![Vߕݹ1q_C_%ݨwf&#ZsU8m;u[SWD~z/_b]!pϗAGזm58ljmX__:'?  )a.S-3\n_$kZUkGgL_ Wc17YwMOMu2gSV{WM?[t蘏8h#߃'|cu 6?ΨOQ1.N_@&QOz|Xj]3ڵZc& /ȵ#tw9C€=LJgxʤiLgTOf?00PY?w@FްɟOiS.u#R 5o8pnG?ͧ?8RK[{gD,uhUMoVr9> Exߑ?݅嫺Cpw)<Rlb1~w#WljXRׅI=#õ&!,uS?QQ&=Rϴiw ϝZo?Ō!3?⺯?|5@?x{ 3 5g2tP!~K u?R_gͨ cQg/Ŧ #! = fhPiANk3qN5VŨAw ؆O?פ#{at8>B?E'4x(=Vܨ/GS 9PD ,JW߹h@A!+7'OGǽt>:/\O_0/#ۋ:v -_ޘj|WKfo _2Ch-Kwg{G57ʄf?@ݳOOO39v&tPTsnsZoBi)jk8ו'$|E$VNߚdȅvs?p RR8D?\\1σ'ik?/Sz-WZuK:3}k-w9^?>c@ WC+׫\\vo^&7/PSӵ1>Z7;RLfLQ/~{}`D 5r;yzqal6(ͯ$CQW&4QuqPMɝl-ld Fj3R5^ճ4U'hZRzĄQwuڠD^kWvM+7*+W;y'~о츞5]٢n=sokϐ?Gː^?5Pc /bpbz7U)ly k^]² ~ɐgMP~qu =4[Q'{Ր+l٪%%7c&w+ ؤ:e+ReszFS '=+sAxl@_f Nߪ:?$4Mc%ZHHAK&.dBs%k`Ex5ق&eh?_zQ+W,SrD_wY] ®꿦CͬW{ߑŽ R2FE!˗i*Mp[kYA]G~qmw^dǯ>/q3?\;o9; wpsvLDaõ+†3$W5|<V 3~#+k[?5?M/w\Nwޟ'E䰭F2Ϛ4/ jo@dzwM1qiv#gX? wty/. ݆z;Kk:n2X@vvκ}24֦C*zl0v2sŰ dտ'p~dAI8 ~DΑ{*ۯV+3OvX?|I?8ꌔ 9?q l ѧ]oP#c[}=`eE+{)Ŕ?sM_;R!sRX+lF?0 SYlR?JS CĬ<:AOzK&VUہ7?<ԩffwv]1uWrG/@Gw;3O&;} n跤~󱰃#Q?J Cw0o+SBB ]392_8z^ k7M[ /Z?#?ݹ$'Oj)o uDd3 {w(,xB?ٹ~pfH&I}D_K Ϭy4ꔿ3/o Jx"` C%/;%)?-. _i_.\?! ^9 9Ҡk`;s,?"g}(G5w ؤ,V97*BHc̞2w[keL/R6w2߈`kMɵ1S?ÑE8vf+8Oq_c3=/ByT̘zsȔ;k c8ݽʟz3i#̪YRFYgz#^ݙV?%T!5?򇾑9O1`b`+9?fn0zp)TYbȟA= =POI# ;m5>?6JNߔK})sj{ͳbfu\N"wQkNSE={[*8BcooEx \Gx"5*o9טAF_?=کߝ`p׿k`\όΰ#b|ףѶ?$ekeϽ4@N=F|WXgrS?8sO5Q/|o#~fmSְA#矶N giᗂ>ZugLZ ӭF >k߫Z1sߣx2YFS}{@Z+y> LWW2ף^{?R:֒^Ua?OEp'?hSoRĻBSj~0b;W)COE3;oWOgR6;˟{jJx꯹g^~{#)OQG]pnKO5i R[shoְS~h9ѲWH/z 0!뗒a y{G]\vj/KuC# '6i3?ʲeS=}no?wg9 k\vM$߭t9XN38z'r߭3|vY4ׁO⏳*oqVZc:1c}D٭?l?*g_\Ǚ?wxW'E//l[j9 xł^_?}O?{E\̦#C@I7 O-Wv@X{ sb̟Xcg wV_9܋4zE}[t,| {<$6_L?O!([e8YW̽ryuO51K|<#raq'C{MyC ~SO=3?*_9ǂOJ's`h?ɩ`d76w9#ssS1Q 7?8o?@MyB!3yα7_ƙ,ߠ)o3^%^ϰ* IͿك7m Gשӓ O[h`rSW7Oc?y!kAO ABoH0y#ﭾLpV-Ţl]R5|i/+fɤQ0cox0ܻG7CnEK?_h0ʆ5z#Bf,W ~]:@_ߣSg<lE |e_8f3RK=әmEq'2??Z%Z "e㐏G_W MB9|=>e8 q?}lq__sRO7K)?Z.50fgj#p}3*l% ͫY|b{Ԟh4b^{Ð?!  FCGyjV q9eN2fτL?'FPmE<1ssrVI58!M;wƧ7?DmhdgBL,pI=Njgau[:G.KEbJ,}+[{.S^'pށὥ^r6;ZؒUy3гQ V w 93˘0GgX=kO|C:fN`8Y9+@֐9<]\B^ !c3]Ο\ǽ`H?iBOwzssEEqЗxՙ>N]\'o~P+?~)A'p}_:@m%ϦNSlE O!cgEYf9#/x/W ˎ_;Ƒ"kn_ 5+h8I399kg/^' 5| ]z̲?/-fTWn ?63f#>7?\O kR?29 s3>r3WK5Y?NWL9 '2f߇W{W*@g?MQݽ.vלX.GmE6:3bG:_r%3PSBS~q'i?_?}?>px^yXg $B +r !@\6a0} $H" ! !`w500q%nh{!_YU]ܹ<< U~|_< JY;[+:"m>`guB%F̹2G2&~*$ͳ?o H,WǮLX_'Ij~Yf-{SlGf(;ؠWE||R77|$|UvTjZM|.)=P*]\p<7cG/y`hKMسQm,U"ӖK1nYUT{cp.idQ)l8;n,>b6& D)WKOM;½{_'x,ޑa`7qHm[n.lٴ1 s>[;짟߰<`chKXwd{gBgUT̀ ǥ+m'1ο߻/zPR-Jed!ƕF,ȿᡷeCJ 6"dO5GcbK=3 6o0;. bAuyT4s!.eX'K6gǤGf1m1r?FopR #\G .{Fl政&БgȀd8Ԃ$ ?^U!S5}twez w$ ݠ˘J  I=#\eVԿXFR>vqЉM=nrN"d~I]描TWG_#-?Z|GA2AX2/q4s˄M 4~"p>a]LeJi=E7-Eή\^ *6Bӿ¿Ѓ#eQ, < ?o? w/.Pt%:^T%j/m]8 fiZ <$OG7eG.YB&]3ο&lOj#/CQ˿Xe/so, JŨ7?q>yҿGA[ p&Ŀ[e?ы)'hKKRFEoAmxmsYe ֌LS~'!'P$?_?J[|d??j?<';Eeg3߬?~~\Cr9?ҿ_O|HWdžCT}lq?US-ߒ?~?J]?g3?m4?. #矺ޑ(*CxmO7=?Zމ?׿ig I  _??OߓmǸ+g9h+?--M\sAvqϊzlUO`)6-3:8X_t QpXU$rU< {$sГ3i:Aoǿ.,6" BG5CFO@aeʜu~F\/,`JWkg5b6n?.+UʟA"<`]Euus ׿?9Ӑ*'w?Pl@6mLs3;;Q?78囫{Ş@\6yOd>-9}.fB?Eպ_j#?Z'fU_0W.>?'OӴԙVe?9{aס6- "xO9p7v`919|A"VOAz"Sj6\emI#FBB!~ j?ը?Qbk?WhBic0?hK#_:6n3?;%L Cg?:W`!rp}£7]^Rj{_n{Ƀpv$1)G7dY,xYv|}EwlQ8Kx;9~CYvq. }ggH+99?w4⏳koz|T?6앖D5VRˆK3}W:cM·jhdaR@ R{cYEĿ .aXү9N~&xz3[?JD=ci;7?63+D1d?׿c|jcD?Ey_u(q[ãc_qGk/}Jߣ=ȟ+xit 9쿳Aw|KlO?ȀB<p5SsZA?r8VAzW:a}1ciD .#ձGdc_FqǥbVGR;]3ݭq~?4p?C 8n"h_0[ӿ[KBD̟v^-;\[z#Amty yvcHz/9>+^.X} j}1#]"D.or@81c ?8T=9f/?+ YOf{ZԆPzBNG ߹ס+T_=?<'^yq)_Lj{UY][#bT=,jܡiiN`?ptLjAFX?/^?q-kqؙ%JMKEp)"{GnU8Ew##;oHtUkshտ-?3a+'d!Pxt$ }/JXɠ8e͝?JgV BA?TkDW_;/;lU/zqR ynqg٥C1VBMJD/JDx_ߓ?:5{"sg4x<:Sgj"+>*CAn?)RdF-SC[xG'('ҋ?9ȴ"bB0p/U ȑ/?߉fXgJS}2Ή(`]7ys]^&ϭsVGtAVWh&jYEiY?m<6[2'3L5_|ypVnU"r_\pT{2Phx8q/.'0;!9|%N~Kd:uψy~dhϪGak/ w*RC1ոyV2*LV?/gXWOdG,Nn ;uս_~H~OukCr-:I7cϿGI~"-Q?G&?̴\06izgj?;ΠJlp5?ֳO3[֢\s!%_Rux?~$q@GI!/0vyulېt]dgĸ-G _3?8GVI[?W<s-j\\6?ikGDZZYG@uv!V䉑f`Lop"|[.YmԄ٣/Y+!:PUZPxūcW[hWP\psa!op G4:v%fSZF%'Ukppd>p$e>CS\ '\#fA6K"LU#^H@r<߬4;2A,Տ~t^/(ߢUwdKMX\ aZ^_7m0CW҉w܌8Y'Oܬ?lF[ '4Nk_᳒_q/Iz3_|QkKw4s@<#?g|D C 2=?\RP;ΙRVc95\+?RFݯ?ϯ?/לUx^yXU AB !QQ0RBxjH/1K%묉c4H!QdpDc P}!)P\*8qȽ]}7I8gw) UX3S+6qaJboG`vڤ [SX']8~\}\ VJUL-[WV\<-f4\wC;V0Bɚ}58wZb KxiuJ-]k'NOڙ2/UjW1E[Y/[ݝ.j2Y"h`-_s2 UoMO51'MXgy 9?R#I7Bxz~VזPZ5r~?ǰ_c_.z?VB,O;_"(uǬF9!7??߿U??_9}+[„[*_밞WܞwȲd+WrT 9Zq?#lazKΪνluTqLJ64ߩZ[#5y9Aknם?ԊF[׿'-RWOk>}-BHGj{{e[;yW5BPg( RH?){~.ۯ=G =( W%Yˆyi?t*^boGW(a|o=[ɿ==(I~'Y?΢'8B&M~g,Ж;۬ _-Ũuɽ)ހɗw*3gykCܝ #kgfVzNc/rkF3}:Z?>)UI2qwjSq GV#ߩSu9k!b-')c+d\sgٿ[yGtEMM<dž/OOOhQʌSO9SqT\^\m?XS|WEH?mhu\¨dvg.KP D%eLYޡ~9Y{iC?M5ɿOʧ.[ƒ &?r9ub>̟<R~7,m"Z Zv}Iϖֹ+ UF*עy|5 uB}BG*'~WYOf?G/ [IʅbM;[}ׇVHJ8G KcY4R}O?=oJ=RUOJɻ+w9m-zRg\FO޽Qx09ڗVԮ\nAO= 9ol_?FbQm+b Iwh-ʱR_s?[CAzLzbh*i'm>'X7/iȃW)O4qOlK8R% 5_JSEF>;c%cCXs% 1某=VNu)c^G=z8=xϿqSzTd_ƻ?C{CW?6Y^O?q27i\Hi]A,Olܝ?Ken[ЫUR ^6AXm{mrq!!gU[9V0B?1>)z4[ =9G,wѻ_Iu=m%ĎZ> 99KEb8FeTrս]"~|#.7g8ukje4wKWoqI~z$WB1u<McK>?\2kUOY{Pk8_kPzuV[6ޖmeB[ֳ%e?j;tÛܝv#ipb_矰S+Gs1?BB֨^ɪiK_dC9ٿ+HaOYG[tzzƻ-Vm/|VOWuuG'^Hc6gmki92bϱԶO0՘{wVU;<H<;ǿx";?z1GXCl`WV[v(?rH_EM⹾eϩǸUyBQk)+  ?_fmyPGݯauc3Do_v?g[{dh|\bU@:.🗘)O?%_/F-zGr<ѡʊQmO>Qi ?=M*RyGC4^yڤwDBƒTJnOfaSvz]sIsBOاx64b=_7 3Fsq'OkA ɌjR}ff_ai7qb OAMͳ/O[1ek#;? ӺxS#PwGN ~j%e/?i׊"\ ܐ7Qx#ˮ[2/9/1GT3#,a77?mlqFCm$=\{e NTm/]?cވ=T_Ta1r ߝ)S6T-1Y? uB }Tt/`3g{m#LG67;Gt>Q,p&_s Կ$v8!}7G_]?Ϧ?SK>.7ǚn?~IB[<6JI=VO u1s6;aqxniKI5o0.S= ^#cFV|?GG%U U󱘛Q/ƽa{_=O Rx ?#z*45C/-tȿ(2S9Z`oO|G'S.>wx}ejrx6v_;sWn //&O56:'e͟qY?./Mӷxϒ yOϔqc+Kϒ8*>@u_S@rTY3tpyG!UҚUg9hm:k~g%>7y2.?^^Zg⟐zؓ|8申_ecZ֖Aqȿ!Y(Pp=x={J"Na?u+gڤi-?Ṅ,-8c_A-gtpIHn_؟ y,}_;sI{K9v'MFKWljE=i9/d{8Ł?U#w i>Wfv/ϘO-{aK-<ȟ4dll?(jqV(EM[;{'?W,P5yuVE?8 J!oq^]rGtˡvϿ՜Y'U/BsMಇgyp#&l޸J>W?~ ?jRvgL1_Mfǟ~w$TREE@ 63 8^i @  +x^UX}|ulSZ lhrَv< 1mCl!3cF"$7ž߳Cy()[~^y:z_v{ONtݪ_rh'èA[ưTvDIt'#;Mӵ5-S3|( pTo򅂺rԁ0qcc|_O/G~R .A9ǃ RQ/rhӳ#}/~qT۽ x}-n5U\:fO a(7|"SQ/th](lWQ|Z;[(zIA_{H)ZTgsthӑsvgJ}+C4:66 e74#`~+hOAGqh)Ckv_1ʃ<X(h Z !sc>9*]j;-lF&Co%@tՍjZxGR&n4D&7׍Zsasz|1\ ꡠ'Ouh;ЈF,~( f?P1 s~QgF"Tg‹ڙt=rhaNKiUܡ%L{/n-e.4W:i(#{s<.fXDYQ1ƕ&4Q?9נ*jK@~Cu/ sEi =q"CWRt}{3}fI쿪>jxnЍjAK&Q5 rC. tkjn߫˟J>ZϲMiR?6uSf$_b.&}ebOeMŨσBعz >qdft UA۔(PցA=|R4Rݥ-Ecr30bScnZ~/*l:G/^ juOrԾ||~a"˞\B69hA6k_J,EjR;.9:=sǜCNKTv@l?`Ȥ-CrW Ŏ`o\=I埋;*TU GaΦÿr$qy3~hg؆i=2m<H4;`k7>,r,ۍZ _h7]i+fqwTYf7- ^fĭdfNnP 鶙Ms^tfaraqY+4h=߭QZ}ȷxZXkYK:js)0F_w>@?y`Bv@Ov^·VI7usԉwLf0Xvkrx[@<ؒlkm2M? f4>h~"u?"2kcq}'x0}d :,V$r:ȸ/ PuL5^80rf9m]>sN{07-2ݒմ谕TbL8r4,ԋ#z"rlg)Ow>}J)k`޼ 3M E_\CS_V#?Ŗ5m٨=:A2WEhPЭ}8Tqd@hJkS=n),4 PgwwΣq39JP !Yp+ 聆}ܹF~XT6 6!]ƼV/w*ɹ\3%kN.)hc{A: o0MV"α}. sEM]?,1BA'í4~ z:]U7 c+SЗkTs.ǂ( )>Wxz?&mm7+S2KߧeDkPvp34?V]?۪@%RlڞCZS{(| yOE][Џ 3>ʚeGEuVb-ag֬` A5"-uر$РQP=Iw47u3][~ѯ'oA$k2:-@b #Urs_K){Zs**bfug9wEմ.e:Uۍd^%r ˟pRoу30@3tHwMw&3ڭXY ˆXcTr,MEU! 4KW4xRloa 2v%͓dcC]B+hY28v9m\>FYYݼ[~CrO&STjgRk[r+L(5q%Gͤeٗ$WӸ<|nTй́c|g_E_q9CYTs) M_qQUQ?scB#~rh$پ=f !0-VF/G%P$;>c b!b D_*hm)iҖxmQxnȘs :5z8]k |VLFTYa\VuHa C|5ƫN1 ƒQ<9DjZ|P}4hpBz?HUα☐- zv]M)/_PEf{XyW|h;-WUЃ˘Q8u1ۄʷ TRcGnZy[mUy(tc8㨏\CXSV oDLwv1z-0^;_>%C߳vs_ɆN&T|1 cCч>kl:600b 9Z}ͣmUHT`qګJoQP,RЖrGѓu-v@.(@ԃc3]uMQXo*hH,q~'YqJTY3۰D HX$/BZ>v Ҥ3#+=l?:b3QHMڠW 2( #譠+h6lC/냛M52sT#JwK}2(ʌ*OxK`-iʃ͏I㏐REDD_5>6QvjէdAŸTԈʩiJ  OܷFqYIt2#*JH8Qq܉[@5 "7%\^t.+.oz¸QTp}_T׭cLAt@N9e6ε'i9g#} 9 u+hK{kNtAx K=|Pd#Xs?Od϶vP:b [/ŃQwbsyO xJY#+zt>*Ћ=kߐT73~Iw! (hdYR)ݸe ,)LiPA}SӋ&v@&̾, µ95kKƭiD1] T; 6dMT1u}(K9| Fl} ‹[Ѭ2"(vu' Vڼ!'_Fr(c̪A!ǯq*;࡜-װ!KqnSоr+jز1͂RZx 'З6/ge1-(xJE=Uۉ~84q rUPu:ջs4æ–5c>v)v=qD@r${CE¹ҙעV4SeZ 5u+wG6d+2)IQ}sMX8 Aˇb# I686p:*gCRD9ݣƉdzPtw+NE>MD0OC[ ]&yާ:{l*c) :?nf3:t1|Z_ x^MXw@Tw~,.jc[ Ƃd]`]EAE\AXAQ XbBb51~gᬜ={fQMs7|3,nj[Ŀ϶0IR"m;šY,vb`Z:U^-`jV˵LB)Ѷ6 Mw6)MVW/pH!6FRao!#-tBI0+ Q$M\ŕ9!_Oa೜ k&@< 9[I$@z]:H; "oW P"Bu`բ~$M3ʯ9Z&ZX\X{KM >| BI1:v7d`;&:$=&0B j?O\#P'~Ԇ`H@%gD܂]J@Ogt u)cg`I&Zw+LLE?fSK&ɳv晓|$JR$Q?ݱCzs|T+CP2oGefiF ZK"Qzg}K̙[brI"]5"  似 ؆z uKJDԼ[סP7%ɢvQBd; (ڄϊuMJCrQGJTk)tTy|oɢqӫW-j}XD՜cڨH`f$ .ʮB#+:cswFz\c"΍(g}+N!`s%~![ H0uƏ1Iw¹_r8z A cs-_,苹[k( EPZV^s q:ZcF,˳:rEa*aT7P+jڴkQ30j&mlCJP1Dv+YRz]I h &`Ъqd * fA3 i!I@P D\?v}a 0<16QO,s+ |Am ARTPYǔkxFh .Ѥ,-+/Ÿ] ì.cvyL>FqaNC)* CUFbrlno*~~4 2CָJ: m#a ,B7*\Wdy@3ϒzS'QV`a`ʄË8*fƭB$РslհQ'9:0$` }I2']&}e>هc@PB!N PT7et$kiV n#.eGʕlڵѫ3V+ [C=&4C*N+!01loIw,\kZoU:N\0@[2r 䘤p,vl7r@ 󩰔$;, n7Z!r sjY{QU:܈j<(r)7on\/ln15;] a|Ȇ4Qĝz"ۍޤə|>mVjлoP.pir=e}w#f3b2ܵ,>Ee'8dž GwN!| [8*5=4*mZ+>%=o_iԂPZnt>Ru-GsL}Z 0@XlÞPXqH^ ..ޢ= &f"'ͩ3꠿,N[eMqP%͛ +ޅk[wRh.>9k0L-E5V.q2Z`h(y.Nmz 6A|d$rI $O0B~ E8\Z0v] ;}Og$ϳ}^G핼i;#0N; R Ц)HjEN@,B'"2MDdtH.||'^cQפ5_51[ѵ4 =KR<[=\;R(WjZyj52aF[?a&gaXYci雕|rڮv"Ƭ}sTU i8 :@"9CW:bx#g}z.\6es8qQ7{Ŧ駦S U&^rU".tq ,=k}#eLYuq++VXoZ)F^Mp~Ȉ1|mƼhj2.-ah2Gԛe|^J)/g:ܴ{7d b*n%s#J=@\;tOl P*Yapyb.Kv6L 1!H ڷf~r0K|x^MYy\~a5~WTJ0  i AGEBܗe$4p\^3MsCGڇ(\Si=߱[韧ss&b[mn]\$kg,Xag\,Ŋ(fX -gUZtHjYv~z,\%&֚/pH j6 hBFY_G|BŽw( Q6/8 KGB z/l Qa"z(8@EZ6fU>X.›yIg<ja*mx]<2`ɞa1:F(1^vJ%~Pyjhdqv.&1_%ԏQ3d;iJՂQu;4Td$e٬UP$ 7)<:$=BU|oñwڲя0 "#7RHs>ܡSNXHg #Y %mkQELa56"J}_`r3!ZHT̓9$5&!5`.m/,z NBSB'C И[soD3#BV6B]wjзS.R^p=pB9$Ң\ (K5]@4zX ˫Z%M?uu蚑Z ,Ā@/2M4`gR,TX Qipڜ "J4`Ys_ryaü'W`%ߜv,Au3_nsa 6nC&E{`@ʕRvG@jד(X||0)Eyr_V-'4xMg23X('0j4^o4;Z0ˊ-F]%vJzO]@ 0Xc ~V"A j! ;ލr}>QtCӔH4eT:}`5v'5O:%bϺj ʫ+{Tȱi"e98~S.z.-Cgn0Ӡ{5F M~hC]``˯q{[Fw\茼1(:&p^r GW,g Pb ~k!_lb`&XoH>LY&c+a]z}t4:9Z(k,X _VR mǞ?H .OdlWnU=d]vWտ "EK LC\&K$h F92N3rmH6ͱcVUVt[Ltނ'"X֦2E1 ȀKKM5bjC(&-O&CzWENڨеɮGC-dx)1*>=m6g8E6^f;gA`(^Y;S 40j(AAtpaw!/ԁgqa_ͭwQYUZ}͒\*IM/vd_[cSaUH: 4ίjw]xi-mۆP _Ą0:!^;z)`DrՑT>w` G\(<.d`;ϻ9S.^>Fij',OwkkB|y%ۋr-S]u性KpGB.ScoU’?[b9WBj&Gz#Ս.ر^P\[Nʢ2@9"B?]{|^@5YnrY ._(GE\HTYb`H`>6RҠ:N2؅lgp;Ji+}[E=eؠ:%U݆<idaԧ0G*yy|Г6$Ljg]9;api7^b(׋BD{Q=zAc{ZTme#@^ "x.Hw3f &쾤?Zg$;6:_DMņy~՟-wmQA:bfЮ͜k/z欼QT7oQ-לqofm4㟓x}QogAMtɳIbwߝCt`D5@4ೖ9¹U=착_P V6V q3tei?'!la9 Fasݜ`"jd΂,8$7|/ ›5BbM*^I;Yؙ}:wi;,Kk>,wO/\Aq\'e5(-f\x$@窇Jc\tBv VϏ6|LRvI;A =0d# ʫBAOT`piTʆ-C/x^UYX]؅5 `e՟e,eр AP+Ɗ{hb/j,ĚM-ޜ<|aٜopG%NLS;gSNiw8CSu*ab`KPi;8QBAUC{ \ "Aaè&~m$AWꡈ,n=tU|6ͯĥ1O cS2겕ߪJTjWܤKD(givdu)0MKFa4j U I?d^%, Y>&4 iTC1JJFmS*/:?s)b8zx6UU^LC\ g~-20 ,leml]"Me@iԡava3 hJuKIR?g2j 8.-臝F`2GI]O xXRl^X,Aʉim G.C ̑ $H.2n@*U.pN *7&oXdtu % 2j\R&l);M& KaU9j'aO'.ֳ&8*;t`{zHYNR?p$F]w;eP0A;a/z|_Z2 E81)VBrU Nm'֍RIT/=_$,mfa£/h% ]]dFd%\dCt{l "(h#,` B!}`K]G%m@B}"jA).T>暖`Z5oX C65y:U`{t(8T bȫnKQ;AU86IB,bMU[+W%ԹȀ)6+fRa)En.'kKF h'`(ؽ|hohvzV -+C'k͔PLHu^tV+L|Yf7BQvg ;v|2&IF*-%(.pnX_EuaL_I蹟6#xي~zTw!mn,JTǁI.ZYW$>i!v..%fHQY=H!" lGرU,؃FT 5eWrYkQߖQ[-H !{E1ÉWnl A~0Θ-_TB 8lg2#f轆h48 SdˇqptG[ZWh& ?E7u C'tjRT~7j>/;Fͼm*mz^fΒ򥦡q|_>+5.m &@9CHGB俏F>`Jm=X6`O-vho>*í,2v"+đ>+n..›jCמ A駴j* N6"Q NlQ#w3(i ??{AVf%y]VL f6?,yyŃdP3'MA8ʔ|@(m/ cYsgv5Xi ʀ5BˍȀIG5敘k&迲u΁!ABwC@W|oʕ ;ZVX0 30Wy;1i~~vP`H5 5@p$-*J7΃%S!4huЭ)ϲ@_AI }Yu Po$+ yzc 2%4^+:(/ ]cã~ӾPZk7~ %S+N4tX7UX?{$oր[.m, ˜b䑔5 5gGBQ+7؆u3"{%i'8ޅZABwuެ O^4 k]/2Ԉuie=HyOY'oHV -| skꋠi.J@w--k_}L/_huFP'ms/{U/<38Uv^QGE}+i\jT\5sbVVȪ~J!c4j@p,mav1%2"źS!݉,So5(>k(w2Š&!M6Q@ \c%n?lC#,:w]4IXtI[eX7xGxܸ{5J!I24,f}E_xaLZϭݑ.y0wE}jPC֍-0COMal0MS(Z4ѽ)kZ^ nDa͊uTjW#u%t}wa4IY`Z鄨h-l(ࣰLP%s.PYRVF[D*IF[.u+B~uZ!A8q >ihuף@"$z"M؜Z¹ES>PO p  Q'R!##PuNbP7h4Qxt7`KZ3NvڐDQ˨JK󆋮e5!hg&ho3B[ڑUB)U~V6p{ /{tz:,fWVA*V6XZ 3?%@;.#¨ j'3P oiÊܡ_ׇ1K > Wt; ,/jr |~-$mȽer_MxwI-J䃏+9.B{{Y X jHy(*,QJ{;_L+c([텺v%hx{ \-:O%MhxqVBMQiƢffj)OPހ>?XȨ _`6(ec 0Ȁ0j)nj0J NaZkl8o&_FlDNs|=+q ""ˠ] j 9\ ufKiG0%ƩJ|AD*<=cMeSZBv/M˯C_twci[x+~EjA>Q P hC= s})j" I(Z" UI"bu0 86 дKmJ!$D 2)(i3h]TQ?87jhwfMg!],8ӢȆt&nn7&e^4{+=3qJO"w4 H4 A3l&}5(.;ZN#NʕY%w펡_"5x93U>M xʨ˱ kK}WKлye Į/K oCi.^+wĸ Qg1JMD5ح/A >8W[z9FU!ͳ>Ɔ')EB! D3Syx%BxљB"ʰZnhv:B{9}o=;n>ygUn7j4Ⱥ>'I7M$c0j }ۚ3J~?R9vD^bay.HTREE@ 6Y QVx^MZg@SY kŶPBGɛ{S 1 Mĵ`]+b[ւ( @PE)DU@֙9(Y 6`If'XqVn"V7?U}&o>"26OwRnٝ {#Ta<%Ϡ&ۖ#Lѥ9>.LUәވV>|W&}33";- .!? IFTgV=Æ|_|ِ͘O$YAp NSADJ̟"Wl/i 'Y7ϕ wam0E%Oz~I}WEe;zkl ^XȇYoMyDJ^?%d+0Jf0H$Pѣ#;|yӺ¦c6dL8|!kwDzXy׷'ZU WUY@Kf9 >}c%bw'uFZ'.݉aC<y/: 'o+GRRCàWOJ(ʀ s:Ihtr+̮F` E3 e]ĊX%4Ыx^;`y92b.6{sZG&e0cWBïnl?[oW?v;gDrV?֎="p9g\٧]Ieb>8*V,DV!N#DN$yep? ٭ .Ze?= {欃YeH' Rfhi",ウqdm_*~D_[m+c*g3v8bJ)Y? )~qʹX6h)ШcX]|"0G|pLz }'w[RʱԆ}aƖ!u0{p̛`4gV?Gg9Tl|ԃX)2a&r$zˮ KacX:^8 GZ.]ٕ'Y8=(~D(xvÑm2+ː3`Zsgh|!avI @B76wz9/i6k:+V!66H>< u#amTfô|6I|ӝ1.14&.ACw0W_+|_vXjjېd5>3EvB=>eHOβg84 N;.v imK'> ܊=/@bQ۸&pQ )zęBqv ܦ%.Gw_%ՏmG]l/ uhWaWނ㫻Cz7l (EuH(vs'&W " RT^J^>ZVwАpgHX+˕T`CQH8A3HRNt FAxtrF?H>7t>Q6lle,xje2A3EjM޿.7^ixd1tFu.@ #MCѫ g9l'̾GՐ9rTo x89L$+(ibOij6$ CLjP9Rh^L `PO|w70Aw`1Ӯ*(Uշ80u88R5oDY%]p#쓐rj3odniF,7ŞUHm s'2Bʐ R4|* ;#8w#$ pP>)n4i'7ZMbEZ\@xûLhcq)R{Z O~y:iF&V%{Ф0'Ean8"Vl&4S8=XG?I XJiEs 5iM7/NeA~e(P7j;g7ϛw>2*xE;֤s t[`ҠPg-$(d:O=yR l?j`l6m"Xf kݓ %[l`w<UgPQӋ+W5!ߎ4(nƫ {AQ*W'vv?Vaˀ<:!s{(r "viW+rT/?2NQծ|pLCQd[ƈeęnxgJwD)*Cݯb&٦CEIT`HC*_uI-w3W˦*hW(/C$O[ګK[!e0VB2L{ sR鿨<iC*g@FUl*Xw2 Ғ"t{8MIhkLtvf-^y)R*lIe,\Og*|4e\䫀Woe$Й[oap }U?&5CY]Jp> 7-5y]3_tטbw*qZ^ԣosÙAp*H& v. r0F dЪEp1$* ?QUR}uȭ|09lجWiE`r>r(l t@+ Twk|Z'X vYG)LJy󫬸O7ɐp:wL#X` )rHQHS!]! D-\G7 @lG*)|;JSM׋=,a,4 NeYt~Y\ ߣ~>ݥ5 ¸%B7P}qѝzE l|g 5&) fCe@z*+Y>fOտ uf=zYa\9s<Hn%7z'fmMݜ]ETO|_ͼLe*Y:X*Hƅ}\ hYlz5V$5ʊlBAVGBAj6,-C2'4>JK|>Z FDq˸2n\'eFQv^%uIp{ X?ȽT8߱2䫶.GZK_ F[ͥv ,{ s{ 1}m/8^jeNyAZv/k͵}JЖT=[R;cUd܄w;䴼QjBv&e'L1ܰRf1_Z1tV4V__fz IeR-h/X fX7B#hѶ}HXt"ە2:KV.nO /msw;7:4;S Q3j>/CwI5ʒ0Uv:J)Bjo xI:.[Ok B,|CAd\t*8σv(^i=/Iip>xv(RYǮ5%pl3G z7V @M - ag90^MUT΋$\h^y=clt掠fDpEsH`p^#44L\P 3) WacG BҤ=k8l#ABn%+j n/G}凕@6(>8_8Ld+|yI[z2ܚɣ}d79CIzkY.(Er@6^K#勺WqZUP8 wrfeBk *E%?/Ghl 73j%p34ih\њV?W"ȝp,w)R0NY?R@uL1cNS_ *l{ZBE0TIM.gHz^$U(ۛ[Y5iЫ>?­cE/HoS%\xL~U%柧#ոYA1"^@l`xt4oiߊçڱP^_>n̹k51Ea^ 'bcmD_cD [_n(peo%d}"uC,W>…U8h#y (S0vIqzFnu-KSŨVه͑m݃=Qy ʜs>ՌAq`\AQݎFZ搽왬6ꬲct]]w:@t7CXc61hx:/~0]ׄ LggEM奠jV"AR?6 +&-~'pcb__/A`բ_\O I?0Mԩ ^ёyVb~H$6:'-ˑvfk)lkcHsәeXxS }`bMɿP.-;[8}Jy]_o[u7}РRZ†~>?G:YٹՀĞ*[\}~e(Z@~R ?idZNRݞ`AiȩkzT%%/;Nq1o,,4J&QȅjOHW `$c$PH,*sN[C]6LU8(Íͬ񜱙U jH=2a_+ q9ǥO.uI#GᚫЬa(ZΈ5Q+d{ps%^ f\rxXԛ[2HH! oĒ5c5D#p pخlL bwCnhJfO|ю@A /y *g25(unҷ n U!&4!ûZa). 2ˑ`rnLmVMrrɇ1G諸P(>߀Ϗ,>QCSd6σ) j6+ '"J+`[O +3 /(uچHz).l`cEL"xӁMk&)GjY'0k1)(gb͛`;`WK"t}/򬁐~HVH-95˳LzSb 7|eehD3E/ e7z>j?LXI)O>9Ch^aM&Mr(J>(U9[ yVx5t$53CK EG[nLD7.G&|/pv;Hmv[Px[W (2PT:~=9: [yʃJThOX K4  aӺ:UxzO1{\wS:Tf~s*WBR߱6Ќ0- .43X U=]H _187V}<~%EP{8V"^=̃&5Ad ΙNt9%qVۙ' YNHkқ5I_!Wrޮ%Hc;XImϻcAjމG,FoCkY͖q[9;FV÷N6G[<o>- KcE<|KGx^uZgXYEłQkL2wIIhWWײkÊ]VJ "bkw 3p9={Tb1_"&ތJ"d¿ofxؾz>{H5gJ#jvϗ w<O7viU0cw͝ZGOUAӶgBBP.gIo}OFio&_n U+d?3:o9cAPz3]!7Y_}U|&0=x+3Q9DJ@|;FOF!pi½L"*ihPJQ6ؓk1 :%OVUaQ&<~+S+=) 8V= ZZD®K!\OZyW`"?!L vmƁO(>r8X5[& $Ah|fk$PN%9F Z#>Ah܄%#֩9{ @] ZeD!BVnLt:?M[PI+=Q@v%@.)g4RK[:B=ح>چNYABq)lR<f EAr?**<5 ДBBq`.BG8¿>O1n]1 \o P<\-6]fvVGx;3v8~3z[#f;S X}'".DX/{C 3qTJvJa382 q|fٮaM$%:ĥ5t\p.7p/DMLK4G.sR?dN~ uH-2>x@ y0Z{SW1Lp$V[Z1G|㫌 ðBBUi#DAoTGJ\܏:;`o7TܙkP?"yRVz# p$ ٭3OU|1[s*Qؽ1 Y ʕ$D>M# _̱;TֱI;Uo8s/:rBn6Oڰ&; ~}w(#aqn]#'ˋvhTk$#$W F4qZ kȭpT7ho.g6>@+$l{DkD-".ZNH?W tPLY}U>ɪV/|YD U<ހ9M WQKM k7ikqUh6^lS`;G92ZW0C˂=_ jF+W ^`™~ʣVcDiI4?OAwy g?9=L.)Sz~4BNQpjjagޢLb`+\:WOJ·HB.puWdcY[H") 5 f_er;LG<(Z%WKWjv%Ew!02X9T0_ɸKV=zPgC[,xix]+2?nO^4Nm|o'pZ[rX WZ$-yŬ( Ou%0 wuضO= }W yWX1M2v9u>-%?$$Rjz]їmSK!nr W}q,,^5NpefZG4x1zth?}mP Ps3gX1s6Ir {߀7 ?E=<#iYp~cN;Ndb. wCmBuZS{R<_*wlzq 퐥"Ò3%!$GH׹ֈUr#rYסZ(y"&t' Kv".Tc<*vg9ڇĜq|iKw]/7sW)*6" oh$j7cHmE>M%mV ܍;AH">a{`hKmXcњih%꧆n"̷Wa`,Ҫe"]yLD^I0sU\ᄐh>o %ɎѸ"ĒBitdyE?^@ʬKkϴ;e Y !DZ7TG+ov~+h)$'72UY0cOϬU䩚b!mX 7z[m#*%45y:y&l4)cn,bp1-3V^:G~Ug'kVMO.!/t!gKtT:{6i>h;1RJ4~s{ԶE%w$96:Z\OCq7W"m'$T1c?[S`Q"CƢ,m2\ˑwCz]&K2HBB:Na4"dr(rN2ݿNP (ę̀ ܫØ)u51$&=4*³`Џ8\5l$ZG&YE}c$;.YP0O:>ZbDgX7̓y /dBh#T$s=sm!4UX!Q u4ǯUDD J8۽Uܜai3y?t73reNfTd(z3[OHihu^Nv!dN$.cN3*Sǜ_"tx>sƸ!3EuOeUp$Ux;h<< \`.)CS+`llsqj ? ^9o[ɸ =;DR:b Z "[f ~q0EW*^c|f\t-<N=OЫP_8*L lB .p-f#d dqThW h0+$xE" uNLx"sY9}&[4 ktȺ _gBs\Ta554koc|]5uia [!FO7H ֎ϴJ]  T*R5g1.o͞ިxRܿ6B-צ_YNt1sb-^Y٨ՎFWn m·9p+MU_Wzbpe.${w1L|=05JNT-' ~E9-b'5GnbnY|ayu!,r\flO"2{Ry2^:Ҕjn<iF5r5F+z)>0@+r0ѼVe ڹ$7;x8GAݍdi2/πuFw컆F+ Z^!erH-FMH.-HWa hVjIXq+05(tc“6C1*儊Vr [J5Ѷౣ(km$8p;lCC"?W*e `j*ot[\-}c~0 g T֓p5-+mG#P<^)3Glh66kDVki? Kg߀֠ +:­JŠR%lLW֐D‚k =Ŵ6 wɱ_ ujZɪWܷ#z,w*G wWSOk0١U%MH\\֊7pܮ_զphΡ%0YswTM_ƼU'%g(fg{u(ބ$X!1a1MZ !M:ԘI~=3!qqU\ mQ95zIBr5s3 L"Ç!y:,s ~ tۓJ7Wb狳N Vc^P9{n@Fep 0Uܽ$ ƽs>ciĈc:-`VΆXuJ]~y8/"{%'B֡}X6OcNAص# m[l3 JXoG :5[A wFE0k6[HEpaNM1|h?xPgJ J11ji:^frђ}p9H"m^)ŗtxjRC8DJ;sE_ܴ`6-0"Ѓ1/F: 3Mgl=[|vsIBp&]~)IyKuDbj.]b$HITZrmΝ8VBo!H%aeI.x cqgȵr#$Fǻ⼨nkj}х˻Py'iqΜ>Z)Hr_Pl3/o`mOao=Ӳ`[O8"J^mZ1GբOn'I/Ϣx'[SGul8!ҁwOsf?"]k= E#nvc_z# )Š*_>[gik<'.s3z1{}^$yQOu&{#@ >PJ/x< |W)yx7)1۔jsbPxY,dwý I,z0~_ ^A/H,8SR_)~C&he]:Q}DaEH~ s$W3_EAX&ami#&{.S`kBr>X揪dxtjT" ,Hߞ ˑh7Oūy{f_Ϙ xٝrﳘjlZL߆B7aN(P =vzwE^ -)^=XN&~F?7FpvCÄ`75\ymތRS8P cEkkr=ՄAӱ1tu_A6,LiuXRaB_Q@kZQF7eptٵƻ=ĝr V"̂vx =բVm\ޯicIƯ4Z\Sk} YFF#`zvwɆ ZI24lt|e0wIìzFo xj JJ)q]nß 6f~n4דtK jB^uct l엪h>!NgꌫIgTREE"~","X"`"3 "-"O"4r"`"b"0"" "<C"hfex^ ?]m I)3{P{/RJRPDSlYID2~9TH(R)EosZ)?̔jVj jRZ8>.G e2k, ݧLhɘL]PAD-wD>1gn6W)}%+Jpp/~ՆwC`u `xѻR<<"R1Ua>P F &jI\>u:Zr KlSh~OeEBfCt&h>Ŗ^/z(_ 7CWh0WB v?6ugͪkqNoN J~T⊃A3iz No ;٢8F:#W|Eh]sZ=PUKp|EW,;hGg6ڮĝ]tQ,@WB߈SC*V)lۿboҞ;ٵYL$dKF8>g [Ɏ?o>bjfOwA;Cto^usqU^[~4^yo:u?us'gʾ@2?дL9dv'4fӢ*+'b[yiOJ}\, \Pu}߈j+eNKMJG,UBup"0z$#x>-za*jPKFu(Ы*SL}h`mf 'ѝ{@r}B~@TQ(J~kKM !a7f,w8ۍi/1\d^#vsR+Fч2vC&$e*\7Z1lR)V #t@R l'`xh6cǐa)c*1b\+h + -\NP89cXqo P[I7t- 0T ?j ֭Y h2>-8g@<< %96%cݩL-u2^EɨЊfMः91 Lcp5zŝ_R53.gvcӊ'K1()YǦf6ٻ}m}ظqWQC|^9^JR&ʧ1p/ycDz;|dY{e>ϸ9"l8fzpr nʑJ__Ν$dMLOQPv) \y5Ȼ* T8QJ"A3\p|"ZiJ~^XG>•bpHXl}εDotjE 'j-%PHc ;:"`KU#},kz'\?a DznH->c':7Y l[:9iu\}j#!UέϗJ}Y@K\HdEM3`xO`n1^/ΧZMoȦͿ wN4 V~r*+Ja{ N)ҳL sW q0|Vv(!c<yN8f?iluE.< M`} u1YIO_Sz7n{~3G}s>}uu`^S!c^3]{F }*<}DOFQ+jϤoǴA*968U'n5́&F ^=bkKEYFQiv0]7~ GHr~KEUl1dxU}ŏe0ݑ˥>h%%OǢR8NWGAE w#Ng&}vئep'k"5z}__ӜyWWTT? :Msice$&=]9;Rj+2yzHg=ݴOdlv({ fo7dPY"6£2=&9 m~[ haE)~je h[.M3{%rs,BuȪƢ[KpOyT~%뛆߁QȖdazh̝3w6-  S! ZbSd?w2)b:5 >LdX<u+R+.&dik9v;,0Oh7rz {L'zrmj6 VTO0S} NH9'k搎%馼WZ qKbGᧉ.P2L/ʹؾì7'7҈~C,5:'WuA>xZr1G߉P:!^gG&7HNcۃPӡRr!*VMq+v/4xL*Q@ˈO%e~_L|S91߹}?.kiy9dz8x6HZ5Qg6]u(ÍX(nc$3jX-ėZ>+M۳xd63a|*qծ!b./a=(-Ih/,*_/*w qmTõd{о)\TֽْL=XA<yַμpHK!n`wu6QqSכOG6 :q=D": ZCvԟTcghy;%%0/ML01$ 7ڏlwjo~?>O/E;?=kc# ueySO1~QeR2KJg^9'.E \ Jm90MZr)s٭5Y_eƴ7Es n#6hvTA0EtfE~UsNžןC\_; oM7|@ `J0Xr}*o |IPO؂J*04 NJ,P I;(,3}Yu_\s.A,jHzAU X>_2?)#'v)r7pv /n1X^.fY -!^V3 tc;ī|2c f 9~}Cܷ@=Yh~RA{io Ou%@%Bt%bZ~FJ\7}D;~b0+//~"zQ(*}G/u')RT0"<,CtcUWaɠrCc3řSoKDO5l@/9j{QlTWA|쀳!^<Dϱ;-aSJe8{O|T!ޟuQH~(K9wtϗ̲"1l[l2\jndڕl[5+u@&U~j]LJJ|||ϙ_ yAlZ )1#h65^:\4Ḽ'N–O7Ұ=9 پ~[=,d MILq&@;ZݮCLΉgn٧`=fOmKa=ƴ s`CW6V fE K4w7?sUŲ]=l?8SMZ:EfCFTrFpre廀uxx+auLj;a:X{0T@ǢRd׌ׂ==wIfs _CdFe G&S'"Ϗ Y yVq Wq b⃰icV\n +S1BfKY ƆMםcw0 :fD"&8* SS'EVlX6},T4] (ؘVȽtTKORTf!>vmo~R[KjNs-U!q;OMn˾q~>UC`LRa7sQ "xHѳJ5 JIx{.N`7+庳HCUAҚM ',~ 4&ؼ: =S|Iat;pb+-nk{H5/,y+eù|LaP;XG^=ŞxC 'n!kXl~Fƾi`UoB)'zN}И|~CgɐW# <,ރŮ/w(YiwO8cSVLyVUk6;fdu87^YOัC_޶4Wrvb)2?T^v2ox=qV>v CgQ,&~{eDaRa ;AI mm zYrl݋ /ė pw(=c}uM9~T_[v/[x'uX+Jn Cǫt鐐z1/wNl,^#52}G6JXj[\8Ufg0j,zYAe."\@-mqa콧x? 1 _'|4;]>IL~-F>o؎20y$21{r8*~?kqzYg%%..4b!n^-uUlKכ(t|—6+ew&{~%f҇8Pֻ!9| 58n)~2n,mDs31d1G'Z1eK5UЪ6ѕ5-Tǐg@5ٝbd{u,o qӌ} 1iy>co9{(Wx[dzB,`!%q4-W3P7+6ߞU-LnN8\& 7E|<|K`f;j;\#1&83E]#4/NHH٫P8<=Nٜj)eRhqT@kF6m=H眠85}\IUWD.ԝiQgyoמFCAǵ) ;U÷R?"wZ ضd.[8^D5"ϑ>*(8'-?spϏN0)w{Sto1N h53=?cVp&-p".0Fz23c;\٪ 烫"\Q$7+2\M6i z햓MMNvBcY@]aQ0w2D'h$Zʝxfsa*vo,G`)gctcl۱UtKɋXGi9tѪ >>!S7/?Sx5C R$.ҟm_;϶3V:L*2}4dw2eNΚRn;'js٠pAO Qw9?I4ԡ#8LM]j: E^tdaN[hLAhkG% uAhQLwsCeIRQ,oxN"'G3!:z_8vQТ;qO$=*al^l /s6ϋ:ƂJfA-dkyC&DqZ9~+!m8No Vu@ކE'7MLxz;atP6 6Ńkb myܥ5XjSr\`hSm?93h[$gm3ґ_pN{Tj`gow\ݶ*_,&yWbyl׼ Cѐ{p(h2<2F__jR%.[ WiyG,NF dEQ3͒ 8G>Xk )@?pj;7S`{wj~Mʄ@:uK砳` (蓮lhٴ琰ŮK(5`VW9x(Kx^ ?oB"㛑RFC* ҠP**DCTDRZDi}Ji(RdDEEH?88_qC[vNJgEٗx$+ϲ`e"rb4q?bhvzzR3˽ainղ0 ^t blE؇G Ήϩ0v>-'R]iፈ =/Lkhx2eĭ[q\+` jRD.I!Eے#K6BSe-Z)ÿctfzvcbfk>?&'uaLxjj;Qz=roO'm?%nK0aYXnc¯{L| _Jc/4N= aVY.(%7i\t_5&'z l˳ؠ/7燶1Oc.9/~͗\Ӄy]< N'Ww]J\nve"?Mzs8۾1tdX`.;g#5>0rn/kDީ!$~7T%5ȳ0IofMbC2!u?Vokel?b -DOut&Ϧou::8~qƼU~" bԊ}8 kt+q$>S'pC%DΠ TIOrj$B%cRIGۡt*k_61$pJ?nlb5 <}2pud;L@Sn'(S9ۖY{7bjMʥ:c#'=s(tD]{o -('85|kFe+a5U1~Qm6\=H1QnMY*ٺAxp]'.Ļ{GZd&G֫1⍋dy(aRMs6I'u@e$ ABqXl+N^pwhܭGdi"{'Y{eپ Ȓ,ȋ[ؒ3 fT/í.^_bgҙsax76YW y=nF/zp1tGPuU`WՆ,ͻ`>9/npW# :g}NY2_Kq? ˟ec!r<lglUϖL?Rt۵/|[Wƒ\D?2)6` ;2#-h9wQ gϳm/#?fg?{n1kz%{Fr_=v^N1QE ? YE4v',9q?b+@ٞ٥3^Ns 0r2e9NtIF͈uT,NWA(PD?Ig?d M=GA<>׾2}|Z Sרf\.O :){T]6ݱɽu d=?O, Eﱫ5g>UOqay;:#Lx>E; P9eR` ]EQ)% uPYp 6a{h:: ,<|ux)^2Ղ z`o.myjq71~JxZåjABPړK3JP3[l[@G\R2bl^$}SW"ϺõW |W*l'(2pZNNeeĄDy}\:WوE{؟HqOnC_D%jrudA)cN Y!:VvuXnQk_s^Sג$n$~X;=Wvk M(&êdYyjYώ5 P`p(YD[ȟ rӛ@n-H[K`x.mak'R U!n9[{B+%ٯ_":&V?7[MHܗܯsJd9rO8pokm#m#8V'<'_cZQ2J7$4SjiŞrm;G"e$,lAkK`3w̓rg˫:bt1cV={Lp[]IIFtӀx6(ijPw "IV:u)gEؒj`@Om4QőJڶ<)Q3§bPV(WIw.[D}宁yj9"! -5G`٥"2~#2H '6 2\fUеI܆&j`W!7&^{A ̍W}yTRxY߂⺎/%6BҸVQtu/w:дx;ڿ+.i)j17˂|Q:FSIO`O^?mEf m N&__>vKʺLHS)˽d=0|FuCEؾ?~S /SE-W ^,kWn*ne9:LwQE*VK?5|,H^S0&5x"~ buE2-x5Dzr'w+Fx?-7_g7g骻8NJ3 }3^{Nÿc(s5YnHMر_qh##Sxmec'k)a?Q'g! $FjT)OmhK:. MBnhLSŚ҆ iIwXy6Ae;kȲʼ9!Lvd{ϗ&0OcG%^A-?c|iGKD0KW᪜,20hwOgDd،-6ɏ~[ٽ$ϻng)k[ PUCU.6p0rUiTGV 9U1UX-?lg쑑?;vq3l>mY|_18>EE&av!zqQy-&.B,G'![`P!v4k1R';$A+yeS~%ShI'TXFc$"i_]0IcI}JODzxn>M0W#VS4m]UkY]\3k7AlF|7? R`>=|2Jg=_&Z+q4Nl- ׳ ٸȼ"]P4/q>txh,yy @2QfpnA0ESG}[p9~:8[airJ÷#kOLf} y~{/^cSGYpU=Dϒ!>d~s,gՂHT҃T E5-PbjKG,rĂEtq^w{J>k*AZl\_ꏦ(VfXrR`U>X %Q|U6 Kbru>ť0pvwfgb9/IEh:ِcW 1D?rw ֧rW[UP2STdL"!Ȟ6F65,?~ک-o4c2[o?Kz~Fq_PJo= u_# \ҧk5yw<,3MZJs]_-1q )<.QK `0t7d&x؝ GQd]7aż(03 s]CpQul s 18fCP:xoͻߘ}"a٠ϽkeMO.Ws14p!SUaJ홍'gcr naJmjN.ęŎoxHԖvDcN}k"Υ&v9]o+ P+-EGE5ϓƲ+‰ (~'%ݦdDy׀qiBSBj3zn﫢D [] x9%Jg–MhB\ٛ~ljd;AG\^akfLK'le`,ky/(J(!uK/TrMƱeb,+̂[ȏR 5fM+Vdҩzrx v´ߎ4{l;9,rroKDg"ggِ8lDGc $2? Ħϔ9F U[ YLrlW ~#ל*p 5=*¸֟b2,_gbx956sN`9\V, N.=lL]JLc"_(eΧJ 9G&۶^i} 2(*UH FɌo!_6r$/zC_ ^±ɂMNG}42™"}E?]z #7X|LPwLl&n\]WIHcf it'0Hٸ.¥(En1Z|3!;J)J|bpӃHC U9 r0`E0>5 ;qI":]&BăBj}W/^q,r9lPq-J>0YOLD香 { =BH SKWM+Kʛ!?[ҺtO1g8c:{SOkB̀MU-`'h=[_j{?+J9RQ#8l EI) o õxCRRI? $w'k?&q?/'J/EeIR-sMXӾ ʭuiWaWtZ˱I]OXW]+A{k.;8P\6)K}nYF1`)O '7t0YCG=.Ph}5=ӝl6/DR&J Grr\pixhe@%@zja&@A]`[KsdWyQ wf=^?hI_^k -(E_K%dw_I=Wْr^ޒ 2wqռ(_1cAR,ٷуdz>&5=yfM$ + {Aɡk$w9\I`Ղq\`'8+ s/v>Jܥ[pZ >%Ēku1s\>Ll f NX1l]ɗ`3T)f5Œሸ$:&wסB /=DSj4<熝7GvwQ2Ľ=c3_mQC\~- o&TV >_Y>pY?Mc'kx6X0{@It8Z{py ‡zuC <$d*K7KSOanYLvIن&|iU |kn.dW:WR0s-$-bdK zk$ $~{1~I΂Wr-|-HSѴKm`%.rauo5gfyU0V*6&þG`[M;'@?$Hrх=Ϲӷ169J"O o_I{:Ow|ló-"l_|U7<*pEM]DfӦSLGn:KtFλh4s%T\°.0#m<єj¥I_4"{<.Xt 7;ЬP3L LnҶ'`UrڶVtIIM9+Y4猃򸘞xVavԛEE<{xx8MZA#4ǧv7RDJ}Kg#riɢX%ո d̵ڊ/Ub)XL >?.׻atL|KWatZr_\03'fox.~YWiV37+ߌ1NOq:b,CdBdUM0cVJ7[>AAX맡lEz`$v  ,8'*fG3Nbo9L~]&w$7Un`:##ҏiQAؔ]BJKdXq2SXIo@EFҋtǪ ސ?6+ 0}r4#>sIXiuԉ,Rrt(R{Q4x^ [\"}'%)* ;HeI(ED" m$iU)"Ң;!{=CI`gΜ3g{9}_Od9(P~KhBmu9 WmT6Y´qTI+|,?ηcY퀩3I<؜J +ajbJM>G 'I4`Fm#Xucn\qR,rYŝWlrsh\ MAIo-rZaӵ4_AMKSllta> {E iilE+ s7! t͖o9"D3jtމ,t8^;/ 71yo)pZn+a9x_j?j,CB-O?@ ܸYJ"z({CuQz n pJkm m1H=6XB1)l|ĸxStGu CGK_qJK t] OtAC0}@5"ek[ (KFa.,A]hFփ4w5nzq8\\Bk)5| f%V`S-> aGؗdkJ$c-6Y18/LG^f@Dqx{+o'0]*ѨA-!X*uqz$|JZRY 2@pRQsI"yC:A]P}d>?= /RbZUISE%F&ncceZ%S!f1ltk-Z7쑩U}z:Kh1`xfHq2-"Qw1uU+y)6F%7ME{͂zʷ ]M6> ,> TibAav6?t(Tvmua+qŭdi:v꩝xouxstOMK "W?Gw"ܨr|aͿmhU+ h`k7RA#`bz Fs!+J`p0!:xpr~0<>Y y.w2]J$PBzٚsa#yd&?O3 ~:dsو^GҶa=7_i?1N~w~m^c;ve4@}BtmG[T,ѱq#ߧxIU":q"]̥} z g8n&՘Dg-&<ۉc猩8oo))@p|Ta@ +4t']:xh"5{0OmC$ڹhOkGozntI75__*必QW=gė]ϨI-7s1r&8lШg<ݽNVf3at{4u./BL6婁hM%2{72fڼPxn(nk|/DC]>^WN`̫M먽GW٨B A\>I7Qy.˒*(ʋIfpKU׈2w}.779Y<ɤ<奡Q|cA^Ҩ\%͞-xolV4=S>W6W6a]!|X&d+RpJ6og"CnJܳw+2`tp0vz+]9tH]$ͲDhy'U269(48Hýf\,x@+79v%*ϟqЯ)F"% 7snSIurzi ë# ҈W(ޫ z%_OM+rtm4yl7l-G,/ܠNN[su߫ײ4h )ΚOk@ɂ S@B҈0~_K1a{h]=ŽW^Ɋu?Fa gR?ndRDj6mEX⚥ + c%̽?Spm~NxV+95R)^Ȫ峬ˠ/Rg&O&~ϸ,ܒ9CGj'"&H?hGTR=o}PpLvw'(a}xI\0NgQ_]@ᅦFHzB0K݁;-02##\$sRSڙIT<]@uO(QǠYڔe2Q5]ZKIaTTtc|gDoT;WfTY#,J5Tu5xk\ gq00H^ϋ+.<`"7'5R%l$l$(ήUIDM|I(+ESQ c0B$wwvYbۛD$c 26?y#F?;]\(ڭ#g?Ztx1! iYD3ne rS:VYʃ\Y#-E|Ū`ߏWlނb: ǎn>;Z?@ ͎x6 0|K*\ DUXS@ڊoM;5ޫipYs;0WoMyw_pT͐V/rqۖ=U&HBpR70qCc2KxΜom\l*튒ߒ%Swh7|F~zӶG-E~]dZl=Ye?(XUo/Cự 6x 2{6^Md(+ޓ&^=ىo{WA m1X*i(,gb&\~5<Uݒk+p{[x g/^ ewo[>"֔ԭ̋]wԨĸzLzg-(߄I|.dŰ"q)=QoQYh|K@'poɚxv[!}PHAWzN)'3&@YY9 03^޾:ְ|$T&kO&Y._döǯ`n͎]GAKڐL|3Y5JW .~JӿQ!W/mtKtd҉`AɍB*=ɀ$Ps~ӟ.V…_ϭR1(8g[K-c`<دֆ˖ꐡVEm@?5Av[SIo<Quɸ[,/Gcᙱls8ԤŇ#LrgVQmA [?{΀^ 3{icXꫨ`:VbIJc-hm*#5 ,lgmHd΃ìWL!V|:M pқ;{Dl"OIevg,0/% jApа\)]&Ȝ΃{ʭTeq\%43 l9|t_^f:F{2j Rd3ܖKVoݏ2?X\_/ O(GQ%3yH(˞FC{o;aPT&NR4mHN{VZkC01~.( _wbҥXq_Y5""UIC6 @bU1~sA6.NWNkl$®k[2鼙1l58؝X 9K@E0c^wRO[XߏE-4ڙ 1ت@R|IpMeCPCWIM9nYu7muS/Oҩ67 9M]ʢ-\=zŅ R١r vF(aĉ8T5X[T>#ny84/dbԥ@W@>0v}<ޱz>?&M]7\VvaXQMl=wqOב0w d;kq YI҅Z(; O x%>΍˧Ja# ͛ OVgT^8B$qn;H[b@oh=[m6Pt z렫|AƹONI)/ w/Q*\ IQpL՝M^t6%cT8'}.(TN0k,OeLJЯM4Cˇ5N?Ou' Kmx97`oGsx[F.HᏦ,^|T|G<k_]TZYyOȥEJ%eOt(>+R ^}ZXϵIAś<#_yvxrU擜 \ٔ6Br>UF"ؕmߐ9n?%-1%iQ玾̆G 2`\ΰяA ( :Tg"׸c~eM,*cn‡m! jk=jtɫLL +;$EV+c,S}|à?ﻱ9PE^i7xtޗЯL4ɂֆt6EWP6ch&ϦCٱffh/^HuP 9{43O[C|nu(ڍz߂{ &{;7@Wi妚CXQ|(˂LDq"Ʌa5Ƚ'my O뀯\dPkT>LTjJO(}|Y(iG Mk̏ ,{s#$B1:";va#ڮ]^UaK!_N zS2 g"L)D FƱҼ&.=:LOڒI\_ݙMSH\ΟPwrG_nڅ ް*%($+wRt(gNSXl*˄.9%V?6o&5~yS?&f/{S8o[*d^.N M\v]wrQ|CaKkz=9kba΁lBe8bplOv;R%HՊUT~|x{>]I^'()ÝD]c04'J/L";^!ȭgC=M8m #8ammRy5(՟wVrLC=a ^.'چEWVU;OxhǙykp vn.-|ٷ 4X6 K`D }l9/ O)4(+&4#[Uٍ{f֢:M "BlluoJઅ<# Ҕ!`wJtmt;W1Y48:cAJпYZ6"C)#o}/_}eøLbZmٌIZ5pk`k:|C r\yٕ5mEQfUiV? -G0J6XB2?XZ[HkQl%r_Gs(;4g!}aB=طvgXt@dl `i/¹Vp/^FAP97f=7CT!97c鍃䁛+Vsw )q2 =9u5bRyKx\G'IP8 f+!}`C+r<ځ ?\ok'k#*{\?d{DZIĘ"657VgŰ!;Nx'n17Cd:Pz%&@k?h=WyP(3 iLa+.C1|ǘt}5 kit|;RkW5UX[RzR>Va쓢Lql v|+{·2]l5xP}YYq )\= 24d; luu.{32Zg,M Vjq 27ͨ71 kraT25>jbgm^uph>H蜊y)CtufApt)sڇWK؝u3Y)(v pfu|t :tus]$Y*lvI'n>kgZ`> lu ?N8?eTӤ_)or[:ZMxRffcH/ L{quߕ'zpy` `1@s>1ǂѤq$:\FȱjyuUʯnp…~ *w紲*BB$\HEL8WlTtDG֗7g6|[mDR u GaF~wdKڧXq9j(x'tct"Ԗ.2|vp$w)*{p5E@dx^-{T 1}z#w]gVgZ6}wMY?"FJFG6?ɀ&i7]6SM0]^#ڮы$j|T|^)l(!*MFyT[;يg30orXߺBTE՛q\l~?S3{7(AмP#:ac~\N1t͋枷jlp:H9Ԫ<oNZR0 z)_E;F &wWc{|ɁQ>(PWz7XaR#Z[&h'E#5i_=fbqZ>%LLҠ 섷 G.hq}? Je2I \NA; "Ҏ4lk!b8=S]KwQ2l,ûtD{j }2>[dfh 㭿}a0Jقe)`$,5j`ڧ0yV,J ̓dŮR׍YL~܀xyWԝ`sޙ=*ُڅr:.((1ѡW|&Y:(ǣs'O+deuRX_I\|0浚i G~(ƉSXU6@w2p&.bݺ7k&,]|ʓpueX2L֌Ơ Azk';' +OF&|MLdzvXY3!@t{L^'0$vЏi$ 7Z^X𠄛8\OͻMdWZ~zK3(DI(,VE #0m0 lY~eߔߑX`B`}&?-٪{^?ŀ"&啰NǮ$V^ӝ^8'%NIwDix%֗ʿځ"H Mqiv=JaݭQ[]˵43mh:[Ʌtj zO-52")([yÅ*Ib6Fb) bvhe!FLƉsVKWO^ !ƅ;]%ŕj0rKMCxwU=d9{,"X ȹYҒVr|5qTm(ꮳ azT7U!$&ƮcТ_JQ!#Cu>qU->ΛBw#&W"}K%x@[ڲxR$KapNo#Sp«ؼYœPu 8F4φ;*Ss_#~{`ibs^<)ômDtQ$Ȫqf07.Bp%vx< ٗyF$Qل"Ƴ<ϿBň+f|[^3$gLGezT,x&4 _ |ȼT^H|{-ri-y->W8C /aaxz3%8(cOtnOOfF)NhTUmhF|*5 +p1D _wYwJLl<9YJg<>WY]~ Sĩí Va6?!{T]d Y~Mj?Igqz8Һ $_g%_ÅƂwLZ_X}1$FطB%&C} цGaX:聰'@-K2e>,C|%dp86RYyp3EŽjUy C|JeǀD*UB lmG2 nfcǵWDܟVlNAZb$M.fBJ3n՟7ƔAz %fZ*;.g‰]`ǖgɑlEѽ%O8w( ʽx֟s,_5VWOV|YtML4L^]? y\`2_t|.)EӍDƳՁeVX5ߌ^w}<$%.{4ǒkw l `ČoLluZoO<43$DZVP [8oA D=?-DK07sхH]0&`~+*ϦewGDrB]=u;ʃ>l¯07/ۿEeg!σ!Z3n'X/_~δ/cźO"3(SgO<` n^_cvu"^:e#HyoUл? cɐ9Xynf6bvy z,.?D',TaJ!4Sq{hYv`Clw+6ٓ쒿1{y>}bJcDPtŀ6rnu[ط5YW0oW0-)$Ơ0gSsHѰmM9MTo1=|]eJ]_ qrj6UDCgXj-dSF?PX %/sa5]0];哮Kx_QYjl[aF?L/ͱ|Ko>%}"W8.GD1Ob+ˁ+H7o#ZjHKai}z3LrXP^W?h^o.߬Qj5céc c2F^{-/#0-[}Xsw ᾼښ[,'la?ѲSR9S,IGg|d0 K|hDapaEo8j1VYcxH޹d 1>ՇA#z2}0 [] mqhjmgNgEZ6WVu(veitХ/]p[ gO &0Ѐ O@TQߏxt0Vڋ qV*Wi:k70χJ0}s\ b#cjH|jPN:eƙGP([F}SN`K;\FdwE=)dѥI2WBDk4A%Y+M8|-S\`*2~Vf`(< ;uN]/qhqj ?e7Hޱfmi,2Ղ>rH$헛L<#c>郼pᆌ"j{Խ݌!"h_kR _0e)qBϓXL.Rny[͏ĠZb4:܀ױ#CS9硲?w*i+m>/UӝCʀ z᤮,|O6Ah:˔"d~/^m, uξwzܨo[os;Oyըuf7؏&ca_Op0;C'| g.]q"m`Ū$+5FdI#k5,/۾Jl`Т^&ڡSDG&`M{(kglsEϭ/s xl 0 aR6uQx)uU86~E_$x^ i4]%"HdTBI/sR)JsRIE!SʜJ*E< EJDB%o^KrV ]T%x>~qqau2n *uq=WTw!s6>Il}C` 񙠥 S{A-l-c41ET Ly:񄽗sN$ҊzWjѰYص*FnEv,['Rpu=׬x\Iϔ_rCg8n`C'?(¸%9rFR=H;YE휱Y->Kj-6tPZ4MͭſIx#5!mu9@\HP8h؅Vgh>r_xeM͈9=FOFa'NVb6wWfr^O]R{piVs,֯ݤa %Z2Ep\[6+ Y(Yk&Al X+^EIׅƣ|`:N= eGSGl&xoIC˳4S<]7l%_baI0R/ ~Wgw>x`P5$bqП}[7qS Nsz"f%u IVܩytd,Eaci{vv a4=*eޟIWv_._LZ6N=K5\W: ]j$Slp`E4ߗІfI}fDiS%aUE  ԦC:)LmVm]݋(SrGs]e{^|_'$7e=QU^Ĝ'l6wt J>K&ݠEklbƴ/"XE>:,5fҗ86rNS3 K"Ht@ݡWzA߿7̞o.-dxJ^e"G(wJ ls6l}#s)>/h!pM [ȊIxxf^x.wV4Q5$[JE}*?^aR%{|IЂtN2p;& f087,Jϛ[: c',oPX9+z:.imx,zz }p#;!d٪*wLgl7rDh?-EWqnyDdW_OjU[7/` t՗״d3ɑLq*f)>Ѭ6u3෶8=ÆcI@oDCo|T8cbbnރF}7D HlvS@ݸ>,W.T,KjM'!~C+ 7]WχR\1;x>NtU ϱAau<_r??\$99ihAC冉&sx?6;;=Pa'Byvkȯ*t)sWlR•vx.e7T]yKJN}ƷrחTW> : l M=9m&@/G Ί¾]T-óly竑ZCԎ6$<Kakv+hkiWSj8ZavV_⮍p~y\y`$HH/b?hqDZn=K%`xD"4:T`zD#@mHEgh1c}"'%#pm&Hx%N(iq6֧\^X4 3 1d *wa뭤9%Ny 0 (( ;ƔAIc!fM uxd1$8 0c)Dž,,$2iz <+eo鹓v%?uc^|V1Bm5SW6ÊvjH< ΂@'ٌِ/KG[p`>:&vZ sT֬ףp୹S3*{wSW 4 r qCufwr*sg@l$jA-I(- zpmӿ`Rj2Qx΅`ev !bxX?]ȂSkntY3L NvXMo()>R\L= hY1si \EBPf2:tN(}S{[? oN?=4~2)s18|WE8 M\m,V GT+mѲJ8In{=m#rJ&Tpmoeq^BɍJ@W:]u=h})ߙEEeڪ'qsoiL E/kXflÐ*2pT:G$2BjŋL}+=YG$>?@8>5&./P!"U հħZzK3]G#S׍ϱ&׹%p`t!{,.Ƃ]%3oW5ACniV@ܬ2LuZH (f{V΅#X{~+@IE-R&s6` 66Lj>uSƧ>\+ ^ ف:JGeJxzxndY&L}Qye3glX4ӤHc<:ޝuTz<ʍ,[YС8@aSi|c2Yno!HPtGOA(x~GCqA,ߚ$σ^DÙj.Lއ8gxq(hߒ)WJ@ gRc\4 6 UzOf r"X"+c?{{ X@)Ӄ?UqUEM UHoʱ'31@s%V^璮|U3xdKDlQ'+S+B1-"a0HOγ_t?qL;mu܉GBL6eG LYd OGIKo*!_ :h?%i֘r[:V󉕒Wdǽ1V"&h|><@ew:*1Ķ/V::IC[yQk9"z0w\9n81fw~6ra{|'>we0A ۱ss+NMJ>J[< 3Òdl+w{Jص>[6Pނ$|0̏# C8 Und҉Z/03(&Mx 3W抳;uh Cj ZJœ]=~;+6x842cqxg/֎g?%VyxݪWSƳ,cuE>wI3!Y`(ӣc/V(]D%.ŠƊ1P7 a5pLE_ / p!8TB?NO0iM|&MliYM* kA4 F.]>{ƒo+\>ITg uqL=ln=HXMS\AX ,M>J-Ňe̕eq~$\]~/ Exjyar61WvX"5 kKe;fWOG|I5o8)=6m4ǻ~W 3-Ek!rjʉ\Zg {tf R[Q68C4؊ʱʑȶp&gD|?W7$⹖nm. kAxYp~je ;6O1ޅq%$A̋Pv#ht u`i6ƸnY= `9,{0b7}a*!MGvbP 8U(~mFg$W>'&E6C%T^jBYγRO^dI-Y BNzpw>'*ʮ OmR _9Yz>ʿGd$ޒTtj͉:SX~4A&H*,=Nw,{/ r MuCG["h V6|'j.ħ_7DJ5-x&L卺FXX"UW"YY8+ \f+N{u,`c^ ~M47JvOXa/ y6$Y}9yoz>Ybx\ҀuJ3~&uf}$jGxoE5{ G_ (o?Of8,YXz0fq CXv.w!u" A'!In d8\-Πs!yNA1EANu]cl:\((OL6a~V54oUn0v'XȱޮD= \4Y)Vx__L>kP׹SC)dkeRyxLpNKeU̙8V)TTl<:拡w$7Eǰ` udBϱJ*hla3EmXE.=_|R]S:ʞ&[iB%hOncR dM?Էʌ#j*qm80 Wcqn3w%&L8Ah*m֮.: rޟtr*epx;LxF(b<2hjPaHzTZ O/{jRicD?kD4J\bYcb>oU:獇I\NC$ViIG0;fŻpm^< 8Y/&Óx9}AJ`3Dn٭㶲ӄ|PwY*OV^l\yeXv7}IDH~Q.oVR83JE iJ:]>X_4Nȳ/߲Tjn<n/_l.mUwș=>v$Bs׏"pg%iO~!c|9Ō0_Q5}77Kͬ 6pR^8N6I?`13Ã3h> ՛05cGfo8{Cp2d ^@u{ <ć#6xt}LW>*o>cM{;1 >ziˈ('!K vy]-I^|l COgq!C2dA|,G+XwX-ⱆ܎ L.۬H-Z勐6~x6n'*`/?(bȌos`'Y܉( -"rnw(DLO:e2QqpWe".mh"{ K% M:( h fK]~5bƦ`\Wz ;t-OLCrq߹NM(Gp')ilýs 3\8 RRD١:0JXC{CMk~3,bl& Y_v+ƹ[Ӵ}L t2y׳ Yf9GvPś4l&KT&}v'9&/Kq *g٦boHU[J ϰz70X`5>os#4㳟b0/Þ4M?uPב+:s謶(d)~~EIg2UB$|ܶXc;Lc)[&Y2'phWRXh3},莄Sanaw7/píѠnѕ ~[*ZoiHS4i† ]*ISDɪd܂`g$>^N}pؤ0w:c yϴcج2jԛ_ _sB_GLo=a[døs5Etj>utR7ZȚ7 w͜C %dzwBB(^o'[ߎ"pXUk謻GN٫`ubu-xAtl{q:+.3x夜MrG0lW40.^FOِ};*M:VJ_O{!urj ;),b?! Kx&xN;mzp'(^=w"Fn#zdTvKSh0/beH2'^9\[ ZMUN;FTdl;B_ɪhw:5eaJ6H<+Ϩ1a<^H\8犡F%hl)4Foѓgry?*28/R'7y\ .c,TO#'264KpY<7P7UP7et;>P(a+9X*7! ZW?.D-[\ 3q Oǵ储y3ĝx]Q5(Xu7رYek$ufF4I62ob̓Ʌ5%7@Gs"Mb4j N1Q2 ^ UX5jҏd`%w4&AG1' {$-Wx,պˏvwK(NRx-lxRѻt2`Nmyb1Ug?QT94$Dǯ^so`U4 _F}Wyk(qek`F9Y^VawM (}4 e ?KLʄ|հ^'6iM,)KyJK"+#M4dWA<+DxѺ,[dn3g}(>V0t/.o'bO=J+F Y`>>eY1YsƍƜq\Zo~Sq5L}WI q:\X=jc}%*aV|3 ᤜ+$Gbx&Xkԁb}j{fԸ|qP޳P{b<ƾAKL u ݃5Oo uhAP@k㨫J1۱f`c/^.>Np)n%v1C =Z$&- *v0}b6%[n}5h?t1TVtCXQAcbQ$܇j2dv)zJCl;:C6nJ e gH\aaÚ_v"P 7CPP jPkFV8R6!E#~_7]%6%SSX頻8붯YPhx^ #mJC* 4ߖQ* eTdv2*CiЖH0.E޾9!XUuXҴt]L)l(*R$C*>JeLhI3rح9GAiǯ (݌ MD|\˝V2.grܨksxu$>,_y4M[,2PۿI8f oEYv X[Suߙ_> R{udOZsKA>yOԊMKH6m`hhƬ.Q\iEm&*OǸɗ)k;R`a~10`=}pLR%%TU".x}ČvET{l<pg*K'G΄ЫI*/7 1hʝo4.,20bV9!lr;0~Ms:R SʲtP\fI+PMs\}BӶǾ8m ^&ZJ2r6t/!Vn4e-sҝSJ򆿗N`k)-ͣȋ5H[[L;AC6';s?V$>ZsH ;{6^vA)R*? V=s†Xa#ml3I5EkJȳl54v{'օdwo?,b_Du˃j]^TAˉv|꙽ JĞ$1`1-8=\%͟ڐ?ҽ.SKHÃNj)[A(3ej~E$U=~L$H]1dT>\C\Û\^mVD\>u'.zeM0MivZi#/1gSK$T[Eow.FA,<LjV%Y~]e_^b%]g]폋SZ<,9"MI)eLEF/bh*oOd k2pR1g _>Q {`tl*6D'0m\m oƣ.<)n B"oٝPqOJyf zi{_)~{E585U,O#ː{.^OPi*&:s^On'8wBW#c]KU{}Ml~y,/ ũ@q%d\MJ}aQJ{0͖;.#?۳\Fx:DmI&_aǨqyfGp;.ͧw ?Nd/w i0Sj1c7KD1s2,[1,3\9l6X"A8vxmܒxt;7@XA&ϽD?^yq$E"V🈴/r^L:)y )wIr?ѤvCpq/Y-ŏ`7; qf(g{MYtp4ޝ"qW6AmpIZR'ؖFev4xDs6MC^(+,)'ɾ.MɎ.z"rgyu* e,EuL8A3pL[JafHv¥D<lf X_{ԺC>;4݌n34-@%=Npt0a4R/oPj,ZrJ~Y'[Of~??<g KP#b>ƙƚ2O{eMخ D=*[#كN9юZ)5D .@Hnɍ8h[];@fA`kxyO%c░A;WAq26.u+HؕpR)5eJ?1]<+-Nd˩T)*: \k v5%iƝj/a=;+DMc3A؋~rq3?7;oC!4 9H wY~[Nk:|Q M-95ťq(==Av+ Wɚ[قc.Sn裏*`ltlSot!;%'_ȷ9PW+ vH~e qOedQ~1,o:kxݬVW7~^v /KƯҀW{uڗ@op%C1ŮBa`: żVpǮ\xe*J*"%ֿ%\\Aۼ]~yx8/vJ̟&i4Rh֟!줭 P. ǞW ̩8n,i: i|F;T-o4&qN76eh(|ʳ*Dp?vTlϛb wK>K~rKU \.WOϭ#wxǘY"6+1 Aϰ@*Υk1 Qn29E龿WIe0;9؍2-oc]˺a:_tv>EZ''h-&kX=*̅E1HvUd~/y΄5ٔ;I.cU ˶C1I<}U;SQf); &Ys]j!$Y{~v&nX 1+Ab<,zCU5%d+zO]?s, 7.aka"?c&M'௾3['ggVj(E>P'ݵRAd?W'nyNׯb '|T`)YlM?~Tk9b a8~eO:+NDX=RpkgƖz|X4n}/_.~Z"ŀ9܂ pǛYp%3흱t%v򝃚lM?s#ZN/pM 7j[nD?1rګP(5墼`E/$BnwGwx' ڮcUǾP6Xd30ouATc;ϡS$e$֞W/` +__v$kM\xɑ& f>ة [@kpa|R3<6`z^-K~K_ߢB?XtQ50ʉ+.uVN geIl1Y?JkOWL"*HvTsx'^mIdsRc[pkqB)t - 9\n[ؗP s1w]{^W{> 2d8D `$mSo~뺠*|TtQ#]~?pEgy#., |zz5,k >xz %{s O7dPɓt?CJJYAUd+Tp8;AuE<2g.H4REu@աjZ.ŮӸS4tkh_[()E?X%Q0)NYdal,o?9 v~:8|K.1Xs*6Fa O;"ʯ/酚}bG^iZ[-䕯RN0es|rg>;44Ϣ3ț40? uV:n/h)rSI2@ɻvmrn˔"Ȫ زmжpLH uӂ3 CE]]0 MUۭ+u}Zp덚 D>D-b NrφaɆ6[58QO{*ɢ}2/3d:1 y[w[C>y>Ř]ë;_92L7? Hr{֠2\1`Hmt.>G-=yG3'kCILuc!^ '^`s9w=1X KT/0[FLoj}aޤ,ݸS5 ΑZ31dD\~ {Sss970)^cRs~@;ܞ@QX%-:hss3̬GWct|?KtQm.-A%^.{,s t?nb­,z|'X0!uط\j # l=3`i uBD?e䶭{m'V+qTX_Gy6%e35/_X>r<gk"CL'6o?W"gYBckybIeuwq0# {V:*<dUU~N(&gZƝ颻3bq4*"/Uk"ދ9Vs收9㖜R,[nVfg{dN032G* VB[ǃU-m)lb0;s=%:{+a:lXHP/G5ZԽ> J"Puq,@٪vY[kCva}oKi~@~Ë_vW=e@03!իOx 6{UckD Ň8_wU@&Ւꬺ=Q8-?etռ+(n 6k'2"ƍ25<&{"P ̎FO,cDi6ⲭĘrE.Y#=C׀$.h /%7e {vpgWy-jwJhK N^S0? PY9 (sOy&sz dlɠ"o>vA.|*uחeά,Wa24ƳcOٲ`#[/`P2c ~Ĝ"} 6MN ͂:Up^Y0j )hڗkP~晲p'L[9*̩ϣq3pkVW1f"L(; Rq=v(+~dyX·w p})F >tR3ک̏Ũi\7`8x,0 GQ]hNDvq~B\sk!XhC?\ov \m0i>j.|ƒE٥*ע$Z|l-;%e3kˢ gͺ?J_]pn+cu5;yxЧ70үg|f+}LJ?,KBWh\|Sh0h?)Ri;̏&Hf x_CpWMҽ$^'cs3֚֔pcl;<cvlj7[~3$n >B{'.&jpS ~yIs{) A}W6\Q{%{Qqd'|Ł_r>ǻs+ۨ^@h`J)ϥ C;Q,W 5T9tlrwbT:ɞE*MfU8t]8Sw%X=`ifD34Qu H/VQdM! MpuN-ªw|: 3KIq;Ѝ,O(f+v\l.}+^\lO;f w&Y9ToG{Xd7>VI 4|}aNwaa-q0e4{̩̏̓zCx@= &s~oBjLLx>7$j%哢Aj]BikpR,zBrFec̔T~ˮ,V?v/Byτ^@b!H26v>Q7DaNMpj)tJ*7kkķ5_[ `ga5egʯD)Sc ^"6(@ϕrlZ7$YkADlΣA,N e`k)j v/ͯl[Ʈ3a7Fmd ] Op(/m1n60q>gzS)x_}EdMG %ah\\K<[ 5"͢q=h0(&u!>ϥr7Zy6iF= uf#1Y311~R'8 ;dm&q>Hs:j pL2k1-Wd x^ _\HZVH~ܬDHhRJ)RJJT K2W%4D^9s>.L: &G༠ )Gpk9cl, TKB~V:@0K"1C¶cl-ss{}fo/@׌Ø_y/C!(=Bgvt,(+Χ86J 쟌eC`%d[}{>(,E)gQ g ne}9XfoU`:XH~mcvCN/d^a9OpwO<9!&#Ae>=^D*d Ǔ?MifnpyB0@KR8iWbobAۤ?:Hc~4١@H!DhpuMp2w-M['pk*G,%= 02f U4Sr:n dZ|o]Xze=ngw` tϪԛ. xPMQGfe_.&`vQ 8YXͿSWm2p~U>`䖇'hrjpITgķ~?7f)pܗ{$SHR;`QǕ1\,_WMZes\ -ab"_ų6e*|3t: N sΊ0L(=PTla7#}<(<%WYR+#{83/ Oc FR"Ft l#!&@Ppj)oeK@x \p})4 ,zelFQKk9$Ld(on#s=(t_d1H|.wb:T"ܕ+ضAs5= Ip2^L"Ky\K;&Kl^$6LCÈSyAv%l#ݎ33.ѤM {X2<$`ǹN".#Hp,p?h윍\2;iLb#B7YЋw/Z_w. 969adAOOV"7eȯ'u 5j ]{G>܇bX5\:= z,"}:Rț=Sxv^H^ O,7AQ.\,E%]p󡩸Jb"ݕuTW[pSi'kxu<ʥ#%n!l]!.І@z9.lMÄ%06ku, 9N*|~ bLn3 IH%}ݰx|(lȃC!l<+*2zN]2@qƞ~:3>;! ^ߑҍduX#g?_.PUYl9Jʨ1z#tfBx,ÿ(VnNߑfx.qȆI_ynh(\ aZtvY|.^.ʩi73 i6}ԄgpCL5\?-x9`D<1Z,kfONƗ̢N5wxeWavEsQ>$X\]2E?ݯQH)L%i7鶟 WY-wCpot;,&˳ue-տV4/.mƩ;uu\|%PxZg*(NJbg*z==+sxc6Mt5UcP{@׎bbwc,2Ơ`+W0:ͤ>Y3A489>"6N$WOߗ%A\[ ga_e&@,Q +|h#Rn% g8Gn.F,Q#o0GLuѫEK%$EǙx<zźø](V'm&}w_Ƽß|=KFNK]y' &׳Y<&]–lH^ C^Rz; w/)ϕ DDgb\+Y毤_`򘿷&=4%S'w bQ%ӖXkM# fPw#tXmdz4YzC9Fp6}nAݣ7D#%)M"IqtB\&sig .V)t=_f(N3I i1x;~/0|| arِzܿ mjFyCD5mNbpTJ3(" /e eD1D)`GV#}/Z0nթ,hm3i؝H׳ IW ]_jbۇBPߘ6~rn4݁}b,2zrC"iiIZR+cϘap E(að$w˳a`Lg+of 7w?*ifT EuxUod(MV{S㣪4`hG(fWZq\~C`"Ŧp㳗MwC읉<ƕZNKq}"nr7|6Bq*$.Vv#](" # NHyWNx)sP@/y/U) <[BnҺM Fb- v]?"1#* 'rczZ91׌vf eADp5#yS$; fWFwdՙ^_>' W1um!Nn7ȬC,~N25𢴈V!3K"аg>v [,v5~S2ORL<>(7ov?k[ cO.TJ]$Ga/\sU`7r:oN5=Iƅ`WQV4e>gsNgndo)(߆ ;Ũmowb+ki[uD@ A(0-P~6Q*> UqO[u K~q줡  QRqL޺3 9)x˗o•h#v1vj`T5>Dg2DCS/L$gZ*\( wƮ}"Dmῆ z!\.u˒Ɏ#w1 ux%S)kV Ãm_#>%$Õ jQxw;4_|}ehfƤ-qrӔdVױW0 Gckㅃcp;!E{qI$k9hB\+gcx5OxiMGlk Nf0^ ϭ g×dKSPJՕ]SDXǚ:U0G|YH|:M衿VU;EHʷϴ{pxY9kcbзb5PD?)'Nqhs>Mk7n)RЍhc7}=!vJd`xf{}"˳:vt +/Z F+\i7nF_C@zq`P>:W`i[w]//1?1\kysq[+S<0E22^z!HI% nxNJʿ&[.:_KY iHR|ߡ!\o Pu$ +Th<&{wHPP’8DfYfrt2!J@7ؒA~ eB}{^[L_oi;p<>T8ZZI=9~_NM"$A,]˟믠_a; L=[ڳq*"f-(ef_'^#1#gT鬥v+q'*׷40W{=ل}*,|p~16i(FU8bn[ۇHkdk5\TL$_B-6 ;YwM 1eLX#ϣ48q I||d)osZTj+M%ԃajm0+i=1'3m=O e]mMY(Z=[!_nZ[xBMdޤw7r87쏛T]Z^;^[TMWBiY69B1oa%(O[!VCbyDY/4hE'ˎˡ*a9C O޹I[S$~=:#NbʛHvx]4 oa\!KM59 !1(A.TxjUj́~<ĵER_h(̘E,e=D'bW\upy1.Z]7l\X jYXh->¹{{9#{m x8N(i<7珅6s2ԑVve#(,a"1@E1]%TW]a/XrHDu =&IpJ1=Bp=DP***@3S2,?=)lx/P-6^5EKtV\ 5Шw[qA?M r|pj:wa:B(ҶC96Un~K;-Sa{|Փ jUGg;0EwC!QH8\Ptx6(N g7cW&?FtoBBIڼmO>\l_U3!0w/_E'yi ]<}< /|EH~!|=^Qtih#%Oʇ`JLK?W,М[dC0z(v#N\fשꅐ<]1xnۊjZIn>A{`nBiwa7kH/<<._ʋ lh'S?hǚ/C`\܊C$pQud–e! D?XWh& F4,0#m jš[Nv $ȣ|C' S6,hRNki0ia/hzJBTk ٸlQPK'Ru~!č~#χ: ?꟞^ӎ{|Cu09/ՌZ"tn➀*38ZD"L0<# ƟcЄkݝ񁷸_*F'x%1 u;zd<Κ'|28.Z hƇ8RŠ`k_hNV)57:Jw#nSqw!&9-|^8 g=x6 $F>P:g'P2gW'gLׁ]MiÂ:{?->{ ]eywc9=L-Ƕ鳅NqK̝ 0l7[#ЩdKoks yPh?P_0庮3@AX Gs狻[Q.r VQjYD4e?fO/ky>϶,(NͯeL 0:t *_'Hn.g}0v\ dO--w({-ϻ諈\\:[Q̗OF8Ӡ0 =Dz 2p^)~Ǟu9Lu>еQf^'WV Ť-lAi_z],hy3bE5L:Kp~>Q` 8 J j`R⭬c| D"^sodZmP _a'os Lڊa>b~z|*qI%7>ӧ#x"k(Vsi]4*wޗ/n2³n0J]0ؿIэҚ d+E̺$Cs/jTX+RSm: #CdK/ ||H9B.Z [Sȉ0?E*ĪxeD2L;48aĢ8ڃ'n*®/)ӏŊv menP؟"m# `|FIG/ gc1F`rsW* SX\αr7#vˆq!Kr dIIIUbrtvAbf|kQ񯕸 z7:ggI\W,ݱx5Ż[c98#]-{ zت Ԩa&ؔt;19}{t6aFrC`g,6Pg h=i{*E}X18QOiYzidM*tGGlBH k϶Zs9duK]po{,]Iz #%!hf 1`e5 ps>&Yo'X2a)>",Dd L~5GD\9˛> pU'cBC%hute ˛|CǧO=\}/MT9>q97Q٪?.~0ilD?jJ]Dw_pSE8E ~m8<] {aӹIU3j$2ԫL*|O߃4yݥJVWBw4 ( oG]o@K ،^+6ȞED$+~%4Ӣ&wqw8+s){;;,| ,=Ɲ?Y%cc55;]F齛-mN;AjnY^~?=#)ChLO ]4>S+Dgy>=ʢ@( 0vX9r Js+m (bjOX%$&8ADCf }|ߎi`Aa\ml=*i|x^ #mőMd#!~*dRJP!+QI"%|T$RF*RQJx=~ΩLUdXW 6w >7v \HN t~[_Y$0$J >\`>{Q}D7~]_M;|X@MgNu-O Ck<*X HDTp)x?@R< BЏcX8R_Ik,XnJRx:7e}<(c"YU80)TjmR0O~+xm"HQE^}pTPw9B'6!??PUJolTd[_6@OAgbʳr%Qغz|3$r1MxLDËIg.uhSa+r2-ާ7YHg(.T F׹47#5~-I)9az8g6z/f,n+&@ +,/TOK[kps(:}׾j'm%~oMMqnΤxL3ˆRpOvػ@s^i vpqޔs$Ł7i2G!' X&-67Ίvq4{Moq0ec53a!$C稾>ojkKmT!x5fY : kpW{P|I4]U٨ժwWy̸D ${_8[@N0'yhpZi6.x+hJ(R˿) ?Zs>}p* EZF"cưg4ds\W4?oҰnmfZhC l٨Pe2v l1Շ#V?Ʊ,Fe5C8k [KWnyod</ÊMBőcF軆б{`X\9?*,c, ~$RUPL75QҙkIIO%fh7Ȭ[Tv2F q`NLzs 朾j1gV_&Q0^x}+iU8Ѭ׎4K'ܯ{3Г\!N  >iZ]DLTch T g@E- B"@C➽WڴdH~<0_QGøO˱Gn I fsF{6&㙵ł78R=f.z2_R4MqF' gf)=2g< XsVkn $8rnN.}(g .gq~#C?CՏ?h;ЗRQ Zkvͦy zzp&9@‰`=_:&{owN+i0`8@(q4Eeum2Ʋ^=Ox:ә|#Pi* vCk22]MPY7KyR. >S>iql  ۂ[bC6=>BϒG+ߌhe8|)v{ޓ2j$'G^cF[~<5A6)&O#<0c@*zfR0^3p8<= 3ϒ? &}+Pvl Owv*nZu;bz-T9z $^Oլy|ҐE5l[DAj64źZ8^G0ŕZTeEsM*;l4اŠk\hαC:Or'K_B[*YAc(*GOy~֌y3x˰]~bt+O꩝z ؞>M<2 ZOj0ķq4X&DV ^NDe5č]@ [~v\ :!rN{vwњl[fu& Yʜ+pp`6d*Lg:^>L^?$QXy)f7zB+.3G~CʩI(tKaQ={o { ]saZ+Р4z%\]WC߃gL9^R0-,PfזfJeܪ&#:d DT<:νABSKͻ{L %rĘE8y:˟~:]PrK#z8·*isd~DPw@ذ DkxQ 3\܉ 2qÓ2!ch6*t`e ?sd$̅[!^qvͺ\UK/rǟIΟnZ\Y csLl !9 ?½cgR$$; 2Y(X7v([6 'B\OTZEܹ `k @މ oQ\ ,/8j{-XM< ^|:ZL G_)O%Yu^[?^jĹ$4f}bc(pjM@y1Lxd9|On|)'NJxƨd2.4lgOy'.ga{a%5t/؎AB\_(jHggz/iٚƜ>lSpPjI; I=2sY)&]F㢛g60- km={ʆԿO!U,o.B&Gҡʘa*cn^l »Q2wν-ƨp֯2l854/ƐxGçxB\4̛TRc÷&:wף.u*+/pc C}TY$5fDFlza6k>'i[,L+"O5X"{>0u0Gi.I I2>% B0Dwo.Sz>}-3 jGI8wdbp$8:RK]rӑX" )~({]Wr zlVŒsT"HC !jsB4Xr胲p(r Nb>?}:`$g!7$3fJTYbGkBG—hi&9+߃t+kۀU όEiInqz^9HS"G_CX:V"8NPb*8އ sInaDX4WmDzaY#ޡ|#W6\yA{0:$Xs,p3&Lx}F.ⴽi }}a$[: VMPD"^+ex_ZtkF/u(OIt6پ<=IœA3bUЛv@Bu/Ly{tw@sJKw6&67(r!2]5L0!V`eN 30}:> SM.}Mroq;]m;L?NR;zW~*A%eA콥te]]^cwҽRpœ8.]%o;r)vA0mm#n>8(k5Jr[ -=7*(i.en)6!7 ~;b@?ޫG?ziwwc@cϊ $KR#hg ۉϟs)dW3|QF-82Í/Ὥpn~m]dj<mka95rFsܑnnAiAEtw6.9(n#X7/S#\3|ZOdc.뼓I^''t85nC>od0_~ 3G`lz-BI6ZݏÅ Ʈ#<Η 56F= CkȧA-81ԮVe1qc {W7ӝt, r#"b| m &;Z%k7W!KM ^Rk%1XVbn` `IWQ髋ແ!jz&`F"!I(Q>^F*ĖvKн_@oDXͮX2SH?.\ɿ!"o/ٟ>$qJB?'n5JRɌ48 +be4(ϦQ),IFKrf}ݫ_kfݤ`?Lyg{qZvip2B(NS"X1ܖSZ,[*{ dbWK3΅n[!k4Ac;t듀A1`!Frað0[=N4Z &)lZ‡FZg#yGxvYmls~A a1}LX}>?MEhe| sٓ,EO6 ;c4GPTqA7hm9>1#:b6x~}# iPc9h-5>9괃8p̀^lB&Tv5oOłI]<&ۖeS^r@L=u CR-mm#ˤvxIxMzT^SCkAZd$v%6up߇UnץOá@n;6.|,|S.FeZ:#?+0' rԡhUoT.tWi YՍ9\"%n72ʺPw:` i-@ܛĸۦ*}g]휦 WW- A%c3 (([_g|@x߁Sqy]7Ojl!.t&5W!]RO"L,|S.4Bobٚg,>+at%etmZfrl`h XTSHh\S^' g{ɳ;'*6^x^ i<]E")k%"K9׮liHJ)e'[!$KB s(EQ*H*Jַef~3sؤ%Oc2j4}qjMr//bAQY oX6#|I)ubq]s/H 3Lu^$x7muE$yI*vIׂwq?\6.S–xZK,N]z w5 2 &=>SGKWI-*u^,>2Q3^ܥ(mZt#Ι"m!ЯSú4,6X3&f?*%sI,$ryk<,t2E~H˚2ٙP Wq9Nf ԇ cQiz1-|o欦=#=%\>~S7a 4IB6 iX3̔HGעۘE~խBrkxt̝64.kvO;;ɢ$/$1He;^Wgl .!)Ps輀,t@HәeSMrd`2&8hre&X.)Qe\;N0s j'6hKX/y/ 7ѫ[Lhf< KU֤ZۭLFi`2G) ! |~,|TuTIB[xZgRN[bSvimnRNJ?+6Oe9e,mkux!G˞}]C '#tN!h~Lg*%i - aȁ(k;u+L_&zo)m1%Brt)[Y#@E% TFR p[q[E_!患O`!K'mIA{>eq1f8UAps!D\=郍+[S DRA_<!C4=!υ1LG/uфW6qYɳ1hC[zz^pg '?f/xFpy q2~CM$Jk)q$!zϓFwTg 6g RӘ4JFpR%m@RJĠ*zVӶ-I(y}N;h{WԳ ^wOj> 7k:yxz9FnA!A œT>? /阇iG^,_ŞQFrXBfFO Kki{ @YX>v;iۄ8/ mѰo\ܼc-=!ODblD:,]`$dA#_N Eh ?EM)QjY2 Gc<zj)}o!9$nQn%n*+Le1*g*I7`.a7kOg3==G㊯;+2Ep˿e/G %-d ]q 71yfs^ [Ӻ0E Ḻ'y'0ކTB#i $lk0HB -q nwA:N4GgM$*E`lV6RFJЉL *冒Ƀ% NӸ߲F@Vs,lM:?StS?(r/T? {qvhQU.;F3{tFC1F$Q.DPm\Lm8D}Ow$)F$i@X O,ݒ-}eOrϨp]ESz'ϊkRTN)UZY%iJ .@mDa:>[BԸ܂Aڡؕ0q A:x<5V`yyIҍR ~*7-/b0;YY ƕ7owtGϨJ$̘@?/a"N ڸ1[npϷs2y9zca1j#_hs#-SМkk<< 噪T6s Ds@y"!܁!HXL)eo@y&pElRHzIȯK&4EuSqZg^`*@3.QMyC i}U,9뛾&ғvh`shI=5M䮳.\|k)Vd i>[=Q i2>9kbǢ$N`51#kIa2.$'/ӡMQY<9sm N =өX"\fԪuuC ︎䶙 )NEXd}?ubz-'|Scٸ!'U<5D8\=  &VA 4au2[{bmX3zŒې  T VK%-NcB02DV.Wս_)7F" Sgυ+`蝁wK {8̧ sl7xd=0:e=y\T'?FRPՓz)pý^ȗʉ]%警O$[# L%J-&WTM(XV.FF')kRFp 7<~Z\l4~Ŗ>Ie,Ļ2ǜpxVoNG_!'>~RJE1Qd1b (< 1 ό7֮ObUu o&* ؘ \m7ĿK]dq,mY$(auLj[p r :[A!jOhCq6FEdv` 1|'mV2n+_I#)^=qp"tlYLU'2-м%s_xx馎yzg|-Ρ_\9& 58ًPe90:fCʧ ;#/`Kn 'Z<Lٺ717ut^X)E/u>ƍCUCnZVh<7%߳W*e _C&XdwNz!h>zZ+_:(lkiq9'i^<[Ѻ@][q܊ h3+>TaVY2$X " Fw}y3cP]%J5`E&u쁨:p -lsD{'&l꺝^ L134m(? =l cŜFI"_BUl?:ry'[xCy#M4M:NՅAB!9Ӯ{Ҕ#بzS [h $ZNd;YlԐq MIKd_/z"8@k|c;BX/JrVKl,: dɼ]ep3doyÂIL3W(J5ޭX6:A=-tFU +9@ߛO)O>f4-1"8wSa0KODCƓiDnJ9V"d(Æ`aTc6AbP(DΜd<)'V&qo(LIn9Ln0416mye.޾!u"t\p҆P(}v tHi ỏNt] }I رdWë l5Y(  hĹ!P:?u<0@NN{D[qq&]Pm~^Xn곏gJ{pY-A w?rUE}"=hǛx$nB ,/POoNS2^`%o|M<ŘVOv;8H||IqY2!k@!%&ӜM8@\IA%VYk]_*x,oL Xs'lo<;`m9H*\{:e"JCkq$C/s@ "y V)s.!WtsFd܌K43~ v {UYd͚{K+o9_2'D.n?K"IV3 *y{XObH ieM$G`* lAxήHaa6 7֥yq1S3y2[Y~tɄ|z<;=)K E͸e-JI>o?:z3qS4ܜIDkb'{R _jd'+*b 3Sf_aש*Iޢrs6޼"E|Ц~Qď4ւ%LQdjK1TAB[D$4Prg?՜=F]8N 5`9V#$ipr21AL|m]vޟ8)x|̶;Hqc~E,g E465?4GipQpYr%|PcE3j2y9;S |QOS=#ѓƊIV-ݿݲkZB릿T?b u tLZJr@Tax۟ױRdG!>{-Oq $6Z2&EZ;k@3Z4T5xmp/83(S{z39U0N 4. gݴnMJ5cňQ*ӮMN/GqR=;Eq},P2GmT7”B*=3;s:fcsrx,vQ%wzU8Y*+ڽ/lf;V-=KCޑ IZdy̩,fAh+U HHYč~< [w=L*nWȥ+vl&S8 G)lw% pyX]"v΁#4xjfėoQ[5?Α &b`96i'z[~vq gniqC ƠbL:L Ɔ^*]!$[ 5aMb ٕ =$JXӔ9 _7He]2 bABBc ˟Zgs~c.t2 ä-9&bHj=80.jLȧ^zK)IX=4$]!&TσsgP~q x/b!8aBp_4}7U==L‹ |"ON!y$S6>J7LhT"%{,xJk3ߒLv]=:Vd2]>Y[Ж86×TXxk0_`;Hh5Tb5 tr+1k_Gךh4xN<"% W-|"$QcTRCZ]t '྿]ˮ yxc_вKYp+ŁȀw6$`D`q3mx%!T 4BkL-T͐,O]v?f}crg܄Ay lbA,'9sҺ^MTdcJ?XGpt>e~=^SmDW$lAXdײH NCVĥS4X8'V]Af| w7NGӇ4> cNisWbTnİBq=VG|(a{P"V 1c9 DiFRr$;g} өt0]p%4'yRˮPߞ}B`t遶iv'p}cւ-cQ+bQMI.+GZ (vL/u G##LpE밼"|yoV9[5;b|-5ɠ3xQ_M=Jeb5M7  SZu##V7@4Q" $QT \n'g{  /@g;ˣi(%lgPk.d 7c/CN"P9bXu"^h2Re =k8af vm\q%̈́idk@4N/'b1?P=n3r9|vR>&xKL3rntJctVn//!hHu{GB2ݜ.^ZF{jtq`qgu'?˃}b.?&uNe!mu^U.4 ~?1_ޱޓ[8ב\\O9C}8d |`k $俎p8 &!aCȈF^0"'Xv*wX p$z.qґ(ޝFq Ax%_5=d1pvW,ۻ'{E8:<Ƴ\,o8t= u_rYq}v.'?4{&˩6b!gvAg>NDQ?ܶA~F3] ._BRN}O]Gt.R&%!g00IWg~]뚅GC%(@ "Am!ҲK YZJEay>YЃcT\x(ҠFʟ$"|~*X}XggMW$Krϖt9?UMγO#8D&F-AI,疢N^pN@opϾ?68QL}"C}M<C%=#p6wzvB:6q_TLm&t2sc*-f6E*e.sYhAͮZ!./_ESd/zn^&)xBt;;j6AB&ZRUAGCq/؝vHWҵ$qD!|s" !ϦRgl. :7S&3LKǤS>p:G_ޤ&zso"heQ:q釴7 eO-%qKI[u~ETE$Nċ {p<4@Rʂ$ҹ>HzΔ~QΪ^ ݞ:@ھe~ &g}DȩpõYҽxdqr|) n,n52a!\ FG. {3M+J 2"Jjl. ռ M\e!oy QxUzdr#&{oQz{պjc2Kے{Ջ,)ܕE8ɂTC !|3-ijGIa/HHVZpLhj]sgO?|T&!3frޙr}@&W'^Df݁UieT XY}%t\F~6̿=oI0> sj5,q*jrVչBppt$He9W4E;=Zx ilfngAn2&KjC4sγo`D ~*&LhsJEw:La.N`*E\ѷPzb ga,-1[H >e:G_iQ=f6-"<ܩ089pA\-NoU*2` ս®!10?7Lys`_mjQLBԯ%yp+ʤD\qmhCB{.jpk\O>fo<+a783l}s؃cjNM)OQ3q1G~M +z'Ȼt 6&z_X;dߕIY)9̱>B&iE/ `. Ta+IJN):%٤e5ɰK$FEYPhcjzp; }Fs!a|='YV恑?YtԘ;6[/y#H*l#+b7O"O"p jnsiLw7?q"o\6I0#0Z-)'y8YR0Ɉѭ~3HԁqtQx_1e,]%7pҟxXRSKאFMЯz1K0w&IesCɇ!T6¥ Ws*҄ꮞXu@M4mwMdMя HbE߸Ҕ_ NF[p'mhvIMmk2K#de@2zz2 Vи?V6Py 745_rg'~Kmk/q^>7vJvN3~/,4W2x-OH?ޟcm:` oH 9XIf 5QO56ÇJ];ʯڞl.y6jCP1w'DlN:uJDg-g uP"sHhDA'Ւmzmd%U[K|R)rX:O vle"x~?#[Y)TcyDO v8BXvZE\el1Y=jaXpב9%-M;e0qhr D3-u;SK}`-7ijͻ8cY',?AK*XT0۔?Yv 3EG |DvgNU5O,u [PLaxݧ0ϕ=j󔇺SY!-y D۱}q%]a Sqb)OpPE- vu4&(t.3F bO=&O>՛*^xFK^')Jeq %7Nm9i-8)eMF^{]8g3TA #yiݭrx ?ιH))@#.PI7cN\.;!ĂUR8~)VL0m_wa.H~׼Nlpk rFA~-B(w䩙-ƴ 4v" 23mi(IEºA.j&1e;7\DX(K&p&4m_DZg=x1LA,"ad4I 3٩ť/U G杨fG7ofϞ.efZZQʓ ňc~k!zA`rE|-.h0w^^"+4cV7,ŃqF': 6 s>h a/G/~;x_-/^| v{Jnbv~_ #2_rk4۬Ȥ]%jTr2bT,5X }fwI+U9X:޲ ^ ~Tev 4WVksf p|CKzq.=l+>G3nx7h,w{c h}f:~txON-)xc#PGtOb:CaJ,Y%[`z}h#x^ g m#2HHّ ṯT"$)EMD[VF!JzVIR*PHHs~cQ%S7,d5rbf)-{ČnXc"xp$` 4D`:N-=iI6Zj=rCzƾSE[t#9]I^$D;UQ &^GWŜ8N4g턞PZ0]t Ǵ@B=OF' 1DUx+BcYt 5hf>;o:g1k1V-PI$=; h*l^l@t_EF9X+|W3(M葟m)Jx|wp_ L_ª蓳W88ƒǹ,J9Yב'SC7I_XgtbUA")H{ wCyL<\VŜnUp(Ok%@kǂwpѯ”^ ŁNߪI?LۦbQ~ SOfK@'C,k8xh5{kVЮE`~ O[EdEk)=5OԢy ."w,aMӬuԿS%" |)lc`P7]l`nOC^ =4| `+蛦 δNR",9npn7 pKϤ.nd fhbEVq1v5ee4wtE,`#Gmjq?W HW7 d&۸H {V1q MN/\z" }&h34!CfjobNNR9mH TNCzhiQ!O2:w޺;CDρy DPm>MMzc#)Oiyc--=&ѻr]Ąm`8Kzu&U.zVֈ ,U|i I3ܚ)&2'YASSWI7#VP$OIo)`yk+.7Vrd $v5{ O;,HB;Sh G0^N\h^:J sAAq&]ˤpi$:\hH/wWWJq-ZfMRaZlֽ-*0<{B~I ފ] kluDh6* kVVۂ Xҳ˷]o`$<@FkDG sFa2o`=Q]G[Z^=Lt r=4GTKa;8Ѳnt_톡nϥmi<t 1 k^mlS׭XDZ%paI?@[TwAh>;Vv&Dt7 '}&fp?!nztƭ@o&V^3d/!z,ft.f+S&'~/,FcH1.\9,ξF؎aBhOeּɽqRN7iC$KR% [!Ss;4.|qoxPH[ O TWС7x ?.ʊ+3'Cفпg ;F?qY^,; r+k8>I9B!D^{L0:SA $zF>JdžW3&F/6|,x| }n)bhp0o_iY"2+}`E :I#MF aK'^2D/\kQcPd[`g? Y@F2d|9D7/ա{>zwhҡ@d=X;clRzBˣ!sbo1 rFG%ZC(M٥°*̚$Ĺ[Fk#O ؘ*}!*liKJ*qtC _+u:ٚ#v gxqfJgNVݺ& ^[{b 4 {N`S) /%X9]R!< ghcA{C|7]NU楓tɑ]F /Bc/WBpWq_c~:;1dxjl0C;qHRGRp(#͓S?d]dI m\D\!be><ʗ3bV];1pTM1yVOتEXK%%!_n,E7oWȹ8=̋jj?uVZLۺ.7}G$We_ݮ.Xs o=êuܴy+9ab?smay4n˶ ipZ&M 3\/XNcm\-n}qmYΓ/@B?/w OOO.kiU3 9nmP*6C=d '6H/ 9Զ)`NdGuT/ 3['(:B~> 2U\'lziތb$-'Ʌ0~O(oLW>Cke6Oj9ޓXdJ|ȌN[`"ŢZxQXtA ;oQ}"*y-1>a S0BӑEaʼnp `ZQZr@bk[B+dM)ūO{ kA{8=nܶÍ%?ߟ wa/Po6{Kn'ih؝"R!-)DXs 5˔p~:ϳ#8gH'u~\e ux]ڿK?&%m)_3u8\D['ҹD@-w m'L:x/$]Jpϩ׽ +p 4Gv 5[?. IMUoH/EӇFc<}#`G&/?v8њS\WWMjw |KPV \j4GtRdxjjwl`XI*D^#7@U8{؄HiNfl>eam,DIΚKCP?g>1B á# ranJw(QD5,˛A>iːC%hM9'S\á2_+Y2A\?f9ռDyz+ߒA:'1졣OSE|RJ5q/i3 F%aݦr‗і Gpi}t7T4Oq5>=}^ t!N)#E#p"7luUҐ~u 0ǿQx4;M]p$3QK'ad#s_1>"\қ)|AIBwrŢn}-.Ǟu2PFD+l(ipǬ9ES|7k0VU$.X  f[:(Onܒ\-"- 6uG=}x=JAa46G? Ê`e$gǭ0yG#QrKt作 wBfbAp_]/:ck[q 67U.X2Hრ-)rsSwiGpHLl= OO# EJ b7{ Gw`8<΂kq !]<#6h}â35BpVw>nrs+o\tip짭@seq_I^.KXc:WG\8|t̲`:;vʙ3Xȏ\^:]3bŬ5Qalr!0LQi6;)| v`t4ERj-Īb}hڧGe(F]`Pbl,(Ș;mRGbR&_ Y=[(LtYFe?rsU{]}HgI"-QHάSؑ0U5*0 u;zJXَ;3Bi6k):(ieh> jgd*?>N*8<2>lSttj,|GC2k7-cMi+is 1$V}T!t2YD19\OZ,)N?J=Pv>E2RJzdcy y!V\L`ߚt9enBӿL ņΣ)6 =Vq3 c3kyMc5%⡹~E5G'oa!d/M=RbZ +NڅEA vS"b H<'q=`zR\,]B)n+[wc MK_: w%HO $G,e B)ZS7EYeG/LjP2p"qIw-^Ypb[M4d=+`T̸6 q)O+]II4ԙɴ9xS*7)O Q|n?S46Oqhz{<ۘwL2B SMQj`|nJnq8x8|F؄5qC:=]KoQӜ9s`6#RCPpgVYQzHu ZAQn =mt4fJï;p5g?ހ6t/ֆ bIQ8i_f>֩Bħأ,nWXˏyF6Ǒ/kjݰo9v/Q~k ][ Y;^1y(ư]ǒjV@5i#rJM.=/bh b> c<֌r$0jʠ86"f}2b=O!ǎå!(.FrYƁJ2MF$_SK–;.xyR'9~܊㡭"FrIKT`*YS]Gky,|9&߬Gg~*m؞:5cS׮f+ $H l=PzҐ83NP=ן>"-(՗,yN|7j_FjRxׄ>G 6~7?SΦcN6^^IUﵸRyT)`/ANwӅgEi}sׇ)1 v DBSoI< Q4#a)4@g#Zt ɕ. ?$1| s=gr`"\Jy.z0uQzKӞN;(%!U zz0Z63k\m9Cd1tÝwCOȄc,j} vE $ZUA`f"||.ju6o1,n'F *bkuG W6LD*0w]1i5K;Ν>"K=W:w27v]eg1s|/q \[3^aet[L y6IIN;Nb>AhCPµTF~5U_Vz=TI!K\!۽<0'~ Owr} 1 |h|~#Yx|>R54 ˟C΅# /xsձ+!:/|swfKh&QÎ &aO</CtQyoe¹\\ UptvVT\AG ՌUÞ%Pb˽/"?][aW~ i;ς{m,N~\Ɯ98 8HTa"q lFү5ǎJ@1WeHϪ|<=Jx{F5, U;ΣU\oH$e"SΏ4Kot%ϟ_Q- ~WG[𩷤R1ȢB*;$Bi~%L:6Yd[0pCP:|u>%UDW6W֘܈$O4VS*e'bsn5 | EpYk CnL)gJ8s_zD.\B:H!^.s)4EXkl".83 FjbxޖKibqx$J;lȦ:ܕFEsyrM_QiL_icoD>TFϬ-/ף|! Ȫp9ە@ncK=6RU`=%ϪXu=W1*Bҁ2vMhDp,3JHv8%@bU$saZ` Emm7 ICϠo|ʁXՒaE;gLTy:Vh7-]]G@3Ra0?\yLH8R 7M( 8x6>)ie< "JS,>|s%eYƏz˸@vЈ5%zF{D tR{u4>񛁦D-@RNq-% 08%H܇ `bWncq̈́(HErYsN'^mVw|ٶ`Or̓4'_L6.?E!Vȩ 4 8r6.ٗOV3Y{yɑ}vgo=GV}8'ݗfꚣw>v9EQ30f/DG`![\k}V-DjM;b)1>;MFS3eaGx 8 c?v_x¯+'븂 ]~r7\QA |*\7ePh]!S(?%s?8@|a2 zx^b8 @=}O׬Y0sF,i/wlPb )VQ\+Bƅ(M HdD~F%1O.GA|G+|'me=(Jw:b/ץؑ.4<Š;jNUj$mg98n2;VqFqUpd;/9B-BOJ# LriL@{6wĞs_jâ\x]n~t?|V6y'د2ſOU.s؂S@DO|]Cu)0>}/4?1UyJ<B-Qш˴wx Ofѓk*fƯnً:S5GNj Xp܇i ]lbR cP]Ͼ3AG#R~H 6Dv[= =`R; #%hu- X L.ܕJyېGJo(G€|"d?0$䅡2W$g:m&/~v':Ĉ`ͼKf^O!:C=@> S|VFZƣh8wo;(9ɚ,lb_g º >) ?RE^%w^ȵ NLCc^OpzpA_Yޘ%6z+osK ϝѓ} i/MC{fR]b(ԧe旯ehM3Mǥ1W6t[3In1q~'d6\= O`uCB) 3"I[:$K~,ȶ cp,s+B.ە[A6*cmj͓b.dKC!Ɔ 0Nq@%c~.MpRy"nE^ WAkL Awz,n٥riYyQ<=@\Ž-!KNM!,|{Uj:xJx=( +هgZx\]O^BJK'K3;hᡎ wŸṐ.=NxvUߣcٖad";ߴN :Ëp^vn^agcVO^]*uҗnT}F*#Rx'a+Dݫ@:(G~;d;ˠw o#f|Vl)p?gDkʢiuQmo6}ڈ쿪ܷ`7PFnf1UC?=Y`źդqgEEBYLi(wh(IOnsg_~Sj(2ݟ1lLS&8-94`Gt zv3c1)cTiR[4[}>zT~Ay{ }1$%!.ʼn v9d3)(o]D~.i&=%F$ܼM ){z]{$xe@zu^áʰX,E~;z}i3t _>DxtopYy.^tGƳII2NJk:zH+l0HXaJ i@nˀ:BZj! 6&w/v=k@EB/0 WN:Ըvn-c!WXF Tjn;M )P&<ǝb!ߔXKvVyI^ۅb˚FK^H VdmԹ0=v;4"2Mj^Nb޲Iηd2! -c8+'3/㡹5.;Œ—M,iW4NBeUq_ rȖ\\/R.!?>t;2t*Hb<dѳH ۳yl\t8F٫LwXKTtFu>}m`w' i3CeY*I<3^g@'Osaz .N0"&m%I?OýXZ{ݲTdٍ|2U1 ~x ͍̱Ǔs4xA5"ϳps+;onᖎˏsL K;K\ Ǐf_9) #44u/HmD"xM[Fno'-=(W5YB !y 랁t0|qLOrs#QNXS:86`S0HߖcM:Qh6Mv"]G\v>C&JU!ʹmß$>*75bMh|*1\=:{Cޘxl-3ޑǜ_ $- %C߸ T=2;lQE{8i ֵW_'<֐ +XL"7?Xe}'ܡ# i´eëYпܖ2YMlrd%;Ɨ(o{q߯Z0 ?>%1 WDNw0l3;O Mq̋XR_H[kY윑=b/A$7 ,z8v Ԥ'vxI8MIa׭b1K=^_TrpP] lťʎ4mv=2 |6޿iN]~(2p[SA@,ޏHy v`IT~1<_YF=-‚J0ϧPbH'-b'V.ئٹ#`>v-q%f DxӪ&}c#tIHTqxMI 0Cȟ򁤨ֲdPc1<|BB L1F8%0gqm)ʚb\돝ц qD& /Bg v Xp"zq;/ vmhj JihJ"qcp(] !agU@^Xp^ѹȩߓmk[jҵKS+l%'Fo65wA<dαCZAϚ8A z$i'_跛L>;8~cO=vI *1tG(0 &v`5 e1!wOVy84i@1~o1d3b 1-]cR\w R{*Ӝ%oe쫶P[;$@5bSy K*ڂ^z*؟:[ W/}ӡ-xn Fm)YӉ|W!1P;.i0K;O (N^sp܋IW>9br&WCⷸ|]( OQC@C# г`jBEN½IxV 3͖]e#,z +nةsNUf볝mTAekyx`&>xJyn73r <zoHn-oQ'}IjG5x%ٌS`S,s*$%`ᩐo!dYf_yIBk"2 7F=8vk'3 ="8\Cw+2|yd?,_+ me#M||AgL3OmZiR$VuXMfQ[Ev)NjSX,zqmr[zav̤bN<joڑ6E#U=6l}~MhMh=NSɪߏ$:&8KVu=`J-Dx;o$ށ/} ,+,!&ݾt\_3q{^/yqˁF%1o$,mw-MU/$̣i0;%x#y7ڃgRgh0ttg<>l^un.X xժ R]qlp9Ʈ{.MC|*繰e !Z^^SnYۘ_wCT.{H%i>BBVmUu먆WIPYpe5Ip%JdgCS*n{PaFNa] "!9Hͨ~,'E_V_& M`[Xp_`'؉ސksImJ>EeϒC0SLg _>e.m YQucL+ɌVix},#g` OO0|ZbK/KaPY"ʠHu w|/`*v7pוeۓ y·o.LٛV r5Ǒ/3ܿy$w)s 0Bz/4{X 694|hc1Xdس#y r/.U%`8,CG%\xKnbŐb?C@vG>j݋ lBf8\D%#ҳ}`ϯEp;LȺ7.U W}?>OqBFR4֟&4hDjM*5JT~|t@0/kݸ7XSӛv8_e ؠD< biV(fq([d6Wx5@=ސsr"ġCYehyUg+ ͻ!i憪,RphbpjFue~ۦ=].GNזh bu7Z}|1VL0">é5o P=&\]u>pT1]p 1Ƽ?/ Y^ | Y2XYX<[/ppgQ ?T׾MxHS\#qp9մ= #{Q! 7 񶣩[ a0Esd#;mX)uy֣-0x~C`sʵjh-SsNPk,yKJYK$*$-r2J+X kڋ1 J^!Cg:>X A`5Yb@k߹V : #s1)v3}k3V]bߞ /ULAbegL??a\]vqG,RC;吪ˏp]P4GӶh#/܇!WpOe|UfԾe19zq8ibRn{^RGMoYě:tgVވX*`=}}'[PJ,KuҒ=djW4.(.ñt8Mu^wZ5S w¿20:TG./6|b~ǡgM~`Io*K]bߖL'Jp$OmZ6HyhX&qH{&bRIzFm{DQeI2\lN%; mMئtyN,˒`ƌiBb SVzxACM93譬]:˺aF բQdF𠿇 3_.x,n< u؊ CA!-EpDf^<|Zg97CtC :##S%uMPӽ TFO_s@қ̼<MsfeD@$Cw@qDQrK==VO(a"8"Za鑓0"}~/ᯣψ714KaB; +"/ڋPwBo}PE_ ֱjp.$c,{A "/e q#(CFgX}:}ff w}@ݕxAMWՊM-No{z- &Z=[KC۬UzxfCy,le87`ݨfIיCK'gq,7|$ Es Z:Ȫ-ҰvGɣglC ]Ni /MKbXmm1㷥\{Ze ,GjB!=+2#u'daBOMՀmvD0ؽI*h °j=Vy@y =+e7% kv9OLΞ|"OY S,٤5~]2[*5{[6 I|MM'[^ ҙNV pTƦS?K/a9#fcikSƼAF+>b, /ĝNYxG x'vQDRҞpSS[o%=똠dJzi+)⺕@7B:t.+#augob&PhJ @V={KWNlM}e@To mfT{В`oL㼉; a%`PJ[|k(?Eن|ZrV*"8xx1RR݂v_0-{K:z'?07,̧UM2+ۜ4EPb& k1+3+qz2ڗAix|8šΙҬ<|o#3хį0c8v.|tg]ϧrR!MPQpU*:9rtkpHK ERY{1^byWȽXJ>@WtvQF6.[dž-#~LU*zlHӋ!J h_Ǻ-,25+ ;ըę)ߐ;i㡗-`%I[ _pE LXg_%<炮^qx]C.]J$?XQϯOWJ&Cwor Y$]_aij:q%W8?FoBϘxGzE% jw;85DǦzꢋz^ 7f2yѠвk[/yj<۔yC^ixq3xj3z0n&o#f>M{fuB(BJW~RIk۽!y1 cũY% 3.KK !9˛AM9/YfcDQD">s6&eOrc:3}-^ɸN9h{[#@?kX D zOE^D6ϜͿkO5#^u0*xG%*EdXvL5^ٻ'%wUyÁt>H|P9B՚Eg7{̴`PZ/` ~o)OO{f}c||(XvffT>DntP<G:R}P/ff0Зc +.i`ρw/ - $;ϭCM,hz* 'FgY~&DŽBԴ{=.ɂws\mo汻 ~pLcN`Y98XClEppNiQIbyxCہEޏgur7=if6=jp//[s$}"¬QpdF Rދ[Hdtbc&)P]a[[a`EV! Z=62K;2txl/fRqP3Gj15 NNqE=D|¹^%3[ Г% klw]/>Nvʍ}nKWw~qK^[?ßh{ 'qp sJCcpRG=S"/A4MUE3ZKf lH:Adk$u5+RǴ-+I_l)pmXjƞ"б EF'uT5lD}ϧc2>g8>A׳0m1&є~Ýki~Q0v3}HPRKG[oߋݡo39tPs.G AJSkMCC>\'_hN?c֐:8/M+7f\YSIͽx/]f7?Dbl> 9` t܂y&Sfiw-U ^K6_ߧ֗IǍV\ld'e3H8kk5E-$^Ң m2Du6A)Э̙Eb ]$OO#]΄p XrcD'bi*op+#Huh <hUE*x Fwd d1նe;k A[Qs_ʗXkM8Iϵ[emY Mhcm}%~ޕnDp1ALѠ8(ed*+~_n$ wEdzA!9}5ģ)X[Fy+&XIZnԽѺ]l8idB]OVJa*Ff~q(3jT绕k),=.[5oP-[y& %$mTRG #࿧r+җd?ahY v(o;)'v Q?]xO"GObuqw YY'#㥟;6/q#+cTEb`8E/3ҏ]&kD~)2omuר0: e?R*g^R1^l3D3tB{BEK[?<`5xZ'gQqѴCޙg%RrٵYt[RcHKcZY2W?I5C TZ0T~|4TCMBHv4#† 4=<"L6o  N7E=sb<[V :ǻvn ϵٓw(໥z8Wk-itXj {x(UV aw5%o# sl+ hi]wc6 o]1 EbBWqlż0tn W5C+:Nz_6QY8hGRJ_Ƣ[-$JͅZA֟U1P.6w%-yRCm?|${OI-ONJͷY7pfXZH=6W~b3ˢFP QMpsUw[YVFw @~?QkcN =^Mݱq7əlKT$`hW 3wD2wvGW!.G!2: -ҐU0_99q=:Deiįl!O*o ~QPN^D>(G6tv!ݼTccFDM+~ER7q,JgDU8n(1=&vޑv/$#:q8uQxU#:$ tQV[4FQJ\v=哸xnm[#6g:c w`pU|tK> iehx^FР[WTl*At6wÔчlyTLRUЃG;Qs蒿Lŵ.WxxM{(=bF!T8ådȄzr>OŶpk*F, i('tlpzG1 *2MRp.1Ku,uvWp|ZOXvEMy =+GKuObz>%)UL?ه,ӄ6k*q ]F:YZkFY1RvfoG-A` ӈVzZYgo_rmق`5 K? Sל8{JAb^-\:-Qǟ95( mP/8WcHb9;Xp Vۧ3u191$'q-\8!VoF$,A^S04|4Ť+U~7$Rc3;ꗶK!<"T<[+ZA=!-!?\LUvs;p=JVBg*6K2 [PZokpw,EYՑlgQCtp *.̴|n2>6ĩ^Z*?3Y 3]q&qL`g-T{Ǘw0xW *il\|gsOI(%#Oy xt6r=ZvX䟋H?'!_o-Re_ Z%*"̄.)5;/JC%p ɒ`jstm!t[h$ d?Kӊ7?൏ծ;c! ~p=!dneÍXCҭ_C*׳)ZL|pG\h<w˂w`{:0墁uG*XcAYFf0x?]Ϻ78aΡ@j:Ԩ㞙S DV}ERTɍt,gWHdC{QezWJKO>Mצb3 4C xw܇(SfVS-TB}Vg˜oe`8?obQ R0#:xm+Jr7p߃50|/Fm 5tqO ^ޭ ϼ_h,uPc[C ;jNۅs7wfl^ #\`'om1:Z }萛رq4eJ2gvcΥ7 mo ,\Kz+,_0K\ |jM$|ndJ0{0kDhPt"N[{Mu ?6MQeEO=A?gmwro'Qch+uP56Ս`"~l H@XmCvUBz"ACO-@IKp 8q_ <_iHM [V)K9SY}[\{ğ MuT/XFE'W '4M6/(mq6'e] ,CBū k=u.H¤\DkÊ!S+d*X*rFΙq($E N΃^,w Yhϔ;^`H&$@}v*fȁ_ז0󕚎L'=$:FL8`j9;||gΧ ڷb2œojvgY ;q5.N"XVS NA\XQ6 JLsm^:K}%Op^  " Pb~[0(o[Ưuc,ޕwv @U;nԖ䑿@.] ob+Lbal|聁\̐j?&Oyx^ ?mRIF%E羾D"!i*J2RFdd$ $#TxnQUoI%Ro99:vur1qxj_ޑ3l*ĩOQqQ# +P{( ?&Kc}iR0Ui wqyJvPRuMa?򬾯#UMqn$L-5 . ҈rrxioY0Ӧ'л&%=7P&qQSi)lͪxܖ< u|e^ VV /֫WeX6eΗBdU=.=Ogk!'Gg1YE8Whlm[H c[1]$mm R%xlߚJPpyTn*qRQ\ ~[}$xr&&iQ8~tuBUh7=Z.߶sȪGms3ܲ~ ͸  \.njb7&0χ %IWQUM5Ia[J@I?+A~ +o2.f) Q_ ;G?IK"wvNyS`ly.c zr ٨< ~CB<|?H%>z1f XE¼%~Q Yx%8o*++tq)gkc!+X_#Eqׂq~Ӭdr2;馩/2tu.pv}m]y^އCC-D{A4oB fڮA^vYc f7\? \;Q}~z.7u)h?Bj\1LKڼF1/2u}rUm<`f:ԑ̉dL~_3߱DUUv~;q n1V m]B$i_⯴MV̊?#EIѽضҟc<<6E"w?>1!u&lDb~^\NEg F4#K#*ŏ;0hY=Sϲl3ѼedsJgD"Xs Q|p uj5s9o.Iĉ]1/NByI^cUA)_dTe >v)W|#~ƪ3Ɠ;2XYH5YаHf'3PRa_'s(| ө"!OA]t%?YD󏘟"79nKU/nj̾qY&Km{ğKkX?k: .SBVT3Hl;f\TքB2޶NC}3?A"+F5{-d}/D#J nM^xřS O'\XKڦBBa0<aPPL{6Om9wG0/\#lN?W&ocHlT(xSBMa~5UIK@ ͜&nmŏDj4{^NPgx=zAy8zZRz܏@ T< Hr [_,q8e,rTs[n 13! T Ù5p^k:To=U0J;!- vXm)aM41xIڃᾫpˇo3uwOo"J p`J )t_ ;e)@as4 L+5&0Y?s/ lwms/c MEYJ.㰴۸z9-a*OuE>3CŠgDoہnXK0|ױ$+m{5z+F?5TWṮ-d$f g:<]Wn4ي]lHKkFBɇ*NX?\A (m'VS\q1FJ7tђUf7Y^u䙘n JؑKYI+fE!Sch9.E|qTcV:0LSPE/3*aO%`t zI|I]vN#x4ixYc {5N.`b)o!wvIVRl%DmJ1l='ECTs1= 32_98THxpUc{#B᫐Jl۷t§ /+6]^4Dl3stO>67U:@ԋKɇ"TM ЕMd`0YߌLkWfxtm{^zG|#}D>+ X_ ~TESlБO[{MfMy9>Z@C%xb[y7{\^m `1RB Ur*9a?Y7rYb"cD#%ܯ =}'G\WwT;!u gLjpq9N7U„ tJp2KYk(G IVćFB)6S?bY)%6[e_Emww f%tOTW V6~QXͽ1F#?4P!/ͤ՝U1#6Z/u޲>xꂟQ-I!:Ka{ _zY#x[Q͓!|EF7AE?l?ҭ_Oa]_D&r~Uon٪tV"2jAƉF5NF G'쎁G(Osd=>\=/p~gl?9MaoId`֝SPm~B6.`K6hAsG10ZpkrFi;ۣp>?-kA?:bmeˤ%==hz9+7ye ;3oSMw˹mi$~"tI_}: riv2Э#hGB^2\0> Ȱ?rWvNն`7ϑ෹'n_1>90,j@ޛ2y8g2d`&_I:_ gWPd9߉?] OA޺L7OamldV}; QO+6k<;qT`2gas%YYKw[$` xdԢ0ʞ7В &XBܳ,,~#$]ٺBeOn=?VȡwÙ }*D?'&pRKYkR ؼΏy0C7{H՚\UA L9a@KmzX4kwUYdlÓ7ڃd.n75;w#?aB4^%bZ.sgkXo\a&,|*wjz+7no^clo4k~@:NUI^oEh{6lyYhn8\3Lr6k8 ?@Lj!70aC|z cW3Q.R>]ld!+Q(b!#+UT(4^fԬߙ: 9kdIW^ю{2#s%?}sVssܛl/+ق%0SC?mN};g`v.6D:Y7w?<񢼻 \ۇSXX61꺓Xi95-čx0G!e)aOCDm!46r% lU\$*l ,c曹`WN+搦qD[?`G&Wxݘst߯䒘D1[]38^F)xct?L/~x?؛iH2-w5ql*/|vp:Q|s k#:_A>9>y-K/KQfl  gۨ>_Mvnewv`؀\;^B2 S7J΁z+}c3{6jeI>̞} pTy9BH;I[)0~}4o!9lqVxVYCUs+Il\`Go‹V}"㷽R-CiD噱JJX;*k</ Ч1*A|$ Zr/ 6Ҭ8MAs)U~:dbr6\ȩV7SL2fx Wܬxv/c94]}f7 =ܔ.T7z_ྴEB?}qQFg& c欁Ӹ2[^Z>-hn[ٟn 53TǒiTVKYg>'6@dRvnյ`ipN0i 8 ^K)=5\8Ny1twq Լr`V ^ϑ5ƔC,I$\ݓsqn!$؝ҾIL&V+ZOx{`1ϒWd9e2ܙ;Ji6Ϳ(tJ<+jOSgP zWzg͏!k#-ǵI+DLGP& :HкR^,i(l?nL?)# INU<ܓ[K*զCIsNœgTR0Qh 2"aESH'~cl_u$[~WûZ0%9ϵ)y/vB'̳ )J$$Ǜ'Qy7yH.bG3+cQ>SA$dBJ>އ+"W4|ك )bOx3؜hѦ ֻiqty8}[|&UAx8ZIEeNPrHS;_`y+F،qd~AeM7[`0L6v/m@lo9lӸNKAh2j^ğ'&qGsR۷Ho&]h?=k^R4-YˊTėщhČ7-i*T\ޥaNcf? EWWӏEͳ|\\ۗI4zʹUKԯ!kFuU5v2@d =(~9o?4tPiؼ N+]?(^M~\<{ٚyM$ƺ>'/Ü ea$bFXM \OG;o,I6rbt!I>ſ'{WQ `_} U~>Zl#q=+ W+JX쑺L7F1J!cNSR 9P8~K)J~iK6-:T=Te*òYJ]5sdDV G/L; fW8eÞ}vʼnrXpR  A6XoWΕqpΰ0o@V<-W}׃K?w-/=¡X#ܚ1? 75!x+]c cV\u pk^<w Yr.+ݐ?ۆm FcМXeΤuR5ْ`a$gUZ 7H fxLډ'ʒiNJt%.K# [,!l27ʶZe$0zZMsC;cNe 4KrNT$ uI]D6PiD|6d')̿[׳]25O\gA<.Ζ)+ļ-<׸oF݃9$'Cf4ɬ"݄4 _wȰetNIn/i6$ێEoO{]8 w_Ir -g>6_xL{RmoyNhX04)sF+Z܋۰JmX !у= ̏{L\{Z `kYNe1Wu . '|% }Aq^}J;iK.!]C=dAd-y(bgds!*GMU!jE&+僃N+ Lġ0-$_{{;Ϩςw6ɂrIK@I?1(A3|Q^9d&†ZdVwUۉ^=.Ȟ[NS3h:`Ԓ@' a[ S6(I]b晍p+FvF_ hɍux}ސڌ D0M pV'awg{2DZ/VL-0}9 *k+`"Y۟_YޒEr;ٙlp5n`=S93Z &fg}jEz ԡ^mN" IT8yku}.S1i^rd7͓fr+p^>5>?'$I ILB$}4^b,0if%!rLi,7Sx(;N'A,7 uZ.ֳ_F8Y-MdSJiglƃӎ`Iho%!G!q2nR s@'.#X| wj*(b4_g}?{G:0Z[궲#IGuؔgڡH1,'jW0v.(YYЈhg0lQox#2/0 @D2N hy9ۃ&ͥ዗9ǻy/m-}VoTE4 p8z]J6_G}$Yvl>a^ؿ^OU ۊ{`T ŎLc'ϡ$rh W > iT/jL=@y:@>=$,ܕwTezAj u/Ty04kڵ5S3ym !pK V4;ù~bx"#޿8q]Κh0sg-ir>jH/C41##v ȱ㛪doRx^ g oM6!JDw/ E)M) "%3'lύJKʈ( * ޝWski`5GBPn'_{|{{#UOdᲝgй5 __/8Z"-\յKSbIگ4$>`$C c87[S:K,P:P fVNȂmvidA JC]87BQ&SqG0A޽ج?yaQ+`ABZr{U^]1.8C҅ӟNЍV9iK4A6YˈFm4W5Yx>]$ ƥ譧 +My$GTlU4rX_ZhK|qZoexr}i ѥ} !mKIYȿ"̞&bT]ӡ4K>8]UF2*m~h\0U7=ԝԹHGG'0e;=Bow gmTG&?q[d|!i?n-͖lM4:~->08}9udZEZfE悽sq}z7fc0^Gt);V5v:}L`lvqRa˕h)D,$sDS lx,[(5հrVoF9zKi~2<<wNu7/*l wP?q47M&oo3=~ et#ս'ꖗ&Pn4hCjJf4{8t'ޠ]~yP2+.39[k.Dpv-Sa}npY4{=f3KBgK_;n҇A3Ɗh3}%a*qOWy"SpZ]1k܂J.X`C a_UHj&o'`O&YX ?W!/{V˭ޤp1wEUsR :fh-oAE`kNm AXK$a}$'c$%OՌ*Rj>bG Mf!?92?Z9[qÇρ2mҍkp$~)w.D >%wPoP*x磊~?D$CfqsI?Sgpz8̵g9^\Lq|=g]ȹxpjgJ E!ϙ m6YƠÕv4f-u'Aΐ1+P),/xg7w|` V%+pO1ybk/xbYp6pb9'i4~i6hYۅ9aQzJvEm*=xi߮b32.qe v']™1&ٳL ߋ*_2qy\qv#v+ ͇߃xYhɖޜal7AUm6?i`^ Wr7Av*۳ٝ;ؤ{ WK۷pEn=8v W2෌a6'A(~y$O k)A&,"M2S/HN3i _[JaY0gr厢 z7ަ ı^fqfwxa}*UJ).үO'v>|fܥ[]U0n2߽Pr%!`Y:Ad i30k DڮGqӈ;̌pMn,lF~vhr!{wwU%r@,S{! w~=T[ }v["q(LM6Cna4W \7UBO -K9̧*\=?ΰ=$45O_q/F~f( v TtM*yulh}mwH5OD4S 7ӺH=`M‡? Nk _׬Wr&Kn3`ŅRd6-D=ng"sEke;,9YJl uHhvfe9 [E`_:sL'ihP8  g>䮂13eWՋr. _'e߆iH<,yEבd!LuyN/0z7KaR|j#%wa#6Y,?вpA=Rk<\,֠q)~(Ɂr4zo%Q)q(+NPkœt<{p*>GL?gl}p,v %/:kw!sVt:&H:'ŧC1`Y)uNKY9R_87ϊl?tXKě[,oiAr"0;}(nt w}Y[*Wa)(l:-OO5/>ΗpokӔR[$k1еRqL|ȋ, my O.)vS ܞ)Q<{i/t(WܓcA_F#{ULU $K8dE踋[;vK!z`Y@D8s¡`qP[k̄x$=G)n^botpRo0OdL#})jl1kMQ{þ>6[gq4muUd6ro\:\d MA=Yr^1hq̚܃~Zj~*ƣF{i7{(;0WeVh|"oFQNA&Ko8RW!A˔|]6.gl$̓-ȑ DV 弿s&S+לyh[zI5M^_%%NJ 3_fKa.>5'tYfP&E]|~LJ翟rCBV*6$*;J[n܆p7t=r?S96]/ f\Sן!{knE /(^{ ޿=|sQNhZuJ9ȃˤI m ?s}cJx?N5jRYAʬheNt.qo\WઙD.MGU_1+h~<.M( _ m|h]:gn%cxטHK5z8R#>աH欈go mdىl'UG3Sno}8h6ک }b@j0 ~džlP`|d,$ua9K0|f~*(C0 = *~.D\1ol5|'|m*2ɡرXI%$_kڮT"[OS׏뺵~+< m~YP1"eGqKF"mdxb1oS-NJ2Dbkժq,a. l2VW[uH)#|o"wnÏx>ewm ?!xXxFsV  aPC(}~;;<>=LM' n.aZ~=eTMZWyU @lrP9Q /95rI,SA|H +﷖+q҆ougmps+h՜ׅ8ﯭvƢ{tiz7Yy\M#k)x&QAvVǩPuLO7_yp!zP? y!([)džM%0Ix&Aq"8Woyu vyyāKKܤ7`.}V=Ϗو{wsnt':}m)8\G=[aNR~xP%SA ,&t%U;.f;ծoYSc+ȋ"JG"7 -n¡W|ި{4~.WXY^S?PhѬ pBE %Wzd&a\`E8y%:ٱ<>}9tʲz3`%ΓA(A.9|:5_H$'?"qpTk6|y]%-=i߱) 2,:_dF$d>$!7Ÿ4eFu2ATIűtH2$FzARaUa*rGo>{]unlW>dYCV2{2Wc;dk?YËU1#)A7lͰ6DN?FI'>GzT#c5hkUMʎ1"0]6Wa+x3y;-:)H6n&rv|'20X#P&-\JΫ4;|2g(x&{l>{ط KIPK#z|ZoZ<]qM8a]n| M7f`XTci|ρ ]@#pn6MND3(pمܓv"Idd`Y\ċ5mYɌa9y JE[MiSkKDob#ܖ@,'ӯ7ɍ)V' MIvrń/ uXd1 +Àt$Fɠ`9u\&Up&.o3pZqRC[U17\e;7n nRV v gh_)燠p|c$Xkـ a޶R3u;v$ttz3]0vݥ9n|)ßÈ>H*OzH= , ykҒriAG9gbR~PqWUsJ"W}m{d&0Y'ȑuA*-\7]܍gX߾e֙{cθ=9OUC { Fp<ҩf[eL#ǩ$#gx :!Dm̧צcfDz8.Gĕ-j MoPbީdT?Z)Wh3/w4YuZV-6Z[ }P?(סҙ'6Ztg֫A|=rctp+t׻vk,κ 4ibhZF~%6JSFFi֩!8+کI=Cd|)fFS7hX8XepOg\R?xrrkg$X'B=3cM/kͻZ3VMb4DWc,FT4/1T nɂ /ѩ4z] Udq+k4_ڣ^>pr`}$\ @[pxp=ŗՐn᯻jD/ 2`vƱ7e.5*g_8;vdt19 \sj6рlS Iys%QP%-!5i]B_DN.ϩ%A׌xv\Cn<̧o{=׈nC٘aXZVʖxOf}Gq$<$H>"NZ|t4O=3awh ,V_s7. q S}^ȝpue\@TX(p R.,8+=n"ȁ%T*_ \%6J5$LNB~ wEf} Yz/[4qŊyzB7wëaжƈّi6<ʥM:>$򝯱ҋI&&G p,@5T[~f{ nh2^sN!mjmt+ĸ~F&s{糷VƣS/`W)$f`qi)F{`"(XyoA$ɀSAQrSQ>4$>5qSgـ&_Hd36(>fw*{XK \+G.| 35nb:=V-SObF hEJ֑Y2}Evr;rEYk\e~$yqp&҂QGXzy#.T"6dT %*Z¯88xfR\k':<,();=Ca'rgAk{.B=%אJ?lj#Q[nYDiKٱlmf*[ǚHo.sV dyeP!a#$yf<ˌ@mY汙{-6>$Ӊ_//[[%8x|n| {51<0-شR`ET_M)c$rx` սJۣdW)D\z<4vOyY8`6GfKޠS7Ԩ!@>,IM ]Nu]9eeFRerA:a\UW7 < z:/Jxl38B..N&}`A%~{BΟR! DapC9:ylU`+'ww]dlv?%ڳ9a')h)Ps"j }WQ3Y1< l>%V?UfަCS5$hzÛ =CˣOV/Zl&ހX'Q` p yX/ztڻ;;JLgk?*W`n>9yU-hs:)_ 1h^Ek"8ô\"{]wy@[z?h tAnϞ`NnbԱũhl'rr6Dt~w,/Ov(n~u5_<m0U]O?Dg^3VecVQjj`lM{ or:'ϗs2Y;%AC8ˊu\ ~Ľ^0-6`@<p^ mf7tjLl> 1 VEJ ޫj.:샶JARF%D h#5vzjð:uw^}}:i:QVL[yͥ&nld?Ȏ,ĪI.P WL&h΃;wsX`+/xr3nfLWiܵ-ƗTYeqxd-]䖡4$OR$@~C5trU.^Exӫ-oz? \BxUc*9,aS7G.-}(-4M2.$&%7K`AV/~kN< R%~K\“pޢ^Kc{H+PUu'ď}Z51bA9j2jyT_O`J,;~tf ɴr8-ǏcZJ 4>FSW}N8/y&QU#kU.qS@|UM@1lW6>6K}H3\\>&MHo-IR'㔨8sww÷uB$o=CED]dmWQ}.g bW`Jz ڮX\-&S.c ]-R -).F^Ճg;Ɠq8d &'*?_cJ%JRu #ƧFkF65OVɻ`xf=m r=a*뽋ZWL _"wuwcOE6: ={L КdDEfnoMwlhK: C^p8rz 79./[M78|K_}OJq֧<_h^`"3k+&S<"FZeӤ.[;WgCt `P$_a Ca9bGCorum&9],cEsbRAevKrIVv To(z܋6:B)Axq[NzlYX |=LgbwkZBoVp_ލn@n7YEt+ysHaAFT/^ qb|NF0NXZBhv.ZkN  W`|cHWCTo6fLrM _&6|V7^4x2I;R]M,7_f`b ylp立IJ+ ϦSA~uCn{A YmOEwxsFz}Ԃ.L z^ӝɾPRa% Nmp5*IymWyacӏ~sYE֒kp-i.8C"B4})s]F1Dr,<af7`R V[( Yvu!.4.FAot;lK.Z{OY;5VŊq[x<T a57bV5Kx}+ASvWf5ߡQY8%eWȝ"p BEW!\0yk"c#%4|dg.iذQ_n;p2վS_UMNd$ G 9Iiq#㐋nJ!F'Hs+%KԃEtGH(,~E%1쯭 \'f9n%!X+a>pxe h98^F`">G!&2VF#;qkp@oĮ5=|{ܶxu~Fv_;U=Uȋ82K혝@=Q'dnaF*k\Vdcm u\pap X?|LUAE\PGC >*xLv] |OblN$rmqx6wZ ZP9>>"lx3IeL ;Nպaͼt4mN/lcz'?.E Qyg!`~j(TOl:DYCd lA߯a$x9mЏlrp\9q> [ᬕ@{"|NNO|y2|/DP cdϥ"[x lW2/3xx+0&M]vzݽJFB](˔%YQ6XD =s e?(#˷cM ÂuJ|}yB-OMXO*aϺ5nxW޷=MPz42 С}'oi5-vƖMՔ+^)/k5}2WYe! ^f_7j23LwW.i@iH>Qo EPD6.{^< QHV@DJ6Fyo %-G{\M➔ v_؄Dwy LaaS|}>\1_0ph6Wo_6?+,hMg/7,\ra=\P%"qOM>Kv9snp.rJ/{y>eJdyNb^Nu#Y]R-g}1/-<݆["mWyժ^SG1VZ9WXI.>FLt&im9,uغ 4W;u<?:swX6qP4~S=]"f64ON,`dq. =~3*tEAu0<d/V.WI|-tA?KI7KRe18,bqZ8B޾CEh@|/KV୪i -WNx]i^W{; ȩnLSa,qvmQzIs]`$n4B4d:A/"4Z:yrDFwhL,3 +:8 Ƶ>˹&ZsS8`hJn0P.xc-gHąG0*i:Ϋ %iga-i-?[g k,n:+?<D%iyM]bX)Q f=wA>.cI0OvXlѓ&%Y 2Em U 5kdSS%iOf4;?݊-o+2 gG D5GV*=B^o$ nuH&9/}~EƖi`&~=Qcd"[q/fЗɗPH' "&`s}.3 _xٚ4넢mh`f[!?(O9K?雥˷p8R %.&1/ȐYSbe<\)#*w+rN;Mb% T腍ҠC 'B}UO9AaW-y)yn I dU9=G^S'[0kߓThvR8mJ fY$k Et(|k?6"_>@GdxHO7 z?? +pfUy:T+ 'ȰR@ЎN cG3R?FZְW8m 0Nn+.qll&'Шm+`\ڇ&eّY#pk>w(l6C݁q;TNzpHKrWP| xBMN[ Vzs| ĎՂ'Z \l,&9,P@oA oFESoZ|TeI;8C@Z zՆj(@%Zbvp胔~kP]MH3?U@&PCs*2 ?@4 4 deflate@[(TREE =p-{ 0 HU `ax l *K u  K$ ) 82&P> hE_P uW Aa=kLyG]?(7@v Xtpt  O   /|x^Y}\W?D!zaL-?~ι9׹scw=RƞnzxPȦ^POWY.Tt{˥ e{ۀTX5'CMk[{Y X_Z6cS6<{vE"va>ylB姽WVwE{_ vbWR0e:zcn=fB]'Ӯ^izG -Bj7D*4^;_QN,Xǯ1 ,wq,{ؕh{?ax?=}D (U+d3p~")n$cOj$7?!gTȬeb=1nzRhZY$yF76w83~"pnfhw)ƿjj)m:óo7E Ea{NJlf+T[a/Z:g7!u&FVhNv9gx|/U)d>?9~8[/,=7|JaBSRķ f.KT[{]֫~ݭ/ Ff2*nB(MU۰C<Xorj/[Whc_"?Ζƽq[?~|'4/v'܅9)!^mG.qyn9yE׾|6?2R7oʃyޗx{G+w;" =6a;zC*N\}76r츇#n@[uQ]iތ8_3k{}V2p- ;'2{8U~ا1{EBx݁Gm/ju}o"-,3?Rmٔ s߶5_Q撦BsĹc5;t6m*EI2}νiſ g(WA!nS:GՖ~B4OyTi\zt*Vfo½29!uѽF~g}D䑏>A{qv:o_uIyBtBk})A#sH0ƏlƌӷIL?`oymh\DU1HM'ϟ}w=*gRpNc ^Ү .3H+(>u0Opch /z؏q| UB K:YQL̟* 18'>6}~{ӊ؊ї Yr [y}QbBy|qB4h wH:ŞGlOex{2\^> }t#?tSۤ3#GkckeB%fex l8zBML 1iC_E؇>yX؇0HtMv%`jl^֮{!/`v`u*Eo\QkFc3LJzሟ?A簢'KL{B(ո4n4y~0~ڄ͢|b dLB^3ıV{ʞ0ceBvBs7S ٷİ9ÓGEO2X_]?G=ogׁd긲]?`qAXRF|ͦwc6>p9>TvUǑ̘[ck?>@qWO٠3F4{DޠGnON==uq٭ByJo f`ȼQ5vS^[7S!JGO V{,Γ~/ <x#~d<ZHeM'Q&yWG .Zz ~G#r#a ~fvNPzom.L> 3N@ט TOz߉>2"܈'/S,i Q ~ m-]哳sƠ7?_D<‰/!8O(q/lM Q:b-4ZgI퓏)w]NE"|5;/!I)"ʉ-_[ ύ([4 6ۣ {yWyvП밒T|';蹈! yfw3uc~}_:2ߒ\u[ByS|'#݆Ŧ/q8[q/WXEzF䏛7͋Sm971㻴@7".k? W2{\W5QAO޳B݂>z<o{wi_E)^9D\{ުvC)fE_P8;Gdļyׁ]LFfCWcW9Ҋr)ePo|;iûTĻ> 8 `G?46+;rdt LkA};ա>DdqHv|Q~6O/ lO?Eghô;'xV^j /sfaQ =PG<I;Hp~_/ .#eI7n2⣖@yWe;+A 37Rtc,xBYP!ƗXމߨݣcji_/%[w5ַAqBW/_:z%}5wt{F۬{-J//G'sjB-z]Abpu%d$t1 JTOEmpŌߤ|up ??NR-c\aǂL~p|ec9?I?.eGuB-|/ykmG{ {JHam5lcJEl:FtnQ9΋=7~3R[GiVC8}#{+;0նxO9k/K@p;= q";-/wx^mPVEǯVb(j3" :T#7w"o! DpTR/b|iC/9{ٻٳItԩSN:uT=N=d.{9k 'Cce d@䊤LNanw:Ő=mQ1RWQ5GrǾuNRQkCƯ|jc|-5[_pj݅>M {&rݬ˂zJNyL??J9UoY>uS6ys!;e+S!Ћ4CڃÏpJ׋!Wx?u|^~':uԩSNw-Tnd"_;NBN0ˈ~@g4zh rC+甋>ʩy g8nZT. E J~=e3?`b>'}5y;z |XG!bZS}F3'd {ST2M|:;~@ʯ'+n"Sr,F>M[ʁ@}Bn{?1;[juU.,ȗ^~'/JGkidߑ)ݙΩk鑈v8幏Qx>LL4@.­Τv߆J' *70>?k- ee7v;m*iRKJWlq5G5$Dq._qq^W/>F(wy;X觹b=ԑ+gB/=N.]wc1K0Nwb<q_{3 x u@D.<2Y'Qhw42IO~Z1S l&QwQBTq$WՇ_1^-~^M9|9]}'q߾QOr^/ـ~4JKrc-?D7~QFFg7XBsމP]KޙpYqCU>D# O6A$Xw+>g*qb~˫gy 9VN$N=QhzEZ2~uނ\V6Q}:Ca/,؏O#~jCߧz n`".U]1NY 6Ջ'`N}k露yos ƏvA>Fb=?!S2 u">¹WrE~~ qt-aUn yQ?BNuj(^}\'ƨq2^Ѧ`Eq=d-?81Agv9 D+ZrJcJ%6*3E"gNbAz]!{Xp{0ȳ;{ |dnBzI2~}/Tfa/3tO%@["7K@㝪Amx-B[BVYX e3z^nⷱR4b,TrFAu(ZSyW+c]݉.XߎIF,/HsE ؟)x[] ڂ^Quw*4m //;}#HtuH/7A.]j^K+> UhK͛-whͻ/1W+~ŶSVhx=\v و$>c<.]oÎ ةm^ V7vu)eWTh2N|3Z|*/6!G{b7FC~ƌnX ? =ho៤Džxf~0h=hڬ;iUVsf{'b8PL2{Ž𿬽;GVXx3z^5n{ m~Kk; teC_)47goC{Xc!giI"M^~u{q^rX|YR9;`gx5î|Ւ>m"]}K"m>zŜ2Sc" wCrqсx֦58R@{>n|`8)7#G~.[%+TzvyZ2%*X^A^x;aY# 瓅1{fmgr}XP G턞kO􅷑*K> 5B`Wƽ};Y?rm'CzqR|?Oq8 ~DshS׀pgܟ {;M}##}e 'S<ֿɯsz<ψ9k!^dqn_ҙTPؕ: &?x&b-Zl܃XSgA"r$(x|a: 滤㓷d'[YB~it 6AMxg}3ݬp?zlEOz|eGq~,]0Zh}6 Бlد- 6P<Lo_76C>&CyB w@s+\>ɸz`L>;|5Vc5Vc5VB-礭ToOXPd!':9*M.ڍD5nQ<}Q66W!qȗW(Txs?釺,jFJm#N)/%"o6x+R>~Uk{$؛Ej爺͈{F-Igw{L@eI8ɝꪞtȷ?n2VFvn>7ٱ,ZTgO FNXeoC+y7~{;/4fs 4DUA^yfQg&l3/>Yތ>x=Ck" d꒮#*LA"8ǐ7lO0 \Ig|zF~X6Y?؞B]?Mz~ ^*&MV ez1fQQɉj܌w z '.]ת/SQo[S'6)}/Kש/0v UW'Ff/wEP1waom'$o_Կ>Tv z]a[6յQ/03w}wl$d=S]G[tފ%4^lK;E?>2bZҒ{ɫ)'}EVRrCheXu>Fa_4 oEXʏ|-/̞'ݲ/gurO'6~`_K+z"MKޙ KAaQG] (nArvnmNv(u~y.;}3&P1vvTlvuUG{0_u f 1R? *tQ%Uɂi򐗉Ou'*p.;qYugl}kt#fS>1왷{oIq| =_U?=y~WWC޽Biz ^ZoO+) T҄sCqL/PHSm  Q({5[% 25#?#o/Ѿ`'Q~a/]yOro> N?m]P3`7v/SO&@.D|ӣ+$ފGZ!#./gfvQume|B6q1 q~>,*6QxfOSz,zd,1/QyGQ׍+WoΈ4󵕪dy*&ZwZp>VAgܨ>s(!g9(ڳ=k O9j9-s!GV?;_Af=q~_}yLD"7i?)x7yag?y} zEwBNA??Oں1Oq gC5k.R=ڃ΃[ZhZf"9JP{;~cdc7ૌo I3tB|~gA؛Χ5o, O䷖*Yd(Yj< !)7dV7#vnFt1 5^ Aq3K?}ۀٗ7ȴ+8|SoY^ڙ2ۉ?I Kt^Q^9[^o)X} #yi%5Y!OA@;< ]lV{um/ڕ,m|?{We$$]w-_y')G=uwo̐U8Or%#rO5ǏDWA#_btT P_!~#:9ky6]/N'wa?B)qtr5قg7R7s{lKE(qF!m}-F1B `冞x^XgP^^[D(⨋ E%_3!࠱EX[,K$XD#) bATjul(5Xchܙ9{v={sK1ܲBht:E3K>!NS)yp ŪݾJzv]ZdJf\,U3 }noڬ65P!MOꧫq31oq#I$h2!:ٮd2\4|#=ޫd: 6qnM¾e䞵ao̿<x K]vֳ7< }HV;&*#/یqq pfj=v~[ЂH} ɯ#yħBhZ(JȘXcv5.:6B%aӕL+=[tIwu:Wό(;o!M}Z!t+n> !⼬V̟`oUs]|ש^+_oc<xZ/aإ-~܅wR2_jǘ[?3&yvRf EBL6o AK{S̟,o|I=L{9m~|.hflCܛ{Iғ:!UH'e7`kq(c FؼZ؃Y ~wMM^jF3~d^NԑtpA^!/Cx?tp>9"8/? g}052s& Dwwބh\+?Pȍ߇.Sc+! L/tF)R 21rYt D%qH";$"45ag :1?sMwgeW!tVA灚ko|5O$ye2<-M\P'?{!|2qR_cǼ~.M] rp je9k[9x~i C8-;d>B ɜp't 6UoS>tƾ|ί(4oIӟ䕉 FN)T(8 ;q!aJf ~Ȕ7povuiU<̾lkL6Cc}/r4ℵ~FWK81xYӡ]<B¾-']{*=MT<-{ >H^8_ԝ<ϦP(:,e ^?e`5n:y\CL_y'z8Yӻpb?=s xJ/~]2_ֳAA6~CzV\&oAiLGsnXO qюLG^[/W`@]*GENg$솝r]ϋ#4܃ 7[FE74vLwz]r|a뾺L߃Cmch;A6:WUnti.=߁-ou2ޡgtb||Pww^\eߩRۀɈ[xG*.2.3@ ם?UO}C\ػ1}ȸ:@E4El ic쟚?G|RsXǚ/2{ĕM9ڹ- ;l.w2vdb=cn/pObq5)nG)$^w3$4OU"DKLC ß'CʧDʲQ6C4/i>=F,MT]9 *.,]: 1뫧fمX_ '?.a0BJjyzGƓJvC8섽Ca?pK$/w{O߇ ,>yYkOJxդ(%y]R&^/%@K &C$oS8^u"~mI-Urߩ!'U7^ع2NXv"Cv4JR&9vd'4f{q3`sldޛ.ཐw'HwFM˽qNVa",!wmQ emq/G@ Kzغ~y:$CR->  K0l#\/b~m$ 9sʗs.t$arăU"`+ [dJԶ%/-Y2gnqMǰr: OE'k$e[7T}:!zEةw-=m՝H2 ]vs8m租|oj|Df+uXH73iҤI&}91vƼWKt?9Nr5˜'IYA:lWB| ;w{_-Uz`\KȤoz +o`/+yq@w\<Zqo0ދo_ ۣRc\Ա*]Yoaܨ <&B^M܃xîN^Ma; aG;U<@xۂyϻO3g뿏I&M46z{:m1 OY[3/^:I >%~-٭ْI(;V1͹|- vwS5v vF=F]F/?̒}fpD:?$JիS0>*OH/eP:I\:Y[7Vcl$aH[Uq@= discZǟ4@}\ldwM뿏I&M4N }X>9")= 1"5Fd)w{B//8qȸ<sT{wuOE:KU }Udz,ԟF{$=71n . 6g|> ơ ǭEPSps2(lܧ5 |=)FҠr&d+W˧ 9Vz>v+h ~'CAuF탾䠤8}4q&TQўSuq{f?ܱn8zγ(} Yx}?F^F)C7D8m~7\c#F;>8gp")(_X>E`׼ܣطq59WŘ; ]~#B=_%3!gr_KtCLFWuDcҤIqpx^Yy\gDkG.F4sL0EC%eG,"9"X7Q|{~syg9? ?(ZqKޡh2k=蜝ղq4yRn]Dl[5* ,ފvdDgf/eiuP#yc@xS!g~zy{mGyh:FkuYFO޲8y}5.oEg{ngǽR.Xq6F%]Q^tۋv*i~aPlo˾韎-=BS ~"m38]oàٽN/7)&O˗àf#. FZՖIɨOυ`/|%M7AjnS@hm{%ߵz_ M:eydE2LAu{)2{rhg;%فoZ$jzVAsP}.|Q8KΗw~ÈtmulX(C9 {qoocc=F<f')/ezq , y3a~~3Iqb]?UU 時mKɔ+0ŹE[[1rg~g.H+8c{ _lihM|ēP\2 B yZ 6*"X=0C)Y0}9E4 idbDxCag ;[S~̫ r9Q*/`._\~Q >y-G0/֥<]5W)zl_e^+zf^mE oa^1Dye ]i0solaPEN>/Vv෼aQnF#Fq93cexRaD#kYt->]WWzG(}ްWT٦} }TiX5ȅxjb_+Hᅩ=Tdע[oQt/R(|I> 2|VL/üXm`?HlW xsf(vNVx'S>ߠ_>=`XX^{+9|}Rgwn#JЎEOAy'a{QyAkG/>,1_%(~ B?zbt=ͮ#^&\{? ny,gE_ ( N%5w$٭4S93KXy>/KyU)Y5!nQs*oeQԛwuOFR"dϮ#lw sn;Ms:# ⽄}fCOIJp]Sv.^btz ?(c/S\_yz}k4;gA _zB\uݯK#?N7 yu죌 ?5gr],!+Ng*K[Sڵ VŁg wz'LGc,tkET6kYB^WT+Y?(Xx^Yy\?!fT^2ʌ`zeɌeFb^d{-HBRZBJK1ˌ:u??99yg;Ul_3?[b4編4/(Z.h )Q[6U2VW#<2kܻTk^N /r}{m;WϟZ)Z/Qod;e|ar'cysQfqΛgyԤ3,9/<\6 b`UG@7#GzL6|C0?1YrcSO]F1]9s [v{D6Nb>"؆yKhDv0w=s cp%j 3]C,aZc}ADӎuT$!X7_s7a}878Fswnu*}]+R+|kަ/;'짇d0!8ݞ :8_7TKOބnB&c}W4߃vEoQY bB|x=W(s'12 ɱ1uDm2C/[GZ75Wg~cQ/&=z=qٜ#8/po9 sn➳Z2ٿ.c# A,Ѹz4KǺmsg'-1Vc]\Zs[o|J~`\Y jTBF[7"QrH+Kg`<ĔkH/mU%hS?xe-y7w_'@1-~a.oC;UW%GÕ'ê7SS/A^֐0|yJv4]i+{ &[{1|?|,[o#K!7ӸEG|uu+/*pOؿs;٣;n=`Oڐ ] z0䛢pЯ6=:5=6ЋM Y ;ƹz/ɟw9Ys$jKAp.{fL#yb_|c<9"(~ߧKzVtG~!ƞ͐2bΏ_Po>y4 @]4S }:FVklG0G|UCZVO_)|<-7$. x ŏv-O ݈ȗg5:v! W=  ?Ԋw)[푨|y~>1`3Ζ:.W[5"}fJ=y%~B )d{e6xJ1 l@>v2/L,> F |6zB):?˪#pr`y@ߊe91ՋC 7^K/֧ވs%:(U퐧("a){:얭ۄ8|GՀ ؀oe ⯑A/Ql?~{)곩7 ZqHuTt@]:"*'aVCr(mNēk߇\Af<ݙ&QOPh5wy_WGj:dw6ǹJ5^FY~bؿ*AAONnIq(ⶇ ͌D*6ՂGbs!+E~Qv;B/2~&֯y ԏ}y<R|v'g.>wӷOe48 ak)1K$)A=޲''_sO` _K>*ف9rDqkB})o52c3o^MԷ 8@gko;(q~#jO*Dkeo4зq'䘥ݥ~nwze> W'j8|bBcbkT<"J>טN@>%y+4E{bB/bq*G?Hk~|}ftӲ8U==;SbG33;(߭y6+]ѰcQ~Br"76 a5%Y5I7;l!(N{}A4c66w zn{UaW$ۇj`J-VkwOSݗ>FqvH~Dnե5{v] a&d͐h(~G\iؾx^YLWGcl u)uS%h\U(RĥxAк E * bF+j9yOM3/sϙ{gs朑$JܔH"yoJ$*_ 0IC0>{`դr%/'9J~泰z9: ={;KЛ`W':x$S옶1b{#pC)+.%yD%}ҤO[τ蛶^"2jh6 ϑ֙27%>|Q2US^zV9DD1Z?y$4/y7!6_a6; { +}qJߢlmYF8eAI}05vn5U z/=-Gߒ%ÿl&*)*m8/۾4~Ź2nw ',a7D{8o/_rFa}m>t+IʦiD)=q>q&|Z(|7kϤ' 4hРA&x^Yi\Y>cs4 c2 MsڪsЧr➴%]j?d}|w숤 ߰#΁f%v!Kag>cٴW]13-PN/I㚙'Zo4JOH+{ss$?__J~D%OO6, vO= y9{VeWğr#.8 ZU;?h(IWa}[$) MGfRi ƻ9od#_"_4@X\"/֖_?9ƛPoU sR]`,.Q_t y6mH3l'3TxvF~2Z~qGR-36e5Qi|PoxGo^ ֪B$ꏋU1s=Eʇwt3O?V:= m6eJM+4;Z%iS5~N?,io yzN`_ZG=_ b;>Pj`=N:UO\OB$j3s%?h\կo;_UWm9+>c5@q>IF)L=}Q,Ϋ--I&z2y  4}z;^j[ KMD_E Vc8Z.QBm0 J=ͮߠUOAߊ<Ɲ%M!?^ϒ4' C$dP?,m4~w}:ȯUug*>/MtAbUW?"ǯtOMSq?tf`<#{8xK8Ygߑ-ZshP@ӈoзC^y'8`=n>XTT}Z",6JZ+_ΈQ'vfޏI [B\#1Ral-7qA?r ?~= =| MI,U_'3umG C|/S31񆯤E^gvWyΚ*>v8@?@%U# @{)i6*Po-FW1I8i9lI S!G^%"KqZ1UbG8nK%MRT?8obiQƾuO%6*sHx+x>>GQ򼟄bG:NO=PgSuǥ<+%*gnC(yz>^~ 'B;I;?p|) ܟho^}mbpϬd:U ߾GD#'C^LlA4{q}s@eS&YGZ_rJb"/xK l VA[!7yS͓*3jg?!%8w]#m4 M/Wҋc?~Avo rB$.vX%n`~jf&ު~F-.[ ojg$?x'CT yZ bi:w$A޷IkƂ& /^OANYt.mshW.~D SNA-^xAQ#?heN՜oq3j>{LCo7?;Ϣ@nMUKp$hҿle˰٠'C7 ]3 s+ʡߴn,v68gCO' q>Tf%c|nu,¯\o6cڻqnXGQe}g1UK!c-`^Z lv^]1?.=1sե70?Gb7nV,¯|3u @^hza@}{t帮z~~1B@be<nB;݅sFcҺ r&3x=>{]9\Q>%uY>܄qnr/nɰlz ^{H@yvq [Kni/}}bP{|z{I|=sJ|qN٨ryLܯl7ڹEw_q+?~Q,P"嚒qhǫ!O@.+[~Zʏ vF /pޙk$O+ԐߩR ߦ |= 8-#X_b?RS&aݘsH=2s2pvGgmy+?-ƫw~?m#|vH4#z¾捞hOl{GW A۞)7{ -vcՕx?Hy>5P~nƸt_qeM{p: S(%xEr\!0y+/M~y$\A~ \47Ȁ!߀'8pGz Z䧂fZPpD };5gGxekUI_B w~,Î"95[y7ӱX˫PO**ˆF4hivޣֱZeo&ߣi%B_3T|T#:wY8wn@!n _i^ #˕(Չgd1R5qBIutyB!qb?t;,3w㰣NC͔zc X'VqZ<.#^B_v{ܛ'7#ؽx] ȕ;92oz|JϲA=2B-$b@uYW[GwWӌC@%ڸx7qz{R^||G ?{yE//;#$K }n@#ʒ] 0i˩nu.5s,: GJ 2ID}z u藷d1|yໄ\c S[Y,H-:b~S+Ǣ~NV~R^^za б u; uJc!jS|! M EWȧtɨsΨe]v]]>FV4I hw]O5'j/?hD#ш 㩾>}otAOo]wRu!PX-vE}Hs?h.Gڙʸ0a K)}#eMmk@_l|OU7 >do@u+!. ,MEq ^ D:١r]_w!Uqb-Q-FOx':2UNc^Jm㛰vهsfgcgxh^:*;t)x^kPUUwN3RЋh< ʄ.3hsz2f 쁁>d/7WDKbLǦE9ɔ|lf}ڬ?_ch<ݹͅ!^r<(z! ~xR_!6y%^Zg@?\O-<ێ7z mx}:3Gʠ{ylZޤqE<'n .Zo}#v%ܗQ+nے |%PKsG֑߬u`W r/+jzVb\=-dYޓM1'mD=*󕑞聿FclW21 P6߁9XwlsCDv"wcnk#w_}TCX{s0̡׆fLE=g.>݄:OGAЉ/5SΣokF?1;3n=x{&9v.+h'9zY<-=ZSu?{㹉[2nil8ר>o&V>7N^~%o. 3ARKg;]Lx.rՃ_;v畾݋nVُ/A76_d!]u6!ᇾ朐\{1 ;먎Fu鈳 ǃd o3l];"jyMH9>ﰾ](_NUa=!2ӿ@۶f>Yޓ\! SĽ6w[}71~$]hFG_4luSY׋NrqKt""{S{Kq G~lG,3 j1T݋QgQrff O}qCrs(r(.&⸜¾p>jCPM4b1 89$ո28"j(ü>;a'(*x^Z}XY?CcPcjNYE%"2!tgޞR*HIigeJ:\?us>ys߿sBߋI$hZޠ[d~,{!뾝oH,H6I-hXW;; }1{?|#QsR(qR&'+׼ {8s ?aH>aOn!n^~G.n݄iH5ΝEܤN{#emĥS`Y%65BݻKF =-.|-Ovmq\罰{,o#Eŷ:֕%tq)!+~E%U gs54:e*dws 5KوE歰ǘi8=`LٮA?.}V,Q@EKr){ 1޵O7h wm3#?~Cڹu[})X ,zlѱ1%J*_ ] c@(٫\X,՟b}7RwPX3߯yݱw:Vy;;sZEeҤxTCaifo`\<x>}οe;iȧOK_V]};O<^;bZl/xz9y]oL`曀k%G ȳbU@x_FIcF\/e]#~g6#~kB}6m,QeU=Ea~pOL(Q>^s߇[h75q9JO/WJوM/Q')7ь*z}+&FX Q߂}]>~ /!,#v"j ġqQ{B1 -ߚop@/L_f|I3UMwhG%''Yav Bm]r R(q-F!rW%(Z-Qku$'+2\t_#-ۂ}}˵7i7r*~D3륭!jΚxChE+ZW/&DrjOy+zsI{noi]1^;Q;ܹxiSj~9ZGV"Γ&+u&}lyX?-*-A$^m\ؕ(e~ƑwFZl|D~~"X9z"IC$GcA=u+)%KN`SfH)GTje-T}v/t/do%i,2V'nT;^ߜ`7 P?R@&|oKCXJvP|qyu' bcLy:v+}0uޯ\aӄ $V&\2A?VhE+Z׆:_; Oh6:>*Q܋Z/ёRYN?/Y<CwI= @7}G 7}O([*l}zؙ1Eo;A]w·=qso^H\U ^K1NI@ޤ.n1xWS. D4 D=_ fxnzyui)?oR|-ɊV͸7u?ΈTYf&;Sqf}MwULigL ipO-qm>}MWU됔 /({7V<<?.&);m8G:&h{b t~T#jdJ!pnA㼨eDf.ሧ5r0Ggz#I~,3x^ZiTW?E"5nLR3|s(E& AP<"sDId88|́9䘡[Co~ksL{e- c|] 0(QhT Vˊ G+j_/V|ݯoY~[%m&dMWqjovˆيFG?L@@ϮTy.Tw=Fmex@3\O COGRͷ"߬ g_:OvN *>d@]B^Xr0k^ڨh& E3F?sz샘5rKM +hC~+;\⧐;fA ZЂ-hA _vi~ Kc8q(Y~Jjv ~qNxcx-+ZMEޔ]idr-{{=XG^Y6̈́ު\;z[/#Пs#;֔;ȡֈ"; Z8E^۰;2ZPFA@L "nE =}ЮN֔G<7q qbMKUxkA?(kc !EW)xWk` k]kgy 9g_kw{r 幖>R(oЮ|#mawFO ?.!pOȫT8{ϜGSf;pw%LЃG! gS(W׆->3>:a-WRۥ ]3z#}X$/άn-˗Pn.՟*hYǫan+ڠ@ bS<3>:yȇkW4-.ec"> 3gUd#*a@B`Y7*`g ;lF#cުVȻ"{wݙ cϞ"n0"Nчއ1[#._!.O+6+3֨ /|p.3^"?'q^;+>3| *BG#ͱ /+X'͡R;cs#?!gS'ΟVsGQ?ގ_zwxj?{£t=&AnUOuNF{3#8Pɟlec݃w#Pw7Ε@M;Sޞ*LU7_(6j{_@\ ;ѯ NV(گ>NuT[zVut^ȩ?Ps99=pR3ӵcwOQɨ[I?S Kwþ:7y`Em.K?vYUo]~G3?h#_u7ܖ;×΋C7:OPo&~Tq5y>b$ҌQ=<^36uBW3ɍClOCvկZWsEݮ/;X֢lq)Ðz\{u-ytr>톶~@vE=-om G7 )>Spg6q8Kil FJzՠ_/DY?Ἒg.^NFSJ)~HOoF?@䫭p9%$}G7)>")c_̸4/ro*rz^wAG+l䄹YOn?x^iPVUO5 ]ͥܘиjbMsE% PQ#+ .QYdW09ÈItD-CG#)Z_m~9}eI0ƌɝ&۬Xh^M/z-l#p#8yR;͇eǒͦtF6+ $1_{a*-ƹºG ;ܷa=ӑq/bD4ƹa?K, u ^/<E<~WYVQNtUCϯja}j&xaku@jۂvœ[un>3 h,p_Aߍ$?bD yq(c l('^JT]'<kw!QU9vivɺQD6"3){>QuO4kRB9D| E>|\~6{~̎ݲ` 'Bbr$DoLxZ;?ym^v)3vD&!3GV\C.:N5^+A|31Έ;ܩIK~-v("^P\blu: #m?} yo%Mui]_{ Λ}:^ ^z [_~GƩZtqs] ԭcC9o b[!Y< 76mڴi?Q4Lonk4r&58-WDJ '8] }f G>鮨xz+:M}%k ɦ{օHM| H̅_\@?b^XlhcjN{P'ԉuD {VWu1ۚy;z:j Z^9,|/ri&z6 v3W[gͨZg/xN6mڴxwY#jWtZ;C?{!^b=Qf y?(̀D6%'e$wC= ]̖:u]aX=f~^sS맮?"c؟r 1|)xOdzG˓ϷzU?E]^ *oeg~u?06@?Z2#"ߨVo[L1ɦ=mPox^XyTYȑ1DE44&[yULKȍ$!2EhPf)J>ɡs0ˠxg8 M^s;9~wn!D|o6 h@Ѐ4->]g7$*>>2"tt]6q: >[  9Z ߓ- 'ts% U8!vmK1xݤUN1QQoX"ƕ#+ d2ONIyaX?2aDkE@a]>.ƅc&U[-W uRc[\%Wz,ߏ:ޏ?)%v*>23-jSnݶ(Uk{]T *u Z8 %O~쑾E*da9F~c!Ym=e!al?z!8M-ܴ`(i4| uŸ)\)J_ OgOqCYOx)hޣv@O{_DjXr CZHTA7ma,| `R0nn88m9&Yr ߰UYb\~Xr_J^.ZOɥ;1z9X#j:Ak6d:?w)XW 9ԓ +V<.,?W"J^Si닾mB\j;މÑ7[YkbSZEPioǢ/ 0>"Πa6[t|AЮxCp#)fx%/%^sЯ(yu8*P_syIv\.[x+P?vau ⛟B>Ҍ*NP}#5KHc@8N.eAwn}~GԆlvBG~cgG-8+P;sy[UJ"U; dd? yJp6#֑ͨ޲)EVZ|c21ND$XB۪=4u;-m{Nk6w56,_ڪD>74O^5ѷc!F3?\^ /o;G˼T.TW ӈ/ROh~\Eƈ;Τiou ] }R?#W`J3$#(QmPGCOoHHUz`ě_Xe|} ꬾI3b [yM'C>|=}o,_jyO 9 rWф|A>rO4pS?qy:Niݞ!iή27'ב7bV88~.tMmhk[Z]擃 Vy;iAGB> rWuk@ߨy_Fx_ dS庚+{Y3W_ gwzn_)y-X~Vy\}zN#d\y9,V, xWr uϼ_'ِזE|qh/4~5KF*ݑ2Rk#fJa>M*X".N· < L^wjCGIXb\Xg/ g|^$ΗNB ^C^C^1kțŹG_ط;쫛p z5 }y'ϳuK=qzKTl~ӗaqY@k8?Z~ _I=X,Pkn=}VtW+*㡲l-Ycq u!ߪKZ/uO+5?~.B|~ AQ GZHv#7!>e߁PMxч2~!h8f4"I 0/YCѿ=WϔI\bPYė(!D^O^"^b|$Ѐ4 Q/x^}POYǏ1vMk[[evBʠeiBސJIomfvC^޲Z;s;s|yss91urd?K60%N➘zSG:Y!NV>lDgNNg KND>EEWqٲq=;MT"Kz(<~a'PNX2n$J>wG4 eQQ sTOa>'9b[bIFmYDJ.ԝ[䕼3_qu!v^4hРATop&*oWTwSZ t$o tĿ%.7};uqdkcgIC>QO(~CD6u%Q?9g`*yyd~)S*JjkwTan'3.vOs1~ikg#;DVHQ]rz\W:yF{_JF065 q&S,<~^!ȷ`0l@oC&M=$} րЖo4 cca˚%tT6j#L![ɯDowPOzmF3W/9'] W}h [6Y/o@X71fhi.|pS* exe ;ԗ<*C9zxv[N;:uڈP4M9D5b3`=\ =~ u@^#ndG!4sa!@b>}uned9ׯ\;ck 9l%JȣY%'[׾V4=u$XT;V%Ng0 qj=uYH~eT'ǑۛF}E 9S]Y]Yc\ɛzྫE PڎX LI~+ }/r .C?+>$+fV%B>XwêPWvKjc[Sy ]\OqiI2K:_<#oB'ƥЭZ@cmGzeԮ8Ga zݨ&4 ?:e6 fN׵azlBeл[Pv>"k[q` y~=goO 4_J_zHψúMrl{6o >^f2RTq/||Ko{0e'mb>~8m9\s}*?1hO*ywmJ}fbB[ͬ:Wa^~g3cZk0_rIv}'=PnKn\zO(֙`]\'ar ;@/݋܉jFzk8W^5/9M}QW!YNTv!O}:tzC_:rR<_qy;|}>"N%'#k >ر&ĽDՆo$҇>N}ԣo$lHNvbuu"]8poF {QOK;F6oYs!0ZV4A\8 rfLt t `]ԼnF;"(S{i,G}'Lr;.9>~[,).֟`XFf=%ߪJCC:,pg ;XOPP뮦!]qƀo;e^Z:|Ů/^g->O' otWCK1.zjEhnQ?rD1tb้'isi]|5WNQ썿?%_2v&֗^P;s)ɹ]Vb^H"N@B6ZЂ^u%nA42J".Q pI"y'`Dvo:/ Γcp/l\ؘq4k{x[۫~ رD4SSWOƂHHQY8'4Ko& :(iHMMЎxiO٬)w6, =g?RA'I{_Vkksz+cM-J-RqzƛOD"ފ2oFEhFa<+34 LYhzW#/ȎiJލy}'e`Y.gւ)]jvR:K N6kq t-8˸/AИyܘⰈoo%tQq1ˇ/R:A&쵅_ ?:uk 'q|vJުd[ĕz߶iXڟwrcYW#W oEx3p1k>wVng5t[u#:#pv/Q>0y{"?I֋{0t<_IQMXopuO tݱXXa"MBңih|"a>M&Yf',13W03_< h'2[kږ])p$1P%|VAVm_}GsZ2ԫ{y-qarXr:w-ijG.*^E|7V#NfGQYNCw0-V{}A|weATBVs'p5nGyWC+1 Q^18 txP};(=Z - d ]q6( |ynU?=Cײȶ8oґ73GC> v4ietcؓa1h)K$^>cai"΄_ hb頞*zpsZ$+̳]˜#//"?6_[גᇄ. s4gtϦcT˧(7-ZD}Bwq]&HB;%>M`XBt1yA~:XQEr:?;RwIl<]޻¦HoU@ތ=Ż獼a=:ZkK~S7ig٬l# .Tp/v0b'އX/t\ _AOdev3fc0̟r~wD?POԕbXQ"ɍ׶nF٦,,W@=:{_(+-hA ZЂ# x^YiPWGbH Q RA $e F}1(7(K x0≛]@ݨ#f3ʇT9٪{): h@<7TOW d# 6˹ȶDnYշ)ꛉj+ BG\~z{)W ۓ-]cz@p{n?v ~hKn>йWppPcda{amGC;A\/8K@Ns^Y0 >ji>С.Dh/Ʃq h@tFEU m>SF4u(ip3:%] +)pP9+8X\<Ԟ/}V{y`+{8x~GT $R 7'uv $NU-!mӸtXw~=. jlMu }'j_y62qE1S4G{??_.i4Ib?O3c'W>ݢv.QƯt(k`?aօՔB)VBx8鵰Xy5ZOUł"=v{`cʣ[bt==[6x x>EfTozh h[x,(G!i8l@xuПs kl*Ă^%~Ct?=.cg#U'opF{~phWc]8oҹpAsncSfFAos;9 ;+n&])#QH<ϵe*571Ѐ4 $sxoIy=!|dv8cڧNǻ@iFCb^rިCЈ4_ēz_e>wx,o8z XO!&'0.q@usƸ$u2/OhOk EöPY+ЬD?+EoG$ t~NA]K}R8Iw>8F/`Hm~FSש>#`\9Tc{ 󶾧ty@oY.ݣVoM&~$LGYmAhO9#cH3C-== vM2_6vF=}yeyA9̗y}k(L迚ymC%{/7s,#Oj ynE8WqX^ML Y~w|0,rC@o_^`+w:Ķx^Yi|Ҩ-/ը55UAI4AeCb,k,E#F$$"E*D&!hjI(Zƾ/Ѻ<{,ϙ?!DUח«n].|4(?HMَ7hdF=RX7GGm\_`|\ 0̋^ˇjVVϳXwq㴵\Y+QG'8usڄy7wb^4t`:߁hqϏc_^QV'nm9 eho.#zMB_8 ػq~S\>ăطäHVF{Z S "<]'Mp~Lw۵Nݯ}cľܫ?hJc)ΩbV,X=!5Ҥ1#-ܹ!Z3s%O%RVi$C?|a[dme5=žC0X ?mV PÓ^ulG }8' Ƹvi)ةmac>HʃEϦ|yrLn9ysn/w,; +@x=.߇]Fg?߽@ޝհ/Qc^xGؽ5;K@^Cc`/lȷGM/}oJOVxw[ױ^ 7z+i;`޵niF.'G@_ݘr.75$r+.@O"`׿g+_tqϘx] H ?_ۇFyIF>;}WoE_:u.DLFq'<`Aɝ&5693&[?n/F5zx>C H~mmqlBU+gyyL:3 򷑧-&O=Y1_Da\__^=q痰S<zbV؍hR;iF3LG>!8e2?We=+aiXOf&/X}:j ۶B䋜H**܋j^?ϙ{cLZOBq.1y[l}{'*Աs$Z*J{+Oʁ#5Z7SM +A\ˑ/ȬKS ;R}/|| d\*x_{W6Ad_ȹn+V)Gz0qsY<`F~O!=Q\H5~cVe_ol"IkZu!/=9uK q>iG߱\:yUfDHg&O+RGO<"@LDUUǣޠwbcm?ěcԇ_y˾E;.AŅau17Wj$kdC7HN+聥5ѯ&ogƘSD9=V붕|ۃʢ;Kef[/q7q܈5an*pb3r {kĮ(رJ1y#.+HN>[D.ԍ='l"tiؗᴛnz,x>B~կ tԟ nA^-÷ _X_w*~De^AާÍ{gY}n&&ouD]B'݇L~1هcNΉt܋dFQJ.8xSCǹxo?EE|7JNWO8ia?ww ~K0yy_xDw+OD@+û]aSu$WX[&.3{UcQ\>oWv^.{^K&R4Nӈ{x `Ԏ8OZq.&xD :xwgM~x Ogcݺc&Nfi=? -gyO'Fb<詢gU1n=My=m~n @f(Fޢv- Ͼ rM+m_yP;;}lsNSr'P {d "NX[̻ͼPtZ?kgDQ 2Lv,t% &ϝIvݦv+EN1a Ygo63 ;. M%G|g]VoF~7ȹXTE;]ggtO>7#+ BU5ßf Y#G!ZOzqdA&Dt+вFg^d^f-W2y~k{imjk Egz'ڮKq??`^ "}}qnc~$تo{ <<ρ|"={l2 o*+U%x^ZkXVEBVe$/F(km!)H솆/"RPoAVPAA/{@ zR#/=&:+y?2gv!lx_Ī%2&DJ7GEvʫI"qr.}\7"jVJL?9r\^.Lu\5WR{fK7l(\p$} ?Ao!Iky$͏U4q'ӛx-_(igȻ ߽r˒D^~Z'x)c'ȯ-x6yQyz5E)-u(: \^ػ;~!囇`< WtE%Y_#CS%ͦ<9͒ۡ];qū9y]bci׎/KNPu7?,49 b~9 ~ QwtyqYb=K/{q㽠?|v.B$Uk!(1Q'`%xg>{S8gܵѱO+ZъV?@Z>ic B'~3?{$Q$O(ww?ǼYœ$B O U~kz#>?̴`X~ @ +E{y Wqt~%ob?as$-RɽK~/.2ߓ x񉌈QyN|"7+] jO'}31BhSkᜬtU"ߠ.BdQI^s<#tapŊM^_PI¾A&C~ Oy;$md\LpʊV6|ޡ[%@ wIo#o֧"%32HJTښ.ur!Owb%o~א:{1 ^gDwV70O@Z$#/%cڒ1j j >vãK' VIꞴ؃&җ'tJ彰os!O'TG@Cc sFMyDҬsJ$ F^,w9>ͅ_0lv(~r 4KԖz[n=P<\陼69düWMci#o:VQ9/U}3%_~i7nC=,Ux~]U/q_~?|sv3 NXъV||O}coMK%譎7&Ug.SqYGSq~ECN,{*?a!%B0O6'3X|X_ ?6*'%Җ 5 ![?ٝ4ƚʟUM ]'Wo.Ɉ7}Z"4oOf:S"We 5c5E>/..e!.ðNc_ PR9YfNC͘|H-U~Ov)IvT]W/-YfZ#>/eDI+ Mql:mR9Q fIDDfZzɈ#5m3?;?rn_Lwۢ_%WZxMՑQYYu^$J.sٝVt>7}1Pm7-xn=+ej"wcU!MO|տ(/TisRTy,Om͓xcG&=hE+Zъ iu=Ѹov#yREz[W4.a'9kNG|bAq#n{ 9JՇ7곎@\" 5FMqQyD-NSߩ'6DLT){B_Y sԇƩNb0n |`O$t_S E_}&j  no> |#ƍD@ qڗWܐGf%~?;՗ᜬ?@ƺx^ZmTEIs$B=m#Ym!P YQ\l@xWDTFW(P^A(ISH!P2lvy?׹癙gk^B{~׾\Tݭ7PƣnHN,6yDOot 靭fo~뤟"_zx֎z =-:f~}%Mq-[e3ҰvKX.ˉ-򑯹cdM04%_Tv ['9I 6輰FƑo@9pU1YYgcQ?)m?!G[ΠW׼YEE C^j!oM4?z[#{DC8w7yFG'rbh)#ߵ5/4q?$QƮEy^ɔ׶ Xxr r3v>I>" үc`vHjQ 풃Q~|.ݏhG B܂x`]JP7{O7Qri2p-G.U9 :Rh'x1#63ߛH\ MhD;ma`y i3xR~y<\+MoLoD;53z/$G= _C_'M49:\D܅sI[K74id[Ew6_giwXYv!xծ /<sg_ԯCܴUWv/Bv}-5$N8M[IȪ¹WB:zDy4zi^.m bHj }XRvmta9s:b{BgC# xoʗ5`7&c+qүF%΋yty@1<<|%`7߈|nK9CǻԽD{I-)x_},iq+oOm!6< PCق<*O\ i&mB3%8a|+%#P/G_}}p*:bNԏ ¦<~yp r*}j`_?ʎV;9O|"D{zE䂿;b 2O!7JGm^t{W1F[hڄ\uϔs? {cлx~$!zb~kхƺqj>]MSz9NJrwW{9Kԇ.@"^? :F1'0_D;;pBďJN2x~5?ḐP6ԫ+Sev&j/鼆b56cU_a`%9X|<:22AvYd:U9<~h&hkp74c |z@}V}/0Z{)m؟yj{Q~X}Fec++-ڿ_oL t:ܠ?<[ci6_ [VPc)EWh/}"Wo'}n10F=x3}sou<\$UVߴRﱱ_2S;$_ 1 x^YkXUU*r14f)xQpPhOZ&`nA@ƃ(!mxC B l@P: =䔢vF$idZng?ﲾz ' 5P7VMoP[ pC8p'7k MnL]"̂C s`/h֮ ς'"n`y/L8%WϐTm>OEG1;^g.G,}=Ux:TxퟡN{ZPIerX8wCo>p/Љi8oa/%\&d@uC)!܃,w,K J7'zuQ;ImȵENuZ5C5Cj,y3.Youu&=Wbtē!YgC?MD}(-N%U::_AB55yolߚQ"kiF}Jn2o^u$Ą!oq K؏}o}o~ Si4Gt HFدpos,6^}ԿraS{z>ץǗ10J'O։<W[aI1>X |~,c 9`u">YyWf6\Wu8ÃN2#zgq .|ðKg0o=~:+L;B[y htR!/!u5='&K89C4Gz1 C2:Eov?E~ʍ@ԋ'x(] ԅdi)\P)'ŨJ2Xg cE9󤡆j2| -y-TFM HÇq_%+ e$e&)sjQxH]P.oueCW::~2ϧ]s8yұm\Ly'?_C}#^EJ%1zvgb/ lf@~)$VјLJ|*3=BvḮ8kRYq3/Mi?Y,'Tc g-yyӓ`_7Ƃ/=0f]1ww}{Q]~rola(Oqq7YK[f'9ߞYylo yKFrN&SW7@o|oЌ'-"x=?oaziiÂb3o+}Uo-=V{RS~oq?+nԺOx^{LU C)/R4ݎ/KSD ljq!XʒPPx**_*9Kʜ-5{k6:=y΁<{@QQQQQQQQ_#DaJm9}4^Lo=h],‚R/A6OZK6GA?& qy9>u%w_I)ts#fWK#5t*5nMsRTT#6goͮ^zē"[F҅i;GFg{Xц5e%=y}/ Ili7ĸ~'xD%>{/KjNwQعuzL>DQv/11,a$,;[#_q /W/cy'#dS+EEEEEEEE<Q4xUV|/3>r1^@ @_-\ o3VSv!Gn=I~֌p(BXS@Z_/7:aqBͶ_J# ;@亠bU7czE DD]У~K;ڕY27-^}Ø?d:"[X$W~)w Ybt؋*9)*7lx^iTV'X+ GM+ƅQC5;qԠ(D\QQ@A}Ye[V[.c&9uAs[X?˗yfs<B(wӼHZ3(mRjUN3}\Q%%ޯh v{\\cߔ e+Y[nӖŃ_m>Qdc׼?J!U.crP{hbkjHtRwΙT.U4SK+wجA%8v1wdc: h$9HlKaM3+}PĂK߇`ڋ7(#Ḡ"uG)ƃoƻbzٖz'Xg*{C෨W^U/7j?U;d/nv41UhWx ~Wx? 8I fڑBQOyO-Zl;RHo^ Cަ(ů#T9y:gEYoEGׅ ;7> w&o9̙ثfӣ?؍Pwue= Zz)lev`KAtI(Q ub1if"!T{sbԯXl:N"K;sgIKv.Mg6~~^՘V߂]8i>z=OvdѢŶsW={WElﲩ' =i7.Iճgyw0WrI'2[E|E=aZ_FCN>G'n{2"\΋r͐Mh]+'r2uw{tkLq\o% zd`],ޮ'1._?(Mvu0Ǖ&ݝLVXqƎźXN/H }HKm䝯qk'Z3F-lXL׼ق}䋗aIǿS2j17Ї$ H:j_?":^TiWBD[(̦ (8Wq'L۞DUƜy=*۸s \k"Ց2w"}^x.RqOMMB*2״u.݉y/ts_2Mn^zpWľi'@n4Q~qmeqW&4u*ni50 tz\ ΀>-M#SKLƵ 47&4 MgC5'uƹ2(wsdNG YX(>:qZ,C^T'=I<>&Em"PoWKԉ`>P@*iA֏w|CiB#Qk)UZ^C-WeCnޅWPTz,^fbmd!Ӡqt^gѝl[u~!o=Є&l+ J$AϲA+ G~HFԏW^Ka2]ԚCνcϪo g ?i/k-}; y/ꖅ9<26@Klzto}J]oX^֨UCy%.]kzwYz]!sїmE js@I?'K .g/h=Є&lLmALsZ%} Aarץ&G9>'ooRwE6%eN[~#9E  T e9u85;<7"v]u;|bJZuf(AwCrd>_|H40L]p*6#Zs$fH5}yO&4 'L7˹E^]o8}@BO}^:CsT'rE]Db^b~GZɹ%7o܏Ug(ќŲ#~ɯ d&O*; op[ٿ+, -tDɪU=  zjy2y39$ 8eiw<:OI;!P5{e:⡯X: 9S˹اΈQYEsƽO_PIyO&4/sUY-.c-y'sQE8 9~7 Y O X: oSo W?zV{`vYs͡1F*JJe6W@Qp{Сq.3ST0EŸ/3q!u/{ a;{ 10ϠJ;'eM}`u~QKuGi#SwvxY6LiP ߛ}HUC2+F.^zoH:K:&<M?Gs}!!ېɈ2߆t/2->0V>ϔP./e'ObsI9R݆,<r~".MU\u}J{%OR`s>ƹXM 5쒾O0CNc G4˺dOJՉM*&b@/XV^YKfx(m-Gh彣!▐+@e_K V8.aE m^aG_>Sy{֋C917%r>r}Z;96s1]BqN㬑n}ߗ=7IƗm7K: {+4d,q_3rg9vQ?mix^iPGWI"F@IPq+R\LbHiJ+޸xD+<>DoDX!!PxC[TcL^mtԩSNWaǷR8t4̯8Jd"37M!0ү(z$=SRi^hi($=}ASGi_汛(6؀FS(Q|aΌ";ֲD1#%xN>2q^ߏ8BbJ~PMVldWS##2/CdIaDqZz}*ċkA\qtr{{B?)zs^ Wq BwGh;ԩWQo|+j8QwTryD1ڄ}܋ľ.m3Q{y@ v~rWCTE]$%05y@rvoXFTm1C>-=kWRi,K_ ?RϫQ$W s|.?KLTkՍ@eBI+5#x~;ܡBgx"pqD^ x~rƲK\24¯kBS2CLğA ܳ6?@`1޿lk\}.$׎Og=䘥{0}3Q0"? ;f#^'1wwQ WvsiC}f:x# Ǫ0Z" | N s_ykR@R _o K { wݎ暴7F|Nx6)ȍGN]z 92V; YˎX_G3P_:Ac9s /:uԩS!aѝ#c/E_^w$jzNJTyD8&׸tȟ' ΃}${kyz9(,#<˲E&7 }yGfa8Rbe81pOlQok#LPիY_+̈́˂Dq;V&)a?"vNoFXh;ԩu,)iӟ-l4F|zJ+ЗSKy>sAЮD ]&!>ˉ!$g7G@r&)#d8=f+̜9YyT%{@;'?9ղ񸏓j.zuYx> j[cs͹Yq5u,W"k<Cx)=]JSчKh,8]iyǷ^]̧9*I+ P>2r~|~Ky 5y Ay좦#έF'fFmK'㛉ش`H&e9oJE3;>X*S+ݝ:WKÈJBnHx~)˭~塾(b`<}0ʴH#(๵.ЗOA8.)3B!OpC̫xuQf om)Wy=.l~&M;~V)Xjܧȯ<Ȱ3{)>|uZhy{ڈpO!PnX>Wu'Kp,T`Xʰ'Xײ .]C>-f3ޣQ+~o;mYpΙpfu }FөSN:?oJL x^YmXgg޳ð6gEѼPƴ"[tӊmTTIk(zMD([y#vkbR=َ۾_~y}]y^<_W,F4h腪KvyzD!K'lZ!_2o-fvgYߖv5.d'dn!dnr(.2 a`@%4)d셽Ow3Ÿ^"?hW<|Ubm8'}r1I̡!ӊdDȼM[u>pL/ L!+2n[ʮ(rB7`✌hD#шF+TsұRxzY@axW=_l1~C7gy{{{o;Q0*8:B |vC{@ssE^w!}6 YIY d)vĢlI}C??&Xf{ aw+eӻ'}q,q xw8ugeBߥ9Ww\y*zgn^`:|/둃FҬ3 Tx6o캟X$ڵZs~ѓZðR/Yq ~oėV|I9|xej:g<னSR\D;Y||HmJ9?>݀sUڃuE:C~&ʁ[W T뷊~>5.PzaBjع oߣ8m }s ?š8/"OL>h!gJ 7xgRa-21Zcő3o<} c_J]K!M$/;)O>\{L<3!뗓/KtnAh+^K)!ιnWnw]n; gtߡߢx.!+A%{njhD#шF+JH⚗("GDwMb,GJlz);ϮVjP'ZP>R޽;Csxlab^zsk`fZN$K~G{1s"_~{4nfU˕gl?;Uhow}Ll5RӗX x,7^qNF{PsM<{~N T\nԁ%X)mo\u6` C|/*gGgRԝU d̒fJ?0LaY.PS]fQvy0OB#Κ .TL+g;"ֻyw!R/Gkw˰رA\l_o#x~V` z3] }z3W& lWTR]q:Z䧬M%B6~s)sݐ +8s v] @i_e"m1г&O#/S q.30ϼ@j: q6ϣzu䉅sq)}Z whG.ӕ8ыn_cuk M@,NXI@xE\j%(ܝaG!KVQs}ۧ^D^iR)Q} {PP>6r+7ʨY:&vCS F=E>@J'+-YmfA]ǽ(7NG=A^|OR/ԓ Rz+ ##>D|')__{_4gU0Ճ*Z#3!u -n(iE p?8ԫ Ǯn^F4/1]BVRUv̋OΤ{Rt4ԣQkG]CF~k9WO$ MWN:S==&=H)*}%!YoDV 7*tx*b 4ecؘpOjC>z2ut;L^Eu9.sjV:/*Wr}C 7TSjx^YWTVW>ш=6&Q@-.m5QKR{CS q)c;X`f4oӬ<1ӷ>޻˷euv;]czD{NRGdAc,k w׺ߩPΛN!O")LUowWb^YeƌmllM:dпKWzu9I)Y JLy5× žmqGVujQ < zoT eu&fb<(f7o땞{ɻ۲0vXҋF@:YJoN9g)*4_>0eiӷ{hA ZЂ*1a%oJ/ˆmCɬ pJ6 Źϲ_#8% sz~~>^.$X> rjeZKqrQ#sK1߮dƋguLV4nyܹ78 >ct/W?0tѝ@~;zbVeoE,qzU 2c > nxLj)٣dx3ø݅pw'f+4;IAc|5 |;'_ul`|O<'󭋠7=x~3 񗋡=RswdvxeBWE`߉wۥ)lv W]" h^B ;bXI98oe:Wc١jo+=ϳ*>km<)XbP8JxQ凲] kO+<2p^TNPP-Vh4 }=܆ߌ#Zx 9CA5i8 Ƹy qU\Bv[ šAkʀb4?V5yS 3阒+_+L&E'SZ盡եX~g/:j\;Tȭ֧c6x`%Kݮ ~-|vXz d~`U8/)SK );hԯxҪ^oއ|@k΢~)!YpvPr_Nr!Ww{D~9>2ֻ9FYJ6/_,XـꛫSfŸ'W(OXxTɲ/ɼba_<]8Wu%dmY&3I.Go]7l1mٞj\6zFX={!ׁh d$(KE^W[92FR7u5 mx|ѣ`"N06FC_3bcd[; Y<}R#w9xfk&@目n3DnG>,X1ٖDL]~. f(~6m_E6t%!Mb~R;g"+Z+Gk /S}ˍS#)xYac/Qڶj_S?~s/x2,%{D3vm#PoHsO¿e4^Օ[igeTG=G=)1!ZoLh5zP("_|+dNmE>,W<ԃ}+̱ iw^5b>;^G稏pr/?G>Sr_Xx;3Q~_-ga ^_QTW `^/~Z]nD⹗b\GP }Z2Ѩipoc*xl:ݣVNy:"ԉcʋ)jPg)ȃ,}՗}moE+~Tyr%ѡȠT$KG[]د(=>Sŧ1g=ԉF7ji87~@o7 uTmȱ>/ z!ⱼ}A#h~rO|' ZЂ¿TG$bx^Z{\Y?c(e\;;hdO(rBPRJ.]MHQdRu%wZ-qu1|k?fs<}S@𺃥_-w2j>K ͔k0׏b} k .\%')y~#hoq2FzYXEYO0}9ˡSl(=v`^ycȢӍO=Ωg‗<Ti=qomJfCrCGQ E[1>{sB6;^ٯP~[0_&Cv=mCR,cR|2Yw~6Q⣍{o(wR3M!Pc!/NTvZPˁUף1MɁJ6i ڍ 1U(ϫa%`75l5'ڣs3F-v:',f'^yl:RtZfu 0-@ M}KW q^nJr!sޮE*n0iOd@zGJ6B^;-hA Z@yYdVdVzY#eb_v^#"V0wno^}&̛5!h-:7yQ_#dOC ~ͪB6]Cm&Bf˪Z[6yh3@Ѯ%ƑGyb"7ykBY1t%R|.5&܋GŹ#J",~țɂ j^#O3~RȜ^F@*D'{Va\ O.|{X7Zz?/Qc^.doit'>?b [_w[7c_v*Y\dQ<zy_3mƗv>w*;. zkv."H2uBci1޷XKPDȣv'wtpk!'kc<᯴C,]!}N_xlB-ZOnKysq<"N/(Y~C]G#9Ü*xדCy\Tco@<כ ?u`NlP)X~))~1j*4R"'juo}:d ԀfeIדohؓ|Az37U}hCaX$jO{-D#:?$CӉ7 ~F'Gc KTʩC\l.ޭrsEBsj?G7'zGZD^b=lsnT'8?xBO+]0׻W9w%ph`Oyp^}XYm-vKQ0N7gz](]2 JM%k^jsd&7&wIzD{7ۚM'es+{i Pm-q(J[SsψNB^ZR~ou3܁\!ɂR/QڷZXri8tZYKwAHqƺ?w])^^xbn=A=aU>b\ om20I8A:"&Lƺ<}?ȎȬ_J x^Zk\'hƥ\f6ƭrxT(2D.m)~BB%MwQz^s$ft0$A.g^{^$I S@9b:JHMrWcL} Ժ݂8&4T9] ;U]\eM\ =x8$ b56 z!͡}.m ٘q-!Sړ=B|!oG -BV%?KOO~yQn4=źIvV \5!Wzf>s9!s}8< -ߨ8$广!EU ĭm_y~n@eUzγ p^| uU!YYo%dd YqD\M#4'w V&Pz/PV6밗0n_!K%P9PΏpy~66);C39n,pYeO^(Pr6z߬{qiI<.__t3a=1h  75 U [ }{GʡqWcɮ;ggSuo|u˞@eX}0,%5!1E{`S1/]WAV:o-P^j ]ָ vO<5Z8}b[ntOkKgGKQ(m @|(Mhߊ])_<~,9aԂcCWT*^ ª@oxSiĺ0bOa[{Nߨt\)#'np^kV#/Zz[<viSٚuu2{żw틅@< ))؈ڴ|Y)]C\h/"Є&PST\{Ry85y^By )-궝xL4:Oܛrcݼ0@j5M]?w;sA>Ԛ#pWw6r^߰į}`:<4qQ0-楿lKFݍmA>`1F}mwؑ&(G) |:4D;} u5Zd,h_ƒQYU.{Pm,yzz{hr,K+#_7z]@)V![OwK*ΠQ] ض4_:+de7`E=ݞ{m[{#ѿrwY:l%uJy|ֿ橽XL{B~>[Cq<ݧ~\?>+[.P߰Vm*ـ:Q^t$>3ڊD]TFq6t$g:"~XSW@3hޤ8P=,;\𻁞q@n藓p.y@3kM/d -U`FVe Tn?ĝ5|i8ߚgS@)sRF,5GQofݭ'Z,ZP:jߟ5I=f?eX+q1ɝry7.7no)Rd0Sfέ;Ee1kafk"(^~zrQ-wKN#G>R_ קǽ&NF p+w۶w䮨ꩡXy#s^DG{)&E^R${6G ! }{Y;>Є&4ᧆrԇQLWBǹ^~gcC&W'}~ݩK;~G{DOX5/^oNY6cNcg~fH˚4 5C_ⅅ8ex(/xz!hJe9u(yk3nr3` R`!H; }J=HjT9P|n6<cUL z Jxc-jlD<vH_e.DzM2+T;I!YT8{Hlgz l6𻟛Aeww[wA~1;O`>߬D6t#a__5diy8_=O;6_'D=jvA^9(xRϮRϒO3-dc^ qX/in1*ӡ/9`5_3ɮZ?0e;G_#445 F:yr@ٙs./YBz9r3]~a>iDu\)*e=Aȵư,zVWC5_p<߿4m9~HK<xO4hF94wI/˯1דQ=zbGQ2g)Lu'Tf"h<0IF^I9+=M〼ŊľmqCfa_Tb\vH>{.;KZdjJq{,ۍ$8{96yWt ~g~} Jb҉ZqyG;~OvAv@ޔV#^Bt3&?%9m+` R4~f\*\7"m"O_]y,C |RU4ч%{\_;Oy܋rI9Ϗ|_ \{N,G=x_֐juҽ1m8JQBs8oqi:dK8iŹ8'-gĹ(xQj[?Z,;G"ncK n+~>Ij`x+  \JJeDR16,P*ںT޵z˥L\tm'Qb@>T ޛ#D?zQ8ZH,r⯵BݪQ.X!~So}&v.rQj&j'1~[ 1N<<^Lu?1 u51hРA 4hDer }A|a8KQ3PdW?1ow~W|W6_!NFt'g8~} ָT5!$e#PϨ5ϡeק, bk1nj%~,9JEb~\`-i_$?da5&Ѣobt C/|S?#&$nB^Ix;+ٹި? oqq=?K  deflate [`TREEc TREE@3STREEHEAPX sparamsscoresnames8SNODh5P8@;[TREE(HEAPX0{groupbyreferencemethoduse_raw(SNOD4;[#SNOD c(x^K*ɎILJ)x^+J-.Ux^O/JMx^cx^ݚ1r0 Ey섲KI$%L_I@_؃OZDZ4S Zsi"Z i]dת=BYeh"1㖵na. 4S'0>_559€%58wQˏLztgMWܽ,޼~Foh"[B0{Ku6_ʘd>_.ΉS?rwG#B#SRomBXS3dlJڵ11_;d-s*j,.6 0aIϩRco =E]*mY:x֘Fz}pT:3@!T9, t$NtB0be`s a>x^ݚAr E}NҴK,0A69@ 4 t-nP@*&y|~hp/pf2K HcSiXn >7J2!% 1]AG=k.V`ZP?&"-u~((tIC \ViΫ51dZV擴xԭv(xvz~[ީlyLLd`x^ݛ @dt@T:aS?d( \:Y.BK~_&.3Z gV98Ut`: (<L+{i9gd}w39ƣp'|R ?r,PVR!(~zkʯi/D34Yrp+(s'+W"IqL݁[yqVi54v %6aNhN";@K=wAR zh mU5hgǽ4(*8~(w'%@8XkY6-Kd߬Ϣa;7!9p)_mw n o9V-A% f5$aċ{ۭSԑwwRODl*Mk5,JE?0hS a^x^ݚMr ŹPR6$]v7x[EA5X*mjqw/0:zmU<_ G^}!#!n(hEVI⼵$_#)-ғ$lǿء8 ՚שrɢ ѳ;=GMsHcq\!%{oaWYU%_OzqW=hT[ m )hJo7L9%YAz/EuTa*ZyPQ3_l\,AǾk["?d!~8FpVnMyR`3">>ڋ,ރ' Դ$ۏɗCC b^\H-1}X8̎mdɿ" %{WuflǭF,?Z# Vn:. ØF(gx^bX x^+M,Qx^cdx^c`x^ I @1k \aC/<1omv8*lIizF>^x^s7dp7 f`x^3`0d0b0f0a0e0c0g`d04o deflates[`TREE v deflate [`TREE(FALSETRUE deflate[@TREE `dd (CD4+/CD25 T Reg  CD4+/CD45RA+/CD25- Naive T  CD4+/CD45RO+ Memory  CD8+ Cytotoxic T   CD8+/CD45RA+ Naive Cytotoxic  CD14+ Monocyte  CD19+ B  CD34+  CD56+ NK   Dendritic$   deflated([TREE &d(x^ w\ ť!4$ \8'nYrd"+Q4DEnI"25$"M"z)9=˲X7;|B/Gv ھW}߽'2." /R`-rJGz[>e,i\7k+DײE,;~ܩeSBR鞦΅ԩm' iwq}(qt9 0!0gd8r13iMi,qYʃRKj\H;iW,Mz4̝M`џw;b>?=Q<;9Wq^nYy .}eĭkBoHU‚^[zU Uo?2Ζ{P8 miKIMm<[qW-x_9ҁcw7*[0ڃy_P66YcA3vFj%ߴu<AϞl~ u=䄿9r ?7Q̧;4kFkU,%W[Q6DrilשFw%|fmJQW?ck:4'?ĆԴ0䷘Z©B.R*_2Qii )”l~ϞEU lE<@CR!fW9D}35g_ȻzJ(|ʮ{3xџpțI gim'Q|VwTf_dm̲%U 8/[V)/:{9z> 3# [G޿87rOZ@_ }ZstmDTx<˅BNc+0fLb<\[apZ{mP8iӦPwiáj}9$j2?ų\9ծ:qn *~d~3[#;[F&xJV2_I2ubsM֘T@{@=:{1O,G%|˚>uvIUɗ\nb^7).tp1897_ͦlNőWq(o'Pr,_Ϝ(>p:vN]&bweR2Ks6߅ 53n&7U+x$"CH&~daϛ,; ߤIAt> >e  /Wa~%\{+0z gSc.an/a[IYsǩٯalt^.ΐ " J3IQRlWʆzAc_4[;;\3!xׇin#= A1u*=a[1)0 Qc#r2 rfQ͗ *XU0>J0q8tLwEyNea-ct|Չ[4 pȲaX6 gT3V0_aHYCYTs _Za+FaC'–MgYЃa9!rrp lpџ%ețŋEh. e$F?P~:wK-̌`6!Մ]5@ENNç~[L8.=-}'aӈ~B](S6ךܳ'8Eup1apO qSgAh;=.uhOΪH3ޏ,dx XQ¬FJl\ SqD m7XWOЉۙ\1{Vwc9=Bp vpVL=b{;egץ2kk^BǠ#ϊx$&><1xYzXxbJ"tLqMM'^}x.RJ/ x= 2аlDR"ddX CD4+/CD25 T RegCD4+/CD45RA+/CD25- Naive TCD4+/CD45RO+ MemoryCD8+ Cytotoxic TXCD8+/CD45RA+ Naive Cytotoxic CD14+ MonocyteCD19+ BCD34+xCD56+ NK@Dendritic deflate7[TREEZ?#A*cC1/E8F?HF{JM4LTM[ObrQix^ݚAr0 Eu!τ'KHTƽ@Sp;  >v"sӳr)w9YG+:$k ){Cu{C8/VTIu': @sY>Kc+:$/C{c.ي+c7MHwzEȫoS}Y4CU.oŗ Mf0ʈ/X϶R7x@^ Υ {CCas \[~JǨs!_[fx^ݚKr0 Du!DXmQ :٦*_Dd߲w(珱CX#"If+:d"噭zG&%QwO?_A^){p~^olORTH2n~0fO1@>ֆUwWUH}HM#Mj3@{ rY~]4Zq?0'g`3]Q2o^C8Jyo1a㙤>J՟ cBsޥrJ!0sm[ri4W)*dz[,]1GqqtZT+T|Gk,U,7'ZJ/-QqX=$褦?/yGݤ7)h\}8 ٕfx^ݚMn FsY~\@zE+[ecdu~ٚ U:D+,5R̇yFjSbK!CJ*-uNg$U 8kf7!sw)f(394e5Q*[kk<ѽJQdޤd~0Iq8V [Rqm!d$^:D:EWdRjn-ۭj[!. Quwwz5pu=ţ,$y]xKU _TPaΑ~aqI,"t\s-8y *:o*ob߱K]dڣE~ -3ij3WnuƱUˏ#V߭ܖR(cx^ݚ10 EyH@R⊢f&/"g;@C>?;i%MkYK-ⳖYYh~ԢAhmV9PU{.p!É@{E~]^;@wuNb?gYK&!E)q@t|xhGs2oD]OKZ4fax^ݚ; EЫ2ey#'  tf @+4K Q3䐿Lj2_#B&d-w=x_WWhᰏcOR=4L7ᠵW*|ꫦ/uəs}6 ەJ>HHZ?#ABdG +Ƒ[]@e]@u9K@͔x[C"G닄n!PMv^):+ʞz$|hEhP͆se ~edB~oYJI5"T I_6rr$`a}ޣ9E;oAB|-!_5Dp{B ]}XCT'PO0+w\%o_Kw% u_T'?gx^ݚM0 FuǓvPF I9@;ހ[@Swg>ڃ2M[{T!Ǵ?Ii_CBN|4kY6(9Yd;F?`r炬ap'*7,ymN\=ew~x?K5R"(}ԫ hib"xp`߀g- gimiוRώۿpW) NZ!IK9v,R ).sB8y;@WqsBbslgxEƳjeW3;Ƽ>SDHG*E>0W ruQ;H/0zk۟_T?s9p_$ "O,9@ ~ag,rcj8_x^ݚ;n0 u;HS~% ] CoM6YSnC"7 w*!2ܹX4mXC;DuOuVD6AdIˌ{j:7Ĝ(0rQ;D,ģ1^Ń5DH6рWDG5w+K AK@ GŒ{] x VLhIY6U"iaaP\H-y 3Xźj9ψrJZAl! ϺU+d`8Ʀ뵆}c Uণ~)Ok鶗+jG gg70 ~vQ&U+HD v7|^#e &ڇ˥^% tn# Ssij, XVqC"`ex^ݚA EPRm,14+6U"YQBHb`qQ!rd+A@Lv?=w]e̯P쫺mZA҇f-~01$HeߢV+Cc692/D6k˹{6/bu14-y 2D_!⢋2ܴ7bpkxVħ07}a,j=1if+Ž ?wx%?&Z' ڋcȏϹhÏImb4l{2; %}a!^gH-eFx KszT$'% V7q Ȓڞ܈K@mYBn%~2C)abBIy' nwrHc \Go\L檸7Zs Gت<7y߼Rpu$3ջw;D'M{^a]x',aOmsϏo\bN\?e\Bb' ^$qUB*V|"Σ닄Všw.S>D`22   deflate/S2[PTREEg_2[]TREE^HEAPX(]variancevariance_ratio0SNODRggx^7lAuA-jA!9AǹAm1@|ӊ@עL@#J@-@:@)@ޏ"@@H@G@]g@# @Ǟ@t@@@?9????.??s?9E? ?_??1&?x]x^7= = >==<7<* 9_5c<#QOnĈqHDJ>&s+a 3Yj63;}ORґ<5h@ Ӎ e,Әrֳ W=E*2" uiB:ӋAdYC"7x B?$#=)@ *P01`;9qr<-hR< UGS҅ fֲ=C^G$ 9AAJRZ4A3,d%ANpkP$& YK1R4]F3,eNs[<a韄 #9)D)*Qƴ=&0Eb? qXqOҒ||B9T汌>rǼ&F<|L. )|W|@g"lg~w.p<&>IIG6SԠ-hO72Lc>Ys3\OxCHE*2" uiB:ӋAdYC"7x BHICZҁcV8g]'d&E)CUєt7d氄le9%n򐗄&? HNrPThEGzПg YFvqx;"ObҐ,ըO3ѕ> a4SRֱ4#^DmӚNd#Lyx^‰0 a@kB\!#p=Rf4|sEKJBCdx^]K 0=j"ԙ]QdaJ(MhԘo?nzsUBtwZ2kd/žd E@3)#yͼM7* &x[R9" Na-f>`;LN~rO܀MB;EV *rZPp&mvZO"=G{8+K{'_įZ1gx8޷~c3GFn'n,4m ݄sWnyUc#X/@قz򼲢ML]W&C$/CYR'55T4n9h*EB=P쬕%Ϙ~d[ëG_o?:#CȟXo1| /΃?곏wO8ܜNߙ6Ȋcy6oUSk6)(_n"7 38_i?n=v_}㴋E}M97_cFUKF/Lg,#lPfU$uiO-V:q$ʥ9_BOHAum/z?Zد)s>S?.O<9ߩŽd]M *ՙY/lF^U-q{ﳛU+[n' UN]&﭂0B;ǿuuu+ ^ͼܵ u8yS>A>g=uÊ|W 5 >-_X_OzY̑i`|λk:|{Owe],h~̾zx8΂O%>J { `<κs~]62/W>זE5g}=GKLo}*R{{_:K͗z4b K;8xa>/~+fW xF Z]*v9q2*}z"/ƃ} <0"} wE|K"gv@&|ђgF[T<1$;NFϿDJ^7o;geS1OĿ_.O^Mh^ nk]=z檮Ŝק;1wNNԫ'GHxOocf*G7g%Ng#/[D]"k"3NߥΣ/΁/C38<my6%~« -א^bwk_oGiP׵ rߌ'5"Td=# ɟG/3@*>>dF/r>5CGoEVC\o4ؤosFb: uߵ)GijLon] D~mB]乑L}s/ysvb:s(wk&F-;;y}}@sfELGXpCs[,<**RШ8{yp ,sO'>I63ǥb~l=ϵ^_wSݸ?\O} LO7ՐAUAέ/߈+)} ޾tzk,b>|n0__ƽ+eWN_mg~Y\ȽٗwޓJ9}ͻs[@Ν:ځ>}yl o*;Ux}sn8n/v>3/x̤YIxyB䨎QMioQzS3O|plνS @^3г;{QiS?F/uYzo2:W.wklyW7=rPr>b~(N 8QB{ b眷Cסȣ_rOdw+x^UX{\g&!VD#ik#k^-rk%E X9=[+gÆ0 =&)zsu?}_:ܷ$IbXy/!IZیbKX`j'3ʹ53t7 jf!'1G=FuUo`X(?-¸R48={7Zb?f)N_Tovf~ΌF @m+U'v-`'5|"Ji +-AFC#MjCa_{Г⹓}\z}03q1 P-HLu=5̨3ˁjwψʎ_>@P3oaRukց#I@7Crށ79M֑r;n+3&7曎l[AD19y9ldyC3mG̫Y' O-eula|&ރԳ3#=0<=q{P|;R:mO"`=#29ؒ~sy~@)!= ai׸T: so\#c]p3=ga1\jFux?:,D'ʲ0S>x͗7e?G_̧v?'F<ګ*ueTXʺ|490Fd~8XA')֌ߒylCMMδ "kQ/ȓٗE~P/*K^Sϣ؇M~\OMkߌb*㲲9̸qqt߁w.m3_rQ#ϕhq!gD2< Kuʦ@e@PoLT`Cka7c7udbFTYW{V7qݠ"oD.Ӹ=ձ@%qݰc4_[o<oMŹϑĠmyz̓jޗar خ/@6L k_@V`1& ^ |򹏸Z5""}G g^^@^(Q~\(ǹ XwWBVn2&a_t()?uw9o5J@ַz7Tֿ?S?}Nqm_.췍}<_ cL >-,oXonU1_J`"cKn y*[eiù?KRSr~WOԱ .]Jc3~NҏnKo^ \S|C~ ك}etoeKO`sḋ=iw_Fwv*#G3 RPbgҮq&ӛeG+^}5c7}+O2δ|0z"\$l#4)Eċ9|> H4^9@y?]C\Yq໴RC|K廚z*q' 7mIDr~wQYRC/E‡^jZ`~-6i<y^ VSd'j ٿ$b_v}7&ۣ~>Q닊x^UXyXOi>C!TS8} ⤱DQ%4eIc sfZ3cᒥZ8)Sc:]}=ϻ}Y#xp$lizj5䖃W\Z& :ԜY`|߂: [0Pz I'߰p( , \bg8PzqiNdKS45PNF>=rn,difs ~ ̊¸Z$s 8~t-ΩS +d`-1}3` ȊCBwȃRz( igELʶ &tivؙFMNٓ{?S[^iYQĪBI6ӎouF"g܇,x Eِa}WP}:^G7K/} Y؟#.>OxPx}_ar' u^Z? 6|/$M^\}ő 줴< Vȝn e4P Z8)J)ߜyjAxZ\8_ާ.~hz?{QL=7*R_@JR?K%/ßr^o~ӂ7Bj'yg7 kȋ| ~J/8j ) _'=U@}c:˵o+. ?j Rbp>k|Wp<ݗjŕ|yF9r:DɈ~sƁM[Y!UqM-ð͐ȫz# O1ۏFe ᄁ$d~eU̫bv¸֒T~7co7)ߝsYធ0ާh"yʴ dmesgpW{7&_)r;n<8?C QKq/+n }zɵ|+C0._^hzm\dpJ &6iA,6ށ\ߍzWPKg~Z`TNC/?Ogc>^ +)#3O_@}ٝ㥃!ˏ2E^%ߗ,$8/<7oσoϩ)̟W 6}@ƗbX痆.~lpLbӦ!/zC} _Zpz8kE\̎~g]9ޭ@&Dž8y3F_mmSyX:>@9m4hRHˣLvĒ@+\ ډSܘg~rnC:1&gzc9;:h8IG7zJ)w CXi=mOL}~'g<]s|W>EnvgC09׹~&!~Ή)3CFʜ}>ҫۜq"Ѻ,=?Etv)ޟxIzz-V?0?y>! )7gM1>9PܖW3鷜5WRsE|SD ;p ˉL+@K9>m^yswUB0.=|WJ+(7p]Uk)8E>ik==2<|ǾWw}@ezwQp/'K/Ǜ̳aKSWw$,;eI\_w 9L^\ZyC+ +e>Կ`}NǷsGM^-~qʂ$P rA"^:4ݷ?tbqu4VO8/[a؟@wXY7+xy|?kǛ?GQ7 ʑ9wtxyBrĹdm? (4[,PKiL'-ǧY -t1P%ΣFߛ[~zwKO{f {14j.66hVz h'LoɃ}>VYúìW`/en0ƵΩ'2OHO ry y.مj_qa ŔöB<{ОkևihRUk~e_z%| ߥ8Bf2=kz/-UAوڞ>.F,zQ㭯QXq]F_jA0O>dynHK Y! (Wp^v pb'Pr7ӈsBwne@U(IA@Uw`Yb\ۚ}ܵ#q?w̌,-Q_A+RC:lcmDh rRK/,goíhn\ {굻J^}O; ,t?eqW R(lDmo!Y{mm9xҜj ݔ'oQ?]s)kcS:o䂝F\@\9s >w oDeQ1ƅ(iQ^xw6ϵ+2^O]?;aRu=u? >F,[oc3n K]v¼ɈK-Jļǃ׻rXܱb >/ WxQL~D|Wl]5做GHE; A`~Z BG P8Tݒ0.x,?(J^rXck*fw;#607mAݒZQ9~"pnϏ_n w9wyާ3ѩhr#y1(凧䓘8ĕt=Q}PkՁԺK E*Ei54w]O蛈/Ѷ#d-{(0qd|zy꧓?f|9ݠ̟C9UzDžWΙƃ66oƙK#;4ƩSȊF~o\/`$"7Θ{?N2os&tXGEkN.8Ì( Ǽ 0O)զ".rݫsOD !P)=TKv Y Nyԓ\_ Xt́IdA/:uT"y VOֻ3G2~7*>{+0qn{T<Gƃ[:`U5jry9<O[Կ.O^0YtbOjܝ!{%@V#?,oaVO~3K:^*+,N϶%N̫řXgco.Wt/ۍϋ 8hky=Oa rj8Qb ne~Hy);`=y8(,(?,ÿ^RAIxQ;!˿U9N~|-&ֳ%U:~kOINI6߮uqusIJg2/wc"gE3}ƾ,g7Ogo31.伺<w6B˖$i̧>gU doaB(c֍o,N{O:N~ *bߢsSٷS1&s|d#j պKJƃw bh H[fN}wyKө됕6?Go3w{te7~~7O{>٬&^I$Ư'޼LȺjɃ}I%"V8uѳ⚣䱰^@e8ֱ9w; \fvs(Y۽\ɺ>=6X豰:kewO/ɢđ|/K?'7i43ˎ̏C>8Mf)wYZ>\v%0E^8h e;ώľ%S?Âqdur4Kv:K%wƐ䡨<{B;n¸Z>gZKi_xgfk} v}b:{>f}ɓ ߺO?̿imm5S{#ʕbxyߏyb8ިY_^Tn.+ ?'J(uf<}H*g,K%ܝw^mkc.~p=/'1-ҏMY3>Bs̷[!<VmJowZƫ1O:üz:$QWm8.;=ϓlU<ڃUyAt]||+c$~|+c\.u  `\OcUM}JK(f{v^1^c6#s疦ͤ :n,o@{ݔE&>y|vUطiq~1fZҎ)UҜК\HތH= l2OQWN*>e7i&{= / E_&K;G)oɓB;>Ӹ3} +siExĻ{i߶v &/KN2_Cn!BC4%zE8H&oQ.4}Q` lc~ ~ٜpk =x?oJ'sou<(aX?:)m P ;пI~3t`_|:ﮊa|X[@?cӦ⵵b[X/7q1wc5tQ^A;?8yY]q- 㮧-A k߱Wc^nd~t~+0o 30OkX.m_%_;Jkx^UX{\Ow?(RJ5R2JqljQҍNL1*s{5tKFjĄ'\J,*5rYy^;~}.}.#VZAPR'P 9;ZKP]Aw5<;!d.EAm:(]n9k~,cK>&Wj'W_ZCڱ+O₩ &_mLy@!f#j#p3f|."]1'7a}B9Sf|}  k;Ph ,߳mrg'n>nOO.bf( YI@%x3~#؟g[xolI']~֞~a*{6 (%ȏzn9?ִ< ?qMr(ⲐXpy?$rQdQOr03]ރ=lk ~ Tg#M+ȉAs5oF8ϡN݉^NXEVmt9^ 7|#~d"u]d%zQ2]!qq]!+ Sހ@- 8ש@:g~C-q>rC+m~`gzK ?Lzcdu1d5ߢI릕?.g<=qucmyv$dx!XO:|0/ӝW]Bl7KE"?)1o~dg.[C_*?eJ!'Yϻཚ~YX<w6u\従7P;֙ɡղd#an)kdeC _l ;QG'݀#&B/v7r,:9 淑ZmYE=]s_,˛Pp8G'A c+C]!wuwfMaZQ/njy{Y3o1?EdҀĕ.y0UM+F@3*zpBtP}-pʛKY7h98(%C]yELN{t"oz9 :@)zĎ 'r>Q˪P|I(Woc Nie^#X?aEwgL?bZ,?&s$u+8GWzv]9Yw+ȃ:>;ύF!CXjR_K4z!qdh+< ꧿,|3ڙz{Ok1(Mq'q3S6֜|LjsJ7n'uz2_*9gXq+{t m!q+ȚJ|Wsvzu#u{&<%mm^e0'uAZR1OӜ+$d1wKPfzc-~U~ޟ /zP~4r\Σ~9oܕk)x04Fa3GV`]׻'G{^nuՅ)]xާ+țhSƒ_ҟ mnxܸYo~Sb EW TzWQ6{NoG\+WX'Fy9љwXvx"GD6eVb}WYyN-O|3Ń<]G;M_yMyHJ}6)}tt^%a>3~|ݝɌw 6wx*<+ƍgΥߥGGQniL}Y0ޣ|z 1xtӮI;/&J]?Am+MQKN/-IEF~|1K5x}k΅xdN /or_q*mI{p(-zLS3asXrR/|ax53xRnva~CA5֧E]y '^㜪eaNzǰ'ҟ˗J3̗73 @/sB!}I}OA Wz|ʺr h-(eڙbw"]8?J5 j2}Ki༒{ҙLY=Š2zʾ:1a"c?8/f"pakw 7}JɏA>^l@muπ-poV_l!v YT;?]ML_DN%n]y=fm9hM@@(úf۾(*y~UsׯWȞ̳C9/}/,9/Wu8Ue=3 ߛ{c,b%Ma_oq`8:<އt>iϽ~z`@\gsC֤zٕ,}Q#f:Zf4Pn2om/Է?'3Sm-_N  ]9qr^mA53N̾#WcN\;6?BSr*N?,\ی=Duɑq6~h3X O=1_vG7;Gqulm=:-">s`/Wu?!ac˳9~]]~@[Ko&Oi6)OT|Ӂvvh/l'2?[@i77}c)j6x1lݞ̃^T}obu( 9$\RW൵ء\O98yHg͈ǁm567K9g;z^;zj[߇xI"u뭞} 7xJZWsGl8->jI\iGC~Rj^OI{Ociѹ$|+Zc@3;Oe۹NivTț3E~2qn?dzԁFw2E]Pm/t vy(utJS/籿b|ӡ}C,7/ْ_2u 7j໛tԂd\wz_Yb EgKYA] ;~5ĒZ*HPO Vy~91]=_ZY^WWx,v\YCzx<"]鲿1' ֛ut4qn6~⧮-ˁX){MxEޜMOyvƸL=uUs晣g0,rԖ? OH?StC S_r{7<7{Wv>$)9Ng.4nG? w+1dyaRRo[$&ԋ ! i %ʝE޻ >Qrg I䇙J6R֧oPy?.tw?=_xLU6,jue"+;=[QIS|EF& ˽4K}}ig^Q%5+8Po)綌{)@ =.Q[;OP#WDm_6K7Y^hg: W"KU -%b'39{}^w$#P?霣V&zox. zy_?s'qFϥ?s?K3RONPbѼDoI?SZHqms &yt|@si{W0_l%z(3Wϣo?<}8)KDP[y{$G6#C~{Cgc2nu=j@=xPx[K6āƸzP*yғMUZu|FLOxc꫚z1w" vԳ ʞ֏{V !ϣ.?L^xޡw8uFYu2n?wqQ?Cv܃e:sJWn_kčM /Vl̼#"৓*/&O1m)2R}/ջcasX<(~D|D7|VVzͦȟ9Yº|øބ<}_Av޿.Kj,#ncyl Թ-E[U37Ƀ*|?8C^G>|h9q#՜ gӽě`~$z>'RnOx_kGrcsoi=qi g?<ļk2,0f򝻅취1{uF#xm UOYO/Ήdx^=X{\g(9uD"=JbeQ<$c[s.{hN/&SK,r{]t#'?/M?,j=}|<iJ/`)i2호v}FS޺ {:s ۿ]*1(:ʰuVYPOǸh} (yL${[p=){Iy;~?:% 9>$(ff2.znʞcISq#Ƹ6`O5>b8W_ , OK;_ـG_w/u 3k<ƅ 8^f7ډ0w 2fw}nMٯWaPtRgf 컀q7(x,h@(vWφ1Z0:!Oḑ7|[vOfޅ:>JG s6vn,U4f@aB[/Ga?̼pJ~h۴u.Y׋[|ʹ_p_eNY*sKVp#)ƽO.ZJD1^~&CN#NݵS#gK'Ә;{;ry̛bJ!H3+Bj#_u iE,s}/~,GIɓ'Xlh&P|"vR+ۉ7^K6C.q21.*Bk@Ǿӝ+Lji7X޷ dSxOaA?\ܔ#ps[&MJ@c'? tĊ݂먗*~9UڐG2EsQ/䗙?0Y:3YWI +!'ּ SW ޣ#Xw@6 yW&`^\W83P|1SW>!@\s/i+5u$o=B޷y''a1 w(cIP`)dN Z羁"㦮 }g,|_zfX8]un3쫚a"<~r׵<+m=e[#Д|;Ac?8}_j'\ TG׈UY,ҁlCd#PqTiN)7O2+}Ҩ̃%r~w Sc^RXj@u~~JنqaRx;uYj$ #JAýiwXgnqKJjp|LЗ S;1)n&*p[[QǤG5O[+kzc<T`TFV;ZTۥ3mo[u w$Xֹ̻DA)í<6OwhƟ^u@5g%%~\}B Ի'̷+V~?Xޅoy͸KFǣ'򕽌J.jO9oYaHY+V~B;' Nr#ҍ2fw4u&\ ʑu2ٕK?{\_{y#o c c+>jt𥼢qJw"=n'__?J>7r1gN5bZ~Qϸsc?ּs؛( yQ'Ywa7[yT'T S\YqL;gz֯6o o)O7cZco5ayfi,#AfJgWGiN> R^cXߧNWa?}a֕3ߓf~-"l{{yϣ5a-Sffi8MF9fmKj?Of=g8I95ƕRb'<׹{xh佦g{v.P!\/uÎ(Aw]iuiUd#nzuQd΋ٔbωvSc(d Yd?HЧ 8~!(o~NEOgƻk6έEW񝰤e|B SKBwWh;*?7 [ɗ6gSly)xZu/~w0yE~.e*0N<<3odQ~hOɕ7&xn(u~{6x^}X{XO?D\RT:%QD:Ru2EY,u 3:L)MQ)x69Wy~/{AK hbRMv7 TB9(<5G`]K/ClG,3@%=Ei] o, C@ZtO̊w0PMY Y >zPnI]13VvX/sm(',/~amzly} Gad1x?>JlN>4~SA{ KI@Ѫx?>|?%wJY(+}LٯR;pJJ}yeA<+SGu]''[;6zN߆>Fmouu&=R_lڸ6P:?b-WؿimԻu7Phb}`w?@a֌ N ,gO}RW Wns6Pj !ڟp8E[{~^ԍ݃85PK&b1qlg%=~q(7فz7V9cyӟ!Iu&̫FySSsϼGP¼ܚڏ({O!oQDG^ #X`I#7|D gy}@ufhčX9M=VnvJu99[_ 9ϿrϓwYsB vj=:^*\uytO?Yӏn _¬;Y˳yϨπ} vR])S4uԏxIZ<ӂpsȪק#?F2_!Xs.vM7qF=Oof.#qi;ڳ;_->Z)opud蕰| k)/V.Ϸ9(@p(t(Uzކ_@ĸ~C?Go{eR+o#^)՚v@/V 6#?̃{&;"`X{@!/Lg|C+?~B3 aRgxiK߼W?/K*sM|HT]<.19׍lWe3Γpn9]0q7e&TrS_;ԍsV}so9O c\VE]JWBpĎĺέbZs{́, vZJތ Y Hm;A EĈΜ3J-1,k^G?g}RSGQu|_:GxC-y3C d}v,aOɳZg}%2W?'1mY_}?UmSڅL̼Ë[g#>䜑hM~*x߇}5=q_T8fvfLP['QoU$0o{v =irkԋr&s83npWnr_ΐ*]!>re)F>ߓ7fD w[/I\@b 5a w_q>T3OԒnԲO1PߜGVx4ѵGQV8gF^Msr8i&Ǻ_:?7~zQzJeYQ %yZwMOL ֕cQzzU7đ u6Ps-&vܵV=菫zgW㜇4{a|ԍxN5c8gO9pUy_;t|Ι?;}~B J{reWrS5eEJGM# Oqy%d9 sHyūVsfAn89%x>vkCt ɷְWeM`^?kw! _҈ƉO~ú}07scrN:o.:. ?\EWsZ"ep#v7}_S6r'Q1N41sO! m5QsHOb?.}<8żx܊~qELs}}8igO|Q ap Q_9d}gC=yw4AIQ1H/^;s {[g-UE rg[|  deflate'[XTREEq  ''$^ N .< u9ux^uyWU> ʰ @!R9Y`"h!r E )3K-J-SA]4ˉq߽ߵg= N~%IV WZ?#S {k>=J.#-#}} ӄ{Z$ (DZ%&,n2a,Dx]_!<+tac^jmp0G$|No;6 ip]pNn-8&U">;TyC8OUI-,ovN OV/0*w^>Eo;hHa^"]yc  -7zw+KjE!,)%5Zkg ;yGmd8rra!6 >D|ovęr6EٷģVjte;HJ:'{daGmO-z'^9Xx-$?  =|g[ԗ{̒^%bH.: X W>p"t8|H_Qs8Wlqh \G;1FuLn)OL<~SWkӵga/ xc/xp%xc07}%+1d.V"F~?P ]K[0[Ͽ=vwtE[hf/q7^jtל_aA89O X ~̙v]œ_9\3 T]xzV-,z}?'xܼtp~ v{{.n+q_G{ ]V"ץZٞj}0[X$!\=s?pX[ż8"<\̋l[΄7=cǩ~>zyC-}d;UU3 Uٙ||&K=8gSp9Cg2ۗPhMr=<5M1d1a[pfjL5BŢ#["g]Ō~5|ƢVsfWyߥܵ8?OmHy3p7.?NW >WX)]ل֦ԉ˻ {w-ř?塵gyɶżl!v! W;{ɇ,zSrHNVς[=́?{z7}aL@a<=ZK7N$=uwb? ?qEyI^ΞA3oz uH nG޾'pSp8"NO)V.%ǷXmB7荼Ǣ!9Knm GCb2='t|O)֧ %^{Ez,@(=fʽ3SQ7cw5w=f7 ./MuD4Z?{$ht-qdq'$|Ÿ]=݀ښ*l;sg{UHS o eQX*\{z~E =f4LKrOߋ,tD~C6?k-^O>'xk |HߠZwl7 gOƘ.:8Nn:1 {lw8xwD~#/r^g9fo/ mϢou/xzsxrEMęg{ę"|B'ə͜YVL~InY[jI+ |=3К[<ķ|~ЗĆNAd|87\"<{_"܇)EẶͲߌM~Gi(rG4/64vj4ڕg{|y\8p6uLs>֣Z~}.{wyQnXb^x^e АUϽ  ." j8b!& nl.h#)fYfH[k9p3wss[ٶzUadkalS5*aP%?K˄iZ3UJ&b $@X+<+v` AY]gqu io  _ pH';H++\+EF^$ j{֒C.,e\rc#y V  >q3 =$> ag 󅇅YknÇ_ۓ“sb0QF{L4_o  5~gdqb^nc^s"MnNu)6F _qq0N‰c0\x69B'zF 撟%&Ah!#τ%s[~/l!A\>UGXY[O|܆ywur^ZN`oa0S5?5}[ g  +r'~<^܉::yN зEgt%ֈ"sNFK윀mVߛ3ñ 0LQ%;Z8%g;9v ؇"[݉WJ;a ^>K~Sv? 4;imL\)@㤇0>5y,sk6|Z$ɮ20;w̥STrtr܉w0$o__cӅ)}gg.sH^L>㽲s6zЕYX{߳8kyAy'o2QeәmI> >Nٴi)M ?z?z%?/f=}2S#=3.&ezדezO_צKV&`̯QNR?1H^;òs[= y©yyx S5[7╳X܋'FgyE]a"_}R=wH>78ҧ[vPӆ^~#_KGcGrw֌H#xG޼7&w Cw^w-"fo܁;ɟ+0yG=Z [j >Eb@O]{T(WpYX+>{ޜ;<_0ͥO`Vp/J,1C]g>xL$Uܝ6؀ZɻEPc|X9-Z!FWw~;O Ԕ~ؐX#b~- # wFF f r( _%O_Яϥ_%OWUI>GoNO}0*3.ƻD|n NwO~g6ȬqF?:>FhnuPQc녕v{ChUBk/ 5£³B{͍̾s a0_X=g?apEsWK4HXn~W=f gQ8As&aO 4|ݫ}m BgNMBo,e_Y9WwczQhptNϜsuX.VT5q}Hx&\?f~ ξ@s 4nB诹'5Q-{nԞ?18 Wo?+;9S=W#=Üs=y<oLw6{jyzpCk}onzkͷ4vrP8 yT3 {zlK~hEac5> lIۉ}f9ƺc'# {Aؔ\c&X3e {wKR)Сz<3g(ՙVg_3%yޓ둧Ăui8|:T4wݨSY[Fr^ `m5\2ׅ 4gv}ߜ\y9>Qۄ hwGp>UksmY˞SO92T8+;w\!z7hus]G'#apvE[)~\7i]Ԍk9{ސ~pK7r"k\sByVrCsCocp}O=Swe?"haC}K¿56E.\&4?)tXdahZrz3'{wx7py.h:(34 {>"1j&kmvP.+4NSr Ywx9.6iO/{e^5"Ԟzi~{[5yG{ywH͈=q38.G"(YS_w&qG1=e1\è1T#sV/ Z*gGBy`:x&>/!'<ׅ=5~pL86z#yn5./>I^*F%:y.;yLΣ- .!]oͩ~߃G}>e>ٶ+QCc&Pтi~ыQc%ZNLpWxִL44af.:vp<&n,<w_%_\{^Rȼ_/rYGNx1VQG#1%G99֔{$?_JɹF%"{boYbMߢqGgZr>xν9{ mho왱=hƥs>=J,ϸ r>-'rSVg"S+ф ֡!.jS/=Gn3|~kdcL͝S+>B5sx0-o\8gpbJ#\{nUu9rNU[wMv߇)&GShV ŏaxgOg~dm {bic{$bO;يn~Wy~j~ |.7;bORO .2=GTb;/J{/ןZ0_~F8sIG @?ͳBk/ zyX0 g|Cb9W9 |K.'?%o^w)hr!| 9 3 i}^"[f~c4żЋ'$aWfͼ`Qi%\B9 ;>9Wȶ2;F}Hw3 ̟'x6; Gp!YɽC 6Yk̚M?*gn3Qbck ^9:o,씜\59{nPF1jZ?V5Iu Eޝjࠝ3I֟*?IŞ^r=>Ӽ#tm7>Sl;37)C~Rxbޗ6[pGn|s͏ܽܗ{e;AfS]s]Owɮѡ2Jr}{uB2|N+2`w#up֗% h}!k}P~)Uy8DB@k NDףBSt6z8"ل;擞硃)̝߃TMxT쇘k1#fh+Lzi։yqx3 w q|32{4S[}%V#. c\c D2_Me } L(Ĕ;}SK3vBv }N7wjQQWEFK+BĝUfϳs&A~pȁ o071swϚgu; ;$|#y}ۡ}+m-+{pwh2.z6ZD&wmMh!7y]r;r#pb7VԹOG>Z| Kl!17s| ybw ?~Ѳgz 1 @ 7Q#Wۭ)>l^ǽ=  deflateSD[XTREE- aCOTREE[YHEAPX Ndataindicesindptr8LN Hh5sparse_formatw P h5sparse_shape@' ?@4 4 deflate+Q[PTREEg ZM  { W K W eW FR@ '"<'"SNODP\5x^ezw<nhhjX%2.4D "%eUPD$%BD"++{3c)zy~<<Ϻ뺟gSj_oRBP7f{%V߰J+%ND5ʛPG3Ű/<%2›G/sJNz5֨cac`K;DqATioC}r"x&T !#Jً׬F|tb!ѿVPD^c4N=W{=y3-D[8puaK5"]d~Au\1_܁gĨ@Yvəq6%^G=]hĮE] W h}9; U]_Iz=/~!W_J=#ժh߻dl` VBt &6_9{-L9R}G@y~JB~pF C Xs(PbbcfL,tHĽ{c\3AluxZ^,ήL 4Y;+s~"NOfm5#]?_XO pr$:UQS' ~pוgOckƵF:1#K[d WcnI0}罛*R_>wǚ%;˱mtu.8s [L:_X\de4>! `TNNKg4ljǶZuX2Wq'L(H |ɾ|v9E*%BͿqpMaT[󢓘HHR(v;ef}gmOJJ1g O4{_"e=6 -ކӊ8)T@=似猹80S%*Rz xn~͓7Z8g gawB^[ vcH@o˯ҙ%ӈԳ5oջa^cF>Ռ]339){iղ㪯=,ćAՓz1elZ֏>)AAĦ̻DD:`^ʣvP|֦CFJ^N}8fy顽%6J=Gaa)-NV G!<\Hho >YPoW #R#~MhΓξjO,t #WKPP -jqU@˶[=/dE$jԻlK8WBSDg:{ lI~^~GD=&,rƎUuv@7&N"‰U+84+CXS|ăNrmķ[2j \x:eTw5L搱mߨ ilq{dB{96D:H%sr-ҩލx{ڷ0$t=6V ߠ ٟR;w%^=R^ +O| a󹍧gm~ ~Qѳzb.#JƎjԡ2+<_ ؤMO%ϽxX5=j}8l$ a )օ, VVҵ3,vB*ߕhQpܞ]B.IpI\](L]-ێd2y=p5&vfOnGszkL(_rNU&kRYx:\7JFcͣѵcJOe!n4~چt77Ӥ͗0GD҅u8|?rH1mQ>õz|Z Wnp+?~p`|TL\M CЙVM(z-faW7v/z0qeDgz{B7_U]RyrJT9y2cQ-)U =)rj̞JpKט] hM*7ݓ%=9qp~ε+G~Rŀ)>Rk4W~-ND]XѶKoM _܈  Uo&%)0_?7OUe ЊӺ徶 xSkBo^l~Fey#ۛɎ>BZW[K=]&Js,b>g=$mM(2o5[%-YSݿ*7$[^|~r_C*̂~݆4EZ<4u~OS&lUq=+V Dɰ.Oc!*U>B7\;r3^(t»&eb ֜ab2סe0Kx r*Ǽ`QĴsO;cn2f@ItsxpQQ(nv^m@aꁰ0[f|{A/ 4is#ihgLOu$ J5r;O]*DYh΁ g] 0-54/yVGk*EtCW  O"K#zJk1ij$3^ϵ*(Qy[֏+5w|Oط=:'7DdWc*0\iU̔,^l $Wq#1qQz}o֐lGݲю|4ihDS90pp??3DxX7s5569+ヨ-D ,~ҽZcFcFp9u\%3s7>H3~ew1go^MeqILal"/U[d3uO`5!RET!}}oC0u܉ Zu~5D'*~['yn}_zAddYT߆>0k]rꅈiWN]^ {1|pI7y|ǚ0&߅4ܠz܌DwbRGﵝ%XT _ʮus給8qO!#ęۿ}V3-3YHcUn72A}u7a7ϴi %O΅X^*b2 7o6w ߡ@kn \/⼀;.PZz7Uz_Pto=ɍ+m~'Tߗ`1Dۜ]>4r#'εIʒpVYB_v<[/1pDFw<\cnܳWOSRSeF^|d ~/x\k87w32TVgT+d~ o/kAY#rvv~1uyԿ&̎NϚ[Ad%ؼk)%}[{j18iG%+c჈ʈtFR\a,H4[fM/1Q^x%KTJ̑(zGw8RL>Q{u3 op蟭_^҄U.gAƯ4puNRV~HM>&bFcik@ ވɭiL툶|C|Y 9ysz R>/i.4Xu* Vڄ 0YݙPBl. .b+;akUb↍u->ґ@@`<?:>x8cJ2O1˳OeN@Ztn xg֝3#4Lѭ&R/t<Wb ,t-w}\)˗xP.Hveh &$XюC۟Ӌ1u!o)m.i8yعs@.W!'(~ryZŎV.(ˇ}ksY7~n|%ϓؘʎGB+ ᧍7G^9|dP6*)ga@5>XBUP 7|p`>sm*8]H@Ȍƥ\hln^RgGۭhn*~Ft{ 'dǪ(Unh94x5-WnEKPS7AD74=Ol'\'uj8_/j+`{+4}ϫQIbOYrqn k?^ |8'yObKr2wmMsFy d|8a6V4}HZQs o$ܶBڻIw_,k\Eޘ MN6#| 1lإICUxn1OZπnՐOQMQSJsriBonq2k,?D4fCqf"*Z,ɟ9?7:z2?ԏV4AWW Nm]JcG7v@jD+m& vJt_| } pluucfXZPskeFh׍ގ8vT qO׵nK)^DH GFnVcJ)d{9[o{LHl쏞&WG?3 gsz@|V+R\zV"W@gOl\.H)>$Y."q?Z}ee.&U=Siic_͆l|8D1,{|d 1)pȷg i0˚}:"6|e%յUH=1L<.[m\|&ǛЩB3'3әL>m"FLO_"~y7TAgc n~apQf-Y);J;6lgasq=y({~yjѮ%q !|Zl{#w0+3oە(z>['nߕYyמ$0p_|TV x^ezy8_>"JYJ %*E%nEJҢX%Dڔ"YҢl}ߗXK){]f;ל}{s?wB£Z| *յ%"eƒ4,M^9Ew~8U ~9B8teK/<̡8=ҼKʐ<$XGr}] yK |KMJ3:JSJM?$LvB}pk%s+i_;|!+M 0/}[8 拄Bdy،`X|WR DWZ;w*%kK 4,_!jׇ :YՄ:8]Tq0J̰վpI/BXF7x9a[!EONW )Ē{8]ixuH 9hxeN}oTvDZw6LfGb%dX5iԁ\ǃ U q1 !qf.^C`>LyVS$oq'/iȟ4S-f;:,fss:+q{LS yf1W|>Ll&f()N?Fʒ Ago]C.9rT5[FAdVkpSQӮ"ݾp{ZH8oH>YTрg3]wԊ:VGԷP_"{U ~8xWhU=`>`^K?ҙ `5.g?3Dj0|14=QɲVK#"Vbж ٸdۊ+#ӷ kBpyȬi5K =֦`ӖQ=Y׋WksY)@7%xWz^ Y0L. 5utָC'6'*wx3L,܄/*\*M4+s?uhv4e05؋. }o_փ'oja{eGInmB tv:nyʷil{|+c?\X^4v]&T?Sa&w]nLf[꛽*pU=khIfr!ssku=uOC=R1|/Y5Yew$d2c'34e,7#ux7M:įP߈̿^:!Q_ ׀OS@ߪ{evP!E„:*~1s YٗbG9GҋݬZ7-ڤUM0ymOq ,($P}z{.|`. . 1*/:XDaD*=Ҫy6-~T ZΜ/!".3q/ x3Ϯ:6565ђ'}~MU5Wyۀ]l;۰#1ĝ`jK#a&;\en"7f!iךpNpv5*zXv Z:\Y𠌦-~Xz"ܫQOyv@[ct^#oZ ɘRWqXoDߧ1Y"&,>?.k vL$8FC`)V,}Aາ޷YBMneoVl^uѧ F=OB -=w˿O \ڇfi]kN,Ư+lJĕf 1|=: 6(GM<(xkmC4tS*K+4ݵ+"Eזēp4-ȣVaG~V{!nNN>Ko-qNhZK8VqE58Bbe$x}(sMŊ8n\)"p~+I76B'ڲŧi^68N˒#Ѡ㬭x)$0yXoQnET#G&ΰy-Od ?5^ 'ګ,HH0FbEb훯ek~j6`YW(,ټ'S{D?SN_cfT zmuߴfT6LFCws߫f׺cN^WHa}d%C㓃wߞf{Vo|F% 9ܿtkIU9 wu 59;ҌkF,CfZ9 mo3H^7Wz/R@_8{TM7w@1J;7.Ά>isxRQ3=|jl\u Kⅇo5n4 $hH9tۧZ88QR;Y8,\dh;4>*g׃w\5.YY4¢[*ڥW(3hE&5[\$Tʐ/WFE>qQTcmIkzr,{< gMe Gcؽw+! P}iFejC( Xw:Ԇ7RNk%'lN/˳VWoI ђ1ݩXo 3Oy{:nŔ1gf<-+꿗Oĺ7abD-R@c=/RcL1FN85[[if)`ܿ6$+<*G$t M@HF )D5yxӃc݀i|7SZ <Ut7{W[>>ŇSF)A5h"&tkxvR"S8$LGwV|p\X <):Dzpҳy=>|ykqϕϱB|yp3ag ж_mAloKDD {<+e -ɪSl0_y=EQq]Y0s_4Mb>/ 6'2N@7 t7- /}vp52,rԾ@cac%C*-*pI$Npo'^\{4*.>rlp5^p'2iڥ%8J-Z#։"NNE@%#L47KRU:q"E([ϡnsZ,9 D˗F "t {`Ս9uŸ7TUG~@tFѰ݅?tR23K* T܎ W˳'dؤa g3̗k|cgM ԥxg1D/l^ph#m:r5clNiy \t\bxk5gIa"5pYֿ ˝g-XH>ag=L׎C+vߒoR#}dbgοPG]%~# =sX^ p۱~%'yoo!w.5{X?t tjN̈ Ok/ Mcⷌ[2(5dLyʛ3YR%RZ*{0Pq,e)3myɧ}nPZp#5 LEPppx3iM*k{JCxYΜ 3<'V9Fv--Gu)l^M+]ˇY/E2ux~ER~ł:95R?]~㼤oH lLt*U+ [[H]siJ#DV"èJ:G:L=5+$\T\ls-v|c%~ OjDEpb~RmJ'#y`Ju2_dȅ_Qnz  "e<ǽ6#tӮ7* ]7 hPn&"ⷶ~<] qAH˼FVQFJ־!G71$XB<qV|з3 5FbDUi? ׎O־jbrHFxk!4fY Wvi6F?yQtSmo[@|vC)=9E[vGuq(XtKy" (<益@-V֦,|_7ه҆o ήo6}ryaMDɗa+2M%ݤ1}QۍB;ZvЏݏuw9[luWbKP5YfvW 4,>-EHLqawK\m2S]hS7M4rR.Z/%#̟fw렺ef\ZzaRdqJ:eO3u&BR2_J*cq*;1fHy 8#zZ63 /'lPiŞ_\e_T?lȵX<dzT'x^uzy8~IJehV!RJQ!wIE4d(cDIJDRh)2x3:y̬R4^_|s?<{y.׉E%0(mw fo 9FNti]ј>t>peZB˜L:j.Ttrv/$k?](1U'Z4C"@d7ݗT |쩕Ep&ȹ| wXH+Ɇ{ĭD\-(~S1/WcgU3zZH]A[8hK,dEd OREj<^n6qQ/5l-"z'rqh Zwa~eX \3;=QKOk遚L\{}u\;d^wK<~tHR]鑞ٙX6a#|y~f7Zi,n 㕶 {k`Ն,|Nl>ȕ{^[Lo9R7,~o;v!dkDRJQ`ݾ\M"wt܍ د| 5lA;6\hXkz}: fy,caW 2*U!de[( ҴQO-YEe ';EάFV?^Q;[ +Gql#+A;ݰV5&2@)/gjTM߉ĵs0pV!B{sur e q{D>OlF3r/NHlKzc)H˻)*Y.??vb\݂nxNm7q=^TQ}OnYc:v9^*?bD&?cĥAU[L.: 4)(5FwiQߣ+.Gpθ<( s|Ss\/&GCwO2L$Qpئ簾4 ۊD#HoL"%,4ۜ~BK,ax' dzdU;εCQ>lV 0rS1NU\{Ԯ;{z_:󢤉;ѭ/ʥ|ܞ:qcM%#w|7_v,*ݲr)ʝAF)gDBN꬏+ w X )f$gk79aSΚw7}}xX&LOkDŽ~r*XR pߟePR9R5h5dWsiޡ^d,WvsIS {mD~MX`[r yWeWQ &Y83KpdWd<;wU7^QKVr1ԣ4%"ս%%غ~ۤu˪pBA:nmQ]00 XjLSW|e#0ĕ{4Msiuʶ۾g>3+N춟rt<3Jl~:vj _.GpaC@Wg=rƥ8-8kzi;Ln1[;#pZ"Cރ?IcVs9¦9*d,zcbSBgN'VOzK7x׍A p(V[B@XBha?tK@ 5EfUn4TDn,?嵪CB$ϐ JLM5؊tΜ|քJ,}@qJrxN. WB$OQ/ 2?\>wa4!A5sKp#_7C'f $,C!&tE삳Sjˋux[9jQ7r5pVy+nxwn'Tk\j>0V/^dق]߇!ʨpr,uJG? d9rfV7"6wp9('p3xᳲq* 'tߦ(Y +wXl!uᅁ;3VyXs˗n{Ƿ&d$ [K8mEޅ;ZVRm~WF`rM=ջkSehYa߹4_?৖l<~xqE Jޫ΍ =ߺq&"cN̉oP;̝_Fc|C uu_7&iw$]+ |j8GG)8Usu;z}(%r #qy=j~)Bfd{l\SU!oJZ+_GUJmD=޳ IN.CN?$yW2Jj=AĻj /XШ {-mbBK*wWsf%>ϲ@^_ 4bS/_YBA\ߎux~Zk6=H-xS#/I8|bi|b4&(⢋$]PQ )H\r#YKAs^kz,r2禴K&B{ɪ6G8Ъ;-5_[*KS{]؈khyhg-WnAE\b11wvH$H]rWhhJIܞ!?M]"nDXHe\UL"*⟏NU1}O4 /=E m,Km_ᒎ] /RPy.ߟkMq7_Ys=¨7s18= :qe,A˛KÒq;W3va$& FX߫kZ]zu'o$PQX~._|IVɄ_ Y? DA_wB}Ubu$32n,ǝŒ3vsINǷI֎:?^g%E>v-8+7rj wmƪa,,j;Ռ/ES2`v9YϷD,ŴÓNR!g|gLC{>]yߑԽXϓokԾNK)"r2mP/~^ӆ_oDvx&}5+\DŽ)Uyy Yv,Tj nE,lfu).MecC' 0+뿲npvEB.>(vRe9 *vYOz7"8Qfq@#Zqɓ?v}{Z\aDsT-(zʠb;c,1[ h۬2lhZFVh?w#TL?J[ɀA&`2G$9׃[[~bƪqį iz Y\Փu}eU~& yX NcSc%]EA޲s&p{WW:xp_|E1> G.UdnEv}顲y8}Ο,9$`g+rc*niE|w|!գD Ne0lmMgc= I W53OhOn("jp;fwURM0^4klie6̻语ߺ4[ {̯j6g\J_1!|GV:sI`/Xy^MqW k?1dCtQɣLnu^gxt'4,4|L܌K0'8>'9jGG}SKoZN,6>ϫO?HO2k+vh[LSyw] Q3 5U˱.|ڮ7Ӂ<j~=q舔g^oرU&`A&7f}pā}չΞ'T,"0ἔ>^PWs[ƺR}\}6OF+`7\dx/3j'.'fⷣ Xt:D^p l,^Sq#T}9I%=V}OCd<>86[vOI3E'#g:F4?ei\ )C]GD0ラL .qv >CuXmqd=җk1ǯpյa[X:kK I{sQD$uO #D<pEFF+5F-]8t*isF ~g/6 N;3&#Sx"]$VT`e׃_x|ZWP 'onŚ o.=k06^/E?O/Sŝj{xu3*9Kӑnd\op]l>֢~jsFIrfe>F: Ĉ0y$~1޺-Nq*\}_rjR5YBmwvVTjRy7ɢ(2gL[8rKm-jvӔ͌Էbq7-w ^-^ʼn!$~z"̕z>G>eTd^݉snV ?;ԅf 3y\xRcS- =ذGz47}+~z]s^B'@gOųߡ1ayr#/ rǛyĚ˙ͼcq}Wx>/}|Ẇ }_͡4b g[)tN{,nlx-Rwֳ8.ܵVLW/Z7~84@gf$,~?nJޖJ{faЍ 6,rxk屭C<=oT1xf6gX(cu E!&L Gx-`s34ʗ.^!4^H?s:|:5iABQ=77{$_:Jߣq-TEdI{tE-?k)y3zM'Í|Qr-|ڛNiMѡv>l-./ez,ޱQߟl,u 1w@Տ/KJب?Ì_^'ewuq&f̓;y?dHSQ)}sM㇖npTp"oY]:-/-ȢJ)ۜ?H発LuFN &UB Z '{GH|l&VDٶj0/ިJJFb66>ib {afF҂g}s(5^1dTO2pW7\zD 6j!x,9‡iyM8;xN#Yvj:L^ofܙ|{qe :ekd4/~R|K/0\;ys-t+h`-S~. ~;9R'M#%_kr5$؟KǛ*G=oAB޶u> Gkdꗲ }"Q,;\!M(h*-Bّ /[x^0 s5)HUeg 2V~˽Ad㕾s×e_Ρk-:L~sF]RqcHO.PB! S5TT XżCƑOq`12U2P 1;O.JMO@իgI6eɡO,DdquE{AFdx^ezy8_vITH%IHiQ)ї"TJ*(!/ ,)Bw]3^o9̜yGl1V~) =5eD-v.踗u?X83s՞.kV4õӟH3)z?WʪNᑑ#^dG~ *ΧߙXy0c ^ӃڣkPUb  43}5D3J{%% ]nvyݶf*j~uͻR3uZLJ!^RӜҍ盢MtB/˽ͷ8UI%+ )G+3z*D輻^q_vTch3],嫂GIX I7hufWk6rZtUؕC뮇opyLN Q#I^N;?:b|7Aޛw\ )F.#N`@)Hks_*dʟrSN9?" .81s Ė~}ZN᳭=ĵ1r후o}xu8uͧ2-cp;ݳԜlՌt䴫+WO¯%H#= RVvN;{6nOu `q9in16 {U4LO+n\"Q}Q2/9dzt8$ZSCʁa“[!]m9 srPyy{}b<^[if3އfO[anxĚxX<mdȍ̵e>ʳfAG)cmEÈOFm틯AiQ^ ݣ]Ùg|m E'ʒ >8{gpto< st}e&v >_}NKSvvmη`n G︱F\~Ǚ‰cz Z&n;57$/s>x N,%QiK╈>%7[^ZP^{Ȉtgu7y ބ@YVxq֋7j`NPwPzZ.P[GBb̀ 9]~Ţ.&Q0ɚX-͸A ^W8rӈQ ll\E/b+a54zTg.Lyݪغ"YӢC-;k.u<꽱[wt7f-e.WBXrEHEحU).}R֊ k["C|qOkCb޹nidGXՎS05>p$ z+6Lg,ܼvG҄Y۾o1t@ L*a$ync:mӦ/5FNJ7?3NI7.E hwZܰ {ѐRd78:twethH=(˒r%,I܎`y@:.t} [Æz;t#g$:qd7l}U~X<Ӱ}(1q-ڝ ̯?h،l/W3XWkĀ`- :cՐע FU1tH=rU ԽAo\+(&&K HvW(㬃*#a*_T98pռ6 [l-Jh"IjXLQ׾QlԸ"]O=YrK=CN{Qz=ZCǹ&nk*hn46l]#հX8߽Ӂ`ހgF5{ 8 \>Ȳ6%(ZbaK3bvH3fk^%B2.oŦj5cR:US#X!'3;GA?kH-^v ph[BGMlef¹y:eB:TN5dPyqI[ʏ#9OR#J0Zq/n G eͳA.^>= 1)C袿bf^蓛!KcW3jE󽾁z-Ӌk2X< v>X⻏U_vxVCio _ԖܻހO?遦ЇyThk>82FT|_#\ǃ4ʶ+@05UȬE߶ )zBm ,3.?kѮ\GcG.2cyKϸ n=m_8u|S߮DЇ~xLa#Ž;u D3!-DŽׄwtv×⨔Bةz(3dXDǿ*MeѸpM;]:DĶSM`xқI.R8ʿqVxOJ"^shTK2kT,)b?!vVO 8jKi-4i}^Lc+tS`QGyGwT}5G%^۵-ӭzؘ)ժZª/xYMʛ^tU@lPKڱ.YE<ٹre<^U!cx{Pg/ׯ ⥿ͦgӪ1\$ӗƳ4G)7r)ּ򤸎on8_֪ߋnC (ڵO=oý[R a[" )35TcM3•bzeZEcPuS3N9^`XpķtwlseiEo1u@p:3=jZ(I'.i^XwIz{[*qXq8?;jߔ$acެ%W{ӪfvԔUNCV8zE3,6QVOo"j<$qx}_w{ 9[A~eGy7[^>7{EsEJ|qm4J/կX1\e/j_v\`Ceg ӞuYd7x_&td5؝BqK?XOd]-"D$m%2A+Z&.G~vo^z"Rי᩾g(x575ªP^ 01MiyGnA龢T(pf"sK~2M)-G:w|1$_J*SǫڟP}[en^nη3Vs|%6NUx4,3!.~uVc,Sh\1g,Ls8S$|nv ' ǘn~4!6j.DHXQة>|wby.U*֙.`Ԯ_OazzaJ&p?!z ,*&pg6c˸Jڗ M<Ԟtgł_g@v`YJ9?0n\cPdFf\)\R*VՈ+f52+x DY sqV=->_<;c3PΧt=?`MpN+P B~%P1s'q+W)*} y]Zp<j50L (Ų3]y^ZtǂU`FwS55zotG tTu!Qy <oMƧa8v*v?[y2vZ,XV#ܺ`0GO6.=ڢR ع|i'~3u >#1U ]3|Ns]P҉<1 2}8cOꇑf6V3Gjg~+[5}.I?uez}ށwJݪ GN-nFM}|_9x a3$l3{q9Z 6f=OC%U؋.W3oaBoIg蹔 l~`૸jh(:}*-iH}s1Yқ9xkָ/a`=نH˺oR +^] 1 sbIDe+qĄ܍o.2$ 㪻u|'kVqyı%DAICK;TI>XkLŴwhYlZ?/(RhCyZ-Y3U(y& #]@űjÉ+w?JoC˩z&!q7}╟'ΣMw']M-hՠjOa5>25 /.Gw,GArU٢{u ka.8;Wq r ʰ{ǣS*tnamv/ObX:,=t2giUD~ ]yjݠ>n!{m]P5^:`zp78;Nfyeh0Vw݊`[/aX5-hkA5n]IA]]80ǩ]XxžoɕqyGkCy&cvӇ(/G&c[5ZHdxSL}¹ߎSX)3pt!~kU3 2U*yg!ssg|*?wwfõc{+'DkCYyC_`OT0弳FNek |)4J`Ջ6NI>[ VQTp! r3v>gWxߝ1U`J*;g1ь4勼m TF[`/5/>Up.Pܻhե:$:v+&)!Y ( 1|D(ݺ 5/_qߚF>-uMa\,gPZҎhM\X;k.\!vRZ=EB3{P,xS37 pah]f@َ 󄍵X9S߿\[x~Ԍ4+y:>$P9ؾ>ۏgǛK3 :q&X#{m рФ,>+(uOxVG% nJIͩuՇwEUdəYEuˢr#]j='_uWK!1\2y4Cia<B,,`?J5umw"q ?>g"7:%Nǒ[S)sǎSYřz*k޵4gIuikJL]8X.h􃄳q`#~ŰԇKCX:Ԇ?ٗ/@fj. g db \hhc$4Z=: ]X^U0S&Pap*/D3pHq6{: _߶xEXvf-^!pR5It$VZ Aw/M.O]Sxtlvlgxz8Ȏ{53+ _G뿡B6^uM yϚ~O؀FY FQ'Iޗ}_t"W?AkˤWo}Q|[Zo(oqHTTOֵċ8}iĵ]+{~"s_c˨H=S ;jWIJJΡT._t8q)dCؤdՒ!E^>߾]Fہ 7>lC۝mvÜx)L#MJhrg7KW8@opZ7d q꼻uE\GOmwx,ekg]'idzZx?20װ0b}%{?~9ׂO/z;2)v_- ɏyh%w۩!63(؊ґkD/Vf鯹]9h tiƆ/nշ1k\] ='a޽vbY82(OĻ^ɵ ~_N|Ry[ [`Lk"f]-21{:5}xBNia 6~CWХ!d80cF]Gk4`.ws,ާ;tcOWqd7~z߂y/7),~(+7}qB7CvS``u( ;|ey,FoX )"q#L7|?2bF0I\H&.e֐?=Ԋ 6[G?Y_po]v-XHD %5A?+!xʟHgڢ D:HM{,ހԑ ƕ_|p&͛oai(T?e 6Qp2_R7S.Z('d6 .vSԜ3UAEޛ\~b]/Z H48t'Č!}y#KjrѪ=`&k@ħ+VLc%eWگ^EpC4#+1xC0&<]`}66xUɁ뷰`g׋׻ǘ G ͳ23wɃ==D:_@Є}dleU"I }jsCs=Qh|t3Ɠ{_wEC o F"LuqdNߣȝu(,\:ܜ6GփԀ^sИ"oC2vt1$7!V63Nm;op}bϗag;@%G8.8VϧF/S,5[>mpKz](=Y~9<۷msD{ivߔe5W6/?tSBM+5@f96ZTX|e%hmnH.H2m|9p$LTl/.QopK/% Q .l-Ü[ڷ)KTXo[5hN^#4Ɔ/ӼRYǩE1]3lzo1!:]n3|6cn uuIRgკ= =1)_v.⼤~:VJ~f3~zs!' JOoKmr8z3+nƓMJ.݋O M_BhSl Dݟ ?P?Ǿ{>lPi +g'd"7Dh5+jX9w+蔾KEdͅ}T~oe_%%^Ċ9vw1r۸v<*^M ՙo<16VpX]3~ .UА(}MQmCn Z;9{-?[tVjeԐ&>+ C/vn+Eˑr%y O;Hz*AY=n,z\ș|NKoACE!DξмS ǧL*0vhCe\=-գ^0\<[[rNOM6CV1'FVW@hl64y^9cw ѦGrOvmFg#_ېme{GEW0]|W\L$c)^9Kl83]-C=viIc#v-DY+jy &/>~'nŏ2~B \Y&^)'$1Y2MyZ*ν6Z(e5/Ɣ!:ݢ)wګq+_CwQnRc!}rGpU,?z[i[$-v @ڎl.N[WZ5k8*vZRʕ~͟H,t A,0Y6~s-)}L.fHSakoz{[]Jq T>궓5AOOIyhdpRVV=V\#G섟oB&t S>0?=-}=A;%D[<+܆ 񚝕x=K+*`z, Gtb0|W-uW∜{CTܧ@_O$6my&Dœc_6p,rVd2;E&(֢ů41X$\vWk`Jt#Kl!&[=dGFMR+"nzC+$xM!Dw%'eQb>7z,[ +9𺸴7QbʵV] jдxk>}^XJF?p KM?tS3{zz{ZMxy>A.>*W7AR8k5™ʶmN54{(YM׾e3Hy^7?_}R vy8 Gp:ǀtͶ;UőD4ӱ |83ެɣl_-lDe-ТɟY@D+vt5%_/러ב/J? FF ]=O*Ǿ=\~GJ/xa]+ԅrX&W`@e.y"vy[( tf;A^`T$}Q \~M#JVH{rbXKyhCEN@ dY=tEi7 J=)ؠ٪=sPpBgχaɿ `\ Y>gU x0İ,eZ3=1rP34: ˉ̣%(MBԇC/8qij.y|-n驥Cci-ot^zB(t;V7 nG%+i bK]Y{&Q ޲zr1iEد{%SdM5&7H=,𗌛jasOa/^t <ЀiI\ܘ&XL-gIHݣa +"׼vTK;=o=< #|m&x/*\M"z$ȒrZEBn|)~7 s.7{ sm2(p2 S]6Gސ0%ߺ͕'T(#|ğ,Ylv*d-;,j{Hٌ >MG¡si0w'_8~o.iUOר[]f K7X &[!1ݜ%1/f)+nk._09nWXsHy+CR w?y?{S]wҭ^b( 7w_miB9 ={J7[eq(@-;񞷴́%vF} L+`{Q)?Ǿ{ vBG 3Qm 0V^@۴fvĿ>n"q˳3k$y9u  3r3<RT~󘌊H{7ݟ v{Q&hw?PS_r1Ipt>K4#X3B+"~] 車a$%M {ejTiP0Լ]}͖TrkƊ5P'30԰9IB$yUR$o~'=% ݲw1 J /64aLq**X&<>ۤBEk'V^T3Vw9%_m}[WzXLI<*U&ք`oRVϞ~I-H˗}C#m4zm]oE EyϷVٳkO W 8J, ,_|Dnv%|2kإWϜD8:YٛkYG@>la~=罺Jp[ymts2L@ *q鯸u5v>zZ!ocrpu7̮K/9)@@DSi|e8f<96αmc>WZD$]^rse($z;S&-F"spHCv6,i*ŇĔ.H:W/헴 M%n♺; *&\?/i[^kWF35r_ ˰jv){n1r,4bb!X8Jo3gfS^ļ78:6|'moS1H>clc^L!v\*~DoQ -BF2x⤆}]"SO6ql]󝂲o9,oZgGq hܳ&_ֵmLC֢l9pp >TA]JqQ־JyKr­ӧ3kݯ*(oEwlXBb˃@z #vG"2+޲cs8hQ+v3Lm˲ar&تBs2Ͼ!/ (GIH*m/zCn:Z1هeHSm5PPxhzl[HG\a;akF9٩yt ٨`yN-KKM='bϮJV,,Tr9**-LmL-|}@MH/7í?E(ؓgfq_=gag[> ΙNџyu/ K8RX%5 o Lͯ)B?t+btd6RIjWGo*}}\|)ILlQI-P3[F?qg"M>Ыl?R SD(xvN{G ^_q;W9=RF LxuQ1s4|Trc-ن3=RG-U-5]Ÿ dgI;^vl}41SM_q20Kav`L&j{+=[Ra_cS>{I`Phyߋ%¯Kw_!KUac߽tι1{uK ➖F"Sd? rbNڙR>z ee[\]D.VĊףƌNĉpՕ]Uz{ ;9%9~foPꬕ)y!'|D+i52q{'$w@l]}aX%*D/ixTdP/^ѷighe^c&DtcrM^iH-ؾ&9V |$CwjDx^ezy4o~),Q,%J҂d)IIY"HiWDJgacw;9sߟ{,u=Omȯ~KEDXC1l oE 3WL{dT!lTsxuh%f*5BZT^/W+k=Ht_,CbӱP2CWkխ;E7h*̎Q"k5G6͈IS|$ևRe;&M<+:o?>y bپ*_#cJxT=-^ M݃xR«6vt rq"j&(7Hm݋1ޒSx9^E:VKkY\!z4J[If |Mժ_^*)E<{=W5 ;vDKjܷơ G:a;A 2Vۄk$}*Cݛ-X$/)n;KO_9Un7}8qUe cN6J.A{GyxޣquUMλXqF/xi>/OfL&o=})lv* aL^j̩:PzY4Ĭ}!P3s 6׺ c ԃ͍X=yHX+>P\)m$U'X3CoREpZFקz([H$ʉKd=52WۛQ7UOho'`"H?W;Z!Z*NJ2,Rܰ:i{FY鮰% t̓HҘ vexxR߬?;$'bvP:H}1`ToVA֭Rٙ.?|9 ЊkPZˮ™n% źD'$ ݕ.EMd}i; 7NxԀR$,:Wof7c&sb"/RaWZZm'\(Mfu`ixyǮ˄&oȧBRi{OF %¥9+8ʁ}d ݫH )r LI|l?$kL ),$? &^ױuY9?/&NKf2/(XOj5OA9P;x}6_MjVS <{s2K9'WR҈6MؼE@&?_Ū--ƾػsjx>`A,qskZ|7V>9Y}Ldl7nuΈbcªf, yL$9 =r #(6\u~ $?U #θ{ >*XX7;;H01p(-ʇظM[[(D\6=XJ 2"%e+Ƽ ?A+tmw{+ _Q3<{+];7&uZs1&}헴bz7,*v,*ٻ9Ffメ\5U=vF\&:_c_vb@ŵ>+]Uϳ{wd>Nݬ/bGzsrrܣe$\=. v%|</}]ɐw5I;HAC7 R| x }+]F2>9Gw; 2(F, Lti7Li5cJKY>d$`bV̱֙u }{z*NNIrp\ɟWSH M2睑>|smEBlH)gTk N$0+Zj9>cIo ho ipC- H|1kycG&;+ -(þuUHߪ>f ?6Ge/!JR#M+sm8x[GVFyEfQy@+ C;,N&v!'!Tʷr\RN,}ݔM a"[y}lYʫFdmwTMuTqYqj3]hdoy-1qV۱f+=t MɬTitL4}㾉@`WoҶo$KʪH]psvtcmǹ>̆ЏSF~U7~x6 ! =*ZNeayQ-t~,F >};$/NJvF&o»ߊS̼z.9}Y[65K-yzEzy7y[W& _^\}? 邻C.718g7vk6K__j.I,V-L~-,Ȕ5MÖ 0h~-FdgQ R]lUcg[G۾c6X^Um ˼]h:pXyJm}x'ƺhutūy͢UsW 6\سA_oq0e6)a/]K ysQ$EghQpI4||1Ujz)}G4N;׿ J8غS1Oxz}58n598G9OWWw\nó<Ŵ:J"6+3m;8ѳRS' UX3~ů]8za%|V)3gV;jh6MharBȢopqMoWm}0I3[hOienA&NXǵӶ}e DJ4F~ё>˾W|UE})B՜/˪RҬLK" ac;=k3ʖ'{K3E{KY? q*t(K(U Kj6"2Ek[8)# ^ٱy^~uzNeuIX+2l 8QqNc֗ĥ0|vD'7:()Ͳ_5U!ks'F[xgoڦv"F&(_ȄuC~rn^i戊]bxgptu[\v$'/­Ms( 1v}[E%\: =H4Uy OpQCB~ΟjtG ۇM_+ث%AWu_eMMvǷg'vTp!:^6tVy<׍.-NCMkO4.r,{Rqup"v}KB~!|ANy6 v218QV,r։ݨ77JY_VN]Q_!VR'hP@o *Zi)xinL\aT {CoSCهx5~Ti"'-;D,`Em5#Pa̱a>6 {DUhU߫wp2a|뽃g  ^VtהOb\۶[-,>-X o~`6R ;90m&柳Yw[ :P.{G(9\!QN`'ZMg͋Ӷe{n-<]kg[;'˖>oQ{[BicPtb-!ïXCZDJO0CbgY)tn# _a2J\Է}jZz3Zoҁq{O¹9 [nly)˧\vk@^ %yf?-j+y|?@L](rcl(_dqBIzD4,].H}V;JKfߪPmց rpzhC;RĄy"YۇsDj`7Hljͯ=-y|c < rBU`/u̿o4ySŽߖ ޿gQzzfTomďHNdXm/ EGޕC鷀Y:7KqFV:h|RcfG:x?[ɠd :$YZa@qFx$U^0Y&=j;pvtTfeXAeNY(ލǷ;&f#B,s?6EM)7 -~RQEƙP? HSɳ xˆLf)ɸH2{i GYF?˰5V>$T3|mi.ߺ%(ͨ6Ě2J_*?)hv֘XՊ ד綗Xi=_)5H(Ûn/.`n)qlR_?C?ܺ#;48pZ'oЂ,8~k|jWf_;ًpU,b +#"N'A6z>sx;(d sn >?ߣDVƩUҁi42过u4csC!be{濶'9wOMV7W"(]7F v&fn..>*Տ|ޙoש^ƪ\}wRrnKK^-˭̛ Pr}ǚ/j.%@zj%LpN(pUʐy3th:4o~@-n^Z*9/5$ߟDŽk)2>XGio*㳼-v[6JGKp"[iW& N<1OEݿkhs=\ʨ:d,d`mo|ߵV7 4rNu {$g#e"}wZ.8+މw1a]ntg3"4t2_}]y4WK!48k'Ʊ99]4"N:zϏxEzRF8x=aWmLϕHޟ#yL lܫwopktŜ0K93!~Fёz'jt> ,Z1o85b!@h`Gi7RrLZkq㯲}n|^Y³GAI; kv,5*g jã!,)?Ȁ1IJѠ*g2 {%^RoB2zkt {?r 7G nnSnJפ6c" hPW*Y7Վm詧hiRUCXV6T@ue=!cs:{?ٚtkEMQqt\ZCţEӟʘW/[ܸtۭdd>ʬ8;Hhs4V Et$"DO,>)quf.-ڴ4 O:ѱ)[,6vkbZp~-Z%ǽ9_$~ {uiL _6(Ĺs730^Kl7SgRqPGVJ]㣇=m~ЪՆW+'hV\@e˷lp:Z}ѽ+ Peُ &X^qcк kʗ;mF",W%<)¶yU.8~{KT"o{*/:DbO.dȄ1VLEHJM;|7O6ԙ?NU"eb 7qRٳ?1M/T_ig^Cמp~5e`eKR)ǀ}ȡ,v?% V+ԠT Z͵!jP WJH`ttɤd l<{^5_RՁfMjVWT8}܍}O E;`.uap4"ܓ(l{EƹݓwXx1ĭچμDdcx{dh+-u 1F]5N= ׏#.&/0[}29s<3Of(uxŧmi8/yzjg(x9cPؒأ~Aed&dXw6]uIN/wk յ9@w;FX 7KҡAL||:U/ɀ=e dJ}\`,[NBZ$&"[qF,q2-|%VtIux NgoheJw"R J<#%zOa48)`ԍ&`t/Kl%O`p\ F%;,*/nfyҲϳ)w U㡠gUb~ $nqN\z]pS O zx=["+|T5GGZzlTq .N+v/32.x5㇗uve7U~ 67aF t{[[lBR׮f'NG盉=}; w4%硾Dh&pV"{.y{ O&A>r? r+"|Y{]}Q;|7"+7|Ǹ_}RZFY4Bna؃w 7Prʍ5 x=mѭ}-}o.T|v)XQ|(@ j9 Ēͱ;_w8 V/ZrWbUiދϳaҲtӊ6gZK]t/RDo RcMF58KzA*&94d\>'vfEmVpחd3 Mf4 Pݱ*HAoR4Gٙ ~\rۨKZԄ id/WrnЙExY`]){<ωީmC_|;p9>J 1}%[ z۱Iގihbz4u{ZPM~7t_ѬB彥gg`=.v\JI P :W9RڰAYkuu㒪S[ IUdB楕FM4^] B6x 11T}9_9TJ(ۻs,8Nk +{TaD7P!1xE)ϵ>Xt0WB-gAfKJ'mBez7:U=Q?[$.Kƃ+* ZQy7`h\SEx^EjP iqJ){Xk$e* )fsA;%'M#Ql.>2߅/qL#eӨ YVž9}t="/FreJg^wBY(OG膭eKKHL3dBN'b~\'BwMdk]&:4OqMmI +GC?*X@4mOmue4C*t4ocxm֜{{cZpӾ[2K꙽P|: Vy}9Ubj5IIF¢Ro"SR2DTzP'j{Dž sɍ*zR,e98fS߭ fF4yfщ-ͣ`'9PBjz̭D$9ZB5Œ / Bz|ǝEi03+\evѲ2yl%7+z^oYVzx1yeSRe5hW4|2pXˣǏ)R j$SXκ:-nJe,OÉ U ;N;^wp74+}^DDOA;fjĚ0OKiW S>4<vcX~ե=5[ e<6!<c18(zt-V=)ǔGGB{NrλfFZDq NbCRc!yX<`S> g /'88 oΙ +kOMS 5UjX$>pMGi^}xqgϙs$ kPzd"LܳݸABuNRk;raOVhNEawɮOz۵7]s'xuqJ/# uS'p*JOK]5y8ϘB;f 63$aޣ^<%iFq?lRO2jIib]hw{1Jې6էV3Ae-CQ52b ע}}ȑoD_,i+)Epeߝj O9;긠E0],Z>eB+:LJ̙@׌A(V|\mx^a&wU-!Dj]yGMKf9뢓=>qJ"J"n^Q3wa $Np !#zF"u7q N'o(7^}S;]|)FA!K+VXGN1j`_†K%Kxr_dAy^׾bꟲL]Y1읖oeTD5D$VkEƭF_pq培xߐdx2Q!gi곘AnHՓ&:EHw1f`.1Ds>=8ȕ`H퍷nT\x*À7B$ 5͟fy=xtǟP=2=~z ]֑fѪ [j\_y:%^8#NuvqM5.gݚ_Z]VWBe{d* [> M73_}R,]>uIt'i]zF)JvbJQmZu+MXm#VW㑱]#]E{'?ws ᥄;ۖ8gAr]6[PNN!\*bUj(lj}H'5U%>DвFsxSUOQ|zUb J$|'ݤޛ6$xkYwISQ~R=1[G8VE2UZdթl+^xৼ7*HJ*2\_DT;[h[nLŹ5wmabqԎjY?_P1bT~υ.Gмg][_M`̧,F%LƝ.~EfךmWq&j17|Sz{eRhV]EtϺ5.zw \^_#8-MNkgc˧?%!oa&>"CEh D"g.)n%lҭ1eH5s'~K 08 TGC~O sϙO)Js3Vx:]2Meꮷ0rWTEGt`4g/8P7JHtINE)=ޕ9p xk!\sGE{*"ݟHèv'R82 W+jXNRauRMM3 PvI6 GQ|{Y3&r۵|2-J`%BÈ.ʮDkPݔt ug|ag ~b׍⚞7|:F?NΈjSSo5ZZSpZnG&t:ZRݫ8yن󸚈UEkal\hO3xd]<_mWs89LK ;)9"_U0B(S6L~,T2滲LRZ8>!c6=ק,k6X#wWl뤕MWsSL/='[emR2:cN05 ظ4 զy^lGJ8Lp}u@E,ȏ$yP1 H_~d &*sy,oe ;[t`+LQZ%wi`[l3 bB/tY5J^|ٞ[CKl(u3oД'Њ~:g~?RĨګL/y+1 W -%5sC}CJ)޳|h@h-YWWn z֟b|Bȥr&.2/=޼^!-fؾ90oG- e쎌ϥx;I/ͻ[a§14\|%/C`U:M](MX7) \0#fĊu*TxJ`Fh*U7k CV8u^nr4=*S_u H-9gյ˩_)- r G,XvFIr15quHv{j3ƪ `t2L-`Bq«//>˖_qRq 6O:ZcYM6JEzo{qega7в]\0r~wlX?4 P~yyEO9-N{ܻ\ܐ9pBI0 bˬeğuqsHk?۟4{f ۫n ;H*1F6rY%Vrt=j/#4z*˭[]#Ā~pG> )ߍh8-`eSׄϽ611}CVZ ZZTo!_N}F[,9Ps|`n Srd9r= _YSU3&)FK{1+J8k{Z`qKil1:e9S(p2$^iHTT,~C|Ny`p7̑ .B0>;.8r>߮(cB$_(<Ϳ׹Mqio#BC[:klsprynGZ\&4K 4XP̮c ԗbDeqTcvѻ |&rlTbE兖iؤ6nwEϸu##V}<=^@*BӖ+Qw؋j캑gWcmK:ZW6-yDչGdi^M:dsoR4͓V36_?w46~6iF68啈_Bi-PyŇץԝ3\Ddǘ-?/$ {(>qT ϩs*dK&a(0s['GV-1O+^sq>/TX%C_>:ل=8ړ [5[8uKGį-]yU4_1ds+T(WC\ Wr_}yw9 e"Y?ϧGY﶐;-bBk 6\DT$>W}]L .Ze0MX*V 6GAf:!pL{?AGVuXx^ezy8]EDշIM(IRDHRT-Ⱦ}Ό1c}ڪ^{]3^oy9Ys猳.$lpV9t*Y!yM %xXG_ S4.AK V5ߗ4|?XKc9_<_Zڄ9PϹZٻB- VLb Nk%OR&]܊JRi16FI}R pߖj7KԴe:ϧrW̿\mr+ lvgbxKhWݘ)W HX½ueD 9DxLo^BAY|/V]V޺Jghљ~c.}ňBrz\HY_-%Zd~p`@  д4 zu?25:bU#%ZDL稔~=3Ͽ$  8ZxEOZ Eg^ygIpvy4LϦ#y]rgos%aXFӐ|O :2!ӗa޹Zp(A .K ~gˀvd U5[N 5/ nӚW;D5aQk.du|Bⅵuх.ŭGKD߃=qwe8;42KGB9qAGu:`F G/}*lw?[?󲒫 H Gؙt`ɮ:h.LUlk[/hޓe*у[n%D'f,x'&5h/C1.GVpvQ>Go5wnL>l<濽S˵W"=5od5&^W;u>P 4CĈB+~ר ڬ̉TĞk,+tnׁ$oқ+Sh[U+~ 4jZ#::?[F4Ύm,![?Z>@{K* #0 nPZZЄ#LzVUV'lˆ㘱Je8CzCKr2hhf {dz_-9P6`gyeM` (V LMGIl(wHp硵W.;hlb6?9|E.1. *ޯU1eU|䲛m CtϽ/ l?GVvG&fRŒ\#6r,Ly)QiS g=ppW̖OT< LU [9\>k!#gy @[AM~ZŐᣓmՅwnFF(Lb/7_<ߔ6vJ<ҍO^{ gûHpME2N-)rOqr4DBl; F K>DUi[:֯4jcux* *Sz"cN)K}uBeA63;pږ׻q32, mpk)g}@Œg5+ sDEF֓aMZQ=-g֦+G`>*;7!hgkbKӃxq_h/ DxaS[*q9I1ϛ:= ]CxutfƤ 5aΐ?!wT GGQ:!0"o6SP]i6E\'E?e\<-H "Ky_˩m5b!e4:wa⇺cU*FTęa“lVsf]H _K 2ǮM=%3} xb6ixi߂?R'lHpQY\h5⚏whBO V ݌9qW o| +&_Ԙ掂"X{X]ϢCtκ{U{%_> 1懰 $l "Ka\أJ.]Gr`B"|ݩj7w-"d Trlν\n* Z "6v$ r=Xx|ۍ^\`-Yˤ)X)Pۿ݊/Nlݾψ뗎yG`]\*t.BxIr&ڒ3̧c}o̶EQ+#ƮcXswoV7-IVޖJ |门ޒwv5 r)ORw_@7ؖ/0ɰ8!׊?;O`KQyOan)rPHf/Ϸ3XJ6 "_Ft"⧖Z,Zon*{a'C2nKZysW^S;{>Fl=TCn"<ًbU:@m/uXPs]`w#ۼxb>kA㾟(~H'.߄A/IJ)1;p5d1{p;_RxzgvY t ~ՉLyF,qH?'ڽ;Ֆriua0~mC q~l/iģ%73eЫԜٳ<ۓE(c}5ϸ 檽x.w¯&:NQ؋,.:,"# 7 #:r)VNT>?D\v DW̯fIFi[zH;”˞g}<|7X7R[L)%p)[نtp;~+fD/iqy{M_,NHVlW=Uv2Y폒IY4ۇ7b] _-FyqB5ǡ8UؚmC 8˩)ʶy6N۵Z4P|{(PvD 4CwfP~‰Mr_g@k9DraZg:vz/Cu6(}x4y30 {k%磍)b>cB:'Zt6('Ou~%x=+%;~VG{rZdEߡʗ+>x R=X0PNx{g [(t-sQ<-xinVj +LJ* #:s߲:UtE.vL%Dqsӱ tN&Vg*`k1[df) ] #OL"֪GL#L <ܵ1jHYͰ;ZB$n%/Sxy,Ğq^]XGy $됉veo"D vO"tp;TL׳0m!O4q͸αExY(E:AεOm=gm{Ւ$`~ǘ+sE&,ebIǠWT\$u3G6*i7rtge+2h91h~lK W"GfqʹPAđiuxZ@lƘl" Ni'q; Z!/ʬ RBiN#%T~۫LYɰՇ5T=gXZD5g#stЍÍ7M[-C߲?H $"[k -z'H v't wxm宧 ¿ĚWM8e܈|}%~" ,áE|kw $Po~ {황oT>'9[ thLBg9UZ$;1R%SUw"VՉ_FŠG׮l0Z[5_RNTZm4=24u)gjk,Sr^W:_'"bVD/?*L*/T|( +.~ˍ W<#Gvu{t;sX{>Yyx⮇|c‡ âU>oI%X'%h5X+XuKE0u*\}"n_ndY+gqyqw\ۈ `Pszr83Nooغ;$ވe^ .98;74bj[>Q;ZQK酩tW2$1b6;J5@r tIȯjV-*LI`.gdsopL\|TN .;~Ѯ9 ?[M})'F"> *Ό\վP U啻ͦ{JqZ|VWܕuO\_UDc^-*/*ˀh8IXFl7I9AÉzFI{S.eW):u9IJ̮Ms?nкѐ2֡plwhWk~aMcB񻂒nR1y;Q՚:ON,Cؗ8kDlQ3k+Bky-?hV]}%tgYc3CI8yz¾c|o!C" zc1'awS2}l<8 MP% jQ4Tݲ '¿)k_쿽%hN%э6GkQ$mf~ S<]{dKc4F gbϱ{N3K.cW3*9T]0e}E(D">1aaL UKHYҷNl.0?"V>Gmxw"Iʼn}K2R pT %r.Y>s 6S(ۍlނhM뷎vBMX8_H}=C›fkR 'rf&{ף1Q+09l=D\H0"i+oyQDJח6Ojx'REw(xž3$R" n\/W齢v;&cB->Cmv 1!ʫ_>v)0aP39eݱ3>ލTt0 ɢKxJN?.SI)*qBĮ߿K/}j0 Z%kY;J礒{ qjDQMm5=Lic.j[PsQy\Kێ&X%mi6p=_̗9vјYR/BkHwKz#'5k5QX(_O$2_RrJm'/3qnV_E A^C. &6a-ƮtbY*+1<D3M=v,Os>$8m:L 7n{[}فT"ewBelJiZiiO38/rqF%Z+tCJ]/oM+X#O_W-O_ 1q"y1݇qU.TANIyk%ߒ*jH͛NQҀj)-Y?Pt0*L%Ts-Pւ+\r?gAȇd!<&ԅpw;?|)pfeeP=,oF] c\Ս`|&dNJtcPfG;ن Wb˲J9XLУansƜ sNOW~3|ёrY,m{}Ħr J!eQ}ض=uyQNKAɮ &3(hO}y)eS--CELJ6 ?3s6]C(屒xuMY8oyHO_s1{(ÅM~gSJn)ɀJ,a|}v0guж'ft}[;'ϰǩ}F*qu86܉;Kac=h@wCSDU*/L3xKa}fK̥-@%~lm’,!'.YIz[&S#„:5@$oK'Q~!pM7ϥ5ood͇!о\bG[<^1T(IwykÏx=IJǞ+}:o 1%A15 #*>/B݇K!ufy;:foKE`lҭ R.ڢGv!q`?:U o}\_%55V?Ѥpں!>;Rajஹyb'Vz .&,;} !ɣqH~S$,K8/Ij>ZË}K66Å'/3qdfG]rvo_#W-(` w2ͷ G0q f))"֭X)|>&{2Wgͳ>m?'n6Nq፦ 8ם۝Ί>r,(V^̘Yhlύ{~ЛyBN޺r1Ϻ^TzkoĨ#nPȂ^}8qmG,*Pt~f@Ňr{^c#&\ *lj3?nۯ@[K\47"BI(0gx1%i.j61O%ppܡ4/?jsVu>Tz덆"NãM6(`OCXOVBp"y:l[ҘRJ-gFn]c8˳cn~ymMռ9Ϊ.̥"bK]:djZa5eG] .]UХ˷O7 u sNh $TΙ"TPp3vͫTu3ǣL6,DbC'm O_ϒ'-Dz6ՊݏAҢ%f7_N?a}s6/|<A!.` %i:Ս S;_nDn}UCMb|BMJ X*Z;:t[V(Fs`4"^ BgU _7 (hњxz$2!*>FIJӐWhU;Ѷ}bl̗mμҩڧL5 > J)ܺr'  deflate] [XTREEX)fP v6ц.FFx^uU@pw\&H`%kHpwwg.֗z3 ݙwvwUucD“›$%@J1>;I·G?œZ3{Ca~Y\.\'\곽nE0>LXZ8G, -<."|$,G[ g g w ?  ? c躙%n7(b/a [_OxZxAVu k ; ÅۅtXd b¶6aaL1| k ؇p0~{razaYamappppp𳰚0GuGg^~fccaa'a@qggc/\, \va{4gUM*$&l$(\#.)k Ck u^BO9vt}pWg):np x~Ya9 aL{ cwЯ :o'݅© jc];Mq^,($\/)̨/(Z]KsmOoIn3'8[8n 9J}˄˅nn|)%=c1tTvR9G1w".\R\xSk~9ap܈u\Y߹E|H{Đ >a߭#Gx5.zhݬ}\,;7ʳ|5w"9PGw?[8 V9aRoá{ Tfa0OϵÅK>19 Q#+ǘzXߞuE5eO)^K8A0sPrsX_  w Tҵ0ɳC6յ[y4>_JXQDZWG8Ѝ?|٪:vKnT\E-b0OU|ޜs/19r~=¹G/lrXS,P{ W¤Z3Hkk֮"ZCp8?C,p!|_\=Y8Z{I~:GEXZ঩ qIM'.x7%0)ZiZpy繧(z=+s~x:s͢-1bX]XIj3bU7}N݋}ӍZspQsQkYYs0R. ciV WVU> s4O[ ttq< Y[[.>$ V {!Y7u}G$q987v~gl%<&ÓxFx>iabM+@ G[Lx40KfbEw?}l-|ravq~)V#xp|Iit GFLC>OƜm{ʌ JLqx,ܿOޞƫM ׍'dWYc7h<9'7vwp C{hX٫9Zړ]^Ygv{Fg<Z;='{ Ov5Ku_ ba`ҕK }sꎸ.S?=ӄa΀/Зx=h|uOJn%Db?gq. c 9Gnbo~3gp~UJ-]*RI{i|u#[a^اM\[ [ sۏTϱչ:,܇? +3֦ΞY8'o. ^n{ *!81k~_̧#q8|yHÙT3T8\ ݰ= Sk~z}ej "$9HO3bmOG.s?Mvb?'Kzej^>9\/swK[;{kGzDŽ<MѿWCt{Sm7hO71ƿC##A<“)gQ8vIz yzDdwmh k9kgJ 1ӤGypyΣV-[ѻYYpfžyp4o&O}T6:dOW&~Wy99/gg=й}+cpϛg=/ܓ^~ A!I o7Ϝޝ$֐XЫL솱xVz7u>Wx6'NύW+C_aoO\'xG|;(wӵS79KY'^V9Dod}kI_m$g1W}rf^4ڏ3@s}gx9p^=ke/`Op^{?;<߹$:}qh!a~jXtG-IM/A #rY;yIf~!+Ngޜ^g;k}Z˳;g-ΟOshl}?Y> { ?gNyͳ^P\{M^g_3oeܞ3kO}Zu_=f&nqX4yOlϓ`B={lO=%ΝߥWmr}Ύg'rz 9=iy< މY+x}wkأ%SAwg CFf }B~ 2)})MuHxm_G]朌nwLx #d^@M f΁f%xԋԕ'[uE/O߿^rBnkjGΫt)c熇yN1\#tyF擊{po>ó/l̠_r˒ ̍ϔ3srF"3smt8y}zFs&F]so)~>f>&g9^|3ahxԋ ֳ=^Myh4CUwD=cJ3+{teLw8wg p5%^B3| 0Ɠ{xD܉6p&)zp"}&,>3-Ϭ!;n朋᳾joJO7/{Y&Fq=-s 3(<_qkS/Wbn0G$ψ߈ZQk+ıxq8_<"3Y_D+Nv1o?ƪb7q8Y\/;0q8]tmQ,qEkG'hXNk!jXM/}syɴv瞯ˊUbqt /m,my3[g4Zw8JK̬W*Vk-\XO 6soba]T .dp\ZQ,k6ۉoDZض:. {b^,y{k7ֱ'|~ |~;%RS.--ձ tY89Ì[Ό=E[9gD+apxX7b }B>Φ5Ĵl+~qx`g9?c,m1J!čb%]N$iyu#]j9bpC֐?+P1@#6- *օIk79Dp賵cA wnlSkKe #qxP@~-*FVy2>yE|MGȱŵZ#1s]))mh`8\L??+U5^;Z{A_-ʼn3>G'/WxvFW_ +ĞoH}\һvk/ϕXw^Ĭjkr\:ѝJ1ޣ}AWb/ޯŮb7qMx}1g.Rw j϶gK<#ijOs#ZlC<^W >?L\'~.>ׁ)xX]cêgw{,:U^an}zuX/źF.Ź:[ham}*Kӕt\U.VBqQx/Fa VT.^%ui3~qUv~-gk~4ehpjyznf/؍1a [8L=v+glShsԑ~3Ub;We24mo_7;GIBw=BYk3AnWiCR's?1xOOYm= w zğ\QYZ3'Jˁϵ92; v;>}hz].3k+zf^τ7&'N +~3,Q[?Su ɺ|%y^rg%3ss_rg@wsKn~B-ș ڏCC7c?C=cz'z+SRN&gC9|Cɵ=yzo6ӟr.c^:=3NSi ߟ^{ҹ\CErqs/>~,X$o)B{G|=Yt :K|w5}gXΖ͚zX~feלoe]&ٳRK%N;|ogo @4G=Dѧ= 3I8:=чҋqNYo%{YJ=ֈf-do9=&oroG!LwPcˇkuzG=E `_YKWM֮}ws}9}ώC؟~:7kBibAmy7gl#;rn\h8C.-1!78Ce <<r!5'Zwb!zvfs/ϼZA^wI3xwG9CcR =jj :^Kh sg4f.<#7ש~o.QϳN=ڐ~bűIˈE%{'?ǾWbi(z'?8'-FϦ b3Qk:ŚJ졥~d,pI\oGRoi8G+ #Q8 GD7yx|zm03uޒws8YzM%m2]ڣkrݙ,Ż,{6_=/ϹO< g>Ѱ>w̵ⷩ{ψY>ݝ9oޑ3&oG8LOܠO&qN1G㻉(zpvgxqKiQau3G-!1/??<@u҃3" {{bO?xޔ 3 b1Şhރ\}ï/zgJ1x½ݲ.PY셙-tK^&#O E pF qhwFq~<]@묥O<Ҵ=m6:Ehכ,r ;ǥ{2Ξ-=H ˸ߵ3y!]`I8xOD?k?~w9 >Q?Z\JYr?k,?/!j+<gK\ND+|&4cw3,] _Px^}WUϹv+ v (v`wbbw` b+* -*3ݍ38u̸kw{9{s# aO7++<,<& ,Q̎u˄ۅ7ɴng]K8U8M8_BVWxW~ ‹'´Z7EXA!,{= S2\2\F c'u:p𫰐& . ;Ӆ uy.*$!.)\*-籟<4NXQX]R]8\8C]S4~a0- ,+ ڻf0[ ; w Siv({%dZ{0VXN G Ä9f1a=<5ƙKyGOhoQ8 P{7B}bG[>>z130Si{w gAB;9RFk KKŒ߅N%x:g nj Z:.KvqsK =ϴEZz9,||:8%Rς\\ٓ?{NN|'Gغ{7lpU۫W #<wBkOUC@Cr~bWWN;N+9g6r}f]}+{ 1Z3Rw|`x>CO+SROƆ:Fu=^JĈg8ςcȇKY,~s!aaNx-j`a۶e3p?coR7aUa}^:T'\pb%g]S"0:_ O?>j?j}uDQSMΘL앧n=gfAe 辕St})-oך{&Oo?wN_OMNQo%pWJx^xOWB7Nn3PxZpzF^cb;]xK dG0±hm~%~/Gpd5_7k7 ;u\U%w58u|\Jk{〈ⰙEHݥӷROu7K_v#whM˾U!S=x 9gS{hjp$%c8/pJ"#vD;i^as}zZph7oxOC|3^Kh 'ڍ6=t _y:F>kmgSѭҿbpAS9gt'=GwiKߡ5C!`Q]{NTYR5M˅+K[ނ7 J",Uz9'z۽{K|)FFQ8^s(cvg+Z8xqI 08>*5 ?gAT=*p3s"C+Ir~BӜ砫zA6zĩ 5syV YC[x 뗨#KKp><~PY=wG)5 obÂ_Tx{/V0b?-UBv+UV\r|=7g9ӳï77λ7584%O#͘|`~^o/ئT@.oNVymr`9@N/#{\<턽@r@k;נ%c[_ p&wfSǦOyZg)կdLx+MA7wo,8ao3{21s8o\7; 0~"^{=b3}Yx t!9<9G7'}ܢ'}Mt~?sG4} 1#BzfM}KW'=Bb\ástӦ>t~rmܪ\4 _l(g0_}"Qc֊G۾Dp&1¡V{-M~E_^M̚/v,uI|Ƀs%id{bVm!iviW?CnA'܂&ȕ=43.T')CLץ  _Od1Oƫ[}~,^UzWh|QӓV~Cc??᭸owzCC,Wc]Z@^;g5pFMM796cVkScέZwzH"&}=r׹p~;|$Ao#k?{{mӷW/-M;u)sD~nMXGz|{z[} 7PJ[L<݆a8r":e/gsZ>utZ#Os亮mz>߇ / eݭ}C13I8ל4g-ɋr@LZM]-PGraMO݇{MG }:x^^Uݓ N @pw[ˢGS$U,U]#3=sXFl/.,=_R"^טqy]W5f[xa0@- (c ' ch2.9­“zJa&p0ޟCIYG8]XxTX^#,",<-%#+,B?ߜWX[Uf?[B|}y3nfa)ygKFajNؾx:rBhJ!gx$fI¹{wƝ(%&L7P8BϮn.OQ4f\a ae[]^x[8[~6ט3|Raacapͩ?n~YZE%5?WX_cFw.V :  kp'tW˜Bus.?58_Aس4q]'Y|svƝ%\%W||g6a)aCa3"& xk8> .TqlW 76u=e?gӳq5%35loi'a=RB_HXR Zu''zM&"w>$ḙ,;|_ zg.,>߫7y|=^&;SBz¶՜W\*/Ü #sh&nKSZjý悏O8.Vן݄S+?uC0'sgaj hAv Ceuc1XRC|l&;YS3j"K g>#8.|2^cipԵ_udniktg)p8"q| aո˄Ü_ ~FXU[<|˚x _]Xۅ7!?ֆv"XۑaXX#)rk^Y^Z48ީ  vܵ>?r=>7{Hxͬwa⵲N 7Gpo-q©;W7jXR8w_g]KTþOǤPKx`0L#\+w\9ޟ^^]˩ݙq6p,au!< XA;U|b3&N7%cܽpp~~&Ըcyy`s'"L&{ƟT/zjp 15ku5}%.0y.;ow34o;b~HC={Fw@oWj @ ;ajޛQ4\YiKa`_ {>/UBýb!u5Omas8{ipmX' 8Hn_^S͆2EnVMJFì>~~͸aXDXDzhzm=X4X -E^4&[o-|-^Nև.#b}"nWQ\G|glשоq̺{.]F2熾ȟ-٫ #M] gΘxGɉk̓+ {U>{H_#{C8ۚ&u |g - ƃ#^'̛hW`żI:Kӷ~$iVb^e~bHr2X].#><[ w C'+:k$6yO'&.x$xڙ.y@L&J_C"z-=sV=[s@o.kÜ{]{8=͏c̼D Fo.9'!26³oRG/|^*>!.]{#dobx!V=aiKS¥µCxY6BѓQ(+z{Tb{9bt=[~gi(ϩGؽ]c40Wu3pnٷ3ܯ85ώC='b+SX\DM 5{4-'g}?#.|Al#F_]өO#OR߳bhWj>y)7r<&8y'\O֙p{K76x<Ǘ ҵkd Qǁ:\^>//Sh;Fs>sF>|\H{ňy~w ;hkjӥvhlz'hk]7i/̹\^"ZbϞ;*ɇAQic&јi½b};#2F_R+{zg~CEW ܑAl'X7˚%  deflateE[XTREEPux^}puyB<14yy1˩QM7],4~]Meuo9QMx x bK2Icd6 (;ʨ[OhI'z0$&#g!?yY rcErJjAs"A<)H'lE1% Az(īAE<׹CӆBg1x ְ/9/ك(e(qL$יKP>J*U{O0dXf(,&q4#~đTX|N8BШL,S/TREf$Mԋtg0$0ט2ְ]SMnecdRBYfvq߹EX3 }3L!yC6nר<Ќ`xeN(gMZ7Й^ % 1%䲉/('OhhKOF4I$LYvs_=х c4|>J Rxha:e   deflateE [`TREExe  deflate[`TREE/  deflate [`TREE@ SNOD(yz/| _[[]u 3STREEHEAPX }dataindicesindptr8=] Hh5sparse_formatw P h5sparse_shape@@   deflate [PTREE@5 #C J#^,* 595= ?FJO* (UX_a rjjus| aKB P I V?ZO ] .Ymx g}/ * 48V?I# T],E_i58 i8> tpG 4xPىY~^b' #jȩ$tm|\ KM 5: y)])4]>sIZTT^ bit~"Qm+U04<SNODjE!x^Z{hU6 %e&e&ԾcNPgZLM˲PӁj^SR+5]axY$EK$"dTFF7!"di(^ |<{~w~?^{}}`߶+nuWݼ}%A>5616kmd 'y屗C%?K''[p\poc=b\ w(7LfM-C2率 ^twǐSzlu>~)8oybPۂ]of"ܱ޻f;FNo5uq;G<7A`ޏw|Tc̵G=⊉l_XAymb~Wlb¶14~!Ebsv9 Fp\WAl٬<1-957gq wbpijccui͋}`>pU9;M\?μ=yrszi5͟2Gk ^vLPo,]0O +7[ku`G>;O.\{/`H y{rۉqab>=&)c^:~qi6c~-q5GGuB/=6$WK ~9}_8b3b3Repkw}ۊ߸sZI3.p8ocuړ^cz#n4N0=bUvc=u/|^G}O'13a-ڽ瑒Ss#;OlXOI8r!A9R×ovu[2K}K؃֗G;޳ܪMSwkc-NⲦ<oTPG~]qllZ'cqf q5Vzqi=fy9;_8mBOV fyܨ؝Xi^>/9 vW}wg>Pvޡ:eҌ ަ?3+6pB4ҵ uTg5ד}}.ϖD{$Π?bvѹ~GBOsڛ-Vl+uݘ9qWT{Y-3ȁs﹆h}jX܇Mq1 g<N{3ʲ2C?pmOڈa~occJ#Cm0_Gx6Wuʉ|)ߊa]ӎ/B~[?r{~|ń9nKg&!^}O[vƾ#gߥ!}Zϓ=L*mi}M`zmIӿ}MI]E ʼên)Z׮uq ,6c!iR G{ɉ>quJA?nXw`W,ƴ3ZN?Ck3+_xj=P_L>՞cfǢ +ze_mƩw3s+okq7Ϻ+6xwo WxcB,8ч7Я];AW{bPCAL{І _mXʩ^:_]|'&ⓓ :%=QF-r+>rGcsg)ԕT4YW禸ڗR;fКp1^mޤ8vdb~ȩ^27zRs>xW^[uJ~Bqiu^X5,SxYoyL:Vx^VlWMD0-i`*.)nCpґ@fl B4[ȄmdH4#.B$ 8cs_|kspyz[=xL1^oqܕ5׮(Cc2^|~}SσjRG[WwZ. +^v+\#I^pUCiJs;s]ah$T]>EěR#s~p߇kоeYphxpQmҭ} *z}:b`~3ƴ&uV'j1j0^M!a?Ks+WUpzj}OG\Oϓe^@?-y=Kt'8*S}3w8lo~7~=Wz.kZ`҄{s9_fmvF3V ;cciީCn5ZY58~E? ^,{怓y tT_ؿcL-}_Iq;th{;=84=h ޔaSa]j?'O?Sz@KucĖ;Y;wmC+'X>#C]|fkt+476^ ·z ZU#=57Uc^6{_8'w{wI}SIDl8 -8vيzsN}k]Pܓk׾߿ނ :(O[^~8t.ߋuvPȁ]ce։cK%ăC{ yM1v˞<5nq~ɧySWj8ǃAr^U.ugM^{mZ ;w wZpj9K|_̎}JWoH[ps{RK{yYN@~G3Gzr}]NM\瞪 |g׼GbcsOr=sK˗DM݅Ӝzu Qh-ʡ1y䃶̽k/Yݦuw oH]}?{fgmȇ~tbAT\S9bͻ뭁QyqVh΂)o.|n֦;Ό19<_k(),gڷ#{526oKEO1Ͼ !428B&cZB~u?VZ[/KڇKGJwn[ v{~~}~ pg 0Z_b=մ7z@bOq~Ӽ;ioW);.)o{ b-v<ۿӳ6iHڼ#;؃եG7`]׷E^k };9Jнg5pb)ab@6ot|ӦZ}iOol⨷JtXO6mh~6P=p^XOkjnWv掲]SRcI Z&:O~__V[.kiSg|=+/_R#W9> 9:_v/K;UHCiwqedA?Lqz o1Y^\ 5v8 vď-{h9;96Qaݏͱoo?Z>ݡ/cWwc= tO;tnEO+Uǟud cu8?w$2.3Ε-k[}V;dwB]ov31ڊæ8xKuuDG~P39C ~Qïk-vNM|Y# q,vtzqm';8>O'C .kmMTg񠆍3Zug;۽W{5ݲ6DEqŠt~Fr~bgsuG]e) saVϿg{wTk|?AC޻jg=?#eCsLc6., 뭪U/Gֳ8x,C hu&D>LyVͣ mkD4'{54 b3g)MO>~vCKjV^ ά6Wf~(;{|#6gzx Ϋv?{iT_Jꆳ@ҸtG޷r{~rSQqBl 1gBbU959|zLHȇzrXLOΧ6zu3|L:rr!+w֖>E,ΩoGEš?K(3 a*u޹K_i=ۃ/?+i֤>Gi7ͻ^yg֢Fw+9VY7?qM[OX߄Wb ܽsr*3X%sI7Y_{%6cf;Ȑ R `,bflm+1-{!گs'N,uxK3K=zn:kVWsLڽØjs8C.0bvސYLc&Vi˩ rAm#AcX[|)r[^kAN?]9xNp.aGN| <-/c>34>cE9/A[ע0oglǣAqߟ;-Kr}vG wKx;ֿM8T/ ]VċS7yKq֏k', mr(e=jR^_B<9Ҿ[ҹǸ}U?z޴nwo~·X/= T_X,N,KA_>{fC˹/A￾֩V8jUM=s(ͥ߉X@?SnY9q.Vԏ[$xуiשI͙b59bvoZ>czzuX۾hk=/{Z5tKӱ/=Զ~c1[}5x^YmhU&"o~´,XFs2KGG5`cbXZi"& >$)1t,bf$EHD}V׹s}<}u& nʙɾ=vx;2ۻFݏLp|Mm:xn6ոa߫zQaKg5"׺綻Q}s]}o|GڣcgM7LU#64`5o \ yz[fy 1}0ƒkc8ќ}!淶پc['{H~^3Mk2DÙ PQM;ʱ jR A'lY?jAa?gqX5@ig"gNڴgElgYXX쇏.޵ű`=Q)v[;a%\EmG ƽ/TgyX唋_ƂCސUY̫1c]G565cJiXY΀ix^]u uv1]ǦF㕃z9c(pjWsM̿/fHU:rj`czfƵ9ya|sK zq\?tQo!#V3{cgc{hs+ls>Ls!^cʁ5i'/, )6ۻ83_l = }(lw`!O}agB=kvy4 py#o+0G.*; hȥ6QcNɁҾY OP78ԦغS9Ї3ti-0{˹ vل]bOmi/ԁ{uټS{dm雤 Qs5.ۡ9.fTM1W&ζ :_)g~UcO|d~Q>W a%00Y=հgOkZA1._jv_*б=Ըd[7Ƶv汽Up8ufٞ=v*tryw9ؚི>sZ=)[飭19=k2Ė@/O[6pgܶtu6a}ӂ8b.TԬZ6=7M 7$s5kOPڞ0AaonUTj-۲X<ΐLh;[>R wx^Xl5KcHu(*ݏCиFw(8du1`DT6!D7ԩ6-qc"YibB!3Kp;˹yw_쏓{9y~Rs?FYN{Q1no ëL'0n{p~+zNo nGB:G.>;Qt飦ԁ=z9}䫮3{ #= 64GU![%ϔ=pO+w=6<_,W̋~1۬8u{ z~ﵘs.^ȝc }.O1%l<_qLm5Q~t8=V4~`ߵ1>&4s=wPI#DO󮽰Vn]uG>cc'h>4G{\}/j~vn_NG޿>N80myCFz-Q3>aw7x='o}홹<=⬡\s~wPGXWkg\Mh_{Χf^ROA흳kM ~~8ޓs@wZZw4`ls5WCv^ԦatտI{``r SXǤI!:o)qU[jkO3.7TCs5}~Wzw9oڪ ;$6zz;.u>Bs)_{a>4ξpbwڇ?>m͐=ܿҟОuOm·Xp۔iهyoh{'. Ç;9>պR-)$sF2䴎TXwO|E-z0}/]Gi _/T:&;mv.Ww{((hL>[+קoX_%s.rskjSG?C䁏|]4cKGR5sFk;?sox^ZlW1R'CP14FUw`E6P2lpTMkMXj%º.ٰSB42ǘSt,Ͳ4 ,[ ]߹z8~>s>?-{o/;6p~gʵs~n 7gƧ6s-?Z'u.?߹wac`.s+:j;\G7yQ(V-'֏ݺ.Ө?,kCc/ꡖёPа램jgg5] {gׅ+_َ-1u7!9{By7Ta5vν+5N`-ʭ]Efjyvsjc6p1=엟Nn95튯Ťj`斀w250ki~7/iǏP h3zJ?5Rz >lO>9UmXb[?yǙ<;sa|o3l9]{)6/]’n6Wm) ͡qZ1S|>֥s*~'RKU{o8?>V ?+™᎑%j`J1w}rqXQco,Z Ok8{E+t4cS5êxYa&&s]xSὸ/Yr0#jb;r:EùUm'ܧlwsk{<"|.TO6,}sVx+m<m 3@xuyShޖaU~jh-jܣ }*x g|K-{+p.sTYGmOEvQzEO84/m ~MRqZiGxó|#u9<[RY۲*`l=mؿΆlO{1Õ1cTAM߻C}mTӶ 󑋞~0_؟ߟ덾X!2y^2g<3tGZw"5^j-)8)-0Wܕ]1s[}^g WH | |l YC"BO;3:ZT=)hO){5jk<=NW{^SQ*>9Z=g{[ n_7}7,?qYgyXǿ~נɩE^όeމ1{|V)xU7x凌!v.)=׹ }qrv5?Uc9#ơ_=\Z_sGؿxܝO{w3rSx,矜-zjksN]WU\~ϙ&jXKm5AmܻЊN{Z)}5[;)m¯>KK5C5y=ޛ xgC]}A{K?ssnGX/Z&ms]/npjLɩ36g ؜>Ĥ=ֵLh\U?u{.?k>q͢U1wKjawL?l89c~_Lbآ3V#m8sgzBuu-QKJt d"bH(C,#9ߔx8>cuY>FoOz`?k`{m:GߡIw{gMYjۋ폰o#vﶞ4. ֹZC>y@mClVi&]p/ogϜ4佀#N$D~ذV{G(;Tx~ cNƐ5x;7_cX:U5vp7 hz`ZMwd5ÎO96biڹC:rJoy"9yD6EWhS ;gw1Ztz<>iG~[@mlxoyckϟY)Mxs}^=^Wg~VM<կ<1Vԡ>˧V@.p#nsk_ME{&8,ahma_}3iosu{Ҙؙ:ȃwdU5G9ycO{\ퟳ> byTzvz3NBgwfasý-ܙU%g2=C]]NO 3cAVؐKchY;mOm)РbmCw!^+ƧJ4kkZ''ffgF?XΚ[96Z y IM9z |f}{gl:c5 ]~'+yʃ grWnκX?#r&΄j1~µy5h<מ^X0$qOQ/($f]ܱ:nsZЎ`N3G޴&9yڕY-6#/m+۹3VϵxX<-=T[}ȑ>9k}7z/[&3y_Hۈmܧ0hǴ[Mei{ewҋ\Æc6i񌽵1q9cNkZjcG} wl ~1VPG]ջ^ZOsFC{pv<5<8䱺rVM@X;u{㘉WjXor>֯q!zV us,g{܈3ke_3SLqs-WÌ {Vߙ7Y3{̓<:m/_\w8^<';u.͍s\m|~x?a$K6_v^ȫ6jbyNZl-mokag~cC^ÆgΈ?[]Ρ5G<xsWSĬ/;z`b=8miy=Xz> T !Kx^YlWiH& 0`YÊ"lq2AǬ &cCnB,V0CB8ʯl=|-'y6SêUMpʁ jUWg-wK|8wuOtUxTeylAW.Mmq8vyz9y-eS׾9ڨvvmʣڵ>5r6hlc\oo~2"X\զaH$gͣrTfQSw߉ \Cx:Cߏ5{(Ӆy=A߆1?:`c`\ˀ g\bN?2a'l46u0~[S3|ό%`C1WyuOn\ԄkW<^\x2pʽ)mK@/[g伾: e]Qߞ|;k?цQcڲg*}+hs;SoO)3gn[`5V_I@[.v}u⌨ }7sK[+RL̟v=WGg\?Q:'\kcsy3Gs|Y׷1栿cV^߼Wo{w9 jބޅ#|yZ&1gXݻm7Mt:}OcثN}Γ\ɕچㇴߟjzyZ[fS-_39uK.,xrzx'uZre`C7ߺ+Ʌ_~KyIc߿|3i\Ӄ`Jgye~w_qhOoqOsw,k \.YSCgsژy=g鳆8Bݛs[Ig?lʋ9ZOwΙ4nYsrc[-?8MPym6Ԭ6_s(}{@OX9Z6UcqbvujXἨ+zGjp?E8.7iJoD^iF>#{eI(r}39;X gAMW XwwzTy~Ggj/{ Iߝ+j?]goQMmԧs\7{OXC>yxNFzXuǹ+'r{=;gyqXO~qEshw }KNWOA'''\{܌SAׂVo\7+\O}Ɣ oRwጕy<X]_s[gp-Ikvvc}k #c1 8ݦ:XO|RdM̴cG^y~+pabGw>'{ tX֤W] wc=s.wKP\Oښykx0婗kqQtmcKץwW޽#ӌlbX6uqjm_Y9츮8ID!4lU=Dr٘wݦ3E[];^-qӇژexpĞ9KWgg|5&S]ڳI>-q 6T:XqKKt"g*gJxFc3d_ojCkkF#DWU߉jĜgqcJjʯ;g. >cl_s)'|~iv9KrJ{4!{*9!9~@OeS窦l?8s#z>We8饔\-7|)sJ;.qƾ|FN}۳=[:Pݸm%f|4s"w[OtK <鵄 ;:Fc3wN}31 5Npp邶:~QcC|/:\UStd?c )]r䠦j=aw]qnv!ݹK=ƲԫQ>W q}szem+~5s/x/>J[oIV<1.X,6cx[/!ӌ}bڿW?}a=2%qwƅ Yp ĆoIpl n9U<~KuIufl.%k;>#2vrU}q;ؕ{Y~/@qʋq<{V8{P-XqK׽#k'54L_9^ߩ{]zfoG&ݜ 7yCuޛ!Fo1ᏽwMw!]3߱ĽtٝSq@oXlpǜx 59|>bˮ~_w_ggަᘯ=%H'޿{4t'_X1qRw\~ELӕtgΝXyOd2x鍊-1@=y{scz$)s"ɏgDhƒPS7Â)4S[]>uv;QWM8U+o!=i|#'ٝiqʜ< Oo~&{7ˍs t~Jwyc?pxSgrb,ڹoyz+38L47*[nrGb͈ͻʹ%8w3=J3{dnΜ[Q<^|[ۏ.97?cδlwS>շoB3៮oO_{oe^%&`8+)K{g\'Yj?xS]r7)Yq,mk*:إSyG73meީ"7p;oݫm:r5+;Q޷ǖ@~=rg۫w߲Gow-[sK|sb|G~?k FoRN _{ܧ4Wc+:03Iv|''cbcxoTq]]ɇ<6x?ܜb˧bcOUL):*yv6x N9 ?Evv{u򼾣Xngrukmnb#\W|کCIg_jvɛxMڳa&|okLI.}g;u.E))NvNu틞+g%]oMF.?5;yƜof/[}Џ:~N'r,Ĝͻ7=/=ި)=UoשO4|.p=;.w84U;:?zgUM /v.w=~eEXÉq[g|4F~np߻;rW6f*).4ךٛ+~#Ώ]74kx=NX.p=ī }PߑuuO^OOx166M^\9a״ȁ5UqCKW]D~[Pw{{~g_?̒-o}՞w~ +6,'yCo5n^nx'ɧ@4,1zCO{ ڪZ Πuw8w2j{ E;+:W85mbl2ɬ=WqypGM%z9=Q[\-z`F9޿ur]OnÞ1o4Gcrx cs.mΪ>լxG3R3wtteнoБ3AQپlD}?pkRj^:lL|l \[QWMߡ{Sewnņu8L=7>95߾7j1_}|^ʝx'kݛ4 ^Zkk7B7v}†{Mރ50[Aݾ7p媞jeл[^o?<B[^lJ@\ţ)X{ g mSGQ巿lT{ uwW M%}@Ƭip=o.]Tz^39BsP?rve`7ݲ1r)7~P]S*^pP<[hSOͦ :@OyڌuNRıgOH=;fR}<:z?]!-C9{Kzl~:}^xǬ#X /̹CaG¿P}a״NY{a||7|g;fgS 18D>yCƾQ9v爀 ́lG@C{q7ɓ9>53j81 ,(4WnONn,N{AwaFYwNTPN\mE]wᩑ]z8&7AЃ}'+,Oq ;ZiyqLo-ށ}71w_N>dCW}m6by%O90x^Y}l&Z anqnقEfs VN=UHS+bRJe `c6BS(FCB AeS)f97}y <8½нҾg/ {WguOKwsmڧ7p2_y.ln9Cs ʡ~e/%=(O6Cv>wb?Έz+̴_--͌:n:m!'b&vA\RKWIy6gny&#khy =${JUG:S<\1>S\<ѝ}84(^ u;}(΂~|*\_>?}D7s%Om cݏ1ߊmgt{~ỰoiBK߃~9&fѷWbIåt+\vob=7+wٚ t5S <ې {u{EU}zhO{tgU9?sՎ=9umEk)SWKic:>U;|sպ~KP{vO#cQOny!qO6Iq:$>Õ;,s;gX/;AGG ݛE G-#wų.vp:g-x6\)#ً|3 N}˲ 8YtjoZtOwY}zwnY9Φv[sS<w3p^ZN|ޛEx?kKwgCUQ=OV@+|)@<#!Mͱǃ?~onݮ8_Ys]On:~v+Ʃ]R:PoH9KH;orag/E_h.zZvi;Ex{Pֹ0fc@- {>xo|ǛJ=xx/%|^VΕ1uc|/ >މ )~R{`{˿+QN6$7ΌvިuS1B#GBޓ\垴/?߹6W=N쇒{dZzy[naNix'eD\O?{/:ϙts kqb/=^sqM߳QY-z-{Nhm.LgcKз4~-@zQXxѳz. yڞ6ur~=Oe='r5Gy}'ag_j𰇏߻;{SKXMu0'g|ݡ[ CnguV^|>)53\wxnPl(/էޙzLoKmΫ@oIx^ZUIdb,iIi"sZZ(Kbq5mtM3I"Lm~L !Zl- H w{~ysU?UWNLZ?zdi9L\򉖑D?<95S`o* 5|s>>TsR5>ќ&z2zZӾtRw8,'0~S^:{oI놜lM*оy]1%pf 640#ޕO }{u]Δ7p_Bk鳹D]k/뢖s;5OsS1T(gݨ;|GY\ѽhߩكf+XoRX 7_}fXoK܋";h3>5<Ŷ(=)3u.RwM/~h4Џ>k?.Z5,*-w݁_TSOByqWw2 kUuۿ?Di !9SԽSow9+ ql7w ~ҷ?c6sxOE==7|Q[^&w=S3ug9?Y9TgKE~U-2㜔إ}xGUmzZн<k;AάYwdc; 1ڬ8w0A@[y^S0>O4;yw}Y85nȌB=R=j|u?mvWКzKt/46yԷѷ^1 N]U9c~s:W}xq|ʅ}zSwξ۳^lnTFqw\uV =cWVV ;gO [^65}_ϫŠׂH/?\y-nݮ\skj|zMZ?}tG]2/~fQrw h:=xG$|΀7)¹y|O}shvp+WψȫSr~`;lc/ƻz '^?7kj>,1 O=19Υ>߉jxyc_̨Z+_q׾|&=bϓ#{U';sz>tO^sG- Xj "~֭{r/싳T/g͹:w;Vr]'z+وizZ߂rڪ]1=׫< l<Æ"9}_)mFR>cSfW?]0tu)z>Ypb:;)xs;$'Y=s,<ӄnx^UyU%DDtl,Q+Dmas=wEt\t\1EED,d5rIc3i)-hѠ"$DdJ,-i+r:iڽ4+FXLjql؊}'.Hsycb#bZ]tbh|G7#>i%%fCљIöoX*zjxK? R~:9hGAK\-okwąfVѼBV\8mbok4jӛ~q?Fw35ںc~oXҼ);kޫw}Wr6lwLrʏZ~K:7u+9-^}>l+(IlRbژ[h/s_|; u zٵV~z͌)~}1~~6]WwK;=&8p'||ǾCŹffuɺ!1<0s𖹁on q?]O޿mMO>ᬭ=/Ӈ3أ33Nlųz N<{|,vf[Yl ; w& %C-j뎉>uoؼnU܋iC]&rCi>9&q[Z51Ԋx9^&lt66'V{ 1|K˖;#Cjq@MQ^*GMl1?JG5c~k>!8ǸP$~,B}ޘYķiL1-wqrNƓޘ7*$굎6j&zSkUb}z4wނxv?m?|6sC~h]R{n:h;G}ۤbھEa*n6 MZ,lwy-ѯICWb#WQ\̓C_gգ;s?kcyw +nlm P9 fJCgmmQ7(5]z:k;#5SlboV46p6R>Ol 1?:Υhr8p_ P5:O޹9]Fwaa}q6 oʡ!ϞM5zy/^Vk-w ڜclMا߿b9vF}xS7RjCfVvn;-=ԹLyyd_ }ZkXǹy9Bu>m^YZ.7CʅvLtYi}SRwp+}گ\ԡm0o3em0wDߕ+|攛5k)]j l|ixʓ]+W! ;zu;\Ы}Z洹'|0CN9Y\tjK߂IJ _zzC/3]=9bC#:˿hG}д5ö>Ly1wЙq'XCk&ޅQ96QG[}>uϰ+5w'ћYO;7Ҹ8_olu]cc1[w{ֺ& 鷪rI8.xA,/ bꋑIkMW}hl/EYs3o·`oIZڶa0vWwyzk;~ǜ,-vSkᴎ2}vm\:q/)~oy|1ڬܺ?,>VQ9iq\9_Y6/Ϊ{mx^X[lTUB|@@AF <2R!U1hS D)Oc1Tl!)FTP"L4Q$| 5:e]s$s{̤&I~.(tO^g?Ǘwcex_iY160kczj[~~|ek6||gM nqGst&9?kuOz}_8sQ흖*ڝ}pթs>5oz<, k9]ayc<_>5"ң8`Lk}Qz\L,頏8B3yX>)gEu3dԔ,k9*/Вއ{O:7$Q;i.xFV'!>!#gs7rH}͏&7Uz-}Uxj,(5GG.& gI,u+ԁYkꅰʩfqVgܑY~;L;cS=Zw⍕nW[Z<>}/!mؙSIS@JG:}6/ 'ֽt[Sw߯PW[պ }.0X_̥O:&xl74\bCXQuCsh5ɷu˖|cgjX}]{kSb8yXekɧ{kS_]:7£ڀC?7M^oUm௪ԙN߼؝Gчs6x۳߽MSU~-n}iNkݟa|v],|u/͎Ԩfi C?sy&oۣqծ>}hr;b>̒sZߚ؇4K]*7:t·{IqfL0Mq }i?Úgy 1_>.-.tZ~e[;oVgk*7o2~+þW)M quE΅EvG{5&7͋bh}t< _/:WܒQaNhy4"m=i}5m=Vv-p# gΓF؟bmgk1ygW їZfmk`O-Qٝ;nh<[.u$=95օ<; [mI≯Wva[ a.CuɅZ4;k~t^PCivbAsv#8ˣ1ӪC{_~qþLsdYwuxFg%Έ&7X 1աƲ`cmlg.`պk;;eq<+qX]-<gq c\|~j~KM{!񨅕aW~)~~E,9~C!Scsǻo|5BXL7лU~S} W^8 ;e F/|ݒC9,m+fuZx{~w"k;s:o/Կu,G΃#,Gw gWS~bU5≓O+)_U~k եyE驑>yoWWcmqcأ}UՏpg$<,M%x/[޿+W~Ϻ͉Yq^ړZƜZU_-/ds1s/w9:>cli[ RC >a͛G11G849o|T/z =٘r})'VKv;/5\v0gcwۡCK<.V{Y{բZC-Mx\ n/8GW _Yfk85uFF>E0ewrG58k{dZ (+|1Áx^WyUDlQQ0̾GԈmfmXdS`1ڠDES-&"!2HHYEyy~O8w=sL_WEO9<|♓s#,ښ2>b}{TIHu1k+_5%aGLuq"9<=y'T9Z`O{3<wSj\5&;pOؼrr1ۏ={m}a2*W عq㶎OL˿ +w ]n.9.ÇOM}ܩڊ٭ƴOWz\ЃOyuegxo`c6߃ruV#}!Ӹ0j@YOᤞ'[}. =^yRԧ~rs|um- ꩖r<!sjqWX^sE`gW[ְ|Yg}TGCpC4չ:NjGۓkW`g6IAi/l:GyޮS;zԇr-÷ɢalWVXhqNo-:y-i.hj[D}|<0zǞ][Ym_iw3o=7nYNTO5-?˃=~C`<́7g[ V`}-ټr'- _967+F9;<.J.{yTC5y{SP̘~4LjK'N** WiؽjbmN:3T[Co\g{>=mt4^vo_[V{ = yAtm\x h={J=Џ{{eCF?/wͳ|ښj!s?=Fȩv3 PGu>jioy>E6^Y^9Es#o;>X_S߄讀 .Y0Za| 5'`& omKT[{ڹ}q[^_XzWyj9apզ@l=й[s+6x]@Ys.7{~j;/8j^X:VbO=X lwkS]ky iqwE}ë ~MmK[i?V;ٻc;ߏI/CYMo5+NՁ;eew ;3Z+1f=A8wӫm^?\ǧ9x仿06ic7Y-ۃbܭ큰#?rSr-45Wc:ة{6}Q|<>qΉ%uGxm˹"8Ol})p}W޺;/~&ږ/͵,XK葳Qx^W{lgEBHm0PtH)u|P.ٸl\Bͥ c#8"Abq1 6 !dcl˶CRb0[uKz-~~48|niKc<5[벼>`O>rk|.UE ^UAO^Qy-`s/on)5OO=fjż'cs`z#@ݼOWG??t[N?dum(mZK}/d3h֤j=vs}:GjNQO^{:{y[w)Cצ7Ŵ 4'A.w[+;u)hL8wx8_~-ڪCRuEϼ0), e?b_ͧAys;4sS2O1yg053s{{ѯs+ffx9ˁƇs3rh~Εg~Ok8'N|cNXǙa̫(x:~ oC+ޣP1rh{2$s13dyt4W^3;~0Vֳ`h{P?_>=;^ ,aIrAn9΋YY}wtswk`]v^pOo+w[Y{:/9. #yԯ>wM(~HJ_[1':>٘Tl3Or{FE+r_Y+cܹX{>^O^RT.|Wߚs@.~d9]]9*rɟy]Υq+Ǿ;}-ĉ;i /0S}~(az_e/oj@ c8kUҨ 7оyɩ9WT5Yޞ=%R|)yXyD'y=^_V5虵sV!??19ȋy!>Ǹߏߦϣ|[k׸dfyNi˺ݺeKCzYtJE^f=Y.jA?A8YQ޿2ʓ{=|8tS΢GӘC^3hly xq9Խ"p5Uo{o Цe}cU߈-{qʁy^6O߰.f_mPwѽǟK-n iW3c;$vh=ښG|g_lH0{Fs~jqKkǹWMT,xgv |ވ3r|Ostr1'uw׸r:/owwNyo㛄::asW >ܩo=5uǹG. w[w1t 6p/Cl>euGE{0׫<+ѫ(ޗ~ހRgotFv^G9&-S7w۱hv|9)΁[q9E ]:Fdy}m>Ӏ[bW|7u6ֆgo}O'(kݗ_*c罨s}=/iV]:c|{ĄB>ؔucͺw<&Kq_}>Sǵiq.v.HSpސ̼Pm^ɛ(s-A^+o:K{\Ȣȡs[.z&w{(ɝ*ث|tʳF@;b[P7vX y.}-r~],h.CwN|sӚ~bͮlGA{Я3.>ZK}Qv-O8}V} ?$x^W{lMF!1 a &1 x!:-q 2 ʴ3QBD!1+qy@+*,XB$A]gW-:?n;΍僧VC/ucOƁ37o\23$b|oT SRG1|+^{cA{ƽKprkwmy4И:+q/ _8CyG=j" 90_ JEgc\8+~踃_@S̴G9SVbʇ9Scٰ{RjOw>=1ʝ]-q~["v g?'W>H>rcc)S?E>&<`01`&3(u~b}Zcb?R:5C9k̤;cE`OĔ{: sۡFmqWsΠzށ<'PS[{s`䶒-ՙΩ3oGj~B׍ETj xiCXʶx_S椀\=W{AbF~?0ao{fI{^`qm 8PHu]Ƶ ao}j %}Rv[*:k8K~SuHMoIk4_u=O9.\x+5纯\R~53Xk^oiM Q?uns;ya辩:|5C~siWq86O/hyc恕%|{QG4l븼d^| }m׆SͥJ3wކ0= 6dcn-PU1q~#2cǖ7qv"ƷĢ.čeϥ}Gz~SWVjr/8֣ۺ8*&G=`}ȋycxZ}hߌop'pK=bؓ`o=WW{: F- jVy}n>(8OWmUc=!uO䇖Q8we' {mNwB=:ηqnfH?I >jt~X׳j=z]^{!զ<{Q~msxJ[C g 8l 593|3حSN"ﴷ*8ogJ<5TyS>G߈eg3aŐ >ρwﵵ:\9 sC#}!n94oмZ[fmZ˵&6kAMa%E1[,F-;?Ɣ> P3M:quy"=T;W-u!r[}=۾WbI!c-ҿ0l)rﰆ ]X/(Ӵĩ.m\\X:y͘ũZ kK>^ƞM!g|[j;JquE,V 33rz֬qfzPC8[sviokּj.}'v~:k_̈́߈}]Zv=sj05){ Xߵ!,4?Ba&uUWȜ3>ω9r)xu,/UD5Դwfu'O[cgCҵ1~5دAęw>Yܤ}3r4Μ{j+|n,{95|6{ u!e W۟>|}f 梁nҾkF,թџ|~B/]Ɓ^7a%i=sX+_uCAGcgU>w85wXmC0gv|1]լ.}p{3[ gHq}0oJqtO=Y~O,54F]}xԆz%;5,W!AZ=Ո/GCGur[{7圾}`svk=Wk9>6vJaoMsX s"~NoC\m #g{׹PSl6n o˭knշk9r2uh0/Óyg !a/V66MU>hIL] {2'ևre|ٽC+w'9pnMqh~g5N1SǾF]Na7Z?yy`G}P?&eOۜΡ^[c3;}/,;lgl{4`O.cK,i^3wrAtXxyX8T5^+|BvأwD~ŧŖNRy :y&O'e,9\Vˈw`νh-a ʍy8qhsx`'bPޱ0Ҽ+;jzǞ-\W}Mwv5C}rF_:X6L*W^-Ft),f?.Gn(S><\rzjx.׃GR\ez9+7f>5xݚu(ʬ1Dv #ۢu5wZve]BMi6}'ݡ?pߍ<ΡwSe+WN徙 *~h/_}1zO= @6c{2nصOϕxo@Co>3W_IPפy8GY|R)t5a:CRk/crXvbqa3 zΘOFk}ԯ~/G_e(P׉y_y?ke>azĭ-jV4wq;u@OZهﭬֲ;ѻ' ӛs(1%5Wc|\7d͇u[֪o3}}ܛvΟY#+"Ɂ ݮ]}w/VlO)Ʀ\>+fT~o9P>l.TꜗEOW^ցsb 52>Щc-jzU(y22΃}{>Gǝ}vͣwB{d_j ׯwstq]W]9mg֨8>6WҼ=NO]yȅSKO=:rCfגx͜ӟA<<|8=wu?8 qyf/ o'O/ 8CƦѷ1!: C^Poκ9tZYt>ʗ;s c~Y5lf{)~|^3~;ݏTk vC?+QOGzߩ~s<뙻=2v^<0{O{^̞NgmAk7!ޗS+wtmX;]Eÿd>b><1Vs6΋z(|r|ʩfF;2syunǖq '~}Uȅݪve#'S<0YzBGhyx>[Rݡ~-t><|2ֿ Dfۀ q)z@Kƒ[ mwO4/<sZXǎW`ȣzΊv9Xwg=ƾ3/tdim^'e5󦐲~XҘ^n>s7Ğ93ud=ޣXpt}&}`NyxNwtvoSҡ<Ήqj#w`裾2r⺶( f϶Kڽ;(qn֢7̳Vǥ9N1 eVRR8ٺ3k'/lZm܅?&DTY>@'0cFcP}-C{WUK/u ?xyw۹vx^Xmhef:j&e!ke5tVfc6JŐTĐ)Df%(*H"$"~iby89Wquy}h{;辣8%rSu-}>s#'1iW]2}稖&p{ǯb66.Ivޗp.D 0FMI׏jj?Q+wQM`M|?s|سXKkz?j'3{+SYyuٰ݈&qc00gEΔbZ//wVMÿXژfv ߽g~Q$XU߇~upU<"Ӹv' o>j]&b!+;/|6Gy'}OSycos#u4gjG}ث5obss꺖3ڈ_#>1:(49~=ܭj-Z|wޢy 347h[;x?^ -jjjҁ?Yfgزs e6+hIE:~^:c#eW+r^S9ڃS4j'5ܨW9\ :W_|AѡNI8w):;IQ1҂fodýy9W%O lܰ*?=UPz,P >^f/S؟kFG' =zo!}o徍Z]2=u6z'OϪƷO/uT>P?f`MugZG΁]s >8:Ӥ]g3qe3L}]s|V~%7wn8oEwoukY !O\>jqu7;+OG6t'_=[@ߌw}<ϸ{$55#b) jyGvf\erw?;L~Tw >>wmJ?]S/xq/i_G>7мm@.%ڣ{B{wY5YV|Bg{"Gu}`5t Ƈށ?Eʗ&=u9/žuSV1嫞׍tWb8O|y~|q])ϵ=@?}8Zޓo[ogy=;Jx6]mk nMo}̉x-Ӳo檶͇wSܷxn|V^x'G:9?XCd9|1Vf~=WWuНVz=}[^Vr]ѷ tv.`{z8gx}8_y%y pv໶׈P54ԧ8'kwrn|؋n}x-B>Av{` Z<ӯx^X}hYbZYh,kYYsZ9W4ew+0,cK2[Y !""ezcHH Z&}}yy>swNy.(~i<~ع}ec!7ܟg숷qir>%کNzoO<wW{J_QWny>wMNU>zПwNɅ:9ծ6Ko\\?S#kuna~8[-Jn7Ƨ;(Jŝ?ܖ9= sQ2ovi:4Vc{ˁ3tC}Wrؙ)0_n4YpqnVsFχ'tɹކ38Ε{8W5|ʡ<<ڵ6+V}/U@.cagæzl=gנM5\ Z~ՠڧoIް{vriM 0a?ggBqg';ms>Mj;aW"ƧiЗ͎ͻ;]9ؽm^0G'rxg+:d]x[|'V1~G{ӺǾSըyobMŀ<~Oi5F?%1sBqOXvwjLc ~ ظ%_E_|ҟ1)y1Xw\nV6`2!6Kg3 PM>ic\X-Ȇ~ w=Z=ϼ>Xܗy1>}azt^,x7^|P_K>̤Aq?a3aC\ںwLՈA^"C{lC][~巵[?6rbC= X.{=1gܱsɅ3>ki iFD]z͘b{~pbW[W: ۚ >sejSCy4kMsYE 6?O폂}aBبVja@P>GjZ 7mM ʥgecȭ3֒[<[)OUr1j^Kr6wA[Un6Wݍ;|Uw2W^~ػ}fݳ7;;uN{}_9fê}Ԥf+ƥAϾȣs]kb_~lӮ .ί{X6㚖˽rYɅk(<4YEKB#\io[걶rhojw5Os.Em:k\8"]WY_բ?棍~ѽ /9[j;5tkmwojWnFwt[ .}j&[Ce*qj;ҁ]s~n}-5ɉ͉߰Yh/5Z=.J[-h- CQm6泖ƒщuUڵco9|ް{{j禼] n=^ͺܿ?.`Y6;71swm FLXsݧ-f k[m%W6e&`6W{"7h_~YGNah"?0a\+Ʃ5 3Q.ߖǯ9\?kUr[TwinjF&1xK{W8o=m)vX?ۑy+3=#g| ~pul;0+kjk+0'ѹP>c4YгNl{!̽rNԗ.уֵyi\8s>~5<t.PԩVOkC: }_s~Y\ X|Ex^Y{he"Q!Vd5QjYv!ˬL-P[eü%)fc"I+ 2bt "1$*|;><1}K:b z8m/p}S`}_5:4)3H[)U[rnf_4uHfp#m}68x}9 ƫ{ߒ=y}zק/姖Xߵ'rך){K`zGSC{AcOtB̍K=On0,|h0K׾nkyͲH߾r[bmnnw='+g{9K#,ۭx s ;K/ Nfz*?_f_J_OiZ[OS.#on[~1ա5gLkh.}=znOyuO=뼩>?]1wߓ{BqA׵vM{!(_:cmzvW|+ww>ω;Kv&'|8 >p/53<m]s5).4OQz-Tx>="7eR6'{z}KI/КݺF*'<v]|Ǯ<XC;R~|>RownDU{~{6L,m'Oܧ<;4ك?: ktrXLj$O^jz_)Rsv~wߗߕ;ֺ||zZ^m社?ws5U;8/8PlC/d:]m`im5Yﯚ3sWf6O3h{LqR:ޫ擧}6Tg3C{U{a-F1ɧ6rh|Zљ}Q[{r|#hh\^^ǁ5oYywuEZ>/fW/sb k7ߘ%UGk2b{^g=>/{hK-c-zE|}{LrG[}nWvk5dZyPN^.)x_5tfFZg+,򵦖{Y>w*s9'W Ԙ߷u=D 6OWl4x9pe,W) 02@Ky~G[]`dܔޜP{)wuԉ[k;9rch>)߈Bwl;ΆQOcoõ@ 3uﯜSW<4qq^|G}%пφWLe`j9ߐu*7GSsa7]siPx}jV^u ̹ҭ=Q1mp׹^8ށ>WN/:9Wjԝ!4gULuջ=6yڜq-q}j0 M:;tR>ڟC{G~?'{G.gWڲ]_߃3 \wD[a{7#bjx6.rm{kΠswG.k]6='{.bs4qrYzbJ/{5;RNfGǞ)t=wn'>[nV<*`MS 5wXQKv=F-4!qf8eq:w>9`ܲ2W5<} k(I="֮z:!Νk]稦A5uƔ5WO|ޏ=h-﷫NN|Zͽs35R]m|֧[XFЯ}._?OUK 9'0a.5=K#Byx^Y{hU %)NY2;Kc M3[j*kxK.R1Y2DL45R!"C6R9|{^}jXĽ̇ wBWTu}h{xr7 v\D\Z>ɡ6rTW#`c 3^uCbj~ u׵-ꋱ'}8oKeI#mQ F|/Is4cLv \}9TSG4'G̔Zb'|EUbaoó 1q6N2ۓ>gδxc] y?r>by=h^w -{q827oݬXoʅ7?Ź# rܭac8A$Ve8g-rGt`Į>WQZ%68րNۋ hX=Sgٯ h66p2}qٙ2?vfCR:c{Zn^W _Y-΄`|uѦ@,rOk`;KdVYA6_#6+w5cm{ΐZ= :~g.Rt5\t/56okoc>}|} Է5 x2snxz[e} #"t7y,_1^bG1FNs^8#q_:U~kަjX{(b/Eߚ㥱\?B9v^k#r`m9tЮ=C\Q MkJf쬍f^[u[r?Fհ}O1c]Qɱdljsh?YuI;=-IAL8ߩ^w|my>ߩA?]K"zĢھBvRͱAj<8GHចOk3k kvAbOTKI޽|Dhߋ!8'0_~TĨ0^]m>_wwAkG>9㜈w;b.Kc})[ե{|IGݍu>uwͪn{! M:?L&IlqvZں4qCW$IhT;)8H͏x=ƪ_{ up)C3urhnESZSuĽާt؍5_kqPr[?·ۯvt :g:;Kܡ"7o<¿ ̫=GUMߵu/n%4u<=Mu=glUV\ս|ZW4E?_Bۻ_v<ݕlCڧI9iCvC`yѳOv26u7圔_DC:WO ?}fJyozh ra^bdN/CYcl.O6X8'ɵnVݴ#6{f }=iN+/allCwߓ>| XoK&r#7r-zqЛ7~ʵ,yA⬀ qkG3;KSFvAęy ۺ3[Ntͥ$?6=Yc> 2O`ud(r¦{l:'G3u8 K bQw޳ ԩ3b\yˊX6A3=5{oYl9ipؚvǶuEU&1zz3o/A<=)j;Uæx jՏ>sbCDas!;ͣNP=eg;,we}6&bwzʅw~[R윬+e)9=[{HMH-Hsϴ4ѨȦ39W87F>rRYj}}j_Z䴳pw⎦tvC:S^rpEcb{q*7$`>ܽ0il|; s>%} ߨ!ʯ ~-vJ^Vx?Bw)^Rp%_(^bdy0F1x^Z}hU1 ck,9x2CK1])c+E%(Ρb> LT$dl0f)Q""2BCSL]y}8 KZ}BwM;OgگKΪ}q_ikK?yej_A\~E`k!@K]~>:\v?qxsOEEQQ17쏼Xcl}lNõSq[6F=qMΠ59΋P/U~K31;\ hNUAmk֢>g"9o{f~M{*6ƿ-շ֟;+۾۱}Ì7At;y6yPU.g'o3P=??=v)~7UO'ͳǩڶ쪺^\=C\6+'Y7[gֻc N-EW6?U'^` ;qtAЯ`5shOkYMd}̕l<ơ\bhjQy=N4|M"8=_>ϻS1F(TՕ: :GnHOн0awC1G5d<k!gz];^F[ t#>l삋z&[F-3|l1o`2O R`?b91bBߎ ~n/ DQx}㋞o!O&|<ġg5x2v1 ꧏ EmSNfyֆնڟн|gsWU▫|jO⡱P>[9V;;jJG܈p'K}Wt4Mt)4ׯ`hBa~vXG?hQڶ>|%%tj9KKp w%T#jr\sq']}MSx[|iR~[`mFoFyUr^˦7_v;oq{bhMj-Ocv4]=&|'o8x]fo%'UW{s6Z|wʵZW~hB~ڨmhWmm/ro'hoQ{|vg@ʂGښ<]smBS߂ƭ=G9mΤ]B}jKtmX~?,}aoݼ~?}ɮ'VokޗDhNvV1+}6goEBLV/ Y771{Z3tmjg;Z#Ь6Vrn=Kby1ȧf,fj'@媏~Olj= N9[= j>|c^K]cKy#E "]|CCF,\s|Mݭ|ܕw+~Ϡ󐍉[P 5ٳ}|?lO솹Ա_ߑK<;-r}#y{zzqeЮ7S'}+Z?wg5p[?fƛ a|-^5;&?4+~.v?Kvx^WkheGHX2M92U>}Z`{prjӝ1oCoYJkr]Nv'K#0/sj_3z}Okor`,PuX.}3[ ._ZTTcoR3zbwkp?='*ۋ.5!saG^VO+?CzcƬz 89-gx~tQj7Ztȷq]>9-;X/e{WXs; 햹Y^  ۋf9Я½vM߈E;YXON2>.;.\3{fvUOxS_T?uEĚ"lw7hKvȲ QGM>?A vM"> ̻Ig`yjcE=sC9ض|jͳէzX߰ r\=ԃZgih=f,N-/m,h_;C57T]Xp؉"Wٸ9 iOcͷzo=Wsa=V/6| <%Nsi?9lO~!ok*Jh~ts8c >5lx6j|;w:Y[Jjo/&E{L:o(~?p.mzV/x[=7U,s.Mww{?X=G+m9yfqG0W}T35ׯmW{!~},1n4k-O MMYGй}ۊا>{{Q}m"׾gdLy,C>xnF`];sjl𶬣<A얇u<2_<6|=+x.rvzyV gkRC}y鷵Z~k- ^:𻀵x?fվz4zfws^,Y{VW-sk?]Oyr)h.lh:j#32?̇{C0{gt5ݴ5{l[3Ƹ}#}~q o}l-~F !S}#o6ݯ_s=i_ozi/Dϥ}7Skѓ=.[Ii~YoU }|̷<~3o7Y:ۍek[f|ό5Sko{.=8ЕagWϑ}ϔw$9Z}Ԡּ̕t9y/ݤkW:̵_sk, q/Ʋ8ʷg찜jZb'| ĩR} FqY=!؝Pmsxg%O׏w79^z!}@wa#oGV?O@W<G=aw_W; зL>>|O<]92[Rn_XKYeCcZ}!.@aci{k/`>gнl>'/xAr7(+LD]o,[yM!7Z}UZ>wTΨwܑ{$賽m?}IQ7=P_BCM"}[NNu7{8 gֲrhCԄޞc\U1c.9K+xX2f-Ή=3?~@}Y]U__W,w>x'L磍v<(mB_Zu/V#-4Eoݾ `\î`GQ!o:,=דr5==lOX?ux}sKgy:nϞCV Y-Mޙ^sz?&sf^>?ϰSx3P8PϾ?:E;smOy~7bBx^WhI(I?"%t% mɲF[cii ]Wb 52$-1snں1kuY(M*")Ѝ}~;w?ϽsUX{ƅ>0q9z ܺqK?qq4~goοh#&kLxD?nߚ->*W1i̼6V.+pyB;09Ը(&}X8/&19,74gjmоQgUKi>vϬ);ey׾Us'gտ 9e::ӝQ˿ƙ`ţT.:3^C'VM@1[}S;Rsjύ8{ȷjՙfۅMؾX˵ '/2{m|=BOe9^ w>wԺ-×'+ fsp^fwSASC֏rR|7F>ske=>uM.gnڔyVGΧSUU컸rզ=b&0}x\sZyXBSk ئMwurZwݼxV>3j϶ WB(}^>&G}|'.ޟ=tFڝӽ}*P]zrRs( @~0+=҇o6UT).])}\ ~̌"?3phxaauC1ƻa՟'n<\N)}~̑q祸 _^OiQn<ݝ/Pdg=Ѹw-CwkpR_TsVw=@8uxsh;'ׁx$Z}W|#y/{soSo7ד+K`/}uf/UiޚKۅ`ᵨ7P]3*Y֘>o]S|_3ysaWyTܧU6} 꺎kϺN97iVw/Pk.;;ހۀo}wj}Ծ_ coЇo-US};Áf¾ӕ__?U~ 7#_zGsM?UvG yVFuNEJ 4jݯZ-?={';Oͣ1j;HYos9ƨz3XQؗk(yeq]wrqԟ])^gr~Qe\{=#zVb~st? nhwk1[7 ]3 '4qww% Ɯ>j0zjfدu70 UDZ1By;)=R>G-ġhE#w¶MO'o4d]pG˿Oۣ3]߂Z|/<;++.\pt۵oAGA\"_뺎ڎ;ݥ3݁8yji~_y=YzCk:?uxY.zZ4Z=κ^{3޽ٔYR35Mݝ֧M]θ̬g7*v_V|? V?-5=qFWֿg)H@jKyóV.KvFL~۱of:;84S1kke{ ?v`g΋ao[7ܗ;3:WwggN}k%u_[ZE7}Y;9;!ovl7:zNr3u等~FG{Y^2Z)POu\l~^YOw2 G2 5\[}űlGw?jL gLw{+#\vYV[}HR|Îax# "x^YmlWXff%#bP= F:6'7kXX ㄑͰ~R MniH]/00{nˇ'r~[7T=g] g}*bSK_U诋zK5Q>yѯ:䂡k83f|'FuQOmrOs?uٹ]̇_jJcՕ{a/S=&$¯r\*soZR]Ti>Ρw|ߑOIFٴy)_\4?޹w[;s/z6puSK~޲[?[;>֜(K]Q>x1ٵJX 퉽㼞5.ٺ<<ƸJ8f6Ps?^MW㸣a{qgx*~;b0XIAїɫC͡Mc7;l/s.&uSyړ:.38m%po}/ C+!M[`^U#4<\_{kv.[O6d_3qN>]mI󵦞 Im=O#*{{Qn}ӓox:sK׼ݩ|{ֳ~tCt*0΁as74'Oy}Or)?5oD}*Ҿ,7?#o1+3sVD3'/>֢s>O_g.ufcIe;25sWyfҸv??_?wScF]Pq4xSG ĸ/7c:~aCc ׵dUUcg籭1侱5h1H?Cx UodMp \Ov3+bԷ7jQg=d]{K'O\aƝߊ:@3=tA6f]W6?xHuS|̭>PWE[]y\qj`xgU tsѧq۲8lpW臄qW]}~訵w9.k#F1/^CsLQ(!}VOc,OZK 9܃J=ynuTog1;p[}3ve/W[q+?#=_} ]k%;=![ߏ:y#ٴcS;sb_[yc\:-ŵ빽\t7J{)~j)pOy]{{71UsNrp?#r5G紆['d ]y^[Drc V}5\=SFH<ܝr^hcǬֹ4ųs+wattF.[nt8%+Sj7äK'GKŞ3ہu9ͬ=MRGv6?:Β|?Y(kCJRjm匔G^%Toy#٩r5OX~nCW#kӯqV`?⧵^q9u_X7Yu~ڶn}1fNC_vVOr99bo~T[_}B5N-眜-~ҳ~v~s޸;֦T[yufY>G]/A>p|ξ+Ol3Q޽hB%1nH|qU1t#R?6+Zȹy9m1e_kzZԳgW}Ey:摓;`chIh$=i|^a>ӁZ>8:v'O{13W۷ U+phT7k8{c ?0\{LK9:I`oҌUBW<R. GR.^9억ܽ$XqZ#v'6WRz9z6ıom>7ZߦZq@ ݋UֶCމ:Voܻ<[1x<|v)9IXۺaCcncjKcnms{}\;w3ݵduIZ!^5#2!>4흵nlrγsӱZͿx;S8U,,م郾XrBkySV{Y;]uB3 j{}ZM-{;T^sc2$ϊ땫#}qZtƨiʳ3Ͻ hozۃ?ʍ#]kOodhɸ=}(1ߥϽT8#f9Ps_R@;Ow)3ϫwb pzZS9Nk_V#spsF_͕aj0q:+gcwU;gϘb‘3zy51 $/нvG"Z^ܝưS 35u373ZP[0㱼̍ս&7=sfEF>G=#\ÏF5mG.֤&lO6}sqHұ+~շ<;c&.o&t0j/k}=yxX?]V'6?sC=lۚ7&`o#f0]9YSCIʃ?tng斵yka{sa.a=Wʡ>Xhϫk [7<۝]RЌ6p杺Qv=17NW΁;2j۴yJ뷺t禞[Z?,b;1[.r?c"\ΓZة~6jO1o׷-rg㮲iژVwǝh\m, V߭n`?Uw>[mƀšr#=j}~C`^,ZoCowb3t_Po1_+޷ڧ} Cߧwo^=gعf~|7C _^u{uy?Om}ydC,N-Ck3fuSXsYڪ|#7TG礏|طruVxתz j߫$qmq̮Gx^XmlW  نcfMυdc"T`VIFmdSR-) sd@? f: Cb A` ߹='z~/BJʎ@{(7;X%;`R{ 9p^uchksyEܦ0кκybN= >ʳSһfL{<ʑ9qG'^G̮%;kE>*|Yu稧;o +t^#w/vo$?!vG^ x}% ,75=h{"pקJpBlA^R\CD-gK;. r qxtnw-7L0ׁwܔXUv#rވ_9X!ɱ~a<6pw9ǹ )5t݋;9-ߘyClOoYoI}ڻG{VgX2yzg 8pF'b,t̄o@g]] 3n/#[sz8jN~ZgMQxcs߱s)'bO9D|R*s(5|rYW~~橌Xk`[o[t/v=znO-.qr~;Q,02߃`5%?~ -[ؾ{C,0HGҾk,w\Q>^u?}QYviօ}FG?NzY;?|j^W[{/8 vgvȧMxmϾ|`#+\vԞd,p/J}6fQsr`Aӈj$"ujam怓w#W|dž^Ġ\#Ȫ+7r/~r4=7tǘG7ã[`9Z]8-_5۝_</!i^(3wZG<0ف:Aמ 8\P]\ʎAq|r]8s{ϹrF{r9W{ހ?;} gPӇw]93|r>;vϫjwٿ`fij{ұK}\mGx^Xh^gʹcU-UtK[YB7kY,1k]1v(0s*82dRFaT&j;sqxysmclmmod'>N1==#3F>;zuڪg Qsy տ<6鹐kO{^<:uvHitv~X茚{=9vP9prg^纓}p?)]p}p&ӳYH{%b(qT9w+q<ȯI&3s |]ƾTnwqx)HصP9¾lOX1x \7Cp煓sٵ~ٰ|K8-c{HML]SWopSС^JK|7,p֡RA~S] Csuq̯Gz{N:0C[;Q?5?>p¿ +|{d?K>^Ük&ѹ#SUWciIs}&=w9]@zwߊ7=ךΣp(`ogE\S9-߿Tӡ]"W8=qq͘U{7~~8{n͵q,Q_5أ3r:S_^?_>.!p{V# 6_qÂ9Ł6?o skA ;N4k\s-uf8{Q[V fy:`_{c?;=yLgЛzkZx;qZߘLJf)Ŵ\ O]_tV`o6-8KCaKDS/lwBm;`)v~Qq UGyluOAH5긺 18|>`٪ %-ܱOm\CC{T3i.5>He'?z:wuyƣy9Ԛ?뉥d_8?lw4}\`ÏS7~G\__@~xK'PGZ|5C-kN \p\;\Rbw1zhOS̚Ҿ9gf v xK ϯb.|'SYR^m>H91ѯu枼\g۫혞Pt:w G?m^?;mՇy<-Wz`^Kͤ}qj/wM_g͘)pM0yu𾹿."5^=\S&JĻb / Wvܮ9'ՃʺrOͥS91 flj;{#?k[=bwu=om@ckwE>44;Ն[|4O{o}+Gjs*R\e;w5 qU }RG9Z]7L}&R-`CbnOOpޅyp.{n0u{u |c&ݰlmljpKr翟z̻ngcS3zlڅfc==]97ff}ڭx9n93hogޚ\SY}WX|jOZ^Oǯ|COֺ9ZG Q+է)jOxO׮Dp˽QW*zΑB+ޕXcܩhW˻w)zߺrqLQpQ98%ߟzo2j$-,qH31ܝu.յ'x*'Kzw RKYKxP3b>VڄI _Fx^ZUKZ2͡JiN6aS YjA uk#AR02"a))cs㼜|fw8{9Xj;P^pV(UN'fwbNր(PnZ>;3TN\<GFٝ1]yeK;}n;!Z47,9`8ogBg*s 1RhKR@\}jW^9:˒Lv-rA^jf<|ER}8fV~hC?ǘz瞊0^A&]}T4^v]qv\8,Ա/rw{KO̕W8K;M\-yG?|oMyL;{U- /,BQhS:#ڰ#y?Y绖T1\ eS`ڞX8KOŒog1̛{ۜ*>ŶfSf1^_~(tFzヾl+n'g}=kJpz~YQq8Q8Stʭw<rnϸ1/n t~P~No>_x-sϞZ/\vOX9X w;sk̯7ϯl(${/W[*3僗5T^ KC7D΅7uk|Py`-nNzlUO֚,^g܌z`>m_Sz Թz>ESyE]{⽫٩w~'ޛooGN/ޟלquܛڜGNcPh\PtyyiJōwUsNItִI,~؞p܊كkᱚz0'3՞S)I_w:OW~w$'.7=4f3ÿ^j` Ϻil/pwp;z߾YZA/4|>;]ê,߹|oѽj_x]`~[6wIʦ>YhWYqÿ;R]4ӟc33Ac٤f1?yq*wnYj mhj/ő{]Qcrp>*m)^vvp\piC k9rlENi ]Kʎ;S{ߖQ@S\qXrZY}~Sg7p/wVM=NԇX܁S:~w;991QϿ୻|Am܁f vh;p.<|WkßWjWK5w벟Iխ9gyq>l\#+m =0a'*k.hCM{XZ=CEGmi5w {Nͦw SpWZ\[*u{v P+ʣ~D[ęڶ83ⓖ\0;L~9O@=:E~١E*#d{A:oc<|sG u-1;P&O̽Y̌xW.xZ[Ob\>Z~R] RT٫ry? ̠qpcu8s֤v:;GCsE)vG*'P!VSOdiT}?>3\L9~U{~}ԙм2XOG@sީq6<l/Om=OEaMHn3oް7o\}K"[稏~u5To3q^w_Uk⽻>̯e{Gq7\kjkW͘/WrU/5س`CrmqυlwM-aj.l.&Nl~/}sw<_uZS?\pr׼W~?fp^JͥqNݏL2/<ԇ˫_9=yo>>ЧuvYMnzT[3?ٗv~[5R::3<8`FՇMm+{Tm4yw6L:S}/4m3o\wtBCs v7unj oxPljJw}VᾮsE>NO\9)mG*)\YֹS䝹O^]絋a>h}/SN17sq^f,J~/hVgsMY} ' 7UocK3^[#Ks|6⪩6b{B})4#XKO8Z>='~~mho<:9xbYyA׸.W幭vgˎoy܉w禴~|ݹI [OIi{M{|&:5Rcxʜc3>1/kuګr胀;̧|yĝ }9|.z|C|@:(r|K8wwݱaKϲDgĵ]^g|̱7S?s;eӟlʆwD&:ݒ~'`sҋ>k Na;b ӂ~w9H;S/۷>#3*ួoB%];gz:~Y}Թ'jx.8PՊ;,8s9ypg}bFg|3J#2~SĀgQ@-\ii9R!yF3<:޵.y 'Ve{t]`) G{@gA'ߓM6gp?ke/Cp){.-Z<3.OP+տGDg˔+~SqޅѿHX/̙<+h b%>e=s{Ҳo/͚Ջ}3[cϕ{p+lOοjuwGߢI6e~54D'5/!Wݶ~KA[ 7|#~~WG/ʳˠxz~=-^c?urfG>sMt0mRh8? mo=q]me#o'[7f\4/+3ͧ= k}_SrSi6{;+Ǜ',mNu8kzvw8CмUNx [66U{Xr3yˮ~>]W+z8 S_}\,rC{sJyrs`վa{3Lߎr#Y_a~ <9G 5^ޠ:O?˫w{Q0|פ+Ss/z޸ꦁŽ~gg=4c{Hz{n֌5R\#Z~' >p,]C 5ƞFyω}h,k ;d]Gڧ*XyЀԆPCg/un|_:.#tNi-?geי9Cahn[1WF= hM6Yq̥gշ8gJڙgBѼއN?U\wV?6+E{o(ss<:7|m.lew~,6ʕ1[l&Y8]};S꾸OxfA3g.09f6ٜ뺿}!ε3WGɳ3ݹ-u gv;޻jJiS]xjLSVbuƱKq%p~ༀ>+W_ϛ9i[ܞ}Ys]x}Z̽,,.utÞ pzNSp /jũ>4ޣr!9c<: 5;aW%C}y}]m)uV>zgüfؠ{W:^SoOxVV 丯LۊyyE})9`oiTTk0V}D%΍Լg[jVz^r~0ruO7_|x-X*fakIڇ\h]'{mn>pw[[ý z$9~cW7:Y\b dEM>GnDOumL'zD~pJg3l6)_᝸Fz4@%Wtĭg/VS|G}3l5/ͧR~Z)l>Hׯ#wwb9r`iC 9١sc:YCuy~̝ҘюE?ƱxGƧU[XٿW!7A{:7%Ww63|'3eN= 71: G>yѿtx^X*&a4ۆ]b8)>k<3w]Ҧ[Fuvĵ93LqC\ujlH&rĚb,1vg>9~^yxd##osp1W8c8U~jݱƦ8QomN3{]<6?]1߅:>i샱Oעk:ew)6 ؿZ9=y^g]x~P#F _9Qg]]- yEG9ޟq2=ܿkaU.|F]R}ZRM΋jιaS{:4մ݅Su1֣Obhp}݅A~/=4ۦrh`v홚RizI/$΋[oLS'[4ՇC>kc5{ |E1!uOk0;9yֳwq^kbE-g1!ziܔ7ظ'kz.`z14l x[vƗ\{;:`گs)ol9mC]Ժr2؁w+O mXwfDn 9[Ք8ԇ9'c9B b}7:ĕZQ"湪{fw8V.}cy49 ,8.Ѳ uNBu@3wڀS1f}cM=!i<ؾU:/{Vw)y:yrq}7z_cƬ,h SkwNrl 0|k,=*gZ2Rzg>ρcy*kάD.}k 8v]ŗ{;ͱwŭy| I8yz9rk|1]T܎{w3q{]ͨ9^88 ԅo:1J5Ysjh-ƨG\|~6}ߐ-}Ókjӯ9ZQ; 'Ng uC3}pפⓗ+p闟J1b<<$?3E>5|>̢:Sm1C.=+=2Ϥq{eye|Q{qTCgRѾjp–]q5wƙx:a0G~+ W#璁]D}= |6P> 15BQM5tC : Ρ|ƕqŽ^.{"Cǿ!Ƹu꒧)LJy-ͭo4w¼r9M31yw^_7}ʝ=acᮺZwƸmU=j/ޏ\1hޫj}Nx%j>x-;WHN}W{,ڥWo/5.#v.ׄ#8N\i SքQjŸ=aa5O5?17=bĶ7:+_VO,;'uV}knoFwJ5\J<5 {3>Գ2_0ŝ-N;-=D9ex\e@=4?ka7: g@͟|"ߵt<:{ŭY{c/{i=0o{qTl.Z9as潇&&wx}'F4D;1KW4-\}[XC, ǁ<״Ϝ|ԢF^]5_ԧ_ݪ>x U5C:~g}hw^^wgb }=t{[K~trz;eyTo3 [{ep=ָ}Ԅ:NBww jw}`՝~ߞt:ZtaVO.pŝq=} ܁8iS?C5LI{KcN C>jߕ#z|j>?\r~\~ш9>!<^R mwg9r^}k76_3Vo֦:knTաqVܜФZT}VGK4Ķ; w?|}0Nwy y={ܕ4{f];m?m6⬡}.6PNoB9/bѦO5ahq+v;~b|ܷ"vǴzWX3r#4$U/4?@PѯtZC.':[ M6'M{HC_}!:x wnVyo|w^!cuX-i߉})[z~wi\#r_Ķ֕VB1- bI;'KgkGIgc&P,;@(mzk93{_4"%BZ,ˉ!޶3BWh݄k88ȃ]@jZ|apv3iI%C![=6f}.xogQJRk!sz\^C 1oZi=Zmw쁫 tr9+wuk88t8CZ:Rm `=Oj^;|˫9ʥq9gGَh}V<k3ކ|] ?lrxƸ^֏gԶ;>@9n 4CHgcqkYנ^x7Մ;YWn;muis*'p;c@?ѿmI=ȷq~GY0Os&mkW1ܡ&+^R2}Щ zt_ ص'pcjh{y,?U>qoZk)5`9#j<5 ٿ1CyX}ӓ@=k؟1%Ý<ǯmʭyA{`cj3(gݟ/pο{4D?]ΕhB;Tv,B; C _)悍`O.r3į/U:jTtLvQiަOc_N|7~s|>˩>[G'is~^T'|GZޒҿ%f(qr}₼=sv>ZqӳW;yWo}O 'b^K8Yi<ͱ@_ס{>xZ>=XמSv0&gzk unc>Swx^Zml^T%!KLƯ(07I! * +8enظu̩51[9@``bCoL,Ȍnq֑~zqr}>ssoʉ:4gg~")54D^A5TbhXL`æ|kOX36=hCRޕ@/X\g~~Ȏ]۩b7żsE{Dr=.4e|>㋕;蝸9Ju] cѮtZOo?Z%6dnC ~u &YwÙ4`.\tWsy{OsW)M8`ΐCR<'lY`iLqT<ΞV^CA~4朎p`Ood-JK GkF-]qCBb@ybRxfr_ {awTG#yz,sj51ţ w墸l]0rW (>1M3 rC?OfiMjwssVoCm}6䚃Qp@PC1C~]:6C5ΧO9c<Ƶʣ\-dȎ](Я3'[Xu)!pS>yL[9 M\3T+O}geo ?ݵf+eWZu;٣!GW7?YNw>}.mӰrGx@9#u&"c}yP/D߇:2~]=;9]yοG:#lBߚ;'$ЏⰆq ꯿1:rbͼw{w|}9Ā}w\`>= ^3r=ux퇯ٻצK ;"ţx'ou]+}&:u_n3OջRl_oDM+ =#vÇ/gF仲 O>| cSίm :oG4{=c7?iwu(Ňړ7␽{[t옇3bUÛ4՟hoƈ|:cч<֩pNu纭Yx ~c1ic@]O<e]1ϧ6&k |Wt|z')~=4v߉gÉFL}{U;d `gjkGg4 pp\߻KupWЇ^y2;uqvwG.[11]5;b3OcA^X7clb.=QKgZUmy1Gh|N㹿{5KD{:zrԽykrgM)€Ft oQ߸ohf(w1?pf]} {uWn] ( o6 շ{T^kG[}!X4+G#|甜<372c o4XM8C?lvXY?T=~z+ĸx\(#?Cu T55/qv ;U<./tyk_vW5 #;{KzwE_*!40K{Nwm%^*=7y.~ǭ/E<W.ܸCSe9܉7^< ڑ;_:o=+>ƺaΝAϡmˢ a;q6)#T.h+w^=N?5 7dx^Y{lWF%.Y榌ADɚA#rq@I-afˆbǺ leDHP .?Ԙl:1&N. /Cu}{y9|uaó m'VNCn]:b;lԕ;ߗP>W&z=h%#y.쿥&(c^}u.6zay/3b0%/f.~y8>QnRӓgws{P0OKR۔0qsk~?{[ }9?rەއr}Oʡu9j>Sӳ:fֽS9O4瞆1e֔yA_י8k3v˒ >ЮuT*]Z{<^쏱8}>9?,&Ogx[ڟ#g|{ƙP#3ys;\s8mKM3c=%v`M;P_i:jyQXO!}ҝ@{X~xv~$ VF>^Gcm/ZRsv>uyܝ1j??eU0/+a~rbl^tnvG u9KCgŹ07Ó c v5Oܹ:jT])>6k.|[6?.|>?x>nl<϶}3(džg&n8s>y5YE>j+yȭ1^gj#0t'Xu]=g/:왵/Z߇"/YWw|DSxe{ ;ޜQ5;bV现{"AgQ}":|CMoKg/c9}*=9zp{l E޽ojԋk}ojy1}nw[63俾;+p+|ڡQky  eqګ_ŷ?9Ui/X޿=3R{xڸ[:/A7pMO`&e=59q~*갩]rKak!^uy1gg z 温3Qػ4'ǥ'㹄ǣ߲\r|ui`,#'y ٽc{?zoG:3Kϐ~m'WxW/>JőySn)描>tprf9nJv萃?}5I\{"dyg95W|ʋ}{mǛ'+ϼ9@= }w6%q/h[Y55;m<; =55ڻ:gY.B_>riȱ|l[ye ϙ FƱqΑƒ6(wT \=d*uܧ9؃3{ss;ϏToL3"/l{Dm+WgRT̄ ؇'5[|%ʇ.N;TH5 yq)=6A?@t=gyrvzLZ'侷]V m5wuSN2Ϗ^vܬs}WGB.VߒUḘ3ph jq.6 @W큾uLz4^#GC}D]b~ȯqeuz.Z>{]ҮO?+ `^Ɵm[}\}y=Ec!sTw{R?OrqM{Os~PZ'wvh~WxYcwjp>丙>A\#&k3qLq!1:v|ly_sV=U'?8>\ N\}┾^Qj=6'Aa>껂>{Ϝw%#gF5|> 쑚ȏ<koާ F~3/<#+q Fg} wvdwuqԶ-^9ڏn\^{gGpJ3{_L}xO'-́o:v꺵i6w!.X2ZqSpo|aM^HzWpuS\~R롆O@syv69ϼu;BOΞf[?C8(yvPS.OIZnIA84z{O6D8s{FtV̓d=g/=hmr2yw\zg]߈.r{񾽞άoocջ |7{f~G!~wŃ.|6ixA07E{.Ogs'}'摋!㹹h[]T~_):!k*/3[6|c;PO7"AϚUS%x%_\mGun4cs67gg?U兀(v{j 8(Qׂ}sk]Cϣwvrej]dMS=bˌɁv9 !Nt/2Ɇ3E56kZZC~ȯ]Ѹ{.GcFṣ~}Ɠsrs^<֫]t/uw89:u??.u~.b䜀o:hjy |ŊGbk{vE4^rf๧5<_6%aUNy1sUsAy5N7Rƾ4ǡ~zM),^{<zFpJg"|o\rFpW^g`hWw<~T29U|{vր5||]Om={ a Suڴƿoй\f3!}ύؿ]4YH꺏cI]cah7\1{~uQ:C2~hGeFInd.t5OxGͅZ6^_ |:#}`\ufбw`;w>ٓ 5l`ߣH7=px>r@G5wC?gʙ7;ϡl6zτ"zK~5F} *}ơ)wGd}=Ce,~o7;ww\8=5Ϭ >\{1w7wIy 1 kSclj %Q΋>.TTL,Φ#U~O {[ܱﹶ :g9g_"xj 'CӈX?`XMbK3%Ofy#_}Եy~{}8ӉA-|[w,\}ϧWj2TݱRn`xK>Ԏq_Y?WԨn8g?8+*Vdw\Xrđc=;J ՗ 9jTm5|z}rX1> uuF̧k][}hk,7pdv?Kv||?{^)[31ΥM)8 b`=V|.S~l|Na֦_'josX\jfN~۞q v\ή0G).ql$[}|Xcy-O]=f9v..ɰq?ll0g܀O 5׽nOkMg=8%{R4~(j/ `9>O͇=!7 g@5 {eᦌ? q1?`/x֨Cjlo~h"o+=4h>;s7bZ7{˳ޛsu^S)bO?̾M;a mg[ns^cO-ڀ `\ɗzuk)\qx gj?cerui;6AZ8'gL-Վ -Nդ5C溶!ށֺw@3{Im_zՇ( kmk;fi/ⰾY3X-cPjF˵֍!&VɏCZϱ-X5h]3s t/Wt_t+k |jv{}N{\:0k C}j:~ ͯ]U5ZkncT'׹c.ybl?_ZKl1l1}/Jk{]glAZ>(!o]SڜV_9 GjS_.ƕ%>4sUe{i>{t;Eʳ&t9N>O>|CxpO0惝{ 8xj2GkD<6,G :lk8~ۚ9wqYlڼtoc 8 u]'KLRrl1ғqzzL#`co뚝Yj~ɬ{6. M튖݅/Hx^W}lW]iQcvdlun|L0$Dlp]i7:2X:P6CB.)a&#& YHuDP{n=sqr}}7J+>L7^JcC+l)hG~m/;Eg[c,5zr¦<苾?.q\`Kq@jU(m^ZSM{/[c gjgj\ עv1W|Kj'iܴ兺_Dw4Gڰ׳ ʾ8w\9DM3/{VOY1V9>T;4R/ZfS3?NO0KNܠ3".xuEoSxCS}ЖA'6gΑ|Z' o\u1\)=l{(L'j ُc7^~pMϨ;d*fߗcTH*}{I#Vz.||]8{X`O> wNv}慅y)vxPKϪ6ϡWu5TϚ^ѻƐc1=6u4@-=wÿ3{/r^ ;}B;g`ɱ6wcU3}6TP?8?A?X O񮮻/gKՠ\jc u/V.k1g޹#SJ' T\3j{}a)hYzr#{_r]#c{~rOP}ubާs:ؒxi mn^0V9[P>Շ3nB/+0t|r8q%6;l3;sʽɫq 8~ʶ p^vZה֕)3z." +\9xN配]*[3볥ݵ+tVsJjoݕA'gQ{@.WBfEО1/kRR,:>sg}}1~'5~:D<9<23 6{U溍>Y(W1Q4}f#w9N=jj<Og{r1{Qx/z|gwϗv:[dttהBymٿjO׬>'u=K'>\[ ?ۃqZΣ=8{:La^eoM{_힣d ||ouUݏ-M9Q+l?u9[v9 3#srS5hw֜NVK"ǹMqj$\Xt^}ZsOX䨏\) 5p;@> \3)?zT5bۛs?r}!fRT5?/5}5x9}sܘ{s꠿X˳j|땏~e>sKnz<#N}żCuLvx^XkUl5ZXJjLJR3QK!11MQJh Yj4|{s>kUqwl" w^f?#gcZXuCԤ߾1׽ix=ظ(Yڻlur< dNVsy8]OVCaq?gν}WxV;^W< ={u]ЍuPƨϚgck^ZOx!qqNKߕ9=.v']X 5+f9_lJ:#1?:K}UnNIz4 >sWŘ;AwᔂzcNQ1I[Omw+"z}֋ͳ:~l K 庇W㦅cL?cAMWY*Y_筹6l||r_wU]zo1qרӗXCYˋ>Ao6sOW*c[Z^v_;ܪOB^˭sEs5Ɯ# =ih泮ݥi}m`\7{)?n =0#Qߙ!mu7h{ZIg3y~tv̵aɷlPǟMm 4Oנw8I)({ל>ZxgV` Q9GzÞ5ԇոucgZھ9iIT\/WoM fx^Xme6b6Iҝ"f;ٚ,Әr[ڍ)}3k'aJw4D,*]-BaYX+}Pn<{ۏquuq+4b[ j3\:Z݃-q{r|#9{i;21R9_I>931ͭ/7?,Y-/uw?k0؁wØ17U3}iO;=W]kB-OUu|WNkz>+߸7/obK-ފgg$>PPMo2JbE>sSyU\j];uMTC'p\}σGKYmV6ť>w>dZG*#Ks {}n9 ݗJOq`)aʎwJ^T*a }E=厯"]vn9>a h~3Q j9'ܢGZ55`v߻DwjW~ Z>i:\m1Dy@+؉㓞z:Z7g侾ш_㯧,{8y=患y+̫Ù&1wmC~}e- \6z%sYCkjGtF hWį;㦬o<򕣚9ME>7Yֆq׍m{O9Zb7ܷ)~묫a ѧ1)GeSU}^S:?8,em[kꇱa5#6~%Xo'1lh{2nΜ 7 _Qr-MG?kAx%tsk;WKpwI1wwzwZWWU7y󮘖O|7a9lh{<5o&jLMZB`N8g/օ> SYtX|7~vZѷ8߾wC+{sRs;O C˫b>ISng[SA}jW3_ߞp_7A '@ZngJ p>,jK6S:^auZ~e4x_6rFϛ=[oÜz {v>zWM~>zٸ+=ZW _w/|{7W8o{|]˯QcS{{@}ܷ?T~y}De(8<| czvN܍m\ݘWx ~ZjXYU?Z8j-鞜-{зևy>kνWށ1>*x^X{lnbfNe: ΤNj%Vr:CLe 2EeĆcāLk a8)6#<9y}w(S/5,ܷ:z۸-mjb"vlF˾F 3l,Xۯ}dq1`<]|\,]>-=';xzzsٚ;o91_,ng7Sw87OԾQo}g[ jbݿ;9:oT=Q i [oi1@B"?f>|ڠsi~ @hqۗ?3pG]@yZ; ˋlEܾwFW.v3ߛkߺ- 䑫oGU2MZ2+ʛ;CL5FI[;Ƈ >l&GJ:1h?3~EڗրW 49m>GtVc4bZv?bv~b%V1䛲:g ]} w}k/6F|w$spk%wcNl!W9KGas,G3 s>{ߴߧX.t?BWcQr^+ͩ:)pG}9֊,&ӝXh }Cy, ;N88ֆ>7 }[r5m?ٍmp<΅;s{? w7A +wŘ-q_SOڈ%A8r(7ry50_{i(G}I鮽LJgTĊ@~4q;:uf"9=*__w;Ϥ_TWk[HsvC'&Fwf7ǎqlm>|lwX#A=^\aC3g]uy6;{ay]7vé]-ަϞnuv\+ϳ=5s]KYrේ1;'g5qnpGruΣ4Iaw`ֶ͉Z|]*rCו>_͐SU遹Vc߬ F{^Nlx? ҺVx"_u 6brY̟Ag vy&o_ IYzᝅEKk@Β焻7q<ܒɍifc91ڟQﲎ~[Cu?pufՉ14<;ȥڪxQ ݁dk8sݡs; םs*_ߠ>=ͧwrl-^k^NV#J-G] ܇/^XU}EYGΏxo['~cIOJk~OX>{vК\3i?T?~IsR,.s:Wϕ)/K43ݵΒF쾊osȗ}0WL~4c.4(E`+oqqkџq;. |[uk&N@LO?"6ƴ%;{< X/OYmhZl z#ZWwbu{-̡m?}uw)n+UUC;{S}\;E;y@G+|vO1ƁK=kp}ڋӂ/O׃Q=s˿\NsC'fhU1P[ya>%93ao;J++b,)'`cv{*>|ጙ`=/x5 =Pާ>B-ՅQ- k"ܯ|PU<7ulkܜf ]齏뎭 ;?zZc|[f,8A{Wc$(/|y%a'^@bT 5P';}SX:>r{J {製|Hyw'`;bP.@MA,o}Ԩf\Y,3{_ͿR_ եu:C+w?߆XӚUOig=kΟ:vs |,sfg_co^{oHok З{39 wp@)"gsmach(Юq|@iCWTS>mhhJ|4i \xԆZc^~gkgpmI3zzX:fR}I/fWP8@>_t.^spF44 RKO\} 9lhXrv56\ylؑy֜4h/9h 0p̲?rbA7m50F{æw^?܂*-#娿jv &s;p NֿŕsW/gGmI>c _'Vڰ:4_A.|;?|`sˡG\ Z9?yWҫ>xr8kɝ5ݳVAu;0ρKrr=>p=#awm~Vnw>O_xl xrTκ'v|ۮU߭_MgQ8 Y5,qw<_5{8]]/@rN=l 'yq~m+s͏Un(^\~ͱG Q>{}>aŝ`ϻ=M] /K>y\¹{v<0.b_ܮ]SJ9xbj\^Zg΢l=UsLIvƪhcrk yh^|K6*P 18ߵ}FqZoo|6jܗjTT3@ݞ9oiϵ{L h,q917${WZ>ήݩ6|g5ȩg38OS/xV3ucjW?VRyrxUem5FӹMsx.xS|= 75ܶmO|s9 7;oUݷk9Ц1VsNԧ\7m VO+) aba5cߜG 53Vʻ|}4[|P=vɁ>;4 艺M9ʟ7}ߥW&E> ;b<4N:{WQe} xv~\ b?֜4ws8 sh͏C=cu^p@瑋 AVקBwi 7:g_jܟ}g[ }CܛNgܱ w*63~F\>Дae䇝qͅ{} n=ùz롖@}=Йq=_|?ux)`=¦m~doh'+;&>x^XkUULDQzY´Φ|`SdbTFiiaZXeCdT`@bd% !iYmκܩo}qocƬxa%+lk.C{gxf}i noOr/a ^wJ wyngl}3°q]}U}5?VZCU{QYNJ^'gƻ'nlx7 Hw71@>5v|nwM4~;XG Ze{򡉼(\֡{zy= jf=5#z\Sq)7ϼMhk3 )׾enPs_b:h9O=Y\gyRy͗{ڽ?{56fvlTZCjG3β6R_5~ι7ĜÅ̂q+E<|.~=uiΰ[W]p>2ŧ(8/h{5}pC \`pmrUXUc5zS{h⌽ZbƺH>Z_/X= ׮1g*+sf|Wּ'O9e9}޻D/j}7uTg1>°koMq-γ5GK["V+Cc[/YȭuA>TG-ojy'?Ӗ;4Ϸ?.|ۧDg .8;[}޷މǽݾ8ރ@ G]0k9t#$Sz0v㬤}ߌ=9><|w5束[r4z>?ܚTZGk;ࣆk qռԆE9za>2?|x:Soe9x^Q0PrS8khc8oO߉;+LNw>Zn|G`?ܷԡoɅƖ%y;P:\T CT~'Zzo 6gڋ\]~IaO\=nXqnߒBs2Ns\6?1[lk6Oeq|f\y&vC>1{R!N`o,ATZ'#_9UM̏=7o޼<1*;f|˸#RA ?9+Ɩz49R }x^]Cmt6oʗz6۲pHزp]vx.߳;wO_S`k?,!jcV*O ><0FR=Q՚a&t<WO?~9m;+Ͽ.\srZ:3,*uaЮ:uKxzlήB~9uߏB`nœt&ʭc t3kȩίǗ6B}ϟpm;8]N<3+|k:V|o̱c\Wk!_yeiyugyY|Os1)jzDu䦂vj1: ^DyCsr@~{Vk>6f~ӥszjRlاL&۪Uu}D>?ußS:߅(ß2\e5MkO!A8WkTw5ϩ9>g*gJ:L|VrOU>{z=3Z;uYO^<ԾhO}GJލcpE[33`@o0kg;lp\|__Х6V1hӐ!NMn;9o{ʇ{<myK/ׇW]~󻃕Ƹ_.G=6~!:'npUk2xj[64)Sڴ3(?k{7#aO=U¦qpvV.oza>{f.Ryܱ|i6nns.'lܣ~p98iOO U[rG ׇqO;{eߴН{U5SPmS*V3{ԉ ojOg<Zf@^#|֗qwb =Cﶻ_ 8|mP^Sȩy9ᐋ|`\λPZKA{꾜|_ ksZ oE{l\u1k:;2?|Sz.zぬ7t.~/˥wTw87ڞrt BtT{{~!j3,{xwySo]tzm\~$^:^ 7uem=uјWvPK979;1NWcCuyFn~a}e{Vx߾lC\5/r rjbD,VڶujRCk~'gKOʐz7E8爜|׸gWN|cUۜ;g\ڿ}ȿ~i!b7Y3N=084ˣ<]YK !p&ԯi}P{&JAkG/\Ss?S6S';p87keRkÙo^T}<ʔ8c|Ԯ3b;'׿=l\ 1^mލ-qns^R5um0BEڜx/[q& _h]x {peT>:mEu)m_c֌n:l.U_3/usrkiOw/u7KgT֚VJӁ͸ߵ=zLCg̺U|gQr6VΗuo}ϛ|{=2ߎ8?=D ʗc\0tdΟ;mtBZocta׿Y/y{uM=b}t_*?}5m#f6u_ Sg3> sG;Wj9nG.kqg3VG5 ?W4nCëD;FK3롏\7μKĻ9Wu,G\Ȼg ~΃z^j}{u^Ox#xʝYkx|*?אָ'W_m ~px <99@S}P|h׼e꺶&|Mլ1;˕37YT)lKbGɼZηdE Xl̡ծ=sr>k5ZԟNbV"޸+, +_q?TLWYgmpFɩ}hg x^Z}hWUΔaFH>ŵ${\J_:bX!4ElCMdXM 2-B$$j 2"#B"DF%JA(%sy}{ pt/W>a[eq3ihy]ꓣ'۲o\ v z>bz}̓AߙpdlӸnb\imVrs~9Ϟ많(gb8;t[ްgɵ6u >vcuTO{]hSx5p.ag`5s95GԀ[ ۆmc;*PH`_Ʃ@;eOx;8c9t$oh1ۼ |1 OOm/K8}`hovc91hڋQm={s_ĬjਆƵn ܷrЗcg܇4PzVo{[=jՌ|1 N=ؙ qO^ОX[%ħ+^|kw5v<ꐗ wշ}G=Ami|_cgk_[-ڬE_C}gʱ=q6ڨm{&3YG__dox˽{ M;_*~Sމ|3A=ö}?gwй1Xmٟ|rP*wlNsT,N;az-O}h4z/ؿԱΫؾTX17Jcmϸ׍zWsr-hcM#q6C{U4{{'hqβyQOm=o6~>qu;o}5I{: ;c^oŵ-`c66b8C3?cq'\@_zYkխ0rǩ;VYc1ryZW];rKm;:zJu l71=ڹПö96!GʫˡϹ/_W}XEO|˺iCC}Դ?K7OָmGW?NzAb?y7,~!z9[]ZQn5}˵Y5~/Ƶ}hwPڻEw<cԍճ5uv[OF,ZԹ>mnCOts= t2?bZ1_Yv t%>fL7;*>sy'ж/yB83uNtԱkX4:V }rɷZo˾is5,{^Vavd{CqfSmAo F>ýY?Գɵ;b ӓ=BWsZ8m/\;ǻEٞ,#j`ok׌l>`gMLZ9<G=7%{ٓpnpWwGƴ5p/i5}x~_C׻֧mƵ6~R_ڧAF>!k]>!_MàkFǸqR^oIs&N3׍?m >Hs:5pgPnKkcu,ЛUsX3SzcC^l΢wiwM#>7&>_#o`ՉބO9/斧ejo~Tu\- '.58amĩ`YeSz5DS)>83p`x9:S;ۻ+rԪL&o݀>\~ Mƭ>#;xn+z!aqzt#EâqwPKΚT}Α \wxKK8 U]TݲuS\;tuw.\>7S oNX)|.C*SggOG6|G;aRyaW8#`Qxo@[{lܰ+uΜ5g?Jwf~:]39g{wzk5y=2N55X;մ1sW_q^|eE.ܝ+a?o\Z5 ugߋ}*;x}N+v:[{NpYZ|M>Ɍ36v,Z={=>Go߬PM➚KC ;> jTM{L{b>:Bu[2jP=>3q91omJϚy^Cxf?׶sT!e={~Ag Z'];g]j/]w`nŁwp_*o~ӂ\BtW#^ks-΁}?j0]w|Ü/gZSmz:SgF{qޞ%zqG7EoE{ktvv~+OZ6x?V?2ZF?_kp>5!@$ȅ<05c&6azF\ܓ֠j٨'ygoZξgLsS9{bOv;<ʣJE*γ83+)8b7y-ImgXR9 s h+q F0WcK]ϧb֗{&G ƨqZSRy~ڧP+3P}:|=3Bs)`=ʹvGtsMݙ{ܚw{GNUmK1ڜK}kͥ/'v.N.\85wMdu@+æ5y/ [~V{szyG]se>3ueT>h@^(ܑRofݼ\-c`{uԜP.N9+}ZT[*x%d;[vNr5nٿ=Z4zߴ?k{־c/>TMtH={bѹ90F'3.Mӳ3A1BtwԩuSsvIsS1݋#|9y\ 1+_͉Vp[Ѿ?Y׾ln~9Rz=i/{>]Wd빷?.:.i-nx"-|)[ {Pͳ3ѷx_<=Z~Žo\3k3c 7nxL3rUBkk=yjS>ˑ'~W^폷Ҟgh"Z5)9ChOΣrm-¾+~3<`1h_hY@hgx^X{hUR&"i,PZþS)k*e"lLM.,fZ4ò+D]?D$dEH ̢͈sVלScy>r=QZ6<|kaߎhrȝ>Qto]~XB84g߈A#bY65kh{#g8\}ߣr_Oi] ~9gjg<ϛz. w$J6=`FF#h~6ccm?To3:E-iϱ80c3=s.̓Ĝ>Ƕ'ǺW@8 sƍ{B!k-_y >|~܃+Oչ6;Y*?1vC~;-+wHYK?g#弚3y ݏwIiYЏٱGc÷}gk!ooaDž ]{2ZwWN9mY?i~*CgQ<TMuu,^C<B *.UEUKݓ ^7LUuok'ZꨧgԽB>> Vgء? ho{|J@~#p~}(S*7sCo2oqFe->g]cgVώv{@Ѻz[{W.߾̩|*wwWQ^)^Y8~rC.pSɪ,N}8.lյJ3ךŌΡ5նqYo<Y3rq^P3,j~;"չ{]1,Ʈ X5~j;)a6ϣwNKG~|+OGb G ݒ >kJM]d|8w_墍wBų*׫Pv[Y[Ô8X>J꿡hyc1}G SSr~@1/5 ;\E?}kTM@zS_k6ꔰO>r~`%Q*}ĩscN9Nϥxcr?)c+o/Qcn,(qVԘ]s6'rf%߆7KYQ>_n70os|n~R~JU ~q޺wz=t~aϿrN>毈_ږu3u僾.!B9՝3sԕuOp}OzbuĔ&es1,5#:"5d_5&yM׷uGcݮ6|Ω|Ùw6ajjqs* MTvmj nS9Ϝ6Ҧ>O/ ϐ΁3{}z5#;Dꮝ:]{|e<ߌOcuVp&1yv=0{t/_G}69ϴPWI.v{sy;ǹX`BR#D9oި%ʙQ 1D}x^X}hUc+HLbLSaݓll%k, ]VZCTh5, "!}D sQܞs;kpy?ysᎁܜyX5|n8a4;Ϯk1 RP~ jX7g=z\c+Ďܟ mR5զ9|t&;\owρyn=O/؍|G|Wh _~}զGW&j.U.ƷHBƩς#/um̔jg=V}}ͽ>lm+ 1gv.^ {?l:s[}Foȅ8[ζ9Щ9:ۻ&cRL}:=| ׳3UjWXWzTm:whǽ:{isGNrÙ[9-E>؟Z1Uu&P{p[[1Ԥ~jOFN6VWM"vqkmb2{IJAjZ>3Hmwy3S.|8Čҝ`߇rp+]G% oGgˣgX1uK?ۚZ>z] Ü;7#?{1u`ӽW}Og9`g@_ڭu&rGeu~ث.u=U?X~Fb<Я:4X~w~Ug,>fS18~׌a]zy繉wW .G bv^>UW1=;Ǘ=&y?/ {ܸձ^VQr ._4쮔_YL<ayV{V{)^:>]+ )=XgTW1s,$/f{d">, ~ءA=ގs'|韰?͞s̞?Л΅7W;wnw'p_ϿV>m;Cm|Y-VGsg<g*O17V-l{D4GZAڎ8in?09cbqk,Gm:sGŢɃ? 9i}ToSNWnϽ,p߮6sOcuܶw#/rg,}Y1Cp%''bSS7y/y`ߊM 4Fas֎~&❏fw~ư7i"fW^ ge=gI{e垼apiHcZׯ{s"y3ߺ6ذqS?TZ'/KY.MqҗΆKgq(hDb6^m9CG.<-_=>i;g/Vу- r,JR{6FA,N̽ =5YX&ʫuۛWED1vVyݓ0-,C׎զ|HOJ*1Jkzѡok$YV7'fwP)3Zx^Z}lUBH1 !e~)v(dKGSBYs !v l]D&@4:daL:B1hClj7?߹͹8y}| 쩍5\1L cy{j;n>4' YO5p^M y96-j@E{Y1\ܵ WUxv"> vWΰ0tMߏq1&ݞx!p/8uU:9*^.cup]K hcϘ}bSu^Xr>Z@kF]i^JQF.31/s婏~49f-ȯ(意lH\p?Ž.yĢJ}qs^CHs:܏yW} s_Mk+Gy޼{iUd=hJho5>I=WdG Zڋh{]6ڥo:܃(v ~x6p̞Ag>7 &$i̡hW[C<ɏzުww<}@}gTP粓 ^xk }m]gʼndz澑R 9$8H Zwm/.[N>;=k3NkUqoE;Vq4|`ON<-z=8pkYoYG]c'Bg!vۡ΂ Z+D7~̿E;w]xv@/+;fRùs=S:=W]+i 2G| P{ '+߆ί;_=hZߓ'h!>|].}+OP}OA_.?Ip^?ϳ 7?wo#ƸSO-]<Ł5 CU\}%܏3f9:ܑ+GT->ra\ϡ 8怭Cz<~r+:}1z i'a1@hkO'|+& ?nn&Z97m\my~Emi3T+mmXr$ڕ6w'W^>UwGM` ?mgn,}9=;_z[a ]uCƪ\Y\?9'샺bGk>R})G]ס}|_Og?ͪX)F? &r]I0zcмC~>0p9K q֧/9/}Cnuq=fm ܋|òE"SYsg\:\뀃y0}?k7zǏ4~WZq=Cw:ڣ]Wzẃ^PKߝڏ;罒Nɇ3%!83ڊ//p޸+r &LO빀VǼ\=b\>ZjVŵyG$_{Yru*sM'ܯwP_-Ih}\x㪍߇ϓdߋs@ {"r==\@\;aZNf&;|j jbZ \f>+wWٙ'Ďv+xGFSwބ7WuaL ,iz/۱pnž6żcifϖoD60fgAw\L!>i8fAq]=b~xOh:8> ;O0}VFοgA{-]P)_yz޷U/tn==}SS/䣦}辥 =8E=w'/K1-> ׶3muS\u}=Q=x^Y{hUk2i*eF~MKy)jK[KZ]dQV"8]bQv!&bDv#Hs>oy>^~+њj (.qs&Cnsy'3ƐK_л2={[ƟZ6l?N>Ey[cN[{}§Ŝ1C/?tneP'Kf[w=L_Sw{B7+W}V>zp>ZKVNɞzr_'=GrNݫ)0>o?` { Mc{n}%K[CVG~'2;3Wr0g8g7֭m-K{Zυ\B~5g|;!coԠg5-ܵr8Kԝ|蜨c;S~Bw;p>ڞڗџ֢5yoO~[-O{=?ǻ}Ƕ|Y1ݾ/9ؓ==e7Gz~՟|;ww?9r?uvU8'syN_kG/Xm>SBZڗ>N;r=)_bsu/>qI5 :;8mpطjŅsxoqOZ=8[U< 7loKhs/:a#~,+9'~G9VW}]e[iꏯs8 /|-?Oӯ6|טf:푦D8`ɉrt&֚jẉfx; }byD ֲ{<մsNҶ:6FێM}jX_ k|~g߻,nU{sG9Zp~=ӟo_0ܟc[CrSB,Y bJS:vbAv6|kDVMWΥhL}7tm|'=-d<`7a/W[ޖo^{}#ho՛jj6Q1ȵ\.L|Ρ{"O)qiZU0w`żKmsUHU䞈O6.g_1h~c>FWdLU=Agc%C_kzT|l3:vq{X-;ݖlέ;;GnI->u 5X]]keW.ljhէ32xD/*8e6O ։ij8ZSh9Wh7wݟ|+ʧ&rv٘Z@뛢/'}YKb6ZU//;Gxa/lۑwʿ~ =l/ΡK>[½W/W>'8UkkٽTSmow e 3kO=1#xs{?;qw $V q 璪x\|ͩ?5,oӤ o@>)i~ַ1̶Z;d?vWrPyv;>ѓzX][q/Uq:ވ<΃g{g+J DZؘ^b-?4|+յzYX|ON1liҟ PqBy;6\Bw˪<Չc1\x^YyWUL[LKK23~e,kTpZLd0)4t0 1ri Aj(K $L$DZȲ<;ᄅ8|~η7?VW.bs3W_;wrM<91X>uny=)w]zb}1pCRLfvH{9>cU7l8T<Qף[Uhlg֥E/MIx\]꠶jKR-?16ӫƔE10|d:7\cNF {e>j!6|f|庾Ce|:sXg.wd gg·9De9?긏uouvwpd;Q1[;p2iyOG{Ƒץ1YG b,fEW/ڏuE:|Fek<Ι4ܕ_6}99: ۯWu gZbu>LWdG>;>0ﺮC=M3Ss].95֌gas7@'֗A`8;wl蜶ϭu so}cN: };>;<=xή9/_- {և7f R%/w:d;G7_Vϭc \}>ҿ.g5OE9H}\YAߐ9;0ˡ_56?[^Ho\yxw>Uo35Pu'MiOzv~{>9j<N9Vwս3W>4Gk`sw\9zV>g 1|ր԰led 4=39kXvi7נ˝kr||0淎>Zxpkg2"_~vl.ODwCW? NcsmY' =k&%^A]o[ߐQi5'[Zx+߹jh]g.m^4e k,~V|jpOOM5ELuayzѺ`{z 1p>>=ӯ֡8tXOq߰&JCFdv= ]A?4\_#GVm¾ނ<x|Tg[s[+=-TK }:Oq>a]Ss5OùǓҙu>rhm/ӹ7|.H&oi]^Xg;>Cc3,՞Oܱcy`!'u=-;p2@ctxԥ;>ufj3#}.qzky{' wة'rg]ZptoO?, yq|޵nl؝=k|agO?.h(:j:<}zv ϯ0+O4םpҥ˓C=8̧:1}߯=oݾ Lo΃;5Y-p杶bj&'&+җGxxޯz^?>/Yfs̥5aRһr)V-rY+xFg͊ƺY>Qݑjz>7}Y_}8c{_#n:Q9`:+}Ҽᚸ#_}{fvlovkb ud}B>cj~QOT|=][KiݪQ \W$/qchmQ=Ui|Y96/#l~Où}vs΋?)x ~YSCoûs =/y#|/ՙ9#-s:OhF1{|{|/x[sezvyJqv|Uљտgiݷqj ݏӹ3GwZQ5؇<͡=bB{oa!iucN_-b9wpw\RpF閙uEFGuЋ5:MDagw5WwژwOԥLLX٬l}=2!9G]w-}OU}{?ybfϩtSõ7]s}qr~{1?tE#ufu.m]9gY1U>v9nGw]%GHe1;krjy_q|-ОG{|9/\Ә޵ZrY=F1|ws,0w<3ƣ{EDUw>;FsGٽ'C7N]x?5iqy858\c\\'>fZr=֏~<"z`˾^qBEZM|KKX58;gUksyOiky׳}Գֆ O;;ƈsw֥ϭ?%Ds_u0_Zw$( <)DKD-||xG XCJg|#|(Cy3j}΃{`MWGz|n'ZWIV;a9rGMܲZ@㷇ߘ4A'ܒUY?cGsu¿N y~ƾsK #w[{6Nڰ${8T7sؓwؾSGg1,iCߴ\?\2k\aI^Kߩ ݹwz0;ڝrz^ <=} ^<{wN^ơ{`]+=t-Λ[{1\Y\aCꝷT#y =S}gU9qĩT<}7LYu7%o>QiӠAy?SKAAw|A~OzЇ!ky`hj5-ݍ_ː;qV%~5jv|<3҇\u4,KfK4~D9BS>i>;cJ'9FU%&6{r9QLqλ70ÞwՅoz?q·oH[kvΒڵB{J<;yv /N<#Gbn3N޻B.|5+|Z`̀ \CI؏*rvġw,>y=[|<>߅L)|VFSL/궇9g68#Oyn1YUL}ܝ< >O&5s'}//k^;h\/wOw[5rWpNYU 0N wN?Q_r_ܑ˙prQa.B~x tX^2 &= Zcխz:W}V=+4p;D]sy8Q;Gf#Gꮽv=mu0qzLm`FN\zua&Ύyބω;з0i?ftLj_kHAH5q?8uWxOJRmk =㚧&x?:y68oLjNs9O8|}Wv߳{\nΒ1e {B{k~[=8 ? NW)*eI[uzT_nک+Wk]x֐cʓ¾_̍}cWmm.Xz #Wlɾ1Q|'pGBAn*#oG.]}S%N\zw)1q89^~ T=we1ZueWdshXnO) ܴgvjg:jR>ϧ7x^W}hU[Cd%amfj*[&QiZpk`32?2b }AD QAo;p}^tGV]Pw+y+[|.qWM!>!y[0޽vb{ ;*jS'?%kiƀ wv=9灒>ELfvwO~{juz12VQ \6zBXC(Pmeư5ڏ{3r5􍕵NkD{?0!qA>g`oƙwW.9V s=m`_З&Kk^pGVnlmx>b7N}e-PoeE߀kNB͜5{b5}_j>7i qZc9܇7sǾ"u6b6]+"gyXMS+!=ٛ6&q8^X4}Qj05;k\o"=on9w5SPB{Л}MsnzO[qJj/{-{;8@IMy_rB%n:zʻa{o>sS_Ϟ_:#7]?Z\W{W95g c'_Lw׀uu%z{旍L4y},Ʈ=zg kOm?㰶崆󬦞}ַM:V1=cyE"{j 6yJi9sW;^5S>B1V[oLOYq ز*5[:} qlcyrTKѿ~c8|]6踟v/a5Sg~қS>,^;wQ>G.35wŸGruMIq`v>mk8g9:W{ory/ppl%--pCs\hCnـs{¯;Wj}Es",N ԧVΖh5#^Ѳ+ ne~ey@6}cܢEWiWm|Ƀ/ x^X}hU!!E`5iA,25m*V*K̤fdk*)4f $̖D)JGGHjw߭?y|#4=a3?9@'ƹ7M /=ҝ5c2>pyw<-ö^O5)COwl\>1S\Z`/qO\v];O᠎ܹhusXQ\<;>~*||'5oas׆] mw6U!A׻>==5^8Ov}U9>,~(=M QIQ,GOW_m+ m!!w>3R8pN>S[1K ;?16r!?`vwT]h;ga<9#9[98=t7|{f{Z/+E`_KgŞ?SH{{wGk;ׯRZq:zAAm=kzQ/:ԯ_^PO zU.-*>EٻN49ރ(9=~(c9;\x4_(8ђ0pr9~go#npNORR).WM{k{ȑ=ٌelƮgsioaDžN bnCQg QXE)}j5: @<6vNMa.G#6}+Kݽ:fZݜxTn-{~pF,jwrk{eIFSةY(] \ݡq+w 0/ɑQ}kמ?;@͓:;NRge[xZm..䄀WsR*>b K3!5ms|൐C㮋u^X٨u=~rpjk?]ƧW}4UxkzԽ.1dr\=k_)|ZdoL4G/{SuSN Y-"u:?ڔGż!CZ~̃l#gUr9rBƉo95}9bhsR} W 8ijsl.'9qǽ;(XQ0tIo=b'Qȇy{MoaqOaq/`` 3);|mYF|[)]ѷeu<74y?cδS,bq0ȩ hx^WkVU 1Gf>ll4>R%&IIMibb*"^CH G$B#2fug;c?6^{}70]-=?#~*M>*ՈXOq?bZ9zQr(N5=WtO9j q'Eze~ũ&-/yOߒoG9i 0[?zYnwqV-59c’ܻ]m/fwyo jɩofv̊_׺7~fe5ϥO'Ϻ9ghDmwE sR*9R~<:o[oL>C̾MroqW,=#8Gv O*p{g'lz\ucso;)@WnjmUhLw;pZy-=Qy=ϝFagNլX옜Orsn;N:gp.5/u+ t<4w峜k}wFݎv`U}ۧokh:˙㭃F;/a=:=/r3̏sgNyb=c諻q|<yq261{GG{ΔktLRgM~{n`W8_{qFNP[eN1|/k{s^}"7}dS@}zqƦҰ֟3Ý1=|{^_;yM:mp8'Ywlں<|xu1Uc>ӞkhĐC9ku=OobaZQޱԄs㮴N?dFMy2)m>U.;澏"-܉ż7~{w. =gax{3nߋ֥v#VsR2yoSmwZ`oR6];Ol=37]twV𐃦x;2`\ o:UA{\E kgO}RuV3~4Nq[x,;{'S U+ vSGhṹ f=9U+WW<ͫ3zk;M;q,Ro4e>{mwtsN[~xevY|SØUcyX \ma9wSo\q;0$3 G\Q?ͧzg?9smU}ư_};b/>x pIɩ5X}X;kǺݐǼ.UφI3â]f!r< ~9\'7ՆV~|n[^kyLJnK?+u$zggѴu-c0O U#Fj\JUzpv*''fT b)9=mEs0Gsʣ>bSxpM͛w,2ŻTk? ;x3U7m3ַaPSLlDŽ#u0ȡg(4C_^שO޷aIPMĢrs^Ƹ 7=[svv]=hiwzk{{^R9wqqfP7 +"x^Y}hU%1j*jL~n+5ͶbԆ55S[riDeHJZXQY"cHfePJay=ýyssRiN9Ů|?G/z uM|{͓n˘9"'-|q j}?/S=ϭpkȬ\q{Č|h\&-)gUAŞB_3\m^Po;{b{Q<8/Wrչ`V V~Nb3}YD4-3V!ͻ]B|-9&ZR'>P~'Ϩew3=t`8rޢS5Lrzh^cXj+G o zbu߁Zk= 7wYKWFwȧrO0Gz}aQs~v^q?S y+o$ksY=-R=giYZ}M{iz3W^(^9DV-Ґ+9ܙ[5T6ܐEsVIT+~OgZM񴷽0ɑw:$w;q]~ƴsThr,_gKgh[8Z[Fga!5+P3Ηg}V{oyXW96|s. ޭYۗLOO{b^γa7f5u;0˽[2ܻf-oSp3mZBu{ZofySX֣1VA{uْqqbϰ.>Sc1GqSq [ګD\yP3j$_ ^`̹2^}m5|Հnh~k{*zfճ={{ r4HÏj=oncel!Xj<--c=:ӖvLvQ;wW{^FjL5B{9/:z ?jD#Õ:i΀Z폻[_gc=sx+osUx:聽b}a,Sy7o|L<'˜QM+J/[7}i T&NǠbr!}O]ߊLF ê9 _k{ڌ_ ~cׁۘCo<~w&gK4ruv?<mUڌx/{%5X:%uq?s >gE>8}xoߊ{CئnQlaw~?ɏ|w:Ys;7ȇ7Wg[B3ѷVm6n:/{qnߟC~9cU G(xio7ѢNk>5<7w4XY5Aϗ~=cp̷}|ށ/N Փ ^w7XwXø}g=3}?{,]1CƟZgEhʳ§x{=o΃Ώ ħޕ>빗owO/>`kqo8lj{k9V5o]!m7e\ Z՟[-X=}9+ pg{\?꒘{,]b-y憎upމ}=/^=ښG"NuΡ @t9ٹ+|{K!) =WȣG9(Qho\?~ GPm`9s9/9->|'lf+| W{^>)wIIꍔg֬au8=+ዷメ[]á;{c;4wqN)fݻ .G}uց\=Wv =w!u6rAkG2s+4WNM1cAn;-ޟo1 z:3=hn<;~#O8S)^}|.Czt~M;ţ|)nroފ?֛ᆣ_{~pIW+y<`1\1t[u̢8/hף9ԙ1/ktVLSU1>9GZ-ʞË~́4mp''(܏xGgj&ƻ09]j- 7}v{9~zߕ`k4:ywA:sS5);`fQqCnl+k@׽4g}[0YrNe-nF9i09 43謩_d)uUo=SG=|p{Ž׹ґꓧ m-Q6v[A' WѮ{k<r0p>^*r|SJx=g&}߂o{6Zk| E>r-V|6|sܧy;o-ţ7Uhxv>U=/=7_Wړo{[2/՗_ֽr] y_ Зsґ>9/)|`گ1S>HqK_.ui]1w !7kѣ~\Ǟʓ(yz:^- o4zh{b/ud3]w;.;t>Yxb~3[e6宋G?fԚ¯z'uM>\yWqËG^Y˟%ι~tJ󕁹oR[7R?g^}?_oc9_ҖW]:~דݾi}[̡tVsz[zK=|v*tR;Nn*Y6LBig@@U/'.w~ªLUsҧf _8{F]sD8wpTkCv:x[`Lb[[[Ѯ<ؚh'fuo=]aИr/6y0s~GJ쟪gFlokQ쭚fZoYlZ|Ѯͅy/KG~XS~S}VwK{v?vo?zߟ++E%pfSQG>Uݵ'*cuu~gbhS? ^灟=W?u1zRzZ7RE}WJ>[z9u?ϟ]#ō~x8=PxM.s&OS?c#n?Y4xRg_먳Ns.Jytoi/Pa'!ܯ>͸uWj6S;TT?nN^:¯Rj.㞇^ qv}ԝ9#Fdws`}V{kwSظ@j^)?g;Hi>桗}RnZ~]`sVphQW.́>7^d6A÷=ھG?w=.Gww^Y{:~~:o4>[uvka?fgcoFk;?qo XuTřՠ>p<>'L^QcoK}?kx^WkU6) Ll)Plj:>1DŶ"Ba2`)MmFK8MT[iLXDK14JHm|Xwuu7{߹}Ϫ԰{h a ܻ&. z'j GjjkQ1<h2ߚ=ւwA~F}Nfkwc{1}eK| <]ô $Ơ x̞ rpEw_6['DžK?yoo~3iy'~Ǣގ">u rjQrw-yNOȅU?W'hߪx~Qq2꽺wZ>d=侅[]z9׏k0WcsޱsuIVULP[Xq_튀805Z:y~s=:妸qtuCnYxί4pr2m@s@SMW Q0;lY99b9͜u>?PgZNϏCg?V 3{:xt5)ww-?zֺO>$I3+>]ЗE}g=GgwkZr76z>+ |173vs'J廵[:4{J܇߻2zǻ=F. ·|ͦ~115;91_u1 {ZZOh{=QV{{yU9B5ή}T|7AQz\gϵ]U#M%~Xv}typTZvM|3y9sWhT*Z~rZڗ=;W4o>p>{ܶa.r5S?Qskg ~vhp-}*T_{46r{pjkW#Yw\%&wȣħۛ6;yBhGswZGsDC -1:953wQqWČ֥ׄݻ.^ﲁGze]^:;s`.~kh/ΫE^}+v>Fƴ4Wfw/ ܶP̞o|&7ݚĈ g'~\{]A?삭 #ϡȯv<nYYUrg>`;v~g=݅rqN}}k;|o}qzwu/݅Z4wf8;ߜjoi c?j]Ucs>y"GcCKy74hk.>јCgOfS|8:k3п5(ًΠ[z*w'5t6G_w1ɹsܕ3g647jtMxa"8X隣r9{|N>T̋]C/0Qao~[-qg'|O|Gg3~¡&8'֛_-NJ{U͜1+s\5WAߙVԒr=V;i^ss26J>q=ouZ#gwUy8b ǟ[ZiTg3y~%OB}܏k30yɜRzǽpZ]君sgo[]=Be[sYBU>s4}1;)= }mVuE<ޕ񾳱#.v-ZqE|k20w$х~=v.-@w_U'}uGauKgvNs;Q8o'ME>sXo0'hEkmnFxb|wg8b O\sOx:pfT̬5wr171}sbsJw59G3}FoM\7-ߘ-7wS+U{qziɭxGĖy Ѿ)sY ݿ3"ֲ}ؓ}V0kā3qPqLT.jbr^_we1r^%=,{.N*ƪff}ߔI{hqINoəଞssTQ7=Kzr|~ό$^ ߖeWytm轣ͽoߝ)wߵ*YC.욾f7%o}`l]i 6qFW#^{.z"೻.}|zZkkyFkm_sGM]v{ίcOLK>KO)pFnU.y|]:բw{&]S٭弨y/-aϤĜolWZwxGﰋ]ڝҒ߉>~Z3jo}ܿvo~~3麡s[r!8Wyڑo.4V̾;/Lq&gPn-|чa1К8ƍ:ݕ}j@ݹǿ>7}ƴ X+2;٣E}J=5²_gn:!/f9pƶWyo ǟ{w;`smVO8=mEOg.Qj@+P|Gn\ }xq>(:rnL->Ƅ?w _5x_ﯖoγC^.WvU@>up/}4Ux^XkVU))ĴY>!{H}Q 40RQDN5Th=43Q;G(z~L~{7Xskǹ#3nzt+?mӣu딘 _ǟ͇>/?=,+ZBkї̇:gLӿk)T_5=@{)ޯ.`1C㛋2stߵW:})r9Gr#^G~W<#p8a1qEKJR~w~7f-7{Ow'o~{CU0vJxS?]1Vr悌jKg3ЛC^+s{:3؛r3 ]}^܋X Tu haWϘ V]w]ӼL OqwqbCV|t[#.Ղ, kqց@ ;|j@KgU]~蹬/hx}1F.E5-ֺvosp^<ڌxܑ y=ѕ,]}。qFVSU?+osz;uw]ݯ5Yhp^3|}/S=I;ovkzky~R23G_WݧnXU?ĻT/^SrX2ǵ#7yefT<{u7Boa ?S}냑NK0N- rui\hs]\@5\5 N0\ 뽞k:/R{tmr~t紩o-?u>ү\{we]Ygwmы~JK;ZS5]OsS=疨4 Og82-}܌yР!{KqYVpZ0cʏZg-kcuqMreoAc>#{{KJз 9]cOcEsG^W]܉-}O;k?44 ߿nSQP :r5Znݩ/~tv'H/υYk{h|r9=^kxq^k*j/[*_ Γ5bR3c܀;tּ7.׳}v }!'gJw.{lAgOkZkz=>#C{ʶL_Uʠ;I]1bӪ}q ޿nY8œaKc;Q[_ݡksGa| qc.hsQgaޯzᴜU\'6]vTryS]sb{x|b֦O}g8+WѲ.+|[-msй=nM;s]+ՏMk ֫ }@Gw={nVW̃[U8"ZcD뱦#'iM ?)9dI7^;rYpz:^Sm}6C3 Μu秸w)f;г] q\͎;kq8(jdN= 65\g;^?XY<&Wqy8h>*O q3%T2s*_uهyu К]0Os=h>(ۋtj؏j^/8x^W{e f+1k $t{d5Sِ&+5-Bˢ.RJv*qוAve$kB"D" hw^8|o>s\WNwJwnݕszN`gS~Nj|{50N*>poO; ae>Յ;n1~c\=-SM&1pG\#wU%v8Cm={vN@w]y-祐8._ؓ]F,y-ξMFͺ+x9KvD>c wkAwڋۃ,TO3 {nkO;H1m֚bmg?_0|ynF, 3}[:/b|5{c݇:{7p{JOjCCw>c~-xgN6}k7|ĪGG?Wb[c k:3}>erU\s:P.]V]gyl`n|^qr)]j j[亞)r0)BUїŁ1{Q.y|u^_}9"G6,%{*odٱ'bsޒLɻjX‚T-XhDQM瑫9P{QK|;޸;ZOĖ͈)};aGڻϖ:gM ]5A)U}nsN17, !c!۞ x+73>'uxhsUE:kʯKzոNl9/47bg5TYT_~,}v}fCf ~:%vaܽf=;8 4}w.P}ӆ{t><vOkt~i?z.ywĠCF?ojԠ_V5ÚɘsONmy5wN7ߛj5m/d;I˵SvJ~z/a_:@ߦkw?hi[Kq>cŨx8z}ȃ {;VjjN72.58lw8g¼1rSu].wruUOQ-텀rh]J9=%^Xwu~.vagCiʿVU}5`[S?s{yR魯szڱsR~zk'fNYOߧ]8tϜwN}©Wdv G>3!Y4P~i6=ra@ 6+Qmor5λ<^j.x'Xr-̧,Bz>?wsmemߓo~>s5wxO͍f,:#=G/ |n`aҮY>нd6/uؓk Qh3|PϵS9%㠖+]`Gx "GO|Sks8U4GuOօ~g Hz{CrR=:gJ}6cOT&0v 6ayȹ뾌}9@Syԯ?w:`] HͯĴ_Sz@+{Έ;imکvΤ>^\ŋDCXyϊ[jڞ[m_j"nmG3}gѻ%7bv=X;1G[M ̅Or]73O{Ɖ+1яϧKkc֫Gz'OկmY?hU|x]r{!59}N%Ts6W}p.}E:]L@y0S<0fn_ؽ^=poM4  7:2$')V5q)41D>RCsI#aqTfo^} bidtni~Qq.>x^VgFP@4{1;vQ E#bXQAE%V4b7QE{5;yY{{?{|`:S;y,xLcQx;V0 F5jp\A` 8NǂcHp8 ]`k`%3`:L`G` m`$\ rp8sY 0 >`t;`#>X ˂%`aX:`vg߀W 9 | >3GC0|)m&x ^/S 8x<Ɓp-+`0\v]V`Kl:` :XVˁ`qڀ,sW+:E&7$UzL~ -=c7[3u_CW'nѫ,:fA`KǺ l60wn*`EhK[Ud\sf7n6#Tݻ u$vn7;w=Vӽnwѷ  T{[VtUn.u:iXz.N.% {<-#ѽ nrp.w'Cv^Nn `-q7y{zX+\vwn~m`3=}lU'y>k̺,3*0͋-\g8O:y^/ ֘]n 6FoWF:yF[:XȪkw.Kہ950K]&줷&}KW+y,x<PwՇ4u _=_X:W~%՜=GTu(p$8Bs3wif`K!9VSKi"saA;0,:_I i[>Q `Zg^ys[>Ϊ·Y]~taVE33}{Odb^3w9 ʌd>v[v^ޅ"/s=ٌ`.j9'+prVDFrfﳜ;n aEsp0Ghu,[{<.̙yN>\s0~+Ysl9o6Qe7)`1ͻ>fΜVwfGqb'.ljm&M /KN͛vܵ&Hnnu#^s gXAvx }Hp\;@|gZG/D݄>x7+wk1Ӹg(E׻ g׬OjVƞ~5Hyo>wbְs™]3{uܛy߽ޥVqkf/缈^3!:9E-8ݹõ=>8ˎX1ώQ۪wx>;__˳ooֱǭ:oՎq++&Kvrn:/R (ݼD>ncw1D/:} nc a}Pޤñ@[;{;^\ݷ/bvVwEO2͛~sxݻځ_zro^ u˜-s p'ssErW:x?I3[3wQms=yY_Ί&Y_4E9?wgD=9+?&~|X|ND`wU|N]@}.x7C{9o}o|7[$Hwò#o-scI;bt$|7+o!=}}yoxü73 9C8?8;87Fό e9󟷂S{ Vtn~O6=zfziYzg/ib_/o~|.;z.wv˝Bm͚}_ʛ_}Gp{9}T3mgM 3Fνe:]"$wFX=ɝy,Tpd_ogoq/ww47y,ozˎw6R=py3=Y~]vdv${9^FFoyeG?<)9nw+vkrvrr4yt,mFMs{yU^Ʒh|.|z>3N}{ ܍msdlx݌;vxZfjѿihSG;؝{udp]:2xMigs5w)HohT{7݌]s Ӱww[-֤޽D]E]Koסj|n\!̽8+;1n\GYkh_ϑ_9x^ٸwKqwP\J!% !@q NqnCΰ׬}~wgfyk%|>w[u*x<FgSqx]` \pgSI`(8 G#p =n`W3[z`6X V`iX,y\`603LSd`01(g3'|>`x^σqx< wfp#\ 0p8ǂApp vہ`kGX }A"X, K"`073`Z0 L &c1n _/! o`$x <vpn׃k5 pgidp8ǀ`v;5 l6 @?zr`X,  `n0fL`0=LS$`b06p#(M?-o7J:D ,%nׁUJpE /sJw~"8ǔ~{.%_M%䀹𝮷__%|{~cpJ`w[ GWK|>̇,mEF ^̉x"'Ȉ-2&\c-dpEV0'[YloXd&`cXְ̌`9X, sYfa"0!"'/KV|yfG}Ϝx3'̊=~l# Wyd%pgFʇ=a8##}=b;|PȄj`eG.,fO `W%|Q%fmv6~dw7ݦi{nIo~r}L?m?.tX-%rq0c շɽ1['>׭=iA ;,bsiT Z4;  Yx7E_,ZѷvO;Zkݜ`v0+diy8w?xWs1(cv#;1ة# {=dPb+3/)߅C %=dwr+s#k+*- ;r=B=Lia|$%Qۗ}H'=u|ۢ";O.,u]i,v\p'Xx 8 AEm 6j-os캶sSX87 g}{z9vۃ[w&fѽ<=zcqkm4Y{qһszv\_VܾtFީӦMqǎ)=oXVZ{ܫ4kKʻ] .*}p pj***ݓw?ޣܧto˒;u[u#~ 'kGɎگܭڬL%{rts0NޫVFԝcٝީUT:W޴XUvܮQuOnm-{v3^rl'u^- Lشc[+OzZv-}nwKPiٻG {۷ݻ=Xe~IvAvo.s@[WY0e{\m}DsյW&3Kިܻr}oշ{۔`{0lS2t]]R{U,u{N}[㻺]E7^oXtYt뾗GX{0prWYN{yw˻upg:_l2/ab}>~tk;=熦O{F~~س5v/ MO}Λhg[{ZoYxgy5=6u~|қnYw5Jtr]i9}Rw4mwKlmNrJ=};4;׽ϮfkNUIߤ{t{=a `3*=Ysv{.xCN]O&UVNYluu缛zgj?K`Ѣ̀-,3}-[P{_w,{: P ,􆥮ӣ_Nwunʇ<򡾱/auf('|қ`?7 fF`^37swĕέFέPH[};tә)}L;^V/-x[([x-·4uX"[+|OS='-|cSԷ}+s-mJ䉲9R e#ʐ ,톫-CEfm=1C#1NEv(7x'07Xq7p3Noo ,<!79n 0C3?-[9m w{wF3rov [rbglh7wv7޿뻠Fݺ{%VӛK~w5~m|u>o|zI'O/ΧtR]ߧ_=O~S5zNa`^}wYnqkӵ{|bob&75upۧ%-bol,n1|_oomsNno^u{Sͽ>[]otˍ>7p:;Kؿ|ΛL״pnq)KT?~Qz~î76ߏ`nka%ӵ=-4Nc]\d).[ncu0w;0oz]L70]GZtY> q[fY8#:ۚ7mItdǷ?Cw=Җ}͛oW7;nT߽ܶܵrD.枥ON )Kt~nm:ޱ=;w]۷o&OyzrW%6+o`}祳w6v/7lַ͆az;YouKs>ԣo:+P.=Vm<\hgBQZz>a.u߹ZdD}3#,ŘuFso kEf=\Vp avcm۞r~S֢)}}wޭE\=ɟizKF7t;k:uzhP-k-mzΕͽy3o輕72e ozkfl9wN3sh|7r{z0a0g+߄;A;{*x^wW>v!:6+XHcbb,,hDE1]{h֚=kιw:ɛfַf[&0jp%8 `8xp,8C ph`/' ` lM`#>XV]`y, KE`>0/~1OG0|_ 9 | >SGCxL[Mx &9 x<c!0 <Fp-\. tp8 0'p8}` zm6` l6j`U XˁeA'$X, AG0t\`0 ~A[5|>;M:x & 0<c0 a^(O%:>NKRv=t884KួRxgl9[pN${I᝕J);fN?莟=L<\AO<3i/X_<F=q/芡[}!G A?s+>Cg=uzfCn#{3p1f{]2ɢn8"P0Zxd YS-3m`˙W7a޻-3]֚ƂUv95gTt/ƃqT3svRk w{ YgΕR}SS5~z-vM_0O_0`{`=&蒪=cAcŜ)!O[{E ]=  v<3=qܛ菋=ƅ|BMiwKmG' Vz?Uڙ؁+jnϮ*w%<%/HHHb'>{|F[t :;mҝF;D>}-v3GYN:KXt^H~ê{N`GkMҚVR;iSv~ժOٛ%?tP}ԏtQ?*{QىJS@݇-Î Cl9[p o*tz9efRvYSf4ue_p Lav==,totn)RrAuO<MdϷ[.;2]嚙.okhd7f5tP`zmC݂w v ZO.;r"$wSfڻ*cDž,wr10E7`&I˨'sd}YeOPN˞*IEY-w2ʫg|7=1;2QL)wVOf()W^Y(N/{W09F{έ7]F5ovqW̹,k3{Q^78|놡Y+8 e[7bf;!7)uN9]Cg;eC>)7Bw8SnY!'pvqG3w9F}kfk^ApnAp^W6YS|ЍQ$Zwg^@9p;[5 {d_y/g6~7mvrvz=Vf?Pmc6ۘsyM>[sVϻi9Ku_'%\]x|ɢ'`qLb9uR'nًؓ]xsXf3"/ ~RpZʽTݏK^܋ySGeo}ubV ymڃy9'w+{U[)f.=E>u ge&i&j.r9So}KJiV> L<'Wr'l,1s[8s;ԫ;1~.{:2gf 3ǺsqN2+"3^N\<ߪr62k̮Z0/g:p9Cy]XsT;0{pz;CgY~{Zsg }qx^ۖU>v95v!=`؉]b0ݢb0vֵ9yk^k|YOtk`2X V˂`!0/ fُ|>x<1axFmfp- .9 p 8 ǁpp(8}nl A/ `1(X,`6 fӃig7|>[M:x< O}^pFbp>8NXp48 }`Ov;@o l=A `) 3N`F0=L[)| >D0^i ƀ! w;p#\ stp8n/M`]=?[?.`0ef-|SZ~Oٳ/g)\^K`\ MI F߃~p7pp8\)HSS89l¿-`3)(tX ˁe`)03eg)8o>~$xK9x<zx{x3\׀a p!`p8Axt{I7pXV+`I7E@0`SxjW,| _/3Ino jhQ =.~Z8>`8Ӳ링S-oaPp{=,/|١kZ P.n [ݥs,:u90~)jٵ[}-]g{Rt).t0,E_r_N>dNfRR)`}fVH˦ȁ%Sy;cx7 x\xs6{6nY̆3"l8lP.C=a/fw z:{yf޳gjz.g_^w-zm qaeXY.fZj̾>~`},:|' pzegttZL/ڿrRn({{XnL)2(e?Ovtu+=>EoN RvUMgH);ΑZ]UwשR7M)Gy[˹.qo(_gxkrgns:}8أt\[;^;;@}5 .&XcȆӀ=r\]=n -uxp?ȓ-hyb3O FYd mL\by#0[α/ @73G7~[E Ș,rf= tX^n%,gsAa;b6bF[95 Le+&̮fgf1LD|b6)A:?Ƞr7{=Rf wnum  ou)voe^Ϭ)! 5 NPp'p]Lv`p^M.O#'z3 wþon3yy3x#h'z~mrc,n|-*Eiᠺuz[,6;[|bӯ%-{].BoR?ͽtHo]tGMwUk7rgӡ=SٻSt[MxoiՔ7nn:%K=]v_;V'??[ooz<{ޥ+ٷ#冦t^lͫnKw4hK7r*otޣ+znnu.,wa[t (|nV.#uY7k}Sy{o`囗]rW-l~ӥS7.7;Z]M]=6}.zXw֦#S}67v(^{۹^ӻL_w/{Mo]RvRwps_7d|м).Ǝ̛紛(dy~C=Lo;9L︙m\z'62qsLʴ2̓{}3o[Y١-[k>G5THk͊7/`nX\|KkٳznN=̝X՝\}qKvyen0/&U1eƘܻy_O)6s:^7-M3y).7*o= ΥϺNeߖ)o Wсߜ(<ݽ~l-ӛ{[G tv.ΛAo[Xx1^n(T-B 䔶&z}yww7MGw ]㛞 ʷpn&[qpݎq")Iys讠w-OAGo3fJ)o z-g?۽#&wCtp;p7m. mPʷ@n=@{`34=VaO6@w0Z:w:N?l^eu/1;+}wkٯ,oXvٯt|}˽7|ex^UϽȨv FTTV` Ա[ 1kԱQcb?sYGz}KRbqS,&sS.fĴb1,:OG^|+gcxK)/3ixX<(ĽbK)ĭfqA\- Lq8E$NljcQbO#{`E$v;bHk5EXNU+3tJL):ID'E&oW T|$c[q-[ qL\$.)Dq8N+G>bQ &m6bKLu:XU b XP/snbf1,~S_;F|-_/bL|*>81V)/ċxN<-bW)׊kĕbT\,sY q8Q b"bK)b{1P [MFXM^bEXH, 7}^13XVO~W#gtIxD<=% #sɄ{rɂȁkU9p8O=sɄŰpL\8Ty$[eρĪX1{,+=Ţba?{F-ʞ3dωirɉ.bY)yF|JN|<'>IĻdIDxP<<;F3dG-L'dɳ|q^<9G&%{n3es*Blļ6h>uOne;6^6w9]8nMog,۝.~.ןdwb{tqG~okry_Vy:v.gx{=ۈNzs~ '7s&fݑ _txkS&mOG7 Nt{dwN'#ur'7Z8X3yf V+_4y5yfɟT3<0[y6 Xg7} ՞ǖkDxu컡~ww!x[=|!]?d46=8ћ^^'{8e*7;N-GvWyKWSST6vks:5(nFFƾ֩]ǦN]&5bSS5=ʮnɓClulѓk_.+wol/olћ'[;ѝd`es>ҫE/kӥ=:5g`t(Io.{wY};kdG7V άoƿַ5]=vﺹy m`v7w2]qӧxN>5oho7^}6'ҟ6nhwoVozw.wu`w/wp8vK*~vM(}N#;E׎kyq8nי,ʛ7Yeǘ!݊uK/;x5G3w^aqí1Nӭ>nw/VG[Yӫlx۶~w}=\g#cqnxݳrZ޹w𽾓͵]r<=M7׽Rq6w~޾T\< .Lv띬΂CSdj:^=5nM u5:]Y&:z;V޾y@DyF[בϯ^w+nf%v[9N ۻfy =kg oॲߩ=;]'t}n557sc⼍^sܺnu8kcRu4>(^[ۖ}{;5]_6{|C qI\'׈UJq-gşIDq8N+GqK nb"vۉmb@ b]/rbiXD,, Ĝb1AL/Sb21!_G+KxL'ƈvq\\&.Hq8G)NNjc0q!p1L"b\l*6 XE,VŒbq_% bj1LL*&=D&w(~ߋ8B|.>OCxG%^sYxJ<,ŵJ1ZE(G`qXq|[s[- D_+N/oMqz sq3x6s1N`*P/^ 9LvO^1F%n7g_kW/Ĺ쾆G?!@Gvp en7wgw~ݻ{7.&fM)3/]}={/{oޫ="HŻ-fqN\K%Tr}&K_?N߶xuO]"q8=Tbtbw+95vssZ}5k:%яލYpSܑ}~=GGsOޕ'tdxxxUvWu\ru˳g߶#'ɞ;& L|:{0N'ᅬ'ă]qߘ.J&{3;0{.ݥXrwqWgNNJi8l}]3 D&u2Wr/=L;`xv^j:4|iA?yoln~-fYj"qVu΋`r79e>}Ugs!vB\v r!sʝˬ/̊ϋqEq~nrqZgn:̐crr'ܨ:;҃ǧAݓwZF\:rb;{qnl\!5Bљ$ yON}l, Tt=#❅.wfẺ{2'zTTu :_s^p- uTTKZϓ_:Gt3$nt Oϐǭܐgp7S:Õrd5gJv:{}[w5X7/ls rԳÞ+^N;<|Nzx ؽG%jO NqH*3#Y %_ڻAqjf>Į0cj0 17'\n;:w3kGOn89z0.o8݊nrz<7 'wvc;q,ǿò{.Mǧ>M&_tyr':D6x ^W` Fg>p'K@?p}y p2ǁcQ;8]`W3l `s)l6`M*XK`qX\%M?ik%| >w[`xv'1`gy{כ;|*<>i|(8i/dz{`gۀF`)=\7+=ۥץd~Op~mz< FYx=<#MFp\ K-2@ޟzZ}p3@ 7,ܦ׫Y2X,x [[xʲtt~Rd./πS8M!Pz]:}K IUngSI#)p{ƁH5`{d?.pG0 nbYswr~v<^ Ϛx.pyM? 9[uVb~֪n!Vfn_k1g78 iiDCAm`ks{3 :NrV/i2=Ü X9-\O2ߦt9))&3NMkl-56 =p{ oקR;MOHݦלt_y#e9w;[p~y+r^tE o~f@}O<9Lwx{p"/;mS))gras̄V)2aޔsA=Olcs'p Kd_.)X8fdlӗnK>~~7I{|Sѩk_eg~g OJyg`Wu'FW=s7Kug:ە;3!pv}-;Y9^7| 9" ù׳s.˝̃]Swyʁu)_)s?;vwL`uz>{s7=we `ok}uux'Μܟ]}-Ns^y,fOOn{tyӜmܥ5ǧ|lڧ˻<ݝͭ{+5:;nxtf!5z<;=r:~{\^;>;ܧ׼^=t]tN@9^:ot=`9P89כzsf@(xM폿+7xktMcU,gǕow-2-NPunqwƧ1]dFD;ɓ^}OyN"oM[}a0E 3Kyś^}{TVtYļd>E yPO[7`ko݄oJۧr) O;roR3u2qT4S̴ީk0ӎM9ԟlc[1ԭاf֥fGC;'z{:$s76oA0xd=une/F{Üg(ki֓-}0Cx );/&[wf7fDyfq݂G{SymM},?Rx^ƌ=kǦAc4#VGU5bVB쑠bXmjM^Ws<ϛ9=kߧm}`uVR` ,9`f05̾_/gx ρ 8x <  ^p nqp3\ WEBp.8NSIxp, ` A` l A?!X k>`e"X, /|' zY@w0#tS{|>O=dx < }.p' n7 zp98 N'q`8F`88 !`?  3 [f`clV}*`%,X, `.0'~S~Ew7,xL>pnׁk` 8 $p"8FPp08  3m6`Kvk[S`X,BV4L>5.R_/")$R)H]^κ(n3|e/ hpNcjx8$R7%RFw+%M-K}hx-f~ˁe#Tk-9l:)RӁE_zjS 7=L ^/{jy0S `oqWz2p!E{^4}8{y3y`#Oj^=/zyjn- | K>w;m&xAh}z~G0l6 `]/7sO, ~ ^&0wɊw3#Z- OYI_JW7nW+,4#P7=uSMH+DfHݲK٣~?ylzStIqovxj)Ҵ2 ϼgSseiX=㡞=/zVgjg޳SkyѰz;3 ټd>{;5.Gfq+y[3[B+ZS,Ǭ>2YjPcNc?`k[ϺKZjoQkux&K+[;;uN6ҧ;٪ߕwGZG>J.?.N,vKD.u)ѥsntjL3=ug>So{KSꇽ~^k<y~gϧs(O_'xnyz>כٮO_Xճכ~!|׶sozԹnzʫ[5o~V2_;Fp\lMh YOas^[k/X_yr7@AF zhH9s5WE!on0*cxcWݏî^]w ^n//ᝯW/ [=~A_`~p.O`S \^:<@7vN Pgڳwkz+/6? Zg^ZXg՚ie}p e.X􀣭{ksKkK,WwQ45]_kw~j+K voۇFvav:Jf VQ]SWԱ4g3߹ũcYN9ԯ֭Yw9;KKҩsiQZdF3k-R YS3{n^:|}7V|T.'XF72o(wu-_siGY=Ȋnn}kr y}oZ{ѼQúq#mnrte= ]w0& ^u~B ?2;aQԽn>{/{ ho%ϵ~?3^_,vX0a(zS=|_o)~#x< tzӝ~//szߵ=BP!=gxtwyG卐wwVHc݁^ƽQʿt q>OǛo5g Q>Ou!#jo%tG+}A 5ҳZ}]Co,7=[~{m 1Zxߐm`yhn/[GPO&66g0פHAS7wѷI]a?һt$7 ޠ֠J9{o(ڛV%ww4sEsww9uz{֠k{D=j_H?Ohg+/ ?x?XϞBkGPwvi]wj坵:֍uˮA]u+1%zd5ɷ@[yo䝑ګW۠5ֺ5jnZz;u^}Zk;;7;8}~}m;{@Hy7T_yOyN;q'P{8ɛZf`Ns'٬m75)71뛞v`5$o{q3t[vh ukMkShǽ{r"+ּkn{j~lV- [n^Wj)x=G~QvF`&M=!{ al?(!FG2~0*r+9Kۀ#@@3su_w{Qt\eOW?冧ǯu~jߏԱz5u`lZ-`s4uZ`%`W3| _RXo0ƃgS` x<p; n7+004p*@ 6` 9 l 6 z`]PV+~o ,"0 &`{?vp- \ .#p0 Lp8S@Op8  pv;v`+lu@}P WX~߃5 | S X> \qMQOŵ&hOuC\ 00\,jzxp,8!.z9P\Βh#&`cl6M U*`N~V%iI/`"x\/Oks] nׂ͵q !` t7qhp8 `/\G`+1hַV4e|ISKGCxo5*IkπQx nשkp.SuFSYYc'kUgGC5/褮=uY]oۃ59D]w5*;lk~u>Z=jݱnK`"x<#I{q]u7T^+~*I.NQsOQIPsq&QoI\=I7G]]0̲u7Vd~38+*ި,ݩO׃kX`pux8\}oDp%v-q?sMQn{F=66d#sMe@=smέdo5铚,V%Bu}Qu:G]%o9M>Ap?ܣոڽ^^N^ !`{_fIgk{uMM㎠hکkzMZ[kfH]MԵͻP]kk`5&gq緖7}yR;}}Y5*>FڿARf\Ϭ>S\yF[+' {L`^s*7P5%dzo2+~g6g,=yvN;3ۯi>2Oc7n9$2/^sO)򍧟_dU?a&>ے5zz =32=7x0Y:g_ɒ`7lY,Q+GRMASMY<@Ss3E7+~bg}y#rT'2=(I2ȋ9yq/ɣʝ:r HI^͎@.U,C$kfv٥xva`ǦwEo=,~Q4m%嚖٦ +6}+wW["|b̽Y_`GxYK~lDO;JRq-u{o-&/2UV9s#;1|Ax#sWtF zVco ro`aw+N`Gs|Wnc[2ͼc䟱[0v+zyy|)}Su }]uTީHdBzDl?)'UMWr̊̋Q.]Nxg33FwȐ{M]0\jPnZaVGAW-n3̛Խ?|jʝ3w0{n 6Ԕ?={Vg܄{ ?siя"݇}LIbmM>xPwnpSFng⽈-2p)Df Ϲgܸ콼̼!KiAo 7HWpT(ޓ%ݒz2wyMi&uoV$edޘ3r~c]: o[n;Vx7[ptkn\ѫ؂̴e݇}/VܷK}96ykr^bVVkg_ٳ+];Ϸ4yieճ,s,wњ|~167r/SK2Ӗw.ݙ^mLwEw=*6<̗̎EC?Q5~I EN %=ْSRLI/ we=@|uNmץȌb˝Ȍ8׼Ro\1{h<`/ u_ryo|g:v^~F3EoCw9qr8MD=Sˡa%fz,Z<;Y-Gej44|~J=͋w/f;69 F`,9"4\eͲgrۢf,)Kfv yfȵLݑ:foĮ75vyWtNJ+4=0ޔBܸYe,EMSϡzfף~l~kfIK.z[Q:eO+4ӡQVܥ\ܟVFM>Gq-iEn_4mz@]=wnoh]&nq7K]FR_(rb%$'k0{Zx1gv1bsfw׹x?K>P3L>߳ƈw(Io9{A#ķ#F3s>3~y#{\s}v܊5<}"q9Q=,<"D.Tk3=o}yɷ+[@+~ǃpx`'G-Gys@jT߸Ddx^s6Bָd˒{XH")K,좲+:cAo纯>q:}p|;m:xO'Qp/ &[p3 FBp8 p$8~ }`G lMF`6X tk5@g:X :2`iX, /5|>7u xLSCnp'F0\#"p8 g! 0Nǂ0p(8 }`[-`=XV+eR=X,ځ?` $ OG0|_/s|>w`x<Ot0 h0 \ #e`8\.alpNGCA@p {A_ۂ`31XtkN`U2KX8hc-;5|+`3<||û=n7γwc{x/gLp:N'`8*={z8 z{'l 6{xݼ)]Kxx(Xس{:pg_/S4e?x2I_{ Oy)=;e=GFwR#l`.oy 9םRv{ˤ| X4U]_8W|y!޵p5/X0ݦ׽`+ ^ kΖ}_,k3ph Z[Ls&7lf?ՙ, nj3*p%\ {̄29Ϝg<ρ(om΁ns`YjNjt'oG~)2%N2QԘpR'p]]U7Y>מ)|e/j"-{vHՎVv/S8Ξ.g*|ٲYe7,7YQ|fɡ>2{[}[Ns7YcӲ;[gvoH7ctrm 'J..oXrFK|r\ޣ9ϙ{yu_~cG.s2Z^9(:ef2}?z1ee.[pmw)wpQ]x+Us=a0-eN7):u)z9]"t2=tuHj>;t _ճ)m Kѯ&{z5]Vau,Vtxew%NrSQUݬ{̜TgVV`џٕE|J qdv77,f%doffoY17-s [X̄M1˝Za%˙;Zګ٥9/<3ݜxennoun֜ pٜ =wk=mܱ9'xܿ5v|Oco95#zf:gss e9y̆R39M_~Cf:nh_qk7z2{=N\RmyxwMLM߿u鶼ڑߥtNn>ˢgS-28 gp޽nuk]~b-swթXuWK (vLfx8LWOpwWbexWi_fecpz[ߓy mw=|e׳|b LsW8}/wsgt9s3=~$,'V01׼yS8N_ wq޾yڥy.ͻ]RҺsݾ83/ҺkV򍋽worͽs-;kEwOYM G[~\Ȫ7pzΈӬ:#-}}F.]]_ŜbNXl(wo71jFA͹G -×yͬ<=g3gvF`wowuݹg3t|E2߯t\޽~N.;k)߭MUvo[uyܗ)73Sovo޻RY|.MO)rK/VE6U޳͜O9: ޲NL坙`ޱ؅p oLyW.;qz0w&˷JhYYySQ݊Y܈k~Vn0; ݦ舼(oQyd!߆vwcvXy䎫+Gavm<=(:!Xc}}j)oy-;z-3GϦF6=UoTst;.3]r}tbr==5ke.]cfF*ՇNě3pT-uaz5z?f^2+ݲtwfG=X2?[CssR~o}Ey>/ߓ;{h!؁ܗ,ܤ|WR `lp2/f#sgwe_SL,;]s;O#!"x< O'Qx<{v0 &p!F 0 Fx0 }> `S =`=X ˁeb`Q@;_o;- | >{]&^ρg`x<{`2 L`\ q0 gid0 'qp0.`=l 6^nuKvug5;Eu~S pnK&kvŽJf ܯ§oV~UzVݰÓ×2%vpzM[SܣO/ѓEN^GZԽmm"Pl^Ѓ{W߼*}ߔ۷ܼ y97-hzQCꝃ;~6xK߼gp`{UtjWEަWB=[ZwDl;4(xDc;Eܢse;[bzw8H[ZQ7'p{~zGXM7m-oZu{]e/Σ;Aޜ5Np+NG+ |T_C$>X2onR)2גjs"Iwxl/uf6ob޸2sjVs3ǩy3ݼ1tl X=MS߱[62#PnBӼQ .\;!6 LRq3cs\; fVI?jEN_nQf;ٵx(oq`׈E9³b3sYjVyBϪHl a4٭-n.n^1Edw~2Nw h;ix{Jy!]Z.cbA_-[mHG=7ʎCO~tťAKʮ5[ϛӷYm`/UOxo?Ω.>4ʲѫʽsa**jsܧxˈ^ʫؑS7ŶGu)v%/aWO]wҟj_Oq]#ztW_,?NB/V[Yy9.V#[LE;YϸJ+S?fT^{jUVSE5;`/ o==[ѳޔZү«*pK_ѫxg-=ٮE:[|[>5D=i4~CIJ?\܏InŭU'KJPoA$wk"ٳ>U7~/gNĭ[iyGaz O ?; Da6!}$5X 4<$|c1+'~Ht#J7e?u?R%D?cj/Q[$v 4URꚌL7DSw~`s47N.51FQ] B{f>5l&\w S* 5DP/.)oq;nCF qkpc}꽁ZbFBCO |dx^gV>b6^ Ɔ`E(1*`G[T`WXƊ@EQ cE#X{D{֜s{13ό9kup$8}~`_#]ftk`U2X,E`! X sAn |wxL/sY0< c~p n:p- gS)$pG =>`[ [l6A{ X ,فG-| >Ot! ^/q0(pKPp8 N'cQpp8Aw t `=:X˃b`a  yIG[3'#0 ^σgD(x<w[`$ p\g!Lp8cSvPpP HRO; vI)­MRX7[*`%b~-±R8FU[0G^͖­|ٱ<ٳ=<{ <$gcAo0 n7zJ.<p4zw 8ÿ~zxV;K:IW+=|\ū)ůzD W{S:s&E*S/t"OˮL wIK2K=_{;[;kMC#_k^r},f&+>#;pٸGfblS7jW]m:͌yM y5/a[~_-wscEeNS[Mx"B|zFX8=\n\pG?[9ˎeZ)w뀵-_r+Ë[_w&S_0 EOMSp}_L.V?.4.ݦϧY+RxL-;0}evOᬲVήr.r說)KguOՌ-ꭷ%{.eS::Sf]f>KOg뎲ϳ={8ے~򮤣Gyvϝv7Yޗ]v#YV|k2=oto[sUoTv',Tfg9ogz|>ߢ>=âŢiǻZUt} WZOfVuL7Zܬ̺[_,~Y;V9fw )g)2oQ9IuKG=#EfA)u)3O99W.sYݚ.}{)Iϝ"<53޳_OkyhuiHr<ܗtd޲rgnMu26s{/}g/=歪E[f|Ǻu0ssuo9o1waY{Ed[x̾}EV>"ٵf7y]ff7ny,X"]=f֤l,cLl,{nm߾ko.(?:.o+;*߃ -=wȽ-zq[nwܘh`徤U=|z kyNN }Yާڗ꽖+{cmb:͘{/OuYnr^3=Kt^˖L0hknj[&G[xLnC`4Aޘ,\ޫ|denͭI_eT-;mjSuo"^IUWե?Ǧ)jofg7jw斤[NUl=H[mTOy[UfewK_u2HykRi4Y[җ{w=.}[6}9ճ:oZ&nݺguǖ{`9Iwr]ʬ[>ż.wfv[Y~7]ߧڂ]`u, K@70#LS)x`\0p ~?w+|>/ 9,x<ppp8 Adp8 6`!=JX,`>0/fL`0L &q@_;-|>}x< cp? n7bp Ni`8 `v}`+) z@O Xˁ`. ft`J0 LqOm}>{m&x ^σg3)8x < {.Op; \ 烳p80p(8]Π/ۂV` ȋk^Vp!0{{qoOK-]={%zDܡ7?`l3Fz^/"}y2ҙ'`xFHMN]UJpyQܢS`82ҭ`(~ѭ]" [F1lk`HV#[, |`H\L`H蚃|SO>zGF{qk = nW"0iT0ӻxwtn7ӻvm 6z"XtoKz7ŵi96Ӭʬ|{c 9,| aeQ9ș7 \bez ~.m9vtr'.n`#眜䜣snV Lh#g}1gt(3#f7_/3Nr=W::{[4GDzJG/T3pH9x8 Έt3H.{Fv9XAGz&ե#g‘jM?儑n`iQE9ZG:J?򜍚O'Dڙ=g" \ =gٞٓ==ۣ<}=sVr^rFqsOg+yqtyJtyAOl^z+`*o:=1=,;\n[{9xn9k۝3aK5ccWˬZ.{,eU]sy4gm705{댖uz0 rggG\hz?Nss[Qt^](3Ȯy/L{]#g2{nsdΙR="3{0g3_4rF5y#g4r`hjI#@΁z^<;̯x+w̆|g zf^`oAݟ ^A}zWٮ.ϼgKvSKe]3/{Y=~;+ɼx ^fo」91{e?\g9ce7вiZf)@;0{B{YfȮ}e[fʶV5-=O0s{)ȿ;aư?7(c/fvvgPO`G .)evee xpTd7Pv07YlP0 8GGq (́=;^X1t6.~~<;9;Wno]niv:L+iZ.yRoUޛ߷c,;yW>o.J^kPKO9}N);=sw+nM89+[:XŬyӚڿIqsގtf<;|{w5d9;sQnXExɮQuOܦלN|Ug1vh݆GvEnC-,͊nŻ+}7Ly5vhr7&Wuk"kށ_ڝ+ލܡ[g.ptkѻz5o[WDٛ/w糢ܻ8 ٩w.vj̺q4ehv螑.7A7wU&X٤LjQ{*E,xg,wnfwO-.,2?=㻃,3{|I7̢٬,bQzכ7hl4zc;NW9P=cx ;38!*GLbaAg]D;3J\b ^e^^|R6ȌmQ7EfsJ݈yP[{;0gԓ~yf]yws݉mQ; zUxwa/A0tPb~~Gsރx}Q{3l%/9{#y,~Υj K̴v~ohߛ#oY?3c0Ӵ1Ӵw9{PRno/d̯Ŝ%oڷ6+v&~5,Ou0涮ij';zT;Z.Rw)3gG%3J;Y}Tw]M'f;3,MDY/J_ya> ]wf w !,/l?0SyR_>cc~=L#ܿԇx#a^V;%B AHm \m7˺(e oK}[MF)W&0r}j_pǡu;zƎX]~AetYUǘYʎљ{>|M޾=F+FDU/tS!"re3/Σ; {E(݁7]"w:;&;šQ'gzD.Ϊ Klgomff}=G{:4&#Ct}x^wU>vk1vcѠXb,5`h %ƎQ,(vY+{֜s{(7?o7 zp- @p!g3i$p8~:l6M`mXV˂V`I8X,  ٷ0 |>OL1|>x ^Six{.0 p%.gSA/7p<8 z``= :` l65`U2h Vr`$X,-"X,o6#|_#`*πd8xnpn-`p @p\~p8G.3ځ`C#X V5XK`q,yO|fO]6x π 0 L1>pFWy{̳l׫ G}t\9_߁%<;=|7`'0L?h` תp~e%?8mS1=-r Yj9vuZ`5 W˃Vi< |<@oHW{Zbу;؅7FOً)gdnr6gᦼ,{.\Rtr&>tPޕ}qҹ+R?:wQ O3O [{]G#a)d=˝SR&`j)fa3p-gܤy8YxӼ~[Y妺fb lFMUzr;rԳx.cFMżyǣj^չ>+'wW:I79]'YG99\oUGc^lIg)(;+U:,wUOꮚt?r2U{:,} K,}.*gJs_iejiKSx?Vg_1f=RtYWx;Mʽv(=V9VU;«k` x <8ۻ=*ݽstA0\YKiߵ M߹o``睻{7~R)7⾢bOβ="ڥjm"ǘaK0x3NNlӝUl+o3ʷ?^/2yWvwztqG xygnJFQTܥegUFv&co|ŜdzSt^>ՖK{03կ,o&JS}N geS1/jfN[[MUw-V6`ܼE*CݸcrhNZ)Qp;wmq/=fmG|mva2SuY^"cǾ-3sMʹ[\ow;)U,mϘp23Jf$wʹ+w2e^)3)Eq7cmYW8曲mՔ󍽭u|~K,봻snʱ>,c=YeVƜҝM7ZxE<6sG}=43H7rgqʝ<-P(oxћʶ;eP,RAQ_=Tflrp+3Y0[+LK-tO%&:wu8xU'~,ףŃ~uٝ>KcQ=>B 182g5Īb.-b=rq4*7(7=Qatq:Vƚ9z8>}5<8>|ʸq%tT%O_tU3Sr?k7HM?q=/q2'M|Ŋ?tvvk2۫oCK]|kι={;˒܀pynvVn@8=5o2{(7_zyܼn5KyinC]kt(]v\G~T.K־8nv龱.KfW]R,={cg38l80`\f0|NΌi3fn;3󆽙j>{ܡٟ;34==w[݁YS{1kfNoLSםB_]YBvO qMqErYnvzvDO8,~P zÿc1b.+׽P93[sܳp>v1t:7,|u/OԽܯAtt{b܅\}}TnX%w8-;rWl߸ݙЕܶp*.3ոmW.FDoC{f h:~hW]?\]=.\wd[X.qG/p8 ܒ}߾!wp>º opndw]sq+o n5#qkUj?65wW8w:v?ջy85y11 ܱcpcJdVg1 b/Y`?gAܰm_v;ߴzydq?j;sNk;wp64wN[^wZ{xJR8RfM˭,\e/k9i_t,{yq/\ugYr+N>oq\nDO_d5+oN]ݫW\_Jnlgf]{K_2<;nqgݞ.ܴqv5Rjuc'q7nnv}c㶖%wǍݻ-ӻmۙ5x^Ĺѻx[fͻ8]q YWayy/kefox1#uޟ3/{C.s_y3gOVr׾%p7xinrۋmgod/9^oȸӼO-˻Ԝy &ѵ[e&kq7vώ܋ߏK%ٳٱa27nq$w8g]$wup:ߊ+9o-^9;©*vihzwءcw]waveu1ѩrV+]d/_`kcdVޞ6*ro+㨝[ٓçҢjfEFO_v\ޤȱҍ#s˞{Ytٸ}LŸ.̮KߍWx3{xW<}sx^Ɵ'Pl"{Nw-7 &y0<Ɓ J0\. @p>8NG>`g#l @Xk@&XV˃eҠX,` _|3!xL/sQ0Fp; Rp \8 GCA zn`W3 lA7k`%XKE"`A0?-<<5ef/SvM)).xxD M`T -ѵ)<);CA@O [@m»uR8V+R tHkMa%O' >k*b{Eӓqx¯HkM %pXp `' ךת`%, :UZ­E3z y/ώ=&=sW] p /NHopOwn.n6y˵ˁN^ɥbn. zv^n~K:)k):3('Յt=8\J /I O3SG#R׃S8 L?:n]uKuS)W>gϖ}YCoѓX/[+[g,ª?bs}E^oѣz. gp]uv`s 7,ݥ/zݧmw{e׹ea尺]o&7Kw{s9w5}zWv.o˷:fFu==\岏>|O]v2ݥۧS򲇗Kƾe\}Ng+jٱ-]F0]𔎖nu+仛+7ճɪ.aqbjwRulaM*;??yzգS[N6zgO wx?߳zޖ71v/{[<-{W][+n`tu!]+sy;&Cj༑cj=- ]omqutyf?6-ˎV?u37벯-^v6g?y^↭{ZTwzU7l͙?Zn?ݳX~e]vncSoG[uk ̄tpy-},nm޻ZdnZջ[z9 {]}3?:Xӱȏr3? L]~/D\ݮ )C^nsݛ{=W]_nvu=7{-(Wk |P辮}+KߵWvp0?x[g۞`BߵKy+S'w ng\{9 ˬ>5t}b>}-ϜX65ѕPNh?z=7!]xe5cvhK03!v7{n (>i fw~=3)t\qM^fv@{# -moOxuۗwz+辶7zv:m?yV:]O3]wr@.7ymn|K_}S}gחOgͽ)Ut^4JO}LO{;tOz[/{-n鼕94w;}cZp88  ZV&ojzNV=M6^VƾXٻ^: ];|[O|+ԦayKⲇ˷67]7M>{{ݬmN^4ņ_ 5v)o6_r`y6ǎ}=7fF=/FX4;zU=2,^g-uu ^W7Ȗrù/-ήsse8s/7<3d7nEuSg3[^#n}/on|;oi-O7YNK'ov}o 7ݭ]mnPw)v[om{T9S8[nrg[rM[}ץ{a]K,OK?&cjwOwu˷7v7ۺ t༻;Y_2v7;t^ne_z7ry~Cs;<٫7v6optN..v]N>ˣytvy];Nf7m=P4Գz6ױoo\ԫݫZٱ׺otwn坌{W;x^ce'bR%%)nD^_w.BEe͒-$(Ru9s_zv\/|ι9ϻ v=ۀn+ l6` V+`q Xf_| _`|>O#!xLoDx ^/`x< >p/nC5Jp\炳Yt0 a`?{`Ov`g#t[n+6u`-X,:=X  \`N0;~?o`|>`25x/3q0<`4`\ WEBp>8 A 1p88 `w N`GW-t :`MXV+@4hK`aX,O_/S1wd6x πG#!  w;&p# ׀+p8p 8 ǀ0p0 =<|D567G,:A{hE@[zȜ>2 m w/|[K^~򼅟3V ldY`W<,XZ_g٬ֿMY{i7^S<"s< H e/#RxT!W?R2)2@pJ 8"COJC')|D^e~-5)<3X_-,u)|F٥mj/s6),7ϙ[/}M_x&=7yӋoݧ<F;׳u2ѻ{{x,{its'o!-1s@Mh,7޶]&;(S:@P#Gy1om]c֦VwYi}]ݵ=e.Yyt9m6f5zb= qyZuOxz--ߢ.i_-*2:!X#n u )gQ$ &r飩q3.\zGjH@[b}НҮؒ""r#6Pꗛ^)6=Ru`Nr(嬪ϬʜMQMMavxfS{T]]V6Ն8j?ս}:~R̦0)Ş5|;w~;65{n:ʧyhމaSafSvbj7.)V}gMDuܱ=w7;6 =7Zh>73-v˹SfTeweՆ,< g]7|*ojG,{j'ݴ'j?,o/-'wBfOj}/;)mj'5)gPf+R躙ykף~;2JU sR)1Wj .uKr&ݖo}Z彥VQ7VٳԤȭƀy}H]jj9m-w9V& *͖[T=J)5NȽI:dyD}[Qݐ:lo0]H7ӎ.[߆xr0ox SSxuחwD RRVO27S)5;FmU_dVFycIUYXjSnAuRʩWԬU4:C{*2um[fyS[U]qW{H;Zj/X7R~:zd了5cbs)n.wfeRg[;N^SRPOZ-VSή`C5Q+%:wvdN}SsWvJn|cmMOƭzw[M:^σg3`2&qIx< FQ0 wpnWp)\ .\p&8Nǁc18 @v].`g[?`=:X Vˁeb X󀹁U' |>3'`: oMxL`  w0p ׁkU`0.stp8C!;{= l `sX VU`Y4h V`6N*f5 | `| 4 W2x1`4x<`8\%bp.9LN'>8 n` vwö~Z Hs?ڃU@[)X, m} O슯]AOLixfUGჱx[q`n7kJs/ 4#;m{ !Xkz{7Z#[_/0^GC>p~ow~[睟~A[ΏR#8o{7~˛` s;[^ ,s~oRn7]gr-[n鎟$4&8)= <Rnp; nWKw7OOP!tGi~ϼu/&/$~ӭRnxq3wVV~k|[?;{;\f~߃8p6o~{n`+}w[y72?5Vx6w"Zq¯N|p%0LS£^xD #vu'\+՝p1 PwsZ@<@_}AWD68H3jhc&T%Z|B˩.YD' ] ʿc:Y.-d1 r{ ^ϊgsrWcvo7Kp4,vI5E#ߝ-Nǃc7px;!av g/茷Zv]/r{R stP)2)y9-R/8Nʭ;>RzW-wl5)le[^^; sZxcfiݸa.y7[q9kxpZZY嘵9w9+g|ϱ#_ݪMdo9ni혍x@ו䞜y↹Fevgj5_g -mj7mh1W9v}smG~{7vf6L[vpV3 I=9lMx%ꀜ;"sq"g0Wo-r4ۏ!6m4 y擷 9D.8.6z//'|}+!6R=gwqrWbOjoN{ȝ(C?kyC-~7 |ޛ}/p:|j}Ѭu_D&+pA3?Ķî=^;86n y B켹+DGwO|S?ޜ >y g{ny oL׭t ;3A|#gDr7cg?}X ]=`-L\QRr!"7d/DVx\[o"/D>hi^NQ6k찃:iqg/;F=7?#S 82Dtf&=iyȹߒOƉw'RJa>a`Ϩ ypcNZ3.Ru͞a*a$7' ᠺ7wm)o Oő3"c̰MYbޘb7af `51+;CpkPAyk$7j^W7&]/or[C/GKeq+~im1c_G0LQO/W&jl.ˌ=ߠQQPw n9wQdZ6Z*6ݵc袎e3~wwځUԿ;FKo_{(I5Hߠ1]:MFEP60oOB0w&+n=ĝ ,973ꙅFΔ{ plZ&3`3Q0rjW/b{FV~ʦqo }7}wiNw3%tSLxKuoD>a#zΠ/q0ð׌QFvZ :#ل/9/m ,{L{&g!}2%n%t =0^σ'`$x w0\ \0jIK鹜Vo嬳=B`Ac3]dzXLS)d`K߁o"=|#xtzTDz$x(vpk2pI(~H{GMw;~`k1("^+t{HB~7ݞ)Ӂi?{=] ρ1z:Mo7UJO/t\>4]>nU8rf{qu𫥧t'Yկ~nM0tEKo #^eӋ,=bfp2gwˍes(lj tbKk߻ʿW(1[Ez(thfU`'oHm?e,cA`#C7sEflKQCdFdƲ0g/=T~]=e׼xO7üy8 IH=כg*W&\3O{{e~`vO7=3>XP:YX8Xqs5sJOZ&3~nmύ-;p/?CفW]e&g EzI'?t=^^f6K:I2NǛ8y]L4 "<+MyL< f&ҿQdһ5`xds DWwZ:G׺Eɼek>t^'v n= 췏{!;Hs7<]kwZ  Nto?Yskz,s˂e~zcwYzpJ/~z=d[v}V'o[Xeٲ=cڗnoYNJ}'_ޓH;_u=ʹ;"mQ5]YH߆EٚrI埶_dOUGtrh梜d2A"Ts0Q_{sS2GޔtM6KKU{?u<]y8%I^iM@;m%s?aV|ϋV,p dy#bOˊ[U݄EރtT[7Xњ}Y(W򕛲eJm9Iec1sw"}֦l{#]V>hv[ݍpۑ\Vǥuhv\ލtXdTvX.cϮ=7oܳ|cO^zuovۼܚu{^^nTz_Ɯ쥧aWߚ{1e|˸^7Ko{ zɼѼsw|&ic𖦝ѩͻ~w}oG-v+Vԝo7z &[zDO/]vނz[<^kz?{ 7?SKYMy Nu3{p;}r_m1fotPKe ϲliyЊtW}S`om[bz[' 7Oh{U*{L}+E\ھ~r?Q}sV]~o{xe{Ot t^j7oXWtR@Nt tsg'#|_fusC0y[yoRvׁwü=rS0,|SOWͻ\`f3yΠ/-ݫgpQBVQUnڵ<3{v#/ݕq˪jFSr;0cѽ=ݣwtg+ǼuZ;ڳ)ˎZlRܙCSfh-];Jqc=Rk[t=ގY}nNHw3 mL9۵:'=;k=n}ގkx^e^9@IpwK\Jܵ)!k ]Kq ^zfϞsty&yb?G-{b +뉵bUXI EBbAWsE/1I(fӋb1)}%_Gb@/5xD<(Ɗ{]qM*n7Hq!EBq8_'IDq8V G#aq(ľbC"~b'^l+[b}kU*beX^,+b>[ b:1FL)=Ťbba7V|#O'c/>+%OxA<'qO+w[-fq^\#Wpq8_#SIb8B b[;?6b TEX[t5jbeXQ,/ˈŒb _LbZ1!~'&?w^|'_Ň]1N<%G!W#MqN1T)S)dq8, b<7&̌ˊϊųϋϋ,g´bsa21i.$`>0I>^ψ'ςS7$1J\-L> .O>)8:8nSy;$HbT|#W4I;ߋIhѯWKs_?oW]m{xGky>^(5wLq8`s/C^.^3wv7;7dzx_vn݄X\$Ν$Nljó6 l;gvb[evýuEvn6>`3͙ݽzݛ(7<ڙjr%.><';SqM|KŰYL]$'߃RRqsw[^2?ڵSY2vTuΚW\:%RH$A`i8N!^7 4w11VsyN^"{H&'6y%vfn-%V5ޢV'^I;&{g |K(ӹ.FޑKvrBgDAsMQnvrdbYWtܽCjddtwtRŸTzro_JUz5֮ҩqyQ:r8JfҕJbPrOq Q;Vd:J8sjfTbT:nd3;+^N>o%#iqxYI>^`%#gX'XHJGs/;~." ZtsWf~Fg_rqOO9XO={Vޒ^ʝ݌tcb`nS\K^cT\$q,(WnTrëSd!9v{)zqm~_OM l;~_%wl@< ]u)]#觸{m+"X,%{L.o&;CnzkRolegMݵٕ(6r؍Z|\=+~Jnx:Oc?_/ ,c=Le_nwl=WTNENnLNF.;t2wȾڳv.Jgflw.nVܪb[ mB{U3:s[d[65SSdї' G/>ޟ;9Hθ8V}47هqjҥk>˾'ӥ;u#Br0zsݙ1sr]i\ԕHr8Lo9n^4Yɽ!K[_J~J=ާҧ;5o}z3ٹB*sw7ϗ/>{2sm}yMxϮgyݗ-嫭1/سcfĎ}`n 213b<~yʷL3bY+޷kf`nЉ}8vx'"M3S/&tycgF"yAǜhψر#fё̆garyo^`Hхy{R%q#Ksnڝ:p/;p];:pvV{70{c[2:nw`x^w*#fWF",4EڒXgĈ{*!jl|}i\?ys $n`~0 f?3AO3 oi5*x<x n7*ps,p:8 N Xp8~ {]Nl6>`XV˂`I8Xtl[ǿ| >wU&s 0<&Np; n7k5 p9\.3h0 G`8A` `73l[-F`=X V?˂`i, "`~0 f3tW |>M*x&;vp Wqrp) .,p&8'(0C`0`=l 6`-&XzA$Xt)o60s f }>}|Fߴӿŭ>& p͚z kqgX%o$p"8 !w`O=-/X&{\ɥRwX.簸Y`&~d3|znw[`x_Iw7oyHw;)dxW^$y }^pܕ. ܚI7kSNjiTp NLS)\:4R{n`[~>`]gtY/*Xkˤ贅)gS :ncΠʬA1k|_/,SuAeW,oEVp>d~1eyp#W[dqBp8( goM晣0£-rʜ3"k-cm6`kE"ɷ-&ְV3u03`~47r^X0f7e!\Qev9^ cR<3 \7;VV.놿vvE- o.Oeyun})Xˎ{bvԢ[T~,n-[[{3 o97 Qǭ0g0VE~q[aǭ^57ʛa8#sͼLǽ0;q/k^ KxҢya!of僮3sA}o;f'RR!|zޣx%1༔oCs#S釤|zwIqS)noj +}f=[̷W;7]}S-Pz75;@NnSnZW#IXܯ:oV&P|xzW27q} {TM]gb搓;mtw39{gcrv /"zԺwrx?zGΡoJntM?5e)瑋Rso32zF9<Ap@er_` m?zGyEYCY {MvzG} ڹ @u䟉zZ=D?c,w9:3wp 簫(;;[ f5+Zr"35r ~9F>a~~~AW0f}g3X V@g,X,ڃv`~߁0|i-*xρg3)8 `2L`" \.Xp 4p* G0p(`w v; lzF`=XV+@7XtK%`1(Xt ~Hf_/g0| > .x `22p18 FYL0N'#Pp8{`kz- Vn+Xt2`iX,߀W`.|>O'`|>w46x ^9$xLdp? rp Hp63$0IJ Z8b7YVu|[8.`9/ KXdb`a7zO3 /yNןOyv wxxNǯJp9.p|zv,p28 XϏp`߻y8N}A/mzx-z3y<뜿.9:؃@gp{g=]好gvh,y9L1}'Ŝ};ǯR))/U6ÜesO1{)=:5:̮=0ۥ[ܳF)2;7g\)\^0r^`L0êݹ9;4b.O-f3ݖד3bN_i18;fp9Znsgrٲ[v{ON>]ZszUu~ _zɥ˟O=73?eȚܑ4}ӫFϳߋqF+(pϻ2=lˣKk>Gc9Wdihv5=fuw=}Znkrާեڏ٥鶜~3E~bW.fۚtp=e٭GqSvܥGj.M׏MRt{5t~{`T{ WM;kkmqrwk][;ve:}\r,\L\:_vrd=]<õ3f&Ϲ'z w}#s7_ۍz2;yGUocr^kGltȏxsus>q]uvS=wo:~~߫7Ln^l^īr.waje=Z;o9oNѝ5)t3w8=NrռڃLOK:^{a޸My]Z=NrnXtqwic{;3gDީ"k}raKfѥujZWveSmc7ӽrr_^o,b ZރՏ9[i:|-ݓwQiG漥G?;6з;OUޭk?+42]ީ-o[ɥ_l>=.3;wqVs.smq^]ok3t .9n;)؜S޷7 S̍KE);RdH}3CH9C8L͜Rfv[Aoȵaos)DsNrn7vfsBf^cwaα(~y[{G0;Dy b3Kj|(̽maFfԘeonTkԟ|7P[!s. MRo=Q1쑪j LS5yPQ`=fC ̄s~wg/ZZv>7iQuX;xOԋF{H,`Gb?KG̅ow=[=Iy۫]G̏γ݃%ǴvAFĜa3SC 1kDCB u-nOR~;"sՒ^I=蜔{iH݇7%=+1tea]_ʽo7Zv-SNXvuR@ȯΩzoRi"yx ^/)xwmVp#\F p8 A`/ v;`gXV+e@OXfY`&0 L&O |7Xx<O'#0w`$ B0 N`0{.`glZX,  f3i`*0%'b?߁o7k%| ex <np7:p\g4p*8C[ǂc+j]N YnUB+׀KE70f)U7ǕQ`H P7`٥v6`ZYT,,<`N0GTLRB3?+| >-.x ^/g,tEM=;(kjKa tpqX v` 6@_&X+,XBsK?ZhoIXo9l` M& xe}?jC塻gԟw;n+^ux=G zhOcv1{ͮM=kCQ{낵@&X,Kz3<49\#ۨ)<4:Fq?q)5|Ns^je-qwYe-p5T^Xy~{0_;ص~[J3o2XKдrp^UBEzF0Tm+ gV?Omg^Ծt/?jZz\f9z.8 S  ,<`/ mV ͯjj:Yd̥g3YKS=ܤ~snRLZC{h|<#;oNvRу<:(>`O lMJꤽ=:)5L^t޵o7}Z[BR{(3YzI Q{g]KjZcF(17v KO )]QK엳'feӄ`#jɚNNڠ.Y!uAMnIMp>~k[tIe$|<`n^}zI-u_#>ȼNfkUzh4# ;fzhkfe^PR'=k6Җ=yL絗MMRO {艹ʞ\77s1wן8|^ϔIiyɬdN*#E݁W32߆G݅)pcQ7!f隍ԭ:o;ywexR5Zu+n`׹¿ƻ]w;;:MzEwnuWi+w=;ppEۢv{mϾOz.;n}闽.{PD!Y^AmyH>'Dӽ{q+oܷ }BJ8$uune3_؃Vb룯RjZE"oɾ'p ͜e F^[1 Gh/õjwy?׽?xmm}?M[tM^Ǣ; dPODнúL^bgg|/{I^{mľGg<;t7w$/.!/ԕ:O'xM&3StSO~"o=GYJ zHvevEwmZ}nۡzOޗQV;ݞ]ۧ\ݟY9fl~3~ny#y'$]WϙJS6x<S`2x<Vp 7up5 6x ^π (x<=npnm`,\cU`  p:8 N'#p!/8{=@/+ z6`K6u:`-:X ΠX, yo-| >`|>]0 / `*Q8pHp Pp8 ǃcQ` 8@ N`{l6-`6XVY +` , XbfpNs,scfy&xp6p.%Ōl\<999 lbl]ŜR̂`3s~cpL</T,1޶t+<> zx~z8ϻxxtpwmgoW˃e= =;?,I/= o%}-ncMzpei`eNZ8x4`ٿC@_ }=,m6l,I7.lŬ5RoRx%r^O)2ӕRwj+RbpNM7ߧSxz\ ?2mf`/[u _RR X)E3;pv2nx.+g?ygy8ɳ=m[-f._եz5`\f^ttofF+5sy}K`1K{lYe>oY5?;-2W^k/3u4K0pl9CGZd(z[8K_g75:*U]-35']rSO?>~"/ٳeٱsj}ڏGRS[2_'Sd+z4E2SGvx wS\eZnvSvg)\&-)gpWN,KU)=e30߬G.gr9g0]ѿ1ѣ__鹃 =|>#G3pf|g׏p]\3w<{N[&3+{s=y{d?]{]kE'.Eb9,x< |ahX)+X+'X~֘Ye8s__=g[s^{;ȓ)38G8?{ aGW`??&Ek~(LyV05+X3Ŭ`B]3bT} ͈r?g^^ٯ}v<x1\99'x /gQ;gB99[{΄:888Kx̃3ayOK8{=wm9UMˮ]nkt{E/ڵ5wl`>jg^VZc'Xݮ`9 orΖ~r?UO*sԘOG'{R{{^Y]*yoNG׏H짿ܭ^);gaN]RKwHfjϖL罍2۵s+j[2'yݝ޲[[go˛[.ompugcwl1s]^qG׍wvLww58e+6"Kw4nƾ?c:|>Ǫy>w~kj_}۳wozc]];Z~ͬ]\Y+gvjz|tT_ WhoGRxwN}JOucN)vg(eߖsiN>wh:SݚOff&4+t?g7fe&{:慼qw=LnbtI;cLvbݩdgt^1+՟-w^ܛnuw1/α}{;ƞ[/3+33/m_I 1/m#3/tM7gu[IȺi=e,ߧ#cF2靲qߤLok&U)2}9=d6s]ѽW=ޡxfm]枼{sȿ<\[BWY me[=ޞ.;.,,d^Ev_^|厩[/qK{{;{܈t?nw -qb}X7"yXވ޷Ml[RO>fٹ-nDm{${.ݢG#!BoB@Hot=H}U{|Ϥ7v[FICtLoEI[ލmW9.`xE"Pܑ"&Sdؔs*pY|b.Hl: `8 "NH9SdՁ)rg,)23v;]-f꩚U̩)9h5΀< k]̬9En1,ru˹ x`_̮2-ԭ4"XUYuEVȫ`Ef C`0NWYds78"-#&fF`]ņy6E6p(gIcʆ+R298>EpI,e@@] ; IX1U}%nNs}[vYs{W-\O[; pbrzEg~8k:]pa.w[Sxma,L9﷞̻@}\\;_e`9<ڳ}L/o}=>c㲏zgx=__r-юzZ^}Tg[]]M)+p/LpZήyON՝N;[tN>4{T}[X2ef gZ8]򽀝={$ˎV@z. 43]f>]V'y>=^Ϫe25]榮w2{2;Y>#uۿk^en'<ܦtnl1ջ71[j>-;ͶVǽ< Lx߻M7\mKx3ݲ-1w171]Sr ;vE7.EhMַp9Vްz[[ Z.c~ѣ{t^ܩ}Vwm=7r#c{,,ݪ77cyGײcK'ٱz[}[]=w5ngo;x;ݻew7ۘnwյw3ξ}9{}:-,٭|+ӛ>{2@[-3@}nUmL={s3޳Wna*WIJ`lv+3~#[m|`Z~c+ a|ܨwY~gMA=.nmvqΥm9qENhŎnջ";V̐M-eo[f庯mr{|{ʫ١Ǜ=sD##|ם} af0/ !wwž3\ۛ@K.[zŽimrkrsO枦So`|WuMMOOySb?צ9瞮gV) R@y73}-osgs.iuy[yNXqssQoA~/M-n/,{e-71lnzΫo`O=; n|#U.6c+gycs/.[H=7o;7{{Pew}mtv=w:6]z~Cw3u?t_)^7Rt;i^zy;;S{=oiN)Loe\gﮩ^뭌Ftwu7@^Fyla~6[nO6۟]o3Fk ȅFPg[ q˪ݯ|w\h^lP.teC+3A9 `라e^Oz\_y}n}|:]=֗].xw9^w~;{;<־J wOI͌NKy}ˏI[ޏJwz7z/Rzy9FniF.KETklx:^[垧tZ,צ s_{m|Gr.0~e{yro}_;MnW4Os]_f;{̅wx^UgƊ-( `E؁bCcD{Gb 6 vlQ` "E;fsνL?3$"OD0x`4sY4p 8 G/z`kt=l6` :X Vm `Y4X,ZG|_c0|+`2x<π wq`,n70 \K"w0瀳 p:8` c(pCA`v;.l ڂ`u*X KbX y<`.0'0_/c0|73ipcjp%K%bp8 S)d0 NǃXp 8 }`/l.36`}:X˃` X, Z`|?`S *'x 3I 6Ɓ-zp#epg}?A#pz;;v'=l#> `s΅uZ8 RXD|p߀;` x˪? ݿkVkjsY掟f 'f:`3 hځV|_Ê+[q~is {Šs]iB} n7=/8Puun?u}@/V]=@wۂ6}@Z^Sw;nҪӢO~5J/,9 | fOs#Yox->8 8^/gx K w5jyp<åsRQ?8@l{mgAW^T;@̀9w_[qO2u5{D+]a+s3`7πghpܿ|'lt}j%Ϸ7zVnm`I㋚g:J3L̞URhqY=V.G^3YZ\dL=%mtKAK:Ia+f>t)2gkRxH9NJ;wxӹga`M"`_7 v;nRw3)ޱӵ}[^ܷe3wI)޵n|: ݛ>Y4N_?iŻwXcٿkſZ̝kޭ%d&mG le޷ٵ#ױ;BV:v^gd~&~=AK{{}ר ;;IZ>}z3#٧wPwYK{u{N_G^W=P9#|ZpyO?ܷswGF'Zv;y䝜x᫊fR<'~Vf@dq3;8e-̝`[m>γsGͮMǹKkܷ5`O]{g+͞'v=zޣ8wgz~=SݦӹOGܤkq*-}:"}y6t;:6]>\K.N׻5^w4#ؓKsONjh?h5tf^Vmz=Uwovhf8;*i ĝLoٕK>/jkvdfu'qW#GW?ۊmdt+0Y9oaogq1wv0=FK.0rmls6wm5y1m9sҽX[˻. rF|} w_1Od88ƻ;1=]QZw#w\[}ϥow~Kiܪ 3ze2:{~XvR2.:̹J/Rd~FvOPv!ⷭc[G}>~<.{VGQ(3,'ޯqZP}?}]75wk̝}:*o[3sYJsef(k~Î{VKy>X|F~~3@wwt΂7ṻǾݽ=-øǽ-ψzwgw笘fsVL]|Ί1VzV}1ƙzgglΜ=w. 7>g7IKvG^GVzѽݛw7o$o:\]{rW]u-ޫpۅ8˞|]ޮ]6zv gn^#]dvnNjHgstݿ_[8x^9{4EfM.E{&bq֊)Es}s_y>˛ٲ`4X ,=|`07t` 0| `Dx+ x<FG>p/ n[-&p\W4p*8C!`p `w0;v`kl }:`u2X,`0? f LS)wfw _O#!%"x<S 0<( Mzp- WBp8S`08~>`'#[M&`#J`y(X `k΀9 _G΄/'Cx?rFGΈW"g ќAs0"r6p.n7k5ќ D 'GΆٰ;%r>9 F·3b:X%r&,988~w0+9z΂/=ggǞ3}t?Ƃ1q0xFzNLJyz~2p18׋'{q>. v`#kOwkx:X%x ,y<}fL^O|+K,]Ě>w;`5^1V|#]^b9V\=n,}daK_tvooݭ`=kfftu^kfv70/?tH'F̖tj:9*K`$'IȬEzy5$ "^㣸yXn:̦tQ2Ef|\sEsȌEqt Qf~2(sZOǁ=}eF3t9-o#<3z7zf4 ^gnuz>V^uf: r+˙?m%kיVpeV_.\kfgNoV< ;[zeNwf5 ޯ[+9 Ι.?LӀ>FyΞqP3ߎ2?/Fs|8fgPpUtS"gŠ ? Gsnpf,gWgvs^pVpN,\Ln5^fBuoKwWg`o3r_o:O׏tώ~r}YN>ϙ\v>Ëpy>>g't=ˌuV.c6KWX3Ϸi3sXpe.l%[qzc\g2dwڟ٣{ZvsNެ_Cӫ::^]4:/2d^Q:ny(>]xUbd"2";]>޻ڞ>FWfmtsHd]˲3y::;+F:]@ݱh;QO2ҋۮQ` T'VWf!0{0pJXX߬NEޮteKow7^nTSCJ7%3yW|M\tqeM{t˼fyWbỌ_ꥇ[zlSX颿:vQ̓_;EH]S>ĽS!z#ggtfRdg;=}}}w͛ {"̋EtΨ?􆙧Os(YY;1o@Gދ're;tLcX1A$G>S.&DrOtc]:KXg.^ܛR{$]Zͻyu#KϬ̿5? =MW`i,iLSqd>{edc21=CN5m9+my]JGuMw)ϸu62KdoDdsw"[S~Y놫[.=fcyUݕXwDL׊pcKGĜdFKuCɣ|Mu[Nν)/$>ޔ>Iv_rB[XސrηwN_݈潈a^7#y۰罈C9nFu~˲r'Vnߍ؁۷#_e4{=쫭iSpg9KYW4S;0s[s{z0UmYRﮚ'aUu3rpW~Jֽ.'6n<2s4ie`SbU|CKgk[wb&e1ar2=b>]eazm&}Ke3ergFQ* H1;<[sΈK7Q߇S9:;߉_߉;(3+Ztnڕrw"bݓ>*;u;Rwu^^WYgO?5?w7g}Cbߖ#]e#^+9}v#x32/'XK*'fiCkv{[t\w:C_=J9 mr΂7͹CgwU ڡٿG'Ί=7]sCy{fќDY97J>fW_9oN]qeurR:K}\v<<,㥗kԳD>Ιҩs0_,:wA>a_w ͜ tvެ9{4wymZ3G}7gM7ϙΙ=ÞSgC~;\b[Mp1Y޸-(ͧIw=3I3Im8u\iw(ΣϼUt?uM`B͛7ΟNwﱑ菑}<7ˆx^E9{C#*5Bȳ+T !5* .QCTEHPb'fͱ|߽?wwksǤF}bS&n#Mb^\'WeBq8_'ř q8I'EoK'GC .^bE,v;mE*[-梓Tl,6ĺb-XU"V+ŲbiXB,.ڊED1O$k GF|-_g)>ĻbxG,&sYxROGbP| ;-xC.^W$1Q(&g3bxJ<.cţxH+wpqA ׉kŕ2qD\,.@1@ũq#*z#ᢧ8T,b?Gt{.El-6΢Dl(65D;XQ K%bqV/Z E.џbN|+_YsX| 3ĻbxG*^ŋ1^O11V<"$?wG's=zej(şEdŝ'Npch4=gĝh^*n涛bwu47vA4ᶳcXun;2Uve[ş˽h]-YאWr`Nz;Ky`Nr"LעaRNԬ']_+n'n+K9X?hNwps {jww}s̝}'V懥Ē1{bhhx?g~0-u7^z_y:T ? SF)#uab1dg\\rO\;I`>37x3X(攭B.Q.^Y=X; =zR񞘖SLI+HI) _y?mΓ[{g{^'ܳM\^M_fO =t|9c~m~{ʹM֏FA'nfb?ς?yg1c=9f.9g3er^`3%4VZb͐|z;}s:}ۓOS_vz멚Ke{/w2oLՙw92zzS {ɺ>={&n)ZyNOn˼}ܶ)Mf}y3~;}'þ{GeF;W2͌-3?;)1>zd׹O.~; `{=<}nA]`g /w f]~P/:3x^ 3d};U3K7'S/wtdMKe^O{~vmYȎ9劼͉7897zf7yFL+3;YF9 ;.ߥ_}Ru%7~&3efߢKهWK97ܲYE'.X:Yz&;XSa+zqrf^\ޡE˙ﶾג3u_ޱȚ*[{θ-D|N}9kgw9ެL#CdwZ?͈߹ r7cf̀žrCS Y$;-atHr]=]{oľ6JjR*-yF&Oͺ+#3ݶdE ~w({T9+;nxbyEloXvJ-N1U߳wes>/*\f /cez+tb޷pcwAay И-7~]_o~jz1z߭{]{p߻߼w_|oyv!x`s){ooƷGWaƥ؍2ߏO Z١9ͼk;n7uw`x{8;doc8njxR2߹q6eipٰTݓi컲p=~;Jv; "<:;WzUMs}_Ow_ \Stre N-Zww/\1d]0 ^4x L=`<Fb0 3qp8 @/? {`tۂ`Kl6`]&X ~ځe`I8X ,ڂ֠WK >`2x<O#a x Np; n׃:p- .a`\ .4x vp Hp \#p0\ p8 (p$8`l:N`K l6`C>XVr)YY?E_-2-_Yds0|f,yX˹fS Eƙ]flP0\b9DLdky?E],gwsvgmo%,2En~9G=0#{yo̬r|wy8pGVx 9U.ydp<=<.`+luY<2Ws8s[_}<~>K`FL9x2lޓr>dLEpQfQrM9}S)g9 @Xn[KS~?n ,E5̔3yaV&YDo^q8m\nK-2ķ", cn Z~3{[|+ȗRvx[di)X~ X~j7Qrǻ8 <7#o7re~eO.n=eqN,t7|+ײh[ o9w{w̚&뀵-|ϺQp`0eG#vL7=fzUW;Qc`=Sq3]It5=>zqb9w |~^2{GnW}^%nNGuȓmxȏt{^ԝni{G;)nR >r‰Ⱦ%ޖƒR8wѵ)\[݋G}ܿKcR8O7P?,1Тt;LoCot,d:Y̮+XO}D=D~x٣sw_y vxH醑z;u ync_N_~c{_n x7T ӟSwԸr7ЖJGKf~z6U=5))N G⦠PWfwj`wU}[)KauR]-m%;D}GVLs+}OuWI1x_h/~»a y+.,w K7N[Տ/ہH}'ͳ?@r5~g*o-};Y[ɭ-"zu d٭P99ԟXd'nt݂e=[:tDoMG_hÛ{TMc7zS'HcDp''jà輎WNk᷶޸Q]r}EOL&=$Wa]7.} $v)3,^;7 zH-:t BhSycJ>ii~{&!wSv7g1b tQzX ]wݡUv2botyA.,QekfyV'eGb?Rv=No<'(SvCn *sʌӜ N'y1}==Nq}sqxQ ۈM}^/kb%/KC.v&wv'Zl_]1+;'=VL%LIb\aA|/ߊo+B|n#񎹻WxaٻbɳũqC̽ǃ} ;; 0wmesV}b)3wmaܹLbs&46SMvp#xGO/P|=}+Sg6{O񌖞ŭCAUݭ ;~.\#Sfo%\Z,);6#fN/ܚRL!&3Z9M_o8|S.^ψé^qOr䭩4&q2%w.^ˣ'wsnn'oݤk$w3KS!fIgOxI;=s74 xmWn#N3w$q5yJldoU+n˘{I\Ji^?܌Ѽqtlu(~37ݬuen2Mtodv7rD2㞗a˜;X̵ntg- o2ӆ1h̶̵hωвϳ$w&cԽYϱ'_Wg2sgS)9j=GRf];5;J?qu]'̻RW_y k(n*~1m1׮fG)S{79qth*FOqy}ý;SY׾ąoWkS>>a^>zi.}4z|vO4v϶xmYuQv?̧s9'{ϙr$h.{d3({ө.9*lIIHŽh$\Ab>xSvɝ[͒ϧ얱W.VMZ̬K'-!͗ܳ9wqԜ_I=cJ>W{KV|߼mʚmĵ3IXqc[l?k̲]VvJIvIdg'K[s_5vv+gӚ1ϲ'ۯ~!dP7 {bHC>5]wc7axG{1Sٴ7fRsh8GטAlpv nFm\[/m3p*˜A i̡vcq]shfcߟ?4w*˜E{W1Lb-=9 hዹygE2V{>Wsi[/چ_yXjb5nS77ֳ'b8|nv947kMˍ94vE:m5Zm&{'yq.¸S&K>!fκ}xG-c세s>Mri!n*6uSF WJBZ+-g@8{Dɝ̤8pg}c~}6e~63l(s*~c^nw[{yW|5͛VZ>{=v5kη}g[~#x2]ͼMy ~ɝ;[7F[ι46ނݨn547{wYZ{d]Cqn]`mfc-Guy ~7z?짼 j~Uo-cTq# E׶Nw{l3Fɛfk.;VDj?pi&{o992s}u^Ǽq4o-Ejo+1 }Zy%^{3#7Qm Q4.wjqG}g 9o i\Ԙt쮆jRʹf377hwx:&~$5oI2tqfS-oܒjiԼƌͽn|=</ƌ;nF*OG+vӼ=uf~?rڸzyt_Yvu;wX5 }=}[Ne_$vposgWֽ[[;}Ggw9vv\No7v^M{̹| si.NGs9[UnLO1VLY۩uOkOp+{0^l{IG5 dt;/p8:}{O2w*No~S~˵4+g{~"wW\[ =eG8\ OGrbFolL_?MGysq'7WGI_y[{131QZ$TDoU1ʯ$'?ܱhwI8M.s7S,}H*4>ݒLc=:ffof=wUNx&/nqgfN4SvƣŻ7Lc?fVn~CC^{7x;̮۾)ǎ;My6kޕq5Ln>Chnۘq\{_{ﰿs!ݳ1G93Ks 3f1[b|s[ײ<~6oi|C\7{4>\;mJcv>5-no˵͵dMm/x^u_F+wmpIBp()V,E nAC4X (V KqXw-^;kzνxkfKfӋ4bJ1DL,0~[|)O'cP/7ywxF<.wMF1Z \q8M*N'Pq${ĶbTl$6k~XI ˊ^7b)XB,$9b1AtӉD?fW @'WXxZ<%GŃqK!n71qD\,s 1B*N'b8N+G`qG%;-Ħb]'VŊbiXB," y\bN1E(fӋ)$bb񝾳ħc@&ƊqxD<,wĭqI\+.|q8C xq8J.}bEۋVbsXG)V}rXR,"9bf1crMޯTH|`bxEdM^+n{5F W5RS0q"6 nܳ@+kլ5ZT,,3w_5Wӊn[~}ݱwr ^ϋgsӯpn.nf2q-:K)NMNٽ"b v;ŖٽXݥe{_̛ݝi{]L+MV穸Irp}xGn}q{rn׉ştIrF ;5R+Ni~c%wlpr;Il#k~ɝZE,&kXX,g=! &o˚|mkV|Mri{S}ȊX?<=G-4xq8< Jiuj߂==Mjkd殒sY|4;bv? ?W6qK.YxC.^)..ޒJ}Ⱦ;K=po$vɞۈKFb칸XCΞ+dDpqX87sęL7nOnb\<)7S3)xH<Γw$>y~^).3qq$1,NdRq|6 cOY@2VJ>b<`% u֝ܥw ߙφokߌ܎9y5Y94#3y='˭ef|y?&˙te2}L|nh;ٟwG*V6u/m>C"9yf>/eV|=ޛȞOd2# :r23!rݍ#ǣӉrs.lKղw实ȝ3|~O;g8N~8~~5Mލ]ߐ$wH\J/&J)۵Gя񗎌u?$yGɻ2.qT<7پt*≯KwK]U Xk5N>w;ϣ̳9QrpsXqlc-66޼5Zњ<{bn+9;gtOӲrl.n>r.S?]=QұާOѥs8HxCX+{R :oq?ԏGxHῒwѧILjhdhCJ4rT;S3KwNu!Yʾ9ZGcKұxݚ^]g)ҭ/K;;,!ށL=8fz}}ϋUS]ύm17'd;|>DƓ2=nۯgy}"#̌{֜뷚EG_c>?wify?šmr2<|yg|p̏>Ϟ ASiQSg.շofGot?ϩx(waS]E+s*fTSqg>uKܿ}.K{fG6Uu;9i|?f6-K3(ݡ3h7SDx"g!̝b1{SČwfiwpvf o)y>@̐MSsv3cz^ot[0+xwoh7oa =]"q6]`]+|̊EaN.~ u63",́el.o/f]}{>%~xz"݃>nnށgqw 3=c7g޲R q8vy^eop' wc67l܌6^vK;~ ߢדͱwYYۇZs-ލ ^oDy3 g5[٫bvMNbev Ԟdm[o .ޗp[vk{W8k\e7?0~^ڹrW;r_g+߾q'gpF'sn~f9.u}joVu>5]]n_/wRϸۥ#'u}]3=rS'w:'t#߳2=.7|3b>Қ==sOJdqݭ};dx^U9v.G.DnQQDpT@Q PngTT lq}^{}^װgD-V+rbYXZ,)^b~1!s9Dw1LL$~f߈/'bPoxV]x[%ϋg)xH'wōq8_&Nı(q8X)bGNl)6늾b--Vˋe2bA1UL/ӈb10Y ߈R|!>q OxU,^ϊg1x@wĝqRgq8] Aq[$v;-b3Dl$6;Gd1ZܭBn4 fbspK?Y$_ֻzSce\\#{{wgvݘݹ˳{w #;xœ8T(NgwnzS&b\<GsCn.UJbEXV,bA@vwv.1={߬65}DOtWtqSr/"q8O+gT>I 'qb8&'~_+{ۊmb`=/yF򙰲XN,-N>s% ṅiχdC̈?oWyyVf{Mu/SIk΍1~qqI\k>;.yb5isd"cg!@/v63̘fVK?Bj9l!cXnf ͌|L+&2k~e֐̛ sq.x+y#l$-˞̕kϔU9,qFnΒuȐ~TLoϒ#{Ŷsb\3&#yuV,>JOf(>:IvqZ\%L%ɽ {!NM{C}XL?05=6Nɳ|EB<߅gb>C-LLb Y3W?ͽк_0ͳ+NiIq÷5wSuG1~-hXsYӈ=yRkzƟ߲F= cd׳|L.O{YG %N^).ͮ~>2?gtp4ElZ3X={Gfӻ#Ȟ:C.={NegWvv*Mo8^td]Nߖrn;M/p92~^5N<:r;)7K}{M&n/+ CW;3f ̅rs&잽d7~;<|,g&Խ̄R?vk~E,N׻<:9~@*!vM<798xYM뼞8y玎.gsxLf`8+Fn,;zt:RsOqTsO[q7X"]{Hv{ac;[==d""l\8ZgdnF.N.&RI|̌5J}9_㮌d&ee''&旅SͨqԼ!s;ǭiPjޚq?{2ӕq=Mܒ񛮼vIvgkۻidsxdSY3;Sd8jd7ӫnrc}ǹ51w^=z_sw3zܕj7Ckwegץs@Oݭtnf?Оb.L޿~;s"އ݀7kfCr:;qWẅQ' ͥTv7ߑ ӣ'03Υ̋EsN}} z}g#.yO3OC7fz;s#E913أ4cd*}bv07N>;9A3y~g=L;;}YО|yr>S=v eSuo\?|lWdKܪ3ncrtqwxmz}أq2ݞwOT߲bn߳\'mww+wd\)wn_.OG3+7 S^딤8>AGeBjue&tu<naW}oR;#;u_`QX:n)>=0L&}`"&;`,\ p .Pp`NG#@_p88;m@7l6 ` :X Vˁe`I(X,?7 9 GCx3t6 7kπGDp/c` \ .#ppg!`0NIw{=l@W%l 6`MX :N`9,X, :gw}~ >`&x&s/4x<6p+ bpC`p& 8p48~ ~`O;~]`!X V+N`yX, L)] )N/d*xp.p׃.C-g-ǁco,|=`/ ow;v.u-4XSkU 8340^IgSax& O`l )|.y)>Tp 8>eO)>쓪^]F`mf Wz0],̓Ǟe<5ܽ 7y۫:g5cl9_zS=c1saC2.== ȳy򸜷tXVs}W;Z}ղ/[yOZ,9px .9.)rw˔f`3zc^9]rNwL"4朦/?cMKٽ5ßOzǙ wx5ne/s0c֟y֟мWnh37Gy3;{΍٪yXr``>[0>O_=sX< ܑ"%\)lb`>1HM%f9)w wЉ)rH]#UsHaP{gT+c0Ee_0{3=ʎ~Q5y z;3y wf ssG0S\a9s%ǁ=2GxN,#O.՝b-{w {2cA`fyOVgn%̅2G)?.qbY [v>}KpeyͲehpcov;)z ;I }D?wq)@y{]/ S w1ۃnCyt){skh9V˾vy=:ƃO{Ou?@/=^N t^!4u{Hy?`W(A wo{?k:y_c.ksm^ n|#nysЌ\@;=\GC- u 8f۝nm:7y-.w \oA Oyk9y]TZ;nܓloA/O4t'Ќם`o3AyKߖrP&,Z ʇz7d>IxΌWYQ zPV G u.h]9P_(?x vp7x`p`;=w^\5[{VGj`pP<`9f w fE A7 M)K  M ]=,v7GW(ֳn+X"%5753,b4ۤN[ܐ"{;;C=ot`p藚3]OʷIeͮ36L霪7u0S'e|9GF=?5c07eoy^[Ee{;]/o e7>@ϹqKx'sxN&˻|VoIk~KN0xϺ;孭&@'ֱ|[`txq;C;)U )<~P c*1w;Su˻%{-z}.C1VgúݭR_%3+Z+pzٔMf7 etYwr_(`]R9{^[A'B?cн@yyPy'gyLP+rw(o?6P!*LxolP{f= G[f2BDn1"#ʷGfCxY)c0ֶ m;!o Yu3{@|vs#x,wڔ*t#bk_ {94u'@[{)6}+NkFiev~<˛]4o<P猦;^Geztu}]z]<Syy={L_rf-W)nr~7b_f]pzEr9[f~Xl]%Yv.9yMO7ow7=|c.s~+u*絺wTy-w}_|PC_)m_[s[pFvy\&wN]:y˝[r[3=0Lix<SC`2D0 c0#pp#\ %` s98 z'8 =@wp(8=#[-`3>X`9 X,Z@ #kU x^ςg0<.0 cHp K` 8N' X v`; l 6 z`6hV˃`)X,Z\|?s0 O x < xV\`k56o> >s{?as5w~sw;;=-`s-V|_,dڼ{ouQ90U'c{:]^UuW8N'{{KWn`gugwہm-} W)],  ~wt6ICqg7yW>&Άz7IzpL ,Wztp0_{ϵj`q?[]/̝ s=cas`"[1g*cҳsWxu48a%K>ni~ѩv`+Sz.o2`i<]ЊOҹY:G-zE>TpG/|SNfnVw&̼\%#{5\%'ѯyy_õ8YYݯ]^ctm|l H; |_ϥŏ}|Lwmq/sj>%(]9F8 7krq7/9DRr/#WzzK׽v;`[NF.h+R2x.(Tty_̳t<}a4}p*t<,ep\e%7ُO4Ƚ< V K;õ;?ثl]3b>-fb*ߌ;e+_t=5uy;Wti;+EfFeE#s-vp)nD̹iZvFWOuR>]Y1rwzΝSҵ]ٯ;mv^=2-OgZޯtl;]=2<9 =ycx7N3/wSwKfڋԼSn.3$y|F'y>xms5w9I=4vJ731{4lc UG,&=c^s>2wJ6o9X}dzl;2swc'ǨcK̮.7FfO'm(bnycXƸ&CU87Լ/PK4q~ѭ* }]0D7}-X)}r]Eŭq<_|lw-vqx r3v|HǢcZ)s3wy]V:#V潐NƝ'wp3Ca9wGfe<睑Y3otwVͼC젭,i~72O;[-|}_fTטy0k'hܗb>0w9#^ˎC΅s$oJk覾Sp=o078<7"WW\f]R}971ʭ!z9+ݜs0{C!Ξ؇ pOoODoy]{0gQܬ巴\{q[vOY4=U3Yiy3<Ϯ.ڸsgw Y[y9=g7e4"δ< =ޘc/{R}εiRv{I̹'̹<&{&w+κQ❪>b YΓ2xNY[.UwݤY.4\i{{g;AV>t;#yeivGCNR9-3}{G:ȽIգe.1wW^zZ,&7|}-7WoDҳ|-±7ko0s ysx^w9(jDǎ6%؈-11*{#H4 vco1VTT-d-9w}L77k}l?v;`;5`%Xtˀ% XN`N0; 0Y2|wtx^I(x<p#\p>8N'Dp8 C0p` z z`]k`E"`~ ?߁o )| >0 5*'x<&g! n7kp Hp68 NID0#@p}`S16:`mXV+`)X,~  |> .x^/s)x< Dpn `\WBp.+8 N'Dp8 1pp8 {Ovdwov< ug@v.cgCypp[σ`O vۙ ͈sЌaeNa>+8'V2+"ΊEB1/f>3in;yAyY ^gsGgMF0>L.΄E` }6}>Nfe& Gd 2g ^ggʹeϕY#?ɳ}F9v*s`J909,`s0?7;}$3{NON`XrǏL'w^`l6%wsf<_,y\Ϛ=o=3-0Ŋ%+omqf+=syh;+;NV.^]ڴCheVޑ}:='7]瞬}3941%73;ޭ2vt>G{dsή;t39.kVil̝[3]\.ߚcGg.oK?_+Lz^izN;Ɯ3R.^grY>kw0=/V'oѵq=>lNk^knU2{89vqs;.z b6]{8ocyc^}vxkkDn ff3u|L|p6ݺ9cVs(͞]toO֎[w19twxݾ5為3n}rqOgk~@72u\\^N,2vqfnNOOM9wnvp:Nu7NGO;7%Yq+|{Zqjǚk3[7Kx;JvsaVnu`nYZ;v/wm+wgw6wUQt6T4;:ucFݙYM7\:vjbnOE z6}Pnnfv[= ~,/=d_ޮ٩#o"'%1wߞc71o|.}YY;$c+s$wRKաK~Ǯsu_K/f/}9;)vLcF$TИ} fKc,t_Y7W̺̳N~1Khsmq'X.߼-퟽{rj7XLzW/k_lr}S=9g\<{l5ɻ0߅L;({~+bmݓnsKNvΥs1I<ݓL}+_?JG^zwjRXw%#̞&x^Ӟ9b Npw R(,c'c>EOx<O'#!p n#e\p8N'qXp8@=l ?` :X V+r`4X , `~~fӃ`j09L?߃k|>cCx< OA w;mfp\WK%p68 N'p  /`5&XVK"X,L`F0=L`q]G` o+Y4 >p[p9\瀳$p~`E;y~ vۂ?-A_O/Xkz `~0f<=az0/ ~ߪ-=^πі^@-=tp0p(ۊ,=`K>Xˊ%SyܠfNOaZH7kB"WoGF~xFꞚޏ#u`pKEjHoE`=&XF=` hEsYQ|ڟ$R~MSީcQO?mz0\Fxj\OmN5o1^4~qj]l嶯ixy]_S==<>wVy߲K>p/R(o`gna͛{MC,o6o^~R-Zkڤ.z="Go{.x3Rs'LE7FSׂk"y9,]>6y##Imyv=&6uZ\9Fe#o`hju(z=^i4ѼۓGnjX:SԛZ~;2x OtS <=C޵w]Yq+7~0Kt'WX"KdOXV:=95 la6 ,` -3-o7?EzN7Ezz9:s?}g4x8J}(C s ;= w9Fz3ڑ\^@Y>2k1=䂶װ~Ci_ɋ|Mċߨ+k^/y Ox  Z~@_+S'=OO93[7OꝀ~"/OlAl ammKXɛ챰7;E>AٳC>}a 09 L;yn!Q=NK߹{n-w=^\s&A_b/;V v,NSЛvSXz }gcKRoWѣVz`WaGaUmGћGjED;Q:ɫQQۧMODFfv7izR}G'>Ez;<_4sZAY.0kfk33DkƷGgy3ҭ<9a/D30{s`0祳SNzf HO_ao9؋0p(h糧ԽE[; 3 BC/m3<= u4, =aq iKM +c]~Vr?[95)CoomqꚹcX jy:f`iX=Gyq;c1/Ύ3Ec)53{ u͝\!Mk[s2uN0O0Su30/Թ 1sod~x;{^^ }3Wۣz4ήS{wfOuϯ|"_#n$z7f'i?a^S{R{/Qa7%Ծͤ"{"<[x<|yKm?픙[ᆢ~^ /WcQkz{lhCw|+l0߰0pÚ7ڃZf,5,}I;KҢ|o7eڞD?A ?}>1y_EFC_R{MПЛe|/}TқnC_ŗh(.}n{W/ыA~KLޣCіŽ#aߴ6 7n~B/a(Kj/a坛0/PϷy^^v:O|S=D٦>\SB}Rԥvv%Sz՛BH{& i9#ּۼټ\7r\nn+Hͮ58d&ΰ݁zֱ/m*M5I-.&S ;о}c#ζu"ʝJmy{Dme{6>]@wW}"onț[[f_Ԭʬ_oxXnY_: ~sg7Eamz+nQ`7<xmnzx< mZfc}.x>pn[`8\WK%"p8gA` 8 dp<8GA`/[`K)l :`-*h K%AkX`0'_-_ 0L$9-x^/SI8=npnC5 p8 '_A_p$8{+mt.c]X,o@{}9}xch<< npN㿃s`>4Nppgn=z`yLK{xL_NSRx9$U=o#)`D gR8K_/O)I7OJM:yD /)vۤpnvN憠# A;"X>tͅ`SqS q~^bK9,k)|F0ū"p8 p?gnme/]@wƚw{z W~ٽE^3pubUWTώ7<|s+ώ{=*{k3NRs`pV )>cAp( I2wwN3v^7ەR8\ Sx07+嬝̜~2S_Ύ^9z;ߛMzp s,Nԣ`z-ru[l6[z]r?Yxef.]^,`ًW{:7egϙx3]~z0\f2=?#j.f]< 6N_ߣguujbo</?ޯ)gr~GnNG?ƀGS^-7)erOf6݃SyL=RxK~}vIՎkpW]$t9"k߱t ,f.0=f\m-ޞ ΰ'Ze]9L;[5k[xZfl} f9cӢ{tbzI'ى$0o{tW>Snaefvëގy^_m䁞4UN_Kggne]Awg=:.f^ӣk3Lxͽgr>/~t]іob|~-ݞYLǙ0w;:z6ֲV^Λ=V i9{Kfƻ'ؿ7::=kN7fq]]:ݯ-5v}+zxͼef=o=oKߺtu\\UrW樜坝2Kաug3U*OK)eV;py:(e?ۃSx-UW䥓tyr'n\oo9IH0[S-r.ccO Umffj>eEzț9li]~ƛ74ݘy\a^YfMub?`"xo x<!0[Mp \ F4p8Q`?'`[6k` 2XKXs`vNͳʜcӗ`xJN3glF.͋VWpl2'1h0<{`wgp+[JV+oY䯻y3T`J+9dSci8xL=_֝|.l%g X Gg`p O=k{knZ27@=wԳ?nE- KEdqA<ΪdSUs=Vl6 #75>/%Rgy#Np;MJFQp8.%'! )&%@)閠/X+%Kk^ij{0?#Y3;#-~-ٯ~k#|kξַugݜ[ͷ2p)iYm3xK;o(wtnenaM e;[:<9ݬd:gY?-o}}G7yʷ>Go7UZՋs3 8Y[Zg|Gz7k&XC[V曙Գ= QKQ䠋ze`Eұk.tkQWXXFXO`poҙUAojKv87װ\ܙe/sgge_ҕُщpMڋtbӃ}ouc Hxc w֮㎋^wNGE>~ ߅ ]n-nZNk7Ժ5]4=$v)AW{)v\8q'>mIm=KwP;.7=txǣ}ޡoNw0wsut⾉-G9X_3Rnw w7\xe6)^a[]}}\SoMDk2mL8[/zYVܒ;Zio{Y}t6VBGDܻbC{!]t #?>z鑼:WS3ΔqmmH#tHt%z$z1RٗhkWncؑbGn [^-I957ep$}3+:ofiގ^ߕ/I(8xL/}ے}}ےa9Ymy{mHg[Df5I$AIE Koᢼ/颩1%c;lLFcx_F̞R3tJ`%ݶ[]Wxy]耯ԷV܁>֛=?|}X{ Ю;M}3[pt|܃"Gu;vsyiy{olv.M-3ۅ {@rl- vRwRs6L Ɯ)3qCl(^!~a;;o|k;M~c#=g3c|-|NEƸ"g?.86wnS 63Mw]?n"qn>}lv?vgzPw!Ivm!Zz5{t`%}W_/ nxb2-5z8q{t|awν9{#ܙLjoxz5{ݙ2)>!r7Cdce}k)۝RBC7o =|ϳK=ᷚpMIV\o]-Tg=oB:6k3¼?y77=sy7FnK=Y}?c0 lս?wl|G;bsw?7!O' ^MwQ7(qO4- ?.CfD%x淒K˝.}`N佑=[$ۂf/5NY}OWŭ1|F:+RB[#{pt:X_J}G#[%$鴾^wiRRzJ}hSS[ek.;fAro\kMϮx^sDȾSmݤ"{diҐBYʞ%K%m#YXfF )BY=ߟ;s?P}{ߟ&r=\ r0 \ `.} Nǃc@gp:CAV%h`4ۀ-`cu`M`7|_es|>`>9`6 Ex<Oap &[x0nc(p\ W00\Az@7p*8G@{p8@`hF`l6u_Eg| Vo 5X_/2|`>w[M: f`*x<xp Fu`8\ .P0ޠ'8 )D 5h `w 4ۃF l6M`C.+r%X`.7x^9`:^/`w&04g8| V6n v4ۃgA>8~oGuW/r,Vr`/5̀TsߦtVO/iR u}Ynv3uw֢&`cMWX"j$ I+s$ODpM1`$A?@k:MKr3ZI$yǍACq7wXOٟ͝ sgO,9o{%w_3,;Vt>so-f0rƇY3]j0sm9;7}z1l5_/;-{ʬfFM:$xL=,XuGx] #J,3k[`KM9VOݹ?ucһܹ'_s;3)q#kwqfIYx=&q<=A$}2 Hyi[6ϭ9kN~KזgoD]3G>i{{.t=gCoB9w9Gz|| E6ʛ0,o@^I5w{;P[p;wyӱc]=0n?^f.=mKe m-3jb~xw1q b6;PCw/6f|?9M4wS:MkoC|7"z܎p:wZN^ ϚFkE_)c^Q/@쵼r[iܨi٘ y_>>@Ӝ8O=cNDw?Eб곂Y~8TӾ}/n UO9w#|ZKӍ3;1%bjݝsS޾|,>g"ߓs&:|̙%żYEvzLΙrq-x37%ΐ[WA͋#ڃ%H-$ͦ=@sIJIqF/Ļw$&T)}ei? ƙ<:gW~5|~qvŭ,{= n͹3+g7Xz|::xΨV7?pVj~Gbݡn,Q]? ;Z-Um:j9ws)pE16E{Z' g 0SݬL>K8Gx+93$n#s ڛ138'#pN9=;B138/JeRc^6]|7B̄G;D{ߕb&s\+q?Tҭ; K88x 8R&nV|3<KB} nߚ yW(t%w;ޥ7T=A+v{<(W[d3 `Oa>x?R#pp~7] w7)yW:o)$+5#[5}oҼ^QL7*Sbg;%*UX;3; FtS#=z wsM3>ni [cC:mbְ6cVJ圩,&IgJy."Τ؇^4xg;D4V|&UE܁U:S߱tE]ĻHCb!|#΢A;e~-&#|ĝOo1_8W.›$+8Oϔat7Ǚ}':gɮ݁3!aC%9][{Lِ,kq佁K[AUjC̅EvL` zEq;-!.=zo|[%m-D|kx^]E9hnb"!ww(.BRHB\šݝB{s{2Y{wQ6&CTq8Y$1hq$>bUl'[ jbEX^,+Kbb!1O#sLbz1RL!&^bl1S̾_'#x_#Fk%xN{xK)!.ωg)xT<"}NqU(Fťb8[%34q8Y(#@q8X${Ğb7Al'bK/6:bMXU,/KbbXP D1]&fӉ)b21XL$qXb ޿߉o7+L|*>=xS&^/gS xL<*(q[)ĭqA\/W+bL\".E8\< \<p@:H hNI(vۋm4ub rvWEE_1ov?͝QsYEo1ZLHLWqrUWx WWo^MgRqC O)HWxT;6ώN#ɝ]9pV46k5{mNeKœ Ie8l&1>˦IĤge's}gm_ė L|*>[;]s7kxIhM'>kp".|܃w;pb/sq5}x8^'G0s?}֊ną+;p9 0`8/sxpVsNo©pRsA8q^omx#^MrqY,\ӹ~:3j?эN:0> l={Zb읉nw>t;3dG8gr1in:TwL݋̛ݧIt{o]rY ⌺웼J Os$wC@3e@1"_ߒwrN_ΞY&ǷfqH\hWJNOS9XmíCV#{y>74+uWYJW|SfdJ!o/"ٳJ.Y{.7Fx0{whg.F/ odk8=Ǒ;; Y2;8wdw~\nrMB❏{͜.!wηo}?r|<»O e~wn$;Vv7p_a!?ĎVͲЙj,e͞g!YM-q+^a-t'OvN:~7V.׳%p\/p b۠7 vDsq\6 nq0~Y.7n#5bmcܼcz8AN=S/"^N{^L 䎠5x" ܀0p[w–A p"OD_ =%v 6 2&l6Q6䞼Y{'+ڝ'w0ʼWCuHaVzPqy d~݀ncD tu[qdoɞCIN㶉Gf;ƷM2㹹Iazt:=uq6V@ϝwMibesNPxu( @vBtR;wP6]FG׊kA)zEl$gz)qz';d8fT9VJs99}46*=ľRCK'~$n:ښA{Y.A+[BZV\i;Kp[@o);o'9$n6\Up\W)}m]m^Ro9:<Voom&ǮM]a ofpT87JRwlV O%Fgmj 3u"lY}׾k{q#;zF}|ߘ7V2ɟ:eIvWrzAA;u+C5"'lm7b\%o!t~yKDa\:DsU)Dpvx^ۗcƯB i6lH YJzKBeͮ(kHP+e2y\u|9{~oVT{߂|>Y`&|34:x ςS`x<1`4n7Qp5\.Bp p: NSIDp8tA{hڂ`/ Z]`k 4`=6X 4E`!sP f=.x `2x<π 8xhp3e`s@o z@ N+8tG#aP Z= v;Asl6`m`,߂o@- > ^9,&'1x;-` W0 \g3@Ot#3`g#h[M&`]6X 4 @} P,/Ք59Z;-0U=/yM9~LҔ`,KS#Ƿ[Գ|^=52\3CvoK=6s]SǀAGA=m4e93є@ %{X<߉xxW`R<_g`6HR#3t2x^<ד Zp|_ +PPd,'ڋx_V ^/Y1πSԊƀ[M湿agg"3YV= p Z;>l 䇦1똻au u?,䈅7Z/4b/ &t#apA7 7Fu/TU@'\ &? }o>@'tǫ<@':aKw6!voj`-b9 ,?g`)}xxM^:A0F'F}1R\* v`0 }Aq[+'%'.:ȟw {]^WɆRtI=q+O/'yg30|oYeoK"H!r<w}.q3ъN+W A 5 se/;~Wǁ.VK'/h {fO;܊aif-yK}A,ТoJׄc-PSaob_;Cܯ5;#ÝA9Wa; S{': vz ~r:C4mv}>NNSgliwTwS=u7K~':k&1KeOpD4x <)oK-Yngg):];"]r/ f {x:[o2]Ż|.@oۨc3MuUWUKy'p&a˓o`Ǧ/}"٭UGJ8/[MWD;\3t;7{#w{]Ivl/wxs+׼!+ZOO]TJ;btpv-wsϜoW蔓͝D'tIK..Pn>{vV|Ws ~''tWypOӢK׿|1|z M(虸=1\cc9#qsstssvQkL16[{?v>îgz S+;JqAtÎ=;':M=eTnCֈ $m&룷~?iډr?6p> U֒䨺nbYhEG}m޷JWE;%oCt=U@%]rUvVSwJރ3-=2<śAx*xC~*ldFV+*n 9@]~S@_;Їn+CDw[~{E/_OyO{.DԼ?q_E&赸]F❃Nؗ,|`_jcq׈4V7|s+4MI]m2S-(JitY˸3~S}}o)~kMRߢiEgcaTcqO%n1SyKyorWz͊ܗ4wP';Q*'s[uy_jջWGK:м3G"zhwE]%atz+-u$:˴:'G5|ÛK8ޕ5] ~agt&&-6BHtLt['EGGLm$BSH-3[iv˛N37Y%~w5 S3Պw%%Fgo!nc_N7w ;Sjt I^-n᝕;$tԝ.ao=q-ؓ6ɻa'o؃rf 3wZ#jӭrbvhU~og[$Yn4ނ݇n̈́Ρob[K/-,)wpL8;یYuhBg=k35kxov~;L|wo~hx^gЕٖأbıkP h Q`D v^5vcԘ(X01kg9?rgsWMfg)d0ǁQXp$8 ~`v@v[-Al zzX `5 X˂e`! t`V0$`-40|>` x o7U2x L/s78x w;p\p.8g3Tp"`8 }` v@}A)l zA6X ` E`0?9`60 LwWKO=.x <O'1w'0 Xp&8NQhp8 `_vۃmo@oA7%X KA07 f{5LSCw[E4xp \ .EAp"/ z=a}/5O:KR_Zp;R[So[h.,I]R7[u`ϳ!7F;oM:ֲROD-K[b n|5H;Ϳ#ݼ/=agzɛ\ Kz9Ќ2G#ppg 0v<l顟=khUY7K^/XdiܪuEmY+,gh48BZb_0Zhw jC[V7"[3Vȝ-E,l9wQʇBm0W=˳ 3aƔl!5Wy|Z$)abP7̝EQCR,R_EY$Mz3E;RMMS'ksk]S;Eפ>_u|ҲVQjYvEB,:%s,kvIGP ͪ?R{Y3Әg)3ZU=n8R]-2NQ=2>0K2ߦxd[u^|0Q5Dy? ˭umM9bb;'oYX,whz݀>SҞ>SvhR/,s] <ܯy 7wFbk^{6ع {vg)쟧$ey/ey35G=LߡܟhםzO%ncn.ۘcz]cQ\)=neuR޷ mz[[ܸ vX ? 'q`7vUm-o6uEƖ X-|K> ({Cov^[}8ۼݖ(A0C=<rݶW5On%mRzE'饾Kqu vj\]B{C:0eZDAN>Ak?욲]ϻ<X ,Z@C[7R4_|.;ó^I~oPz{<7aE7n;Yx}a;?v}W`wM/["+s[6}dF}v}B>[|]{Fy.7M޺ca# z ўo*<|b'ϻd-НΘ [>Pz)t_]ڮ\8 Rܸ47={773]Z^:[u-P43wpiSfSC}_oed MG-bZx㭻SSoWҕ9󘽜7oo@񝩼 0|љ6&|sF>Ό.9IMjם63x]fvǩnefܞB|BSNxfj+/;zo[Լn IͷRRfSRpvz鞚֙ӭ,߶ied{~}vvj9M3e4oiyDxfgN Qs9}_vcsk,{H{",VVߗ~)?|_g6ח\m^B)Y̝NO?G!er/Ў/x.&@/XNH/`N6Μ.7:}@}6o?K3_J>MX_Quֻ-^oSrS6\j[53}:oQLwR[ƵťR/7u}-޷u+19O=vz6u, +{ZSK+r.zefC9y٧y+^S?Yz׫u[éU;uZ,moϿ#QVܘwo$Υqnn6mZ* {Âcw]QD,8XҬ JQTl;A#{K,hkdٳ{8aZ{;/Qx<p8p(0\W+0\s`(NǂcQ?AvAGۀf`S1`yX,m@k(X,?O|_/s|>9.oM/*xQxL>pc` WE`88 瀡`8 ǁcѠ?8A {.`k9 l6uZ`M:XV˃`I-| {5 |>9`6xWy,x<0Lx0nr0\ yL0Nǂc?C@Op {=_v`#>XV+`X,ZE"Xs|`x1/^z ·{=fϳzWỹ<σ3=σqhpWg+1<48{̂U<t~aW K:=Ȳuπ,|LoF_/pu8QI-\  v;-[Zs7F'Yvr qAsS.3Ubҿһ}`b7&ppm /Sa,0$ǃ)}Aw-wtn7 :o[lL)|\ VOdNATu%X ̟~w_{g/gw=8Lo4<ʳ<ͼSn9-/p>#3ZQsNϕA[lnZz<W)ݓwPz4x)2N);KWtS\rs~˹yx,,s*=}Oumyg7cfrT}yWstT~U/$^*+[xxI'ٝkx>=y!'SYʲgчك+-3S~pPV99hZJze:lݳ2#[Yd[s @K[J){MAft)055vWx/'EޕVzS 7t]VYJG'< Mi굥tG K9KwS']dvn.AWf#SWz.~=s3<|⑉5wzN̑r{s9Nߴtg-rn? p0ֲ۷ZU~36> NpNEemf`[.VU_s9 tp}IEƗn.7~#_jcKwnWx8>s.wC!=<g7x56^*^sg<]o}4i)~/o7+`2x<&Sd#)t-)8H9.ORd:=tʾ+}>)N`3Orv)> s^xe[rFOp x>fG[v\25s{EV?Y>uzNי,ؚ;NW~+ϗ,\_L7`t9.~7zO߯p^٭N g_gv{μf/woznNwi:7?J9?O_9[f1}}<קSvM_ͲܽFW)ѩkM9)tSd|\$ry-Eҽ,:ܣwrQ0l;r3R0hyޫ]ld.am2R{ܲ'ylY:\<ګHgx6Q=(otFv9>+ \w,9kV^= I'e}K@={m \Ge%UԸUsvO{ mJTwi^"un-¯2ʌS[zj=ߞpm[I)xbr23&vу1vYdr1=fֲkܚtm9 הIm^{s.>_{ecgOܚ{uk;S~ӳ7zlLfBT!ջi?sOIup[ڋjN>%5gJۑni?*K܎܍vpc!JnƑ)y)gU^NIǸL)yb߆zR qn1My+*Y%~6,rNѵLotk,ls#-n ,DƁ馫ݷU_.XsW' [-|cp?_շKǴr\ƛ-{%7r1:+>u>=n]Zū^)Ø]Kz22KnXnf{YǼNW+e;S̥m~oQ6›*;!)Q#/Ru-KMK6ۈvRj#2?~}="|{y:!uIV!5[uY̨wSc|--D٥w23Vydn2{+;SskR56Jr[Kśu."tPb_bq)1$fc,eo̱>BWxj wˎI"7|]>u)",n [t,o|Ϥ욼{*x yƳ^'=/u);=w$7 `-3R߁-c ;wvR~ yy-H~*xKO>=5kw]tz\ 孓9*WnZkvYXvSRܯ?9O7LyZ: X*56)*|t~7nO~~-(u,v =ѪTyK |j+-},oQS=o-~*Q;Zc 𒻯Q֝TkOf2 =UGX?8sEzX:X#<;My5鞲Y7 f1!;-+wLGGȭn+_52f+~țT\e2o`:~; 75o1wQ¥CShJGكiT;tSv|^;ewK*&U"um,nSFk٭$(=l`6x ^/y0<C~pƁ1:0 \ .sp8 N'#`?0 Π `6X A7@8X  w;/ _O`63sY4}`2LXp "p!8 g3p 8 }` l6k`XmAk 5=Rӵѵ7ν3`ڽ`2LTlcW4: )tl0Y?u`sSӷpmMMǖKEZEIOn^1<;q -FqGFRupH*DoI'&`cT , Ru~K>Ѕ}x [^L{qZsFˬ^$p;r8}5gS1ܡQ{ec>>bsy{t py6stp\:~j'vK'r]-;+,,ѷϴ1;ŭq+ҽmZunz^Uic g?ZmxXޑ=72t0cҿSv[zbtڅ<7b>pn.Y=|!qn2W'w2Kɳp0 $C(RI^wif2/;%]şگJgˬ,]U=-~2/wIVǛ;^Ff}NFV}\{d>2{md!=cѯ2hf[,xF ̰rsOKuPdBOw9V]a^K;hdU3o0x3s=qW!LRza37N,H W7g=a$GqGH{ XU :{ɢ^Ї}2"4ϯ҉Np/a^ f`^ыXz$ܨ{ /3ϧ#w sgO7q+n6W W۰S[q 5.$fڿs~'2ZI:36Yy+YWމt으 ILރw2~g=ŽXmM]3|Ǜ^I/n?e|ƪ>iiWȯ[t4íVCYGlbՍqM]We?.ayq ''yq=Wn2t޽^!ǔw\>{hAc{9FKXHG;Lݿ}HSwѹߢ;vQE;h1t/pQLmйfLK}>3<cNq]qd?WW+mIn*B=7\/{GS+HnOTda`~nǖ9 Ž,|{"]+9fdAdDN;10,DkI>iboˍ)d?f V+7ܹ/}1gi{LѵԳoү2xw[x6B(s.͜gӸgo?7j'̈́1j6Zwt.=;nn|rn+U+q/H.1F~f{ltXfc_qyfoդ2#;Ke:L_]}j.]/{:Ӫ2Zo5sUwk3*۬B_˼;Xwx ,W|X9뾲FOs}>p܌Kf%svuǫge.S-nC8fwecMykk7fX:^fk{Gizx[uǣ㖷iy㖷Ւ!KRUwa yqǙNo+t=K0oRq{,$ocfl˷;-3o}[5Vs`va`y sUf*wսFeOl.rlkY՝ړُYU@]o·~xֳ޿|nLu4Wz^b=J|lqinq|L4Cء|Dc@ﻩgGW3y2gw^-9F6Lg'[xp×;{dtwCŻ5X~ v3::NS͒^ܚRu[l'[7fo:wos|K[Kβ|G)}ܧ3ߦv}ogU[˺}`uNêoew_ƲD[ۙ߃_4݀ }xE Ď߇x{VK= `P[7ut~moƎowS.M|+E'EvnDoo0[P~{<ŝ;DyϳGĽ]Mn6F-;F^Eψ;爿=߭rb [{}[x^wW"WJVmb%^f]+F%f$=bĮՊ-RZz}z~Q|~뺟*V+ĊbyXR,!BbA1K):bF1W6^|'_KL|"&ŻxF<-Ɗ'x@/wVWqY׋k1L\*.爳YL1D!Nq8L"DKĎbF^bCC'buXItˋ2biE,)EBb>1 Y "R|.>DxO#o7uxN fυv0\W̓\%{.L=~o&sq #|\x1y6<-c3Aq #H7Q#RĥԚdypDL8X<H _|Yl<6$τbX#Uy,8?I̐d>g>^`xK)ƋWœ {~w;̝I {(N'cs`^n5Edߛ{Skna7=lb~s 3Yd vl$1!l;>#fpGxN\$W_ ͕8l\?">w77A+f+d_Jtz\u?-̾<O':\~^<*w$;kw<$wlqfrwvpKu_pvx"yXW]|5y"Ut5=hI|_û^b#sJV3^g/c];46zt(d{?xx1׻NL5s$|!o >Ûw5\$.LÛrs ~M#l-S֥ʭKž|mOm;[M[vZsNk~d{{ܓg{nyױefnZ݉~;|=K׼pk+sةYk4v)4| p*a\Bg`nU;t=Qe?=[ޱ'?f器a&\f(YvOpbk˂ښ̸A˝9]wQ&jgKtн}©I܊>bK#+NMIGOn:%Uے~*oMz[sTޱ)q.hTu{2N]XrJdJ GVvһ s皻Nn3v$}DCt?-X]ͷay6Q=Z؇-\mD ׾w` JxǛ}{f[z)ވ1~؏{V,Wۑ%flnGz[~r3 f#\A5ߟ=Į]3/MFmn-yi7N9ƌ仴y>,ྌ<*y@߲UM#w0phvɈ>b5nxA7ܞ*'Hwh=YAOeyܯ3hdN:) y&}Ъ<){VqUw( ̛p;>ݪ><SQ0k7(rh,N-MV%Tgk~޽'n涞]*f1ϭe^w3ƋsuӒG*"bܙ}W7y>ݰdP3kξ#gȓ̏x +"#PnknTre>}J&y["vAl2TScj;ONWQc^=|Uξ ^ŵ:.׻vߗnr{7np|'Ʒx7-n7wD_[7msqng}w]wzt9GlKOr>\^r_ߕg Mp86=e|7Q:(wgz9;n{9|-ߗN7W\yc Wy';շ=yo*o'۴oގwd~񿙿%zI[;nl ~Z;Uϝ0]+@gswk|n!6ײE4yտwiX?ߖw2} }@dDe6ü%Ñ L Ȃ2{vٽY@Ko|2`;`7ߕ:z=L2,(Ů' }{rx'S~vG&̀|?&qtw|#'}|.)9NwyMn\~#osbw?^5u$N3Xs_7p.}j[;fo_5=w7sk.s7\u>T=x>8W~x΍e8߆p׹qz\>B/nfWzguI~77@~Wn,{@4T+y<|sMO> w%̆|7&xSovO{Oľ=U}z 10klɷ@fĮ`w|y÷ˊ'+by7|V}:ߪn''or۝-ݰ{X} Ľ[lh@=7Voq'on&]@čcsi%.fL)̒aw&;r\e w{y?ķ07BnJ wٷC9e\}fK9%fn7F7e`j72;bwwGdN;B 7s}#?b{#qB7_2|awD[!i7d ystHE [w)&Z=w(-»x%dPx^uԖUGϽvcwv[۱Tu`woYg q_}9yMbF\!.s9,q8]&N1(q8\"Įbg^[-D/X_'zuZbMX]&V b9XJ,. EW1K.f34b*1\L"~f?wkL|,8+^/91R<-GCbQ\/W+rq8G-S)dq8Q AO.vb\l(6bX]$K%bbQXX,$y\b1VL&~ߊŗ L|"_]xKoSbxB<.~q]*ŕrq8G-g$q(ı`O+]b{-6=ņbXW!V=OxU"^/3).]^%qG-7;>X 2r~Z~J2 `[~ͬ ͻ͎V6hz<@yͼ]<[.]n2o?+߆ѹo[[p{.M-80 [vOr}^#{0-Ħgv; z>"}\L*RvI:-4%w_/%otMއSqm;L$nH;Jɝ}?M{\;H*;}Ri1*Srɽ_$yKbOq.z;ǿmǛcNu1m~Ț,㸌74w5ZOzUzn>c\$Vγ5Mj/]Y=fyw[3άV׎ޚKc~.9=?Nff>>N;w>L̝ܼb_sl8=fVϺ1#ssbx6Sq|7&1n$r8nN?'wTd]}O$%3*]]A,Jq]2n=ѺkxO+PdefalbsG;ĎVډz#s?5]Cqp2٬U1hWt\|e#^ϝ\e6fo\)7do\< sgdop`K{̝'̽mnLsc i=w^\n355w-N3_ow&ojU'qkoi+,-=H,MY\ex^yf򞮚ՕSgi:g*3u9)d:1s2;1#̻ZǷݾwoC> L_ى6wyk6'VvenVuLNaYo^Eoq8 ,t.qg.\\S87=v~Yz'Vdo.7+\kܮٓU^U{03 }wbfd:S 7v؉iř{x|Kt[U3{13rs)b:|XruNf{Xm<ސߓKyCɹO;ݛz\\Z|qfuh;^s:΍u5!gcp]vv ]^+{s[ 7x+SGZh|2:nvĚ^ro[2oGoMg1w b{r>nzzc+>͸5}]7>Cn>T1\lqEi9p2^8*4ߪؗq}ݖ;W<_8J/s= ˷h9>3-i9NvWy}\v8 _9׎|ӊ{\nghݱ5{Ln݌7e7m_nf:ݮg󒻏q/Siѩ,vqwm-p+^͌NgށŒ}{l}+4sN,u]Ǯ3= j@+o1h;;8w5ބ{w߼ty7N]7~ ãբ [5ދvW^{z|?jf[~L7ktV|5wm<_ܴ񮇕qE[;v|b=ñ }e~17Gܬ%<A;FgG)e7«pi3pFwܼk_dEXj+|P#[1Wt!,ߒjh">E޺o̼̻4ՙhnO?ͽ{qX*\U!V~z 5~?AZ $$ VvK\;f{[Dg-{{S5R]y?Mx¼Jwp$vFÌJwSuj.ѹ fT\Tߋiڝ[rU̦{c=7\aŗ%" IyLx^s9tUJLX"K%C eRC1Q0PFRYfmD.댹o:~?Z~x|j"ǂ0@_=`#tۂm&`ck` `%`* |>%o&x ^OGC`\p; LMF0׀0\Xp8g3p8cpP`/`{tu`-4M`5  ہ%|>O#,} uX^ yx<!p/9p fL0\&0\X0 xp8@_ z`kt[f`Sth Z9hV+5 O2!X u*x O ,{\0 n`:)0\.xp!8sh0gTp8 5w BԁiEolg` ;:!m,)hbiKV/蔥Z`Y-p7K)3Zpe>.d&KQ0D! 0@O59>:ekl59fCڀY4עsF] _$9=q%Eo^5OG:NI~.-WK%"I~9 FQ9 &a$pC.7ŝ3@9$f;$߄k:X+VY _zNy+o{yUqs<`);%7I`7ugPw]7PwE[uGDinXUD=S`%q3!i`xRHpx⽡N<˃?{gx_vƒr?ُ9.gxy50>#V/;.KݠevZK}௖:EV4{3pfp8<Yk>[ռDƣĮn<bc0=FKi)yghl LyO/#<䂅;p{݁nC?ܬI[$%iI`3ߑm fzeUgAp{;m{&1IMam>ɍY8 ⽀9So+1rwoΈWmx{#럫= ;Nӝ!{|( z/?E G\{%ntZ9#n[` "wQZkEto 7.ox|N,w||c?1I :I["֏%X~-׉;$|3}AtK(-2i* KƦ]^wDMbߊ z&nUj1q^giKTG6)xWxhOs`t Z[ao7#+Aowȝn_.tS}yo5{=s]u/.6x߫rx_yy`y7fG'Kp7K pA~kdߣ ae7`n#3oy{Vm_vN>ho潀ͻ-{Aƚw1V F=N#=lBKl[s G{4u|9~W[9ο3R}Ǜϝ_ޟuqC|;3!u#|y/Oݰ xϙ|NHDq#aeny /oz~k | ۯ׉ =Ͽ7 #Ľk|9|OIN{ tA;M-w@dc/19w|3 ͳٞgśg23wyw)-&xς'ca xncMp= F|p8 Ctp 8 G p  `glۀV/ `}6X V+`i , :|`n0hځ9 HO7| _'cw &xxw;vp+c0\.s,0 tp8 8p$8 `7 }`k`y;X,:E`.3~?;-| f 0|f`:|>W)I(x?\oׂk,{lp;[x1W [x6`k OX!GZ~Yx}hf?]7k0 | )19o7^/πq0< IU/ܝSnO+2 VLُeSR`0X(?͟£JjZ=}mi;Z5vm-E_aͻW=EOӇ2VcE,w{J}(R[Sg/JcJp ):z@ =R%뜲~N&ϛ'U_.k=-ww,yu0ua_Y.zh Gwn7f;N^GYnnnN`>VodY ʛ~-c{xQ:w1u10^w)hF?rϒǧuޱq^SuBOz;zgW#y6]l6MяԋgT<1f)s)7=7p#7Wy֜MBY<ģKWa?+e}rV^bʼnb.9I Feusd"u٢3 L,}:be{'ޠK-q[PiS`ݛbS3e-1nNYS̼RzR M9ʝ@'=WyRܲ.B[-Yf7Y5G˖c2^ꍮM!jAR]V79Yww䭭,n˛:-.K}*_Mm=}ZεѲ7zl]̺$MRc,zNQf,uFO޷Zܸeej-V T-o]__~J~?K07vRCfrUyȍXnutd>^blT&˖7UXެ󎩺nCePv[f)-;2=[j/w:}Ww:jz7۳' .w7^^ض܃=<2^уW.7t׺7ng-C׆~V`5A؋o~,b?e ?M̼?ޢG.fVyۘO/Qon#/{4oazDzBΥ޿Eϛo >/ vhy#/o\zufPz!)n\}yϬ/-@ޠRo޺ܹZ{O_(w~z")]Vٿi^e`(kӒ Z?/َUKsӒϹVyRbިy=t.}UKwe406%{~`Ui[63\{5xe-oS˷v1Y@5}{Em=,x͚6.wBj-Imj{D2y7f[2\YMkLkf4{wٹyDԦ<0=&^/+s=\,);>N87'e^}gVWCڞ=f.VSfjKسإ&ꮟ7_s9[XܙܒCmNaN>^.foVen3{Ydok2htf0Wirbo{z9Km׷ReWfY-k[35"wˮη2wyu)oϼݵA3 dz'hzvhvzeuǦ׼)/?e)ȱ)oT~y sKz 2^7^w~Sʬoi7U7M_>7.j'/q/(ߺm+nvN{A7nw+r_i#(=nfYml{XwxϴӸu-m 2 Nƭa*xmkeUqV/s#];B{)48b8ȢmB{<<=}k7iq o@y@(s}x^c9!LHBPJB3FY})e/MQIvjʾU2طHhYC\z}fq]@KM |0< G#` f` wMFp\K0\ `I/ aPp08@{ ~vۃ`4[AC kj0| >O` x,+%0<Y!  )6p+Ƃ120 \a`idp8@Op8 @g{=n`Wl6 @}~JX߂o+| 3)X >%`1X :x ^/\0< f` `Lׁr0\ F`8  0gA?htn+t@G`34M`Sl6!XG|߀3)X -9 xɒ 9Ys'9wv`/ dp ǖg1X$-u5A]K$+3Q5Ku;iu^/<0WEc: M=3 ܣG =ju/]Zt&' QwMn:E^:u8]>~vWҎ4S9P^VS]D9:&zLtp =BᎧc%w;=npx_ɽr$%Yޠ3 4p*8AwF#]xOI'Ve'qOl#f\$WDW#uv[q}$\@#\p}p& 0ƊļDaN@e=%N~ v7 }B74`}+`#ϳ39l-f:: -`<3WKz3y_ҍ5L/ᕸ>,z)w:w!`3Kq(+~K`~>=#wJIRl+ŝԍTNt,6aA߄kR5YR,u-Z{bjR0x#vK2otM/蘸]N]{ΡkI((}ck<}#BQ-M"|žQvJl''+r@_Tw|;Qv~dƣ+DOY'L&ssݑgf;A7sA򽱎x/oSf4'q0ż ĞȻ,Nt7`[zvo#s|b0n!~n=G~͠7?vB+[u&y;ĊwB>`w~ L$}ZMx|wfYkNG=oK|3 N-3Ǽ1krƷ5z:S~KX |o?3Ǟ/~$v~nYL %u*>o)vog'I=^2w^dT)lyd\`?g;rZ^27}͌53-=m[n}ufm@yWo.3[+<]U~Vn~f?<|c^g{0_R;~do/&y?~| 8|{O^w}!|"{y)銝M2},oJ):k:dxC7Ĺ#?zz8e2/7~vsCĿ;)g; |GC*;;'tU{Tށݜa?o$?tO}q.o #pwvvj :|O'ݝHK]X }-kux:h?iiV@}= yG?E 7?ot;$M[>} ǞNo?;w{M[FJKgS{An.|6At>ww.Devpl|ƞf=A*33w-oug ͙Kv%c.<yO\#`ǖfdz#ݙ7S2x ix < {8pQF0p g3p8ǀp8 ~ z@wj`<A$X, ܠ ~߃o7+ 3` x<O `<n-F00p\gt0 @?p8 }{]@ t]`#!J3X, :`1, ځ`^0h ]_/1x7 Y x<n0WK%p64p8ǁ0p(=n`Kln+XV+)_,)<M{[0g 9rxޱpj2=O',&Z8<<pn0ڲ7X; W-= z8מ` owۃ`U2Xɪޖ.ڃBz_<\< 5>c9|(8ey;z=γۛzN=1hjz p p98Nez|080x.ۂmR]wz^ V]RR`3|9N7s|\~+ÙݭL o7-W`3.5jPfTp oVŲ{]-4},2~ y e s>Mx ^/yzDyx<z=8'83A~y0 x̄"_ e1bL*O2}spov]S1GnKZ3u)&[ -Uug%eOe^NT 3vӖԸsH7g$;SFr-DwIފI77 0#K{i3ZŲ*v[1Ixg1ހxc-nV t|[βot7E@;;r;2sDuZ:N+&{coMNo8eN*#BI|7azn^˻.Q!f#wӭw[u0Ɓ$x<}np n#5` . y\p68 /xp8`w+[`X =`It\`N0fӁA0~?+!xL8x:p rp tp8 Nǃc@~`_` lzz`]X V+`iXt `.0'?g|s|> 0^`,x <^0 #*pgp38ty;XMEV\rH_3ithuu,x:Ezp*WK"sSHo{08{DCDz 5`Lwtva0;]z;;-[9z%OOzzxˋ90<c<Ћ{<] n~_7ݦӧxz= tXOzz?ޞ>No 6.V.w<=ɋ˿XzL?oU:.h~`` x<` 7Yqunz-=kppivf`cK뀵,ݮs-zaˬ-=ifThwQd&ވxREqL~8ffrM(_%/蓼>}zVfN`(oۛ^<]_(JFs:i4QQKV m:O ^~̋o9}gFz+|~wq^8s@fN%u6MM ^2ټ\|mlsyX;Ot}+|gɖ+kߙrK}ޖ],-,g ` XJ}o@Nt|'?G뛠v[1Qz?Z^#9O9sHFz}84gox[Dw_?cp.^0NN>9tm gn?S>3G{zͼV^ӯz:V~wrzqz77]^|"p>8one޿|eyٽ uzm̼efV^]Vz>.=gjNOE߷U,sKs7c#[f0wEVtݡ՟>s=y+=yg+Y̎e7ټu X73w彺w(Ku;/o[ܼ}Wۣ-(vWe1[w*,k)Dd6|ޫZ6fe}ӻ=fCm+Vf {+e}[E=nN>1G=ś2O~MsMo MO߸!V"ꍞvg_eѯNݔn ].1Y2[{{cMx~gs{q_.t-hJ:Jo-vVބ_݄GYnmtwNL;ͲӞd+&y RYemJ:ZwtUXJI}oDnIyJ?~lM٦Y{ܐ#NmqPw~a7e/y;rOmr[CWv\=NMApkj7l,s{S[Ƥάo<2O'x!w^(1sqO7t/7\LtU}{*%qZ[Mޜh-=׭wVIOkK%wMmNn9+'re{n heK_ECֺA?7(&=+}ޤܚܘ1yGjgj_[ )2O'U뎺Ct2W{ IirJ_W}UyJWiHW vWj{$\J:Lo=ݖo2ܕY|yns_ֽyeF΂yM;]o{youb5DucMwom߼-zo*:ݘc+oݩ|tU~M|عw6݃|CZm }|U;MhnԺ{vZ3o͊V+z3^o_seΏpnw[e?s\df6/S|Oڻ{!Fޥ|GzOx.כ%]ߑhޡ&xf`q'k#P-=Nh#НwfY-jJy;mJ=ѝn~݀Cdo|[muG6teOuuޥk{Z^;|!yΝMVݠ[[8;=oμWNnkvj>O}]j;]VgsHz x^V^Xw ػbD!kF`Ec%رQƂ\Ă{73z&{}3f}`! kUX,KN`X s~߁/gCxSEx<O~p7n7D0c"p! 4p*8c0p4 `_vۃ@ @_ z 'X+`Q  s?`x Tx^/gqx<=.p \`  Fs$0 CaPG? .`[9lk5`"˂e@$ |`nw[ |fOC>xL@x^ςSI  n `"WhW0 g3Hp28 ǀ#a`p8}^`V` z`e2=^,x~0~fw;`Z kU/6p+\ӫU 0\.I+]S8zd /MMz9K:I[&)[7U[H]7, O){tsv@>~Gn~`{es^%}N]qJYwWt|:sٳO{ξz?_{ܣGs_')wWe}kܚ/GJڝwp^bsrk2 TǃRce7SޓrQݶR䢼d.YSd!;-]U&~ߨ孩jUOaUfvr2nkؘEd ?pI果`KnU|djBvXylw叞%ʳڔܒEg{ʇ궶&ݞ{,ҳ1_.y|{p<8 87@;U3_{+f,ٺG;1߆>}^{:`unX<=J;wy;/ ~J(3Zo 0=Żі"xC~po0;t(Vפ٫Kmg>?;3 Lս̷ĔߜSܿI }kTim-*{ߡREg Z&E/ K{a;|!&k{nnMx޴YceoLޱ,޲;,tv 8QqXK˞r5n,+zX,Z삲px{Yt-u_{\WK^jC, v,{tώ(oqMv ^jyYyz{|}Ͼs総[_WuMSګoZo7|ǴW绶W{Ϣ^z꛿|Ӕ7?2;y_U;۱Un~E;#qxtSg)z7|k6L# ")1#&o{W|+F]({}gס->#-.a9beUueoxtY;z[:Z{:;Twr}}=G7z @iv=ӗyu/7vv Sgt#`w)o2z3i6-Oﲯ=ܮY4 oW;˷)tOඑ6gR74GR*S>~+Sk ؋M N3a/VV=ُy-ŝ?npf2R5e7smqs#1露ȝ\{tlk\f"rs=uhfܣs=y-gq.]ϼM\]Z.ShWtU2縇:57qZn @hz dwfOa9Zwtud;[x_-;_yS.j ƞ\a:oݽqwMNs/koJwr3GٛٙՕbSVͲΫyuaoSxmR֘e~]z̤ry|Wf$]U>N|g7/mk{v.󑷬]-kݪQ.Vۓ{[Nrl,cTFjjҿ2'DZ;(c>6ۮt&>:̫߸O]"I'~J[[xLo7U0 &`x<ƀa0<Fanp n7%Bp8gSp<8 =@3n`7 `ۀ`3 l6uZ` V˃2`I8X$~sk0|>G`:xLuxL/D, c(=.p Wbp!?SAop 8 zcQ`ovۃ`+k` ˂e`)X,|6| >yf«`G.'<2`gGxgo7zd\ z@p<8ȅ<Ϲpl y·};zdDg##:M=zbe|X,9Z{daA`9O3rV| _ w~L- E~0;ȎgӖspE> ȈRp1"'@N$pEN4[d!`_lêE.t,aKfs @X"'VȊ,rX~̉)aJ|`60^J9&gT͆Q0<̈;S5'KWjNrN0JgȈScN"'N-烲au!E6 Ĭj`\`&,KL| 30s6<&xtq1##;=r6l9 {s.]9 8#̀@W9@:z xx_} 9{wefXy:,g_,z;_,Ţ\W,2@y~ [Pp@}u^U379 +-_y} )|;){x#5)*eK}z_jO)UrW$Us@ M)ttہmRx%"N@ۦ:);$h Zjɳyx_v_vW< j7^C=:}y?\3 +/0{nٯ l v^+hWXlP.(ZʃrOS-<#Kfx5OxwЎWOyt2[x`O(Wxg`sDp;ñ+)x{`[<2seClaX#[+K{tnN.Q'En9+Y㭡Ɓ֘9 W-=B3E.aK(![ [d`k3iS0+MV[Xc`X"ڂUxc`^0'ȇz}A:;N07y_v R}}a)zo t~oRtZ>ctP/tXYotN@g+};Ko3}Q9O7{.`gg/ue遼.[ g:\3,+o||cϚtsyN::geqkrMkݙ -;S}/ g*g)]ɹtNj3/o43פvj[g)HO4wOѡYp&̺i?vg͹R:̓/Y3N=:g܃~ϻ]vntH76ZXk閶nj\4Dt/?/э;kkNqi7n/u}x7bqѹr ܅wϳe}iiYm9ao5.ŞW94c|ߢw;z߼-s)rP{.]d՞+'Iuغg9|>;:ks^nݹ(=k3짥s]tGwnPfN;Ϋݓ;͞Z9x.J[gie{|+'g]=^잼kq橃=߸;v_?yݶwanw0^Y[X}έֻ!ָro־-gvYJ8{j9W6oJ[Kwo^޸˝@&EOe* 82L <,f0Fybz_wݰzc߷:;*=k/r]s;i+gr_WH;6ݗ^%ef2Miw1^CilP.p6+tf\<( wm޻jVs]yZ?3akzҍE0򮥞}٪,{,;@W5fC  ,Õ w0)#ʛ)wpA)Ye}Cݕ@+s[|b6P)2b;rB]<(oSV]@==}NPާٕ x[u7w^s|KNPrϥst;@#żm[ 8׺pj^uN7)A^jוOq-}*}󞫙&4{t]nO7qMou}rOe7.JO7jx^Ɵg}(#e;EGFp&QB#cdH8fBd}^t]|%"`0'Ho6 ~߃sW+`xLσd x< >p7\(p Pp8 7z.`=.X KA[0X-A ~?[ | =.x Tρ^pwVp#\W2p)\.0p8 8 G#aPp0v`'[-@Wl6 A' V` G`p75 l]AgI:M;RxL[pw>0w o?Xlw[7~xonM8@/YvX,ngv;ZxZ:9 6뀵-+[tS>g/ӞЖ[mDɧD7?%$7D䣶Ş~8 x$p7=ótP {_n텥B)\6e?M2}immnSMSxާ\_]y z>K~`3@^f)w;{Cn_)E3naaPWF_-ʉ2S˹n oZc3#^ȉ2&Zd:7:PϬ`Ne-^WVnەۀA7rfy5-~oc2wgwu;3Gm\0C̍7>7M90hbs\(oXN~spOCrߏsu ;Xpv?C,6A8brS{avJ=^->/5|_.3ps ếOzBsgrm=`v?Wxx.y;(wnu]~{~{+~ov);>kߜ۝ޞ鰜UI-= =vqu)mr_UnzJO'{{]w=M_?vew~.f:<ɢ'XoVu:}d$WgkEwj3Au6~WˎohU6e'/aͿf7W97 0w<;ol'<}c7=? ]=ν^Gov}u=:֞/;{A0x8^Tu:;nz]_㱳{[)+St=s9u6=<;p7Okl_)nqd?zc?{myf];~gFֳ뼿Kϵruv{ yuv}y=ot[u,]M1.o|M;ۜ򮮝MWp|L_[;t9mh:&N=\:6³rOkK-zuh7sMnk-65Z[}We}Җwl[Z\Rǖ;^&fyg٫MFԱcy_Z,= \.1}[N.77ugqcS`_ŵ捍nojnW{y]v2wm&g~LΦ4ggsoFrYanov]V>IHyH[otkۧp}޾Yƻ{/{ohsS}\biXt7q3'}>f3#n7Xoh-uv=̼8۽k̆rsV͋yKSw2fog(13=lhysg^(ͫ{ ck_|grc>ţwu󸡕wtԳ.w"sy=[ uo,w[9̼7Q)w4]=9E/KqSGVf}'ri{Zw0zNLiodt7/ M/L>j]ToOp.Y{N.dh`z8=:ncEtfok;Zw9U/ƭ-s[~h:Ζyv5/ycG{.ۗ]<ʫ>r.nau1߿qoˮk{ .}K۸WKk=+KթQtR}{4߯eAȔüi7+vZKCF.orPI`_Tq-v)ҭunC[T6UUߗw٩tyM٥|ԫC-X޽Riէ,:U}Z:.]|=.ka-==er[׿9rCk7k3oW'=n^fwM%ߪy]{}=L7Wߧ6^}Oަ{|S+i׷/#5u͹,Eo՗#R򒻶ލڴDY˷*;%-;޵Jщ|Ďx^cQ5q)b첓,#kw03ٗPɒXb%[ْm,3F3u~u}=2]^/s4xLxp/ ncU`\.瀳p0  ` G~p8z` v=@w l&`#5A'ڃr`IX  V{5 |3[Mx<S3)x< npׂ`  . K%K){9>9#Sp)ɱ)}z蛚)<]zK lr'4NX7e=+)hJyRv?1[~b(O}h=o*;}OX YxD}.>wp3ުA.es-;} p:d!JfSQpNm] ֶ-[:coYֲ轶[{t|ָ)?m yw;ǮÞxsyΫ}iD^.zxS;Dy}({WE9> ;ݷg-y4s{lu'zFqr7*=ݢ~^T:M.Ƥp٨H+{v ]& Jye0|?_t{QSS&yIH wgyͰiVI`#7r ]6!߰37Zt+h޹Ī[[xENQgRWO6F;7ydi˝ޠ3F_35ItNW7P郹 {p>!/p#iF}ڏzGݤІ` SDh>|G> sf({͕z`rm|+)(B)=)z&)PsVdUJmX&5#`=D.(:GK} ̰>G&[rE,1Ϟ[n"NUoAOv %m'-;Hz/P(:-|0Xjm.PvϽ&&v: #^uv%vjꝡé>PWPO(7ówL7vw5:x{J G|N#>K{)|ޢ {DygQM#-)>V! vtFq?S; : wNy[aX9 }l ,}tJTgzCnZ[IQ*}r.1 ݡQn=N YW[zC,hiu͕it-kKAx[~sIEi^ɾխ>K ^?xvPQn7zD)w S p Cﰋ#ea٫*o4t=B9" ;HE<{F~WʛK;ts?p )Agn+ Rn2A{7\2$U ǥ|KSzGEJUuf^BtJ zIIt m|d{o!ot =R !WoQ]1,zo-̽tjb-r{n̳g0u߿nk|݂C.6i[$5n;NA'LH %voSzczBwW:BqOMr }oB817x3GCR =[]Ρn-uWp4Yo[|͢r{otv 0j)n|'.pE'xbGtgޖ7 A7N}mv'ֶ}ho B`?'Otߤ+淖ph&n d>'nZn=Fzn1}>8 !'xᄲNx(Hy\ϫWA'h.(7n0%ehW] +ܞfIʿn>sNymStd:_.6Ho.9ʭk_(YpO;7n'iy;$s[X˷F}ޠ-v;63"YY:7ޡܗWޙ,oEm~e |w~>ws" 'ݼgx9 ⎞⮆5~#@g'Aqwn,9E7sǞ_3< Ǧ;6 7;v# nYgíҵKK9v*%ZKKm3nG-@Cy;ޫw Myç]|TSC:x{SGǩw)koXA#ٍ@u;.H#.=\Ou4R{?wp0zyIޱg!0C{]v0^!ޑ{8D'/Xn ۊ^O*>b7n!%^F*-ڼً-u"}WVx¼#{< 3]Rg͝Rw-u~iLYѳk~ bO4Uvi;>=`^~74gFY17gcs{?w72|&>^k:?Bno]ԝnù5oMrӱcoJp5v6]ΓS}'+hI=~,k=)#}$ismĽ-lč__KvofϳXg|yŇ-,ngkŝN7or||p8]:^g[kі^okav^]MvZϹWjN..w;oks꺜^~:<ǭM|z=zssx.F׿b_u4wےMҭ]ls{?:?s;[;r 6>Ęqosﳧk$m}~e{wv3IGxg;ٶ{oeȌu$e~mN`7x+m aG&!qT[3#(gF 3"~g&<:[z?;R.pG3^y;J[>nvVHv\o76{oq#]NsvS>0[};=w77g[[|eoYc/oHr75}.]ĝjs=L?Լi5GcWoft;zCqג~殕pLn;Zx6݊--3ۙvFxgXݬ򭚞f5nVnnkK5w58^,:c> -}[ڥZc?>fJߢCc+GWҵf`g-w-Wѵgw'v'}OܨSߚ'eV4R߲z]-pIȼ?nJ5QGlZvxʷlM/޲bGUf|FOѥrOoWtoUt\fQ짋N.~~sp ߨMŦͿ4_,umܨp*߶j}&©x*ojnq~sߤ>}=]wn;Vӝ踶U۫t7j_'nX&XO_ف.:Sy]\xqm)iѼW}ݼWJrl.׈ߪ3(Ij֒uy]ة])6j] Ol2ޜq#؟|~Vѝ?oO޷۪y2bV{w.sߺ|⽛Ei^Um'Koѵ--iGF^p֖\fF9/.w,yv]ws˴%R^ SkwrdK~#s7>`̗ƜX'sYVf6Ż8oȦ{p4ԡZ|KxdԞENEFq'DNiNλGI弽i|7g1Ύ;Tjqc?c̯moo2̳#2!%xfs%'HL ƭϽ|ZI/Zd%}Ry@;zyݎw81γ,g6r0Ӟ47m=9n 8i96ҍPj.-MW5:gsyv͝Y7Vwk̤si-l*k+|k/g&eV~_cF}=׸a-}a^.oȇ̆nYӷ3fKp;wL| û@Y@[:57|`,=L{7;ۘsĿ~me$cǔ%v ݿ@^'yqu[In绅n.o7 Ocp W=^3=Y{;Yxɻ 07<7;{?1қxcθ?ocKxkxzx^gFEn,;`w`ba^Q@BTT b`bk#bw\{Ys`?pa";`CX V+r#`0;h f_T-LG.x)(x<  >pXp=9`8S$p8A? `Sleb`s`f0 ~_/g}0 ^/3`xFp\F`88 ɠ8G`?=`;-t]A k`Y, yA;07h-`&#'#|_/S!x 0LOC0ƃp3n׃k` \ FP=Գ~zO=Y vۂd X=k:]0ⅹJD >mg*/{i$( 6q׀*p%\" r8N}_9ǂ]s8$^'-=gCwU@'A XJGᢅ`>).j ZEj7Cd<~s ̝D`20\l + m`wn@90 ]k:fEX5EtpN;05U{ß^z;͋yHK__;.ud\ Vw =9_;dǜ3 r8B=~+J7!@%u8eUX,e`Ausk1 {| CI'cww iv)i D-sx,p(zcW ]SrY VwH, ecڂA+07-_tOY1%OGOng vz#1t9`+N6,n {;!: }=V#o7?U̷6ήu<2ެgZ'z7d>]=;3^6þZv]]as-L"ϋh0{Z痽Os`?IJNa淞]f6xL<{Ż:N0e6C9qGbo,>דr3K`Q)s}~\23gL杂d.f#3 |G8#[5VPr~s8oA|ynfsc`^bWf#XJfcnrnǼ欎]!f r8gd9scw ܪ>oRרyõ:#wzt z_ }Mv[]Miqg}EOGp]̢M.# 1׹KL%bY{wKu+bcItȅ|)3?y?D| e [^ApM#z@O)=`O$;FFG"p>n@w{߭!?(<~𙹇1tENzA)-oXWgWtU=qU1x`w[ZqfV{FAq&{p;ʚ}$ ^>{Sxd/ ew#q̽wwv:HA2>xۤha-h wvgOb/xȾ Gdu?1x`ɞ=4'5'eO˸6R:td);i3V_-΢*)ntӡ~+>RNbG 'utդy7xh/A/GI#es'=g^Ľ%5,;tU yҊwv|ni*O}P+n܅Ot,wWto"'z)vfwm|3atO?;Wz-z-z2u4ۏxgo)v%:g={Q݈R8D6WL>E:'zQ&\W<3U$w $vOe//|p ;pq/r{P/)}R=Lڬ57Vv|MuVq3ܿrw]q a׉݆.yL4948sǫ g[qCxrwawr v%xY;L; {JCksG4۳CYtC@@7roW{ nP}NxsŷTz]c_p7bqNKŒ>Ib -137͜}؁JrW`s#:7K)f~ v)RvH0^9 *~ sw6x6r\L$DGwyO͒;JF¬r3y?ު;X9id<}cϚ mf<9x;݂7qh%,w 攻D=s4wV"'9l "yVn֑Z.'NF4-4M˼-# 9׫i5ל&l_ 71".9 _ىmwNs >s㭐d'5R3 'nٛ3wzW,rγs9{v Goyf=H;Gyk֥#[wVo̽3}3<|s&3ovx޳#WZgt=twg6]0Q<ݛ{w!ޝ0_pn{t>Zz86z Fl <7խzc?^ ) nx^gԕKa/Qa%`b$X@ޣ"]cnĂb F( {E{oQY{Oz>{̹)0<& `;M0< IAp? w;v0 n7Jp),p&8NCp8 >`vہm6`kg)6j`%"Xt˂e@{X, |`^07 fo3 xo5 x<'${=np FUJp1 .`88`8}^`wۂ?; t]F`CX Vr`)Xs,w=0 Y/𴅏S-|d 7^ n7Q pg[y8 NǃA(pXv/j6'nFU]W,hEB.`\WٺyJWe"xD(xț<_wwWy{x8,w-]=̫y8;xWl`}ˁ=g's9߀Sv^WSrjjAp_.Rxm G࢔<'UCS`_ 7psO]Ω`nSR 7)\,S89k  | _,}u G3}Q:w` ׂk,< Rs^rvQss/ Y8=, Q*VluG9Ss-Zxc+>3>T)-=󌕟|Oa$z8 v?/==Uwzz8̥kzU:{6go9ܥuo={g<{:= <&Gsq)\5{C{M >9ep` N)ܕtr۵)]4eoOyΝa} 3)| >Z8~s ^/YߚXtazNf`p<=Ȳ{Xl ,| -;v5 oV3R}W^pS~h[stWdfs?ׅكz0]'z8: pp88Ы{xx*GՉ=HǩڏyJn,?ُՋ9奺)f1;)Uc:SŜƻ^)˞\kX3}~,yY,.~b^뗭گ4ˎ?ax~M٫9oW[ח[q gps~ȁc-:6y9˙}A/,`h~oͳ9Ep7hy1{–wdvnr_xc7 ` x,gF#'8 YqywVV;39`'\gFps7-=2c ǜ!گȓU<:ssWQ|A*$ze7Qn^rNRvk=v{S杄osx"e(Wv\al# mQef9! U 0+3ypGYPD7KRtͺHyP66(;l*E}:):`ٔsA?SoZun97y:].Q x+뼯oz` |`M`7kž;Fk~`ЎnAyh_{m<ƫxv D{ ;?Oj< 4Ktcg\9] sh_v^s籃} t+`Xchk70sag(w_J e;3:;v( xO(;̂r'alH]{`j}vP$-"meouJA_0"4XCȈ]n,i ̂Zdr@2 83<2wr=sE {wV )2~0«rнHAQ^I0+vG!0GtsRf s,a(%vJƽ٢{GQu(_-ϧ/(_q[f1}0_.HNrzjj/"wOxaQo'ʧD3SW9Ōb_a>;s;iva󪼙L[eoeѭfSRuFX~-7U2,Zd\cQ<ؚ0>[uN?n*Yc+sy~ =e7f;M=ϸ6se17eQvo"(]F;Ltt󸁰0w9;w 3_/ʕR}C2KS%SS~g\э2.E0S0Kx`pwQ~ O93ʻ)3}97_2/ aT, +eE7 Aʝ%?/qortZ([y!UO:J?ZrWk=eg]]D fAyUo$;hS|r?#UϷIx(=r7<nvv !?GIwu`ζne}y7H8!vxSAnrK@^Lnr~P ]RGtoQwxK䌦8=tdsI^Nƻ!c7p^oyPUyJgk?˾r_mljֶ/U!ހϫM 'ϙ^r-rg7% G̶nyt>Ev|ˣA^.Ξ4;ۺ\8g茔wQR=3S|Rv;Tu}tjv|bx^ӖEϽXPıh`ݠb ؂=c^@Ŏ= X@E j;[4{͙3>I̷#@/& OGC^q!Kq8W#g`q8A bwYl#Fb)VUDXI ˉb^1U,E'C^|+_/gbHL7kex^<'G!Ow;fq!?sř$q8H(ۉm6bkGl!zߋM&bXS=IJb)XX, Y,bf1*VC_/ŧP'!81V<.ăb!׊Bq8K)N1H*nbgCr?6HzbTRqc1hrGf2ܛE"~}-2w k$s6wo>K/Y+^(qY [<+>!^Gۚ7zb-+΄/Kb1Qtӊw1Ԯݕ]ݕă~q+GnuqYv_!|q^vw`q8A/101H gbKv϶3H/xXZ,)|bn1Gvm :i4bj1$YߧRmx3c;Xr'~qG9\\-.K䮞!N ɝ=J)Kb?1@_VNs8kX\, ݒ;-%yM+_M|75s/#^21Nͽy]6ܻѡ"+wy%>![&Vn+2V2 GE}&0Ϻͳ[\*8xW%r3ϞO1]%Ȳ{zwvWGUJq\\Ջ9lq8=p.+v˞{Uvp\\E,KYxv#p3w>\rSG?KlA{JbWNK[c)[d#~3?6wpy& vL{ܿxPgC##'I7&8~6aV<~sJ7W\ot[\n%grÙ꿭.7#qˉzxy&7*=8ބ_t>;1<^[Ѯus"cVoDDoE|<7oHlx/VU|ѫsy7o׫`=ntxGHDލ>ޥ;r=y1oS+3Tޛ㒿97[;.BoWM{0oacǻ]=X׹eչGhtz3ׯ5ìqߊA? }~Oϼgf^Ͼ{P=u#[?oRGMbsOc6oRi}{bw?y4{;ޕxS)]i sě֮lx'MS']K#ܱyxMGn<=x5yg[N{ܛq lv6m{ [ܶM8~8>tzgm[!0,vvl\YB W˽8Wܚ[ws淭zWg껭Yoj Ab?{۔}k )uܲn=o4ѿ~n>r'qpvr#t;ɞLGs̝9[l37Xo|it֞gcS+nXܯ v MG?$yfvs]6~ftRho@x8wr$3qEfɿgSr[Hz97T|4+ 4uǽ>v6]ƌd`ܗ5wvqesW4ތ g|Vn_lb+VQ}?N߸=q|v'qw#0ndp\krfd!ߊփ'Bѧ[ҫ;䒓g2nlzc鼹㭌e9!oRy;Y\$|s*]wtAjestaucrx]x3Y3{Fnxzw󯾋\m-{V[ waJ+oawvANܼ^&{q[ ,6p<#q-r;nñȼ=y]ug#s, [lpI=ɽE)7wjtާ6pJW\957(nK]cKK٠ZlO[fd&ޱ?$Zt88W"[o*.ǭSql5߈]]^Aή6D~rU=vqxY<ַ+6=nJN7tEfWq5;w0ww+sremkݭܰI;v#.׿Mjce$7_]3.ߔ߬92^z_rˎsn)rKvnK]&7]>}.7ff8n}Lopܗp:3.mܓؚ zq8[F7 owQя;T8N[[*wjJxMvJމ>Lmߦ[h+;ʚ {*qyju5XKpu}ߍ;̍O];olQEo>xv.LO~/p:|{p8L&; 8r7 t:~\fMux^gUgƊ-b[`%v+" !( V@c^D v Ƃ 6l(5ѽ5̽Y[3u1`- l 6j`2X Z`i$X, `>fUk0|y0< q0 nuZp瀁Tp"8 `?`%l 6`uh Z%B%X~_??L _/s| >T.x ^`x <`,F&0 U|p.N'(p88>7 vA7tہ@Glڃ`}+Ak X @K So7s0^/xch0 FM`\ KBp28C`o; {li"3hk6e[܊ 7G |ݣwt&6۳Ix<Tw>uƂ;p iq\p68NVwx-߁`z{`'MNfZ]Oӵj`,X,ίk - tq{uq^2x< };7TܯA"))ŵ];ZkC=wm,YqS-XUܧ6`9q;>#} 2c0 |&I^MWρg3Is4lϊop+ъgѱspg`_g]V2m6Z`MsZɽ<:ke^x7Vϸ]|j5{wFטuuFVp R=.Ԫt4-3= vہm}_;S˨zճo~O[s/cE5xN}t~q#F+_xxxR<@<.GsTlm&X]`IRcѿ2>4]s4wE}<ӊg̳cW +Yvkg$+1FǶ@{LkwLz|cc)$>,Qݳꮅc#óWδg3mg^:v_+sK9âKF1.1-kcf/%ʝ.E6#΄+Sd)#9,zrT5\*\ %̑R|#W%r`4vY􅙵xdf-,˼Rߏ|gΗVzM1oXdVrix Xm~5feV'%SbjPY9ʽ~qwzrݖ;)ZDPY·{i麑yVlrf潴>;VV6s$r:/پ:zf]5ϛ'ƾ:Yʾ: ޔbm/JyƝsm)-g[k=3 zAw[=]νδjw|k1W<88㆙w {F̹s̺3pylq:fvV:H9w<1߫Y.VgZ:IMg>^OOxzL+:ϙ5DܺZ*gwgw͙yľs:w՜3)qd.IO288ސ28S!)]>!݄u=@;=:=g#y/)nM;HR+*rޱWw.wgWpWb~ܼ;Kǹ}7A=Ĭ:H{199;Vqy3ek+b^Nl.q3)oC1-)bpgwH};gήV97rplhe?'fRVfH̎yagNM[d-M RcĽ0rW ܩ~3̳3"Ay>+p6~'w ·n]!*[΅pw<;̭>fr?!?Ͼ"cĝV&7C;'~ofwAWJsk,c~Sm{[[ p}GD'@wkDz˽]#w/e{8ѬS dyq ̪ȸ!!Giv vz/:wtvVn{Oj=oyo !8bg\dCp #wNpNpz/]ٽN[f^eJ_ vZz~緶x9+y_`s,~[`zm32{s|A=݀~ oyߠt;o =S-!v D󻵸t>f}#OGAٳ븁w:D=Guv={C~]?o"=o݁wV y7;`.0'`!]ιΝ qxE*ݟnIoݟʼyvCyf.Z:8,.|=`45.x)8wۥ񭂝<4-w^RtAO{7|[^k׸,,F?g٩͜hoq wgv?܍r󬎜tlkjV]L濏iplox;ˬ?9gsmm]/yw=>{L;^=c!ׇyd`y Yϻ"}|&o-qwb'ب(8:؊"`.0Я5XcȈJ8݈5:v ,ggsޯk~ܧy&QHp;8=`G=lkU`yX,s67 ~߃K0| >mx < p7 n7*0\F,0A`8 aP v[`+ l AV@,Xmb`0X?s,3 ߂o+%|f?u x< Oc!0<;-_Z0W`8NH`'=@w l6u:` :Xt+2`I,lf#| >-x <O>p7 n[-0\ Fp \.sL0=Rhnu] uKF`X,BK) IYm f_/:F_y3gUx<=4{ R=t8Cgzht@Ϻ=!z=4lz` 7zZ^,ߥ<뗺]wjVZN? ^[U:FG, nx mXseI80 ǀCA_zf`K̪ZN-2:Xht)]B'n6'NQi[u Sh|NIө` xܟB+uz#\5)k`d Jaڥn3iTpB -+?)򓺦J9Kkjz[l HYۛM&+0λ)ZܥIufop S\ bm>bjy,w<2l~CS=g4$pGVƃq`,#e0 erw>(e|N/kzd<}E_h KzxG̶?Z빮Lg|lX,{KC=|~!O>E7O# NPњ7{/ J_RgS+թm}>^|[YO}Sse/nβ~Un[uV_Y֫:9N,r~в6ZwezLg =-Բ׳j^rSspn {F QketnM]Zb^KSSl5u%M O5s)2Y*w!)~)g+E&+5)42X)K]o+K ?y*3ϻXZkk?,θoۉYKs_ Fxt ^{[hzeR=,t"'fGνhoѽSf"`A,72o-73VnrgS)`Zʽ\Y]IWSr[ff^=zfϦL=L9+խKMYU_R"CR5Kzw/njmݽwz7Q ϺfJR޿nܧcVvsۙw-jKr++c˭Lyˮ+ͲJm)[u]f0t R^W~]9]QjnrG"l霙M7.^׾V3کQ|WwV׷5l/ie3էˌ/}-x챽Os+-npo/tns+;nteos#={{;yv{ o{ϳ_toiW<{|F{znyWɧxSa>OϪVO[>jM(z ?gcFXcgЭM>x0k՝qEv>gx(;6|qF9Y:RYqo>^rz);GޔrOcRxU)R7KSI$o2='rpI{I(dn;N[D }NNP8e)nzq9'z3>Fѻym{ac#=.xφxx zֱ޺_ѫʽCo/mA>7/H*ݳ2OKУOQz&u(|SOKћt_GG5£.[u+zUٯ}w mm^D5֪Hy=Co{ 7 =}rNÍWzG2ܜ}G`KqսFwW)CޢGFۍ8[pnvz<=)eߐGۍXvZFݳPdv}[CmU}SԴ=UR2uԬkfigvCejx]A K?Ԯ>JF#ͪ_V|n3޴Ʈ!jqcF{ŷKvburȲX,Xto>ת[plknS8 tqJ/r.{F̔=@zĔ4R˰K?LSu}ਔo' e(8?5 nZ/`'93?zjvfasœW"r[0uCYAuN-cO&P`Mof-sK R~EVRc: C-L'jbo3 |0x^ueb ]kcv7vw `+bw``ڲڊݺ3w<8|Fvs=_3n`U X  K+:@'0?f@|_Sx^π 0<pncUr0 #`( N'Xp8}>`W=lz`m&V+`IX,s`603 L:WK9|>=.xL` WKEBp>8 !tp 8 ǂ#aPp8 v;`c=` XV+` 0/tY`&080+ | _/d|>GCx^/ )$x<{N0n׃+` 8 FLptp<8vkYqP-oV\Ȋ{Zz7零v}`x<9V;6nVǮ׀+"]\ ΋HΝ N8,ҽ"s`5*ҿ`=`(..ť #]3`(^NoOGgnK:)/zz x < AOWw;=֋g`ys 8ѣ==x{=Ս(t*pg>g:ٷsK,cz73\7;9veϦotYNصt.\p[ZIJO3ңgHmR>Gu/G:vl;]⾤^˭ܾ#5"st9G).vqtW\\stn(3ܴr?+<-eNי[Z]4+e:+}~+Οcy_>2S~fXq|S+GW2o"Vz)z=g~ϫ+f'}]>_x~}ckjٵq{=MٽMck۹׺[f~f/yqW}]Z=O<5=.-q޲77lJO=3]Rw63}yzLՃف뿺'Zl^ܑqtPJrPvVn[f;ʆtPޖqSnW9G״acYG+]hdz+c2o+hޛMHy׹:8wGGۗJ߸+9]V=vC~N2ۘk~wi[X2vɺGfm2FCgXl6){eYލ2ۑw,SQ݋R9V,,O^T*"]9J?&R{Qۮv]ImѰ[WN[J݅h'38Ǻ;6wtqvs=]o>{t?/W7^_n/?y(=]OsTe'O/{N&\L&JZ33ꐺrύ[nh~`_kbv9tY,_ꍺfS{l{}e>~SF?1kxջ͕ DqK>Hl=Gtݲ6Ktݣx\sud2 Xc&E sC}9;:ϽXna[rPG.^nms/[[ }cԞB 7&rFN0 -;S9Voc,sȽtekF^]ǻH7S2p=+\)]\J7e)ߦtOTޱj/>|vLjFqJ3NTvZA 9f\W'eondhBCoMI{7?t2O7vNN~khn$tN޺cw5ZO3囶d]W~.Z.(G!nurT : 64wy#x^E^8+8 KqMC)5 .)%S@qBўY}>os}NqU,nBq8[.CŁb[%$.bg1Pۊ-&b]'V} bXB,"=|b^1EtӋib2;lZ[|!>]xM<-G#a1N<(wmVq+׉khqZ\..#[bx]"^/#aO"n71qJU\&.\q8S.NNjıhq*š`Y m6bKX'bU%K%Ăb1Y$ӊbr1HL(&&~ԟ|WK\|&>GCx_+o7+yxV<-g-s&q|#g0q<90|(v0v`kXl$60YX|/sb6y0Y0<|&L"&2 1dxx)΂ݹ!cr~2f~/CPq'w;e{no ;X;立Ųb\^(b61SnyscrLw?x5Oq^q]*Ɗ,' 1BzLrWL~b#8?Ir7Lb\jws1]&'sNxk= W|Yh>%3s,1BbCNJdp_xos+ս%[=ܝչU?73 7u+n4ϲkt3 N6wxq8Zj~;%k3Xܧ^by%lsdYxEF~ᖾB4kv7oOfw>qgv踷vF.=6^G2o..I.kg%<=fdxI%ͺN Hg;kW}T?yG%9)~FE%'dI[:)g<Urg7$Ͽg_]R,.N+QA|!_L%'^د=N/^:ފ/Y{&>!Vhl.N3六G#b?+H."Vv:qxQku;.z;)Cegr׻)9yW.4vқS|.zI=4vN<=.Cg%];鎢vWc]~M#GWʾFoUϺΑˑTWq43htQ2>$yz+^ΒצQ#cW wۓSNYgmOǻ$8ed.jJ픿8=vYvN2}[SdwݽwܘfL{lݿr:xܚuC[SW̄7ͳ kuNw''_ύ{twWY3/؍Q13.p̏y{n~V_+nu`0;1;|:u fJ=OS{7. /ܛK]=yru;3YrVn8Ӝ[W.A}v7\!^?֝N9w*{;;;!f]!n^˧2/uqfN0#3y@o!fn7B>}9]=T 1wc'5{@txbefnbF~0ܩco]{ͽg|]#p9yt@ܭg:Nܯ;xܴc_;^ӱ0Fmd=w韻M﷥7#Kdp1lY432﹓;^'~ɹf6}ůأqj/Jdaݚ7\ W^=.kܡd Dg=WjOB`~[nҽs6.=:3Wܚsy:grv)hM:r̻>xťȶiX|lcO>0yŞ=;re/_M/m=:pt`/7թ3N_q s7g""-KfGq|'ޑI=|Z#bA{75r \߮WNv:B/Rk"̞;{~q_-{OEݳߒq*|"SrKw!O{S>ܫRQݑoTQcusL9|G*EE9gܢ,nQ˕K<[,gtqGĮc ;m7EYd~ƞYI=.+w]Z+w)3^#hֺKYyIk]ٹY2T}6nN'#%_;b}";9rT| =doR%=':8{#d*3PMc\75Yٸ[_̥vYEƬ[Xqbntm33CLoQFe/[}G!1?jh޸mn]qӉ'F7.|T sy"˹}Ef&n_uwN'n\9n,5:~ˊ9C/k5F2E3fRKGG= k;7 Ni;n̢^KX:wItG㧰]ppz  deflate1[XTREEax^s_Yضm[۞86'gb;lfmm7z=wNwuUgpp#<C#H052MB@42ŀ19| Q¦xp4>|IAP, %rɠZrB5RBT>5?>`mFI3CX[6؞V9/熙yO^Z.aASEp()K4R:SZgЮFVE+l_fU{u5ab-XՑfuaF=V 7x !e ToOk&p)\owJ`qkn:mūh;@t,]`QW8 wn=zA>Z8WZa5! Az/pz$<Fò1k,''D5 O'S`T0M=k o&C V ho0rLY hƥpXV@̕IZ[#k:q\/¤ oirUYܦ; N;$!s82xP)>aerFݏi}\$d9 gtjaݥP2ur_Q5݁PB/ܖw`߰pOپ>acX=U~I@8TF^o[wK8?•O~o_o0l?8b2#ԍqAF׈zF2ZD6jE1D5ybbbQc?qK  ՉIfIRɌɍ)t);"-11%9$9Q<18>Q/-C{s{sryyS/04F"5Gaqt PΗ̥eԣz3 FFJFF*k!J5q11fcW}H#cCvc#lj4jushamiDhe|[hvƝƽFX'=;E ݌ݍ=F^FF>FF~[!}hddlt"]U ˛F@i$em#-I2A\akN/4ty;Cyflu<5 15__I2#r ҨhڨF^5z3z7:667m7+iEܶOӘvg(_{}ƺƑ?Y})VGWǥ #IzJ8\Qsyi{rQ^ O+kF*,N/n^9)y7y0(XuǟEO9|JHn$+ ^?tIWcA?} !'nr*$ŏumEKhFrB3`4n{Rn ָn)v!nx]~zmzg[}dXŐz)BYh$E=oE8 U4. cWaiCZE3 iu 9"b VEmK^ֳZ*Z:pSqZH&Eh4T,L$^~S82hglMϛa#)5d @3yH/cǏnTz{/$ u_@8‰ShϤczBҶ0IU*7>7V29"9de!ZB/ {N6ӳ=BQjB\Pc7O FhY %(%$ #bd>H >(C9I(iR(tEt qɥ^q /ɹF2'gݔ@^2GM  '?x)򅛊r48؆8 V*z (==4O_zFDv[9Ț{cSͯ&'˟k2şFQ)La< {o \Txi&r ҽa5@ @˙&Mhý+}O+i:j= akÑ7pHr4ܸ޶!KuJ|B?W,VXmu*T&Cwx. 4=:<`z,v̸ᄿٵzrp6ϓ/cإ|Max;_o^ %ݒ< 5gٚRtܕdO!SE2MJp ^xdLT64.69 mrȴMnSwAe_7 }8_i_51WJaZ:w47rވid;1Fu!+e [ nd*1Rq5F`qkx \vv;|~xɿsIP7K],nP΀J,'wj8bD%J(n#pt{j:jfM9 #?l۟& k>M+Ɏr7N̑|A2P?#^uZ%&ĝka9&+7M{9~k7d, u A@ܡŲEԪVM.Vle1 \4ܰyþ5JNrԤJ ]%8wZv\d6'}KJPRE2u>!nu?BOdnVEe% aR@}蘆xW541˧㴗_QCPF9F0/ ̕i\ؤ . [2ahW#M0ٽG!o+Rsͫ7GAK0n(WV׌lBb@ %.q}p<鍹@4b$ ai*K=e/D=Z?a6˼Y0^u8Dn)ǫ w3.Ln =2Ioq#P%d?'w#0q53K!S5-X^UDC ?_XwQMe^[˦RM )kj2|ԴL}TT&u*\ܮ~w#IaמSb Ͱ Og/j# PQC5Ar2 w R2Ropʶ]7Fv#Ėʞ3iIBuIoSB#J6q1KzMKDtTrx<)Fb:g,v]N[xd9ͬsxے1RpTI۩B~h"G C:(lWBW>XOʌ G(@'`mՓu.u>EMmbwil1WPA06sf /hR e< N:gj|jvVPmL &u^ M#3r͟9M[ weLl \/KB1oťm_>n3eCeJ'#Q5pIpхc!+>FMI,CB|LT_XΈŽEBdж [/]tBh_dmzH uArJ>D琙sCRinUqo t~KHGCSP/8] |g[_[s,ФCe6@Ro_T4&4^xZzjsxťb.\r4w2)nD5 'eJ۹2P Ј4%35K#mM&3mRT$5 놏B}&_IDBtg.y>إIscverse-anndata-b796d59/tests/data/archives/v0.5.0/readme.md000066400000000000000000000006161512025555600234400ustar00rootroot00000000000000This file is `10x_pbmc68k_reduced.h5ad`. It was created by @fidelram in [scverse/scanpy#228](https://github.com/scverse/scanpy/pull/228). In the few days the PR was open, anndata 0.6.6 was current. It looks like the anndata writing code was [not changed between 0.5.0 and 0.6.6](https://github.com/scverse/anndata/blame/0.6.6/anndata/readwrite/write.py), that’s why this is the v0.5.0 example. scverse-anndata-b796d59/tests/data/archives/v0.7.0/000077500000000000000000000000001512025555600216605ustar00rootroot00000000000000scverse-anndata-b796d59/tests/data/archives/v0.7.0/adata.h5ad000066400000000000000000010152601512025555600235020ustar00rootroot00000000000000HDF  `TREE8@HEAPXX80p  (TREEHEAPX dataindicesindptr8SNOD HhHx!kn(8H<> Hencoding-type  Pencoding-versionGCOL csr_matrix0.1.0raw0.1.0 csr_matrix0.1.0 dataframe0.1.0 float64 uint8 int64 cat_ordered var_cat_indexgene20gene21gene22gene23gene24gene25gene26gene27gene28gene29gene30gene31gene32gene33gene34gene35gene36 gene37!gene38"gene39#gene10$gene11%gene12&gene13'gene14(gene15)gene16*gene17+gene18,gene19-gene5.gene6/gene70gene81gene92gene33gene44gene25gene16gene07e8g9h:i;o<p=q>s?t@vAyBzCMDNESFXGbHdIGJIKLLEMFNCOBPXQYRcSeTfUhViWkXmYnZp[r\t]u^x_z`JaNbQcRdTeUfVgWhEiFjGkIlCmDnBoAp csr_matrixq0.1.0r dataframes0.1.0tfloat64uuint8vint64w cat_orderedxcaty_indexzgene20{gene21|gene22}gene23~gene24gene25gene26gene27gene28gene29gene30gene31gene32gene33gene34gene35gene36gene37gene38gene39gene10gene11gene12gene13gene14gene15gene16gene17gene18gene19gene5gene6gene7gene8gene9gene3gene4gene2gene1gene0ZabcfjHh H shape@   (}lzfXSNOD+P4TREE#?@`@+@ @'7@O@ @@'@#@ `"@/@ /S@7?'@3@@@ @@/7[@#_@#; ;#@ G@  @+@ ?   @   @@  @    @@7 @7@# @' +@'@#@w@'  +O# #@S@+@S@@S@{@+'@O@#@{ ;+@K@'@O@{@@@o@@Gg@k@?@c@W@3@@s7@@o@O@@/@g@'@ [A{@C@+'@@@@3@@@@@{@/A @ @o@G@@w@7@@@@G@@@@3@/@@K@@_@@+w@C@/@@c@@g@?+@C@@@@_@@KC@'@o@@@@k@@g@@G@C@[@@@3@@@_@'@?@+@c_@'@O@@G@@#@@K@w'@@'@G@ &1;DLTY_cjpv~?@`#@ '@ +@ c@@@  `;@K@@?@ @7@ @@ @@@ @@@ @@ @@@@ @@@@ o@'G7O/@@3@@ @@ @@ #@C@C @#?@@@/@ @@ [@G#@ ?@@@#@ @ +o '@#@O @#@ /@@@@@ @@@ C?@'3@'@@@@ O`%`@@[ @#@@ +?2ATct (<Tet   6  5 423-./01  # $%&'()*+, !"   O  N LMIJKCDEFGH789:;<=>?@AB         o  n lmhijk`abcdefgPQRSTUVWXYZ[\]^_    E<䰇%V͔m-CrCn^ )R            (}lzf ,`TREE#  (}lzf|`5`TREE|i%P@0TREE@DHEAPX ?Xvarvarm8=? Hencoding-type Pencoding-version H shape@(E0TREEGHEAPX Cdataindicesindptr8SNODAACkkn(nAC Hencoding-type  Pencoding-version H shape@(   (}lzfIXSNODF@QbTREE%  (}lzfPR`TREE]Z           % & '     @? ;  ! $ @@C@@C @?@ @  @ @@G " @@@;@7 ;  ?@?@@@@C@  K@@KA @K@@G@AOO K @@W@O@@@;@@{AO@@3@@C@{@SA_@@S@W@@SOA@@@@@O@@CAk@?@@?A@C@?# @;@@?@@@;AW@?@wABg@@{@?@?B@sB@w@;@3/AO@@o @w@7@3@@o@@3@@@s@7@@@/A@3@o@AkA@;BGs@B{@;@k@+@kAK@@k@@@w@@@kAB@@/@_@g@7AK@3A@o@—@C@?@@;B@?@@C@A7A@@/@A @oAG@@s@@A[@{@K@{@?A@@@S@@@SB@WA;KAs@S@WAW@AW@c@@[A'S@W@SAoA+@;@@C@AA7@;@@A/?;@@7@s@;@@KG@gOA{@@WA;A@kAA@[@OA@AA'@@+A S@3@@?@AcA@@;COAAO@s@A@3@@7A@sA@;@@?@kA@@@@;AKA?@7@3@@3@g@'A@/@3@g+ 1 &,#a&?i=Y~{?ҕW?h-?V?mQv?LdGv?)K?![_?REeq?`.#?P- ?)%_?0ڂZ?߻6K?\? ?un?Z~6i}?'Ԋƪ?Qb45?6m?Un ^%?Pe?9l?,w ?~F/?S ?Џ? T?ft}n?P0li-?&m x??q*?ʹ? P?Va?A y?<|R?}q??WS?Pl;(*?2-?@%E̔?.??SN=?9?:A[? ?G`"?rOί?XZ]xq?䓘u?ӝ X;??/?!u\?P9v?WmM?vLΪ?@rq2?Y?v?2Ԕ?z/ ? P?a7?5SV?ʵ˯?sΫ?[1ZK?gF2?)3V?+Ɉ*?KO^B?G:,A?,Ɂ?B?Hހc?Dڥ*??T?P㯡n?zd?hD?(P?}!f(?*Y?,\ ?)uzW???[?lQ˙?yff?%|i?ԋ̼?(;ea?oL?l4ޭ>?e_1̹?QV?9?nj-?#?z4h?͚XP?tN??! 5?p\I ?^ʕ?  ?‘ЙՀ?@3\ؔ?|!?bk}?5)? T?Ǣ?Q5D?f[?$>!? %?l!?h-*?™?-Q?>Ud?D^(;?8?$س?֚,5?U?5J9?ߐ:?3=#i?a H?%Q?x%(?YI5&?Ve?IQH?Jsn?dMx]?Wh?:b"?}8? )?P?XU?*@?Fm?Nv;?fY?4 ~?/?.Ve?`uP/:?m?BR?Uά? )?{?T?MXe?b?R?"lK<$?K L? Ў?̿?l?6|4?%`I ? Z?9"4V?ZʐG?f/?2i ?tc?+9j?L5'l?7Ft?>.߻?3C  ?j륇?B*?ї?DTi?{,Y?_,؞?؟sG?8*?sܡ-G??.z$n?h?Z̅>?)[?v3#?o̡۳?@??tKX2?> ?/߯?*~=Zu?tXj?X?@[F*`?*כ?GtZ?Eb?N/g?_$@K?,?\ά?ϿMy?`+?%뱥?*lA?M P?r?51D? l\?PY?4?xx?J8 ?X0V!b?u?kGi? w?`]$n? ˆ?$ߤr?%F?u?(/-?,r?8l ?aIO@?A?^H>?i?.S1n3?nnI?)!?Pn'χ?JE?g*Q?6/i?׹}?6o+?|w?G??m5?^?8k?XP7?xoK?숷n?&B??/?魬,?I? U?^6?^f?L?a,?g=?'?r'?ۂh?e0 ?Xl?okB ?Ws?.hm?%Js?Z?6Rpn?5`QC?ީx ?PF?Xd?OIKo?J@?&f?K}?AtXE??t"ʃ?LQ,ފ?e"U?|E??0*z؍?Hvz?jǫ?Pd?D'B?ASq?@KU̚?I?H{M? R?O/ 2?Nj?(0?C4@'J?XzY?~?M洶?s?T~?{]?X?lb? ,#?$4p? q:>?vT?Ȧ𻤯?1?t[~e?Ci? q?I{Q?(zT?Q? O?3Di?u ?lA?G8?Iį?.*%?IT? ?f@?E?m 3?im?͕h?.?wu?L>@? r|??hu?Tp‚r?3J.?KK?lIp}?*WG?}Y?H+d;?t*)?kq?:7?)h?C p?h4_?>*-?bd???Fr8^ӕ?%n?7Ks?8$?n.??$4?zIOo? ?i? 1D?fM?m!?\?rʤ?Wܞ ?䇝$n?)ϲ!?J=m0?^J\/f?\T??WoO?1vbP?Ju?@+ 6?`@ WĜ?MWqT?H{"?g?`1M*?r#?Φ?[a?L3? &?"u9x?8+I̵?@v9H?Cj7?x?@V۹)?[?ԩf5?&ѥA?4?ɴ?B<÷`?(?" :?s4ǡH?, ?y#?6m1? :?h_&?FZy>y??HKB?l=;,??_O?Po9?WƧ?e{ۛ3? 3V ?mI?e[B?د06? "D?y>>?L?1 !`?@Adڀ?63vh?$Ia?$GC*?By4k?/n?M2݈?uʱ?sWE?7_?Aтkw?.?L,g?,?_iq? ävx?ػsO??[*?l~i ?Kj?@ܸ}?R Q?m@?(]$?ؼC?eDf? l?Tj'??4%c?Hߍp?E3?S9#?c?fCo?7ʹHX?$x?1?UI?&Hm?0-9q?:!?-wuʴ?8)? ??Vk!?c?8?@85?p?Ò?%0X]?֓-?sص?ޘ+{?Yd?۝?Qc?obl?4U?]?j|?&?2=?ٛ/#?zd?w%e?-?Rƴs?>q"?.XUB?Sdջ?0Q2?7]0h?>V/}?n>?3]?q&Á?ML?j?'?~-?@$ꝷ?+4%?!As?oqA?<5?(T?Mz ?H1`?_Vs9??")?@~?? 6 ?V~?h ?Xh?ys?/?9??d6?XјR9?0sYS?VKu? ? p\BU?TԔX?s?E-Gs?#Jsx]?p $%?6?ЬGl}?m9?dIf?b'?zxm?X,U?H ? $?nij?cXk?'1ծ?7UH̰??"A*?."&K?!$?/x?5_Z?u_g?rP?Fý\?hnK?vR~? `U٤?* 2??Le?6҆?>P?\= ?|?h?!-?@n&?PU "o?XJ?t{?Ӄ?84?a?X }u?pe?0wxK?A?ȹ?h"?/z?u!^?^3,?@ŭ ?3=;F\? ? O\? ߀H??l%?+}")?ys??hU3?Dm?R(M?ɇc?Bw?BJ3?zׄ?ĥR?P ?p]Xin?DM.?5sC?g?1Lp?DK=?4K?L{??[j?5K8?L?( :n?bj(?9`Y?' ?Xm? ɥ:?S?z'"C?e 2k? 4?$Z}y?jf? ?ֲ1?[F?xGF? ?@c?B>z?{X?|Ylr~?0?-I ?- I?ht*Yh?~BЭ? ?D?8m)?zF ?4Z?Vp9?NbR?fhf@?|g?t=mc?-Vqe?&P?CW?ZGn?&L? \6nw?E"ϣ?6S?(5`s`J?[]z6?dOR??;'?Gh5b?<%EX?@S" ?HC?+Rmz?pQۀî?(e5?io)?.3a?+?k?򎧶d?Q$pw?/H"?7? CZ?c!?xu?DЎ&?C?gj?" O?DZ?ev ?$4,?QME?;e?o?J??>?r㝟M?%iWD'??-?g`/ ?zOEf?0Q?lԈ8? i?Dx?3 ME? ?.]?oG$?'ӛ?g?GC׹?x֠?ܖ.?ʎq?1&)?0gc?qll?9S?(:b׫?Z?N?a?su?Ԏ)?2/"O?^d+?8*69? M?R;K?:UG?'?L?h'?bk&?o'`?c2R ?vZ 8o?XVGj?? wf ?>?-?,u?rf?\v#?Ьb?vِ?-T ?R?a/? Ol?P8??ml[Y?q?}l\?s?b_N?sM?'Lk&?]P?ao1d?xi?N08?&Q?>zC?'1lS?ؠ?0'_gE??h?\1?Օj?`*e?pm?d/(#O?HϭE?fR9?dx?m?13h? -?XǕȑ?,uݒ?w4?vz?E?cii? / n?}&?rﲯE?m?"G$?d??5~?@L5Z?^A?,2?G?AE%?# K?X1?1/Y?Y?ESN?T?\wXCn?3?<_,?zYt?;P[.?|'[?F9>?3?Te!?B5&{??qQ0?ZC?x[+?@n?0(?Wt?C?אߥ?Lo@E(?ȼl?ִbo?HT?UPOi? }?VA?(@ȳ?XE59?#4?C!?5V ?hي|?l@\?fiJ?{M?O? "U?((Kf?ZI^?*@U?G?6`j?Ԭ?JS| ?Ȩ_??X?eG??+$??I~?&?iH?&V?L5m?d| ?n~?\kվ?\?ZEB?t#?[?9(?!՘?h8ʙP?S>+?w#??Ob׸?whN"?4{C ?MWDq?( ?Pu&?F7?NR˭? d ??/K9?| B? *?t J?b?#{?ZJN\?$p9?Eh?!?`40?|4?iu? EK87z?&WJZ?а7?RI|t?H/\'??>ы?Q)? ^՚W?{ΐ? )z?h4?@%6i?ɤ ?}Ң?)?x?o~?eԜ?Pd?($|?0>Q? ea?9U ?^*?C΃r?R?*[6?G7?%B/?͹x??d?_4? DG?d"!7? ^?V??$)ֳ?ߧ_?*F?~H?ت?]$:?8l?1b?SK?}? ?o&?;J~I?J?D?n$?h4h?0Q?Riѽb?8 T?b ?)?{x ?q!$?򘏱?S?\?-?LSF?"6?+?LgF?Ag?b_? f7?~>Hq?#6C?<?&_?߈5b?*?=JR?Ԇ3LG?¯? ,?-.?Q?EÈ?:XN?{#:jt?XK`*??Nz)?l3?9?fyE?׈?Cx?3?z8iM?1rH??ԣ$?iNJ?u=?+?F\I?P$V?c?omg?Si[ ?Lw?.y4?7Ü?U'DnR?8bU?PmO?塼?n?7{uk?* ?*$kFB? +[?OO? 0?؅?v%?vX0?p?Q$?Hp? ??H< ?hY?C?Al?4?V?>V?Ã?i,\E?"e[ǜ?6 ?1?W?V.4ɣ?9G݀?&?],? "1|?@`?f&g3? rhǦ?md?"XTZ?>? $?ys?Tat?Z+?(^??\x~?e{‚??HD?0c?XO?eE?I?8ŏ?弎?J(|?]w?"k?)>{?©LU?3>>? G(?Z`?i.?w?iQ?ǟÅK? \??,?wK?F6??>TCt??b<%?h[֏?r?_"?Qx?4)?7I$7?I7 P?zۯ?˩p?5d?ml>?PeS?=]i??xo?z?PƧ?8( ?(&0?j q?ЛfB?T?5=S???TC O?xpp?]>?k?itl?u??Q?r}o?ؙ?ߩRb?~7?oY?1?FN :??9 ?+?h̀&W?~?6hĊ)a?]85?o]I?{2.?py?z"'?R{? ?l?&pi?Ur?~J?T\X ;?] ?M\m{? ?>յ{?띗@??ھ?H.n?I+?h?|Q?V9?[ǁ?L/Od?>?ǭ?zFn?C?hCs?TžE?)2?(;e?]g?D 1aq?(S?S? 1i5j?&o%?1{D-?ԆF?(ٴ?+? ?8?^໗?8R?_W?T𼌌?l l?P+T?N%?H'/?h{g?I?`D?<4?`Y?N4,?xu?i#?j/?@j?R?vn?fsҒs?FI\?DFkO?H?] +??kЭd?cXeX?!Y?ﱟ?zN5?>gCa?-o|?5Z?V߻?H\Xu?9 /?X\?Z6?@ABCDEFh; k @ jifghabcdeVWXYZ[\]^_`h;  @ |}~wxyz{lmnopqrstuv      "1-'!      !TREE$HHEAPP8knz|8X! Hencoding-type r Pencoding-versions  column-orderx wvtu @_indexy(( (}lzf%(XSNOD .H.h0# hK(X^@i8hH(jTREE(H.h0TREE1HEAPX 0catcat_ordered8  (}lzf83Tp@SNOD0UTREEiGCOLklnpqvxz P Q R U WXYKLOHIEBhijlprstu v!w"z#X$Z%a&c'd(e)O*R+U,I-K.D/B0 dataframe10.1.02float643uint84int645 cat_ordered6obs_cat7_index8cell159cell16:cell17;cell18<cell19=cell20>cell21?cell22@cell23Acell24Bcell25Ccell26Dcell27Ecell28Fcell29Gcell8Hcell9Icell10Jcell11Kcell12Lcell13Mcell14Ncell4Ocell5Pcell6Qcell7Rcell2Scell3Tcell1Ucell0VfWgXhYiZk[l\p]s^u_y`zaMbOcSdTeWfGgHhIiEjDkBldmenhojpkqmrnsrtuuvvwwRxSyVzW{c|H}K~LGFA dataframe0.1.0float64uint8int64 cat_orderedvar_cat_indexgene10gene11gene12gene13gene14gene15gene16gene17gene18gene19gene5gene6gene7gene8gene9gene3gene4gene2gene1gene0RVefnpqstuF(( (}lzf(xL( 0 categories0(TREE(*( H&orderedFALSETRUE  (}lzf(Vgp@TREE(( (}lzf(h_( 0 categoriesU(TREE(6b( H&orderedFALSETRUE((@@(( ?@4 4@(((_index__categoriescatcat_orderedint64float64uint8`TREEXqHHEAPPXxkn Hencoding-type h;0 Pencoding-versionh;1  column-orderh;6 h;5h;4h;2h;3 @_indexh;7 (}lzfrXSNOD!z|Hp(@8 HTREECTREEx~HEAPX }obs_catcat_ordered8  (}lzf`0p@SNODh}TREE} (}lzf 0 categoriesh}(TREE^b H&orderedFALSETRUE  (}lzf` p@TREE}p (}lzf 0 categories(TREE H&orderedFALSETRUE@  ?@4 42ϯ.?d=(e?Z K?&?BBP?mX]?EvD'2Qu3($ψ_h;       x  @ h;@E @ O@ @Ox  @     , ,?:z?ɮ?ˍ?͛_U??%?;O b?Tx(8?G'w47?$?dd?9|?P.r8?@??;f`?'䕐?@N#?w?(ۓŽgj ^a͂s?-N?ܻc?0 h8b?!M*?܈?#sI?"8?6Y*AP?h?3A?T/+w?s{4?_i4?|:s?ju?A?Z(?ƑȯL?!M?/N?FF7 ?uP:?t$?&[Úe?Tu a      @ @        x @  ? =>9:;<23  4 5678#$%&'()*+,-./01x V @ UTQRSLMNOPABCDEFGHIJK     x k @ jifghabcdeWXYZ[\]^_`      N:K~ [ ! < @#Y ]        㭩20KMkr_index__categoriesobs_catcat_orderedint64float64uint8`TREE HEAPPH=0p Hencoding-type h; Pencoding-versionh;  column-orderh; h;h;h;h; @_indexh; (}lzf@(XSNODPnXxд(h@(8H8 xTREE~TREEHEAPX var_catcat_ordered8  (}lzf@Hp@SNOD(TREE~<GCOLIKNPDECB A c g i lntzORUYHMGE csr_matrix0.1.0 dataframe0.1.0float64uint8int64 cat_ordered!cat"_index#cell15$cell16%cell17&cell18'cell19(cell20)cell21*cell22+cell23,cell24-cell25.cell26/cell270cell281cell292cell83cell94cell105cell116cell127cell138cell149cell4:cell5;cell6<cell7=cell2>cell3?cell1@cell0AmBoCpDrEsFtGuHvIwJxKyLQMVNWOePiQNROSPTMUBVAWSXVYXZY[f\i]j^k_x`yaMbNcOdPeQfHgJhKiGjEkDl csr_matrixm0.1.0n dataframeo0.1.0pfloat64quint8rint64s cat_orderedtcatu_indexvgene10wgene11xgene12ygene13zgene14{gene15|gene16}gene17~gene18gene19gene5gene6gene7gene8gene9gene3gene4gene2gene1gene0ZimvwxyPTUYMNLIbcfhjmxyMQTWKLDB (}lzf 0 categories(TREE* H&orderedFALSETRUE  (}lzf8p@TREEZ (}lzfx 0 categories((TREE H&orderedFALSETRUE@ ?@4 4T_index__categoriesvar_catcat_orderedint64float64uint8`TREEHEAPX 8arraysparsedf8(22 ?@4 4 (}lzf.2HSNOD[[^ز; =TREE. 2ς76;?EC?ȊI1?Q)ɟ?B(?!?U!?f_?(M?o@?|?hr!?"(a?@דc?_8!?RX?,?F?x??N[?쏴~1?ou4?Ϯ)?bK*%?gC?s!?_.*?PW?W? (nٙ?E ?b,W#? yO?ʞ? Bٿ?A?~pb?ZCC??(Imm?cPI ^b?#kAQ?C??ɳ?Y۠s?|.ʒ?rS?a?R0??[?~H>?wY?Jv?6CP?2~jy?^.3?xJQ?SR+Q?br?%K6?Kͯ?i| ?Lq?z̉>?PJA? ?XueIjn?`ZU?Ls "?S?gzr,?Xw?nu5&?Kx^?6Ѽ?U6#??$c H?zy?0)[?\\j.X?vAVȆ?Р%?Vn? ʙc?h?qa?) ?I?HDʼn?>.beI?:s)?}|?N? k?N? Ca?вk$?*l X}?:w q?ޠ_?(5Ov?t"Q?jf?J?r?j?< ?EWnrH??t?xӒ?1G?ȯ46?ɼº?oDJ?J]r?P$헕?ĄM?du?() 0?aqP?ж*8?9?"ʊ?!?&f*}?45MF?~H?aB?qʟ?74?`v?oJU?tP?W?~s?X~Z?mЄL?]g|u?ف"?,jx?͘$??ѦY?@ $8?N56!;?L1Q?dPy?LaO:?pu?^??@~b4?Aq???p4S?IUi/?2AlL?*A%?bʨ?r.? Tq?;ˑ?/㙁?טX5H?|?8g7?$Zp?TMm?9M?kE?ϸ"̺?1?lu#?)h;.?& jd%?1s?Wr?9#h?jzx?3 ??~6M?e_?xwC.x? ?.C)t?sx?. 2?Q-?ɾ?D?:OK{9?t?kEo?g 'n?et?2=p T}?M1?G?̠iR?D ?B?'?ٮg~?)v??3X?A ?PXU ?E ?L?e&i?=8?Luoi ?bq?&O ?RB?OR*S?(j?I?&?Xy q?㿘|? z?|z? ~l?)ӫ?,f?g?u[&o?Ɇkg??S?&#?U<8? ?z>i3P?``@-?FE?sσ?0Ő?"f?4+?1?vOq#H?˽(?P] Z?:4?lv^?䐝?DՆþ?"m?ka? T?-ۓ,?ɜC?胼Y?l&d7?_?ǒ?xQ?`Ʈ?NPDv?@\f?dI]/S??]ֆ?̗i,?ƚ?x6?SfHA'?T4yl?hqC? :p?ܟ<խ?;?*b?)|? ]S?:P ?Ns?X`?MvL?DaV??sb?ADUw?&H69l?,?&?f?<<8?4?[}?Tޟ?.+?p8 ,>n??O3?P?FvN?WaD?XߌW?ఙ?=f?pS?PŲ?D"?kθ#>?8AD?@ҜL?z?޺??Jqt?}?Mݕ\2?.$?-pMĮ?sF#Pq?ok?9?% ?Vq@? ѕ`?ů?όH?0? X?<@?#:?b!;?ivD?v*F[?跫u?BO?nŦh? 4?`A]w?p*b?DDsQ?ե虷? h7cR?8uS?O?,-?"+?Q??cn?]?fKK?^u?HOA?k?1,s?n>R?L% ?Ny?u /-?"t?ş?ˑC?H? nK:?k۽?sOx?RCXѩ?N?;?@C?/em?Ȇ]yG?_B?lEf?ժ&7?`>?La?jzw?*(X?=v-?H3Z?L5q9?$|C1^?'qV? *? 53?sb?@f=5?,:B?nrp]?: P?+J?I7?H] ?= ??>&>2?>Z&?Li?& ?VqkF?s_?\yb? xZ@?8ne ?Պk?1a?B ?,-׻? X?J?@?28%u?4?+:?*8?vOpM?9?L?ZI H?l_+,?*7_K?$ ?mD?ph?s)O?V*?C0?"q ?.G?䴤?eYy? &?2 ?0ɒ?4U^v?0۵?*#Ԫ?ҹ?B7tu?\8K?0$?@ɞiN?o&D?f0?g"x?hNzݮ?U@TU"??K??r}z#? 4J?#܎?q?@/uw?ܡy7d?ZN_P?X^KtI?$|?"yƚ?@|l??&P? j`"D?ݟ:?"?A^?S3?z?$[?PD?=?b7??`Xz?? 6z?3s+?k?E#?+8(?u^cA?U?47? kT?~?6$jT? 9?AU?Vam? >?N?- v?luY?]?jbEb?Hh~?Sr~?:4.oT? 9?ld2? ?'?(|?1?P l?`4Uu?b?wtծ?T4~Pi?t?{gb/?@]v?cڂ?`ҫ@?%V±9?"z? _?aw5[?NոQ?rkۤ?3??Z5B?FIZD?w~m? t?@O==u?Cҥ?꽄? ?\WsX?1.Z?TB??.s?\I+?'n?$vM?Ũ3W1?0QNs?"?ef?phɖ?uP?!?`?6R@+'_? u?C?HQ ?ؘ-e?C(?ch$^?N?=T?nm{=?b~|?FmB?T HU?o=\?3?2#?f[q}?d.??RTФ?%+?o4? DY(?H]L? ȇ> ?4Mۍ?p3sE?6j?ևjz?<)N?}? 45? @v?@?tGlz?T=7?CӺ8^?g"v?X?x?zl?QTu?:t)\x?h8/??=OC?Sv?H?)?Cր? g @?9@w?`VV?4=?~v z?fe?ցV?Ohͫ?H&\Դ?FGxl?hOS?Cu? ?7?,H D?#k6D?'(?%?dY?@얒&j?Ζv?kH ?,Ԣq?\WW%s?'ǘ?66?ÖQ?^?4;]?Y ?kQ?P%?\{?J'x8?ӄ?Kʶ?y$?kf? ?6}g?gE?0=?Dҏ?@A@k?({?0C? o?mۓ?@&2?ڕMeL? ZΕ?AՎ?Pҽ?{Gf̍?0!5 ?Ӓ>=?1i?< c#?=?z?ey[??̲5??Ҿ2?V?O|eWn?ܺ?}R?|4?\bv?1ܻ? Ɠ?7d?:. ?'ٹ7?Oϵ?NO?p]3C?pB?6KƲ?(v?S*i?~x?c?jlVm?@?\4?(4vs?/O&?0XV?a.l?͕?g?|F?`G?pv?Gh?%?v?@&=9?n j,\[?tK˄?]\?`Ekl ?*P{?O2F?;?Ǵ:?;-?jL~?Be!?f?Jv?G$?NVg?PHM^? +^?<8?L0"YV?P].?TQwn?c$ ?L>?=cd ?fЀ ?9E!?);{?!Yel?s1?˜?<]?IJ?4?tKi?_9/?ړ?t?i1ug?讼{+?b<m?{+?غ%[$?hI ?[?ً?0)û?L{̡!?mu?0glt?}0q??0Ab(?yna? -:7?&}?cm%w?@ENc?\0E?#3?b?݈j?`E?  ?:4MX?ToAL)?N~ ? > ?bs?◌?\?hZ($?#?f /?GR H?L#?oBbY?1Z?z?Pu)7?V+`??@ǽ?x?4 E?oF ?4v?8m?.h1 ?2r^?_z? Fs?Pǔ?z \? hs?Ki?tSs?X?-:?B,4?3?D-y)E?`oJߜ?B؏-?Tj9?{CJ?`B?h'E٣?p 6rw?2|?Tz?Q^?VI?T?yFtH?2e?`ϊkw?w?F] ?懸?ϝ_?D#?8☥?|'0?>bd?H_?_?7J?_f{? D?H L?p3;?(?^t??uAq?fa?3?"L(b+?q>.?cWGQ?!0E?|%?~$?+?o?" g?G ?IϑSR?[@9?6_'6?c*6?.?iOΫ?R+?@"]!,?8:4N?4Na?F?6p@m?.ᔵ?Vy3?B?0^D5O?Xe?[u>?=j?DջԘ?3j?'E?ʟ/>?5GJ?Ж ^?̫@?lӓ?VR?@w ?6ae?^e?(C%?`iN]?a?m߅?l?؁ B?bg?Uj?m,?x|V?9? Ig?`?ip2?T"iy,??@n:B?ֳa?"h?4y2?..$ 5?_d?HW Pٿ?اSξ?kj?{흷?L8?`P+?slns?@,G?j7?Ir? t??LYRG?]?A ??@b?0r?W?y.%? S@?^dJ?R?$2?|D?a+@=?+}?ϵ??.*?QRy?<c?'"?gt?J$O?,X3?J-? p?p:?#B?ó2}?(<|?Na7w+c?<6?a[[? ;X?gӠb?Y?Dݛ?`?lnlX?b"lzר?}Ţ8?ޡ?p5[h?b4?~ g^if?? W?VI1:?G~ސ? ?L?lQ?|)?Zs? t:?(XA?+ ?w{Ҋc?#6Z?j~ȅ?W*?i$f?H)Z?6WO? ~?6ɶ?PƿoU?.44r?T?H?߰K?0)杬?h<8l?l?\?~ztd?{]y?,.:C?|=t?f8?ԑ<0x? ½??ѺY??,\ܵ?`q?Ǻ?* (?m&?fPa?&T5? ??ָz6?*?q])u?Ջ?m?Ql#?#?|?0?fF??ܖJ?Po]?]k?6|Y?X#?TREE?HEAPX @=dataindicesindptr8; = Hencoding-type x Pencoding-versionx H shape@d ?@4 4 (}lzf AXSNOD>PIRTREEh  (}lzfx`J`TREEuX  (}lzf|S`TREEWͫ^TREEhaHHEAPPȢ0[^ Hencoding-type x Pencoding-versionx  column-orderx! x xxx @_indexx" (}lzfbXSNODjk(mX` (x(@؝8ȔHTREE$k(mTREEnHEAPX Hmcatcat_ordered8  (}lzf`ohp@SNODm؁TREE}Ԭ (}lzf8y 0 categoriesm(TREEQ H&orderedFALSETRUE  (}lzfPXp@TREExo (}lzf( 0 categories؁(TREE H&orderedFALSETRUE@ؕ($ 0#1 hVEw%?vfd?*?h??dfأ?^ӣ? Hi? z?j<?ӝ[?6A?ճC?H0C?BFC?Yu:q?HW6h?T?d%?P:ݦ?D#D?xeok?B썡*?B?]4C?U?`Z0И?/$?$0?_ xL2?\%5t=?k##?,0?x'x3v??f$?95?,~<x?ڬ/?|cB?>^d? }?8 ?Dݣ? VW(B?\.?Ю.?FL-??n?1ި7?    @     @x      v wxyz{|}~x  @ ( x@  @ .-1!,?&?pX5?u'8? q>?!T]?hr8d?!O?) r?n?` C ?вk?R@?L7#?0G?Ҙ?AJ??Z$0?    #+k)?{$)?0Ҹ?.2m?x5?,_w?    '   ?@4 4Ȗ_index__categoriescatcat_orderedint64float64uint8`TREEPHEAPX arraysparsedf8(22 ?@4 4 (}lzf@2HSNOD@(`^0TREE@Я2*X:[?X'Xm?qi۷ļ?IAO?.F}K5? ?^ ?2/CӘ?L&l??56F?ͅ)?XdOǍ?Nߨd?Cf/ ?!ڞ?05ǭ?U9.A?=?'kS?(JF"?QN?8_U)?f?g۲M?{>:[?^v?@)2J?ys?_ʃ ?@TT+?t-(?8U+?mR 7?-P?Du?4?i:K?`?tz^4?8L/4?Rw?g?P}D?Q"=?&'?H?2>E?9 }?4%#?cF?;Zr ?lݫF??ɋ| `?D~?.PDO?>Ux?0?X22?5`?]ADI[?HŒe?uoTi?N֐?5JZv? &26A?hEu"?J>H?p?D~ 1S?V[?(Mux=?bp!?9)#?2_?4?I?|>?W,,?6o?'?l7P?`KY?z_ry?di?9H`?Э`C?Yv?25?n/+?4즬?v;v? X?Mhu~?:?׌`e?"A?d&?c~I?`+?걇? ??zU8?1?np?Y?PSs{?㴣?F`ޔ?iG?ozP?%@?L0|?N4i?b_m?\_w?pŬd+X?!θ?2ɞ>?KJ?bw)?h}H?$Fn(?}Gb?墰I?֐P? `QF?4>ϏC?JU2?l ӮK?~$?fdo?u:s?h? J ??j ?ҟ?r?b?@ ?;Rb?g?8_?(?J?בN?>e?F4?:c7?>?_&!r? `Uʦ?6 ?ƞ?@ N d?0~?6?7T?t0??;+־?@ʔ?8?V?c&?8W?-'>?&?Bd?>i?~okE?}+f?J=F"?*P ߻?*]2.?U3?X+[©?4.!w?TWw?oڣ(?!~’?'b?{n?gڜs?H=#Z?Ծ?B?[zbC?˼L?V.y?6?ʳ;b?u!'(??(?c ??X?;?bw?JP?n"ZS?D?p?t>!?0% ?0?`~f?OM??z?͟(?\oYY}?;!{?,F ?*?:CC?p^w?!ox?5bS?s?\x[W?- ?{%4?9lU?Q'?*Ӛ?(h ?#Fw׽?jѯ?r?BMElc?a!z?]"?JЍu??3u? ?莳?xvu|?j%?cۨ"?O8?c??AXo?>hy:?n;?`?`{?ncw?JUa?h\?yz?_??3?h} ?:Y㕼?Ơ?vY9S?T5? u1?ر-T$?V؋;?,7 ⎨?챆;?wwFQ?)Io;?1+?fmx?1F?Ѝp]?{jwD?Gpw?@hk"K?`z?߱)nt?8_G?0V1 ?[?Li3?f/?]'9?4?$u+?@+? YG?0c?DΉN?f+Ώ?U?14@0?}CK?:ys}?fܬ+??/y?j~:o??pO }?6pdl?|Ͻ?#?=U?ҜѠv)?We?=Jc?k?x4?Eu?b ?VB)@?}(h?Ga¡?$&?z?!s ?Uc,?hg?<BZ?fNi?؂_?ճ"JM?!]2?%?Ol?? =3?BRfD?Z?5zƑ? u?@B?<d*Ȝ?F%`Ϗ?Jes?I;L?ºj?ͭ? N? r?hi?;$b?Ԍo?$5$?po1j?"1Y?\N?@v?8Ӱ=1?=?aǵ-? ?0 F?i"?c>/?^!?w?N~R6?X4?mtV5?~^?J5?Ԅ9!?i?6 ?g}??0ߵ?,?>CiI ?^]['?g0?@s?Ez?ݹyK?2?=$m?vB!?Zd*a?@굶? Yz8?o3a)?"%=?Nכ? L? r? [Y=?!F?0zk?j#$Md?Y;X?H9㦂?W7?9S?$`w?ۅt?ѩ?פ,?'|b?tHs?)DE?+{?~C?8K۵?70?0~_?NEgݟ?l?𯾒?<٪s?R?XrL=?HiPض?u?r? /ۡ?1?k64?p\'?IBp?p?7??V#?X?À?Ix?hƹeQd?gL?$d?8q?85T?<|0U?,9mN_?7?Fv?-5D?@a,`?8I}?|Y?H4?E4"?Ɍ ?PWbuN?@$?p_V?"eJCw?E?`2o??H:??AQ?hq7? $?6)w?`? ]?$ ?aw ?|2?R*]?X`-]?@]p7x?o? !?v1c ?TF4%?4S7??y?~Bbk?^?0\4? "+?UE?ۑJ@8? D?~^?E)?8,??px??WJ%?r?gP?_??6r??|?rk ?7)rDz?yc?cэ?^?Y?Io ?g ?tS?Bΐ?8{?>??FP?rOy?mC?j?xH8?l?!?%ڝA?DLD?z,H#|?"bjl?T|&g?$85?7p@?0??Jmz?."`?Xo?ME??*e"`3?oQ? 몂?<+?a.?r/?Qj?>1Y?@$N??}?`?bjO?rL?Y?իw¯?ki?_+?M:z?qޖH?,8F{?,qUԁ?DL?0?, >?.?[`gW?5VZ??dw?`.rL ?Jk??6މ?ZI?7 Aӂ?s`e?vRn?!Q?* ?(-\?gAh?(c?\:4?$ԅ?Pnժ?̣+?q*0??Dk?9:f?gX?4%܆?5EZS? ?d4>0?kgi?6sR?/(1R?T>$p?8 +1?W9?r\!1e?H 9?^\|?̗?`Y?L=?7>0s?V 1Ω?CV%?1?- j?8HH?xU?@d? Wy?$z?ė[?DRC?V7?ASջ?Dt~΅?R+?Sg?-:o?{ U?_)?/V^?(;9?B-?p;?!?ړpI?Eڦ?SR?,ǹEg?jO5j?P~Ab?$?#܆?  ?_Sy?Uu֘?:Zp2?|g?RC'?bfQ?C?Jo?s?y8/;e?Z?ߩ?vr ?-D?7x?@; ? Pc?jٲ~?B| ?pҦ?H?wl!?i-i?@@?.5F?$T1F?~@ߟ?'GO?Dfu?z.¬?%U?$Ѐj?DE?<? ysC?ߤnn?)҄?8t?k?y?0l?"? '?ɩ?EW`?jKI?tnZ?Vj0( Hencoding-type xn Pencoding-versionxo  column-orderxt xsxrxpxq @_indexxu (}lzf@XSNOD8h 8 ((/@988H:TREE~8TREEHEAPX Xcatcat_ordered8  (}lzfxp@SNODTREEZ (}lzfH  0 categories(TREEr H&orderedFALSETRUE  (}lzfh8p@TREEjoGCOLA csr_matrix0.1.0 csr_matrix0.1.0 csr_matrix0.1.0Yqtf RzZ EnLJ QOMh RLJ SahXJiGqeloVwBMZJdLOfSZJCJBIyAkEYTQJPdVmHiyEjKPUpTkOBpnFXdncJDfSAesVbS dARp!KLwh"TbWp#ngR$vFKI%mqmM&nxyD'NuX(oeD)Wzsb*Ngd+AgF,LUz-Wht.HTT/MXdi0Btc1AimJ2pQWO3gEbC4xsx5jISN6sBDj7Kvq8GYIb9qez:LPgy;GuGU<mCK=XKPK>jgrT?OYdN@qoWAzcTBDRXCFPrDaXdEzJQTFIhkTGcgkOHQuKAICtuJzvPKGGDZLKSNMtNbNJaTwOAyRPzdFNQCyBIRLNwXSOMXTUlXUMREVvDPWoktXsbgYYZeZvjs[hRf\lAIn]BJOg^tyA_YdjU`ryvOaSCGbjdHcrSRJdkKfeHUoafRGegnHRRhIWzfikSdcjUnqrkIRRZ (}lzf80 0 categories(TREE H&orderedFALSETRUE@ٙ ?@4 4y_index__categoriescatcat_orderedint64float64uint8`TREE`@HEAPX>arraysparse@( ?@4 4 (}lzf AHSNODP?ph jTREE KjR? E%?9y,??N6=]?o2?eX I?E0t7?k6?yv?R*?fS?$ eբ?3g?0$e?q,?j?6>T?lvA?FXOP?SBtK9?{җ?(cC?@2{~?杰R?dU?5u?@?!?l{Ua?HsOT?.2,m? :0?PRe?(rWξ?tge~?ux[ ?X^j\y?L(?*@?%p?0f4#?4Q3q?K?Uc?N"u?zk?z7?AG?(6H]?5{Ww?>~n?3.$a?"#?W\?.L;,6?VqěO?&?4mR??^?V?ILfm?L?dhJH?W?Uy?n.:?m?@*E}?aEB?.1e?Z=?HSU__?󢓭b?vV?ifܾ?VSA?Z '?iϹ ?=*??@q!?4L5?}m?ek??ՙ?5L"?/ 1?u?[?nv2?0ˢ+?rPd\?cNfT+?* ?c4? )[(?DZ@?j`?}?)+Z?2N?P?De? Ւ?s ?`` [з?pD2?s?&}?3s?z'Q?̣ '?֖?h}`J? ?{~D?5}?Ku?!X?@ kN?|d0?炒!?XkE?t[댝d?Dv?s ?C5dy?y0ή?Z*L'?,RF?H#&?5?y6xp?xڏs?>!k?"7|*? A֙?8W&%鳽?ws?;?xL>WV?hA?l?No9W?{h?T<\?7?NpC,?a4?XbAA?(0?@V2?;%ַq?]:T?X<,??2@u?nMCB?|(%?ҙě?Fp?=0e?=͐DA?оؠ?L-J4C?l_M:?Q`[U?jX?Ptf?4U??#觔? 1ee? qO$??~a?Jr?2.w[?$?e?G?ȇ2?{v?v]v?ڎ?C?bB?y-$?+?!?w?!=?p-N?P ?퓣ڜ?[? Ά?|\,?̭/(?\Y8?P܄i?԰ `N?ˢ\]?{%E? 0?rf?L?y%B?Αq?v4(Ok?)?TK5?ty3h?,{ /7?I2J=?h_?tT?W~5?Px?\9!?ؗ>!?z?5;e5&???V9?߆?v:5?9eM?Py1?Pbἰ? :(? “ׅ?f?P??K %?AVСi?4s?L/8?U>M ?ڬ??9?m?)w|?xh.i? zRX?-?db*?r?b3QU?>}?O ^?2ϊ?YM? ? 1?6ŕL?7J??j}U?Z-th?Ytר?z+?LD{?Σ٧?߭G5?kHo^?c?E?Y'?Dޣ?I?N9I?_ɝ?l?f* &?&Ϲ?$Jφ?تZ6?fa?j?-GC_?p;BᲖ?r :;?Kόz? 5C? F(0?s"?.qɗ?"^?`Ƨ=? 7)?|}?Sl ?tD?{t??@@8?Z]W?'9?)]l?\*/?+?N;jp?L8!A?H9xF+? wɤ?~ 7?n1x?O?r~?cwb ?$8?C3*?87&?so?T9?]$k?}31?v\m ?89?UU_'?BCM ?fSX?Rr?e?o?`.n ?fSC?8QD.?#ۂ?^av?%uh?;bJ?`{?P0 ?Q~? ~}?BW?BC4@?07Ul'? `?oe?pł?iqFxX?k(?0XgY2?4IŔ?v?c[k?2'B,?DG?8i?/~%?*J᳼?㒎n? ?,Y?s?jFOQ?2(F??;FY? y)?[?@$[k?5S?p@? 51?>pga?hOdPx?tnW?#ǿ?z*$>?l?d"`ݵ?/7?V&?X A ?Kbs?@܎ʩ^?6]?hXr?xnK`x?`-?jAOP?8} ?V$g*>?:}p.? l$? J\?RƦ] ?I;?8z?T+&?bUA?Gɥ~rO?$N ?A0v?Aqn?Pu؜O?jSޗf? ?Wo?b??p}q?CGX?|؋?,Tm?Sm?n=&?8?%$e?W{? "Q?*,MPR?枀/?zLe?maK?8j?О-?LO?@5?f5?Y?eN?Δ?(DL?Wl ??ۮ\?72P?pgIû?b2DOR?$W?YsV ?s?\Ck?ȭCF?g@? ?b6'?ч?^q:? ?^UG?iy ?{?֔?hIif߽?Qs?f ?& ?sQp?x?p a??iSu?82h?*Ҽ?Pֳ?Ғ|^?Y$?;??,?{*/?m90?c?_2?B}?Xt?`"SC?B؟?\W?21~?D;t?4sn?@??I?~-&/?Nb?P1?a.?BB]B?yOU?ӷŐW?``?0h?wح?r?REqp?Âm?>?2T?b?x/ ?0H.?U`s?p9Y?K}Q|?vk{?/I?"i?Lj?B f?pl?`?p?Gzr?l`?{(|??Ǚ??S?f/E?H@o?-)?\?#V?TREElHEAPX @jdataindicesindptr8h j Hencoding-type ( Pencoding-version( H shape@  ?@4 4 (}lzfH n XSNODkPvTREEH    (}lzf$`w `TREE$a   (}lzf|`TREE:TREEHEAPX8arraysparse@( ?@4 4 (}lzf HSNODȤTREE  pRH?aq05?Tf,?w;? .oQ?c?Bo?(?gӹ ?7Q@? h?ȂZ?X$?m?|Jz U?֎֤?%Tp?~EC*?hnk??IN?ﮪ?{ ?Hd?GNKQ?)R?< ?Nb?@*?BRTm?Й%(ż?f(L?ڈ'?R 5?>L"?R#?!?w7?p.?ɓ?k;1? p=Ӛ?6x?ܗE?28Z?pQ  ?<Ϊ?؂ ?=5a?oB?Ewxm?@+?To!yx?%j%\i?pGO?Jc?X'h?t?P$?wMFs_?vI?hK?莐㳩?׹ ?6ڟ+5?+z?^wn?N)?>Ik?M?D$~?]l?sAO[T?@l7?w>?"*6L?4O?Y8Ġ?Z{?OB?BYu?zID ? H ?s+pؿ?v*!?Xл?,?}e?wRx?@?\QZ?Fv??-zP?&??p-?}?2 LG?jo?#a2??i? CY ?D4У?ώm?zЛ?tWɠ5*?'@ﲟ?t?lar?zy?ޒ4I?۝m? X?*$8?3Y?vja? y?osٶ??PUe?6!)z?P?[a?$Jx?Oi~?4v8j?D{ox?d??%tklxC?W?1c?jv?N6T\?o_? w?c;+F? i?O/?0|p?DӀ?R'L?t? ?cQ?6r?CJL ?ԩ&?J??G5b?xȇ?,?|os?N<7?)>?;;X?h?zZ?lL(?@j2?9,'i:?Fi?dcՌ?2J̞?1??0?3KB?1?ȯ=F? r?sT?U?uFc?d:Η?0V'j?QS?DuB?{F?Vw?#m?5$ ?>~?~a*CI?rĵ?-?|,z?\?#`N?0l??zэ?|?F`%:L?xSU? Ȋ"?(Bob?M ?^9?>4#?dc??jj?s?z5EA?#VϨ?h_?p"K='?4eT?Яi`#?FL?܁? EB0Y?`Nq?!}O?MI3?.@~?{r]??f ?FS? @=??:b?6~?b,sg?ƴٲ?Hq.?>\PN?(͉?~C ?f.?0$@??$P8?(%U0?<H? w?ְtt?:?(K?;?Tϝ!?0e?rl9M?^?sd?(37=?l >+#?RL?ʒK?7lY?s,?QLi:?ߺ|@?M?y\P?x!Z??EO L?vb#?О? ۑ?d\?`+???nz/?']om?j U}?OM?xhMް?q^D ?So?Wa3W? ؿ-?~v7?*f?F^X?8}?,?R?g鐛?>{H?"W?LuM?p ?Y{k?45?X+,!?04??9l?nF ?P?H .?C?x:$???2W?Q4?I?/?Fܶ?0 T?&I?V?rJC?:dt?t;k?+4n?\„?\Q:?3&6?H5Р?@?֓+~-?TE?D3?la.?ry)-?tP ?? \^U? J?2[W?朌7?Bk҈?f˭g? ˆ?.2ʲ?B?$U(??p?u?"?A?ӂ_[?SWo?e睯? ,?vbH?${,8?#|K?ۅ'?nR},f?mt[?-V?;O ?JE?ƈnN?*b.|?.Wo?ٮ ?C"7?+DŽ8?ޙ?0Ɖ? |"?%?.n鏠?aіG?֪?H?qJ? -o?6?;q o$?u4(?W_.?o_cI?^:?اi/O?ޯj?ݡv?+?A?ڝ4S?Y{?qZ-?&?5x|?Λ눝?H,/?akT(?Y-?!xP?0xn?N0u?[?N??u`/v?M捎3?-B?yh?5<τ??|MJ?in? g!?:PJw׬? .S?&?H UM?$w[A,?* I:?o? 6n?@+[a?XRN?PH)N?#???OF?T!? ??(?j=_?yY8?H&?r4 ?Um!?雃?Mwf?_7?Rx??*~ ?A|[?0eAm?P!N'?Y)?k3o?7Ýc?`a ;?XZDc?e1?Mj?uڇ?3𢡄?l/~? ?r.2}?K|M?(?i]?DN?w•?+I?%ZI'?$U ?4={ ?ln'?Nԥ?4)>?ЃX?Kq?`0kWS?=5qͥ?'c |Q?4?.? ]?O?|?._{?{I)?^A? 7?rB?`51ڑ?jS??6? ?6ѥ? ?#?f(ǯ^?)[E?V(ɮN?XG}?Z?≴?n?1?)?Ar?4ԗ4?Y6ߟ5?2?_x p?aeB?-l?T?>?2Q@ǡ?¹2?I&?I"p?ȗ?0su??D95?6jhN3?Rvo7?Fh\?rkhd?4s7?s8~?/r!?r#yR??ˍdb?jp1?HA(?bC?P?D?HjNS?:@&?D?{ZC?Rԗj?B I?zLx?l~W?3+?hϥ?[?i)i?z8] ? ?QLA5U?;Ls?'>?CF*?^G,/?تh{?t˛Y?`?=Ƽ? P@?PJxC?&?mѲ6? Fr.f?=-,?d'?'?rk\?ju^?^*pTy?I?xZT?H`[?t3?.E7`?{?,/\A?6}7?4?8b?j=?Am1?L?AaS?QD&D?D_L?y?k(A ?n^ַ?3?o췹?\7?oB?@s?rIL?b_g??bF?6h??+*?!]x??Vw?"?ԫE?aB?$W}?=?Ivz?bĂ?/?d4]?7J S9?W8륱?e? Ѧ?.:?:T#? ?Zvy?6?Tm58?ju?"{?Z?7?`6̮`?@H?"&P^?b?I#?Yڊ ?T}L?Rf?K?^  $?;0'd??Ч#&>?Z?vQ?PUlmL?/P4C?{jl?P?@CnZ/?L]?y.om?ng'?ľr?d'Jt?k6?t3+-?d?x+?v3 ?\? ?bB?oM[?(sVɜ?pjY?wwv'?P ? +?N}u?r?|ZW?b@?wX?TŒ?5 ?3?n?0NG?~Cf?M]?`_?I,o?HߕX?y9?7?N#ߖ??}?}?d`?XVA ?} G?ǻN?a0*?OodaD?5?ęd?,|E3?.0?C]x܃?E?9 ?Oqk?+: q?5xV?@ƒi?Y^ ?0?;; ?'Hʋ?(R?`g?w¼X?֜R?5l?T/!? 8y? L(?>p?ǟ?zi?4M5?JIO?^QB_?<_?~?YO8B?iҶ#?z3ڭs?S??~Y?1.Ҁ?e?ng1?srY ?6!λ?3ǮI?z=-?@w񕏜??.ߍ?({C?Jm?3?~5W?*?ͤ=}?5Q#6:?y^?"* ?h,0?lˬ?l-1?V?G,Y?b? ;? F#?|$?? b ?{s? ?M,[?W?P&&E?=t6?!o?E!ԗ?ؗgb?HG\%N?w2(#c\?PW=ǺE?<?^? Q?,J?a?^]@$??9?@t?HW?&E֞?s=@q?*M?G#d?`!?<ϗ}?@U/?ӓ? n?梶 f?dg?9@\? WW?(>!?Bп?XyVʡ?)T5T=??ғQ?̄},?=o'?c+-c?l[?0F?9R?=J?"H?UVѿ?/?66{?`\;?H௅?`? .?U)?MM?k?)Fw?#3I?F?`9.,?kӍhW?p>;kq?I/l?~K?u?:,ʵ?*j?|?ǧ ?혙 p?Tzb?o_.?K͙= < ?;: /9 /8 7 65 /4 /321 ?0 ?/ .-,+* _) _(' /&%$ O# ?"!  _ _   O _ ?   / /scverse-anndata-b796d59/tests/data/archives/v0.7.0/adata.zarr.zip000066400000000000000000003631041512025555600244420ustar00rootroot00000000000000PK vSobsp/UT aaux PK vS obsp/array/UT aaux PKvS'ٹQobsp/array/.zarrayUT aaux  0 kc jbXaᄊt[/CeY۾ed0:y҇GR̆cF դS {y!|7.eZ7\.BUtXNmXd[_[ Uʟkz,Jm7PKvSLkobsp/array/0.0UT aaux w4̲^QVvޑ^IVHVv$3!I-d{\^CD99aRh-%^]_hBCj`]>z![!dDsheG%LReJ~fd+? W!Lr![`]+&7fuTՅ2G ˜渙}u .dij)tL>9KvwC= VQJO.SLcwLp4<3!)~gހN8ʴع } нA  ?N1DBJl<Eu<sSԍPkpWqQX]nm1Kt\2گٲҞ,u̪z9_G"pmJu)C"u9r8$b9ׄU@l ԗ7t큮_+ -ꊵ50P@bN0(/lMOUz#X,.Kkf\EAB¦:>=X;Kf+}&EF[ +<oViyXc*?G*(=U<U% |>f}wt6ׯ8j^5kO4BY34`M\px&/K;8=\g\2M_U*&&8 3&⢧%0x*-@L&JqQ0aOק. cQX`(@1} P4JWЯԓ}[yއx_%kH=2{~n.Z:< y ڡtJzTK!RgAZa^Qlug5T[I1ofãL 'ˮgbf(nC)5ؙ̟NMN=GqܹbQ6jfBe?f`yk\P3RnnJ] #HJQzK;iTkU^$TH1sK-$0/}*v_+>جe<:%o A}M*&g{wgz\?9-SRf 'u5oE}rL-fN-//b9~ozUd6>"(e[m{{n0¨YCL:S$ǵm: Ӣ ~:>X6Ul%Əi:ݤYCu\M0rq"V̜!ך]Tl]{_IYkW&z# H%2KZO -MͲ6!vfp4Z2(d3yAz?oS@eJU$75 X9@]4.jtŴAp@W-b ~I P!1.>+Y5gzu x*ko\ҴJNg/ur O" V|ɞmgi]F)9hOPSyV[g9x`_=؛#wnShxO!`} "P.gtxӵ`͎la|q)?UH4t 7#eS7Y K=U4칕 K +4Y8XH&]&qW_(d .;$f J'CJNV"MŖ , go|ﷺ~&%PԷEOc[TnZWDE[TdsSMUլ:GcHk:')˃}l4x"b+X沼cYrVADŽG"~NY lfN(Q>1ZRJ:7\}xroB۱!yTW-~{ ŵӁ,&vg?%7[LWA=;?s>1bNjkmۙ=~+&zּB)9wKm^ձOjYCM6%'wX{n{Ë4ك<"[wC_^va9U3`od J+}6Z0?=& ,~;lnaٯJT[g,>r?T^j45l6 O׫-Ϙ`WOܿ=vQ.5=}7̧%6RVi36+㡼̠mB-EQo_g β'qԍx}Tܡw*uz'9%;L::yN]W}ODT;Ntk'[,Ҩ=;΂|lT ykdط?^N]Q2k6|3=Z&1[SAvy-V|FS Չ}LjjGD}3`o\ZӍMyg| ^t;!G?~3P:\ڷyzMr|~Ҧ>DE!;Yg5n+^ƙDf[HV>0<)ݗ3KW˳O;g)V;q돬ϡ'מ'[% Tzg8XU):<ͮ#pS\Iyޑ/AbL{`P y5_.,*%E)Uw"kRMQȗ[GLzLQUPmW؟Won§u11`;MBpǾ"_'0u/nTf|Xk](pw{2dS ](Y|R=x&E}7n>3mBfF-Jȹ$]j! 8dJ$A}GǢIlfK肌W1=U=v[LBAML߷6nOv5ev6VF wj"7M SAvͦqz`orotA7c8DO__Opp7U He90H6z4~ls&[3;l%GϬ7yJ=Xg|˖;s"2?_5އEf]%pBsGܹW0lw [/x"3+3T 9&q~Z>%3iY'ѡQ`2k/A,bCڸ^D2mZ]7?2U$ ޙ\@,Uwj|fR!jQ=KZ>Y)h ;mj]Ŋ=|%LڳmLZR7S^3tiΟQvp Tp89鰘i&#L+A Q6{ǽ:dC5T8 ļJginmW#;+&{\;]ZVՈ( >ƕx_8X 8_A0Ոh !e>Mflx S2o^|4mv9懘}`6VD[*&x_YWr(<@=D?,_j?^jcm2GI/*Ѧh.|sA6 '9-wTڏR t5ߩ<*={|AXJ9<[[ A~ |5n9Gg-3~s}Qr/|ePFc6(%C5mjXLN T]f ko^0%G iAXp}"Rz#2χY!*#vwu/;' nhY64_c V`+M޹ȕZl0o} 5nMZ8aMEȃRŨ$<7o5=j΀qMu]^o!\ŶvmŽ8;O{05C"ZyT>cjEd*8nZJJ#4~5*ݰ"6q^?#Ph!@Ս a h4AHI !W`XVJv;KB%b!صJY.Au$ FL!Ʋ \G`M(6bЛ JKC`]D"AƯHh44F#  CCѰ՚eqd{6Ń Inc7ڀm7HdT7&!냳4%nƠp"y5JB .‰c]xF8B.Lȩ ԣ\^^[wPK vSw obsp/.zgroupUT aaux { "zarr_format": 2 }PK vS obsp/sparse/UT aaux PK vSobsp/sparse/indices/UT aaux PKvS)a5obsp/sparse/indices/.zarrayUT aaux uA0E̚\hy cH-mh($Bmڍj__-5K|=M@ْ/e3+ŏKTB)ED:@281,#\[=Ęc@jCZCܠZpw1_YUR'FoPKvS"4obsp/sparse/indices/0UT aaux cb4fQa``a bi b^(_X9PKvSCZlIsobsp/sparse/.zattrsUT aaux RԼ̼tݒʂT%+Ē %4%eEřy Uzz0``. XZPK vSwobsp/sparse/.zgroupUT aaux { "zarr_format": 2 }PK vSobsp/sparse/indptr/UT aaux PKvS o 7obsp/sparse/indptr/.zarrayUT aaux u0 wyfRucTJC"" A6A*woBOݜ~@֮k.=a>Ua۞%*x Iy+~\zxNa' NpSXk5OeR >\f @ pPzuq*JmZD'ATjK-oPKvS,&obsp/sparse/indptr/0UT aaux cb4fa``LLP̌Y`V(fbv, sB1PK vSobsp/sparse/data/UT aaux PKvSXj 7obsp/sparse/data/.zarrayUT aaux uA 0EBf-Ŗ]Q1Aq42c*޽IH*y̟ v 5}Kx=('lIOp=)lըv"t K]-j~ao)wbn}5b1 ]A/Y ׀w+Q bp!Y>PK vSXXobsp/sparse/data/0UT aaux 3HHX?03?D_F?VD&?ܙ?-:?)?Z.?.jX?PK vSobsm/UT aaux PK vSobsm/df/UT aaux PK vS obsm/df/cat/UT aaux PKvSL\7obsm/df/cat/.zarrayUT aaux u E~ cm?i EQ&B[6ཛau|ޫdq&a&om_ `*<(s81 2}96Bns1/$Bl.;$@+(b:Jkz_Q+5zPoPKvS?(obsm/df/cat/.zattrsUT aaux RĒb%+x_TPKvS}w,. obsm/df/cat/0UT aaux cb4fc``a= dbadfbfecbPK vSobsm/df/cat_ordered/UT aaux PKvSL\7obsm/df/cat_ordered/.zarrayUT aaux u E~ cm?i EQ&B[6ཛau|ޫdq&a&om_ `*<(s81 2}96Bns1/$Bl.;$@+(b:Jkz_Q+5zPoPKvScBiHdBQfwr?BnI\[ѰÄȢ%`UOݪ=.W S8ʁEI$k(R)P[9/LP/\rY#@%K y˯69{oPKvS-.obsm/df/uint8/0UT aaux cb4fc``a= >ǫ:Ɂ'W Hw#w>APKvS#vobsm/df/.zattrsUT aaux R̼ %+8K"S_ZBdK`xd̼3dD4R" %0?jmj^r~Jf^nIeA*U)%iEJ*R3@ jPK vSwobsm/df/.zgroupUT aaux { "zarr_format": 2 }PK vSobsm/df/float64/UT aaux PKvS% '9obsm/df/float64/.zarrayUT aaux u 0E~Z}A)3J4&(FfPo꦳Jg.s$u]pM瘇3i{R̆5,x דr8V.Q [9G tÖ5^8!uyueD4{)ͻ c>YF,z6Z6(ڝ5 KwڤWT bQ!58YL_PK vS" obsm/df/__categories/cat/.zattrsUT aaux { "ordered": false }PKvS(Aobsm/df/__categories/cat/0UT aaux Ĺ00;C ((88[9E60tÎ^8!uhUe8G^JØm|xZGdh~k7,BUDʂ dyb?PK vSI(obsm/df/__categories/cat_ordered/.zattrsUT aaux { "ordered": true }PKvSL+H@}"obsm/df/__categories/cat_ordered/0UT aaux  E@]=D Ѐywzsb!$ObRRF:kШIڴЩKPK vSwobsm/df/__categories/.zgroupUT aaux { "zarr_format": 2 }PK vSobsm/df/int64/UT aaux PKvS^/7obsm/df/int64/.zarrayUT aaux u 0E~څ}A)3J4&!Tߛ iMg\Ex¥?͡G =LFXM֯OԼjՏsO9@"r`<ǶR"-"-{M1!脡^GLPVP }vmb 3 m_loPKvSÅJ_obsm/df/int64/0UT aaux cb4@,@_'G r.Rx?r s PK vSobsm/df/_index/UT aaux PKvS8Vkobsm/df/_index/.zarrayUT aaux uM 0BfmAJ4&(Fz&j\tV7/3eLRW@+5 #}p|fBdJ T㢂 ڨz`)2s ;z!pץn1V}>g9MC6j=lu;I/d|p?l R\Ȗh';$PKvSh~TLfobsm/df/_index/0UT aaux }; 0በul`+VFr'aM'=EvxI +K -XAO1L.FD+j%H Yi&%wn"yPK vS obsm/array/UT aaux PKvSg۬Qobsm/array/.zarrayUT aaux  0 } ɵ #= jbZaᄊtUq!ɚ7f4ϮyEgԪIh:]/T",Q-,b8  DQX7FJt[إޣkЃl+Z3uĿ^0J*d-dPKvS9)H)obsm/array/0.0UT aaux w4QVlBHddeUlBFYY{eyx{{wϹ{=~h.bOI@n񢴚p%tw8tn1V3#$@!TXZ8M ~ŀ7Oؑ) tz~Mm8)2.$-7ٶoW` 2+g7)|}idj";xnR9\w\<|TSAXp2Aua(Po _mb^7$x.pyIHEZ8[M KH|&ņb&qجm?el y@cA5X.gH߽NtwSS/ c n|`4,ڬ(- e;}WA@P߶$y;#L6q֒C$0$Thxgll¡[[HZ,Th݈]{\+ r;y#lhXRm Dx\<Q.=p[Vnl N?F>:izAأʨoG #4+i"v: [fQ%-ʟr7 ~T47k=i&Ɠ\RdT'<1Ф2tkӻKj/Qn~36rˌV{x8 Tl,w^s̲Oӗm2*3iw!)`ۋW$XM1 g["݊/a@DO Z9.oy2<6͢x3 6U@8[&gR9L=^3T-:3ib5AUu|q)x!ԗoecYA~W>ڱ*ǗaԚ]>IBڋ?׌cT+{tzx!(V IG9^ oo4H,Z D0m^5()Ǥ%L{=(xtSY<'W}bv.~x]V>X#SFji J~oA;8Gu&g%=C2=(:>CniS_ Sb~' Cgcw>ض S 7LO1&cnEև5OxҖPh l"<~zBYzsk4#q΂-Ov+,'B7BwmU%mxhhűXNҺtECigЋrMkE4s)iǯ-v(\+0|Ss!M%пV'JEf|k ܵw1Yx~zgj^xد{֥wFMXQ’o$=e6T>y@ skoQQϦSIL7|.6|$U|?4'Wd/hRFEA,,S<kYҭuk'L:ib3A{U[m&B}WCBGoON,SIIHɑXS|(6^39k,(\O^o Lnpz.wfVZ!y|f&~8մWiyK3e!(Um`fe.#E;GKbĭȉUy}D~V_S]*F\YXP`'8W޲/<8ܴV$}*z_߶_Fa:;Jp'%[Hs}ѧGMt"-p_+Otba0oQχWYɰz{Jݒm5lg]hJju3Q &܄dm)bW d8t&Bz;?8:<l-n Hd aBCnxECSiOUS\o۴5& D?6YęINq%[!Pڙl 7yЪmN 8ɕf~^+ӝbvg|AOpGnћ\a9tGcanj8Q!Vl-Ǫ oBp #S SYQ* ˟}dEFZ_ykjx;SE<:BM3.p Y%hۦ?.Ew l? WtsTs,jg]ECNsvϷ>v+_Hy;fE">ׅRpv7BxEg!oĚ)ISevluˊӧIuIn^_w-BQn"~+Z}*dX+)(Ƌ&ƓS wmRR-fw\ĤeXJ$.9\ٮMxI(稯ןy-bWG[\3C?k2$'W_/-rgr^9tNP"#t oy<^ {]6*<a*uoi3r#fR]uPi_zhENifJNߍ3hv}0|{r"hy_9Rg1aΎԭhށ _'c蝉,>}΅|@㴫?秘Py>o8S8SKxEI' D# Ȫc;:o9YqMcx_EuMr9OI}E "Hr$[McaR冡"tB_YІJO~߽"f8[zJ@+$f)T;#eK/%Ib'q[\+,(sW-G`"J&=QK$r 2c4@!.bڼ`UG6嬪T3fne~n5{!Ҁ_g52dډɗa k [3d[z7LKCkZEQMtҜlu<+cnV!Ք6i}-'%C\A0A5avoɛ /'>Yi] oL[ 2~*^}H;+~CWcg-) g8s]S*szR {~IfĂUإTIf!Z,;'@엳Q9{"#i<z,bHll$C",&'V\?~bn࢈m}[?^S|8Ȗ*ຩ=lK6>=;M3eM@0IPJKɺu;=v<,ͱ|9[kű HbЍ…9NtH::mwVVp͑RͰ`a{mnMLALq!<"\boϽh9/mpWT$Mv ;SK__կͮu9bQ'=gP7ʟ[O[$tnduoV~)q2FVO&}F'_ɾ7Ks:$ݬlcrvStdy̷ʲ)yڊi?}^/L.,ե=e~`.b~؍YRxղǭB6ɸ!\+I@鯟Tl0UDOsH 轶\qVK99gG?$ +{,8Z͋G\^*x9RgNCx<$hzQu^ ަ Q-gimNp\ԎYzoR'iOj/c}s̶sG*WkiL] *5e2w(TiPy9z"eC)gP$2%T GC:EHԛ*TҕǥVht篵⤹"ߑl~0>ǀ̪`5Li.0ꌬ>y8:aJtjΔ{$-3P2)I#[wDT- T]< Zd.u!QyB,bDGrIFJ;\ Cȹ|. 2O-bF|l(K.5z_jT*爵UޥZg*e3HFx'~yvÜf3YRVY {]k&8o=L`ܻEJzr?cqȉ6@(֌]g'n:[2o Ϸ!_׆=oŖ 4)M'g"^*5qGsMш`:2k3/4}W_Yal'377f XHasZl:ho4{(8JEM7k;hsAU% 9ӗWUztG"˫;HϺ>0zAv!Ho'Eu³ZO\8}6ЋBd71T42i|1UV1Tr9NBPl|υ¹@RÎ!h!x{Ch=ՊwCpq{[jJ"E ʷ:MX%RF.dy Xy1K38J 埈 ߙ6#Jv,FmBy}Wz뇙D!u\GlAS"ה0Gۓֶ7+OEjD/ "يrrҠ#?TݒHlE\7wdҲMN1ڎ׼u''|'Еf!$Ztjs%'a3oM,? C6}4Hko5Wb52 _1'_i>ʔkhrl&S)^Uk)%{\(bC]&QOlwwG8B9um።N!|Y6ٝ| q kjKl(Y#NuMWEN=_S1q_aXfD]@ޢe);yj$s8쯾?G`BY_OT;9ByWCm2ѫ=wI'GJD@ىMcّ)y$!$w=)&XREnN8Zarx7e;XW;CrDI~k֠z㭬!>W ZM\?zvyRH:h~\zY\R=oәAoz80uPnSXŸCWv@r_7w  -En҆75N6-H'fKJ5E} _(ܺ%zmOnoAffu>ZBQ-iyQt*/z[z;OE81|+ o?-ùbkMfPt D}= 3Q 0| ?f o t~ˮi/yXyAgCu{F/. bT}^-iʜ O&1$7uO"fN)fG1pL PXsљˊUS$^K^ø4ʛuQ&5g{B7}wL¾K'Eg itti܃d{} ֕yix4r4k3f5[9J^djhKFT<ػ8AժdKtlU} 9J71 _3S0҇J9ⅅ\kE.=e}+͞z)hORN6a׏<$8nҪymCC-:`wBj:zײJ8R/8䱂ooY9X5k, K\Ish ,cbq =_ѹ3 K%4E{z;=He#{*t:x/11'6#6 Oqtnk7ۆ\r5boSxhfXg],v^lOPhU'OQ.MNn1k8 `O⎦z+}{w*\:n{^u yV\Tj򀚨ǮzKhnz*S>z`JX[~Q]xֶ-L64)Kb{9+/UZKNlvڭ^%ΧSiXXi'0Ad aèɖ/m:)N3$ __9gpJ==@]m!_p]J z\w+|jqZ]cŧ=,q葉Zc(kGfpǦdiLl]f'/nk7t ^ U1:c,m@#:v 2)k |S>#WJMT8i"ٵv#,Wn:|q!KӪpUnm4Z{cccaᗔӞ颃ַO NZKwvFpm_B*N.Wsr^vl& KGy VqxFg;_j`LjBmRa ԓ=r{~ETa!sH,asx,=lKб _Y\b]P$J}X8f_"`:eb8" O0Cb)0$C$l5  Nf7V54t=D#)2_M U)8-LRqx"A^n’ۛ(#Nc1p*э\\%Qء50ix|ՏX\n 'Q8(l G&*CMQ( `| B-X3ֈ& BT*HE)3Ke9$ @C:(V(lme@` $["Z!ְԱᦁ9l#/a8D,z}E$:_kYc6jWfh2a8$PpBIcD"gM](" ͮPӰ8a$L`?m0?])%"=NaP0,t2KPW(x|)m@$`R+X";)rDM%a(mHHMFϵ #Qx" E& )_Oc "PGã K0;I̟畆484kScӨ " #S l@%ǸPK vSw obsm/.zgroupUT aaux { "zarr_format": 2 }PK vS obsm/sparse/UT aaux PK vSobsm/sparse/indices/UT aaux PKvS7obsm/sparse/indices/.zarrayUT aaux u0 EwyfϨ*DDT86K=%GWKups*YlRY V*qjR%XϐpS3 QN5^kCvK=s3{ hXM}rQ=-ՊK}uyςҖ:1혭PKvS`,Pobsm/sparse/indices/0UT aaux -A @@љ:CC 7BNUa۞%*x Iy+~\zxNa' NpSXk5OeR >\f @ pPzuq*JmZD'ATjK-oPKvS\><obsm/sparse/indptr/0UT aaux cb4fa```b fb4HyXXEX %X K,!aPK vSobsm/sparse/data/UT aaux PKvS% '9obsm/sparse/data/.zarrayUT aaux u 0E~Z}A)3J4&(FfPo꦳Jg.s$u]pM瘇3i{R̆5,x דr8V.Q ,ù m!CڢV⻯-q9%dMR[3tnigW<"3 jՏ$Vd뮂*vGn(f^b8 3DQX7FJt[إޣkЃl+Z3uĿ^0J*d-E}PK vS layers/array/0.0UT aaux !X51 : @XPT jyrUФA0P7`Xel Ѩ%$к`nR߄0H4q&#֫FdJ$Np.gJZ¾u|rp``O.`p8Ǩn`˴H'YV2@HH `tj fVܓ4Y䆜ΐ6R4srjH:RzlhiQ;'C^t P dr^xHt.{,8jALQn3\bd :ZTj7`ꑲY^ЇP/@Lynȹtv î(|wTn~`H7NdXǯO.C@0(`T 4^Yiz~ns@.( P<5TiQUp\L  0&UewVh0v]樗6#(X62_aT>2II0DjFr/bHDBԂТzLFGJ&mFkjIZ`/6ADkorb6+!VɎb7WTvu"I݋K ;UPPCgd33spw530IߌV}o5,]9Y;wѝ >JQŠO31egrzwJ!k@q=/7[a5 _mCEFuRe0}j-YFd7 =LQF0"˦VG &=G2W]W< 9y)oc096)#k~:*ǘTo͊ۨbZZ җGvmIfYGΊXgJ.& w [H#(Hm_R!k/K+4K=cJRWXa4Iٞx\`uJ {^5 4Ӈ :AimbO uZ6;m<41())xeQ"hvhks8#pPj?L~3[) AL법˗=xr=*T[E\}b=maD_(o@IFh"$I/Jئ.ym"ՙ6@&T0#vl4{n.r'k+xjw NbX NM,y#ad|0xEO+5^'58LpŁMI<8S.3٢֍ LL+nhQ7WR/Db"JI"߄guޢ-Z[rsIY#VnQ*ڬl, $b{W&ɗ\(= ,@Es*G@nd@ >T}'+="V/`UkF39>ˌ,jz_ejnھGJWa( |z4XrnSMAoaN̺_w7~|A)3c c2|DI{)XqWq|<%v'IX@JI5#;T7NB1l]{7Ԙ?E,魶. woDHP c}? Ӵ1A 2&pu5ˣh7~rd?&ZjxW]5s̾hY`@C6f-^T֮4Ĝy뷬s?]Eav4S: 5{ZHPb}Rd>Zjm6-+\BMPurZf]oӖ` N k ;Rly(zOB_~Ҩ !-5=6^ 01ƍb# E6!ԚcQJ$9t@d!/fg\W!BT,-F{)Mhk/ bIα:}Q2 'zXJ,:N& !fx [m'o;j}M(]N' '>SQ4Tm51F@{7UF L͎&X1 c,x.˙)AB<=ҋbqEIQ 7V%صѾ^EN}Zr45pBl37\d!Rb1(CDSCI܀ U>*/{ǯ؋,\y3`A71ҎDL ȹBLgՉxwBWz]9e#8`^# Lǰ$ȣQLCl/]t䲧[' +W_X}G*Dd3qVi ؆X!i_s?Y1ICm3W艸F@фa 5;_(EtqF#0f~;AQ`+fy?O ke˜t}:Y;s[obN\E?êq}С=QcRJڿ;I,Wql܅|p<%QԊ% csA@dX涐貕˥߻v޲و׬үl}DZf췧䐵ԗծƓĬټ᳻Ҳئ곿㛚Ɣ ?AP?????PK vSwlayers/.zgroupUT aaux { "zarr_format": 2 }PK vSlayers/sparse/UT aaux PK vSlayers/sparse/indices/UT aaux PKvS5layers/sparse/indices/.zarrayUT aaux uA0E̚tay cH-mh($Bmڍj__-5K|=M@ْ/e3+ŏKTB)ED:@281,#\[=Ęc@jCZCܠZpw1_YUR'FoPKvS (layers/sparse/indices/0UT aaux cb4f```a fb6 bf(_PKvS(Kslayers/sparse/.zattrsUT aaux RԼ̼tݒʂT%+Ē %4%eEřy Uzz0``.F`f,W-PK vSwlayers/sparse/.zgroupUT aaux { "zarr_format": 2 }PK vSlayers/sparse/indptr/UT aaux PKvS o 7layers/sparse/indptr/.zarrayUT aaux u0 wyfRucTJC"" A6A*woBOݜ~@֮k.=a>Ua۞%*x Iy+~\zxNa' NpSXk5OeR >\f @ pPzuq*JmZD'ATjK-oPKvS4alayers/sparse/indptr/0UT aaux cb4fa``܀H̄fCPK vSlayers/sparse/data/UT aaux PKvSs7layers/sparse/data/.zarrayUT aaux uA 0EBf-Ŗ]Q1Aq42c*޽IH*y̟ v 5}sx=('lIOp=)lըv"t K]-j~ao)wbn}5b1 ]A/Y ׀w+Q bp!Y>PK vS\@@layers/sparse/data/0UT aaux 300@+k)?{$)?0Ҹ?.2m?x5?,_w?PK vSw.zgroupUT aaux { "zarr_format": 2 }PK vSvar/UT aaux PK vSvar/cat_ordered/UT aaux PKvS&Ѥ7var/cat_ordered/.zarrayUT aaux u0E|5 qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "vk"cRtXsy&4b2t!.҆^CLX!C ~vg1oY)cGx-7PKvScYkr*..u"- km(PKvS]t#$ var/uint8/0UT aaux cb4fa``a *ؠ{??yِ+.PKvS Q} var/.zattrsUT aaux ]A @ E̺RJR& hR(-Ż;RĬC##};.E4Ls2 Iőrނء>IW8F~} Q퍁gNUqPK vSw var/.zgroupUT aaux { "zarr_format": 2 }PK vS var/float64/UT aaux PKvS1+\9var/float64/.zarrayUT aaux uA 0EBf-J tc"iLPwo꦳Jo>$u}pMEt)fCZ~ȖY9g&xSyX1Vk xK5sӗnI  |"FhRܿ\,JmӊdPK vS(ל var/float64/0UT aaux 3?:z?ɮ?ˍ?͛_U??%?;O b?Tx(8?G'w47?$?dd?9|?P.r8?@??;f`?'䕐?@N#?w?PK vSvar/__categories/UT aaux PK vSvar/__categories/cat_ordered/UT aaux PKvSq(Zk$var/__categories/cat_ordered/.zarrayUT aaux uM01Cjia?$ݶPΪuIj hmV5}g$SJHWPV5m"ZXİ'NgxS9hG)cU==\J?sr b945Xh?/v]z!+P{aBvD[)YPK vSI$var/__categories/cat_ordered/.zattrsUT aaux { "ordered": true }PKvS~8_var/__categories/cat_ordered/0UT aaux cb4fg``x bF v D"BAD$H "D䀈<Q"PK vSwvar/__categories/.zgroupUT aaux { "zarr_format": 2 }PK vSvar/__categories/var_cat/UT aaux PKvS)k var/__categories/var_cat/.zarrayUT aaux uM0$Cjia?$ݶPΪuIj hmV5}"g$SJHWPV586N -<ǰ'N'xS9hG)cU=8z/~~r$h"j&6Y0_`.BVo&"eɅ숶"YPK vS" var/__categories/var_cat/.zattrsUT aaux { "ordered": false }PKvS1Z7>xvar/__categories/var_cat/0UT aaux K @PM$ 7 +Yd{/ɓTQN4kѪMN]PK vS var/int64/UT aaux PKvS9 7var/int64/.zarrayUT aaux uA 0EBvaR1J4&$B$m6Ux&z7 .=4u|>dqFId[bO0b JEN| "2}SppOt5ǘ>') .\vHVb59 v2`.<-rVZSoPKvS?8=K var/int64/0UT aaux cb4X@,@_g—Ց~ N)G:g@y#!PK vS var/var_cat/UT aaux PKvS&Ѥ7var/var_cat/.zarrayUT aaux u0E|5 qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "vk"cRtXsy&4b2t!.҆^CLX!C ~vg1oY)cGx-7PKvSAXK",var/var_cat/.zattrsUT aaux RĒb%+x_,UPKvSW,"$ var/var_cat/0UT aaux cb4fa``a f`afgc`dPK vS var/_index/UT aaux PKvS)kvar/_index/.zarrayUT aaux uM0$Cjia?$ݶPΪuIj hmV5}"g$SJHWPV586N -<ǰ'N'xS9hG)cU=8z/~~r$h"j&6Y0_`.BVo&"eɅ숶"YPKvS!bH\ var/_index/0UT aaux 5;@@E;R2oTV#:e؊5XSL5HIoWNvlFJ8 /FCzD+k*Ԗy )T /PK vSobs/UT aaux PK vSobs/cat_ordered/UT aaux PKvSL\7obs/cat_ordered/.zarrayUT aaux u E~ cm?i EQ&B[6ཛau|ޫdq&a&om_ `*<(s81 2}96Bns1/$Bl.;$@+(b:Jkz_Q+5zPoPKvScBiHdBQfwr?BnI\[ѰÄȢ%`UOݪ=.W S8ʁEI$k(R)P[9/LP/\rY#@%K y˯69{oPKvS&c-. obs/uint8/0UT aaux cb4fc``a= ~i3O;?M-a,oܮѭr#PKvS} obs/.zattrsUT aaux ]A @ E=dmK*"C2&2M"HiV?. /̽C'Fk9{<"2&q Vq$!߷5W (ac#>LR]PK vS obs/obs_cat/UT aaux PKvSL\7obs/obs_cat/.zarrayUT aaux u E~ cm?i EQ&B[6ཛau|ޫdq&a&om_ `*<(s81 2}96Bns1/$Bl.;$@+(b:Jkz_Q+5zPoPKvS8e",obs/obs_cat/.zattrsUT aaux RĒb%+x_??UPKvS’,. obs/obs_cat/0UT aaux cb4fc``a= bffdaeefgPK vSw obs/.zgroupUT aaux { "zarr_format": 2 }PK vS obs/float64/UT aaux PKvS% '9obs/float64/.zarrayUT aaux u 0E~Z}A)3J4&(FfPo꦳Jg.s$u]pM瘇3i{R̆5,x דr8V.Q ].("8ޝV˲NVh1|o&eKɐD^b2;l Y;'K?ޭ?????P?????PK vSobs/__categories/UT aaux PK vSobs/__categories/cat_ordered/UT aaux PKvS_Ω}k$obs/__categories/cat_ordered/.zarrayUT aaux uA0E%Cjiqdڒrw,UwI xeۗKzw_ylV#"[9G tÖ5^8!uyueD4{)ͻ c>YF,z6Z6(ڝ5 KwڤWT bQ!58YL_PK vSI$obs/__categories/cat_ordered/.zattrsUT aaux { "ordered": true }PKvS娦!Cobs/__categories/cat_ordered/0UT aaux  D@]EhD>H΋ MUn1S*OVf1ըUz 5i֢Uvԥ[?_PK vSobs/__categories/obs_cat/UT aaux PKvS_Ω}k obs/__categories/obs_cat/.zarrayUT aaux uA0E%Cjiqdڒrw,UwI xeۗKzw_ylV#"[9G tÖ5^8!uyueD4{)ͻ c>YF,z6Z6(ڝ5 KwڤWT bQ!58YL_PK vS" obs/__categories/obs_cat/.zattrsUT aaux { "ordered": false }PKvSP"}Cobs/__categories/obs_cat/0UT aaux 1D@]rB!DqJ/Sd1G'$1)TRF:}kШIjӮS_PK vSwobs/__categories/.zgroupUT aaux { "zarr_format": 2 }PK vS obs/int64/UT aaux PKvS^/7obs/int64/.zarrayUT aaux u 0E~څ}A)3J4&!Tߛ iMg\Ex¥?͡G =LFXM֯OԼjՏsO9@"r`<ǶR"-"-{M1!脡^GLPVP }vmb 3 m_loPKvSJ_ obs/int64/0UT aaux cb4@,@_c%O\|7ynܗ{G@5 |(cfPK vS obs/_index/UT aaux PKvS8Vkobs/_index/.zarrayUT aaux uM 0BfmAJ4&(Fz&j\tV7/3eLRW@+5 #}p|fBdJ T㢂 ڨz`)2s ;z!pץn1V}>g9MC6j=lu;I/d|p?l R\Ȗh';$PKvSh~TLf obs/_index/0UT aaux }; 0በul`+VFr'aM'=EvxI +K -XAO1L.FD+j%H Yi&%wn"yPK vSvarp/UT aaux PK vS varp/array/UT aaux PKvSjᩪQvarp/array/.zarrayUT aaux  0 {9D"v-VIց{wRGsJ>'ɚ@v-̾Hދ6Ó!X*x=wr8qD5+tQ ^ ]㡋bbn K3!OQCg6,+xR-bπͭbD64Yl{PK vSL0 0 varp/array/0.0UT aaux ! 0 pTЊȀ|%~H)@Bfp pٶo@T%pPwh6^>Ds"YBzs\F&p2 Dzt' vP6P$O4%jNo CJx|N@9F d0QD{V5~|#0z(>?zhpІ `؋.{6H>0$( T0^(lRQߎv`'jxW ~F>Lp4X?nHx0rt\3HTDrt B.$"$#nЙ;ưٌ .⁘ߠ;oH!0NMdafw cg7 mJhI{G!w.pȗ2+ojGJXtM+N48OYI +v}- #i4Wzۃ3 o[JvDt cODݴ‹);lj,d23rsu>arFx B^4js5EN!@F :b\(~<:ϗs ʁMyxEb+n h˲avRgu+09߸ȕQF J;&ғlyP\2f uAev{Rm-O*+0na 6u^Yq5aYx0u5|eR.BQZTEndN*%LR7k=Q=E%'FvKIA@w*Zpew@Q޼j鞊@lYs!i{dk1;/R c6JGœozL2'J1K=FVSuw#$*ǜ`SؗdE#_"4iBM~r@qPC$P䝦rؖ7sL߷\Ŋz]UMq3*^{",F24Vk+\65+Eƚ^J[2U?S,|}nb.Cȓ%H-qW_Aڮ&k-Px`y<靐Hq,o(XzpCk KRTR#;6E <5w!\Ǹhs +)k$]Ol>6OBD*X,ʖLaCÛta߻X*jyU)a~olv6_w+0'tL5<;(icBFTc:' ~C\ڍ|%Uα9#cjAVK0qf,.͐f@%w(!ld3>LKκPO# do}O^SfH Y54 C:WѐC:4~kB_W bKtJͅ"|閪ҏ 4_c:ijʒZ,[/MZa0o @$ Ԗ ņm(L "Ӳ8 xyi$_Izw~[7HВR̴-oYm5ry4$aٶz8?xWTirb7hZ:՞΍Fϰ-,`l:oM=e`F}I] S=~sN 8UH;e97+li|! /mMDo7X8M{$ߚT&dnQ@3a)gβ([H',V EN.WƮݏqo/v4-ƈT?-ׄ<45;QhUڭ*NޮQbŪ'5Ԙ1xZ aBmxOcԳ5nMlTL{u !xZvz?}G2 *Im8ƄexjxCc\F|LQ &Ԕ,s>Xi01UjBmIzN¥L"b Ġɨ'T#LYO3?bgࡉ.0лtKɷM=#Y,:@ZL\ W-},Wޮk!l P.I/It:砝-.- UW7ƍpo,8f[|o 78"GJo$(.IO̞+S{x/(nuNv3Bh_PDzpѣȾߊƨֳtʵհ߳| ?xP?????PK vSw varp/.zgroupUT aaux { "zarr_format": 2 }PK vS varp/sparse/UT aaux PK vSvarp/sparse/indices/UT aaux PKvSSP5varp/sparse/indices/.zarrayUT aaux uA 0EBf-vS1J4&Lݛ fY%7kup˟S׫ +lYT%Iacp)!fLk zs7G hXM}rQ=-ՊKuy/Җ:1mPKvS‡s varp/sparse/indices/0UT aaux cb4f```a bV fdPKvSrLIsvarp/sparse/.zattrsUT aaux RԼ̼tݒʂT%+Ē %4%eEřy Uzz0``. XZPK vSwvarp/sparse/.zgroupUT aaux { "zarr_format": 2 }PK vSvarp/sparse/indptr/UT aaux PKvS.7varp/sparse/indptr/.zarrayUT aaux u0D|3%z1 cH-mhX(R! ڋ{},Yd%Ǜ$u}pMg 9iIB#[nfpm\:x^t^ Ecj|!/ @9bEP| !_YP'FgPKvSʖq/0varp/sparse/data/0UT aaux cb4P```a 9 T.Ͱw(oPK vSvarm/UT aaux PK vSvarm/df/UT aaux PK vS varm/df/cat/UT aaux PKvS&Ѥ7varm/df/cat/.zarrayUT aaux u0E|5 qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "vk"cRtXsy&4b2t!.҆^CLX!C ~vg1oY)cGx-7PKvS?(varm/df/cat/.zattrsUT aaux RĒb%+x_TPKvSE"$ varm/df/cat/0UT aaux cb4fa``a fbcafcdecbPK vSvarm/df/cat_ordered/UT aaux PKvS&Ѥ7varm/df/cat_ordered/.zarrayUT aaux u0E|5 qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "vk"cRtXsy&4b2t!.҆^CLX!C ~vg1oY)cGx-7PKvScYkr*..u"- km(PKvS_#$varm/df/uint8/0UT aaux cb4fa``a ^xnك }{^}PKvS#vvarm/df/.zattrsUT aaux R̼ %+8K"S_ZBdK`xd̼3dD4R" %0?jmj^r~Jf^nIeA*U)%iEJ*R3@ jPK vSwvarm/df/.zgroupUT aaux { "zarr_format": 2 }PK vSvarm/df/float64/UT aaux PKvS1+\9varm/df/float64/.zarrayUT aaux uA 0EBf-J tc"iLPwo꦳Jo>$u}pMEt)fCZ~ȖY9g&xSyX1Vk xK5sӗnI  |"FhRܿ\,JmӊdPK vS,varm/df/float64/0UT aaux 3,?&?pX5?u'8? q>?!T]?hr8d?!O?) r?n?` C ?вk?R@?L7#?0G?dqFId[bO0b JEN| "2}SppOt5ǘ>') .\vHVb59 v2`.<-rVZSoPKvSr 9Kvarm/df/int64/0UT aaux cb4X@,@_/cxk(sN$ CBPK vSvarm/df/_index/UT aaux PKvS)kvarm/df/_index/.zarrayUT aaux uM0$Cjia?$ݶPΪuIj hmV5}"g$SJHWPV586N -<ǰ'N'xS9hG)cU=8z/~~r$h"j&6Y0_`.BVo&"eɅ숶"YPKvS!bH\varm/df/_index/0UT aaux 5;@@E;R2oTV#:e؊5XSL5HIoWNvlFJ8 /FCzD+k*Ԗy )T /PK vS varm/array/UT aaux PKvSrmYQvarm/array/.zarrayUT aaux  0 >,Ʉ1vc (F+kKz\N$ْ֌=-}UgG_澭Väֱ*x=u8rG'\/qF1FJt{إߓkЃ녡vW1@΀M-beZRR$PKvSvarm/array/0.0UT aaux g< +BCJ Q!$2BBB&+[RI*[Vru0s>qkY@ PK}+UZUf5*]&u&Qm.T/4_6xZ=WuU곝7pZ9ԃ r@@gv n1Vo[0mTt#^k'P *=4?:)aMdGԦ`HKnm֤]25D2HißoiF>5gꧩ[mt(ި77|* P#L{\Kx)0ݢf2g#X",—5 ?̩,+Y:z]\2.+Y81*Mp8P_wڏMr_ri8f WXN_304K|VQ=!]1;舘d"aϯ . >.Hq0*Du9Ӟ&77SKKJM|$䞝>||iSeUF8 ”Ia)6]#.ԫ Gۚ4֌eU)fU͠q\Ab^ɁU /(;t%}Colsͪ>h;[xu V}r|Q@1DyMueF۪}IAdKIX2#yK_?F9H T'w;6L%5KF±Upb~[s1YN?Z5z)yA}{y>ݨ0YԑªX&kMy;Ow_:vdZ3A 2x~FoX+=а )2&M0^bqj3nKʎNn韛)?<{$17^2C5y:h<2|M껞[ќov0g AR/y35_cps:ˡnt,j,~m\B-d- 0Z 6`G)3CJy3PTm'-v豕uWτ4KdoBtGcK3Ʀ|ȶuDJ:~zG]ZU)@eC5su?%]q-{6+ib8 QMSi,ýu [D~ VYT*]Q~I]Q\Eo.##X,w1rmՍY3@/gDG~ 1r,bӪ=̬EC{]h':1+]=ˢ/M8O0hr4!tmJB>(xuq,%x[XF[ƑGedgoLN~we<<[uku@ %{;y@%ͫyySX! 'O]vX`e!Y2]^XF)^'6p; υncĞڒ?e_6(a&kZ?Bo.q WN,z,s=FHΤ͏XiyMJr<9wEK?ocS"/֨-Y.2 Ӊ~~(P u~~ftff9xtNۄ4I Q-T&ul&ej?&~}f뻫{\Oa۷f=k* c<}ѽІKV$iTӗ<(CUp H]mӬ!Uu_6*qW^} ZWƲ9xTM1RQUPy,6{/`@4דfL. hT8ɦ\o-ɞٰ=;N:e^L"N%Zh*'WKTY0iQecĶPR:k%C _NvI{0j4sC_g87vfyy>S%gXN;yinۺ#_o+|敂DCf> K¨%5T[d!u 7?(-h&[|LŸ#_wϛkL 7t4ˢ)b Xm'`Cm?="k~ZjV;|G;|6əťY/Vx~ujxfœ?[uun *rʯYDհ#y)聼I6-M.y ߜb>P7z tihRRC]'yeVcvzvC7;H8HxɏQeC֭JW*Fڼk7npk&>iwdӕєYlܯɯMHѽeHyB<6lo,*1&s/ɾA%.8Cwr9ˑm /nG k7s\Η>a)=U]rFM Tv3ad|RXgkIi<|eA,$. Us<׿곉6]]{0#.ΫVQY{O>.-CAԪtʵ+V+'/Df㋼gg[ Bf""66ª<$6>.<-K҅ՖeI|#ɸx~Ou솭P+gQ[\˄ p@dE֬1>Z_+p=o;aeNz3Qǫ[Sfz~U\%r]өP?!r5~ju<wrӑ&GҗycʦԖ'uzNj!.mh/ NЭzkHJ%(Oijpyxkszfeu,&U}.7[,:hn/mJP}}tIߞ[l =gtYUS@oa^LkHYˍ24ɂ|V7[e3Vqh&Ժ9o߼й5.p`{z/ 5ZlPA{SN#k^pK)6Rl/OQ=*QZZ 39#^;Q=T{A!^|qʸfˑ]sی?\&k{7-.cb2: @O_UN|5Wt_ԏLWBls}ck̳iᭋybn)j+ l{+ܺso7_ENEi?-oڤST|2_{rGOũ_`mJ;(P@H.YIzJl?xflq֒Wkz\HYjj/HOy<7։G7/o-%>E(dckK]հlBi;&\.'i5Ҡgy=vmò nLBu%# ~u4 `R~*KǦjnoÏFzz&;s5pN[V4. qִsaޙ MKWHo@"u 2f=B5=Gģb$o3!?|/,Qng:e; r-_9/tZwn6M\OGENGwks:(vkN>^iW8{a(!#'Y{aWȣgGm\5.A@*oYA^(@y8wm*c&;g{~%vϹm7h'c0\p}phHgWrwFci^M9ZCPa>2w5#, ʻ]# J/V5ۖ4!|S~=L›Z$ W;B K&~D5Z*V24?6.'9藙{ M) 鵧8y47θU1/LpCX6%;n%\ fy&',q~; Ty ]R}P[;! NR<^y? :n hU}hVnSvpY~-BJ~JLK /~.@9bǺeַ-ʹ>+,ExOX;OZ{Ht_F +*٬~\B3%~*PVq7{ҫ-$/}? wΝ3Jڏ_)<:kb\`1>^_X}ڕxA35M=DYhE3lO{:dАvπm#<Ϗk;u` BJ'}wmj#Xv9ާtr;aQ5wnEBSרj۾vo Cs撞>ķt%I`5IuهAjy /߬x&ǶdJv17E]sJG~I΁+JqH)f-re{{~+; /??n:I;TiU–` :ం. w>S5{{ jIw[;"bIZS*J'k+qWflGfmYKR!0nG('BƬ{af$Z#BQ3o'f;MFˮ%VxK=r|qr׉l`]UƟNᇙQ/goIͿ::@b h'O_YW(-B3O(7?N5qٻ5m2jS{*r Ӌ2)ȿ' <(zO&rKvf:ʄx2iZDQW@4ADa9A:gvv&A-1cdÉop]] fHaBX$$DQ7 E汁Il$!T")A %bWa6lgNgٌU j P"OA_}A5, һK,1D0.wo`8F&zMn09EARa"Rh,5YK{p,>(iE쥒P3X<: &Á\?Ɔ)E*{ 9QI( lJZ"lj&d1D108 BmgPhS=OցH ayH_An0IS V(U %m0A D,y Ģi$";!!p:GYaLpL I &÷OpF8l2NӨ4q~uKɌ <@.&Рi0dќƎ! zlEb(U 5ƢІt6yϜ@d$zAǖ>1x EEщ$m~J"O#Fid sj5a{ad, mdzV No с,:nơϬ@86 d3p,t!ã(!l tSp2J{ lf h* D b JWPfBQU CQzSTE6eD43Mg0p V!$`<̱c ?1hPK vSw varm/.zgroupUT aaux { "zarr_format": 2 }PK vS varm/sparse/UT aaux PK vSvarm/sparse/indices/UT aaux PKvS17varm/sparse/indices/.zarrayUT aaux uA 0EBvaݔzR$ G#3Fݛ fY%7kCG?⟧*YGD~-:,~\5xI {,gp0 QRL7xL1#g >9ᤑ{ `%إVƼXVePKvSfz*::`varm/sparse/indices/0UT aaux -ʱ @`eyvUDNnNMFI🃋PKvSƿLtvarm/sparse/.zattrsUT aaux RԼ̼tݒʂT%+Ē %4%eEřy Uzz0``.ن`v,W-PK vSwvarm/sparse/.zgroupUT aaux { "zarr_format": 2 }PK vSvarm/sparse/indptr/UT aaux PKvS.7varm/sparse/indptr/.zarrayUT aaux u0D|3%z1 cH-mhX(R! ڋ{},Yd%Ǜ$u}pMEt)fCZ~ȖY9g&xSyX1Vk xK5sӗnI  |"FhRܿ\,JmӊdPK vS1ְvarm/sparse/data/0UT aaux 3\%5t=?k##?,0?x'x3v??f$?95?,~<x?ڬ/?|cB?>^d? }?8 ?Dݣ? VW(B?\.?Ю.?FL-??n?1ި7?PK vSuns/UT aaux PK vSw uns/.zgroupUT aaux { "zarr_format": 2 }PK vSuns/O_recarray/UT aaux PKvSۓuns/O_recarray/.zarrayUT aaux O @}سDKDE!$6wMqteW {9xox0Le!/ȓHgU[ר,N9qz5*tE",ĦrhAAy&8VB:<$ Ub0Ӱ^ضmKg˳_i(#!ǑT8Hapoo~p+0 2%9@ 'T|d"K޲Ĝ|cI5PKvS%EIuns/O_recarray/0UT aaux 5ˮP&4M:hIۜϨrQdonn@.3q ",ZJVe_٣W[V*{O;nND:f$qҸR]x~ŊZ#z& bN(^̝׍DW3v?qLn`tAXúr$h侥FNE#'?;.>LHB9\)jo_$RNgS1WR3_ǩq18dd*-ZfUVp#;cBוJ%hل γp6d7)"p cB3k)=yCp@+m++HKSDS?'R,??8Oմ=sncHs5y],?WPK vSraw/UT aaux PKvSR8Dl raw/.zattrsUT aaux RԼ̼tݒʂT%+r%4Ԣ<LAqF"XS4 :pU PK vSw raw/.zgroupUT aaux { "zarr_format": 2 }PK vSraw/var/UT aaux PK vSraw/var/cat_ordered/UT aaux PKvSz AI7raw/var/cat_ordered/.zarrayUT aaux u0E|5 0qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "BƵG1N) MGE,9Ƽt|P[iC!&h\?ԳƬ픱#[v*7PKvScIW8F~} Q퍁gNUqPK vSwraw/var/.zgroupUT aaux { "zarr_format": 2 }PK vSraw/var/float64/UT aaux PKvS ՟9raw/var/float64/.zarrayUT aaux uA 0EBf- tc"iLPwo꦳Jo>$u}pMEt)fCZ~ȖY9g&vdqFi`m_=U\W?.@.S]8N"<@;I%k{_9b XoPKvS=r~raw/var/int64/0UT aaux cb4p`d`: NZ_|{B7q÷wyϽK2/}!Ci. !AbR  ,   PK vSraw/var/var_cat/UT aaux PKvSz AI7raw/var/var_cat/.zarrayUT aaux u0E|5 0qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "BƵG1N) MGE,9Ƽt|P[iC!&h\?ԳƬ픱#[v*7PKvSAXK",raw/var/var_cat/.zattrsUT aaux RĒb%+x_,UPKvS(68raw/var/var_cat/0UT aaux cb4f```a ace``df`eb`fdgPK vSraw/var/_index/UT aaux PKvSYzkraw/var/_index/.zarrayUT aaux uM04Cjia?$ݶPΪuIj hmV5}"g$SJHWPV586N -<ǰ'ND =\J?sr b945Xh?/v]z!+P{aBvD[)YPKvS6kraw/var/_index/0UT aaux ;@@3D#1cT6CtbU:˰ pw'JԮ@IwZ.RKFJtC!@.D6 SG 5dzEg?ou6>PK vS raw/varm/UT aaux PK vS raw/varm/df/UT aaux PK vSraw/varm/df/cat/UT aaux PKvSz AI7raw/varm/df/cat/.zarrayUT aaux u0E|5 0qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "BƵG1N) MGE,9Ƽt|P[iC!&h\?ԳƬ픱#[v*7PKvS?(raw/varm/df/cat/.zattrsUT aaux RĒb%+x_TPKvS$68raw/varm/df/cat/0UT aaux cb4f```a ffeaedgcfdgbebgPK vSraw/varm/df/cat_ordered/UT aaux PKvSz AI7raw/varm/df/cat_ordered/.zarrayUT aaux u0E|5 0qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "BƵG1N) MGE,9Ƽt|P[iC!&h\?ԳƬ픱#[v*7PKvSc$u}pMEt)fCZ~ȖY9g&vLcm֣#`4/`.BVo&"eɅ숶dqFi`m_=U\W?.@.S]8N"<@;I%k{_9b XoPKvS"jWsraw/varm/df/int64/0UT aaux cb4p`d`b o{?Iי?ԻAG{,Z|[)}^f D 2,h03PK vSraw/varm/df/_index/UT aaux PKvSYzkraw/varm/df/_index/.zarrayUT aaux uM04Cjia?$ݶPΪuIj hmV5}"g$SJHWPV586N -<ǰ'ND =\J?sr b945Xh?/v]z!+P{aBvD[)YPKvS6kraw/varm/df/_index/0UT aaux ;@@3D#1cT6CtbU:˰ pw'JԮ@IwZ.RKFJtC!@.D6 SG 5dzEg?ou6>PK vSraw/varm/array/UT aaux PKvSQraw/varm/array/.zarrayUT aaux  0 >, 1vc (F+kKz\N$ْ֌=-}UgG_澭Väֱ*x=u8rG'\ x8hǘZ#%:=5AvENB+Gҍpg릖Ŀ^2kfk$PKvSƮu66raw/varm/array/0.0UT aaux %w8 EvNB%Dve&DIY2222 IB9q~u}?_qXc $@jc%Xoz2D M:bFn.%Fu2$E~Ɔ9EE;^M5ϑspx-dnMlyo(dvFz邭U\7;1ߥl8i +QMV=󷔍f#ɞ48)ƌ TB5rl?4Kmb 엑h>ipِ̾b`pgDs줓o\=_7دqh | !@7AI PќU;÷eN`:09q2荽Ƥ=lɦ5--w[:T0#:NLeUn* z˩fzxZlsu 2;|.ˢl}$K;SrOrQɨi'Og1Yq}OI<r'p}̕*cH 7NMq fo`Ә͛ BS3/cT{R2^L$4bhWվ im1-V*3FgǑOA?YeN{2sB@z -fl,{nkx=;QŞ h_W-7 3c:NKxKkj#Jc|% ]kf-ߩjY /@Nt1COu>+ӂǺmdq[_]2N!Zr- 2b0.NÒ&e8Sڙ*ř8׋ 6KL}NeQ1uJ.1'r=[z`9똭|!/Jdl7?_"^D(O&X9T$ ?ё'],j#_zd]p̤G5೘|_Uv݃˖RC5>5JN6!'o@Zw/PrA׽ƈ[LL;@7aK3MkVQ|V0_\HaYղR>n?0YpC%{0F9+1ve.k''9wa[rld8RCX7 IqPV{$pZML^P̨XURYT].KqdH^˜u!ȍN 6Q*Aƛ<2,-X 1lØ3z$ᖩqH(Qv,szhf OGׇ}Y.NPncQ`O q5O7焘V~ $JFN ;W',.eBÉ$O'!SsawµyCo#ztٸ́FYjhjMBd!>JF 6*s+MnaGQ NV@%ݲG9tÝYkjdO9!͹təM7'2sR#W[1s[2@HwpIp2tJ-*BYֳ/$sޚbRlXoȀֹ_YYgxg!RB}k2+nͱ[%UM  balf.df~Ä0sHxSෙ s.@124QN?% P CIja>H_ʈ 0`<Q|ȝ-N_%=zC1辔!b{ Cj.?{Hz1z珣SŰ[A^ӿO C\}NKv*TvC}\ǿbcS:~\4ij"CV{zCӴnjDw~TuE~rSy+KEGMXuEw0'U ɖғ*8N2KC?Ҫ  $b<5!ҝDғw eϫ3@)ΙW𛸽v'N[MU\lSn۩y+#}5h. ~L`wpySH-9+;m-|Jxa{ӛ 7 xD+Ž_aT^sM.%FQ|7@`p /^c*&"lrg>gub>|X#9:2 O{2t={3qwD5]jw57=:`xK%W#dхMr͵ێ6fp&cZ|ﭰnM>{n;8A|3%}Y fBմ%3? fK|/RP7]CE?7 {=wqW"̂O_%+xmD*3 fsǜ J&XQ'*sv(1Ş7cDe[{;WMq|m2҃z lT]7Z#_%efcD${_]>Jr43hbʉ̩n\}+/{EGCsZ>?S;mIh~}-۫:~jIBSx6źóY^n-{F*Mѳ. dzl"g}ZN7XqS U(SS,Z#u)BE _ (OPlZD(,̑)8u:Es! [VAHO?}JqbȪ!ʽʒ)Ʒ>erav̅1.4?<4O >y_\JP^\Kd庺Tx'/#$;A#yxz[t's!rqqΉ2:ߞtpo+z;|WVu):jK0mqm;7&6fMӾ;%CB^N!8ByHt!E@4FV|yi=ajCܠttnS֩breggk;jQMg.:N<0[f )Im")^^&˫`wGkvM? q!-S7V;ȴ,xv#˪YZB'vMnV8L(#Gی%bt҈K߃Ǣ\-We)Ac\#"D%> /":\,xp?ɥ M*M0R\j-GW킖c  w"Jˁv <ד83=bANfgMS戕[9t|H*s ґZo=(jU 4vIB|yllzC2{^*8]ѱ4 v+Tw?!~"Qު'ţ|pdL:uө=.%#(7}lcSR[b޴m|Ol**2/ju`x0.'}M$şY/WrYcjvR#-03q_N2ܩm x\xA:&Zk78#tm `_:pyȭsUB^ӺKDju4q/uG9 NFv]7~j!91Eq҅  z^C |ϱ"cO}6A uA@}&G[Uj|{F.z.eo'N% JX_,&+y! 3ַS#"F ;YdSeKH?`>]xj(|Ҏ#щDޑ= ˊzPO?d]tzG%LmW#YqA~9O+Þ+L:E'Kmk{XDzr<5g<; -i2Z2@M\^eb~_B9qd S%;HHWV[i_4cB4k!eѻpՍ%5h>j Hrz-O[&\w5~h\ ;D6yji זU,:w(+,~#vZ) g`miq2bpznJs&]n .'m+*w]-*i=r!r.o=tuwo2˚կb- P\wR!@۰ׁ\v;jPA"_%(V1&R]&"Op~XԽf t~r~xlaUغA.n6#Ⱥ,J?l L6+td|S4،hYyz^ Oj5-4~1[Jnal0؈^PxTvfHo>=W㞚u!k$YoNbV ʝ8fl>yj[Jm6'' 4"&**ΈxQ\c[Z_!E"b|Vmqa@ʙM@K4/~ HͱؗTQOԽ6q:,ѦP.KZ];jBh~!FmD8IB"_YUHYHu(S[j/%T5|Wn`=&_~ j3kX:#IվhMl؏)hWV{E`[x9(jzӥcrHݰ;fk &>GoY>_UZ`yʿL1x>ٶQ Px7XWUaYټff]wrV[̣I^Rio,ߩLcoTegn닌H?~n튽 rۅ^}xPQʺhMia{vۄ;쨌% R)r"Տ?WjaU`n{3#4KRLUJ߈~"S''zCO_l[>c1˳:u+dtG?E T2YkߺP>;ĵ"uZ¶Ⴡ}ö r]\]gj BFbod䁇3`ȅĥ>}=#96GO{ A"tÀeߕ{V C7/}=rigS4#`%8sCG7cB"JjS|iVkKgIqXFLmZ~$oʱg W{4yǰxxS诓8u>pju^ 6YOwŦd׫+&--ׇN%. rS{}DCE!/Y&no;'vEwPW:[q]G@Rޣ|vd@cGLCeZ1C^c޸njK/i`yBIt~*-*r ) |ʐp{0~37 'c*]bR/*k[P]ޣWoۼE]ySrþۣ>L3;EAyKN}_sz9 ]qO|LZwo3L2z](Ƞ3GDb&it$.'}oMBǻ x0&|;%NyX]R@פG5S m[)ym>EhvmUV=̏;6z+͑x"s ڹ|e9Y*l QƔգFӤ”߉ nZMG~Lq* O}}+*` ]fHnݥrlE&:1g(ú;%v[z)هl2OLSCkNŹ|,^l&}p_~r~B\FTnC{'_y* "s]D㽤Aliy^>|y=~+4EDHPlC{(qvtcT/ A {۞3 *X2,io f(<3ћ0<=G(/ȢO+LF,/VU!:o"ay̼ݼلW v{&//?Sf )ٔ}~Ǧh4Kn7Dapˉ?v~l׹j+AߩBs6p2=~瘮d>d y׳g&cU Z, ^%>rt5 nU[ Q@fU?+@〛ꚑM{G4nkqMuzx5a va{Mˤ,%Fk3ؼ(o1hw$ 1eNě⋖!e3E![J'ؿe&tҽ R찵;^r`KD u'wN0a%:Bm Hs.4YHַm.kQ%18eإX5\!PCqC9$k|8Um6 d{ 3~ͩ|R=ިi2WR(6+2:U5~X0a.w`=~P.H* abVG?$ L+Ӱ,0-R#.bxunx|YTc#c+:ܔ&&0 b@!uқIg #.׍W鮊|&G#F^"xd$nUpBܭݔ=mgn|G8m?Op=ST2cU0(A[]q<|j>G-ɨEc|rjp*2#5(?X ^ -5t|v #.U8hk)sޛ 3FIKRYV4w6ʬUXu[WT+$r1b}lzNn>_Ia~n XbvWZz56>)XbiDuXbMV]痋>5F{r9ʀ|3He>+fҪWX|Oq!} !6W wη)a7N.ԟ-!sA~wLOnFEI4k&͆iGr-I K~ڥ)PW~/C>srDC%? ZHЮ?>7ؠ^`Ȩ+3-lkMbfAyWkZU7-HKWIOҎC_3Zs}*%+7~7B|pq!5O5ȸ%Վ7nǟ٨?|;O=FUb8l/-v%3S4oD+{;+Yk(&ff.t-׊GzA.M8F|;OwkUJ g T!KJ{P帏\- }9}D;_ik;Ɵl*mGuG;LvWr֖ }ӟvBR?phb/@ɢw9R匶2چ}m>V}.E'}/9F,2* 5yjͲٞ-_(4+D?\­ݯ}#ڪ_;Ӯk/y͋ŗJ }_>۳f 5&k׽瘓5 ] [1,9nTSL{.И-3b#.Eojb~ruSuTT<1J~WSߣuk$zvbi"nmR P]07.ϯ}봓Swi'V= ՜bjY,[7} *~w^Kgb W/M[$'g2M.'B?b,n( b'TU ,YW?̨+X7KÅD{$3W9 xR,]]auhKs)-[?{֒j!o8G8Pk`.U:5ߝJ:ڟ72B_/H_xܩB؃-{߻."\C~f}TALD~ ƭ5?z+9ՂUL>(FJ8Ġܴ+b-zs$,)޸~: c qʶABҏt0A] ܆;2q\zQp6!¿F" =k/*c>;Lp7:ybm2~aj|`qՍí1Vj'<.g?,|ZzM35~M{ghYiM5^)v5yhlfJ$!{F?' %Ϳ_nʾӬF.p|KǭB_)a8pgZ=b` .gx/Rz]qzpZKP`ڄㅘU%Tx\Tڻ[IasiΑ.xxׇZOcTr8D}&뙿8_6/MVUK01JiIhNiB't= E.SP/iD)E|"!X$A%DH!-Sj$%BPd <CQ$DB#w+xT;="@ D"* BR*9^tHE<] \0d*l lt#;hE@x6z8EMcڐS$lRhe28 b R##id YwJFRuy7CRVы F%-Ԡhu(Au 8.) -'W),Z1VL9(RKQE>N5/,DҊEE>0X PKvSpuLtraw/varm/sparse/.zattrsUT aaux RԼ̼tݒʂT%+Ē %4%eEřy Uzz0``.ن`v,W-PK vSwraw/varm/sparse/.zgroupUT aaux { "zarr_format": 2 }PK vSraw/varm/sparse/indptr/UT aaux PKvSr.7raw/varm/sparse/indptr/.zarrayUT aaux u0D|35x1 cH-mhX(R! ڋ{},Yd%Ǜ$u}pMEt)fCZ~ȖY9g&vUa۞%*x Iy+~\zxNa' NpSXk5OeR >\f @ pPzuq*JmZD'ATjK-oPKvS$Uraw/X/indptr/0UT aaux %ñ @@DjZn0mp0 󉗼Tq/;2gɆ-G\x͇2iq;OPK vS raw/X/data/UT aaux PKvSy);raw/X/data/.zarrayUT aaux uA 0EBf- tc4&(FfPŻ7 ZtVgn@Vk+d4dULۓb6lisjT;zpC򈹲Z_8ç1ŀQ |"P| xn\$ m.PKvS6iK raw/X/data/0UT aaux m.CA336J*iZEHĢ;XH, [qHM;3g8T93 pRx)4q&t -~#Qw0&Ɛ q'b2ZEʕ^f:CAEx͇lkh,~Dᚰl옆x{!5}DmQۈ۔5;ֹQ];6FR1VUiJRB Vp9j{bݔc?Мjjш0iSu%({9} emqf3\ьύ+2Ia>E;A~Xae,e%]|`x3epXf{ PK vSX/UT aaux PK vS X/indices/UT aaux PKvS]}9X/indices/.zarrayUT aaux uA 0EBvJ tc"iL081BL6*y_ۚa"bj1 z|1fa,@5I! G\tXk.!.52o?K82[?PKvS) X/indices/0UT aaux }Q! Dsp'X}نLf?·d7"EE˓I6h(됆'Z!˥ևoBTotri< g8K<,#rHX`m"t;|b0az`d_C_W!8o,Q@$Swb{PKvS(Ks X/.zattrsUT aaux RԼ̼tݒʂT%+Ē %4%eEřy Uzz0``.F`f,W-PK vSw X/.zgroupUT aaux { "zarr_format": 2 }PK vS X/indptr/UT aaux PKvS o 7X/indptr/.zarrayUT aaux u0 wyfRucTJC"" A6A*woBOݜ~@֮k.=a>Ua۞%*x Iy+~\zxNa' NpSXk5OeR >\f @ pPzuq*JmZD'ATjK-oPKvSjs)U X/indptr/0UT aaux %áPf:3ZD@3Hbbc#d L%y !v/dԫ7>}w/PK vSX/data/UT aaux PKvSu@;X/data/.zarrayUT aaux uA 0EBf-J tc4&(FfPŻ7 ZtVgn@Vk+d-dULۓb6lisjT;zpץ.seFppYo)Obz]#@9;jE\Rܶ!IP+ePKvS1 2?X/data/0UT aaux eJQl&.l mH;HJ|<@o =0gι*[ @X |<苰B)s29:zE.8*2콙?gWşZ r6'@DEt hֈZKq㻜-s7,w"._=6R W-.?~PK vSAobsp/UTaux PK vS A?obsp/array/UTaux PKvS'ٹQobsp/array/.zarrayUTaux PKvSLkzobsp/array/0.0UTaux PK vSw obsp/.zgroupUTaux PK vS Aobsp/sparse/UTaux PK vSABobsp/sparse/indices/UTaux PKvS)a5obsp/sparse/indices/.zarrayUTaux PKvS"4obsp/sparse/indices/0UTaux PKvSCZlIsobsp/sparse/.zattrsUTaux PK vSwobsp/sparse/.zgroupUTaux PK vSAobsp/sparse/indptr/UTaux PKvS o 7Bobsp/sparse/indptr/.zarrayUTaux PKvS,&:obsp/sparse/indptr/0UTaux PK vSAobsp/sparse/data/UTaux PKvSXj 7obsp/sparse/data/.zarrayUTaux PK vSXX obsp/sparse/data/0UTaux PK vSA!obsm/UTaux PK vSA!obsm/df/UTaux PK vS A"obsm/df/cat/UTaux PKvSL\7\"obsm/df/cat/.zarrayUTaux PKvS?(M#obsm/df/cat/.zattrsUTaux PKvS}w,. #obsm/df/cat/0UTaux PK vSA,$obsm/df/cat_ordered/UTaux PKvSL\7z$obsm/df/cat_ordered/.zarrayUTaux PKvSc<dobsm/sparse/indptr/0UTaux PK vSANeobsm/sparse/data/UTaux PKvS% '9eobsm/sparse/data/.zarrayUTaux PK vS fobsm/sparse/data/0UTaux PK vSAglayers/UTaux PK vS Ahlayers/array/UTaux PKvSY+Q`hlayers/array/.zarrayUTaux PK vS Zilayers/array/0.0UTaux PK vSwMzlayers/.zgroupUTaux PK vSAzlayers/sparse/UTaux PK vSAzlayers/sparse/indices/UTaux PKvS5E{layers/sparse/indices/.zarrayUTaux PKvS (@|layers/sparse/indices/0UTaux PKvS(Ks|layers/sparse/.zattrsUTaux PK vSwE}layers/sparse/.zgroupUTaux PK vSA}layers/sparse/indptr/UTaux PKvS o 7}layers/sparse/indptr/.zarrayUTaux PKvS4a~layers/sparse/indptr/0UTaux PK vSAalayers/sparse/data/UTaux PKvSs7layers/sparse/data/.zarrayUTaux PK vS\@@layers/sparse/data/0UTaux PK vSw6.zgroupUTaux PK vSAvar/UTaux PK vSÁvar/cat_ordered/UTaux PKvS&Ѥ7var/cat_ordered/.zarrayUTaux PKvScxҍvar/__categories/var_cat/0UTaux PK vS Advar/int64/UTaux PKvS9 7var/int64/.zarrayUTaux PKvS?8=K var/int64/0UTaux PK vS Avar/var_cat/UTaux PKvS&Ѥ7`var/var_cat/.zarrayUTaux PKvSAXK",Qvar/var_cat/.zattrsUTaux PKvSW,"$ var/var_cat/0UTaux PK vS A)var/_index/UTaux PKvS)knvar/_index/.zarrayUTaux PKvS!bH\ nvar/_index/0UTaux PK vSAobs/UTaux PK vSANobs/cat_ordered/UTaux PKvSL\7obs/cat_ordered/.zarrayUTaux PKvScq?r@sAtBvCxDyERFSGUHWIZJaKbLMMPNQOFPGQDRCSbTcUeVfWgXiYkZp[q\r]s^v_Q`RaUbVcZdaeLfNgPhIiKjHkAl csr_matrixm0.1.0n dataframeo0.1.0pfloat64quint8rint64s cat_orderedtcatu_indexvgene20wgene21xgene22ygene23zgene24{gene25|gene26}gene27~gene28gene29gene30gene31gene32gene33gene34gene35gene36gene37gene38gene39gene10gene11gene12gene13gene14gene15gene16gene17gene18gene19gene5gene6gene7gene8gene9gene3gene4gene2gene1gene0YcefhiklopHh H shape@   (}lzfXSNOD+P4TREE#? @  O# @ { @@@# @@# ?+ [@ @` ` s ' 7@O@ @@+ 3@S_@ @@@@ @_C@#O'#''?@         @# @   @@G @?  @C@#@k@K# @g @k@S@O @S@/@3@+#@@s@O@ws@@Kw@?@g@@g@@[;@@@3{@7@S@@w;@'@@;@7@@@_@@;@@3@'@_@@#C[@+AG@@'#@O@@S@@s@@@@k@@C@_@@@c@_@@@?[@@s?;_@G@?@;@7A'@s@3AS@w'A+@@/@@;@7g#s@@#@C@@w@'{@s@G@#@c@@7s@@S@@@C@@? g@@@s@3k@ %/7?ENV\eqz?@`@ @3 ? @[ @@ @@7@7+/@7 @3@3S@@7 @+ 3+ 7 @ # @@@@ @/ @W @@ @3@C@ +@ @@@ @ @ @W@@+@@ @@@@@@ @@ @@ o3 S@C s@3@+ @++ @7G@oS @@@C@ @3@@/@ @ #@?        ! " $ % & ' @     @; @?    ;@7   @{ @?  OG# K@@O S?@@;;@A  K @@_@# c@#@@@@@/ @W@7@+@O@@3 #AAAO@/@@cS@g@AA@;@AS@C@{@s@kA@k?@+@s@/@wA@@7@kB@;@s@oA@?@@o@A@ A{G@?@AOw@?@?@@@o@@O@S@O@AO@KGAAA@7ABAo A@3@wA;@s@oAk@AA@C@A@s@S@K@O@A@{@3@/@@w@+@w@+@{@@@{@@s@?@s@?@oBsA3G@?@C'@;7@3@kA@[@@AA@[A@W@@[AO@@_AW@@@Gk@@@O@K@@G@@@CAW@C@@@C@@@;@A_@C@A+@@C@@CA_@s@@A@w@o3@w@/@{@?@C_@@CA?@?A@?@@?@{@@@@@KAG@O@GA;@SB'A?@;@@;@A@;@@{@s@73A?Ao@A3Ao@@sA{@@GA AGA@{@@wB@GB3B@O@G&            (}lzf ,`TREE#  (}lzf|`5`TREE|%P@0TREE@DHEAPX ?Xvarvarm8=? Hencoding-type Pencoding-version H shape@(E0TREEGHEAPX Cdataindicesindptr8SNODAACkkn(n(HAC Hencoding-type  Pencoding-version H shape@(   (}lzfIXSNODF@QZTREE&  (}lzfPR`TREE-'  (}lzf|[`TREE|c1ALUcr'9FUgu   6  5 423-./01  # $%&'()*+, !"   R  Q OPLMNEFGHIJK789:;<=>?@ABCD   k  j hiefg_`abcdSTUVWXYZ[\]^     %'π ڀg) ?׀ޠ+܀Р*\E?be?d4v?h@? t1?,_:k?^x7?}Uڝ?H? ?.F\?܇?&o?x? Eq?b>'?ƿ?(;peL? ن??$m?\~۩C?ioz?p^?T6$?|}I?u ?VL ?6e ?\(S$"?Uz?D&?j?:aRO?̎N?w\?b?0HQA?B?UXY?p??R?; ?@r阑?ɬt?g?}5?i ?> @ / I V  @ *   ]   8 # N      @73 :  T  @WD 6  @7 " @G' C O             " $' @(        vwxyz{|}~vnTREEXq HEAPPH(Hh0kn Hencoding-type  Pencoding-version  column-order     @_index(( (}lzfr(XSNODzz}Hp(@8H8 TREEР?fOK?іt?pI?h|??OKT@??i|?}վQ5?FM?%Ô?x}W?Lha?7޸?[$2f?`DRlT?QOu~?J?RVFb?򜘇?<2;?86?GCx5?y?/:? ?l?n(w?X?47?^1??Óְ ?a ?[F?y?.?X͋U?0$; `? iV?N\G?? յO?tC1?\ ?9+Q?.Ms?!$?nl}?k?uB?'Ǽ?珰i?PgC\?h飘? ٘ǝ?8<`f?GI!?4̸S?<L}?0 ?ip?G?ž#?dO£2Y? .$?mZ?u#4#?q5w%f?I?Xf?/?d C?Zj?v p?.`-?k ?Nq??]??w?%MJk?t ?&8?B"?K ?K0?\ь>?P|??i.?D`;?$>5=?^[:?B?[=ex?BAN??,5? U5?tUn޲5?t?.8? #?h#\њ??M+(?.-r?Daw?lD?RHu4?(}oR%?dMG?j V?Wv?2|?7?Qa 7?%/?aXw?^H0??HP?2R?PX?oZ,?]?l#j3?⽖Y?}?l,? r􆜹?4`?̬ضL?m}:?0s2ޛR??@~(? 8d?S7ni+?"@ix?FUm$?8>?8 Е?㷍 ?0c?&όq?)?9? #? )N?5hO?R̎m?\8ǝ?Gٝv?(Y ’?D͓!?d; ?8?JW@i?3?o?w?~!X7?@VB?L5F???k>?EB?fD/? ?4kA?Sb?[?pz?p ajһ?O[?vbٓ?o,X?9}?0J?u&?Dl?wU?f^? P?1AjQG?x?y릍?Fc?@^?HHj? *?ȗ@a6?6p%+?W}!w?E?|jQ)?ذOs?/ڲx?zJ@?0+n,?+?"{?cp>?p@? :?F?EAd5?ݬ?h3?zj?@]j?H?6ma#?& 9[?Hjw?N1?Ƃ_? ΰ?xY?T?S?6"J ? 絒Դ?s3?sYN?($?X"?L$٭,?[`SQ?5p?cSb?l&_?Aã?`NO?`w?e=c?1|?U?J.?X"a???r)W\?oJ?D?V/?N?g[{=?p~ ?KQ?(Ӧ;?Xs??>|?9!cTs?L&+?i_\?c%3#?rX*s{R?ß|C?B['?Ãs?r7?x ?xwY_?|]?{fʍ?0Ô@\?{N?m]bΠ?T~-f?j gs?y?` ?#7?'m7?ǻ)?9?<o?*O?^^>?x+Sa?@f#|_?u;?PGIGδ?>j?ؓ_@?{y?P->T?.3 ?ҫ?Hfjro?(?0 =?'Y?e-k?;gH i?bߟ?ʈ%v?B%?=!?c ???Tb?!q?xY?/k?5dؽ? ;`2?X>?݁?u q?V;L?2 ?dtS?@?rWt-=?l Qq4?^?H]m}4?Wr:Mq?hm[?<Z? ?=ѐ?%zh?UH̝?rS ??4 ~?lbˆ?0-"?1Y>?8]Ξ?̄?Og)?/F?4m?^?JGA?&LC?R15uQ?g?v$d?7]\?f_[?zCˑ#?H_r!?21?H2?4lf?2)7?]©b?<ǐW+?ěF?Bc?-SeΉ?wm*?Yn?rHA?$ ?,FCB?za,u?u7?3K?T!?.?7?GR?W.GS?xn L?ͺ~D?Rw?nՍc?Њ??bO)G?H=?$#?6׷?WG%?'??!tz?.>?|^??Ĭ?l?HQz?wS׎?iyiJ?z=?fL4?-j?$Tj?yݤZ?4N?րž?lTt?ĴGew?> ?P`#?X_8=?1`? l$?w6l?|M?t|?|/+?V$?U.?|(?R]r^?T&?V5ޯS/?PZG\7!?Q?!d!?0E??a޷?1%l?SSn?tp?{.?b?A!? Eq@?.Պ@?A}J?n ?w?RFD?c|?LZۖ?ѶY?qQ?Tt-?y?ƃ/d?9Ȓ?Pgߙ?xt?D '?hzp=g?J?} K??}5?Ns+?I? ??W|H?,j?g#? Ӂ?FH HV?{a-?Z??W/? (?N'?R,?ɍ?#Jo?2Hm?PW? _?iB٭?c },?D? JH?bݩn?oe^?mPX#?Fv=?I?(߼p?`!4iJ ?-X{:?zh0 ?|繷U?*?&ɳ?xEm,?|OoC&?6̠Jk9?r'I?[&*?`J#?o ?-=?ހ@}%S?fgoi?fC?tDY2?-?YC?3w?`U'?xIb?-A?E ?di/N?%eJ=?u \W??3n5;?BetpC?]?[[nT?VXƬ?U^Ž?VVy?@ ?3=>?JW?*P҄?9X3?w#??f\=?TlL{?ujv4? 3?<ҷU7?3T?.X?Cz?q> [D?7L?n׉I?߱!?H7K?,c@1 ?,H1.?gI3?wn:?8,"A?̉ ?ɃH? r?e?WE s?Ge[?<ʤ?c@_?hq[?i ?ݵ3U?yse#?iLI?׋ ?ӌqE?֫?DTL?˧-?*"?}.}s?5^Rk?G,ލ?]?^?W/?4-?Tt?eD?l?3;?$J?s)kKk?DJxU?1@W?dؐ'?8n)?ٱ!?丩?Ru2W_?XǾΤ ?^HF?`dN? +?p?䜂]`S?P=ĥ?4z?asTR?@ L?h&&??}?f:<~?ЌKo?(%)m?`3?ȍH?@,?F~?^Ԉj9?Tanu?+ ?#xY?>?!?ZC?0;7?fS? \?e ?WZ?!.b:?^GB?X (?Yx35 ?ј?٠D?",?z?82}?|3J?d0 ?aIC?2 ? A?ݖd?q._1S?LK?>$?&?4?Qp!?MNHz?_D?~Hƶ?|`Vc?w?5,Q?XR?xA}#?xې?DӺ?.{!d? ?Q?,?lB%?,Eq?,+:e?QՑL? ? ?N1?*?6|*?H&B?$Y?ض%m? df?pJI?=?~?Ry?^f48?g ݔ?xҊD?ߏya?Ҹ}%?R<9?u?P@? M?U?Аbo?BY?bӰ?N]A?AN?,M_d?୴NH?*??e?X/?jd:T? :?xN/Ef?9?x^B?`s#?ے>??(hh?&}?:z?O)?B Q?.D^?PU8?vk?4e"?){?AI?0$?fE6?I_?b?BV3?d ZA?%Z ?|z83?ki_q?Ċކ?NW?ۏ!4^?*>MW?P-Kp?(M?XYPI?d?Q<7?w?j ? x?(./?b38?2[s?p?wtI?Uv?[?h]? @"H?pKr?H(?4UDr?\\q?Lj?3?(m?$`3q?? :?(&?pS?j]s?,n?~bRV?_(?3XO?"<?9]|/?;Tr?j=?u߈z?8?U?12?82?p_/살?lծ?\?zҶ?% ?_LE?Tl?DBe?]v?^"?Fo쌦? '?<\sJ?I=Q ?D=&? ۺHm?SC]!?FB?򏐳?p~?` ??Xon?ԍ?8f-?CO?? /?yr ?|3$DL?Y?@*i?#֙'?] a?` ?E ,?hЈeL?C1t.?@l78?ڢ {$?,뒲? m`?캾̛??g~?ś?~u޷???qE?v13&+?hHkTY?F/?(R? ?Ĉ?ЈSo'?k?^-5?1,?#?^?:jV?d=)+?ZAV?k zD?8NC?b-U??E?,^[[?Ժ??;?e~D?Ǵy?`g"?Dz)?-|?臧ve?*?oS#?]֖f?ECb6?ԹZ‰P?{0V??jRܥ?̽)?p D?gb?}2??p|1A?Nkaɝ?|J ?eU??n?ЩĜ?U-+?G|\?:L9?yEo?Pɗ?RC$??X ?u?H6?ϳ?rS F?`{Ҷ?.)?B]>?*l?w!?o= ?T_?jp$^?dz1?/?##ϰ_? $[?*Mo?D0-?mB!?h?[K?"DvQ? 8ϗ? R-?A?|ڧ?r?ȂP?_h?jzz}?ʱv?2?Ss?V狑?@t?/?%l?06{J?u?((?+?Jlkl?Id?m{?zGo?m-?\ӸB?(QN?G~5?Ci?;e?֗? u?x;r?2vϗ?Q;7?lH?\<#?\X%?=56?̷w??a3?B4?CAS?z4?M9?ńf?T? #?(j?n?-5B??(#k?"P?a?*:?ޒ?&0Ln?0i8?X_ֱ?P/Wv??,?T/?@=?jP?,lW?"F*?$p?j?ɕSB?B\z?9n4?N?T?(}?"LR?7e5?>?Cy>?k(I?R{oj?2,{?rk?;^Ͳ?tO--?E4E?Šb?g? #e?1?BnP?/(rp?/D*?:qܫ? d??_Y,?hh=?34?=~>?u2h?~ܲ?h+1?`9SQ?C?<~?,o?w`N?db*?0X.?@gm?VC?}&H-?z?L?X?A ;?I,?b*Ib?D ?8-?? p?B?]\V?Pk'?г0d4?fnA?Ր?Jذ?U?~G@?ւcO?x C?@n(37?I?j?qU0w?{ zI.?Z?fXx?oi?\?lf۪?ݥ`?If?kĴ?Hx ?x0?8|nY?`~hH? y?i }?:?֐ue ?>.?}?!?Ot?$\?MT,wCY?18|?.vޗ?]j;?[ڪ?wN:-?"1?FHsD?h$)?!?d+?-jX?A>d ?)j?US)&?ȡL1?@Yk?'O?2?6)N?*ܔ?|g?-?}?v'?ZST-?rC? n?"󮼝?F9?.1?X, e? #?ؑHq?dՂ{?!? a1?lm4??s?d/q?^s}0?O˱??GKoy??\7*S?u;'?籥s?Tk_?_,!??cۜ?A ?"w? Qȴj?xWq?P?\J T?T:F?̶"j ?@Vk?9d? ,O?_6Lq?عWv:?Xp?zv ?!0o?(j{z?*ݧ?̦]?RH{ v?~~z?1?xEC2?*rW?7Y?X?t?1?׃K?TREEHEAPX dataindicesindptr8 Hencoding-type l Pencoding-versionm H shape@(d( ?@4 4 (}lzf@(XSNOD `&TREE@h((  (}lzf0(`TREEi()  (}lzfp')`TREEjdj)`2TREE85HHEAPP(/1 Hencoding-type n Pencoding-versiono  column-ordert srpq @_indexu(( (}lzf6(XSNOD>>@(4 c(v@؉8HTREEj(>@TREEBHEAPX Acatcat_ordered8  (}lzfC8mp@SNODpAmTREE[GCOLqrsyKNPR S V W G HIDEBAYZabdehkltuwx z!O"P#R$S%V&W'X(I)J*L+M,E-H.C/B0 dataframe10.1.02float643uint84int645 cat_ordered6obs_cat7_index8cell159cell16:cell17;cell18<cell19=cell20>cell21?cell22@cell23Acell24Bcell25Ccell26Dcell27Ecell28Fcell29Gcell8Hcell9Icell10Jcell11Kcell12Lcell13Mcell14Ncell4Ocell5Pcell6Qcell7Rcell2Scell3Tcell1Ucell0VdWfXhYjZm[q\u]w^x_z`RaSbUcZdbeFfJgMhEiBjAkdlemfngokpmqorpsrtuuvvzwOxRyTzZ{a|c}K~LMJFC dataframe0.1.0float64uint8int64 cat_orderedvar_cat_indexgene10gene11gene12gene13gene14gene15gene16gene17gene18gene19gene5gene6gene7gene8gene9gene3gene4gene2gene1gene0cdhkmprxySK  @      `8 @o@@      K / @ .,-()*+!"#$%&'      ΀!'(,.݀& Qs0h?U˜?`A4?Hԅ*?DW? +? ]?de>?VŐ?In?fyE&?] w?q"/?Ac^?m?,?WJ?6M?wZ?\?<˖T?? ,}?K]?cz?~?^?Cv?R~K@?`t?N ?fb??99?%wG?"??U-T»?> >H?*]㽥?҈?NN?lf?G:$6?^*ۖZCw:7tXIdʥMKK U  T RSNOPQGH  I JKLM89:;<=>?@ABCDEFK j @ ihefg`abcdVWXYZ[\]^_K  @ }~wxyz{|klmnopqrstuv    ֠Ԡ%Π1Р_) $(f3?/Jp?8? x?Z?@*$?N?ք? Q``?ޖ?>.ܿ?W9Z?PBjLѫ?l} ?uv? H?e|5I?vt?_@0E?M?lg?U ?h?qаk?5GF?hg?]xP?Ƣo?pT*|?ok?I;юM]?IFݻf;YӍMsK                {\$}#&         *(( (}lzf(e( 0 categoriespA(TREE(\( H&orderedFALSETRUE  (}lzfn(p@TREE\(( (}lzf(w( 0 categoriesm(TREE(c]( H&orderedFALSETRUE((@ (}lzf@(`TREE]((( ?@4 4 (}lzf@(XTREE@A^((( (}lzf(ؔ(`_index__categoriescatcat_orderedint64float64uint8`TREE(_(TREExHHEAPP@(( Hencoding-type K0 Pencoding-versionK1  column-orderK6 K5K4K2K3 @_indexK7 (}lzfXSNOD02h(@8 H0TREE_TREEHEAPX 0obs_catcat_ordered8  (}lzfPPp@SNODTREExY` (}lzf  0 categories(TREE* H&orderedFALSETRUE  (}lzf@p@TREE` (}lzf 0 categories(TREEXa H&orderedFALSETRUE@ (}lzf`TREEva ?@4 4 (}lzfXTREEa (}lzf`_index__categoriesobs_catcat_orderedint64float64uint8`TREEbTREE HEAPPXV8_Xa؟0 @ Hencoding-type K Pencoding-versionK  column-orderK KKKK @_indexK (}lzf@XSNODp((9@L8BHHU (&TREE~cTREEHEAPX Hvar_catcat_ordered8  (}lzf0h/p@SNOD/TREE}(GCOLTVXaQROE B a c g hmotzQSUYJKGFA csr_matrix0.1.0 dataframe0.1.0float64 uint8!int64" cat_ordered#cat$_index%cell15&cell16'cell17(cell18)cell19*cell20+cell21,cell22-cell23.cell24/cell250cell261cell272cell283cell294cell85cell96cell107cell118cell129cell13:cell14;cell4<cell5=cell6>cell7?cell2@cell3Acell1Bcell0CcDeEgFhGkHmInJpKrLsMyNzOOPQQSRYSZTbUEVKWMXDYCZA[h\i]k^n_p`qarbscudwexfzgQhSiVjdkflgmLnNoPpHqKrDsCt csr_matrixu0.1.0v dataframew0.1.0xfloat64yuint8zint64{ cat_ordered|cat}_index~gene10gene11gene12gene13gene14gene15gene16gene17gene18gene19gene5gene6gene7gene8gene9gene3gene4gene2gene1gene0cefghmpruWYabUVJHCPWadi(    K@5 @ O@ @@u/O(        "',W(`{a?"@?sX¾?z~?'?\L?@VC'?0?y? $C?#? ,y?k?8k6?r7?¸N1׈?Ǭ;?Sg?vhk?K?+]? `?h/?d:K:?ֿW?GK6j5?|R?"!?Ĵ?!6hc?n@b?ٲL??2u ?eSf?|N'iM?RU?xZx]?OZb?;?#ޗ? fF?4G?M ?d4?^-f?N=?DFP?5?i—?W  0 Z  \ B _ ,  :  U ]     >   @7% < + D N @7`              @( B  A ?@;<=>45  6 789:%&'()*+,-./0123( Z  Y XUVWOPQRSTCDEFGHIJKLMN( s  r pqmnoghijkl[\]^_`abcdef    '*-/ , րԠ'὾U?z?ބR?*p?(zNMk?mB?ԍ?wr#?#d~hl?d-3p?EbE?~f$?֬???gV6bd]-'Qeotx,|愲.T?\Nֶ?38??ζd?DNؔ?jz?3~Y?O ?Z?;pF?*ai?2Q?*\.?8v?S.?΁^?-Lao? '?@Iސ?PM?XZmU?$n?(\*o?|M^?>AHV?8;?]_?y۳?@椕?28\?+;cJ?[(˘?pW?J?,0;S?’?^~N? )?@Ǩ?P#.z1?&X?uQ63?hmޏŸ?nܧwȡ?1m?8Y| m?W韗?@e??0:A?hflwG?%?0:d?^0o?7?k5 ??[?ų*?_?p"Ԅզ?g?] ?+Jcb?@3?@(??De?]ީ?2Ç?;b?$W[|?PI\rL#?B?R͝? C?S?-;Y?sl5?x\?<q ?J?r$͠?P `?} y?@ll?Е &?]c?xw?XWɖ?Nhf?di? 8P?P~͠?ܒRr?8YȒ? ?tf#Z? /+?֓$?S?/~?Ȇ*P?0ruU?\4F2?ns[K?.L{?N.ĺ-?97?~q,?㢥|?hT{k?ȭ ?LU?r\G?jm?!Y?8'?|?NZS?`+9YcJ?7n~? z#?8_~?v?teE%?w?(ӆCa?y??4 1;? GJ;uv?2ۿ?:֓?й,-?i^?TU?a ^M?/?j{?qs? ?̒ϻ?%?>x5/?)!??@?%ro&^?V? 6(?C^?hq?{v?P$̸?mԧxf?$2"0?_? T>U?&?g.?s)_4??*?F9aS?Y?ސ?t)v ?Yk?h齽?d¹U_?\(j`?9U?fZQ?JK? a{+?`O?ܽ?y?Z1*֊?(o?m<'?) &r?g=?"(Ѵ?߃?BdK?V (?ky? -?ǛԽ?`U?]?Y?5S?`Q?Joa?V5J?o_M&c?[+*??bʈ&?2oUH?| b]?.s? @S?xN?v?ʥ~l?"?0:?PtR??ԢD)?@y?9?K'6?@}m?w"1?P^?X>"h]??lK"?3? Q$?Ls?<[?Z1?L?p?D;:~B?#|w ?Pmμ?\u?8l?|mX?j?EoO?`;l?t?TB?0?(-;?7?|Mܘ?|?X? ۶?Pg4I?@op;? ?J? s? _??C?$?Ijh?XE-?Sj7J?e?Zck?PqcZ?0m?%4?l T?2v?<R?e?ֽ#p? S?5;i?۶'&?N ?*m|?.;q ?s?NƇ?i3 ?1?)_pY?f]k̡?VQ ???d.[?%,?=(9?u(0? Z? pp?I?XV 0y?Y P?Ռ?`]8?hHI?FY@?AoA?+CK?t?ҩ?p ] ?$Dl?pr?㲊6T?m KMy?(E[?@L P? i?Ge5?X6?O?̟O?@d+?+‚?Ӎ?X?lv?;D?xp&?VO?h6_?zs?vmz?X0?l񭫔?8?nЩ?Dh:)?f@&?Ų7"L?_| ?v"{i/?.Dz?<W?J&s?`? ;?*Ҿ?t7-M?@ L>?\o&4?Jѱ?z?ˆ^'?KX.?kFb?l`m?@%|,?Hg(`?:~?մ?c`3b>?(OC? %ef3m?4-S?7"!?" ?l3Jfl?0y8?$_?Em ??k?t^?*f|? %V?76,?~ HΆ? B?h{hh_?HQ#?WHו?1? B?LH?NZ[c?`c?b[?;?Rbm?_U? q?6]?8?PQ?9n?(X? ?;ҥj?I? k????{%d?`ٍ?M?_o?Sl?<G@?{v?kW?a3-?pec?DYX?23?kΔ ?dN?"*?vYu?:Z)? 0?5uݜ?>;?`*RE?PdA?LsMV?ԡ:ޫW?0cJ{?|](??0D+9?Dsi+?L>D?+?+҉?L>o?? ??u1R`? }?鹰?Z"bAR?;ҥ?lpN?ֵf?M̈?pO?)~? Z?o ?/_z?u: ?dz!?W5Dϰ?x#o?JzN?T@?}?F*L?6?%*8?VW?<]U$?X?6z?&kv?}"pJ?xK?Ti?[Q`?lme?~/V?u8?d܊?i_و??yL7'?a? ɲ?Ŕ?U菒?p*=?ˢh? ?MW?ب)?D62?@V2?cde?9$? w?AU)?Iw?g?c3Y?f]?C23ʀ?X/?r6@W?OC-?FN??sqO8?>Fb??^F?@ej?8S7t$?Njp?f?ň?K=??LIer?C??+-.!?Rr0N?BES;?s-X?ވ*!n?V!? '?œa?e?kdu?PK?W?PD?`PG?#$?IR?ZvN?ө?r⠯? p?$5?8y?@:ʨ)ڀ?Ctx?B9?xņ?lsN?~~?'42?d?E9?S5u?.f~]?I+"ʰ? J?dc?6 v?ۥug?5?zUcW? ~?L?Znސ?VӪUm$?yPPV'??Ay?/?> :?i&?1e?Jm?Z $?;|?,pr?0@?tɲW?D?爈? 0?}ƛm?`a?K?gT?|&>?J?uX?@9ַ?\c?w?wћ?4j?x@?UD˳0?"j6?/Ew0?Ӎ>?ؒ:0?a?}Vt~?r]? ?.?LUƑ?]&?{Ч?] J?JZi?0I#2?t{X?@+?cPr ?5Xl?Br?v.?g?6|ƌt?N ? V ?pG?՜:?ޝG?MҞ?+M?̮1!? tjB^?a\?e"1 X?C0_*?qz?.!?'+da?X*=?nC?]U،?Pޝ} h?BFzww?ul&') ?qp?x?6?x?ک$?ܰW~e?y~0'u?M?,+?ɥed?ܯYc?MT?hOI? 8?IؑN? ?Բn?90\?]Ŕ?rSC?^5(f?^ (?Ժޡ?A?=RS2?)ɭ?ȯb_?|P'=?ˠb ?J3p ?Ϝmh?mE?TI?Y-?U O?n渮?HT? zE?ɵb. ?yYߔ?D7?h4?w_9?ȣ]?p2?a.%?Lt3d?E?z#?h7?T=?"M?nZqG?C{ ?HwbI?Dfʰt??P٪?ʔ=?i<4?%R:?  &}?(kN?p{X?ya?z?@|?@gj6?j/?8isl?_?QэD?zm.?`mW!A?XEc?؉-?Oa;?ȷq???'?^'Hj?Q4 ?"piM?T C?05+?Opx? K0|?dq?7?^?$?%/?@m/:?&l? z?@͐?%&ע?xn ??bj?>p?h*? p?C?1F?lMt?-ZGt?۩?'T+,l?{▦? ɮJA? ϕ`?? E?WO?n`ֲ?44RH?[b?yb÷?Ra$?6?|?ToK?ؚe?+?.nއ?mg?걌?8!i(d?P?H?!ӏ??~??wYM?Z a^?遘?dȟ?X?ݞ?)GS?@?`qZy?b=w)?7l/R?0ow_9O?>A&?B?;p? c$m?-k?w4<#??$w?,hQ?T*{?Veqok?f %\?@D?{K?KUR?0 ?$پ?8h?7M?AR?*ܙ&?IgM?,@"q?n%?"Z? DY?/??E?#.?/~I?8?~:?N?C ?q)c/?Un⚫?"5 ?&#?^'n_? s?E͆? ӕ?d󢞂?H憹? ?TREEHEAPX dataindicesindptr8@` Hencoding-type ( Pencoding-version( H shape@d ?@4 4 (}lzf`XSNODдTREE   (}lzfx`TREEu   (}lzf|`TREE[s!TREEHHEAPP xxD08X Hencoding-type ( Pencoding-version(  column-order(# ("(!(( @_index($ (}lzfXSNOD Hh h(X@H8H TREE!HhTREEHEAPX catcat_ordered8  (}lzf8p@SNODTREE~" (}lzfx 0 categories(TREEc H&orderedFALSETRUE  (}lzf(p@TREE # (}lzfh 0 categories(TREE# H&orderedFALSETRUE@ (}lzf`TREE# ?@4 4 (}lzfXXTREEB$ (}lzfH `_index__categoriescatcat_orderedint64float64uint8`TREE2%TREE HEAPX arraysparsedf8(22 ?@4 4 (}lzf@h2HSNODjjlADTREE@"2T?;OCt?CM!?J??p3?%h٬?Ƽ2?D] BN?`*?J?h6Nz?b,VD?ĽaY?Hz:?^l9?u,<[?g[?^\#?#;hZe?>?P+}?ذ sr?\?sZ? 9|W?4wue?$0_ ?Vr? oJ2?vBak?-% ;?$´:?ɮ'?ꦽ?jǓbx?WN?H~a?K'OF?iB?TLy?9,?p~6Ť?-?++;s?e@6i?8r?lk?uo?kaFjH?h8bi?pb5?$br g?XIt?Cg?e/F?mhk?vA-?j6԰?Tk?!d?m$?Mw3q|?$s? vF??2N)US?)?f,R!?ZuI?~?ۋ?Ԫ|}h?r 6d??ݟ;9?3?bp;?^?%4??da?;?,/tr??9-p?,>t\?"}?: uPF?i_PK?( d?8ۍH?NJzB?5??]!B?VFfd?X!ۮ?Fe?X&?;wT??R ?mb?\ ?ԽC?_]?5p? ˰o?T'?k" ?TH.h?6}?'b?f ?h0k_?Jg?r8s?*u?n2?Ǝud?YH?U?n?n?٤?Dnu? T?9/l_?Cu!??S';f??R?`on.?E&~!E?x]?cR)?˜? )[?.<=? *PW? 4ד?Iⳇl?8Y*2?FB|~U٣?LO'? =b??? ,#?5??_΁8?p]b?!?U\a[?M0?Ĕmf?:?@Ʌܺ?n?F0?Pw5h?i/Ǡ?u 7?im?0J-F?* (?K۽#?_h ?8-X?M?K-:?X?>zB??hfBT?3+s?N.?g3?G[n?3^ '?3f?4"?rd_?V{u?lx*6?gr-?ܭ˽?eZz?M2y?4 ?U?.5-?} a?O??/m?1QD-?ya?.6? ?`a$OO?x,* ?<@'?Jo?H8?D?Xb(?E0`s?%Y_?_ a?ߐ*7l?fm3?RIx?̡+oN?h]s!?}e]?< >?6?l#q?*&?,NҰ.Y?"?T()?Tc?ǵ?.j?Ln??ż4}?^ڈCP?`𤤭?XgG?-ph?yG?A?'sO?!n;&Y?^0-?*)Bo?@\b? ;?Y.?3;7?Gޏ?Gp?c6?$6`?𞈔x?LhԻ?S]?o}?R2?KR?#RV?#7?\VY ?}?n?І~yc? ?4?4C&?z? ?x?"?W *?+r?` $? Rn??+to?($c?'/?P?ƺI?|BF?7? ?(Q֚?L&-?Y1@?x&?@l*?"O8$?lgp?]5?Y௓?xK #?D]??/{?lg4EBh?Èf?aU?{{ñ?{??!?hI?XкM?1O~??z7Wfm?80O?uݚ?2?ބ?`eJ?"(?L2i=?md?0iJ?CgvkJ?X /-? H?Myw?Z ?rwU?,3s?eh9?&? " ?WCuѷ?Ci e?th?D«Ԛ?]p]?kj?>?£b?#B^n?v8/?[($w?Ʌ_0?eKI?[8?0 ?c*Rpp$?.?`-ɪ?1LE(?/0X.?Ds? ?i?|6?))A?x{?OQ?H4 ? [~?:? ɲ?_ ? 6g T?xD?M,|?g?Xw1?9r?vak?/!m?YP?^Y?p8?;߶?@Mۉ?ઐ?K=~? 9?t*H? i7?MU) ?1 ?J:Q3?l%_غ? :S, ?t?Z#? o([?;@q?W?|EH*?SF;?B*?,vL?l?e^?K?W#?<(?Cöh|?mF#R?&?'2(?^+׿?9Ǡܾ?Si?Ui?ٿ?4?3b?1?8( ?xMI?cpA+?L ??"O C? -?lB?m?ʳ1 ?,e(?@V? )Q?pbZ?9? c?اi+?f?ɏ]j?r:F?qw6))?)O?)S?=?Z4I"?>=ฒ?T]?b?|[cX?k\ ?Q Z~?,k?Cޅ^R?S}l?pX?Qq%?R?S?K?Lj*?ֶ{Ox?v)?j&?cE?r7]g?%?|0?.3;U?l?Q? 6I? 2߶?F?wMH8E?6D?]pq'?.m? ]?'۶?Zu|?S_?m?`<ĭȶ?0Oe??MO?o?8p?"6]?6P?^QOql?q[?$+?%?%?q[<?o:?t%g*?؋s ]?5?9~gf?K?v~KP?wZ?5ZN?-Cs?E}V?gdO?piK?Cr?}'fU?Y_?3+z?|ؘ˴?@"j ?xQ]2?TlUo?۪/?"! Id?[K?su?(zP?I晩?ZO?=Ϥƾ?39?HDd?^Ǧ? ɠ?*?E?ZP[-?P)?-@tIx?H݁?C? #jk?kJ?sp,?;&%?_ñx?(X?}ђ-h??e?(S㉞?:?ȭ>g?:?+3 Y?m[A?Nj?]W*?nUu?l&?톘?͏-?fY?كU*?9e2?£{ ?pk ? ?#?@nX?P<{???ڻW?*s? P?T?QI??`}`*?c#Į?@@9?E15r?:hj?0/:?`f eu?7hx]?-?tC?Xn$? Uj?ua?2ā=?λob?^L@?|X?kn?\8[?$7R#?l?ȴ8?TU?`seROl? c?❊jp?a"?0W B?RK?8PXk?}1? b?Č$,?1g3?f/%??hҀ?r㿛|?Vyq:r?c ?B.?fMa?D($?s#?dΙ?BX?f>?xF?L|p$?nB?lڢ"?Hjl?A2?shb ? ּ?TREEFHEAPX Ddataindicesindptr8AD Hencoding-type (t Pencoding-version(u H shape@d ?@4 4 (}lzfHXSNODE0PpaTREEP%  (}lzfP@Q`TREEOpYG )   : \  " > A G J    @S ]  @          (      ~ (         (  @o@K@@eՀ', /#@Y?xd?s?^=y>?+ޤ^?įbC?We{?Eʣ?Z_]?A ?? źlT?xI(T?yT?S'?3?P[1d?ӵZ?z? KJt?c&?(?V}.%?9"N,?%f7?)Qj?M? N?m?      @         ]m?RuI?޸?,bM\?   ?*Do??x'd?Mt?9=<; O: O987 ?65 O4 /3 21 /0/ ?.- ?,+*)( o' _& %$ /# /"! /  ? /   / /   / / @`  (}lzfTb`TREE@YpmTREEHpHHEAPP880jl Hencoding-type (v Pencoding-version(w  column-order(| ({(z(x(y @_index(} (}lzf@qXSNODyy|8o (@8H(TREE~Yy|TREE}HEAPX (|catcat_ordered8  (}lzf ~Hp@SNOD|TREEm}Z (}lzf 0 categories|(TREEc H&orderedFALSETRUE  (}lzfȑ8p@TREEoZGCOLlpwHIJMF G D C csr_matrix 0.1.0 csr_matrix0.1.0 csr_matrix0.1.0ettOSTzAgZLBEJOuzpOpGqHKPGsyOSBEORjwNfizOvwtVvMKMK YMyh!uyF"VQu#gMYY$ETS%QQx&AOcm'bhI(ssaz)SXfk*Cxrp+dWTi,WUim-swN.PjM/tAyY0AOzP1pWP2ieK3mFbp4TqX5ceKj6XyCJ7otv8TPC9cuN:ybPc;RuD<cMm=dNf>ega?iuTB@WJVAuffbBJhfbCPzXDXolEheYFgOyEGrZaHZDyIsdWJlceKkqWPLrYcSMcWpvNqCgvObxnEPNPDQADIpRzwXtSJaVvTjOSBULkIVeaRWOmuHXXBoYblPFZhWUV[NgqH\UoRJ]oXM^KLWZ_mDu`PLriaezFybibzBcHlsTduCFeRhFufgYNgIWvKhgbnHiXZLjcRakiQzlkrIgmLDrMnOtSAoXWmrpPPtsqNyLrQVqsjTutDxjQubdcvstr (}lzf 0 categories(TREE% H&orderedFALSETRUE@ (}lzf`TREE[Y[ ?@4 4 (}lzfXTREE[ (}lzf`_index__categoriescatcat_orderedint64float64uint8`TREE&TREEHEAPXXarraysparse@( ?@4 4 (}lzf HSNOD@m`TREE @&S&?Y{g*?No'?}5?rr?"?Fbj?pR?Yniҕ?|h%?;?:e֑?i?1/?.d;Y?f|J?*p#$?mR? d?WU?m(鮓?1] 9?#?Z¢3Z?t?{OT?\]?c[a?ew??)ivD?鲆:?0]"?}l?1@_??,9?~QoVRL?^?@DO|?xSL?E?J=c3?pGQ=?O?`6}s?_?(?H;? H?Pϭ?s`?mͮ?${FK?6*?&?>0?s?lp?Ol?}?P9`x?`P_y?{c ?P?pm4?tF(?wż?`W?DnȒǮ?H%?-a?Xq?= ?g?Pkl?nKQ??YʉaY?^5r?0΄=?Z52?ު ?9`hQ?ű0^? ?I8,Y{?ҘMG?p#g?0rl;?6?[?XW'?, ?iVi?jᅝ?0{zv?p?Ј(?vbd?Oq?O*d?k䅶?XK"/?|͊ L?`OA?樃?@z8?@Å ?+5c?c;;x?M*?x2??;"?;&/?9f_,=?=>y?J_?הlPrK?7DZo?q?-?[6‰?DQY{?7p?2?T?!?)?S ?%V?|?n?'LŞ?ɍ,?۹Ty?gE?Z?je?9W?53?lLW?=cL ?qUm?L4?z@|f?x6(e?@6%; ?k ]?Pc?.͵?_\x?c_?`٢?FGw?;lFؒ?"?p4~Y?<=?{h?}WCG? J彝?ܿf?'V?ſWA?UiA?tg#==?sA?r ?{ge?jƒ? }Rc+?@B[M?jz0?],? /T.? KF?(}?P&?.?N-E? ??sH?Ttg?^ P?"u?ߍ(Q?C?1,?G"2q?3?=?`C?$~?a8?^#,?Jz? ?'G?4sU7?6O/?(W?;S?֩0A?.)?1rN?/-?Wf?X"?1?轀a?Ye\R ?p0S?@8?T5?"z?Sކ?Q??Qt?xF?7?˻X?܄K? Y̺?ߋ~?b?3#8?fq?K8i?|/w?B?EI?k?'<{?Uu?J?j?} Gl?ɹ+U?E?iVJ?QM?9`?囈?%??|_3t?Ь* ?s/΢?"Md?Taƻ?_/?Dr?䤕FQ?gX/?ZLH?d?ś4?}}?@s* ?2]jD?$O&?Jw|?h=1\?m?ښK?v_h}?z"ă?^ѝ?@//?` ?]z(?vť??OŰ?Sf?vP?εϹ?H?sٟ?Hjck?sSR?Yz?@?%dQ?N?0@ ?v?tm?kD;?jH?CUO?߶?*d4? ]G?OkW??;?\c ??,w??ș?>>"?M߇_?@p?dmke{ ?L?c!~??X8L?>5i?>~?-?[ܩ?5s?3ȟ?8{ ?u5?\?خ <.a? gh ?3H?} r?\.?Zra?>@?i?["u?bD}p?h, 1?sT-?S?Zt?yH.?gԨ?g?pG?}zѕ?{?7~?6?0u3}2>?"?SS?>Ǚ(w?(s\UW?Ǣ;׮?P@K??&Q?b^?aG*-?ioa?=ܯ{?m&?SZ?sdg^?HDT? l\?Q?jAU^?KC2?wUN?b{G5?:¹?Ĩ=?`Ƌj?ֆ>?ahE~^?7?HQ4?*O?N~x%?2a?H(`?zk?R/p?T HJ̕?\aa\?al?|w+?N+/?^t}?⢖H?h:]?5ߞ?ug?ws.*J?^?3T7?=(?y1̐^?ŭ?̃]?`V?zy1e?Ug?8!D?IGq?;?+O??m?ꎪ;?. s?v?t?C?5\?_?_.fkC?s?y b?T";6\?K87??i?&at??gFe??'c?ʆO?؏GmƸ?e]?4? +??N?*?Pz??-kV?Xpf?Xmɪ?+|]?ТY?/C=?r??!x?2Ô?foa?wV?V<؊?ÌxE?XSZ(?刴r?~`?s~?>[?(v?`4Iɢ?&I?& #K?Hκ?o??h]?iL@ۿ?6.?-|Q?^MBA?m? Om?Wq?=_?!5?.yp?/ ?>aU?pPհ?sF???@* ?,R?rй?'&O?l o? }`?Yb咾?qI?&?M?n_ ?CX]z{?dw?ҏ?@?n+?O#:N?^Т??W;S? }?Q*L*?|Ә?hpe(? J?l?LvՑN?Fh΅?fB?uZ?lӨ?HC?[G3?%?9݊?d:4J?H޶?_\?q?č\?CEP?X>?Ȫv?^?Jzз?nzn?p͏?w? ۹? DX*?fyt?V?`?l{Xc??Q ? {?2Τ?bj'?>!~i?Y?kq?C ~?8\? f ?|Dmj?BH{j?ijo?i" ?bM.?2??ě?{?׎?Ψ(?21޾?Д?B^L?&˭nW?hJ銻?ҫ?8N[#6?B?xSBh?WZ?>璂?p |?nE?􅶠ԭ?l.?|L\?U?Ӯ ?Sx?T,?8RY?Z(@?J}?JyZc?}>?0q3k?;Y3?hMn?PrQ.?Br?^? "?cR?P?ʲ4?dM_?Uuuj?>h_~?g`i?ұ-?H?Ȗe?У?C"/?8?#?Ko4A6?Z+ ?$`?%6l?a6?*b4?Ψd?Rj'T?B?.f9aV?TQt&;\?2Ƙ?x?I?u~C$?BI? 0w;?~A?u]?*14݇?FM?F?߰?#?>o L?1ʣ?S?w7F?JH?>a`c?6/?CE?0{?}?P.s?{V*?lrE?څz-?o?c?xD3۟?U>ri?4*`?.-?f;j?bw[^?Hi˫?Iu?c}?C?v1?;T ?&|?Q??(0 ??Tial~?:?{FR?9f=<,?샟3F?Ү@?xp ?}^B?y>?L?!p?"Ā?"|? |3?,?Mj3O?v}^a?eLF;?d=H&?dtN?(ܥ?`N/ ?p R?,6?!PS ?TREE8HEAPX dataindicesindptr8` Hencoding-type  Pencoding-version H shape@  ?@4 4 (}lzfH XSNOD( TREEHT\    (}lzf$  `TREE#\   (}lzf|`TREE2\XxTREE HEAPXarraysparse@( ?@4 4 (}lzf H!HSNOD8(8H:TREE +ז?j ?k3?B?.T ? Zw?3^^BP?/y?FT}?u,?R^?"|s?XW5gW?p@rfm?B?X>J?^AU[?|V:?}7?/S"?^?wO`?FƼ?xPH?H~ c?6]z? Q0?"?8̨?p].+?*F?#?`?¡м?%,{?|H?X/Wm?ƻMD?0;w?,gJ?=*_?x?lK)Ԫm?e0/?W5.h?B?nv._?r?3K ?JS0?poͧ?B>? \q?OE?A ?~?gX>?X?Z<$ ?u>?ල~?DnaK?;G?X?P殯?m^N? >(s?" u/?h?ׁ ?L5?[?Z?/~?zw?1q?PQZR?l(?/?YWz?(So$?r?|Y?;?Ro10?U`?l8&? xC?;?pBJ1?~;c?v?(s'? ?C"?zS?ubE?jƞF?Q?Mz0dcb?>?[qr?.[9?lh?hG??KG?|?}lh ?f?0?QvK`?h?0E?[R-Q?wƑB?뤆*?`?\ QO?&kU?WSAi?Xj??m?0+\?ƠwYB?z.E?N2?7?4"]?\?i?.w?@{?k$?K?3}@i?noƐ?!?Zs?od)?YEo?IkN-?̿~ ?p띧?Ӌó?\UWi? w?]h?FeB?:+X?к ?Z _n?}rO?nH)Lv?9a r?dx$ ?OD?d3Ug?G"g[7?%?K?J1@j?z;hz??ad??Pv??tvdqM?ʌ?;_?Qx? ?OzX?J1(?Lp?6@F?Vzx0?!C:?Z F?BugC?A72-?|l4?sk?R >?Ž䭞?"\9?(wK?er~|?o?, #܊??:yZ??Unh?<ع?HǨ?=R?p?\ssj0?e ^?7ZL4?6\?zӼ ?@h?R??iJ?Lд?6e5? Z?Z?M4??".w"?48"?~{L?DG#?7`?Re?Tl ./?W?RN|v?jخM?$ y?G?•0A?s?:L ?\)? 6\?84#?ޣ' ?2O9??&.?F!T?f;?*-D?̰F?gV"%?hYƽ?l7;'?hqM? @>+?dB_?5 ?VjFk?Kԯ?nSG??N{?ȳfx?Gn&?{?yp?;D;R?m_??U? \~}?'k?P`AGMy?Z4??༏?HU? ?v_*?*wJCϧ?#?5'?8WT?Ơ#5?!?MAo?mA?(7չ?1ZRb?M^W3?5?/l??ݥm~?@a ?JZ?̪@ !?K脀r?WQ?pDrg?&{?`?i/+2?' U?딈h?J]:?8H_w? J?u{?K,vH?80??%&JN?75'? ?[?m+? ?00?.u?!%d?e|?〝?;rO?M-Q?p@ӳ?.9?l;?Z?LO7O۱?)S?@?Y?Rv'?bסVD?UMmt?@c?ٷ4?;b? ,d&?" f4?Ҁ?T??:m?8a?D4?"à?0a? D?tA]N?E?7?ف?s? 7v1?4Ŧy0? %p{?2En?Fkc\5F?ZlS ?Bxb?:0TREE=HEAPX h:dataindicesindptr8(8H: Hencoding-type  Pencoding-version H shape@ ?@4 4 (}lzf H>XSNOD;xFOTREE \  (}lzfG`TREE&  (}lzfTP`TREE] Y@[TREE^HEAPX`[arraysparse@XrawobsvarobsmvarmobspvarplayersunsXSNOD==?PН H @0xx@0Xx( ?@4 4 (}lzf`HSNOD]} TREE@j1kgD@?V L@?me?C?:*6L? Kei?S0?#?zt?c|_7?B\ p?'?Fu?S=?Pl9?8P?)l?]Au?iǵ?|߉v`,?ȸVy$? ?^5?EH!?d/U?Rf?t0j?B? o;?o ?yUU$^?(7bVO?nZ?>B'?f9Kl?n?dLE?\ʲsn?HR?8J?oѺ?#?]똃?n?Jv?R8y8cQ?t?X 4?BI?j!;?J6?*#??Xw? Ol?\?O~-?1 ?Z ?БNne?L~Cߔ?fJt,?:ni? ?|4 ?N6 ?t5J?L)&q?#?QE0?2"g%?]6?D2?sQs?z?}uW?*V͡h?q" c?.u,??+=?)P?0Y?z@#?a?^?-wpI? ?d\uo?LL?(n?@w?jѾ?t0~?d[o?;޴G(?|N?ׂ7g?w?K=b?qݤC?Yڣ9=?? 1j?QcQ?QP'?E?дY?0w?^2`y?6J~?ۂ?~?Y (;??p?g3?PhO??l ?ק-?Pj?Pɦ?>YZ?lMއ?j{E?닢?+?e)VqYn?ZnU?Ȉ$/?p_)?x5S{?(qJ!x?{`?X!v(+?vKK?5Pv?gbI?|ss?d Gƕ? 6 ?W{К?8ِ?1?쭶L??$v1?-J?8} ?b@ ?bpE?z?s-C?\=a0?צ+?U}?Ё?Xs1?Fc?Ǵ#?ּ?Nޡ7\??s)z?ʂM>??-?vsE?ȔUr?Qg?#]?(u?(!q?_M%v?,ĮD?:i4?Y#\?Бjn?>ZfI?UN0?&m4?V?N ?hsظ?3/]b~[?2`)0?r %?GȎ?V9l?1 rv?\~ uo?"ݷ?.$?4|S,?™;8?]}?EGT ?%}a_?2R?=??&X)?@h u?S? _?Iw?BR $?*uj?pM?9LY?4?}?k?]7?7?$Sߡb?NXB?6?Ӡv'?H䰁>?0"խ?gr?ӏ? ɕI?J5? e~?01v?91? I7<?:x8F?S&k?F9g?Ma?zKؒ?U"W?H&Yz?1w>f?L@{&o?|i7?Va{P?>u [h@,l qCd@^ hLFH[dHXȼJpfl 2>ß8 B2Bh8Bx>n|8J0;hPdUƭK$a*R.Txu *Ⱥ>PlIP֜x4bI&T:x}HԎD%䫔`hlN\X``[Q 'df8Oo[)複ܐ,ved`p,!&Nni1pm1tcei}@Q@S=G`_c;g~dA%U3mc`&R*ބ@.լ"}&g+'/sGĩ6ݒlOP{Pmtn%-XgPnʭ9Иr?W,ibOkXOz+cܤ;fËq[7!?ɹgj95l=z6P\cGl4W'g{j} (-ǩT u"$#J6(ƕWѽeTS˄ bfK|B'U}Q_sM스g}@]J՚_zsjsԙ@e 0: RӁsu{kjC \̪mմ~3הg3Z>",s?LH2,'),u*gԇ tCq׉}u>sPbamSd jw:Ɔa4&hljm !./*r'YnOW Q|pv9:_Czn î{Q!Cڎi^&NWpLZJqMP^u>g̖CoZ%fQ?~B0Ժ؅>1waѦk0{4\/OlaocD.HֱvQ(i{9p֜CX7Ŧf@#G~.F*X KPB0u-mZ~"M}L(NPS{obp:/d#W(]#{9oDcQ69Wa kZ`08Mlۅ{pOK52;_Jl6QpS%'cq@%䴐wF~WUrRB/K& ܹtݖsO0.1-0"tY߉#q8/Ekؠ +EM3/Tމě}jw=mKh"OcSdN {􄢧/Lm9V>DUdߊ>M@kLcX5-8 }r@b ?kEO(QLm\oz f!:➝ƆxZ>8sGz{ߐ3?\;QGgAKUٲNjhXL<ޢ]]{T>a˜/Ha+:sTyŕ5fs;Kag'G IhePsvv^zwDyXb~k\f|H"b2?1LJ[ZᒶU惸RyYB cMhH 66dj9t2C~1F#oSJ`6>.rtanʣOg3˺*fwic;af 7ᇲ1$jŕ_j32`B#0/eh0Oĩ{rިOxy! j^/ 6S}"ihe|$ 3Ov]l_V^OL̢;_t\J^C (6FpG{wL|&UtKy)cF*sp҉Fŷa5h^ ,' VЈq ;M>PoY?LTe3L4|6 _"JܬA#A gj]T}.g(=C8,?/ArŏX\F7ܲ8w<Vۻ%*aFXL4sDO|1}^ vSvYӖGK(i!t]O;cȚȝpe!8[{U`[˯|0lF=3@^'ri)+sڮVY#߯[ y 41a@)+m>>p|=dR &g'5rjR;Jm \[wD؇RE=}&077΋RM <8oJ@2D Z`0 wN?h>`0l`_cqlKY52QY#;𼠦zd*"ک8 c;*?"&,yr{2nyELU(;xف{C忷i=ހc[z,F፼H,23~z'U;)N1aRS85zQXݤI{uG`? d/*&/ŰfP?RzQ/s,@/)<~Rm;O߬Gk ,>{~L5 \. ra}T(ظ nۘV,*U3m^| ^$5<Cg762S(W@-oD\^2NG>~{\L 1Ɂ%pwȢg*^7(V1!GO?s\k8ecO+3K!,sk ^c ]Vp>m񜣔EZr[(Qԣyձo z:SLɄJΘC߮К҆?{'iY mڬM{n6BEl\x,ى–3QRزj_-e":>A+lTB;;AFߡ ʰH/s* O\z֟r;}T ~R<<@(@&u/d7 +^53 "1QElv)M{8I&|~>SwK^a{&T5^UΊ6svO`kƕ\+/}HJ^egDm;󩷸Cb\7t]Ҳ=lFBʩci(bV]Y=?aV(߻vɪKAOq_pUFRO`M{w+N*(lNBZJ\P׹*V` q~jjo .(Wh|.@}c>k3n.r4~i/8#6 `4V\I]MLFcLP?????PK vSw obsp/.zgroupUT aaux { "zarr_format": 2 }PK vS obsp/sparse/UT aaux PK vSobsp/sparse/indices/UT aaux PKvS)a5obsp/sparse/indices/.zarrayUT aaux uA0E̚\hy cH-mh($Bmڍj__-5K|=M@ْ/e3+ŏKTB)ED:@281,#\[=Ęc@jCZCܠZpw1_YUR'FoPKvSa*"4obsp/sparse/indices/0UT aaux cb4fQa``a bF bi fb6([PKvSCZlIsobsp/sparse/.zattrsUT aaux RԼ̼tݒʂT%+Ē %4%eEřy Uzz0``. XZPK vSwobsp/sparse/.zgroupUT aaux { "zarr_format": 2 }PK vSobsp/sparse/indptr/UT aaux PKvS o 7obsp/sparse/indptr/.zarrayUT aaux u0 wyfRucTJC"" A6A*woBOݜ~@֮k.=a>Ua۞%*x Iy+~\zxNa' NpSXk5OeR >\f @ pPzuq*JmZD'ATjK-oPKvS8&obsp/sparse/indptr/0UT aaux cb4fa```b&Y`V fÂٱ` DPK vSobsp/sparse/data/UT aaux PKvSXj 7obsp/sparse/data/.zarrayUT aaux uA 0EBf-Ŗ]Q1Aq42c*޽IH*y̟ v 5}Kx=('lIOp=)lըv"t K]-j~ao)wbn}5b1 ]A/Y ׀w+Q bp!Y>PK vSPXXobsp/sparse/data/0UT aaux 3HHXc&?(?V}.%?9"N,?%f7?)Qj?M? N?m?PK vSobsm/UT aaux PK vSobsm/df/UT aaux PK vS obsm/df/cat/UT aaux PKvSL\7obsm/df/cat/.zarrayUT aaux u E~ cm?i EQ&B[6ཛau|ޫdq&a&om_ `*<(s81 2}96Bns1/$Bl.;$@+(b:Jkz_Q+5zPoPKvS?(obsm/df/cat/.zattrsUT aaux RĒb%+x_TPKvSx8,. obsm/df/cat/0UT aaux cb4fc``a= ceg`cb`cbePK vSobsm/df/cat_ordered/UT aaux PKvSL\7obsm/df/cat_ordered/.zarrayUT aaux u E~ cm?i EQ&B[6ཛau|ޫdq&a&om_ `*<(s81 2}96Bns1/$Bl.;$@+(b:Jkz_Q+5zPoPKvScBiHdBQfwr?BnI\[ѰÄȢ%`UOݪ=.W S8ʁEI$k(R)P[9/LP/\rY#@%K y˯69{oPKvS2-.obsm/df/uint8/0UT aaux cb4fc``a= N=L,)X]T{2sn_RS PKvS#vobsm/df/.zattrsUT aaux R̼ %+8K"S_ZBdK`xd̼3dD4R" %0?jmj^r~Jf^nIeA*U)%iEJ*R3@ jPK vSwobsm/df/.zgroupUT aaux { "zarr_format": 2 }PK vSobsm/df/float64/UT aaux PKvS% '9obsm/df/float64/.zarrayUT aaux u 0E~Z}A)3J4&(FfPo꦳Jg.s$u]pM瘇3i{R̆5,x דr8V.Q rd-bfRMђ}Zq; U5m~3EUpk=yOaܕ{xB#hp$?ĵl?????P?????PK vSobsm/df/__categories/UT aaux PK vSobsm/df/__categories/cat/UT aaux PKvSYk obsm/df/__categories/cat/.zarrayUT aaux uM0%Cjia wmY߼μ)I]M*pɔҩU/U>f,C1I煀co*(Ecj9KC/r bi ȇ}z"F y^Ȋp-T5 ~H,N'PK vS" obsm/df/__categories/cat/.zattrsUT aaux { "ordered": false }PKvS-RHobsm/df/__categories/cat/0UT aaux ı @L=@+TZ$(ڿxg\/UTZ>jթW~5i֢UvPK vS!obsm/df/__categories/cat_ordered/UT aaux PKvSL̴k(obsm/df/__categories/cat_ordered/.zarrayUT aaux uM0%Cjia?$ݶPΪuIj hmV5}g$SJHWPV5m"ZXİ'NgxS9hG)cU=8y/~~r$h"j&6Y0_`.BVo&"eɅ숶g9MC6j=lu;I/d|p?l R\Ȗh';$PKvSh~TLfobsm/df/_index/0UT aaux }; 0በul`+VFr'aM'=EvxI +K -XAO1L.FD+j%H Yi&%wn"yPK vS obsm/array/UT aaux PKvSg۬Qobsm/array/.zarrayUT aaux  0 } ɵ #= jbZaᄊtUq!ɚ7f4ϮyEgԪIh:]/T",Q-,b8  DQX7FJt[إޣkЃl+Z3uĿ^0J*d-dPKvS|9)H)obsm/array/0.0UT aaux w<ǏIVV)+IP)Dgdddgo{esdYB^_x}afgC*,6?WciyĴYJ?TRs.z* 7ۉm8h]MZv]Jb:Be擮V㖠d\Gұ.i\gr>P "fu` k|| byEcP3 q,;|#_`׊ZWѲ7J)?@/vv~9R(|2l*b|i]N"9yZ7Y6ټ%{g=}ssosd^,l4O*snt̟#|Ҋ*j7a wolqQ~VW+Π\V!н|ˌ?1WF[Ƃ{E@zțt8s/`˧nkc^ʚD>|MtUG߅gɖ2G-|?IurBv @Wt37t @>91#KoֳsX e-ޏޏT8RC'䷘ N Uh`9!2ZےQ{OKIADYn0ZO흄Di]AjyK|(Sq4~Uf E&$he?VI25<Lxf`'8@ ˹Zq/H;n[خTV3Skϡ~puJK7W&& :R6;PmJXIoBԈRk3 >ؑ_*6G,!+-J&Wg5a#^R ^mɠ ./خf*g% (qŸ-_CWpՏJW;^qy\)uv˖QJ`$Y'q?q~hs4,.Eһcu{$.dޖ)kX/>i9Ŕ蜪W^ߛH>#ݹw7'O5aQZh]hXZגh+kqRa/7" n7>FTN<ѹUK8TSJͧ\K%)9E)KYKO)fY)x~wkoBP!E ArMd]ו#F=' ky׻-DRj?%ƀ'D}BgeHw5$b\jhb d"H)RsPɤ3nYmҶ9ʵ+|KEVa~ԙl f#b_k%qp>ƯTi%q DLY[U97WM[Qqz9- {ޓ(P|RcR)ʥR>зE%h1"W~Sc6%php^ ZT*#W_ 9i ]ՙ;8MsǎRf毷oV/9~/32g^>򧏺hDخ# Xط;̾xӲ:YᜊՆ2Ixd! \d p]j#Ji#ՅK ݷߧ$]ÅiR9hvX˳a/DXE]ۛYT50w \[܀B꯬ ;*}Ƨj"\彳v{锝ڕnjǡ g o+Yjkrիօh>7KI X8DLNuV )#SuydA7-z*Xmm~JC|pydO+hŦ%F=Пp/*䎖y]9P*r4:YC8-59+XӼ'uՙS+9Ŧ|ͷ{q!,#ŇK,NFoNIIYa5Lo{.Db?YrK2b}Ot2qN>\8Ykfߠ@WM3 [qu+~ttX4>D'?a<+Z*s0֖iz~ŒC~%jm}$WM& >#u*OOl!sҽJC﮵`'φƳzǧz{wz(rVa|_hN/f6U. O< ːYc<<΁Nm^8;u썿&zEzV)֌Lo.T}Pr3|Y1*B[G@d\|vj$05̺4Qƴ7pPkڙܲuy|lŷ^LHƆ5~X4ЧÊ)` oo\`&h=? x2;evd$r-ޑ ڳ>;uLjݳd䌓Ui FT#Pu˸B~v$WTMW esKr&SsOVg;|Dž^P؛E}Ϟr_`ķ(wmF)鄐m6!Z~Jɥ]/FO⋛>KSESզ~M\SJ3)4uByX{9kUѪ5ϼzzFX ǣQ;BvS 5>/]'t5OZ6C")aSi6YC,cseU\wnyS/3 qi՘.c+߸5^bW$68>"#4n˓gt Ֆnp]*xIF#{F\' & Y{2b&ȣy?^n#tNRyYOS].>A)5!-&%; a3˖e_X^S&S+ o"ސ_x□WA͙Zp سFeT5kb.]^C7:GQLr51j Bs-_S}S]}+q2wT sqL5i)*AÐ-{:| 0(tȨ'[{[3{Ƌ7ђ.Vy}F+\̾E+:*|ht I-K [.߁sO ׿vC>9Zf{uoN9l|-6r|EtvVo޽;M3i=zymL&$,˩r?_[wǞ-S?9b#&ܬl_:؏[%<;"ɩ?r@*glfZ>ixOŋ\X?;wCruE*~Ť13FrD/h uNq 5r%d݇,y떥ƖhŃ~vmE7  })Gam `限#|lOdUKvymIByi;HZlÛq%~aVq)"v٦ev#O% /S39JM̅Ԟ|h1V4urVo 4uuzp?2_{VrGz>aqjŨ؉0ҜF?&|ޞ j&PhϔQ_yx'>,GOwmZk׬s$ L+Z $.Tͧ|g mesK.ؽĕ)eU*txD;8}b]/rM7gF#k;hr/\l*.AJLrD3Xx_cM4eUXb3}k1Qiƙ~K ?sf^^C2 Yni8t|t[1!zzPybxFS]K`ЖC?)!R2l/4И4ILDRDy71y3‡?p9do?E̋/>8]]n1ԠiӔ3uU}\_ ^a1EC^?\\q{/~MxY-^{;h#{5\PhEJuޝ~ɦd$an K׸̺) `N43:.՞,9~*${"y{ύ$Q'nyq"%r T\Pw8Ѯj*L"'[#4Eઁ,̪U:%]-_7Ր~jdu8qapE<䮡mq Y.&o.3YRdw .KI\Odj.Jz+@)~'cr{=QehBݮ'7.R_'Sŋ&T|O \Kv {L%.EO5O)݋P :Rf>mVwc',.l[`-}Y^ >ryP 7(g̵wXəbY0#r//raKG֔N}ף_?*%%fOC9Ȋ%n&+WBïAbѺ \;(Gmgl~tFXcL}/%`¥{T eba=~gX+>x$gpĸ8r2dܗwnIۮ '+ɸy]I }):rM0e:Ksӌ{Hf'&F^Fg_eM ߴ}3|f)N@L|%Vvwq* H  XeEWgz1` wNB#"Ϲ/5o=?oc.M>}~d[ǿ_s<C؍8pԚm>.m$S?e6gzbAn`)_[)߸? x1SZ#D#j9qQ]6D>ٿxӟI6|]K|LoS2Le9;d[ڇܼ_"ur>۾{~Pގ#SR6(' ?]W{\LLvNtIirлqKN_ _uEu{e_r0rn/9V\N637>a:*rHLy+ÃB0]6/5'ewZudJJTB^^;3$-3]f~ 7}_[=s톳!DRd6# /Q^1޺ϗtoq&R-,7PkcϳZ U-]G .݈?,lmNu:@qfZ",pݬj98 K9mogoݙ,G{>zlѹjÆ+_[T?czBbp9cyX^3.TOycy'/D1*lyVѽDVS[l(y/!#fsz/?3NDݳc=ܩ_}= &a0g(W5@70LJ+ 鐃+!$.Kc$SS䡓~2OדQ۞| Ö {O2ZhV7Y9UvwwϮ }mBPMR/O47O L_l6x*RjH{RPk*b!BoGhGӺ59Aw bmm cZCƓ_I5[aJ9UeÇ*m(:{':Fv~X2y4Zl(1r\cJ*"?dQcCE:+z,QqަK}%q竡pI_n'޼6suXfy<``^Z*jv ?,̥ eޗL\& j[|xTz/ :%rcV}~$MSbVg ߳^J4svOo8WlBJz6EF%X.yviH xmҖ'~CT{W/D߼MۗeEiVeZamC XƋ.἟jIUWPCY_$dxg _'"<^HBh9aG>AIڻGy˻y_PxʌXIc[F9}=4y&kDhS^5`d7u" epzw.8Nn=yS顥tW&rHfin7&@~T k[kB'CA%5a]/sM]%9~bNfVyAsDDo3-/\j%ۗwNw&?3n=:}:R)WÒؚ3LێqE_-<8,(zѵ@[[b4KKP0~gm/M8\ފ#uGt4 Cnp3KdX21*L[xZFDV* ݓi(PCYb1KdEg|C^8 I1X:r@64W)M]!( qA$бxlu1LllB(yއ-ѹ(*%,l 0Cx6ȠROYZ7K]G0qF!A#[P˨gt ͤdmQ%*L 6Ka =JXza IхT"L6x$z$rkK͑ V6[YoERVnT mr#Sk`* Hz Nu,sGL d?PK vSw obsm/.zgroupUT aaux { "zarr_format": 2 }PK vS obsm/sparse/UT aaux PK vSobsm/sparse/indices/UT aaux PKvS7obsm/sparse/indices/.zarrayUT aaux u0 EwyfϨ*DDT86K=%GWKups*YlRY V*qjR%XϐpS3 QN5^kCvK=s3{ hXM}rQ=-ՊK}uyςҖ:1혭PKvSЁ@9Oobsm/sparse/indices/0UT aaux -˱ P#)\j"MP3>pCs\C?gnUa۞%*x Iy+~\zxNa' NpSXk5OeR >\f @ pPzuq*JmZD'ATjK-oPKvSp=obsm/sparse/indptr/0UT aaux MQ@0@-J!4.zО؏dwu|9ڳ7 ȅ+1pׇYPK vSobsm/sparse/data/UT aaux PKvS% '9obsm/sparse/data/.zarrayUT aaux u 0E~Z}A)3J4&(FfPo꦳Jg.s$u]pM瘇3i{R̆5,x דr8V.Q ,ù m!CڢV⻯-q9%dMR[3tnigW<"3 jՏ$Vd뮂*vGn(f^b8 3DQX7FJt[إޣkЃl+Z3uĿ^0J*d-E}PK vS#G<layers/array/0.0UT aaux !X1Vm: zP<z@ ϐ`]|Ety(n\RBj*<j|fh@PXؒ:l:V`-p| daNΔP_ەσ8C;PXo֏ohZpTlɬpx|PVt*P&@,gj}aHux@DL`O̔Lf:N]P2q0z-dL@j|Y 0^6YgPlא hٹPXoXX;ɸT@}pc]PleZpx(Xv W8欼$8bs\UзNʼv_,>Nh3T$*GD f*X]$t#4>2rV\4¸E2&@ۀ*ƞ0g[:b|xrt-9Yz%cۨR`Nvu8B`D+  0 :H1|fHՊn8XI!J#JV6ϐsL`#;ط@fO#y~ÌQ|A,O}E$0ar2{[7o|+NGN ')(6O" AQȁ"Bn`# |9(mm/ǐa}`*JYb 'R) Z~ tL(8?>#"D}*")^w\d;NqQw2J3HgJzrI M=E;@EPY{Pםzf?DMj+)5q{!K|d-}@b-FssQ(:⑼&sHWG@[FR6hoa)VdBoUb9dRo#]y ;གྷb޴V_vK5{v p=+MU#MiYUm/M\4(2N p/RZG/yy

@a~ȨnTvGli?)q[O=}po%XgLe'uzrв-W& J+AivyHjo$BKL88 6?K__i`64.vݔ3 5wvQ9&j;jY+1jfw/;"dܸ}Ʀl n$S( j3qft  *z!wpu]۩.=YK_ъNC4z`&E2Qu+#IL(wgwjP䷂~ pԆũ9ehksoLr qAP-@FTx?a>jY{qn$)SJ`(Pg 1CXc7)-Erg%DZNV]›ciז4[jwN=8}=KsOlI) ru}X wRߐ6vՙ'GžD0VeW"&N n(fpy*J~78S9Kf{{O*uP߷(amI5<&g"& Vr\^#|$-$\%_jylW([X@ܒL07pO<-$F;0y*`R6Pu,$ !Uò; OlnEnvQt4O8i%L=mK-AU\h"vŧ`:]<%OOgЦqD?DY55Zräًē,^Q\uRw$}鞹QUh.Iԓn1d\U+_̰Uxw e, J<q֡0%6szh~o(b=Q'Ey~;Oݏ]uqv\nI<?6h67FtwXm `|wSјFQ60%lηѻ_?_$jb>g7ԴxV%b.Jj&S%7[=y:}{{ Uڴv1FkaWzo7P/g4őHY|$%Q~C_:Z9X䎻пߴԉڊӜϺģڻ͢ˬҽ껠˃ƞبйܟم赫~ߪһ谶ޕƾȗ֦uʏҵʷ ?AP?????PK vSwlayers/.zgroupUT aaux { "zarr_format": 2 }PK vSlayers/sparse/UT aaux PK vSlayers/sparse/indices/UT aaux PKvS5layers/sparse/indices/.zarrayUT aaux uA0E̚tay cH-mh($Bmڍj__-5K|=M@ْ/e3+ŏKTB)ED:@281,#\[=Ęc@jCZCܠZpw1_YUR'FoPKvSt\(layers/sparse/indices/0UT aaux cb4f```a bF b~ bPKvS(Kslayers/sparse/.zattrsUT aaux RԼ̼tݒʂT%+Ē %4%eEřy Uzz0``.F`f,W-PK vSwlayers/sparse/.zgroupUT aaux { "zarr_format": 2 }PK vSlayers/sparse/indptr/UT aaux PKvS o 7layers/sparse/indptr/.zarrayUT aaux u0 wyfRucTJC"" A6A*woBOݜ~@֮k.=a>Ua۞%*x Iy+~\zxNa' NpSXk5OeR >\f @ pPzuq*JmZD'ATjK-oPKvSlayers/sparse/indptr/0UT aaux cb4fa``LLHY0+f#PK vSlayers/sparse/data/UT aaux PKvSs7layers/sparse/data/.zarrayUT aaux uA 0EBf-Ŗ]Q1Aq42c*޽IH*y̟ v 5}sx=('lIOp=)lըv"t K]-j~ao)wbn}5b1 ]A/Y ׀w+Q bp!Y>PK vS:@@layers/sparse/data/0UT aaux 300@ ?*Do??x'd?Mt?9U28[Id4| I9UNl "vk"cRtXsy&4b2t!.҆^CLX!C ~vg1oY)cGx-7PKvScYkr*..u"- km(PKvSX##$ var/uint8/0UT aaux cb4fa``a U:OJMPKvS Q} var/.zattrsUT aaux ]A @ E̺RJR& hR(-Ż;RĬC##};.E4Ls2 Iőrނء>IW8F~} Q퍁gNUqPK vSw var/.zgroupUT aaux { "zarr_format": 2 }PK vS var/float64/UT aaux PKvS1+\9var/float64/.zarrayUT aaux uA 0EBf-J tc"iLPwo꦳Jo>$u}pMEt)fCZ~ȖY9g&xSyX1Vk xK5sӗnI  |"FhRܿ\,JmӊdPK vS var/float64/0UT aaux 3{a?"@?sX¾?z~?'?\L?@VC'?0?y? $C?#? ,y?k?8k6?r7?¸N1׈?Ǭ;?Sg?vhk?K?PK vSvar/__categories/UT aaux PK vSvar/__categories/cat_ordered/UT aaux PKvSk$var/__categories/cat_ordered/.zarrayUT aaux uM01[!0PҿD6eo^g똤Vk\Gfh{ɔҩE/Qg+,CO1H녀"^QXUsK}r5ba ȇmz"vF ,i^Ȓp-T ~@,-NvH/PK vSI$var/__categories/cat_ordered/.zattrsUT aaux { "ordered": true }PKvS@L=ivar/__categories/cat_ordered/0UT aaux ġ @;6`& (BV45(xi."x7jtRL*5tѠIkPK vSwvar/__categories/.zgroupUT aaux { "zarr_format": 2 }PK vSvar/__categories/var_cat/UT aaux PKvSk var/__categories/var_cat/.zarrayUT aaux uM01[!0PҿD6eo^g똤Vk\G/fh{ɔҩE/Qg+,CO1H녀"^QXUsK}r5ba ȇmz"vF3,i^Ȓp-T ~@,-NvH/PK vS" var/__categories/var_cat/.zattrsUT aaux { "ordered": false }PKvSU?svar/__categories/var_cat/0UT aaux A @љQ:Ґ э޾uEݬFv:(P]tSBQFPK vS var/int64/UT aaux PKvS9 7var/int64/.zarrayUT aaux uA 0EBvaR1J4&$B$m6Ux&z7 .=4u|>dqFId[bO0b JEN| "2}SppOt5ǘ>') .\vHVb59 v2`.<-rVZSoPKvS>q>N var/int64/0UT aaux cb4X~@,f@ILJ:?i`AH L " `.PK vS var/var_cat/UT aaux PKvS&Ѥ7var/var_cat/.zarrayUT aaux u0E|5 qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "vk"cRtXsy&4b2t!.҆^CLX!C ~vg1oY)cGx-7PKvSAXK",var/var_cat/.zattrsUT aaux RĒb%+x_,UPKvSDl"$ var/var_cat/0UT aaux cb4fa``a a`cedbbdPK vS var/_index/UT aaux PKvS)kvar/_index/.zarrayUT aaux uM0$Cjia?$ݶPΪuIj hmV5}"g$SJHWPV586N -<ǰ'N'xS9hG)cU=8z/~~r$h"j&6Y0_`.BVo&"eɅ숶"YPKvS!bH\ var/_index/0UT aaux 5;@@E;R2oTV#:e؊5XSL5HIoWNvlFJ8 /FCzD+k*Ԗy )T /PK vSobs/UT aaux PK vSobs/cat_ordered/UT aaux PKvSL\7obs/cat_ordered/.zarrayUT aaux u E~ cm?i EQ&B[6ཛau|ޫdq&a&om_ `*<(s81 2}96Bns1/$Bl.;$@+(b:Jkz_Q+5zPoPKvScBiHdBQfwr?BnI\[ѰÄȢ%`UOݪ=.W S8ʁEI$k(R)P[9/LP/\rY#@%K y˯69{oPKvSPr}-. obs/uint8/0UT aaux cb4fc``a= ط7vݳdXyzfPKvS} obs/.zattrsUT aaux ]A @ E=dmK*"C2&2M"HiV?. /̽C'Fk9{<"2&q Vq$!߷5W (ac#>LR]PK vS obs/obs_cat/UT aaux PKvSL\7obs/obs_cat/.zarrayUT aaux u E~ cm?i EQ&B[6ཛau|ޫdq&a&om_ `*<(s81 2}96Bns1/$Bl.;$@+(b:Jkz_Q+5zPoPKvS8e",obs/obs_cat/.zattrsUT aaux RĒb%+x_??UPKvS.,. obs/obs_cat/0UT aaux cb4fc``a= edac`facfbPK vSw obs/.zgroupUT aaux { "zarr_format": 2 }PK vS obs/float64/UT aaux PKvS% '9obs/float64/.zarrayUT aaux u 0E~Z}A)3J4&(FfPo꦳Jg.s$u]pM瘇3i{R̆5,x דr8V.Q WB}u e_MկTJ¬v|vl h]k3`9j5@ŰGo*ܒZ$`.L HI0 kx|pxNʡZǾtEgFgPݫ?????P?????PK vSobs/__categories/UT aaux PK vSobs/__categories/cat_ordered/UT aaux PKvSYk$obs/__categories/cat_ordered/.zarrayUT aaux uM0%Cjia wmY߼μ)I]M*pɔҩU/U>f,C1I煀co*(Ecj9KC/r bi ȇ}z"F y^Ȋp-T5 ~H,N'PK vSI$obs/__categories/cat_ordered/.zattrsUT aaux { "ordered": true }PKvSP*Iobs/__categories/cat_ordered/0UT aaux ı @L=AHARى1"Ep{,!$Ko*UVZuՠFMhզ]7PK vSobs/__categories/obs_cat/UT aaux PKvS}k obs/__categories/obs_cat/.zarrayUT aaux uM0%Cjia?$mE( g~:^$u2]W~G6+DKN5-*xG>[9E60tÎ^8!uhUe8G^JØm|xZGdh~k7,BUDʂ dyb?PK vS" obs/__categories/obs_cat/.zattrsUT aaux { "ordered": false }PKvSXoNA}obs/__categories/obs_cat/0UT aaux 1 @PIbP%I "b_'1)UVVz 5i֢Uv:u֣PK vSwobs/__categories/.zgroupUT aaux { "zarr_format": 2 }PK vS obs/int64/UT aaux PKvS^/7obs/int64/.zarrayUT aaux u 0E~څ}A)3J4&!Tߛ iMg\Ex¥?͡G =LFXM֯OԼjՏsO9@"r`<ǶR"-"-{M1!脡^GLPVP }vmb 3 m_loPKvSt֔J_ obs/int64/0UT aaux cb4@,@_G߯"zEs]w&3@99`PK vS obs/_index/UT aaux PKvS8Vkobs/_index/.zarrayUT aaux uM 0BfmAJ4&(Fz&j\tV7/3eLRW@+5 #}p|fBdJ T㢂 ڨz`)2s ;z!pץn1V}>g9MC6j=lu;I/d|p?l R\Ȗh';$PKvSh~TLf obs/_index/0UT aaux }; 0በul`+VFr'aM'=EvxI +K -XAO1L.FD+j%H Yi&%wn"yPK vSvarp/UT aaux PK vS varp/array/UT aaux PKvSjᩪQvarp/array/.zarrayUT aaux  0 {9D"v-VIց{wRGsJ>'ɚ@v-̾Hދ6Ó!X*x=wr8qD5+tQ ^ ]㡋bbn K3!OQCg6,+xR-bπͭbD64Yl{PK vS|0 0 varp/array/0.0UT aaux ! 0 B 3XpX|֬wxH6 p*X0,xlWn3JpAZDP"L/zPl(|< xd~4p~̐h`j!H(jM.l}f[`\WX0z4\<`TҼ %IH3nZYIp\ FZn9OdGJzеtJLV!ZAؙ"(eo,p\e@LܘTRj: 2*hldVN; PH*8Mє@Kpܲ'8 %.ZL)@Rb":­t4 2FПܮ^R@OFP]8]F%;g=KeSB ϔX<׶;X 5~QSr;yzPe9T~K L>l ;W;FvgDuQz[hKQRw돲S+."nZIυç6L_o!EU:d"1;?vʞO6zB|R\wz U<=s 76ߣi "4~Rl$GL\68-gh 5ƋnG{y`w5WAmM/ͥJWژi͔HuK&7m!e;M.ۖOƈ ֜D" kBjkZ^/Wr^}`H~.,/wJ*)05vK> O$̳m uל1Z(Yo#HX'%*URUBvKsCzb0lvhׂ&AwN@Я0"mT9X҂ʱ }ƀokļ]Hax3gKaPz@ s>r:HsZzR6ZM.{D7e  0 4'&fðY7@jSDmª'AUvJ#(15aZD/ {,J +0u%rpl7vU@؅Tm80A7%cZx .Fu5f>A/#|WM/ Ф۳~gu~nܯ^>/ҷZwWV2@Yi";FKGn?\GяC!7Z^m @r&+J_v850@;OSMc,f4ã ]svpE\lb3TByT,^"gmBJUV7S zQ"+`{HmƈḪ..o\EX aN( qz$Y^^ʅ{c~b͠T1&c͞k>c>9h`0*Qki?m7w{kBtȨ E]pii>is) WB+ _rLr$g7vqQШ(Fg2lk 9Kրܸyhʨ^¼hJ4w"#/W|ys\#9!"q+B k{f&;_U~kMZƈ _π'T5oARWȕ~ g`Uh]JH0N'[ ȷ|O-9'Vm;day5Sپ wP}|W[:"^c0èἼD_mhB_r0qɬ>>KGРh[R/Ǽ٪oJS0`C;1<|ͪR04\ ´5ٻ"vMA) .T;%'M_xpR}y4*#b3l!rQ{2:w?ϦdQ˱YDt4b&4aDNE710{nF ߷߳s鿤ᶣݧsܷӎGܷ߻֥Z‘ʤùӼݨֳ ?xP?????PK vSw varp/.zgroupUT aaux { "zarr_format": 2 }PK vS varp/sparse/UT aaux PK vSvarp/sparse/indices/UT aaux PKvSSP5varp/sparse/indices/.zarrayUT aaux uA 0EBf-vS1J4&Lݛ fY%7kup˟S׫ +lYT%Iacp)!fLk zs7G hXM}rQ=-ՊKuy/Җ:1mPKvSX varp/sparse/indices/0UT aaux cb4f```a fb^ b PKvSrLIsvarp/sparse/.zattrsUT aaux RԼ̼tݒʂT%+Ē %4%eEřy Uzz0``. XZPK vSwvarp/sparse/.zgroupUT aaux { "zarr_format": 2 }PK vSvarp/sparse/indptr/UT aaux PKvS.7varp/sparse/indptr/.zarrayUT aaux u0D|3%z1 cH-mhX(R! ڋ{},Yd%Ǜ$u}pMg 9iIB#[nfpm\:x^t^ Ecj|!/ @9bEP| !_YP'FgPK vS500varp/sparse/data/0UT aaux 3 0]m?RuI?޸?,bM\?PK vSvarm/UT aaux PK vSvarm/df/UT aaux PK vS varm/df/cat/UT aaux PKvS&Ѥ7varm/df/cat/.zarrayUT aaux u0E|5 qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "vk"cRtXsy&4b2t!.҆^CLX!C ~vg1oY)cGx-7PKvS?(varm/df/cat/.zattrsUT aaux RĒb%+x_TPKvSMft"$ varm/df/cat/0UT aaux cb4fa``a ddebcc`dfPK vSvarm/df/cat_ordered/UT aaux PKvS&Ѥ7varm/df/cat_ordered/.zarrayUT aaux u0E|5 qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "vk"cRtXsy&4b2t!.҆^CLX!C ~vg1oY)cGx-7PKvScYkr*..u"- km(PKvS#$varm/df/uint8/0UT aaux cb4fa``a ~:/e?'lr.C_PKvS#vvarm/df/.zattrsUT aaux R̼ %+8K"S_ZBdK`xd̼3dD4R" %0?jmj^r~Jf^nIeA*U)%iEJ*R3@ jPK vSwvarm/df/.zgroupUT aaux { "zarr_format": 2 }PK vSvarm/df/float64/UT aaux PKvS1+\9varm/df/float64/.zarrayUT aaux uA 0EBf-J tc"iLPwo꦳Jo>$u}pMEt)fCZ~ȖY9g&xSyX1Vk xK5sӗnI  |"FhRܿ\,JmӊdPK vS}l varm/df/float64/0UT aaux 3Y?xd?s?^=y>?+ޤ^?įbC?We{?Eʣ?Z_]?A ?? źlT?xI(T?yT?S'?3?P[1d?ӵZ?z? KJt?PK vSvarm/df/__categories/UT aaux PK vSvarm/df/__categories/cat/UT aaux PKvS\Hjk varm/df/__categories/cat/.zarrayUT aaux uM01Cjia?$ݶPΪuIj hmV5}g$SJHWPV5m"ZxaO:'rЎR4ƪ6{{/~~r$h"j&6Y0v]z!+P{aBvD[)YPK vS" varm/df/__categories/cat/.zattrsUT aaux { "ordered": false }PKvS6=nvarm/df/__categories/cat/0UT aaux ı @;FpG!jhH6b/sjt5EhtEnzAPK vS!varm/df/__categories/cat_ordered/UT aaux PKvS;YӴk(varm/df/__categories/cat_ordered/.zarrayUT aaux uM01j[!0PҿD6eo^g똤Vk\Gfh{ɔҩE/Qg+,CO1H녀"^QXUsK}r5ba ȇmz"vF ,i^Ȓp-T ~@,-NvH/PK vSI(varm/df/__categories/cat_ordered/.zattrsUT aaux { "ordered": true }PKvSdڮ;d"varm/df/__categories/cat_ordered/0UT aaux cb4f a`` bF v. D "@/ "DL" @D9PK vSwvarm/df/__categories/.zgroupUT aaux { "zarr_format": 2 }PK vSvarm/df/int64/UT aaux PKvS9 7varm/df/int64/.zarrayUT aaux uA 0EBvaR1J4&$B$m6Ux&z7 .=4u|>dqFId[bO0b JEN| "2}SppOt5ǘ>') .\vHVb59 v2`.<-rVZSoPKvSdJTvarm/df/int64/0UT aaux cb4X!@,6@AՇ?pUWfG?0g  @_/(PK vSvarm/df/_index/UT aaux PKvS)kvarm/df/_index/.zarrayUT aaux uM0$Cjia?$ݶPΪuIj hmV5}"g$SJHWPV586N -<ǰ'N'xS9hG)cU=8z/~~r$h"j&6Y0_`.BVo&"eɅ숶"YPKvS!bH\varm/df/_index/0UT aaux 5;@@E;R2oTV#:e؊5XSL5HIoWNvlFJ8 /FCzD+k*Ԗy )T /PK vS varm/array/UT aaux PKvSrmYQvarm/array/.zarrayUT aaux  0 >,Ʉ1vc (F+kKz\N$ْ֌=-}UgG_澭Väֱ*x=u8rG'\/qF1FJt{إߓkЃ녡vW1@΀M-beZRR$PK vS٫varm/array/0.0UT aaux !@@̛D`bģذ 4$ jHTp-+elkhpmjTЄM 2f~rbd,:(VFRmThr*nUɤ9S`E I8BI@w܆`~j65><|DD(zhE d :7> ,mdĐ( (+ @8@jEhpl^.询 ˅pU:@i0*K_8M>NrVl4U}ԕ1捞%_Rhl,TL`-!ӕ G$L\}wS TaSV(|dj>zx` ?|(LYx@"lxv8lXz`L0X M,b|+n\ Jd CD>ؔ0c`1/D H VLȦ~gG4|0_ xXv@1<j"L2xbĬF.lPwv-$ٳiL@u޷$Xeh6!wvZ٪݈p%,>" i8ǚ]FX; kH0J2YD 'o&c* F9ԩh'J=0_rHdJ7:Bݗ*B)p ]SgPޟFL=? ul…+) /`8&J4fu}E=zjpf/൛\MĻnFPiuJ چh3G334ۃgeM OQy.`x<žEfI}NT.^XߢAn^*@GcRK#n/g{{Ц178u2e2miCyr&QKWD(q#rWtkv[e[*Ls|)xDܯ$FMn걼wfIV; cMl`WXz 6{DWud5tD ;"J>/YKtM1:%: ;|Km&2+D^E h;9(-Vw5^_I)MpLOe@ rw))=4>kQ,އQ쳺3lQ6 M6'ZS:mjU{ n<Ƥ@ hta^k\7ec b$gq.XF|nljO!J3% NVazlgh |_rJaɽݰ'+68FbrIkkm3))R}6;3룐a/-\P ۹5d۱&b\]6'fu?nlufnR)ׇY|.(HJ'U@Uhy`V7"7V/Z˫O,\r鍸NqHX[ǫO{ ̊4t_75檍cmҦwFX-fg u*z.$'8D`*+]<#*(n4gGs;0\Y;pϩhS,5mpo}e钍˗4+R+c&pћ dEhM~fOŦJiv/eƷ+QhSX3YzʅijB8p(XQ[sqC*AH*l[̽gg9+"-iYhMX~B}ǡ۞-nޛ5Y8M9Hi) Q,t#(qE;vĈ#܋b*mur$)ĥD|\(A m)F)Oc ^?L{j]%|IH.]Oŏ+ ԸZNEiſ+]UIKuIOPtCjkÛ- or4x2u֮ :n0腥J8)Z֛ Or`dJp$JAdᭈW TAýYE%Vݱ}^zYwfW5~IIA WYUe #I#1h/xCjo8lTRjaୢ1ҿcad?l2hC끽hBϸY:<[\Z+sǝu k :bNOBy6;ji5 tgFAԗ$qU!I;;tp}Pdz!wßT }gdn!R.!])a0f꣥5/m(#hzB+3n'f"_˝25amD݄ O*bY7mos q&.")ҤCpO&-B.76`©RR7YyovGtMz)@3a`[ںC" rntF7 -@$5#]<`W{BIm"JkwUhj %qDYIqL48 OaML u hpb^$_K p.ɯȷiA{4~:<bsea___֧ w3. > ,w^BRC,?Yi:2qnk!Pֶ~ H*Lh(S ҫ6M3'fL/OAe ?Cl ЋQbc]؜"T ZkR}%SjO)&E0U8quOo8]q:g]gKC}KU_zj2o/9DɎ[IkJp&hPcu/T.8_5Ql/MgjI&#`GԬ(.Tj^;1R%U;8yMU<螤)%1K^YS˶Y[u&허*īXT`Į5j:e$LXnR8Op"BRX,3%: $fpbt2NJzDң9[#e}r\ZWe2;'xaF,ŽsirkoHg-d|sShd94rFKHBBeT ȷpo'hb _sHuT_E[=2Y?}PGhYob쏵x]}2V cmi\C܏cv_4& *$o/Iއʁ&*ʂԀ?gwhfU!(=dJ-Ʈ s9eŚ]n/w0I$.̓ Z-6۹JET|1r+-Bج(Z9+j)]bX~Ӓl*xgщѶE'm|_mepPlȕ*5fKPsVOr dՔPdΠ-x,%xK9aɿwSF0X[xv,MHT(o"t8t2 c}zfoڱ@P耎^kq(LweqRh̨r(S>ΞgA*-2 {WP*9ru]=b@ؙ[#UlKk|r#>$B" ׸Ī߽ԌՊӏ鷳ؿqrΦЖ߹ܷޠsҟƷ߫ݺ״˒ۢٻѣ녚ޕ緩׾s|ҦӰٶ¥꺲߮ߋ፩۩׼ѷ՘۲Ʒ薡†޶߬ӻ?P?????PK vSw varm/.zgroupUT aaux { "zarr_format": 2 }PK vS varm/sparse/UT aaux PK vSvarm/sparse/indices/UT aaux PKvS17varm/sparse/indices/.zarrayUT aaux uA 0EBvaݔzR$ G#3Fݛ fY%7kCG?⟧*YGD~-:,~\5xI {,gp0 QRL7xL1#g >9ᤑ{ `%إVƼXVePKvS]<`varm/sparse/indices/0UT aaux cb4f ``` vb6 b b+ b! Vb; vbF bi|01;PKvSƿLtvarm/sparse/.zattrsUT aaux RԼ̼tݒʂT%+Ē %4%eEřy Uzz0``.ن`v,W-PK vSwvarm/sparse/.zgroupUT aaux { "zarr_format": 2 }PK vSvarm/sparse/indptr/UT aaux PKvS.7varm/sparse/indptr/.zarrayUT aaux u0D|3%z1 cH-mhX(R! ڋ{},Yd%Ǜ$u}pMEt)fCZ~ȖY9g&xSyX1Vk xK5sӗnI  |"FhRܿ\,JmӊdPK vS⽰varm/sparse/data/0UT aaux 3愲.T?\Nֶ?38??ζd?DNؔ?jz?3~Y?O ?Z?;pF?*ai?2Q?*\.?8v?S.?΁> Ɨ @/-\+!= |~~uqnh`-bZX6y)cJs:հl"ܿ _+C 8jl,qz $[BiBPd^KTѶ Y$킹8]9g+mthTI.@V"qɚu>3|0<(MJ<إN`H\SvFU)US`)ln3tuv7d6 P$E/efO9?ݠYXPM!Y:6qV->oPK vS uns/nested/UT aaux PK vSwuns/nested/.zgroupUT aaux { "zarr_format": 2 }PK vSuns/nested/scalar_float/UT aaux PKvS\kuns/nested/scalar_float/.zarrayUT aaux RҼb%+XH~nAQjqq~P44'*RRY RIPeė%攂$ %EŨRRAF*9tg$M[]XT_X6PKvS'uns/nested/scalar_float/0UT aaux c`PK vSuns/nested/scalar_int/UT aaux PKvSiuns/nested/scalar_int/.zarrayUT aaux RҼb%+XH~nAQjqq~P44'*RRY RɴPeė%攂$ %EŨRRA*9g$̓[\XT_X6PKvS uns/nested/scalar_int/0UT aaux bPK vSuns/nested/nested_further/UT aaux PK vS uns/nested/nested_further/array/UT aaux PKvS{ @5'uns/nested/nested_further/array/.zarrayUT aaux uA 0E=Eu*Dy ӄN4[zw3!j6*y_2 Ώ=?O_* vx:Ed]֯'wՏKTb`p9BM0D1u^k}[^s16,g0ZGLкVq ~v1_E8h1PloPKvSz|b8!uns/nested/nested_further/array/0UT aaux cb4```a `LPJ@iPK vSw!uns/nested/nested_further/.zgroupUT aaux { "zarr_format": 2 }PK vSuns/nested/scalar_str/UT aaux PKvSsKhuns/nested/scalar_str/.zarrayUT aaux RҼb%+XH~nAQjqq~P44'*RRY R 5Veė%攂%DKRQLTr)+H*(>-(7(lU PKvSb- uns/nested/scalar_str/0UT aaux +f``(" PK vSraw/UT aaux PKvSR8Dl raw/.zattrsUT aaux RԼ̼tݒʂT%+r%4Ԣ<LAqF"XS4 :pU PK vSw raw/.zgroupUT aaux { "zarr_format": 2 }PK vSraw/var/UT aaux PK vSraw/var/cat_ordered/UT aaux PKvSz AI7raw/var/cat_ordered/.zarrayUT aaux u0E|5 0qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "BƵG1N) MGE,9Ƽt|P[iC!&h\?ԳƬ픱#[v*7PKvScIW8F~} Q퍁gNUqPK vSwraw/var/.zgroupUT aaux { "zarr_format": 2 }PK vSraw/var/float64/UT aaux PKvS ՟9raw/var/float64/.zarrayUT aaux uA 0EBf- tc"iLPwo꦳Jo>$u}pMEt)fCZ~ȖY9g&veo^$ $U&jΈـLE@1k7H\o'LCzI "zON< Ԁxث????? P?????PK vSraw/var/__categories/UT aaux PK vS!raw/var/__categories/cat_ordered/UT aaux PKvSL̴k(raw/var/__categories/cat_ordered/.zarrayUT aaux uM0%Cjia?$ݶPΪuIj hmV5}g$SJHWPV5m"ZXİ'NgxS9hG)cU=8y/~~r$h"j&6Y0_`.BVo&"eɅ숶Lcm֣#`4/`.BVo&"eɅ숶բU?PK vSraw/var/int64/UT aaux PKvS7raw/var/int64/.zarrayUT aaux uA 0EBvaR1J4&Lwo&m6Uxe&z? ]{4u|>dqFi`m_=U\W?.@.S]8N"<@;I%k{_9b XoPKvSg_`lraw/var/int64/0UT aaux cb4p`d` Z2nP^UVyϷ|({~޹?00 b`f``g`$PK vSraw/var/var_cat/UT aaux PKvSz AI7raw/var/var_cat/.zarrayUT aaux u0E|5 0qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "BƵG1N) MGE,9Ƽt|P[iC!&h\?ԳƬ픱#[v*7PKvSAXK",raw/var/var_cat/.zattrsUT aaux RĒb%+x_,UPKvS68raw/var/var_cat/0UT aaux cb4f```a fedegcbfg`bdeeaPK vSraw/var/_index/UT aaux PKvSYzkraw/var/_index/.zarrayUT aaux uM04Cjia?$ݶPΪuIj hmV5}"g$SJHWPV586N -<ǰ'ND =\J?sr b945Xh?/v]z!+P{aBvD[)YPKvS6kraw/var/_index/0UT aaux ;@@3D#1cT6CtbU:˰ pw'JԮ@IwZ.RKFJtC!@.D6 SG 5dzEg?ou6>PK vS raw/varm/UT aaux PK vS raw/varm/df/UT aaux PK vSraw/varm/df/cat/UT aaux PKvSz AI7raw/varm/df/cat/.zarrayUT aaux u0E|5 0qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "BƵG1N) MGE,9Ƽt|P[iC!&h\?ԳƬ픱#[v*7PKvS?(raw/varm/df/cat/.zattrsUT aaux RĒb%+x_TPKvST68raw/varm/df/cat/0UT aaux cb4f```a `fdabfbbfdcePK vSraw/varm/df/cat_ordered/UT aaux PKvSz AI7raw/varm/df/cat_ordered/.zarrayUT aaux u0E|5 0qgCjaCIwۦj7Ϊ=9s3w/J?{7 a:>U28[Id4| I9UNl "BƵG1N) MGE,9Ƽt|P[iC!&h\?ԳƬ픱#[v*7PKvSc$u}pMEt)fCZ~ȖY9g&vGQUH]̫?ˮ c?`N?%U *N:AD eyq\Kޕ~9->]s4W+͎"cMT Ctfw"T$0ד*]E ^w,]אK 9Nlh>n&w/,JZ}z^v@bGHf6ήئ????? P?????PK vSraw/varm/df/__categories/UT aaux PK vSraw/varm/df/__categories/cat/UT aaux PKvSaik$raw/varm/df/__categories/cat/.zarrayUT aaux uM0%Cjia?$ݶPΪuIj hmV5}g$SJHWPV5m"ZxaO:'rЎR4ƪ6{pp ^*=ȁ7HD>Lcm֣#`4/`.BVo&"eɅ숶dqFi`m_=U\W?.@.S]8N"<@;I%k{_9b XoPKvS"ĈUpraw/varm/df/int64/0UT aaux cb4p`d`f ⏪؄?ytN ;oh\蕻ԤgTL2|b`0x5v5PK vSraw/varm/df/_index/UT aaux PKvSYzkraw/varm/df/_index/.zarrayUT aaux uM04Cjia?$ݶPΪuIj hmV5}"g$SJHWPV586N -<ǰ'ND =\J?sr b945Xh?/v]z!+P{aBvD[)YPKvS6kraw/varm/df/_index/0UT aaux ;@@3D#1cT6CtbU:˰ pw'JԮ@IwZ.RKFJtC!@.D6 SG 5dzEg?ou6>PK vSraw/varm/array/UT aaux PKvSQraw/varm/array/.zarrayUT aaux  0 >, 1vc (F+kKz\N$ْ֌=-}UgG_澭Väֱ*x=u8rG'\ x8hǘZ#%:=5AvENB+Gҍpg릖Ŀ^2kfk$PKvSl66raw/varm/array/0.0UT aaux %w8oP!!+##ʖHd$![d] {o {\׵Ce|yy_P]᧍~,x A$JxҞk,D[]Jfs+L?cҩĢcJeتb#5Ժp7c۰c{N7]Y8ɳ4-E-/;h5CdDBb}u*%2Vj!TgXǁ5<3CPs겗/1VVn]c'/CF $h/8c"Xm,˱zs<F厼W5kG}'AU}oU i Rmi7[ENzT'2~=X C?ITu{#Kåjx3fи>5@=᛭ҏNA{0p,h4LO2_y0k$,,&udNߩ b-+84MkȰHc ӣf|tb^-ǂQ:c\ӭHDA0iF df85tݿ0->{G[GDD1,w|.n3$2N]E s!,mujw\Lo{@xpX$=ڮK~">_'Ymj`f cGT. >Bfl.T>0ϐ.o(Q.Aٰzľ$6R Cr 131 ߻wT 'Y}q_BǪU?)cЩjCP1?r4b%=)fؠoTʟFRkTyAo-*L5)wl96YN TA4H# TV"{TIѶ{R.|Z'U)v@ U95fy 8 +r raM`sWR35'ڷY`d#Z1'eUU+dd|;0A/9[x0!췎' rH%='9%E i"FDM~[\~2m_ _N36Dˮ_#KѡaĎǷT+j #N&G?1)hNۜ<4TKbzSy䷗e)TsJ|l =eM *))ZI&"t9'VRw.m1SF~xg a4!oTѼb`q l?ЏLHhJɤ'TR$0-x]xVG+jNTM"nRX=>R:$A =bAjfF3k15Y#[|""i KYvv;. EWȀgAye9Ԓ27rߠ}*8ץEӋL̆@0#xΧ{A49aՕmi%>!]jfӪ1wΰ8ajl7tZLW MUތXn0s+AczHyDq(\Ħh6OHB?`hyu/QڧQn;V 12 cZWό9v.HWXN|궡Q*Fg51Nl!+Q?]3 IȳV5Dug8omrm4b''5M (b9m!< ,k%et-1+P~VXe1wMu:9ߟ:^Ihz5Euq9TYVf\7?/9.?O۬*Qg 7X-Ǯ_s}P0A߄˿ P!:]=}i[ԚֵwEqx5w>T~;y!Y,0ل9_ gfr"mO"d.=9.Nf{/V{(xy؂Z#WۓF[eQѦ]Ϗ줽B$˽3AL"0ZkT>u_'oF1 {b8\ St/nQc`!"nt hז=8˂|)m>:M9Yk6zjFUViWp-hCByOw*2-Tm#koIds˦ٚ(^&|=s{ ݂@:a =P61ckB#oﲸjl0~=H>:l{` .l˦oSqyk爺*fFS2)K\ܰ\޸Z) skwfU}]05'v5hsOƳ?h^棗6E&{;f*eGwi_؇v`a.}؝>: >jnjt5qu!R7'.y."Z^(rC&@Bĉv3[/iǺHh1q݈@Kү/ešB~<ѭ\.P3$?r Y^\N*TnOg`ۿmk 4R)e[Qѹm2˧nf©XMrݖI KlK-~kï&}^>ͳ8n1dbfMMkF|N}v$yR}3gz.+sՖrc5՜1ѱtĬܨ–nRͧuN_E8=tUK¦t=_]#S\mֱDLq$+hv^ֶ 6K -+SklM $ikixU& K"5*`2m2޹b/X{D+: po<8 h; 6.1OOA.;t򭮞U%t}:hh2}4̻PQEsˆRr/$IK\¯ZIa?Gew bs՟޾imxl^U?t?-*;;y#\8)sSl!,u O]6 ï}*5vY.8 v~pWH|_ / *+>^ƀm7%?-ktִq6h~תsm+E=oRGf酪2:4~=`Fnm-mT%kT* =kUɷQPvJ}LHlJ3N~ R ːmY+Z|-:m|lr ‚}QҼ-$GdhdJK[sOPϑu#ߺǷVOʰ|dHBրs:k7S $ξkMꛮ.47<$R#@߻#7h<4(_U5~$:sjh=nw-R$AUzڵb!^l`Æ};̓kqf9TlV-+FYRG䔴%Q*TWRB>2=kt:{շ2q_ǿoq qzgQy:ݿ]u~qq?ZԞ[qpn:t0LT&VNUԧ/ 2v.;{e =??uQ yy@ُE\]z9sWlD#_yTv.Fׇ@sq%6p=tO] 9 49>2x,[y Ÿ>g/6 زӦj4LKxJ1Ҍ fC(m %~ ZbNUl5uq{A~ʨ!w+7 \|Yx3uGB[od>GhYM_X%?LeUM~aܭ=LZ\2K=fP1~X)iGv# *Vޖn5>~n^uUS]T' 7 O#Zx*AN~Gyr2h:PB]*ݼ1,L]^dRQ{Ocv-۩"}K^ȠtWv:4 8:nONG,| g}h6m~{juSn΁'r6]ĭ e%ٳbڀ$;CZ`,}5-c^Y~X 酆JWT: l'9Lb\a8ZƷe9w`$m";תE F< Fj&cꐽ]jٔXU/V s)k*(BƳV\kcH%NۏL:Yl醟.yyHf):fy+9O [@mu?:=aP.q'uLXf] ld J2?k9: yCqrG/.άꭵkvv Hp?Ҟu:e-<?^2? .oJQ5ӭx*upئ۲upRbG@!5^s?{\h$DxD:ִbpewt c3i`CkZIt.iQU1ӯr'MM`~T!kS}U"\nv P$!=UVd(ؐWW#ml E@=Nyi`֒O˱D۫5~=nR"IWnO>8[^/S) S), luM5G!Sk< X#~-M=1NE6uOG'LY܃_MLc;U_19Rݷ@q_S\TAkOK:Ə-OGhaeq23[M~E]BaoUPwg|ؒz;mwX>}i1#Agy'^7mOW|50. ^q4Ch>_g5ٿu8sv2^߼?2g/#A_̘",6{PhDrkZE=Ra[~BL[ZX`j(m/f H1:1ݺrQMz_=5ޅ 1ZGeگkςrm{.']3u+GVo(cTc!ft=.S mWsF/?p َ,MLnը5Jh(tڿYڸf2&-85w3R*&'Bv, [.*ՋW4?R./Ti:|/n-G} `^"տqFM6jQ~̢٦)rL"jZU;ixW`8~l64=_vx},N_P8DW@鼻y-j"֌;VnƏCf&p}wjLdlUw>>X{{Qf&z%RR08ullFKŵ_wEm ,(J1J,$oV]JWg Vdh=`VTžϯa ĘVFhױ.smxrU 'ali#QUcpR,iOS09edK [];2N'u>Ys/oIϐOVҁ9أ##SwռưGN0QiOќ ^Ӿi־y&/\T{>y%i\HQa|@D`÷=uBèK'Wǒr+[ړ>~pqJMWn󾺾l*IܿiC%MSpvD+X[x(/sEBQ-JkOһ: { _~( ݎ8.t]6e+zfNjvQ7HniDg]R2 WƩksxV1vl{XmXI7by7qӡ84OuGq#_mzVӺ}e;iext |$jyﳘDӟxMYk7¦t 掮@dSMG/x=Muga4J%SDGs~nLc{-W405Q$ B?z5v^x }l?EjL4vpKz,yS ɹ$Jku?< ڦ]x״>_=_ `ce4dᄄKo#ǏU6N8E@X˧ibiyR짢Dz(T.Y%cj BPvZc3e F.jՕֱC] Kq7$¥u:]7ǧƷO臕WJ2%pbG " ;JWK/l+KUfa=Q<%/>:ZqQ8{eEy|bzV2lAUy}w0Khg+as%2úck"rlGF}I~yQt/PQй%¡W]8܈_;V'V>_b5A]u-a<:Sz9/_2Br=8hZάߊZϑn##7y:sOd2IzV,{1og'6IxdmSS5Fq_>a(I,>Q?D]G7(QJ\4]7@O>-Ew ߹Q_衮 ch}zI8xihlJ`bWJ d3oނOdz]}m6N9& V C0ɦb+=`MؖEb4w#=TȬl [+)J~/ԨSY{Q>q=|8bvk>tvܲS˖-l=(rmPm|#GZI~;1`s j#J)el2k=3RQ9,v_g/4e_Q#ӫI{}oDdhY?\\\qyZ4{ J#, |yk'(-9+d/o]r. ?mV*otY3}i^]RUsBUK=*ޘH2BI%g^:_}bI*˓j)Ŧq 9kƫd Nj*w.$N=^x ]Mt Ҩ\I??s dx,zdO"aW|,,~qxp(oi垷oh8cZ=vRSFc4s_$Idl%Nz"z)vL9w*$8>J@O y7sFjr09^\D`-76$V|:tncX\"-=NZڥ^[b3>ܼ8JUk+a`j`R67w|{ϽngհQȲQbY񻞧y9%hwS۠d7`ԇIYğPxxPEǦ/wNd2|_]ӽm,ŲNO}9 J}@ c7`/XyvfA$& dL8;wIS^tx;(QAi8ߜDߐaS&Z8hd@{?Ď|O2+yWKT}jH~n'L4= [   LYqQր AXm\JaRz )o2ZwAqQu=Z%cz1^#'dAM@yLgCHә$K5z?Z4> *CHʥd@E}]$'>f[C=/-Ӯ< o,k/\frW0te?XfudƛסLs1:B}+Re-;x!ZJ% #>hsk]c+MH9uXK6VE1C"ͱt:3gߕ+*.^WÊZ[!C5yK:olU$oe}wW_BApg7oyu SڣC ӜwҮ?奎`#j%>41\c3 3}mbݑ@O| l:/h|l%ˉuXUoigxDu{9 ԫ)w̸5><u+^k$u}pMEt)fCZ~ȖY9g&vb0U@im~q(AxHBwHX} T&^'7_iWHyVQRr☂zVg{V̛s2qyBp;5X,9a2N_ШAY?ߘ餁&C7K-q:@0p|v\ܑ tg㱿ײ߻ڦ????? P?????PK vSraw/X/UT aaux PK vSraw/X/indices/UT aaux PKvS;9raw/X/indices/.zarrayUT aaux u0 Ewyf[?PaHe&Y)9:] %:ۿH1[VvlfqjRaAAKHiQ,S̭1u߯s5{ hXO]rHPlpԨPn."o? Z[S~PKvSٯK raw/X/indices/0UT aaux  D7DT犸30~\3PEQp >6!a '!?%$J~~^ChUyYHeUv/G`I^]?,yaNlM.t)na^EcQthPũl-&TȲu8/qѴiu~Hcd](G Yϴ3 v5 0Bc^)y۽ t y#.Je&Qj cہ|*]\wR. n_,@Ƞyaj >,JQx٠Fp_ȧIsqv `'#6?gtPKvSXKs raw/X/.zattrsUT aaux RԼ̼tݒʂT%+Ē %4%eEřy Uzz0``.&`f,W-PK vSw raw/X/.zgroupUT aaux { "zarr_format": 2 }PK vS raw/X/indptr/UT aaux PKvS o 7raw/X/indptr/.zarrayUT aaux u0 wyfRucTJC"" A6A*woBOݜ~@֮k.=a>Ua۞%*x Iy+~\zxNa' NpSXk5OeR >\f @ pPzuq*JmZD'ATjK-oPKvSL'Vraw/X/indptr/0UT aaux %!@`7&I" ݦ)DQr Ua۞%*x Iy+~\zxNa' NpSXk5OeR >\f @ pPzuq*JmZD'ATjK-oPKvS,~W X/indptr/0UT aaux %`oh`aLwA8q0y2JA_~w3=z2bjfna՛wk6v|;8qrPK vSX/data/UT aaux PKvS;X/data/.zarrayUT aaux uA0E̚D]hy cH-m P!ݶaڗ7?(vmp';],i:R̆64x5Wr8M6.Q a+/gEc.-EB]1+|h!tý"_ 4T(_6"$? \jD,PKvS#_e4X/data/0UT aaux e1NA EhW$Y@ iB)h#A R"\3 m[.dlzu,~_9jPMc加؀_<՛|ľNp %Qy瘃Ob>UjH tJ5CVummS 8Ƣ<bRGȋNPK vSAobsp/UTaux PK vS A?obsp/array/UTaux PKvS'ٹQobsp/array/.zarrayUTaux PK vS zobsp/array/0.0UTaux PK vSw obsp/.zgroupUTaux PK vS Aobsp/sparse/UTaux PK vSAIobsp/sparse/indices/UTaux PKvS)a5obsp/sparse/indices/.zarrayUTaux PKvSa*"4obsp/sparse/indices/0UTaux PKvSCZlIsobsp/sparse/.zattrsUTaux PK vSwobsp/sparse/.zgroupUTaux PK vSAobsp/sparse/indptr/UTaux PKvS o 7Iobsp/sparse/indptr/.zarrayUTaux PKvS8&Aobsp/sparse/indptr/0UTaux PK vSAobsp/sparse/data/UTaux PKvSXj 7 obsp/sparse/data/.zarrayUTaux PK vSPXX obsp/sparse/data/0UTaux PK vSA!obsm/UTaux PK vSA!obsm/df/UTaux PK vS A"obsm/df/cat/UTaux PKvSL\7c"obsm/df/cat/.zarrayUTaux PKvS?(T#obsm/df/cat/.zattrsUTaux PKvSx8,. #obsm/df/cat/0UTaux PK vSA3$obsm/df/cat_ordered/UTaux PKvSL\7$obsm/df/cat_ordered/.zarrayUTaux PKvScq>N ͏var/int64/0UTaux PK vS APvar/var_cat/UTaux PKvS&Ѥ7var/var_cat/.zarrayUTaux PKvSAXK",var/var_cat/.zattrsUTaux PKvSDl"$ var/var_cat/0UTaux PK vS A_var/_index/UTaux PKvS)kvar/_index/.zarrayUTaux PKvS!bH\ var/_index/0UTaux PK vSAFobs/UTaux PK vSAobs/cat_ordered/UTaux PKvSL\7Δobs/cat_ordered/.zarrayUTaux PKvScvarp/sparse/indices/.zarrayUTaux PKvSX 6varp/sparse/indices/0UTaux PKvSrLIsvarp/sparse/.zattrsUTaux PK vSw3varp/sparse/.zgroupUTaux PK vSAvarp/sparse/indptr/UTaux PKvS.7varp/sparse/indptr/.zarrayUTaux PKvS5Kd޸varp/sparse/indptr/0UTaux PK vSACvarp/sparse/data/UTaux PKvSM%J7varp/sparse/data/.zarrayUTaux PK vS500varp/sparse/data/0UTaux PK vSAvarm/UTaux PK vSAAvarm/df/UTaux PK vS Avarm/df/cat/UTaux PKvS&Ѥ7ɻvarm/df/cat/.zarrayUTaux PKvS?(varm/df/cat/.zattrsUTaux PKvSMft"$ &varm/df/cat/0UTaux PK vSAvarm/df/cat_ordered/UTaux PKvS&Ѥ7ݽvarm/df/cat_ordered/.zarrayUTaux PKvScēƪc[iiBj7{2hnmƻR U^7/%rZY@1__fqR4DAJh>Vƹ Z9NV8ʩji){^-I"{v^P!XS)bRrKs(3`c07M4ZƐk+|\|z(P6h_-[@!Pk2n}?L %ddN"m,ǞDO97*~ɸ8Oc|nEB!$};{[2PK!U0#L _rels/.rels (MO0 HݐBKwAH!T~I$ݿ'TG~xl/_rels/workbook.xml.rels (RMK0 0wvt/"Uɴ)&!3~*]XK/oyv5+zl;obG s>,8(%"D҆4j0u2jsMY˴S쭂 )fCy I< y!+EfMyk K5=|t G)s墙UtB),fPK! Uaxl/workbook.xmlUn8}_`A"R7[B²dl"H%*""ZҖ4"Mkؤ9C9CwsϤ_ rRT]wi_VlBqn+ĝZF!ƱQޣB*߂!ꚗ,ر^@$kლк-pwb[,(r2텤ޓK#: \x)>h@U{HuކzsS+U|Še4ҲZI!yDN|~\37Lo&`spSO1KgA$NKꐳ/8'E @1ܐYnohFumȚwQs>ͨMujz2ñN/z/MeO͍-lhM[PK!  xl/styles.xmlUmo0>iIJ$(Ti&$XKd6H>{^R=3k"ĈRW\m2fYGUEV,fUUjA-c:,Ij/tH`k66z')HS")WGXO@$5&(lk.;tXrqQеm4%jQkA:8F[] %yɞҝ9 _%$ޚ"Ma{ˇYTr`U#p+OW,1&yZj rP:P.E%o\S׆{cM%uI[<l.`]L A# zCB8fThXUA[跷789.`:<v᛭uvZ-O+N7ZQu;z HdB~Hd!max^=^c{忇Em}w'uFi7~W`7To sF@tzDžjrόNSPb5 w:yU|' >vDw`{g]/o77Mp9 &, r$jU8>\0A & +`!فٖѦ;smAqFdJglzEūdyɈ{('',LpuձBc+ H+AbPK!N xl/theme/theme1.xmlY͋7? sw5%l$dQV32%9R(Bo=@ $'#$lJZv G~ztҽzG ’_P=ؘ$Ӗk8(4|OHe n ,K۟~rmDlI9*f8&H#ޘ+R#^bP{}2!# J{O1B (W%òBR!a1;{(~h%/V&DYCn2L`|Xsj Z{_\Zҧh4:na PաWU_]נT E A)>\Çfgנ_[K^PkPDIr.jwd A)Q RSLX"7Z2>R$I O(9%o&`T) JU>#02]`XRxbL+7 /={=_*Kn%SSՏ__7'Ŀ˗:/}}O!c&a?0BĒ@v^[ uXsXa3W"`J+U`ek)r+emgoqx(ߤDJ]8TzM5)0IYgz|]p+~o`_=|j QkekZAj|&O3!ŻBw}ь0Q'j"5,ܔ#-q&?'2ڏ ZCeLTx3&cu+ЭNxNg x)\CJZ=ޭ~TwY(aLfQuQ_B^g^ٙXtXPꗡZFq 0mxEAAfc ΙFz3Pb/3 tSٺqyjuiE-#t00,;͖Yƺ2Obr3kE"'&&S;nj*#4kx#[SvInwaD:\N1{-_- 4m+W>Z@+qt;x2#iQNSp$½:7XX/+r1w`h׼9#:Pvd5O+Oٚ.<O7sig*t; CԲ*nN-rk.yJ}0-2MYNÊQ۴3, O6muF8='?ȝZu@,JܼfwTz}vLm'U16!H#HEw &rcv"Ҵi% (r|R%СQ1)nCVhBȚjʽZ 4Օ9N`ה7w-(8LC M$TT#*ybWSthgL-ZxKgJFHgקztWjΤPst{ڦlt&׷%W+mHr^o4 F3dxyL~nr,],]l.N<'$QMW"&f>'u64} s'ē>⒃G4?*&5&WWX+j.П 6s]bI|qr(_#}q5Mr%02>2iIq[ԼKn`1#M; {;&NdS<\+KZ]*Aa BIt1!)ޤl؛an3K:B7( ݄|s7tx7%rBww?Y/v{(d[gc-Zֻ%vBkQC̱B=LLBPK!a5ս(xl/sharedStrings.xmltj0 {a`t_PF]Js56r)c{e1$d%w8UrЬ (/'P,+Ode;"Nk":"-KJ%A8a9"Jɺ5fKH3-uO{[GjV.M}lМͯSPK!ύL{docProps/core.xml (]K0C}~8?Bہʮ n(ޅlD{v)x>9%|'Z(bb )zQ`UZA`Ѽ*~@e'7aiJ,{rċI&#w)PK!^YdocProps/app.xml (Mo0  9mQ bHWbvgNc$Ovڍ/^>+zLdrQ J<.?| .xIOjB*2ǕdZs i4}0ozWey+k/PL״fࣗ1f`ίO֤@ - :%29hޒ.jk: 8B%? aXl"z^h8쯼+Q=$ 3 1v8!RȤdL1k籽Qs`09βCl ?sap4s7>9O{wy^TN>cdrɺ]wc8vQ^_g5%?ZPK-!bh^[Content_Types].xmlPK-!U0#L _rels/.relsPK-!>xl/_rels/workbook.xml.relsPK-! Uaxl/workbook.xmlPK-!  } xl/styles.xmlPK-!N yxl/theme/theme1.xmlPK-!HSόt\xl/worksheets/sheet1.xmlPK-!a5ս(xl/sharedStrings.xmlPK-!ύL{docProps/core.xmlPK-!^YdocProps/app.xmlPK scverse-anndata-b796d59/tests/data/umi_tools.tsv.gz000066400000000000000000000003471512025555600224170ustar00rootroot000000000000001acount_single_cells_gene_tag.tsvm10 Ep(߉d*ԍޠX*XԴFӳ|4/t\- Q\w0-8})Lm"v;E=$mWkY[Kl-f\YN*J Literal["zarr", "h5ad"]: return request.param @pytest.fixture( params=[True, False], scope="session", ids=["load-annotation-index", "dont-load-annotation-index"], ) def load_annotation_index(request): return request.param @pytest.fixture(params=["outer", "inner"], scope="session") def join(request): return request.param @pytest.fixture( params=[ pytest.param(lambda x: x, id="full"), pytest.param(lambda x: x[0:10, :], id="subset"), ], scope="session", ) def simple_subset_func(request): return request.param @pytest.fixture(scope="session") def adata_remote_orig_with_path( tmp_path_factory, diskfmt: str, mtx_format, worker_id: str = "serial", ) -> tuple[Path, AnnData]: """Create remote fixtures, one without a range index and the other with""" file_name = f"orig_{worker_id}.{diskfmt}" if diskfmt == "h5ad": orig_path = tmp_path_factory.mktemp("h5ad_file_dir") / file_name else: orig_path = tmp_path_factory.mktemp(file_name) orig = gen_adata( (100, 110), mtx_format, obs_dtypes=(*DEFAULT_COL_TYPES, pd.StringDtype), var_dtypes=(*DEFAULT_COL_TYPES, pd.StringDtype), obsm_types=(*DEFAULT_KEY_TYPES, AwkArray), varm_types=(*DEFAULT_KEY_TYPES, AwkArray), ) orig.raw = orig.copy() with ad.settings.override(allow_write_nullable_strings=True): getattr(ad.io, f"write_{diskfmt}")( orig_path, orig, convert_strings_to_categoricals=False ) return orig_path, orig @pytest.fixture def adata_remote( adata_remote_orig_with_path: tuple[Path, AnnData], *, load_annotation_index: bool ) -> AnnData: orig_path, _ = adata_remote_orig_with_path return read_lazy(orig_path, load_annotation_index=load_annotation_index) @pytest.fixture def adata_orig(adata_remote_orig_with_path: tuple[Path, AnnData]) -> AnnData: _, orig = adata_remote_orig_with_path return orig @pytest.fixture(scope="session", params=[pytest.param(None, marks=pytest.mark.zarr_io)]) def adata_remote_with_store_tall_skinny_path( tmp_path_factory, mtx_format, worker_id: str = "serial", ) -> Path: orig_path = tmp_path_factory.mktemp(f"orig_{worker_id}.zarr") M = 1000 N = 5 obs_names = pd.Index(f"cell{i}" for i in range(M)) var_names = pd.Index(f"gene{i}" for i in range(N)) obs = gen_typed_df(M, obs_names) var = gen_typed_df(N, var_names) orig = AnnData( obs=obs, var=var, X=mtx_format(np.random.binomial(100, 0.005, (M, N)).astype(np.float32)), ) orig.raw = orig.copy() orig.write_zarr(orig_path) g = zarr.open_group(orig_path, mode="a", use_consolidated=False) ad.io.write_elem( g, "obs", obs, dataset_kwargs=dict(chunks=(250,)), ) zarr.consolidate_metadata(g.store) return orig_path @pytest.fixture(scope="session", params=[pytest.param(None, marks=pytest.mark.zarr_io)]) def adatas_paths_var_indices_for_concatenation( tmp_path_factory, *, are_vars_different: bool, worker_id: str = "serial" ) -> tuple[list[AnnData], list[Path], list[pd.Index]]: adatas = [] var_indices = [] paths = [] M = 1000 N = 50 n_datasets = 3 for dataset_index in range(n_datasets): orig_path = tmp_path_factory.mktemp(f"orig_{worker_id}_{dataset_index}.zarr") paths.append(orig_path) obs_names = pd.Index(f"cell_{dataset_index}_{i}" for i in range(M)) var_names = pd.Index( f"gene_{i}{f'_{dataset_index}_ds' if are_vars_different and (i % 2) else ''}" for i in range(N) ) var_indices.append(var_names) obs = gen_typed_df(M, obs_names) var = gen_typed_df(N, var_names) orig = AnnData( obs=obs, var=var, X=np.random.binomial(100, 0.005, (M, N)).astype(np.float32), ) orig.write_zarr(orig_path) adatas.append(orig) return adatas, paths, var_indices @pytest.fixture def var_indices_for_concat( adatas_paths_var_indices_for_concatenation, ) -> list[pd.Index]: _, _, var_indices = adatas_paths_var_indices_for_concatenation return var_indices @pytest.fixture def adatas_for_concat( adatas_paths_var_indices_for_concatenation, ) -> list[AnnData]: adatas, _, _ = adatas_paths_var_indices_for_concatenation return adatas @pytest.fixture def stores_for_concat( adatas_paths_var_indices_for_concatenation, ) -> list[AccessTrackingStore]: _, paths, _ = adatas_paths_var_indices_for_concatenation return [AccessTrackingStore(path) for path in paths] @pytest.fixture def lazy_adatas_for_concat( stores_for_concat, ) -> list[AnnData]: return [read_lazy(store) for store in stores_for_concat] @pytest.fixture def adata_remote_with_store_tall_skinny( adata_remote_with_store_tall_skinny_path: Path, ) -> tuple[AnnData, AccessTrackingStore]: store = AccessTrackingStore(adata_remote_with_store_tall_skinny_path) remote = read_lazy(store) return remote, store @pytest.fixture def remote_store_tall_skinny( adata_remote_with_store_tall_skinny_path: Path, ) -> AccessTrackingStore: return AccessTrackingStore(adata_remote_with_store_tall_skinny_path) @pytest.fixture def adata_remote_tall_skinny( remote_store_tall_skinny: AccessTrackingStore, ) -> AnnData: remote = read_lazy(remote_store_tall_skinny) return remote def get_key_trackers_for_columns_on_axis( adata: AnnData, axis: Literal["obs", "var"] ) -> Generator[str, None, None]: """Generate keys for tracking, using `codes` from categorical columns instead of the column name Parameters ---------- adata Object to get keys from axis Axis to get keys from Yields ------ Keys for tracking """ for col in getattr(adata, axis).columns: yield f"{axis}/{col}" if "cat" not in col else f"{axis}/{col}/codes" scverse-anndata-b796d59/tests/lazy/test_concat.py000066400000000000000000000302731512025555600221570ustar00rootroot00000000000000from __future__ import annotations from functools import reduce from importlib.util import find_spec from typing import TYPE_CHECKING import numpy as np import pandas as pd import pytest import anndata as ad from anndata._core.file_backing import to_memory from anndata.experimental import read_lazy from anndata.tests.helpers import GEN_ADATA_NO_XARRAY_ARGS, assert_equal, gen_adata from .conftest import ANNDATA_ELEMS, get_key_trackers_for_columns_on_axis pytestmark = pytest.mark.skipif(not find_spec("xarray"), reason="xarray not installed") if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from typing import Literal from numpy.typing import NDArray from anndata import AnnData from anndata._types import AnnDataElem, Join_T from anndata.tests.helpers import AccessTrackingStore def unify_extension_dtypes( remote: pd.DataFrame, memory: pd.DataFrame ) -> tuple[pd.DataFrame, pd.DataFrame]: """ For concatenated lazy datasets, we send the extension arrays through dask But this means we lose the pandas dtype, so this function corrects that. Parameters ---------- remote The dataset that comes from the concatenated lazy operation memory The in-memory, "correct" version Returns ------- The two dataframes unified """ for col in memory.columns: dtype = memory[col].dtype if pd.api.types.is_extension_array_dtype(dtype): remote[col] = remote[col].astype(dtype) return remote, memory @pytest.mark.parametrize("join", ["outer", "inner"]) @pytest.mark.parametrize( ("elem_key", "sub_key"), [ ("obs", "cat"), ("obs", "int64"), *((elem_name, None) for elem_name in ANNDATA_ELEMS), ], ) def test_concat_access_count( adatas_for_concat: list[AnnData], stores_for_concat: list[AccessTrackingStore], lazy_adatas_for_concat: list[AnnData], join: Join_T, elem_key: AnnDataElem, sub_key: str, simple_subset_func: Callable[[AnnData], AnnData], ): # track all elems except categories from categoricals because they must be read in for concatenation # due to the dtype check on the elements (which causes `categories` to be read in) non_categorical_columns = ( f"{elem}/{col}" if "cat" not in col else f"{elem}/{col}/codes" for elem in ["obs", "var"] for col in adatas_for_concat[0].obs.columns ) category_columns = ( f"{elem}/{col}/categories" for elem in ["obs", "var"] for col in adatas_for_concat[0].obs.columns if "cat" in col ) non_obs_var_keys = filter(lambda e: e not in {"obs", "var"}, ANNDATA_ELEMS) zero_access_count_keys = [*non_categorical_columns, *non_obs_var_keys] keys_to_track = [*zero_access_count_keys, *category_columns] for store in stores_for_concat: store.initialize_key_trackers(keys_to_track) concated_remote = ad.concat(lazy_adatas_for_concat, join=join) # a series of methods that should __not__ read in any data elem = getattr(simple_subset_func(concated_remote), elem_key) if sub_key is not None: if elem_key in {"obs", "var"}: elem[sub_key] else: getattr(elem, sub_key) for store in stores_for_concat: for elem in zero_access_count_keys: store.assert_access_count(elem, 0) for elem in category_columns: # once for .zarray, once for the actual data store.assert_access_count(elem, 2) def test_concat_to_memory_obs_access_count( adatas_for_concat: list[AnnData], stores_for_concat: list[AccessTrackingStore], lazy_adatas_for_concat: list[AnnData], join: Join_T, simple_subset_func: Callable[[AnnData], AnnData], ): """This test ensures that only the necessary chunks are accessed in `to_memory` call after a subsetting operation""" concated_remote = simple_subset_func(ad.concat(lazy_adatas_for_concat, join=join)) concated_remote_subset = simple_subset_func(concated_remote) n_datasets = len(adatas_for_concat) obs_keys_to_track = get_key_trackers_for_columns_on_axis( adatas_for_concat[0], "obs" ) for store in stores_for_concat: store.initialize_key_trackers(obs_keys_to_track) concated_remote_subset.to_memory() # check access count for the stores - only the first should be accessed when reading into memory for col in obs_keys_to_track: stores_for_concat[0].assert_access_count(col, 1) for i in range(1, n_datasets): # if the shapes are the same, data was read in to bring the object into memory; otherwise, not stores_for_concat[i].assert_access_count( col, concated_remote_subset.shape[0] == concated_remote.shape[0] ) def test_concat_to_memory_obs( adatas_for_concat: list[AnnData], lazy_adatas_for_concat: list[AnnData], join: Join_T, simple_subset_func: Callable[[AnnData], AnnData], ): concatenated_memory = simple_subset_func(ad.concat(adatas_for_concat, join=join)) concated_remote = simple_subset_func(ad.concat(lazy_adatas_for_concat, join=join)) assert_equal( *unify_extension_dtypes(to_memory(concated_remote.obs), concatenated_memory.obs) ) def test_concat_to_memory_obs_dtypes( lazy_adatas_for_concat: list[AnnData], join: Join_T, ): concated_remote = ad.concat(lazy_adatas_for_concat, join=join) # check preservation of non-categorical dtypes on the concat axis assert concated_remote.obs["int64"].dtype == "int64" assert concated_remote.obs["uint8"].dtype == "uint8" assert concated_remote.obs["nullable-int"].dtype == "int32" assert concated_remote.obs["float64"].dtype == "float64" assert concated_remote.obs["bool"].dtype == "bool" assert concated_remote.obs["nullable-bool"].dtype == "bool" def test_concat_to_memory_var( var_indices_for_concat: list[pd.Index], adatas_for_concat: list[AnnData], stores_for_concat: list[AccessTrackingStore], lazy_adatas_for_concat: list[AnnData], join: Join_T, simple_subset_func: Callable[[AnnData], AnnData], *, are_vars_different: bool, ): """\ The goal of this test to ensure that the various `join` operations work as expected under various scenarios. We test two things here: first, we take all the overlapping indices for var. Then if the underlying vars are different and this is an outer join (i.e., there are non-overlapping indices), we take the unique indices from one of the dataframes. We then check if the var dataframe subsetted from lazily-concatenated object and put into memory is the same as the underlying anndata object that created it, up to some corrections. We also test for key access counts to ensure that data was not taken from the var df of other on-disk anndata objects that might be different i.e., in the case of an outer join. """ concated_remote = simple_subset_func(ad.concat(lazy_adatas_for_concat, join=join)) var_keys_to_track = get_key_trackers_for_columns_on_axis( adatas_for_concat[0], "var" ) for store in stores_for_concat: store.initialize_key_trackers(var_keys_to_track) # check non-different variables, taken from first annotation. pd_index_overlapping = reduce(pd.Index.intersection, var_indices_for_concat) var_df_overlapping = adatas_for_concat[0][:, pd_index_overlapping].var.copy() test_cases = [(pd_index_overlapping, var_df_overlapping, 0)] if are_vars_different and join == "outer": # check a set of unique variables from the first object since we only take from there if different pd_index_only_ds_0 = pd.Index( filter(lambda x: "0_ds" in x, var_indices_for_concat[1]) ) var_df_only_ds_0 = adatas_for_concat[0][:, pd_index_only_ds_0].var.copy() test_cases.append((pd_index_only_ds_0, var_df_only_ds_0, 0)) for pd_index, var_df, store_idx in test_cases: remote_df = to_memory(concated_remote[:, pd_index].var) remote_df_corrected, _ = unify_extension_dtypes(remote_df, var_df) # NOTE: xr.merge always upcasts to float due to NA and you can't downcast? for col in remote_df_corrected.columns: dtype = remote_df_corrected[col].dtype if dtype in [np.float64, np.float32]: var_df[col] = var_df[col].astype(dtype) assert_equal(remote_df_corrected, var_df) for key in var_keys_to_track: stores_for_concat[store_idx].assert_access_count(key, 1) for store in stores_for_concat: if store != stores_for_concat[store_idx]: store.assert_access_count(key, 0) stores_for_concat[store_idx].reset_key_trackers() @pytest.mark.xdist_group("dask") @pytest.mark.dask_distributed def test_concat_data_with_cluster_to_memory( adata_remote: AnnData, join: Join_T, local_cluster_addr: str ) -> None: import dask.distributed as dd with dd.Client(local_cluster_addr): ad.concat([adata_remote, adata_remote], join=join).to_memory() @pytest.mark.parametrize( "index", [ pytest.param( slice(50, 150), id="slice", ), pytest.param( np.arange(95, 105), id="consecutive integer array", ), pytest.param( np.random.randint(80, 110, 5), id="random integer array", ), pytest.param( np.random.choice([True, False], 200), id="boolean array", ), pytest.param(slice(None), id="full slice"), pytest.param("a", id="categorical_subset"), pytest.param(None, id="No index"), ], ) def test_concat_data_subsetting( adata_remote: AnnData, adata_orig: AnnData, join: Join_T, index: slice | NDArray | Literal["a"] | None, ): from anndata._core.xarray import Dataset2D remote_concatenated = ad.concat([adata_remote, adata_remote], join=join) if index is not None: if np.isscalar(index) and index == "a": index = remote_concatenated.obs["obs_cat"] == "a" remote_concatenated = remote_concatenated[index] orig_concatenated = ad.concat([adata_orig, adata_orig], join=join) if index is not None: orig_concatenated = orig_concatenated[index] in_memory_remote_concatenated = remote_concatenated.to_memory() corrected_remote_obs, corrected_memory_obs = unify_extension_dtypes( in_memory_remote_concatenated.obs, orig_concatenated.obs ) assert isinstance(remote_concatenated.obs, Dataset2D) assert_equal(corrected_remote_obs, corrected_memory_obs) assert_equal(in_memory_remote_concatenated.X, orig_concatenated.X) assert ( in_memory_remote_concatenated.var_names.tolist() == orig_concatenated.var_names.tolist() ) @pytest.mark.parametrize( ("attr", "key"), ( pytest.param(param[0], param[1], id="-".join(map(str, param))) for param in [("obs", None), ("var", None), ("obsm", "df"), ("varm", "df")] ), ) def test_concat_df_ds_mixed_types( adata_remote: AnnData, adata_orig: AnnData, join: Join_T, attr: str, key: str | None, *, load_annotation_index: bool, ): def with_elem_in_memory(adata: AnnData, attr: str, key: str | None) -> AnnData: parent_elem = getattr(adata, attr) if key is not None: getattr(adata, attr)[key] = to_memory(parent_elem[key]) return adata setattr(adata, attr, to_memory(parent_elem)) return adata if not load_annotation_index: pytest.skip( "Testing for mixed types is independent of the axis since the indices always have to match." ) remote = with_elem_in_memory(adata_remote, attr, key) in_memory_concatenated = ad.concat([adata_orig, adata_orig], join=join) mixed_concatenated = ad.concat([remote, adata_orig], join=join) assert_equal(mixed_concatenated, in_memory_concatenated) def test_concat_bad_mixed_types(tmp_path: Path): orig = gen_adata((100, 200), np.array, **GEN_ADATA_NO_XARRAY_ARGS) orig.write_zarr(tmp_path) remote = read_lazy(tmp_path) orig.obsm["df"] = orig.obsm["array"] with pytest.raises(ValueError, match=r"Cannot concatenate a Dataset2D*"): ad.concat([remote, orig], join="outer") scverse-anndata-b796d59/tests/lazy/test_read.py000066400000000000000000000204001512025555600216120ustar00rootroot00000000000000from __future__ import annotations from importlib.util import find_spec from typing import TYPE_CHECKING import numpy as np import pandas as pd import pytest import zarr from anndata import AnnData from anndata.compat import DaskArray from anndata.experimental import read_elem_lazy, read_lazy from anndata.io import read_zarr, write_elem from anndata.tests.helpers import ( GEN_ADATA_NO_XARRAY_ARGS, AccessTrackingStore, assert_equal, gen_adata, gen_typed_df, ) from .conftest import ANNDATA_ELEMS if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from anndata._types import AnnDataElem pytestmark = pytest.mark.skipif(not find_spec("xarray"), reason="xarray not installed") @pytest.mark.parametrize( ("elem_key", "sub_key"), [ ("raw", "X"), ("obs", "cat"), ("obs", "int64"), *((elem_name, None) for elem_name in ANNDATA_ELEMS), ], ) def test_access_count_elem_access( remote_store_tall_skinny: AccessTrackingStore, adata_remote_tall_skinny: AnnData, elem_key: AnnDataElem, sub_key: str, simple_subset_func: Callable[[AnnData], AnnData], ): full_path = f"{elem_key}/{sub_key}" if sub_key is not None else elem_key remote_store_tall_skinny.initialize_key_trackers({full_path, "X"}) # a series of methods that should __not__ read in any data elem = getattr(simple_subset_func(adata_remote_tall_skinny), elem_key) if sub_key is not None: if elem_key in {"obs", "var"}: elem[sub_key] else: getattr(elem, sub_key) remote_store_tall_skinny.assert_access_count(full_path, 0) remote_store_tall_skinny.assert_access_count("X", 0) def test_access_count_subset( remote_store_tall_skinny: AccessTrackingStore, adata_remote_tall_skinny: AnnData, ): non_obs_elem_names = filter(lambda e: e != "obs", ANNDATA_ELEMS) remote_store_tall_skinny.initialize_key_trackers([ "obs/cat/codes", *non_obs_elem_names, ]) adata_remote_tall_skinny[adata_remote_tall_skinny.obs["cat"] == "a", :] # all codes read in for subset (from 4 chunks as set in the fixture) remote_store_tall_skinny.assert_access_count("obs/cat/codes", 4) for elem_name in non_obs_elem_names: remote_store_tall_skinny.assert_access_count(elem_name, 0) def test_access_count_subset_column_compute( remote_store_tall_skinny: AccessTrackingStore, adata_remote_tall_skinny: AnnData, ): remote_store_tall_skinny.initialize_key_trackers(["obs/int64"]) adata_remote_tall_skinny[adata_remote_tall_skinny.shape[0] // 2, :].obs[ "int64" ].compute() # two chunks needed for 0:10 subset remote_store_tall_skinny.assert_access_count("obs/int64", 1) def test_access_count_index( remote_store_tall_skinny: AccessTrackingStore, adata_remote_with_store_tall_skinny_path: Path, ) -> None: adata_orig = read_zarr(adata_remote_with_store_tall_skinny_path) remote_store_tall_skinny.initialize_key_trackers(["obs/_index"]) read_lazy(remote_store_tall_skinny, load_annotation_index=False) remote_store_tall_skinny.assert_access_count("obs/_index", 0) read_lazy(remote_store_tall_skinny) n_chunks = 4 count_expected = ( # *2 when mask exists n_chunks * 2 if adata_orig.obs.index.dtype == "string" else n_chunks ) remote_store_tall_skinny.assert_access_count("obs/_index", count_expected) def test_access_count_dtype( remote_store_tall_skinny: AccessTrackingStore, adata_remote_tall_skinny: AnnData, adata_remote_with_store_tall_skinny_path: Path, ) -> None: adata_orig = read_zarr(adata_remote_with_store_tall_skinny_path) remote_store_tall_skinny.initialize_key_trackers(["obs/cat/categories"]) remote_store_tall_skinny.assert_access_count("obs/cat/categories", 0) count_expected = 2 if adata_orig.obs["cat"].cat.categories.dtype == "string" else 1 # This should only cause categories to be read in once (and their mask if applicable) adata_remote_tall_skinny.obs["cat"].dtype # noqa: B018 remote_store_tall_skinny.assert_access_count("obs/cat/categories", count_expected) adata_remote_tall_skinny.obs["cat"].dtype # noqa: B018 adata_remote_tall_skinny.obs["cat"].dtype # noqa: B018 remote_store_tall_skinny.assert_access_count("obs/cat/categories", count_expected) def test_uns_uses_dask(adata_remote: AnnData): assert isinstance(adata_remote.uns["nested"]["nested_further"]["array"], DaskArray) def test_to_memory(adata_remote: AnnData, adata_orig: AnnData): remote_to_memory = adata_remote.to_memory() assert_equal(remote_to_memory, adata_orig) def test_access_counts_obsm_df(tmp_path: Path): adata = AnnData( X=np.array(np.random.rand(100, 20)), ) adata.obsm["df"] = pd.DataFrame( {"col1": np.random.rand(100), "col2": np.random.rand(100)}, index=adata.obs_names, ) adata.write_zarr(tmp_path) store = AccessTrackingStore(tmp_path) store.initialize_key_trackers(["obsm/df"]) read_lazy(store, load_annotation_index=False) store.assert_access_count("obsm/df", 0) def test_view_to_memory(adata_remote: AnnData, adata_orig: AnnData): obs_cats = adata_orig.obs["obs_cat"].cat.categories subset_obs = adata_orig.obs["obs_cat"] == obs_cats[0] assert_equal(adata_orig[subset_obs, :], adata_remote[subset_obs, :].to_memory()) var_cats = adata_orig.var["var_cat"].cat.categories subset_var = adata_orig.var["var_cat"] == var_cats[0] assert_equal(adata_orig[:, subset_var], adata_remote[:, subset_var].to_memory()) def test_view_of_view_to_memory(adata_remote: AnnData, adata_orig: AnnData): cats_obs = adata_orig.obs["obs_cat"].cat.categories subset_obs = (adata_orig.obs["obs_cat"] == cats_obs[0]) | ( adata_orig.obs["obs_cat"] == cats_obs[1] ) subsetted_adata = adata_orig[subset_obs, :] subset_subset_obs = subsetted_adata.obs["obs_cat"] == cats_obs[1] subsetted_subsetted_adata = subsetted_adata[subset_subset_obs, :] assert_equal( subsetted_subsetted_adata, adata_remote[subset_obs, :][subset_subset_obs, :].to_memory(), ) cats_var = adata_orig.var["var_cat"].cat.categories subset_var = (adata_orig.var["var_cat"] == cats_var[0]) | ( adata_orig.var["var_cat"] == cats_var[1] ) subsetted_adata = adata_orig[:, subset_var] subset_subset_var = subsetted_adata.var["var_cat"] == cats_var[1] subsetted_subsetted_adata = subsetted_adata[:, subset_subset_var] assert_equal( subsetted_subsetted_adata, adata_remote[:, subset_var][:, subset_subset_var].to_memory(), ) @pytest.mark.zarr_io def test_unconsolidated(tmp_path: Path, mtx_format): adata = gen_adata((10, 10), mtx_format, **GEN_ADATA_NO_XARRAY_ARGS) orig_pth = tmp_path / "orig.zarr" adata.write_zarr(orig_pth) (orig_pth / ".zmetadata").unlink() store = AccessTrackingStore(orig_pth) store.initialize_key_trackers(["obs/.zgroup", ".zgroup"]) with pytest.warns(UserWarning, match=r"Did not read zarr as consolidated"): remote = read_lazy(store) remote_to_memory = remote.to_memory() assert_equal(remote_to_memory, adata) store.assert_access_count("obs/.zgroup", 1) def test_h5_file_obj(tmp_path: Path): adata = gen_adata((10, 10), **GEN_ADATA_NO_XARRAY_ARGS) orig_pth = tmp_path / "adata.h5ad" adata.write_h5ad(orig_pth) remote = read_lazy(orig_pth) assert remote.file.is_open assert remote.filename == orig_pth assert_equal(remote.to_memory(), adata) @pytest.fixture(scope="session") def df_group(tmp_path_factory) -> zarr.Group: df = gen_typed_df(120) path = tmp_path_factory.mktemp("foo.zarr") g = zarr.open_group(path, mode="w", zarr_format=2) write_elem(g, "foo", df, dataset_kwargs={"chunks": 25}) return zarr.open(path, mode="r")["foo"] @pytest.mark.parametrize( ("chunks", "expected_chunks"), [((1,), (1,)), ((-1,), (120,)), (None, (25,))], ids=["small", "minus_one_uses_full", "none_uses_ondisk_chunking"], ) def test_chunks_df( tmp_path: Path, chunks: tuple[int] | None, expected_chunks: tuple[int], df_group: zarr.Group, ): ds = read_elem_lazy(df_group, chunks=chunks) for k in ds: if isinstance(arr := ds[k].data, DaskArray): assert arr.chunksize == expected_chunks scverse-anndata-b796d59/tests/lazy/test_write.py000066400000000000000000000026511512025555600220410ustar00rootroot00000000000000from __future__ import annotations from importlib.util import find_spec from typing import TYPE_CHECKING import numpy as np import pytest from anndata import AnnData from anndata.experimental.backed._io import read_lazy if TYPE_CHECKING: from pathlib import Path from typing import Literal pytestmark = pytest.mark.skipif(not find_spec("xarray"), reason="xarray not installed") @pytest.mark.parametrize("fmt", ["zarr", "h5ad", "loom", "csvs"]) @pytest.mark.parametrize("key", ["obs", "var", "obsm", "varm"]) def test_write_error( tmp_path: Path, fmt: Literal["zarr", "h5ad", "loom", "csvs"], key: Literal["obs", "var", "obsm", "varm"], ): path = tmp_path / "adata.h5ad" X = np.random.random((4, 4)) adata = AnnData(X=X) if key.endswith("m"): elem = {"df": getattr(adata, key[:-1])} setattr(adata, key, elem) adata.write_h5ad(path) adata_lazy = read_lazy(path) if key.endswith("m"): adata_lazy.obs = adata_lazy.obs.to_memory() adata_lazy.obs = adata_lazy.var.to_memory() noop_path = tmp_path / f"adata_noop.{fmt}" with pytest.raises( NotImplementedError, match=r"Writing AnnData objects with a Dataset2D not supported yet. Please use `ds.to_memory`", ): getattr(adata_lazy, f"write_{fmt}")(noop_path) assert not noop_path.exists(), ( "Found a directory at the path at which no data should have been written" ) scverse-anndata-b796d59/tests/test_anncollection.py000066400000000000000000000053141512025555600225570ustar00rootroot00000000000000from __future__ import annotations import numpy as np import pytest from scipy.sparse import csr_matrix, issparse from sklearn.preprocessing import LabelEncoder import anndata as ad from anndata.experimental.multi_files import AnnCollection _dense = lambda a: a.toarray() if issparse(a) else a @pytest.fixture def adatas(request): adata1 = ad.AnnData(X=request.param([[1, 2, 0], [4, 5, 0], [7, 8, 0]])) adata1.obs["a_test"] = ["a", "a", "b"] adata1.obsm["o_test"] = np.ones((adata1.n_obs, 2)) adata2 = ad.AnnData(X=request.param([[1, 3, 0], [9, 8, 0]])) adata2.obs["a_test"] = ["c", "c"] adata2.obsm["o_test"] = np.zeros((adata2.n_obs, 2)) return adata1, adata2 @pytest.mark.parametrize("adatas", [np.array, csr_matrix], indirect=True) def test_full_selection(adatas): dat = AnnCollection(adatas, index_unique="_") adt_concat = ad.concat(adatas, index_unique="_") # sorted selection from one adata dat_view = dat[:2, :2] for adata in (adatas[0], adt_concat): adt_view = adata[:2, :2] np.testing.assert_allclose(_dense(dat_view.X), _dense(adt_view.X)) np.testing.assert_allclose(dat_view.obsm["o_test"], adt_view.obsm["o_test"]) np.testing.assert_array_equal(dat_view.obs["a_test"], adt_view.obs["a_test"]) # sorted and unsorted selection from 2 adatas rand_idxs = np.random.choice(dat.shape[0], 4, replace=False) for select in (slice(2, 5), [4, 2, 3], rand_idxs): dat_view = dat[select, :2] adt_view = adt_concat[select, :2] np.testing.assert_allclose(_dense(dat_view.X), _dense(adt_view.X)) np.testing.assert_allclose(dat_view.obsm["o_test"], adt_view.obsm["o_test"]) np.testing.assert_array_equal(dat_view.obs["a_test"], adt_view.obs["a_test"]) # test duplicate selection idxs = [1, 2, 4, 4] dat_view = dat[idxs, :2] np.testing.assert_allclose( _dense(dat_view.X), np.array([[4, 5], [7, 8], [9, 8], [9, 8]]) ) @pytest.mark.parametrize("adatas", [np.array, csr_matrix], indirect=True) def test_creation(adatas): adatas_inner = [adatas[0], adatas[1][:, :2].copy()] dat = AnnCollection(adatas_inner, join_vars="inner", index_unique="_") adt_concat = ad.concat(adatas_inner, index_unique="_") np.testing.assert_array_equal(dat.var_names, adt_concat.var_names) @pytest.mark.parametrize("adatas", [np.array], indirect=True) def test_convert(adatas): dat = AnnCollection(adatas, index_unique="_") le = LabelEncoder() le.fit(dat[:].obs["a_test"]) obs_no_convert = dat[:].obs["a_test"] convert = dict(obs={"a_test": lambda a: le.transform(a)}) dat.convert = convert np.testing.assert_array_equal(dat[:].obs["a_test"], le.transform(obs_no_convert)) scverse-anndata-b796d59/tests/test_annot.py000066400000000000000000000061111512025555600210420ustar00rootroot00000000000000"""Test handling of values in `obs`/ `var`""" from __future__ import annotations import numpy as np import pandas as pd import pytest from natsort import natsorted import anndata as ad from anndata.tests.helpers import get_multiindex_columns_df @pytest.mark.parametrize("dtype", [object, "string"]) def test_str_to_categorical(dtype): obs = pd.DataFrame( {"str": ["a", "a", None, "b", "b"]}, index=[f"cell-{i}" for i in range(5)] ) obs["str"] = obs["str"].astype(dtype) a = ad.AnnData(obs=obs.copy()) a.strings_to_categoricals() expected = obs["str"].astype(pd.CategoricalDtype(pd.Index(["a", "b"], dtype=dtype))) pd.testing.assert_series_equal(expected, a.obs["str"]) @pytest.mark.parametrize("dtype", [object, "string"]) def test_to_categorical_ordering(dtype): obs = pd.DataFrame( {"str": ["10", "11", "3", "9", "10", "10"]}, index=[f"cell-{i}" for i in range(6)], ) obs["str"] = obs["str"].astype(dtype) a = ad.AnnData(obs=obs.copy()) a.strings_to_categoricals() expected = obs["str"].astype( pd.CategoricalDtype(categories=natsorted(obs["str"].unique())) ) pd.testing.assert_series_equal(expected, a.obs["str"]) def test_non_str_to_not_categorical(): # Test case based on https://github.com/scverse/anndata/issues/141#issuecomment-802105259 obs = pd.DataFrame(index=[f"cell-{i}" for i in range(5)]).assign( str_with_nan=["foo", "bar", None, np.nan, "foo"], boolean_with_nan_and_none=[True, False, np.nan, None, True], boolean_with_nan=[True, False, np.nan, np.nan, True], boolean_with_none=[True, False, None, None, True], ) adata = ad.AnnData(obs=obs.copy()) orig_dtypes = {k: v.name for k, v in obs.dtypes.items()} expected_dtypes = orig_dtypes.copy() expected_dtypes["str_with_nan"] = "category" adata.strings_to_categoricals() result_dtypes = {k: v.name for k, v in adata.obs.dtypes.items()} assert expected_dtypes == result_dtypes expected_non_transformed = obs.drop(columns=["str_with_nan"]) result_non_transformed = adata.obs.drop(columns=["str_with_nan"]) pd.testing.assert_frame_equal(expected_non_transformed, result_non_transformed) def test_error_col_multiindex(): adata = ad.AnnData(np.random.rand(100, 10)) df = get_multiindex_columns_df((adata.shape[0], 20)) with pytest.raises(ValueError, match=r"MultiIndex columns are not supported"): adata.obs = df def test_error_row_multiindex(): df = pd.DataFrame( {"x": [1, 2, 3]}, index=pd.MultiIndex.from_tuples([("a", 1), ("b", 2), ("c", 3)]), ) with pytest.raises( ValueError, match=r"pandas.MultiIndex not supported as index for obs or var" ): ad.AnnData(df) def test_error_row_multiindex_setter(): df = pd.DataFrame( {"x": [1, 2, 3]}, index=pd.MultiIndex.from_tuples([("a", 1), ("b", 2), ("c", 3)]), ) adata = ad.AnnData(np.random.rand(3, 10)) with pytest.raises( ValueError, match=r"pandas.MultiIndex not supported as index for obs or var" ): adata.obs = df scverse-anndata-b796d59/tests/test_awkward.py000066400000000000000000000352351512025555600213740ustar00rootroot00000000000000"""Tests related to awkward arrays""" from __future__ import annotations import warnings import numpy as np import numpy.testing as npt import pandas as pd import pytest import anndata from anndata import ( AnnData, ImplicitModificationWarning, read_h5ad, ) from anndata.compat import AwkArray from anndata.compat import awkward as ak from anndata.tests.helpers import assert_equal, gen_adata, gen_awkward from anndata.utils import axis_len @pytest.mark.parametrize( ("array", "shape"), [ # numpy array (ak.Array(np.arange(2 * 3 * 4 * 5).reshape((2, 3, 4, 5))), (2, 3, 4, 5)), # record (ak.Array([{"a": 1, "b": 2}, {"a": 1, "b": 3}]), (2, 2)), # ListType, variable length (ak.Array([[1], [2, 3], [4, 5, 6]]), (3, None)), # ListType, happens to have the same length, but is not regular (ak.Array([[2], [3], [4]]), (3, None)), # RegularType + nested ListType (ak.to_regular(ak.Array([[[1, 2], [3]], [[2], [3, 4, 5]]]), 1), (2, 2, None)), # nested record ( ak.to_regular(ak.Array([[{"a": 0}, {"b": 1}], [{"c": 2}, {"d": 3}]]), 1), (2, 2, 4), ), # mixed types (variable length) (ak.Array([[1, 2], ["a"]]), (2, None)), # mixed types (but regular) (ak.to_regular(ak.Array([[1, 2], ["a", "b"]]), 1), (2, 2)), # zero-size edge cases (ak.Array(np.ones((0, 7))), (0, 7)), (ak.Array(np.ones((7, 0))), (7, 0)), # UnionType of two regular types with different dimensions ( ak.concatenate([ak.Array(np.ones((2, 2))), ak.Array(np.ones((2, 3)))]), (4, None), ), # UnionType of two regular types with same dimension ( ak.concatenate([ ak.Array(np.ones((2, 2))), ak.Array(np.array([["a", "a"], ["a", "a"]])), ]), (4, 2), ), # Array of string types (ak.Array(["a", "b", "c"]), (3,)), (ak.Array([["a", "b"], ["c", "d"], ["e", "f"]]), (3, None)), (ak.to_regular(ak.Array([["a", "b"], ["c", "d"], ["e", "f"]]), 1), (3, 2)), ], ) def test_axis_len(array, shape): """Test that axis_len returns the right value for awkward arrays.""" for axis, size in enumerate(shape): assert size == axis_len(array, axis) # Requesting the size for an axis higher than the array has dimensions should raise a TypeError with pytest.raises(TypeError): axis_len(array, len(shape)) @pytest.mark.parametrize( ("field", "value", "valid"), [ ("obsm", gen_awkward((10, 5)), True), ("obsm", gen_awkward((10, None)), True), ("obsm", gen_awkward((10, None, None)), True), ("obsm", gen_awkward((10, 5, None)), True), ("obsm", gen_awkward((8, 10)), False), ("obsm", gen_awkward((8, None)), False), ("varm", gen_awkward((20, 5)), True), ("varm", gen_awkward((20, None)), True), ("varm", gen_awkward((20, None, None)), True), ("varm", gen_awkward((20, 5, None)), True), ("varm", gen_awkward((8, 20)), False), ("varm", gen_awkward((8, None)), False), ("uns", gen_awkward((7,)), True), ("uns", gen_awkward((7, None)), True), ("uns", gen_awkward((7, None, None)), True), ], ) def test_set_awkward(field, value, valid): """Check if we can set obsm, .varm and .uns with different types of awkward arrays and if error messages are properly raised when the dimensions do not align. """ adata = gen_adata((10, 20), varm_types=(), obsm_types=(), layers_types=()) def _assign(): getattr(adata, field)["test"] = value if not valid: with pytest.raises(ValueError, match="incorrect shape"): _assign() else: _assign() @pytest.mark.parametrize("key", ["obsm", "varm", "uns"]) def test_copy(key): """Check that modifying a copy does not modify the original""" adata = gen_adata((3, 3), varm_types=(), obsm_types=(), layers_types=()) getattr(adata, key)["awk"] = ak.Array([{"a": [1], "b": [2], "c": [3]}] * 3) adata_copy = adata.copy() getattr(adata_copy, key)["awk"]["c"] = np.full((3, 1), 4) getattr(adata_copy, key)["awk"]["d"] = np.full((3, 1), 5) # values in copy were correctly set npt.assert_equal(getattr(adata_copy, key)["awk"]["c"], np.full((3, 1), 4)) npt.assert_equal(getattr(adata_copy, key)["awk"]["d"], np.full((3, 1), 5)) # values in original were not updated npt.assert_equal(getattr(adata, key)["awk"]["c"], np.full((3, 1), 3)) with pytest.raises(IndexError): getattr(adata, key)["awk"]["d"] @pytest.mark.parametrize("key", ["obsm", "varm"]) def test_view(key): """Check that modifying a view does not modify the original""" adata = gen_adata((3, 3), varm_types=(), obsm_types=(), layers_types=()) getattr(adata, key)["awk"] = ak.Array([{"a": [1], "b": [2], "c": [3]}] * 3) adata_view = adata[:2, :2] # TODO: is “c” sparse and “d” not? Or what happens here? Use proper names. with pytest.warns( ImplicitModificationWarning, match=r"initializing view as actual" ): getattr(adata_view, key)["awk"]["c"] = np.full((2, 1), 4) getattr(adata_view, key)["awk"]["d"] = np.full((2, 1), 5) # values in view were correctly set npt.assert_equal(getattr(adata_view, key)["awk"]["c"], np.full((2, 1), 4)) npt.assert_equal(getattr(adata_view, key)["awk"]["d"], np.full((2, 1), 5)) # values in original were not updated npt.assert_equal(getattr(adata, key)["awk"]["c"], np.full((3, 1), 3)) with pytest.raises(IndexError): getattr(adata, key)["awk"]["d"] def test_view_of_awkward_array_with_custom_behavior(): """Currently can't create view of arrays with custom __name__ (in this case "string") See https://github.com/scverse/anndata/pull/647#discussion_r963494798_""" from uuid import uuid4 BEHAVIOUR_ID = str(uuid4()) class ReversibleArray(ak.Array): def reversed(self): return self[..., ::-1] ak.behavior[BEHAVIOUR_ID] = ReversibleArray adata = gen_adata((3, 3), varm_types=(), obsm_types=(), layers_types=()) adata.obsm["awk_string"] = ak.with_parameter( ak.Array(["AAA", "BBB", "CCC"]), "__list__", BEHAVIOUR_ID ) adata_view = adata[:2] with pytest.raises(NotImplementedError): adata_view.obsm["awk_string"] @pytest.mark.parametrize( "array", [ # numpy array ak.Array(np.arange(2 * 3 * 4 * 5).reshape((2, 3, 4, 5))), # record ak.Array([{"a": 1, "b": 2}, {"a": 1, "b": 3}]), # ListType, variable length ak.Array([[1], [2, 3], [4, 5, 6]]), # RegularType + nested ListType ak.to_regular(ak.Array([[[1, 2], [3]], [[2], [3, 4, 5]]]), 1), # nested record ak.to_regular(ak.Array([[{"a": 0}, {"b": 1}], [{"c": 2}, {"d": 3}]]), 1), # mixed types (variable length) ak.Array([[1, 2], ["a"]]), # zero-size edge cases ak.Array(np.ones((0, 7))), ak.Array(np.ones((7, 0))), # UnionType of two regular types with different dimensions ak.concatenate([ak.Array(np.ones((2, 2))), ak.Array(np.ones((2, 3)))]), # UnionType of two regular types with same dimension ak.concatenate([ ak.Array(np.ones((2, 2))), ak.Array(np.array([["a", "a"], ["a", "a"]])), ]), # categorical array ak.str.to_categorical(ak.Array([["a", "b", "c"], ["a", "b"]])), ak.str.to_categorical(ak.Array([[1, 1, 2], [3, 3]])), # tyical record type with AIRR data consisting of different dtypes ak.Array([ [ { "v_call": "TRV1", "junction_aa": "ADDEEKK", "productive": True, "locus": None, "consensus_count": 3, }, { "v_call": "TRV2", "productive": False, "locus": "TRA", "consensus_count": 4, }, ], [ { "v_call": None, "junction_aa": "ADDEKK", "productive": None, "locus": "IGK", "consensus_count": 3, } ], ]), ], ) def test_awkward_io(tmp_path, array): adata = AnnData() adata.uns["awk"] = array adata_path = tmp_path / "adata.h5ad" adata.write_h5ad(adata_path) adata2 = read_h5ad(adata_path) assert_equal(adata.uns["awk"], adata2.uns["awk"], exact=True) def test_awkward_io_view(tmp_path): """Check that views are converted to actual arrays on save, i.e. the _view_args and __list__ parameters are removed""" adata = gen_adata((3, 3), varm_types=(), obsm_types=(AwkArray,), layers_types=()) v = adata[1:] adata_path = tmp_path / "adata.h5ad" v.write_h5ad(adata_path) adata2 = read_h5ad(adata_path) # parameters are not fully removed, but set to None assert ak.parameters(adata2.obsm["awk_2d_ragged"]) == { "__list__": None, "_view_args": None, } # @pytest.mark.parametrize("join", ["outer", "inner"]) @pytest.mark.parametrize( ("arrays", "join", "expected"), [ pytest.param( [ak.Array([{"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}]), None], "inner", None, id="awk:recordoflists_null-inner", ), pytest.param( [ak.Array([{"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}]), None], "outer", ak.Array([ {"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}, *[None, None, None], ]), # maybe should return: ak.Array([{"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}, {}, {}, {}]), id="awk:recordoflists_null-outer", ), pytest.param( [ak.Array([[{"a": 1}, {"a": 2}], []]), None], "outer", ak.Array([[{"a": 1}, {"a": 2}], [], None, None, None]), # maybe should return: ak.Array([[{"a": 1}, {"a": 2}], [], [], []]), id="awk:listofrecords_null-outer", ), pytest.param( [None, ak.Array([{"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}])], "inner", None, id="null_awk-inner", ), pytest.param( [None, ak.Array([{"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}])], "outer", ak.Array([ *[None, None, None], {"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}, ]), # maybe should return: ak.Array([{}, {}, {}, {"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}]), id="null_awk:recordoflists-outer", ), pytest.param( [ak.Array([{"a": 1}, {"a": 2}]), ak.Array([{"a": 3}, {"a": 4}])], "inner", ak.Array([{"a": i} for i in range(1, 5)]), id="awk-simple-record", ), pytest.param( [ ak.Array([{"a": 1, "b": 1}, {"a": 2, "b": 2}]), ak.Array([{"a": 3}, {"a": 4}]), ], "inner", ak.Array([{"a": i} for i in range(1, 5)]), id="awk-simple-record-inner", ), # TODO: # pytest.param( # [ # ak.Array([{"a": 1, "b": 1}, {"a": 2, "b": 2}]), # ak.Array([{"a": 3}, {"a": 4}]), # ], # "outer", # ak.Array([{"a": 1, "b": 1}, {"a": 2, "b": 2}, {"a": 3}, {"a": 4},]), # id="awk-simple-record-outer", # ), pytest.param( [ None, ak.Array([{"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}]), pd.DataFrame(), ], "outer", NotImplementedError, # TODO: ak.Array([{}, {}, {}, {"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}]), id="null_awk_empty-pd", ), pytest.param( [ ak.Array([{"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}]), pd.DataFrame(), ], "outer", NotImplementedError, # TODO: ak.Array([{"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}]), id="awk_empty-pd", ), pytest.param( [ ak.Array([{"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}]), pd.DataFrame().assign(a=[3, 4], b=[5, 6]), ], "outer", # TODO: Should try inner too if implemented NotImplementedError, ), pytest.param( [ ak.Array([{"a": [1, 2], "b": [1, 2]}, {"a": [3], "b": [4]}]), np.ones((3, 2)), ], "outer", NotImplementedError, ), ], ) @pytest.mark.parametrize("key", ["obsm", "varm"]) def test_concat_mixed_types(key, arrays, expected, join): """Test that concatenation of AwkwardArrays with arbitrary types, but zero length dimension or missing values works.""" axis = 0 if key == "obsm" else 1 to_concat = [] cell_id, gene_id = 0, 0 for a in arrays: shape = np.array([3, 3]) # default shape (in case of missing array) if a is not None: length = axis_len(a, 0) shape[axis] = length tmp_adata = gen_adata( tuple(shape), varm_types=(), obsm_types=(), layers_types=() ) prev_cell_id, prev_gene_id = cell_id, gene_id cell_id, gene_id = cell_id + shape[0], gene_id + shape[1] tmp_adata.obs_names = pd.RangeIndex(prev_cell_id, cell_id).astype(str) tmp_adata.var_names = pd.RangeIndex(prev_gene_id, gene_id).astype(str) if a is not None: if isinstance(a, pd.DataFrame): a.set_index( tmp_adata.obs_names if key == "obsm" else tmp_adata.var_names, inplace=True, ) getattr(tmp_adata, key)["test"] = a to_concat.append(tmp_adata) if isinstance(expected, type) and issubclass(expected, Exception): with warnings.catch_warnings(): warnings.filterwarnings( "ignore", r"The behavior of DataFrame concatenation with empty or all-NA entries is deprecated", FutureWarning, ) with pytest.raises(expected): anndata.concat(to_concat, axis=axis, join=join) else: result_adata = anndata.concat(to_concat, axis=axis, join=join) result = getattr(result_adata, key).get("test", None) assert_equal(expected, result, exact=True) scverse-anndata-b796d59/tests/test_backed_dense.py000066400000000000000000000046441512025555600223230ustar00rootroot00000000000000"""Tests for backing by just sticking zarr/h5py objects into AnnData.""" from __future__ import annotations from typing import TYPE_CHECKING import h5py import numpy as np import pytest import zarr from anndata import AnnData from anndata._io.zarr import open_write_group from anndata.io import write_elem from anndata.tests.helpers import assert_equal if TYPE_CHECKING: from pathlib import Path from typing import Literal @pytest.fixture def file(tmp_path: Path, diskfmt: Literal["h5ad", "zarr"]) -> h5py.File | zarr.Group: path = tmp_path / f"test.{diskfmt}" if diskfmt == "zarr": return open_write_group(path, mode="a") if diskfmt == "h5ad": return h5py.File(path, "a") pytest.fail(f"Unknown diskfmt: {diskfmt}") @pytest.mark.parametrize("assign", ["init", "assign"]) @pytest.mark.parametrize("attr", ["X", "obsm", "varm", "layers"]) def test_create_delete( diskfmt: Literal["h5ad", "zarr"], file: h5py.File | zarr.Group, assign: Literal["init", "assign"], attr: Literal["X", "obsm", "varm", "layers"], ): x = np.random.randn(10, 10) write_elem(file, "a", x) # initialize (and if applicable, assign) if assign == "init": kw = ( dict(X=file["a"]) if attr == "X" else {attr: dict(a=file["a"]), "shape": x.shape} ) adata = AnnData(**kw) elif assign == "assign": adata = AnnData(shape=x.shape) if attr == "X": adata.X = file["a"] else: getattr(adata, attr)["a"] = file["a"] else: pytest.fail(f"Unexpected assign: {assign}") # check equality if attr == "X": # TODO: should that be inverted, e.g. when the Dataset’s path matches the backed mode path? assert not adata.isbacked backed_array = adata.X else: backed_array = getattr(adata, attr)["a"] assert isinstance(backed_array, zarr.Array if diskfmt == "zarr" else h5py.Dataset) assert_equal(backed_array, x) # check that there’s no error deleting it either if attr == "X": del adata.X else: del getattr(adata, attr)["a"] def test_assign_x_subset(file: h5py.File | zarr.Group): x = np.ones((10, 10)) write_elem(file, "a", x) adata = AnnData(file["a"]) view = adata[3:7, 6:8] view.X = np.zeros((4, 2)) expected = x.copy() expected[3:7, 6:8] = np.zeros((4, 2)) assert_equal(adata.X, expected) scverse-anndata-b796d59/tests/test_backed_hdf5.py000066400000000000000000000372611512025555600220540ustar00rootroot00000000000000"""Tests for backing using the `.file` and `.isbacked` attributes.""" from __future__ import annotations from typing import TYPE_CHECKING import joblib import numpy as np import pytest from scipy import sparse import anndata as ad from anndata.tests.helpers import ( GEN_ADATA_DASK_ARGS, GEN_ADATA_NO_XARRAY_ARGS, as_dense_dask_array, assert_equal, gen_adata, subset_func, ) from anndata.utils import asarray if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path from typing import Literal from anndata.compat import DaskArray subset_func2 = subset_func # ------------------------------------------------------------------------------- # Some test data # ------------------------------------------------------------------------------- @pytest.fixture def adata() -> ad.AnnData: X_list = [ [1, 2, 3], [4, 5, 6], [7, 8, 9], ] # data matrix of shape n_obs x n_vars X = np.array(X_list) obs_dict = dict( # annotation of observations / rows row_names=["name1", "name2", "name3"], # row annotation oanno1=["cat1", "cat2", "cat2"], # categorical annotation oanno2=["o1", "o2", "o3"], # string annotation oanno3=[2.1, 2.2, 2.3], # float annotation ) var_dict = dict(vanno1=[3.1, 3.2, 3.3]) # annotation of variables / columns uns_dict = dict( # unstructured annotation oanno1_colors=["#000000", "#FFFFFF"], uns2=["some annotation"] ) return ad.AnnData( X, obs=obs_dict, var=var_dict, uns=uns_dict, obsm=dict(o1=np.zeros((X.shape[0], 10))), varm=dict(v1=np.ones((X.shape[1], 20))), layers=dict(float=X.astype(float), sparse=sparse.csr_matrix(X)), ) @pytest.fixture( params=[sparse.csr_matrix, sparse.csc_matrix, np.array, as_dense_dask_array], ids=["scipy-csr", "scipy-csc", "np-array", "dask_array"], ) def mtx_format( request, ) -> Callable[ [np.ndarray], DaskArray | np.ndarray | sparse.csr_array | sparse.csr_matrix ]: return request.param @pytest.fixture(params=[sparse.csr_matrix, sparse.csc_matrix]) def sparse_format(request) -> type[sparse.csr_matrix | sparse.csc_matrix]: return request.param @pytest.fixture(params=["r+", "r", False]) def backed_mode(request) -> Literal["r+", "r", False]: return request.param @pytest.fixture(params=(("X",), ())) def as_dense(request) -> tuple[str] | tuple: return request.param # ------------------------------------------------------------------------------- # The test functions # ------------------------------------------------------------------------------- # h5py internally calls `product` on min-versions @pytest.mark.filterwarnings("ignore:`product` is deprecated as of NumPy 1.25.0") # TODO: Check to make sure obs, obsm, layers, ... are written and read correctly as well @pytest.mark.filterwarnings("error") def test_read_write_X( tmp_path: Path, mtx_format: Callable[ [np.ndarray], DaskArray | np.ndarray | sparse.csr_array | sparse.csr_matrix ], backed_mode: Literal["r+", "r", False], *, as_dense: tuple[str] | tuple, ): orig_pth = tmp_path / "orig.h5ad" backed_pth = tmp_path / "backed.h5ad" orig = ad.AnnData(mtx_format(asarray(sparse.random(10, 10, format="csr")))) orig.write(orig_pth) backed = ad.read_h5ad(orig_pth, backed=backed_mode) backed.write(backed_pth, as_dense=as_dense) backed.file.close() from_backed = ad.read_h5ad(backed_pth) assert np.all(asarray(orig.X) == asarray(from_backed.X)) def test_backed_view(tmp_path: Path, backed_mode: Literal["r+", "r", False]): orig_pth = tmp_path / "orig.h5ad" orig = ad.AnnData(sparse.random(100, 10, format="csr")) orig.write(orig_pth) adata = ad.read_h5ad(orig_pth, backed=backed_mode) for i in range(0, adata.shape[0], 10): chunk_path = tmp_path / f"chunk_{i}.h5ad" adata[i : i + 5].write_h5ad(tmp_path / f"chunk_{i}.h5ad") chunk = adata[i : i + 5] assert_equal(chunk, ad.read_h5ad(chunk_path)) # this is very similar to the views test @pytest.mark.filterwarnings("ignore::anndata.ImplicitModificationWarning") def test_backing(adata: ad.AnnData, tmp_path: Path, backing_h5ad: Path) -> None: assert not adata.isbacked adata.filename = backing_h5ad adata.write() assert not adata.file.is_open assert adata.isbacked assert adata[:, 0].is_view assert adata[:, 0].X.tolist() == np.reshape([1, 4, 7], (3, 1)).tolist() # this might give us a trouble as the user might not # know that the file is open again.... assert adata.file.is_open adata[:2, 0].X = [0, 0] assert adata[:, 0].X.tolist() == np.reshape([0, 0, 7], (3, 1)).tolist() adata_subset = adata[:2, [0, 1]] assert adata_subset.is_view subset_hash = joblib.hash(adata_subset) # cannot set view in backing mode... with pytest.raises(ValueError, match=r"pass a filename.*to_memory"): adata_subset.obs["foo"] = range(2) with pytest.raises(ValueError, match=r"pass a filename.*to_memory"): adata_subset.var["bar"] = -12 with pytest.raises(ValueError, match=r"pass a filename.*to_memory"): adata_subset.obsm["o2"] = np.ones((2, 2)) with pytest.raises(ValueError, match=r"pass a filename.*to_memory"): adata_subset.varm["v2"] = np.zeros((2, 2)) with pytest.raises(ValueError, match=r"pass a filename.*to_memory"): adata_subset.layers["float2"] = adata_subset.layers["float"].copy() # Things should stay the same after failed operations assert subset_hash == joblib.hash(adata_subset) assert adata_subset.is_view # need to copy first adata_subset = adata_subset.copy(tmp_path / "test.subset.h5ad") # now transition to actual object assert not adata_subset.is_view adata_subset.obs["foo"] = range(2) assert not adata_subset.is_view assert adata_subset.isbacked assert adata_subset.obs["foo"].tolist() == list(range(2)) # save adata_subset.write() def test_backing_copy(adata, tmp_path: Path, backing_h5ad: Path): adata.filename = backing_h5ad adata.write() copypath = tmp_path / "test.copy.h5ad" copy = adata.copy(copypath) assert adata.filename == backing_h5ad assert copy.filename == copypath assert adata.isbacked assert copy.isbacked # TODO: Also test updating the backing file inplace def test_backed_raw(tmp_path: Path): backed_pth = tmp_path / "backed.h5ad" final_pth = tmp_path / "final.h5ad" mem_adata = gen_adata((10, 10), **GEN_ADATA_DASK_ARGS) mem_adata.raw = mem_adata mem_adata.write(backed_pth) backed_adata = ad.read_h5ad(backed_pth, backed="r") assert_equal(backed_adata, mem_adata) backed_adata.write_h5ad(final_pth) final_adata = ad.read_h5ad(final_pth) assert_equal(final_adata, mem_adata) @pytest.mark.parametrize( "array_type", [ pytest.param(asarray, id="dense_array"), pytest.param(sparse.csr_matrix, id="csr_matrix"), pytest.param(sparse.csr_array, id="csr_array"), ], ) def test_backed_raw_subset( tmp_path: Path, array_type: Callable[ [np.ndarray], np.ndarray | sparse.csr_array | sparse.csr_matrix ], subset_func: Callable[[ad.AnnData], ad.AnnData], subset_func2: Callable[[ad.AnnData], ad.AnnData], ): backed_pth = tmp_path / "backed.h5ad" final_pth = tmp_path / "final.h5ad" mem_adata = gen_adata((10, 10), X_type=array_type, **GEN_ADATA_NO_XARRAY_ARGS) mem_adata.raw = mem_adata obs_idx = subset_func(mem_adata.obs_names) var_idx = subset_func2(mem_adata.var_names) mem_adata.write(backed_pth) ### Backed view has same values as in memory view ### backed_adata = ad.read_h5ad(backed_pth, backed="r") backed_v = backed_adata[obs_idx, var_idx] assert backed_v.is_view mem_v = mem_adata[obs_idx, var_idx] # Value equivalent assert_equal(mem_v, backed_v) # Type and value equivalent assert_equal(mem_v.copy(), backed_v.to_memory(copy=True), exact=True) assert backed_v.is_view assert backed_v.isbacked ### Write from backed view ### backed_v.write_h5ad(final_pth) final_adata = ad.read_h5ad(final_pth) assert_equal(mem_v, final_adata) assert_equal(final_adata, backed_v.to_memory()) # assert loading into memory @pytest.mark.parametrize( "array_type", [ pytest.param(asarray, id="dense_array"), pytest.param(sparse.csr_matrix, id="csr_matrix"), pytest.param(as_dense_dask_array, id="dask_array"), ], ) def test_to_memory_full( tmp_path: Path, array_type: Callable[[np.ndarray], np.ndarray | DaskArray | sparse.csr_matrix], ): backed_pth = tmp_path / "backed.h5ad" mem_adata = gen_adata((15, 10), X_type=array_type, **GEN_ADATA_DASK_ARGS) mem_adata.raw = gen_adata((15, 12), X_type=array_type, **GEN_ADATA_DASK_ARGS) mem_adata.write_h5ad(backed_pth, compression="lzf") backed_adata = ad.read_h5ad(backed_pth, backed="r") assert_equal(mem_adata, backed_adata.to_memory()) # Test that raw can be removed del backed_adata.raw del mem_adata.raw assert_equal(mem_adata, backed_adata.to_memory()) def test_double_index(adata: ad.AnnData, backing_h5ad: Path): adata.filename = backing_h5ad with pytest.raises(ValueError, match=r"cannot make a view of a view"): # no view of view of backed object currently adata[:2][:, 0] # close backing file adata.write() def test_return_to_memory_mode(adata: ad.AnnData, backing_h5ad: Path): bdata = adata.copy() adata.filename = backing_h5ad assert adata.isbacked adata.filename = None assert not adata.isbacked assert adata.X is not None # make sure the previous file had been properly closed # when setting `adata.filename = None` # if it hadn’t the following line would throw an error bdata.filename = backing_h5ad # close the file bdata.filename = None def test_backed_modification(adata: ad.AnnData, backing_h5ad: Path): adata.X[:, 1] = 0 # Make it a little sparse adata.X = sparse.csr_matrix(adata.X) assert not adata.isbacked # While this currently makes the file backed, it doesn’t write it as sparse adata.filename = backing_h5ad adata.write() assert not adata.file.is_open assert adata.isbacked adata.X[0, [0, 2]] = 10 adata.X[1, [0, 2]] = [11, 12] adata.X[2, 1] = 13 # If it were written as sparse, this should fail assert adata.isbacked assert np.all(adata.X[0, :] == np.array([10, 0, 10])) assert np.all(adata.X[1, :] == np.array([11, 0, 12])) assert np.all(adata.X[2, :] == np.array([7, 13, 9])) def test_backed_modification_sparse( adata: ad.AnnData, backing_h5ad: Path, sparse_format: type[sparse.csr_matrix | sparse.csc_matrix], ): adata.X[:, 1] = 0 # Make it a little sparse adata.X = sparse_format(adata.X) assert not adata.isbacked adata.write(backing_h5ad) adata = ad.read_h5ad(backing_h5ad, backed="r+") assert adata.filename == backing_h5ad assert adata.isbacked pat = r"__setitem__ for backed sparse will be removed" with pytest.warns(FutureWarning, match=pat): adata.X[0, [0, 2]] = 10 with pytest.warns(FutureWarning, match=pat): adata.X[1, [0, 2]] = [11, 12] with ( pytest.warns(FutureWarning, match=pat), pytest.raises(ValueError, match=r"cannot change the sparsity structure"), ): adata.X[2, 1] = 13 assert adata.isbacked assert np.all(adata.X[0, :] == np.array([10, 0, 10])) assert np.all(adata.X[1, :] == np.array([11, 0, 12])) assert np.all(adata.X[2, :] == np.array([7, 0, 9])) # TODO: Work around h5py not supporting this # def test_backed_view_modification(adata, backing_h5ad): # adata.write(backing_h5ad) # backed_adata = ad.read_h5ad(backing_h5ad, backed=True) # backed_view = backed_adata[[1, 2], :] # backed_view.X = 0 # assert np.all(backed_adata.X[:3, :] == 0) # TODO: Implement # def test_backed_view_modification_sparse(adata, backing_h5ad, sparse_format): # adata[:, 1] = 0 # Make it a little sparse # adata.X = sparse_format(adata.X) # adata.write(backing_h5ad) # backed_adata = ad.read_h5ad(backing_h5ad, backed=True) # backed_view = backed_adata[[1,2], :] # backed_view.X = 0 # assert np.all(backed_adata.X[[1,2], :] == 0) @pytest.mark.parametrize( ("obs_idx", "var_idx"), [ pytest.param(np.array([0, 1, 2]), np.array([1, 2]), id="no_dupes"), pytest.param(np.array([0, 1, 0, 2]), slice(None), id="1d_dupes"), pytest.param(np.array([0, 1, 0, 2]), np.array([1, 2, 1]), id="2d_dupes"), ], ) def test_backed_duplicate_indices(tmp_path, obs_idx, var_idx): """Test that backed HDF5 datasets handle duplicate indices correctly.""" backed_pth = tmp_path / "backed.h5ad" # Create test data mem_adata = gen_adata((6, 4), X_type=asarray, **GEN_ADATA_NO_XARRAY_ARGS) mem_adata.write(backed_pth) # Load backed data backed_adata = ad.read_h5ad(backed_pth, backed="r") # Test the indexing mem_result_multi = mem_adata[obs_idx, var_idx] backed_result_multi = backed_adata[obs_idx, var_idx] assert_equal(mem_result_multi, backed_result_multi) @pytest.fixture def h5py_test_data(tmp_path): """Create test HDF5 file with dataset for _safe_fancy_index_h5py tests.""" import h5py h5_path = tmp_path / "test_dataset.h5" test_data = np.arange(24).reshape(6, 4) # 6x4 matrix with h5py.File(h5_path, "w") as f: f.create_dataset("test", data=test_data) return h5_path, test_data @pytest.mark.parametrize( ("indices", "description"), [ pytest.param((np.array([0, 1, 0, 2]),), "single_dimension_with_duplicates"), pytest.param( (np.array([0, 1, 2]), np.array([1, 2])), "multi_dimensional_no_duplicates" ), pytest.param( (np.array([0, 1, 0, 2]), np.array([1, 2])), "multi_dimensional_duplicates_first_dim", ), pytest.param( (np.array([0, 1, 2]), np.array([1, 2, 1])), "multi_dimensional_duplicates_second_dim", ), pytest.param( (np.array([0, 1, 0]), np.array([1, 2, 1])), "multi_dimensional_duplicates_both_dims", ), pytest.param( (np.array([True, False, True, False, False, True]),), "boolean_arrays" ), pytest.param((np.array([0, 1, 0]), slice(1, 3)), "mixed_indexing_with_slices"), pytest.param( (np.array([0, 1, 0]), [1, 2]), "mixed_indexing_with_slices_and_lists" ), pytest.param((np.array([3, 1, 3, 0, 1]),), "unsorted_indices_with_duplicates"), ], ) def test_safe_fancy_index_h5py_function(h5py_test_data, indices, description): """Test the _safe_fancy_index_h5py function directly with various indexing patterns.""" import h5py from anndata._core.index import _safe_fancy_index_h5py h5_path, test_data = h5py_test_data with h5py.File(h5_path, "r") as f: dataset = f["test"] # Get result from the function result = _safe_fancy_index_h5py(dataset, indices) # Calculate expected result using NumPy if isinstance(indices, tuple) and len(indices) > 1: # Multi-dimensional case - use np.ix_ for fancy indexing if isinstance(indices[1], slice): # Handle mixed case with slice expected = test_data[ np.ix_(indices[0], np.arange(indices[1].start, indices[1].stop)) ] else: expected = test_data[np.ix_(*indices)] else: # Single dimensional case expected = test_data[indices] # Assert arrays are equal np.testing.assert_array_equal( result, expected, err_msg=f"Failed for test case: {description}" ) scverse-anndata-b796d59/tests/test_backed_sparse.py000066400000000000000000000567421512025555600225300ustar00rootroot00000000000000from __future__ import annotations from functools import partial from itertools import product from typing import TYPE_CHECKING, Literal, get_args import h5py import numpy as np import pytest import zarr from scipy import sparse import anndata as ad from anndata._core.anndata import AnnData from anndata._core.sparse_dataset import sparse_dataset from anndata._io.specs.registry import read_elem_lazy from anndata._io.zarr import open_write_group from anndata.compat import CSArray, CSMatrix, DaskArray, ZarrGroup, is_zarr_v2 from anndata.experimental import read_dispatched from anndata.tests import helpers as test_helpers from anndata.tests.helpers import AccessTrackingStore, assert_equal, subset_func if TYPE_CHECKING: from collections.abc import Callable, Generator, Sequence from pathlib import Path from types import EllipsisType from _pytest.mark import ParameterSet from numpy.typing import ArrayLike, NDArray from pytest_mock import MockerFixture from anndata.abc import CSCDataset, CSRDataset Idx = slice | int | NDArray[np.integer] | NDArray[np.bool_] subset_func2 = subset_func M = 50 N = 50 @pytest.fixture(params=[pytest.param(None, marks=pytest.mark.zarr_io)]) def zarr_metadata_key() -> Literal[".zarray", "zarr.json"]: return ".zarray" if ad.settings.zarr_write_format == 2 else "zarr.json" @pytest.fixture(params=[pytest.param(None, marks=pytest.mark.zarr_io)]) def zarr_separator(): return "" if ad.settings.zarr_write_format == 2 else "/c" @pytest.fixture def ondisk_equivalent_adata( tmp_path: Path, diskfmt: Literal["h5ad", "zarr"] ) -> tuple[AnnData, AnnData, AnnData, AnnData]: csr_path = tmp_path / f"csr.{diskfmt}" csc_path = tmp_path / f"csc.{diskfmt}" dense_path = tmp_path / f"dense.{diskfmt}" write = lambda x, pth, **kwargs: getattr(x, f"write_{diskfmt}")(pth, **kwargs) csr_mem = ad.AnnData(X=sparse.random(M, N, format="csr", density=0.1)) csc_mem = ad.AnnData(X=csr_mem.X.tocsc()) dense_mem = ad.AnnData(X=csr_mem.X.toarray()) write(csr_mem, csr_path) write(csc_mem, csc_path) # write(csr_mem, dense_path, as_dense="X") write(dense_mem, dense_path) if diskfmt == "h5ad": csr_disk = ad.read_h5ad(csr_path, backed="r") csc_disk = ad.read_h5ad(csc_path, backed="r") dense_disk = ad.read_h5ad(dense_path, backed="r") else: def read_zarr_backed(path): path = str(path) f = zarr.open(path, mode="r") # Read with handling for backwards compat def callback(func, elem_name, elem, iospec): if iospec.encoding_type == "anndata" or elem_name.endswith("/"): return AnnData(**{ k: read_dispatched(v, callback) for k, v in dict(elem).items() }) if iospec.encoding_type in {"csc_matrix", "csr_matrix"}: return sparse_dataset(elem) return func(elem) adata = read_dispatched(f, callback=callback) return adata csr_disk = read_zarr_backed(csr_path) csc_disk = read_zarr_backed(csc_path) dense_disk = read_zarr_backed(dense_path) return csr_mem, csr_disk, csc_disk, dense_disk @pytest.mark.parametrize( "empty_mask", [[], np.zeros(M, dtype=bool)], ids=["empty_list", "empty_bool_mask"] ) def test_empty_backed_indexing( ondisk_equivalent_adata: tuple[AnnData, AnnData, AnnData, AnnData], empty_mask, ): csr_mem, csr_disk, csc_disk, _ = ondisk_equivalent_adata assert_equal(csr_mem.X[empty_mask], csr_disk.X[empty_mask]) assert_equal(csr_mem.X[:, empty_mask], csc_disk.X[:, empty_mask]) # The following do not work because of https://github.com/scipy/scipy/issues/19919 # Our implementation returns a (0,0) sized matrix but scipy does (1,0). # assert_equal(csr_mem.X[empty_mask, empty_mask], csr_disk.X[empty_mask, empty_mask]) # assert_equal(csr_mem.X[empty_mask, empty_mask], csc_disk.X[empty_mask, empty_mask]) def test_backed_indexing( ondisk_equivalent_adata: tuple[AnnData, AnnData, AnnData, AnnData], subset_func, subset_func2, ): csr_mem, csr_disk, csc_disk, dense_disk = ondisk_equivalent_adata obs_idx = subset_func(csr_mem.obs_names) var_idx = subset_func2(csr_mem.var_names) assert_equal(csr_mem[obs_idx, var_idx].X, csr_disk[obs_idx, var_idx].X) assert_equal(csr_mem[obs_idx, var_idx].X, csc_disk[obs_idx, var_idx].X) assert_equal(csr_mem.X[...], csc_disk.X[...]) assert_equal(csr_mem[obs_idx, :].X, dense_disk[obs_idx, :].X) assert_equal(csr_mem[obs_idx].X, csr_disk[obs_idx].X) assert_equal(csr_mem[:, var_idx].X, dense_disk[:, var_idx].X) def test_backed_ellipsis_indexing( ondisk_equivalent_adata: tuple[AnnData, AnnData, AnnData, AnnData], ellipsis_index: tuple[EllipsisType | slice, ...] | EllipsisType, equivalent_ellipsis_index: tuple[slice, slice], ): csr_mem, csr_disk, csc_disk, _ = ondisk_equivalent_adata assert_equal(csr_mem.X[equivalent_ellipsis_index], csr_disk.X[ellipsis_index]) assert_equal(csr_mem.X[equivalent_ellipsis_index], csc_disk.X[ellipsis_index]) def make_randomized_mask(size: int) -> np.ndarray: randomized_mask = np.zeros(size, dtype=bool) inds = np.random.choice(size, 20, replace=False) inds.sort() for i in range(0, len(inds) - 1, 2): randomized_mask[inds[i] : inds[i + 1]] = True return randomized_mask def make_alternating_mask(size: int, step: int) -> np.ndarray: mask_alternating = np.ones(size, dtype=bool) for i in range(0, size, step): # 5 is too low to trigger new behavior mask_alternating[i] = False return mask_alternating # non-random indices, with alternating one false and n true make_alternating_mask_5 = partial(make_alternating_mask, step=5) make_alternating_mask_15 = partial(make_alternating_mask, step=15) def make_one_group_mask(size: int) -> np.ndarray: one_group_mask = np.zeros(size, dtype=bool) one_group_mask[1 : size // 2] = True return one_group_mask def make_one_elem_mask(size: int) -> np.ndarray: one_elem_mask = np.zeros(size, dtype=bool) one_elem_mask[size // 4] = True return one_elem_mask # test behavior from https://github.com/scverse/anndata/pull/1233 @pytest.mark.parametrize( ("make_bool_mask", "should_trigger_optimization"), [ (make_randomized_mask, None), (make_alternating_mask_15, True), (make_alternating_mask_5, False), (make_one_group_mask, True), (make_one_elem_mask, False), ], ids=["randomized", "alternating_15", "alternating_5", "one_group", "one_elem"], ) def test_consecutive_bool( *, mocker: MockerFixture, ondisk_equivalent_adata: tuple[AnnData, AnnData, AnnData, AnnData], make_bool_mask: Callable[[int], np.ndarray], should_trigger_optimization: bool | None, ): """Tests for optimization from https://github.com/scverse/anndata/pull/1233 Parameters ---------- mocker Mocker object ondisk_equivalent_adata AnnData objects with sparse X for testing make_bool_mask Function for creating a boolean mask. should_trigger_optimization Whether or not a given mask should trigger the optimized behavior. """ _, csr_disk, csc_disk, _ = ondisk_equivalent_adata mask = make_bool_mask(csr_disk.shape[0]) # indexing needs to be on `X` directly to trigger the optimization. # `_normalize_indices`, which is used by `AnnData`, converts bools to ints with `np.where` from anndata._core import sparse_dataset spy = mocker.spy(sparse_dataset, "get_compressed_vectors_for_slices") assert_equal(csr_disk.X[mask, :], csr_disk.X[np.where(mask)]) if should_trigger_optimization is not None: assert ( spy.call_count == 1 if should_trigger_optimization else not spy.call_count ) assert_equal(csc_disk.X[:, mask], csc_disk.X[:, np.where(mask)[0]]) if should_trigger_optimization is not None: assert ( spy.call_count == 2 if should_trigger_optimization else not spy.call_count ) assert_equal(csr_disk[mask, :], csr_disk[np.where(mask)]) if should_trigger_optimization is not None: assert ( spy.call_count == 3 if should_trigger_optimization else not spy.call_count ) subset = csc_disk[:, mask] assert_equal(subset, csc_disk[:, np.where(mask)[0]]) if should_trigger_optimization is not None: assert ( spy.call_count == 4 if should_trigger_optimization else not spy.call_count ) if should_trigger_optimization is not None and not csc_disk.isbacked: size = subset.shape[1] if should_trigger_optimization: subset_subset_mask = np.ones(size).astype("bool") subset_subset_mask[size // 2] = False else: subset_subset_mask = make_one_elem_mask(size) assert_equal( subset[:, subset_subset_mask], subset[:, np.where(subset_subset_mask)[0]] ) assert ( spy.call_count == 5 if should_trigger_optimization else not spy.call_count ), f"Actual count: {spy.call_count}" @pytest.mark.parametrize( ("sparse_format", "append_method"), [ pytest.param(sparse.csr_matrix, sparse.vstack), pytest.param(sparse.csc_matrix, sparse.hstack), pytest.param(sparse.csr_array, sparse.vstack), pytest.param(sparse.csc_array, sparse.hstack), ], ) def test_dataset_append_memory( tmp_path: Path, sparse_format: Callable[[ArrayLike], CSMatrix], append_method: Callable[[list[CSMatrix]], CSMatrix], diskfmt: Literal["h5ad", "zarr"], ): path = tmp_path / f"test.{diskfmt.replace('ad', '')}" a = sparse_format(sparse.random(100, 100)) b = sparse_format(sparse.random(100, 100)) f = open_write_group(path, mode="a") if diskfmt == "zarr" else h5py.File(path, "a") ad.io.write_elem(f, "mtx", a) diskmtx = sparse_dataset(f["mtx"]) diskmtx.append(b) fromdisk = diskmtx.to_memory() frommem = append_method([a, b]) assert_equal(fromdisk, frommem) def test_append_array_cache_bust(tmp_path: Path, diskfmt: Literal["h5ad", "zarr"]): path = tmp_path / f"test.{diskfmt.replace('ad', '')}" a = sparse.random(100, 100, format="csr") f = open_write_group(path, mode="a") if diskfmt == "zarr" else h5py.File(path, "a") ad.io.write_elem(f, "mtx", a) ad.io.write_elem(f, "mtx_2", a) diskmtx = sparse_dataset(f["mtx"]) old_array_shapes = {} array_names = ["indptr", "indices", "data"] for name in array_names: old_array_shapes[name] = getattr(diskmtx, f"_{name}").shape diskmtx.append(sparse_dataset(f["mtx_2"])) for name in array_names: assert old_array_shapes[name] != getattr(diskmtx, f"_{name}").shape @pytest.mark.parametrize("sparse_format", [sparse.csr_matrix, sparse.csc_matrix]) @pytest.mark.parametrize( ("subset_func", "subset_func2"), product( [ test_helpers.array_subset, test_helpers.slice_int_subset, test_helpers.array_int_subset, test_helpers.array_bool_subset, ], repeat=2, ), ) def test_read_array( tmp_path: Path, sparse_format: Callable[[ArrayLike], CSMatrix], diskfmt: Literal["h5ad", "zarr"], subset_func, subset_func2, ): path = tmp_path / f"test.{diskfmt.replace('ad', '')}" a = sparse_format(sparse.random(100, 100)) obs_idx = subset_func(np.arange(100)) var_idx = subset_func2(np.arange(100)) f = open_write_group(path, mode="a") if diskfmt == "zarr" else h5py.File(path, "a") ad.io.write_elem(f, "mtx", a) diskmtx = sparse_dataset(f["mtx"]) ad.settings.use_sparse_array_on_read = True assert issubclass(type(diskmtx[obs_idx, var_idx]), CSArray) ad.settings.use_sparse_array_on_read = False assert issubclass(type(diskmtx[obs_idx, var_idx]), CSMatrix) @pytest.mark.parametrize( ("sparse_format", "append_method"), [ pytest.param(sparse.csr_matrix, sparse.vstack), pytest.param(sparse.csc_matrix, sparse.hstack), ], ) def test_dataset_append_disk( tmp_path: Path, sparse_format: Callable[[ArrayLike], CSMatrix], append_method: Callable[[list[CSMatrix]], CSMatrix], diskfmt: Literal["h5ad", "zarr"], ): path = tmp_path / f"test.{diskfmt.replace('ad', '')}" a = sparse_format(sparse.random(10, 10)) b = sparse_format(sparse.random(10, 10)) f = open_write_group(path, mode="a") if diskfmt == "zarr" else h5py.File(path, "a") ad.io.write_elem(f, "a", a) ad.io.write_elem(f, "b", b) a_disk = sparse_dataset(f["a"]) b_disk = sparse_dataset(f["b"]) a_disk.append(b_disk) fromdisk = a_disk.to_memory() frommem = append_method([a, b]) assert_equal(fromdisk, frommem) @pytest.mark.parametrize("sparse_format", [sparse.csr_matrix, sparse.csc_matrix]) @pytest.mark.parametrize("should_cache_indptr", [True, False]) def test_lazy_array_cache( tmp_path: Path, sparse_format: Callable[[ArrayLike], CSMatrix], zarr_metadata_key: Literal[".zarray", "zarr.json"], *, should_cache_indptr: bool, ): elems = {"indptr", "indices", "data"} path = tmp_path / "test.zarr" a = sparse_format(sparse.random(10, 10)) f = open_write_group(path, mode="a") ad.io.write_elem(f, "X", a) store = AccessTrackingStore(path) for elem in elems: store.initialize_key_trackers([f"X/{elem}"]) f = zarr.open_group(store, mode="r") a_disk = sparse_dataset(f["X"], should_cache_indptr=should_cache_indptr) a_disk[:1] a_disk[3:5] a_disk[6:7] a_disk[8:9] # One hit for .zarray in zarr v2 and three for metadata in zarr v3: # see https://github.com/zarr-developers/zarr-python/discussions/2760 for more info on the difference. # Then there is actual data access, 1 more when cached, 4 more otherwise. match should_cache_indptr, is_zarr_v2(): case True, True: assert store.get_access_count("X/indptr") == 2 case False, True: assert store.get_access_count("X/indptr") == 5 case True, False: assert store.get_access_count("X/indptr") == 4 case False, False: assert store.get_access_count("X/indptr") == 7 for elem_not_indptr in elems - {"indptr"}: assert ( sum( zarr_metadata_key in key_accessed for key_accessed in store.get_accessed_keys(f"X/{elem_not_indptr}") ) == 1 ) Kind = Literal["slice", "int", "array", "mask"] def mk_idx_kind(idx: Sequence[int], *, kind: Kind, l: int) -> Idx | None: """Convert sequence of consecutive integers (e.g. range with step=1) into different kinds of indexing.""" if kind == "slice": start = idx[0] if idx[0] > 0 else None if len(idx) == 1: return slice(start, idx[0] + 1) if all(np.diff(idx) == 1): stop = idx[-1] + 1 if idx[-1] < l - 1 else None return slice(start, stop) if kind == "int" and len(idx) == 1: return idx[0] if kind == "array": return np.asarray(idx) if kind == "mask": return np.isin(np.arange(l), idx) return None def idify(x: object) -> str: if isinstance(x, slice): start, stop = ("" if s is None else str(s) for s in (x.start, x.stop)) return f"{start}:{stop}" + (f":{x.step}" if x.step not in (1, None) else "") return str(x) def width_idx_kinds( *idxs: tuple[Sequence[int], Idx, Sequence[str]], l: int ) -> Generator[ParameterSet, None, None]: """Convert major (first) index into various identical kinds of indexing.""" for (idx_maj_raw, idx_min, exp), maj_kind in product(idxs, get_args(Kind)): if (idx_maj := mk_idx_kind(idx_maj_raw, kind=maj_kind, l=l)) is None: continue id_ = "-".join(map(idify, [idx_maj_raw, idx_min, maj_kind])) yield pytest.param(idx_maj, idx_min, exp, id=id_) @pytest.mark.parametrize("sparse_format", [sparse.csr_matrix, sparse.csc_matrix]) @pytest.mark.parametrize( ("idx_maj", "idx_min", "exp"), width_idx_kinds( ( [0], slice(None, None), ["X/data/{zarr_metadata_key}", "X/data{zarr_separator}/0"], ), ( [0], slice(None, 3), ["X/data/{zarr_metadata_key}", "X/data{zarr_separator}/0"], ), ( [3, 4, 5], slice(None, None), [ "X/data/{zarr_metadata_key}", "X/data{zarr_separator}/3", "X/data{zarr_separator}/4", "X/data{zarr_separator}/5", ], ), l=10, ), ) @pytest.mark.parametrize( "open_func", [ sparse_dataset, lambda x: read_elem_lazy( x, chunks=(1, -1) if x.attrs["encoding-type"] == "csr_matrix" else (-1, 1) ), ], ids=["sparse_dataset", "read_elem_lazy"], ) def test_data_access( tmp_path: Path, sparse_format: Callable[[ArrayLike], CSMatrix], idx_maj: Idx, idx_min: Idx, exp: list[str], open_func: Callable[[ZarrGroup], CSRDataset | CSCDataset | DaskArray], zarr_metadata_key, zarr_separator, ): exp = [ e.format(zarr_metadata_key=zarr_metadata_key, zarr_separator=zarr_separator) for e in exp ] path = tmp_path / "test.zarr" a = sparse_format(np.eye(10, 10)) f = open_write_group(path, mode="a") ad.io.write_elem(f, "X", a) data = f["X/data"][...] del f["X/data"] # chunk one at a time to count properly kwargs = {} if not is_zarr_v2(): kwargs["zarr_format"] = f.metadata.zarr_format zarr.array( data, store=path / "X" / "data", chunks=(1,), **kwargs, ) store = AccessTrackingStore(path) store.initialize_key_trackers(["X/data"]) f = zarr.open_group(store, mode="r") a_disk = AnnData(X=open_func(f["X"])) subset = a_disk[idx_maj, idx_min] if a.format == "csr" else a_disk[idx_min, idx_maj] if isinstance(subset.X, DaskArray): subset.X.compute(scheduler="single-threaded") # zarr v2 fetches all and not just metadata for that node in 3.X.X python package # TODO: https://github.com/zarr-developers/zarr-python/discussions/2760 if ad.settings.zarr_write_format == 2 and not is_zarr_v2(): exp = [*exp, "X/data/.zgroup", "X/data/.zattrs"] assert store.get_access_count("X/data") == len(exp), store.get_accessed_keys( "X/data" ) # dask access order is not guaranteed so need to sort assert sorted(store.get_accessed_keys("X/data")) == sorted(exp) @pytest.mark.parametrize( ("sparse_format", "a_shape", "b_shape"), [ pytest.param("csr", (100, 100), (100, 200)), pytest.param("csc", (100, 100), (200, 100)), ], ) def test_wrong_shape( tmp_path: Path, sparse_format: Literal["csr", "csc"], a_shape: tuple[int, int], b_shape: tuple[int, int], ): path = tmp_path / "test.h5ad" a_mem = sparse.random(*a_shape, format=sparse_format) b_mem = sparse.random(*b_shape, format=sparse_format) f = h5py.File(path, "a") ad.io.write_elem(f, "a", a_mem) ad.io.write_elem(f, "b", b_mem) a_disk = sparse_dataset(f["a"]) b_disk = sparse_dataset(f["b"]) with pytest.raises(AssertionError): a_disk.append(b_disk) def test_reset_group(tmp_path: Path, diskfmt: Literal["h5ad", "zarr"]): path = tmp_path / "test.zarr" base = sparse.random(100, 100, format="csr") f = open_write_group(path, mode="a") if diskfmt == "zarr" else h5py.File(path, "a") ad.io.write_elem(f, "base", base) disk_mtx = sparse_dataset(f["base"]) with pytest.raises(AttributeError): disk_mtx.group = f def test_wrong_formats(tmp_path: Path): path = tmp_path / "test.h5ad" base = sparse.random(100, 100, format="csr") f = h5py.File(path, "a") ad.io.write_elem(f, "base", base) disk_mtx = sparse_dataset(f["base"]) pre_checks = disk_mtx.to_memory() with pytest.raises(ValueError, match="must have same format"): disk_mtx.append(sparse.random(100, 100, format="csc")) with pytest.raises(ValueError, match="must have same format"): disk_mtx.append(sparse.random(100, 100, format="coo")) with pytest.raises(NotImplementedError): disk_mtx.append(np.random.random((100, 100))) if isinstance(f, ZarrGroup) and not is_zarr_v2(): data = np.random.random((100, 100)) disk_dense = f.create_array("dense", shape=(100, 100), dtype=data.dtype) disk_dense[...] = data else: disk_dense = f.create_dataset( "dense", data=np.random.random((100, 100)), shape=(100, 100) ) with pytest.raises(NotImplementedError): disk_mtx.append(disk_dense) post_checks = disk_mtx.to_memory() # Check nothing changed assert not np.any((pre_checks != post_checks).toarray()) def test_anndata_sparse_compat(tmp_path: Path, diskfmt: Literal["h5ad", "zarr"]): path = tmp_path / f"test.{diskfmt.replace('ad', '')}" base = sparse.random(100, 100, format="csr") f = open_write_group(path, mode="a") if diskfmt == "zarr" else h5py.File(path, "a") ad.io.write_elem(f, "/", base) adata = ad.AnnData(sparse_dataset(f["/"])) assert_equal(adata.X, base) def test_write(tmp_path: Path, diskfmt: Literal["h5ad", "zarr"]): base = sparse.random(10, 10, format="csr") f = ( open_write_group(tmp_path / f"parent_store.{diskfmt}", mode="a") if diskfmt == "zarr" else h5py.File(tmp_path / f"parent_store.{diskfmt}", "a") ) ad.io.write_elem(f, "a_sparse_matrix", base) adata = ad.AnnData(sparse_dataset(f["a_sparse_matrix"])) ad.io.write_elem(f, "adata", adata) adata_roundtripped = ad.io.read_elem(f["adata"]) assert_equal(adata_roundtripped.X, base) @pytest.mark.filterwarnings(r"ignore:.*array concat.*empty entries:FutureWarning") def test_backed_sizeof( ondisk_equivalent_adata: tuple[AnnData, AnnData, AnnData, AnnData], ): csr_mem, csr_disk, csc_disk, _ = ondisk_equivalent_adata assert csr_mem.__sizeof__() == csr_disk.__sizeof__(with_disk=True) assert csr_mem.__sizeof__() == csc_disk.__sizeof__(with_disk=True) assert csr_disk.__sizeof__(with_disk=True) == csc_disk.__sizeof__(with_disk=True) assert csr_mem.__sizeof__() > csr_disk.__sizeof__() assert csr_mem.__sizeof__() > csc_disk.__sizeof__() @pytest.mark.parametrize( "group_fn", [ pytest.param(lambda _: zarr.group(), id="zarr"), pytest.param(lambda p: h5py.File(p / "test.h5", mode="a"), id="h5py"), ], ) @pytest.mark.parametrize( "sparse_class", [ sparse.csr_matrix, pytest.param( sparse.csr_array, marks=[pytest.mark.skip(reason="scipy bug causes view to be allocated")], ), ], ) def test_append_overflow_check(group_fn, sparse_class, tmp_path): group = group_fn(tmp_path) typemax_int32 = np.iinfo(np.int32).max orig_mtx = sparse_class(np.ones((1, 1), dtype=bool)) # Minimally allocating new matrix new_mtx = sparse_class( ( np.broadcast_to(True, typemax_int32 - 1), # noqa: FBT003 np.broadcast_to(np.int32(1), typemax_int32 - 1), [0, typemax_int32 - 1], ), shape=(1, 2), ) ad.io.write_elem(group, "mtx", orig_mtx) backed = sparse_dataset(group["mtx"]) # Checking for correct caching behaviour backed._indptr # noqa: B018 with pytest.raises( OverflowError, match=r"This array was written with a 32 bit intptr, but is now large.*", ): backed.append(new_mtx) # Check for any modification assert_equal(backed, orig_mtx) scverse-anndata-b796d59/tests/test_base.py000066400000000000000000000641371512025555600206510ustar00rootroot00000000000000from __future__ import annotations import re import warnings from functools import partial from itertools import product from typing import TYPE_CHECKING import numpy as np import pandas as pd import pytest from numpy import ma from scipy import sparse as sp from scipy.sparse import csr_matrix, issparse import anndata as ad from anndata import AnnData, ImplicitModificationWarning from anndata._core.raw import Raw from anndata._settings import settings from anndata.tests.helpers import ( GEN_ADATA_NO_XARRAY_ARGS, assert_equal, gen_adata, get_multiindex_columns_df, ) if TYPE_CHECKING: from pathlib import Path from typing import Literal # some test objects that we use below adata_dense = AnnData(np.array([[1, 2], [3, 4]])) adata_dense.layers["test"] = adata_dense.X adata_sparse = AnnData( csr_matrix([[0, 2, 3], [0, 5, 6]]), dict(obs_names=["s1", "s2"], anno1=["c1", "c2"]), dict(var_names=["a", "b", "c"]), ) def test_creation(): AnnData(np.array([[1, 2], [3, 4]])) AnnData(np.array([[1, 2], [3, 4]]), {}, {}) AnnData(ma.array([[1, 2], [3, 4]]), uns=dict(mask=[0, 1, 1, 0])) AnnData(sp.eye(2, format="csr")) AnnData(sp.csr_array([[1, 0], [0, 1]])) X = np.array([[1, 2, 3], [4, 5, 6]]) adata = AnnData( X=X, obs=dict(Obs=["A", "B"]), var=dict(Feat=["a", "b", "c"]), obsm=dict(X_pca=np.array([[1, 2], [3, 4]])), raw=dict(X=X, var=dict(var_names=["a", "b", "c"])), ) assert adata.raw.X.tolist() == X.tolist() assert adata.raw.var_names.tolist() == ["a", "b", "c"] # init with empty data matrix shape = (3, 5) adata = AnnData(None, uns=dict(test=np.array((3, 3))), shape=shape) assert adata.X is None assert adata.shape == shape assert "test" in adata.uns @pytest.mark.parametrize( ("src", "src_arg", "dim_msg"), [ pytest.param( "X", adata_dense.X, "`{dim}` must have as many rows as `X` has {mat_dim}s", id="x", ), pytest.param( "shape", (2, 2), "`shape` is inconsistent with `{dim}`", id="shape" ), ], ) @pytest.mark.parametrize("dim", ["obs", "var"]) @pytest.mark.parametrize( ("dim_arg", "msg"), [ pytest.param( lambda _: dict(TooLong=[1, 2, 3, 4]), "Length of values (4) does not match length of index (2)", id="too_long_col", ), pytest.param( lambda dim: {f"{dim}_names": ["a", "b", "c"]}, None, id="too_many_names" ), pytest.param( lambda _: pd.DataFrame(index=["a", "b", "c"]), None, id="too_long_df" ), ], ) def test_creation_error(src, src_arg, dim_msg, dim, dim_arg, msg: str | None): if msg is None: mat_dim = "row" if dim == "obs" else "column" msg = dim_msg.format(dim=dim, mat_dim=mat_dim) with pytest.raises(ValueError, match=re.escape(msg)): AnnData(**{src: src_arg, dim: dim_arg(dim)}) def test_invalid_X(): with pytest.raises( ValueError, match=r"X needs to be of one of .*not \.", ): AnnData("string is not a valid X") def test_create_with_dfs(): X = np.ones((6, 3)) obs = pd.DataFrame(dict(cat_anno=pd.Categorical(["a", "a", "a", "a", "b", "a"]))) obs_copy = obs.copy() adata = AnnData(X=X, obs=obs) assert obs.index.equals(obs_copy.index) assert obs.index.astype(str).equals(adata.obs.index) def test_create_from_df(): df = pd.DataFrame(np.ones((3, 2)), index=["a", "b", "c"], columns=["A", "B"]) ad = AnnData(df) assert df.values.tolist() == ad.X.tolist() assert df.columns.tolist() == ad.var_names.tolist() assert df.index.tolist() == ad.obs_names.tolist() @pytest.mark.parametrize("attr", ["X", "obs", "obsm"]) def test_error_create_from_multiindex_df(attr): df = get_multiindex_columns_df((100, 20)) val = df if attr != "obsm" else {"df": df} with pytest.raises(ValueError, match=r"MultiIndex columns are not supported"): AnnData(**{attr: val}, shape=(100, 10)) def test_create_from_sparse_df(): s = sp.random(20, 30, density=0.2, format="csr") obs_names = [f"obs{i}" for i in range(20)] var_names = [f"var{i}" for i in range(30)] df = pd.DataFrame.sparse.from_spmatrix(s, index=obs_names, columns=var_names) a = AnnData(df) b = AnnData(s, obs=pd.DataFrame(index=obs_names), var=pd.DataFrame(index=var_names)) assert_equal(a, b) assert issparse(a.X) def test_create_from_df_with_obs_and_var(): df = pd.DataFrame(np.ones((3, 2)), index=["a", "b", "c"], columns=["A", "B"]) obs = pd.DataFrame(np.ones((3, 1)), index=df.index, columns=["C"]) var = pd.DataFrame(np.ones((2, 1)), index=df.columns, columns=["D"]) ad = AnnData(df, obs=obs, var=var) assert df.values.tolist() == ad.X.tolist() assert df.columns.tolist() == ad.var_names.tolist() assert df.index.tolist() == ad.obs_names.tolist() assert obs.equals(ad.obs) assert var.equals(ad.var) with pytest.raises(ValueError, match=r"Index of obs must match index of X."): AnnData(df, obs=obs.reset_index()) with pytest.raises(ValueError, match=r"Index of var must match columns of X."): AnnData(df, var=var.reset_index()) def test_matching_int_index(): adata = AnnData( pd.DataFrame(dict(a=[0.0, 0.5]), index=[0, 1]), obs=pd.DataFrame(index=[0, 1]) ) pd.testing.assert_index_equal(adata.obs_names, pd.Index(["0", "1"])) def test_from_df_and_dict(): df = pd.DataFrame(dict(a=[0.1, 0.2, 0.3], b=[1.1, 1.2, 1.3])) adata = AnnData(df, dict(species=pd.Categorical(["a", "b", "a"]))) assert adata.obs["species"].values.tolist() == ["a", "b", "a"] def test_df_warnings(): df = pd.DataFrame(dict(A=[1, 2, 3], B=[1.0, 2.0, 3.0]), index=["a", "b", "c"]) with pytest.warns(UserWarning, match=r"X.*dtype float64"): adata = AnnData(df) with pytest.warns(UserWarning, match=r"X.*dtype float64"): adata.X = df @pytest.mark.parametrize("attr", ["X", "layers", "obsm", "varm", "obsp", "varp"]) @pytest.mark.parametrize("when", ["init", "assign"]) def test_convert_matrix(attr, when): """Test that initializing or assigning aligned arrays to a np.matrix converts it.""" with warnings.catch_warnings(): warnings.filterwarnings( "ignore", r"the matrix.*not.*recommended", PendingDeprecationWarning ) mat = np.matrix([[1, 2], [3, 0]]) direct = attr in {"X"} with pytest.warns( # noqa: PT031 expected_warning=ImplicitModificationWarning, match=r"np\.ndarray" ): if when == "init": adata = ( AnnData(**{attr: mat}) if direct else AnnData(shape=(2, 2), **{attr: {"a": mat}}) ) elif when == "assign": adata = AnnData(shape=(2, 2)) if direct: setattr(adata, attr, mat) else: getattr(adata, attr)["a"] = mat else: raise ValueError(when) arr = getattr(adata, attr) if direct else getattr(adata, attr)["a"] assert isinstance(arr, np.ndarray), f"{arr} is not an array" assert not isinstance(arr, np.matrix), f"{arr} is still a matrix" def test_attr_deletion(): full = gen_adata((30, 30)) # Empty has just X, obs_names, var_names empty = AnnData(None, obs=full.obs[[]], var=full.var[[]]) for attr in ["X", "obs", "var", "obsm", "varm", "obsp", "varp", "layers", "uns"]: delattr(full, attr) assert_equal(getattr(full, attr), getattr(empty, attr)) assert_equal(full, empty, exact=True) def test_names(): adata = AnnData( np.array([[1, 2, 3], [4, 5, 6]]), dict(obs_names=["A", "B"]), dict(var_names=["a", "b", "c"]), ) assert adata.obs_names.tolist() == ["A", "B"] assert adata.var_names.tolist() == ["a", "b", "c"] adata = AnnData(np.array([[1, 2], [3, 4], [5, 6]]), var=dict(var_names=["a", "b"])) assert adata.var_names.tolist() == ["a", "b"] @pytest.mark.parametrize( ("names", "after"), [ pytest.param(["a", "b"], None, id="list"), pytest.param( pd.Series(["AAD", "CCA"], name="barcodes"), "barcodes", id="Series-str" ), pytest.param(pd.Series(["x", "y"], name=0), None, id="Series-int"), ], ) @pytest.mark.parametrize("attr", ["obs_names", "var_names"]) def test_setting_index_names(names, after, attr): adata = adata_dense.copy() assert getattr(adata, attr).name is None setattr(adata, attr, names) assert getattr(adata, attr).name == after if hasattr(names, "name"): assert names.name is not None # Testing for views new = adata[:, :] assert new.is_view setattr(new, attr, names) assert_equal(new, adata, exact=True) assert not new.is_view @pytest.mark.parametrize("attr", ["obs_names", "var_names"]) def test_setting_index_names_error(attr): orig = adata_sparse[:2, :2] adata = adata_sparse[:2, :2] assert getattr(adata, attr).name is None with pytest.raises(ValueError, match=rf"AnnData expects \.{attr[:3]}\.index\.name"): setattr(adata, attr, pd.Index(["x", "y"], name=0)) assert adata.is_view assert getattr(adata, attr).tolist() != ["x", "y"] assert getattr(adata, attr).tolist() == getattr(orig, attr).tolist() assert_equal(orig, adata, exact=True) @pytest.mark.parametrize("dim", ["obs", "var"]) @pytest.mark.parametrize( ("obs_xdataset", "var_xdataset"), [(False, False), (True, True)] ) def test_setting_dim_index(dim, obs_xdataset, var_xdataset): index_attr = f"{dim}_names" mapping_attr = f"{dim}m" orig = gen_adata((5, 5), obs_xdataset=obs_xdataset, var_xdataset=var_xdataset) orig.raw = orig.copy() curr = orig.copy() view = orig[:, :] new_idx = pd.Index(list("abcde"), name="letters") setattr(curr, index_attr, new_idx) pd.testing.assert_index_equal(getattr(curr, index_attr), new_idx) pd.testing.assert_index_equal(getattr(curr, mapping_attr)["df"].index, new_idx) pd.testing.assert_index_equal(getattr(curr, mapping_attr).dim_names, new_idx) pd.testing.assert_index_equal(curr.obs_names, curr.raw.obs_names) # Testing view behaviour setattr(view, index_attr, new_idx) assert not view.is_view pd.testing.assert_index_equal(getattr(view, index_attr), new_idx) pd.testing.assert_index_equal(getattr(view, mapping_attr)["df"].index, new_idx) pd.testing.assert_index_equal(getattr(view, mapping_attr).dim_names, new_idx) with pytest.raises(AssertionError): pd.testing.assert_index_equal( getattr(view, index_attr), getattr(orig, index_attr) ) assert_equal(view, curr, exact=True) # test case in #459 fake_m = pd.DataFrame(curr.X.T, index=getattr(curr, index_attr)) getattr(curr, mapping_attr)["df2"] = fake_m def test_indices_dtypes(): adata = AnnData( np.array([[1, 2, 3], [4, 5, 6]]), dict(obs_names=["A", "B"]), dict(var_names=["a", "b", "c"]), ) adata.obs_names = ["ö", "a"] assert adata.obs_names.tolist() == ["ö", "a"] def test_slicing(): adata = AnnData(np.array([[1, 2, 3], [4, 5, 6]])) # assert adata[:, 0].X.tolist() == adata.X[:, 0].tolist() # No longer the case assert adata[0, 0].X.tolist() == np.reshape(1, (1, 1)).tolist() assert adata[0, :].X.tolist() == np.reshape([1, 2, 3], (1, 3)).tolist() assert adata[:, 0].X.tolist() == np.reshape([1, 4], (2, 1)).tolist() assert adata[:, [0, 1]].X.tolist() == [[1, 2], [4, 5]] assert adata[:, np.array([0, 2])].X.tolist() == [[1, 3], [4, 6]] assert adata[:, np.array([False, True, True])].X.tolist() == [ [2, 3], [5, 6], ] assert adata[:, 1:3].X.tolist() == [[2, 3], [5, 6]] assert adata[0:2, :][:, 0:2].X.tolist() == [[1, 2], [4, 5]] assert adata[0:1, :][:, 0:2].X.tolist() == np.reshape([1, 2], (1, 2)).tolist() assert adata[0, :][:, 0].X.tolist() == np.reshape(1, (1, 1)).tolist() assert adata[:, 0:2][0:2, :].X.tolist() == [[1, 2], [4, 5]] assert adata[:, 0:2][0:1, :].X.tolist() == np.reshape([1, 2], (1, 2)).tolist() assert adata[:, 0][0, :].X.tolist() == np.reshape(1, (1, 1)).tolist() def test_boolean_slicing(): adata = AnnData(np.array([[1, 2, 3], [4, 5, 6]])) obs_selector = np.array([True, False], dtype=bool) vars_selector = np.array([True, False, False], dtype=bool) assert adata[obs_selector, :][:, vars_selector].X.tolist() == [[1]] assert adata[:, vars_selector][obs_selector, :].X.tolist() == [[1]] assert adata[obs_selector, :][:, 0].X.tolist() == [[1]] assert adata[:, 0][obs_selector, :].X.tolist() == [[1]] assert adata[0, :][:, vars_selector].X.tolist() == [[1]] assert adata[:, vars_selector][0, :].X.tolist() == [[1]] obs_selector = np.array([True, False], dtype=bool) vars_selector = np.array([True, True, False], dtype=bool) assert adata[obs_selector, :][:, vars_selector].X.tolist() == [[1, 2]] assert adata[:, vars_selector][obs_selector, :].X.tolist() == [[1, 2]] assert adata[obs_selector, :][:, 0:2].X.tolist() == [[1, 2]] assert adata[:, 0:2][obs_selector, :].X.tolist() == [[1, 2]] assert adata[0, :][:, vars_selector].X.tolist() == [[1, 2]] assert adata[:, vars_selector][0, :].X.tolist() == [[1, 2]] obs_selector = np.array([True, True], dtype=bool) vars_selector = np.array([True, True, False], dtype=bool) assert adata[obs_selector, :][:, vars_selector].X.tolist() == [ [1, 2], [4, 5], ] assert adata[:, vars_selector][obs_selector, :].X.tolist() == [ [1, 2], [4, 5], ] assert adata[obs_selector, :][:, 0:2].X.tolist() == [[1, 2], [4, 5]] assert adata[:, 0:2][obs_selector, :].X.tolist() == [[1, 2], [4, 5]] assert adata[0:2, :][:, vars_selector].X.tolist() == [[1, 2], [4, 5]] assert adata[:, vars_selector][0:2, :].X.tolist() == [[1, 2], [4, 5]] def test_oob_boolean_slicing(): len1, len2 = np.random.choice(100, 2, replace=False) with pytest.raises(IndexError) as e: AnnData(np.empty((len1, 100)))[np.random.randint(0, 2, len2, dtype=bool), :] assert str(len1) in str(e.value) assert str(len2) in str(e.value) len1, len2 = np.random.choice(100, 2, replace=False) with pytest.raises(IndexError) as e: AnnData(np.empty((100, len1)))[:, np.random.randint(0, 2, len2, dtype=bool)] assert str(len1) in str(e.value) assert str(len2) in str(e.value) def test_slicing_strings(): adata = AnnData( np.array([[1, 2, 3], [4, 5, 6]]), dict(obs_names=["A", "B"]), dict(var_names=["a", "b", "c"]), ) assert adata["A", "a"].X.tolist() == [[1]] assert adata["A", :].X.tolist() == [[1, 2, 3]] assert adata[:, "a"].X.tolist() == [[1], [4]] assert adata[:, ["a", "b"]].X.tolist() == [[1, 2], [4, 5]] assert adata[:, np.array(["a", "c"])].X.tolist() == [[1, 3], [4, 6]] assert adata[:, "b":"c"].X.tolist() == [[2, 3], [5, 6]] with pytest.raises(KeyError): _ = adata[:, "X"] with pytest.raises(KeyError): _ = adata["X", :] with pytest.raises(KeyError): _ = adata["A":"X", :] with pytest.raises(KeyError): _ = adata[:, "a":"X"] # Test if errors are helpful with pytest.raises(KeyError, match=r"not_in_var"): adata[:, ["A", "B", "not_in_var"]] with pytest.raises(KeyError, match=r"not_in_obs"): adata[["A", "B", "not_in_obs"], :] def test_slicing_series(): adata = AnnData( np.array([[1, 2], [3, 4], [5, 6]]), dict(obs_names=["A", "B", "C"]), dict(var_names=["a", "b"]), ) df = pd.DataFrame(dict(a=["1", "2", "2"])) df1 = pd.DataFrame(dict(b=["1", "2"])) assert adata[df["a"].values == "2"].X.tolist() == adata[df["a"] == "2"].X.tolist() assert ( adata[:, df1["b"].values == "2"].X.tolist() == adata[:, df1["b"] == "2"].X.tolist() ) def test_strings_to_categoricals(): adata = AnnData( np.array([[1, 2], [3, 4], [5, 6], [7, 8]]), dict(k=["a", "a", "b", "b"]) ) adata.strings_to_categoricals() assert adata.obs["k"].cat.categories.tolist() == ["a", "b"] def test_slicing_remove_unused_categories(): adata = AnnData( np.array([[1, 2], [3, 4], [5, 6], [7, 8]]), dict(k=["a", "a", "b", "b"]) ) adata._sanitize() assert adata[2:4].obs["k"].cat.categories.tolist() == ["b"] def test_slicing_dont_remove_unused_categories(): with settings.override(remove_unused_categories=False): adata = AnnData( np.array([[1, 2], [3, 4], [5, 6], [7, 8]]), dict(k=["a", "a", "b", "b"]) ) adata._sanitize() assert adata[2:4].obs["k"].cat.categories.tolist() == ["a", "b"] def test_no_uniqueness_check_gives_repeat_indices(): with settings.override(check_uniqueness=False): obs_names = ["0", "0", "1", "1"] with warnings.catch_warnings(): warnings.simplefilter("error") adata = AnnData( np.array([[1, 2], [3, 4], [5, 6], [7, 8]]), obs=pd.DataFrame(index=obs_names), ) assert adata.obs_names.values.tolist() == obs_names def test_get_subset_annotation(): adata = AnnData( np.array([[1, 2, 3], [4, 5, 6]]), dict(S=["A", "B"]), dict(F=["a", "b", "c"]), ) assert adata[0, 0].obs["S"].tolist() == ["A"] assert adata[0, 0].var["F"].tolist() == ["a"] def test_append_col(): adata = AnnData(np.array([[1, 2, 3], [4, 5, 6]])) adata.obs["new"] = [1, 2] # this worked in the initial AnnData, but not with a dataframe # adata.obs[['new2', 'new3']] = [['A', 'B'], ['c', 'd']] with pytest.raises( ValueError, match=r"Length of values.*does not match length of index" ): adata.obs["new4"] = ["far", "too", "long"] def test_delete_col(): adata = AnnData(np.array([[1, 2, 3], [4, 5, 6]]), dict(o1=[1, 2], o2=[3, 4])) assert adata.obs.columns.tolist() == ["o1", "o2"] del adata.obs["o1"] assert adata.obs.columns.tolist() == ["o2"] assert adata.obs["o2"].tolist() == [3, 4] def test_set_obs(): adata = AnnData(np.array([[1, 2, 3], [4, 5, 6]])) adata.obs = pd.DataFrame(dict(a=[3, 4])) assert adata.obs_names.tolist() == ["0", "1"] with pytest.raises(ValueError, match="`shape` is inconsistent with `obs`"): adata.obs = pd.DataFrame(dict(a=[3, 4, 5])) def test_multicol(): adata = AnnData(np.array([[1, 2, 3], [4, 5, 6]])) # 'c' keeps the columns as should be adata.obsm["c"] = np.array([[0.0, 1.0], [2, 3]]) assert adata.obsm.keys() == {"c"} assert adata.obsm["c"].tolist() == [[0.0, 1.0], [2, 3]] def test_n_obs(): adata = AnnData(np.array([[1, 2], [3, 4], [5, 6]])) assert adata.n_obs == 3 adata1 = adata[:2] assert adata1.n_obs == 2 def test_equality_comparisons(): adata1 = AnnData(np.array([[1, 2], [3, 4], [5, 6]])) adata2 = AnnData(np.array([[1, 2], [3, 4], [5, 6]])) with pytest.raises(NotImplementedError): adata1 == adata1 # noqa: B015, PLR0124 with pytest.raises(NotImplementedError): adata1 == adata2 # noqa: B015 with pytest.raises(NotImplementedError): adata1 != adata2 # noqa: B015 with pytest.raises(NotImplementedError): adata1 == 1 # noqa: B015 with pytest.raises(NotImplementedError): adata1 != 1 # noqa: B015 def test_rename_categories(): X = np.ones((6, 3)) obs = pd.DataFrame(dict(cat_anno=pd.Categorical(["a", "a", "a", "a", "b", "a"]))) adata = AnnData(X=X, obs=obs) adata.uns["tool"] = {} adata.uns["tool"]["cat_array"] = np.rec.fromarrays( [np.ones(2) for cat in adata.obs["cat_anno"].cat.categories], dtype=[(cat, "float32") for cat in adata.obs["cat_anno"].cat.categories], ) adata.uns["tool"]["params"] = dict(groupby="cat_anno") new_categories = ["c", "d"] with warnings.catch_warnings(): warnings.simplefilter("error") adata.rename_categories("cat_anno", new_categories) assert list(adata.obs["cat_anno"].cat.categories) == new_categories assert list(adata.uns["tool"]["cat_array"].dtype.names) == new_categories def test_pickle(): import pickle adata = AnnData() adata2 = pickle.loads(pickle.dumps(adata)) assert adata2.obsm.parent is adata2 def test_to_df_dense(): X_df = adata_dense.to_df() layer_df = adata_dense.to_df(layer="test") np.testing.assert_array_equal(adata_dense.layers["test"], layer_df.values) np.testing.assert_array_equal(adata_dense.X, X_df.values) pd.testing.assert_index_equal(X_df.columns, layer_df.columns) pd.testing.assert_index_equal(X_df.index, layer_df.index) def test_convenience(): adata = adata_sparse.copy() adata.layers["x2"] = adata.X * 2 adata.var["anno2"] = ["p1", "p2", "p3"] adata.raw = adata.copy() adata.X = adata.X / 2 adata_dense = adata.copy() adata_dense.X = adata_dense.X.toarray() def assert_same_op_result(a1, a2, op): r1 = op(a1) r2 = op(a2) assert np.all(r1 == r2) assert type(r1) is type(r2) assert np.allclose(adata.obs_vector("b"), np.array([1.0, 2.5])) assert np.allclose(adata.raw.obs_vector("c"), np.array([3, 6])) assert np.all(adata.obs_vector("anno1") == np.array(["c1", "c2"])) assert np.allclose(adata.var_vector("s1"), np.array([0, 1.0, 1.5])) assert np.allclose(adata.raw.var_vector("s2"), np.array([0, 5, 6])) for obs_k, layer in product(["a", "b", "c", "anno1"], [None, "x2"]): assert_same_op_result( adata, adata_dense, partial(AnnData.obs_vector, k=obs_k, layer=layer) ) for obs_k in ["a", "b", "c"]: assert_same_op_result( adata.raw, adata_dense.raw, partial(Raw.obs_vector, k=obs_k) ) for var_k, layer in product(["s1", "s2", "anno2"], [None, "x2"]): assert_same_op_result( adata, adata_dense, partial(AnnData.var_vector, k=var_k, layer=layer) ) for var_k in ["s1", "s2", "anno2"]: assert_same_op_result( adata.raw, adata_dense.raw, partial(Raw.var_vector, k=var_k) ) def test_1d_slice_dtypes(): N, M = 10, 20 obs_df = pd.DataFrame( dict( cat=pd.Categorical(np.arange(N, dtype=int)), int=np.arange(N, dtype=int), float=np.arange(N, dtype=float), obj=[str(i) for i in np.arange(N, dtype=int)], ), index=[f"cell{i}" for i in np.arange(N, dtype=int)], ) var_df = pd.DataFrame( dict( cat=pd.Categorical(np.arange(M, dtype=int)), int=np.arange(M, dtype=int), float=np.arange(M, dtype=float), obj=[str(i) for i in np.arange(M, dtype=int)], ), index=[f"gene{i}" for i in np.arange(M, dtype=int)], ) adata = AnnData(X=np.random.random((N, M)), obs=obs_df, var=var_df) new_obs_df = pd.DataFrame(index=adata.obs_names) for k in obs_df.columns: new_obs_df[k] = adata.obs_vector(k) assert new_obs_df[k].dtype == obs_df[k].dtype assert np.all(new_obs_df == obs_df) new_var_df = pd.DataFrame(index=adata.var_names) for k in var_df.columns: new_var_df[k] = adata.var_vector(k) assert new_var_df[k].dtype == var_df[k].dtype assert np.all(new_var_df == var_df) def test_to_df_sparse(): X = adata_sparse.X.toarray() df = adata_sparse.to_df() assert df.values.tolist() == X.tolist() def test_to_df_no_X(): adata = AnnData( obs=pd.DataFrame(index=[f"cell-{i:02}" for i in range(20)]), var=pd.DataFrame(index=[f"gene-{i:02}" for i in range(30)]), layers={"present": np.ones((20, 30))}, ) v = adata[:10] with pytest.raises(ValueError, match=r"X is None"): _ = adata.to_df() with pytest.raises(ValueError, match=r"X is None"): _ = v.to_df() expected = pd.DataFrame( np.ones(adata.shape), index=adata.obs_names, columns=adata.var_names ) actual = adata.to_df(layer="present") pd.testing.assert_frame_equal(actual, expected) view_expected = pd.DataFrame( np.ones(v.shape), index=v.obs_names, columns=v.var_names ) view_actual = v.to_df(layer="present") pd.testing.assert_frame_equal(view_actual, view_expected) def test_copy(): adata_copy = adata_sparse.copy() def assert_eq_not_id(a, b): assert a is not b assert issparse(a) == issparse(b) if issparse(a): assert np.all(a.data == b.data) assert np.all(a.indices == b.indices) assert np.all(a.indptr == b.indptr) else: assert np.all(a == b) assert adata_sparse is not adata_copy assert_eq_not_id(adata_sparse.X, adata_copy.X) for attr in ["layers", "var", "obs", "obsm", "varm"]: map_sprs = getattr(adata_sparse, attr) map_copy = getattr(adata_copy, attr) assert map_sprs is not map_copy if attr not in {"obs", "var"}: # check that we don’t create too many references assert getattr(adata_copy, f"_{attr}") is map_copy._data assert_eq_not_id(map_sprs.keys(), map_copy.keys()) for key in map_sprs: assert_eq_not_id(map_sprs[key], map_copy[key]) def test_to_memory_no_copy(): adata = gen_adata((3, 5), **GEN_ADATA_NO_XARRAY_ARGS) mem = adata.to_memory() assert mem.X is adata.X # Currently does not hold for `obs`, `var`, but should in future for key in adata.layers: assert mem.layers[key] is adata.layers[key] for key in adata.obsm: assert mem.obsm[key] is adata.obsm[key] for key in adata.varm: assert mem.varm[key] is adata.varm[key] for key in adata.obsp: assert mem.obsp[key] is adata.obsp[key] for key in adata.varp: assert mem.varp[key] is adata.varp[key] @pytest.mark.parametrize("axis", ["obs", "var"]) @pytest.mark.parametrize("elem_type", ["p", "m"]) def test_create_adata_from_single_axis_elem( axis: Literal["obs", "var"], elem_type: Literal["m", "p"], tmp_path: Path ): d = dict( a=np.zeros((10, 10)), ) in_memory = AnnData(**{f"{axis}{elem_type}": d}) assert in_memory.shape == (10, 0) if axis == "obs" else (0, 10) in_memory.write_h5ad(tmp_path / "adata.h5ad") from_disk = ad.read_h5ad(tmp_path / "adata.h5ad") assert_equal(from_disk, in_memory) scverse-anndata-b796d59/tests/test_concatenate.py000066400000000000000000001630111512025555600222120ustar00rootroot00000000000000from __future__ import annotations import warnings from collections.abc import Hashable from copy import deepcopy from functools import partial, singledispatch from importlib.metadata import version from importlib.util import find_spec from itertools import chain, permutations, product from operator import attrgetter from typing import TYPE_CHECKING import numpy as np import pandas as pd import pytest from boltons.iterutils import default_exit, remap, research from numpy import ma from packaging.version import Version from scipy import sparse from anndata import AnnData, Raw, concat from anndata._core import merge from anndata._core.index import _subset from anndata._core.xarray import Dataset2D from anndata.compat import AwkArray, CSArray, CSMatrix, CupySparseMatrix, DaskArray from anndata.tests import helpers from anndata.tests.helpers import ( BASE_MATRIX_PARAMS, CUPY_MATRIX_PARAMS, DASK_MATRIX_PARAMS, DEFAULT_COL_TYPES, GEN_ADATA_DASK_ARGS, as_dense_dask_array, assert_equal, gen_adata, gen_vstr_recarray, ) from anndata.utils import asarray if TYPE_CHECKING: from collections.abc import Callable from typing import Any, Literal mark_legacy_concatenate = pytest.mark.filterwarnings( r"ignore:.*AnnData\.concatenate is deprecated:FutureWarning" ) @singledispatch def filled_like(a, fill_value=None): raise NotImplementedError() @filled_like.register(np.ndarray) def _filled_array_np(a, fill_value=None): if fill_value is None: fill_value = np.nan return np.broadcast_to(fill_value, a.shape) @filled_like.register(DaskArray) def _filled_array(a, fill_value=None): return as_dense_dask_array(_filled_array_np(a, fill_value)) @filled_like.register(CSMatrix) def _filled_sparse(a, fill_value=None): if fill_value is None: return sparse.csr_matrix(a.shape) else: return sparse.csr_matrix(np.broadcast_to(fill_value, a.shape)) @filled_like.register(CSArray) def _filled_sparse_array(a, fill_value=None): return sparse.csr_array(filled_like(sparse.csr_matrix(a))) @filled_like.register(pd.DataFrame) def _filled_df(a, fill_value=np.nan): # dtype from pd.concat can be unintuitive, this returns something close enough return a.loc[[], :].reindex(index=a.index, fill_value=fill_value) def check_filled_like(x, fill_value=None, elem_name=None): if fill_value is None: assert_equal(x, filled_like(x), elem_name=elem_name) else: assert_equal(x, filled_like(x, fill_value=fill_value), elem_name=elem_name) def make_idx_tuple(idx, axis): tup = [slice(None), slice(None)] tup[axis] = idx return tuple(tup) # Will call func(sparse_matrix) so these types should be sparse compatible # See array_type if only dense arrays are expected as input. @pytest.fixture(params=BASE_MATRIX_PARAMS + DASK_MATRIX_PARAMS + CUPY_MATRIX_PARAMS) def array_type(request): return request.param @pytest.fixture(params=BASE_MATRIX_PARAMS + DASK_MATRIX_PARAMS) def cpu_array_type(request): return request.param @pytest.fixture(params=["inner", "outer"]) def join_type(request): return request.param @pytest.fixture(params=[0, np.nan, np.pi]) def fill_val(request): return request.param @pytest.fixture(params=["obs", "var"]) def axis_name(request) -> Literal["obs", "var"]: return request.param @pytest.fixture(params=list(merge.MERGE_STRATEGIES.keys())) def merge_strategy(request): return request.param @pytest.fixture( params=[ pytest.param(False, id="pandas"), pytest.param( True, marks=pytest.mark.skipif( not find_spec("xarray"), reason="xarray not installed." ), id="xarray", ), ], ) def use_xdataset(request): return request.param @pytest.fixture( params=[ pytest.param(False, id="concat-in-memory"), pytest.param(True, id="concat-lazy"), ], ) def force_lazy(request): return request.param def fix_known_differences( orig: AnnData, result: AnnData, *, backwards_compat: bool = True ): """ Helper function for reducing anndata's to only the elements we expect to be equivalent after concatenation. Only for the case where orig is the ground truth result of what concatenation should be. If backwards_compat, checks against what `AnnData.concatenate` could do. Otherwise checks for `concat`. """ orig = orig.copy() result = result.copy() if backwards_compat: del orig.varm del orig.varp if isinstance(result.obs, Dataset2D): result.obs = result.obs.ds.drop_vars(["batch"]) else: result.obs.drop(columns=["batch"], inplace=True) for attrname in ("obs", "var"): if isinstance(getattr(result, attrname), Dataset2D): for adata in (orig, result): df = getattr(adata, attrname).ds.to_dataframe()[ getattr(orig, attrname).columns ] df.index.name = "index" setattr(adata, attrname, df) resattr = getattr(result, attrname) origattr = getattr(orig, attrname) for colname, col in resattr.items(): # concatenation of XDatasets happens via Dask arrays and those don't know about Pandas Extension arrays # so categoricals and nullable arrays are all converted to other dtypes if col.dtype != origattr[ colname ].dtype and pd.api.types.is_extension_array_dtype( origattr[colname].dtype ): resattr[colname] = col.astype(origattr[colname].dtype) result.strings_to_categoricals() # Should this be implicit in concatenation? # TODO # * merge varm, varp similar to uns # * merge obsp, but some information should be lost del orig.obsp # TODO # Possibly need to fix this, ordered categoricals lose orderedness for get_df in [lambda k: k.obs, lambda k: k.obsm["df"]]: str_to_df_converted = get_df(result) for k, dtype in get_df(orig).dtypes.items(): if isinstance(dtype, pd.CategoricalDtype) and dtype.ordered: str_to_df_converted[k] = str_to_df_converted[k].astype(dtype) return orig, result def test_concat_interface_errors(use_xdataset): adatas = [ gen_adata((5, 10), obs_xdataset=use_xdataset, var_xdataset=use_xdataset), gen_adata((5, 10), obs_xdataset=use_xdataset, var_xdataset=use_xdataset), ] with pytest.raises(ValueError, match=r"`axis` must be.*0, 1, 'obs', or 'var'"): concat(adatas, axis=3) with pytest.raises(ValueError, match="'inner' or 'outer'"): concat(adatas, join="not implemented") with pytest.raises(ValueError, match="No objects to concatenate"): concat([]) @mark_legacy_concatenate @pytest.mark.parametrize( ("concat_func", "backwards_compat"), [ (partial(concat, merge="unique"), False), (lambda x, **kwargs: x[0].concatenate(x[1:], **kwargs), True), ], ) def test_concatenate_roundtrip( join_type, array_type, concat_func, backwards_compat, use_xdataset, force_lazy, ): if backwards_compat and force_lazy: pytest.skip("unsupported") adata = gen_adata( (100, 10), X_type=array_type, obs_xdataset=use_xdataset, var_xdataset=use_xdataset, **GEN_ADATA_DASK_ARGS, ) remaining = adata.obs_names subsets = [] while len(remaining) > 0: n = min(len(remaining), np.random.choice(50)) subset_idx = np.random.choice(remaining, n, replace=False) subsets.append(adata[subset_idx]) remaining = remaining.difference(subset_idx) result = concat_func(subsets, join=join_type, uns_merge="same", index_unique=None) if backwards_compat and use_xdataset: import xarray as xr result.var = xr.Dataset.from_dataframe( result.var ) # backwards compat always returns a dataframe # Correcting for known differences orig, result = fix_known_differences( adata, result, backwards_compat=backwards_compat ) assert_equal(result[orig.obs_names].copy(), orig) base_type = type(orig.X) if sparse.issparse(orig.X): base_type = CSArray if isinstance(orig.X, CSArray) else CSMatrix if isinstance(orig.X, CupySparseMatrix): base_type = CupySparseMatrix assert isinstance(result.X, base_type) @mark_legacy_concatenate def test_concatenate_dense(): # dense data X1 = np.array([[1, 2, 3], [4, 5, 6]]) X2 = np.array([[1, 2, 3], [4, 5, 6]]) X3 = np.array([[1, 2, 3], [4, 5, 6]]) adata1 = AnnData( X1, dict(obs_names=["s1", "s2"], anno1=["c1", "c2"]), dict(var_names=["a", "b", "c"], annoA=[0, 1, 2]), obsm=dict(X_1=X1, X_2=X2, X_3=X3), layers=dict(Xs=X1), ) adata2 = AnnData( X2, dict(obs_names=["s3", "s4"], anno1=["c3", "c4"]), dict(var_names=["d", "c", "b"], annoA=[0, 1, 2]), obsm=dict(X_1=X1, X_2=X2, X_3=X3), layers={"Xs": X2}, ) adata3 = AnnData( X3, dict(obs_names=["s1", "s2"], anno2=["d3", "d4"]), dict(var_names=["d", "c", "b"], annoB=[0, 1, 2]), obsm=dict(X_1=X1, X_2=X2), layers=dict(Xs=X3), ) # inner join adata = adata1.concatenate(adata2, adata3) X_combined = [[2, 3], [5, 6], [3, 2], [6, 5], [3, 2], [6, 5]] assert adata.X.astype(int).tolist() == X_combined assert adata.layers["Xs"].astype(int).tolist() == X_combined assert adata.obs.columns.tolist() == ["anno1", "anno2", "batch"] assert adata.var.columns.tolist() == ["annoA-0", "annoA-1", "annoB-2"] assert adata.var.values.tolist() == [[1, 2, 2], [2, 1, 1]] assert adata.obsm.keys() == {"X_1", "X_2"} assert adata.obsm["X_1"].tolist() == np.concatenate([X1, X1, X1]).tolist() # with batch_key and batch_categories adata = adata1.concatenate(adata2, adata3, batch_key="batch1") assert adata.obs.columns.tolist() == ["anno1", "anno2", "batch1"] adata = adata1.concatenate(adata2, adata3, batch_categories=["a1", "a2", "a3"]) assert adata.obs["batch"].cat.categories.tolist() == ["a1", "a2", "a3"] assert adata.var_names.tolist() == ["b", "c"] # outer join adata = adata1.concatenate(adata2, adata3, join="outer") X_ref = np.array([ [1.0, 2.0, 3.0, np.nan], [4.0, 5.0, 6.0, np.nan], [np.nan, 3.0, 2.0, 1.0], [np.nan, 6.0, 5.0, 4.0], [np.nan, 3.0, 2.0, 1.0], [np.nan, 6.0, 5.0, 4.0], ]) np.testing.assert_equal(adata.X, X_ref) var_ma = ma.masked_invalid(adata.var.values.tolist()) var_ma_ref = ma.masked_invalid( np.array([ [0.0, np.nan, np.nan], [1.0, 2.0, 2.0], [2.0, 1.0, 1.0], [np.nan, 0.0, 0.0], ]) ) assert np.array_equal(var_ma.mask, var_ma_ref.mask) assert np.allclose(var_ma.compressed(), var_ma_ref.compressed()) @mark_legacy_concatenate def test_concatenate_layers(array_type, join_type): adatas = [] for _ in range(5): a = array_type(sparse.random(100, 200, format="csr")) adatas.append(AnnData(X=a, layers={"a": a})) merged = adatas[0].concatenate(adatas[1:], join=join_type) assert_equal(merged.X, merged.layers["a"]) @pytest.fixture def obsm_adatas(): def gen_index(n): return [f"cell{i}" for i in range(n)] return [ AnnData( X=sparse.csr_matrix((3, 5)), obs=pd.DataFrame(index=gen_index(3)), obsm={ "dense": np.arange(6).reshape(3, 2), "sparse": sparse.csr_matrix(np.arange(6).reshape(3, 2)), "df": pd.DataFrame( { "a": np.arange(3), "b": list("abc"), "c": pd.Categorical(list("aab")), }, index=gen_index(3), ), }, ), AnnData( X=sparse.csr_matrix((4, 10)), obs=pd.DataFrame(index=gen_index(4)), obsm=dict( dense=np.arange(12).reshape(4, 3), df=pd.DataFrame(dict(a=np.arange(3, 7)), index=gen_index(4)), ), ), AnnData( X=sparse.csr_matrix((2, 100)), obs=pd.DataFrame(index=gen_index(2)), obsm={ "sparse": np.arange(8).reshape(2, 4), "dense": np.arange(4, 8).reshape(2, 2), "df": pd.DataFrame( { "a": np.arange(7, 9), "b": list("cd"), "c": pd.Categorical(list("ab")), }, index=gen_index(2), ), }, ), ] @mark_legacy_concatenate def test_concatenate_obsm_inner(obsm_adatas): adata = obsm_adatas[0].concatenate(obsm_adatas[1:], join="inner") assert set(adata.obsm.keys()) == {"dense", "df"} assert adata.obsm["dense"].shape == (9, 2) assert adata.obsm["dense"].tolist() == [ [0, 1], [2, 3], [4, 5], [0, 1], [3, 4], [6, 7], [9, 10], [4, 5], [6, 7], ] assert adata.obsm["df"].columns == ["a"] assert adata.obsm["df"]["a"].tolist() == list(range(9)) # fmt: off true_df = ( pd.concat([a.obsm["df"] for a in obsm_adatas], join="inner") .reset_index(drop=True) ) # fmt: on cur_df = adata.obsm["df"].reset_index(drop=True) pd.testing.assert_frame_equal(true_df, cur_df) @mark_legacy_concatenate def test_concatenate_obsm_outer(obsm_adatas, fill_val): outer = obsm_adatas[0].concatenate( obsm_adatas[1:], join="outer", fill_value=fill_val ) inner = obsm_adatas[0].concatenate(obsm_adatas[1:], join="inner") for k, inner_v in inner.obsm.items(): assert np.array_equal( _subset(outer.obsm[k], (slice(None), slice(None, inner_v.shape[1]))), inner_v, ) assert set(outer.obsm.keys()) == {"dense", "df", "sparse"} assert isinstance(outer.obsm["dense"], np.ndarray) np.testing.assert_equal( outer.obsm["dense"], np.array([ [0, 1, fill_val], [2, 3, fill_val], [4, 5, fill_val], [0, 1, 2], [3, 4, 5], [6, 7, 8], [9, 10, 11], [4, 5, fill_val], [6, 7, fill_val], ]), ) assert isinstance(outer.obsm["sparse"], CSMatrix) np.testing.assert_equal( outer.obsm["sparse"].toarray(), np.array([ [0, 1, fill_val, fill_val], [2, 3, fill_val, fill_val], [4, 5, fill_val, fill_val], [fill_val, fill_val, fill_val, fill_val], [fill_val, fill_val, fill_val, fill_val], [fill_val, fill_val, fill_val, fill_val], [fill_val, fill_val, fill_val, fill_val], [0, 1, 2, 3], [4, 5, 6, 7], ]), ) # fmt: off true_df = ( pd.concat([a.obsm["df"] for a in obsm_adatas], join="outer") .reset_index(drop=True) ) # fmt: on cur_df = outer.obsm["df"].reset_index(drop=True) pd.testing.assert_frame_equal(true_df, cur_df) @pytest.mark.parametrize( ("axis", "axis_name"), [("obs", 0), ("var", 1)], ) def test_concat_axis_param(axis, axis_name): a, b = gen_adata((10, 10)), gen_adata((10, 10)) assert_equal(concat([a, b], axis=axis), concat([a, b], axis=axis_name)) def test_concat_annot_join(obsm_adatas, join_type): adatas = [ AnnData(sparse.csr_matrix(a.shape), obs=a.obsm["df"], var=a.var) for a in obsm_adatas ] pd.testing.assert_frame_equal( concat(adatas, join=join_type).obs, pd.concat([a.obs for a in adatas], join=join_type), ) @mark_legacy_concatenate def test_concatenate_layers_misaligned(array_type, join_type): adatas = [] for _ in range(5): a = array_type(sparse.random(100, 200, format="csr")) adata = AnnData(X=a, layers={"a": a}) adatas.append( adata[:, np.random.choice(adata.var_names, 150, replace=False)].copy() ) merged = adatas[0].concatenate(adatas[1:], join=join_type) assert_equal(merged.X, merged.layers["a"]) @mark_legacy_concatenate def test_concatenate_layers_outer(array_type, fill_val): # Testing that issue #368 is fixed a = AnnData( X=np.ones((10, 20)), layers={"a": array_type(sparse.random(10, 20, format="csr"))}, ) b = AnnData(X=np.ones((10, 20))) c = a.concatenate(b, join="outer", fill_value=fill_val, batch_categories=["a", "b"]) np.testing.assert_array_equal( asarray(c[c.obs["batch"] == "b"].layers["a"]), fill_val ) @mark_legacy_concatenate def test_concatenate_fill_value(fill_val): def get_obs_els(adata): return { "X": adata.X, **{f"layer_{k}": adata.layers[k] for k in adata.layers}, **{f"obsm_{k}": adata.obsm[k] for k in adata.obsm}, } adata1 = gen_adata((10, 10)) adata1.obsm = { k: v for k, v in adata1.obsm.items() if not isinstance(v, pd.DataFrame | AwkArray | Dataset2D) } adata2 = gen_adata((10, 5)) adata2.obsm = { k: v[:, : v.shape[1] // 2] for k, v in adata2.obsm.items() if not isinstance(v, pd.DataFrame | AwkArray | Dataset2D) } adata3 = gen_adata((7, 3)) adata3.obsm = { k: v[:, : v.shape[1] // 3] for k, v in adata3.obsm.items() if not isinstance(v, pd.DataFrame | AwkArray | Dataset2D) } # remove AwkArrays from adata.var, as outer joins are not yet implemented for them for tmp_ad in [adata1, adata2, adata3]: for k in [k for k, v in tmp_ad.varm.items() if isinstance(v, AwkArray)]: del tmp_ad.varm[k] joined = adata1.concatenate([adata2, adata3], join="outer", fill_value=fill_val) ptr = 0 for orig in [adata1, adata2, adata3]: cur = joined[ptr : ptr + orig.n_obs] cur_els = get_obs_els(cur) orig_els = get_obs_els(orig) for k, cur_v in cur_els.items(): orig_v = orig_els.get(k, sparse.csr_matrix((orig.n_obs, 0))) assert_equal(cur_v[:, : orig_v.shape[1]], orig_v) np.testing.assert_equal(asarray(cur_v[:, orig_v.shape[1] :]), fill_val) ptr += orig.n_obs @mark_legacy_concatenate def test_concatenate_dense_duplicates(): X1 = np.array([[1, 2, 3], [4, 5, 6]]) X2 = np.array([[1, 2, 3], [4, 5, 6]]) X3 = np.array([[1, 2, 3], [4, 5, 6]]) # inner join duplicates adata1 = AnnData( X1, dict(obs_names=["s1", "s2"], anno1=["c1", "c2"]), dict( var_names=["a", "b", "c"], annoA=[0, 1, 2], annoB=[1.1, 1.0, 2.0], annoC=[1.1, 1.0, 2.0], annoD=[2.1, 2.0, 3.0], ), ) adata2 = AnnData( X2, dict(obs_names=["s3", "s4"], anno1=["c3", "c4"]), dict( var_names=["a", "b", "c"], annoA=[0, 1, 2], annoB=[1.1, 1.0, 2.0], annoC=[1.1, 1.0, 2.0], annoD=[2.1, 2.0, 3.0], ), ) adata3 = AnnData( X3, dict(obs_names=["s1", "s2"], anno2=["d3", "d4"]), dict( var_names=["a", "b", "c"], annoA=[0, 1, 2], annoB=[1.1, 1.0, 2.0], annoD=[2.1, 2.0, 3.1], ), ) adata = adata1.concatenate(adata2, adata3) assert adata.var.columns.tolist() == [ "annoA", "annoB", "annoC-0", "annoD-0", "annoC-1", "annoD-1", "annoD-2", ] @mark_legacy_concatenate def test_concatenate_sparse(): # sparse data from scipy.sparse import csr_matrix X1 = csr_matrix([[0, 2, 3], [0, 5, 6]]) X2 = csr_matrix([[0, 2, 3], [0, 5, 6]]) X3 = csr_matrix([[1, 2, 0], [0, 5, 6]]) adata1 = AnnData( X1, dict(obs_names=["s1", "s2"], anno1=["c1", "c2"]), dict(var_names=["a", "b", "c"]), layers=dict(Xs=X1), ) adata2 = AnnData( X2, dict(obs_names=["s3", "s4"], anno1=["c3", "c4"]), dict(var_names=["d", "c", "b"]), layers=dict(Xs=X2), ) adata3 = AnnData( X3, dict(obs_names=["s5", "s6"], anno2=["d3", "d4"]), dict(var_names=["d", "c", "b"]), layers=dict(Xs=X3), ) # inner join adata = adata1.concatenate(adata2, adata3) X_combined = [[2, 3], [5, 6], [3, 2], [6, 5], [0, 2], [6, 5]] assert adata.X.toarray().astype(int).tolist() == X_combined assert adata.layers["Xs"].toarray().astype(int).tolist() == X_combined # outer join adata = adata1.concatenate(adata2, adata3, join="outer") assert adata.X.toarray().tolist() == [ [0.0, 2.0, 3.0, 0.0], [0.0, 5.0, 6.0, 0.0], [0.0, 3.0, 2.0, 0.0], [0.0, 6.0, 5.0, 0.0], [0.0, 0.0, 2.0, 1.0], [0.0, 6.0, 5.0, 0.0], ] @mark_legacy_concatenate def test_concatenate_mixed(): X1 = sparse.csr_matrix(np.array([[1, 2, 0], [4, 0, 6], [0, 0, 9]])) X2 = sparse.csr_matrix(np.array([[0, 2, 3], [4, 0, 0], [7, 0, 9]])) X3 = sparse.csr_matrix(np.array([[1, 0, 3], [0, 0, 6], [0, 8, 0]])) X4 = np.array([[0, 2, 3], [4, 0, 0], [7, 0, 9]]) adata1 = AnnData( X1, dict(obs_names=["s1", "s2", "s3"], anno1=["c1", "c2", "c3"]), dict(var_names=["a", "b", "c"], annoA=[0, 1, 2]), layers=dict(counts=X1), ) adata2 = AnnData( X2, dict(obs_names=["s4", "s5", "s6"], anno1=["c3", "c4", "c5"]), dict(var_names=["d", "c", "b"], annoA=[0, 1, 2]), layers=dict(counts=X4), # sic ) adata3 = AnnData( X3, dict(obs_names=["s7", "s8", "s9"], anno2=["d3", "d4", "d5"]), dict(var_names=["d", "c", "b"], annoA=[0, 2, 3], annoB=[0, 1, 2]), layers=dict(counts=X3), ) adata4 = AnnData( X4, dict(obs_names=["s4", "s5", "s6"], anno1=["c3", "c4", "c5"]), dict(var_names=["d", "c", "b"], annoA=[0, 1, 2]), layers=dict(counts=X2), # sic ) adata_all = AnnData.concatenate(adata1, adata2, adata3, adata4) assert isinstance(adata_all.X, sparse.csr_matrix) assert isinstance(adata_all.layers["counts"], sparse.csr_matrix) @mark_legacy_concatenate def test_concatenate_with_raw(): # dense data X1 = np.array([[1, 2, 3], [4, 5, 6]]) X2 = np.array([[1, 2, 3], [4, 5, 6]]) X3 = np.array([[1, 2, 3], [4, 5, 6]]) X4 = np.array([[1, 2, 3, 4], [5, 6, 7, 8]]) adata1 = AnnData( X1, dict(obs_names=["s1", "s2"], anno1=["c1", "c2"]), dict(var_names=["a", "b", "c"], annoA=[0, 1, 2]), layers=dict(Xs=X1), ) adata2 = AnnData( X2, dict(obs_names=["s3", "s4"], anno1=["c3", "c4"]), dict(var_names=["d", "c", "b"], annoA=[0, 1, 2]), layers=dict(Xs=X2), ) adata3 = AnnData( X3, dict(obs_names=["s1", "s2"], anno2=["d3", "d4"]), dict(var_names=["d", "c", "b"], annoB=[0, 1, 2]), layers=dict(Xs=X3), ) adata4 = AnnData( X4, dict(obs_names=["s1", "s2"], anno1=["c1", "c2"]), dict(var_names=["a", "b", "c", "z"], annoA=[0, 1, 2, 3]), layers=dict(Xs=X4), ) adata1.raw = adata1.copy() adata2.raw = adata2.copy() adata3.raw = adata3.copy() adata_all = AnnData.concatenate(adata1, adata2, adata3) assert isinstance(adata_all.raw, Raw) assert set(adata_all.raw.var_names) == {"b", "c"} assert_equal(adata_all.raw.to_adata().obs, adata_all.obs) assert np.array_equal(adata_all.raw.X, adata_all.X) adata_all = AnnData.concatenate(adata1, adata2, adata3, join="outer") assert isinstance(adata_all.raw, Raw) assert set(adata_all.raw.var_names) == set("abcd") assert_equal(adata_all.raw.to_adata().obs, adata_all.obs) assert np.array_equal(np.nan_to_num(adata_all.raw.X), np.nan_to_num(adata_all.X)) adata3.raw = adata4.copy() adata_all = AnnData.concatenate(adata1, adata2, adata3, join="outer") assert isinstance(adata_all.raw, Raw) assert set(adata_all.raw.var_names) == set("abcdz") assert set(adata_all.var_names) == set("abcd") assert not np.array_equal( np.nan_to_num(adata_all.raw.X), np.nan_to_num(adata_all.X) ) del adata3.raw with pytest.warns( UserWarning, match=( "Only some AnnData objects have `.raw` attribute, " "not concatenating `.raw` attributes." ), ): adata_all = AnnData.concatenate(adata1, adata2, adata3) assert adata_all.raw is None del adata1.raw del adata2.raw assert all(_adata.raw is None for _adata in (adata1, adata2, adata3)) adata_all = AnnData.concatenate(adata1, adata2, adata3) assert adata_all.raw is None def test_concatenate_awkward(join_type): import awkward as ak a = ak.Array([[{"a": 1, "b": "foo"}], [{"a": 2, "b": "bar"}, {"a": 3, "b": "baz"}]]) b = ak.Array([ [{"a": 4}, {"a": 5}], [{"a": 6}], [{"a": 7}], ]) adata_a = AnnData(np.zeros((2, 0), dtype=float), obsm={"awk": a}) adata_b = AnnData(np.zeros((3, 0), dtype=float), obsm={"awk": b}) if join_type == "inner": expected = ak.Array([ [{"a": 1}], [{"a": 2}, {"a": 3}], [{"a": 4}, {"a": 5}], [{"a": 6}], [{"a": 7}], ]) elif join_type == "outer": # TODO: This is what we would like to return, but waiting on: # * https://github.com/scikit-hep/awkward/issues/2182 and awkward 2.1.0 # * https://github.com/scikit-hep/awkward/issues/2173 # expected = ak.Array( # [ # [{"a": 1, "b": "foo"}], # [{"a": 2, "b": "bar"}, {"a": 3, "b": "baz"}], # [{"a": 4, "b": None}, {"a": 5, "b": None}], # [{"a": 6, "b": None}], # [{"a": 7, "b": None}], # ] # ) expected = ( ak.concatenate([ # I don't think I can construct a UnionArray directly ak.Array([ [{"a": 1, "b": "foo"}], [{"a": 2, "b": "bar"}, {"a": 3, "b": "baz"}], ]), ak.Array([ [{"a": 4}, {"a": 5}], [{"a": 6}], [{"a": 7}], ]), ]) ) result = concat([adata_a, adata_b], join=join_type).obsm["awk"] assert_equal(expected, result) @pytest.mark.parametrize( "other", [ pd.DataFrame({"a": [4, 5, 6], "b": ["foo", "bar", "baz"]}, index=list("cde")), np.ones((3, 2)), sparse.random(3, 100, format="csr"), ], ) def test_awkward_does_not_mix(join_type, other): import awkward as ak awk = ak.Array([ [{"a": 1, "b": "foo"}], [{"a": 2, "b": "bar"}, {"a": 3, "b": "baz"}], ]) adata_a = AnnData( np.zeros((2, 3), dtype=float), obs=pd.DataFrame(index=list("ab")), obsm={"val": awk}, ) adata_b = AnnData( np.zeros((3, 3), dtype=float), obs=pd.DataFrame(index=list("cde")), obsm={"val": other}, ) with pytest.raises( NotImplementedError, match=r"Cannot concatenate an AwkwardArray with other array types", ): concat([adata_a, adata_b], join=join_type) def test_pairwise_concat(axis_name, array_type): axis, axis_name = merge._resolve_axis(axis_name) _, alt_axis_name = merge._resolve_axis(1 - axis) axis_sizes = [[100, 200, 50], [50, 50, 50]] if axis_name == "var": axis_sizes.reverse() Ms, Ns = axis_sizes axis_attr = f"{axis_name}p" alt_attr = f"{alt_axis_name}p" def gen_axis_array(m): return array_type(sparse.random(m, m, format="csr", density=0.1)) adatas = { k: AnnData( X=sparse.csr_matrix((m, n)), obsp={"arr": gen_axis_array(m)}, varp={"arr": gen_axis_array(n)}, ) for k, m, n in zip("abc", Ms, Ns, strict=True) } w_pairwise = concat(adatas, axis=axis, label="orig", pairwise=True) wo_pairwise = concat(adatas, axis=axis, label="orig", pairwise=False) # Check that argument controls whether elements are included assert getattr(wo_pairwise, axis_attr) == {} assert getattr(w_pairwise, axis_attr) != {} # Check values of included elements full_inds = np.arange(w_pairwise.shape[axis]) obs_var: pd.DataFrame = getattr(w_pairwise, axis_name) groups = obs_var.groupby("orig", observed=True).indices for k, inds in groups.items(): orig_arr = getattr(adatas[k], axis_attr)["arr"] full_arr = getattr(w_pairwise, axis_attr)["arr"] if isinstance(full_arr, DaskArray): full_arr = full_arr.compute() # Check original values are intact assert_equal(orig_arr, _subset(full_arr, (inds, inds))) # Check that entries are filled with zeroes assert_equal( sparse.csr_matrix((len(inds), len(full_inds) - len(inds))), _subset(full_arr, (inds, np.setdiff1d(full_inds, inds))), ) assert_equal( sparse.csr_matrix((len(full_inds) - len(inds), len(inds))), _subset(full_arr, (np.setdiff1d(full_inds, inds), inds)), ) # Check that argument does not affect alternative axis assert "arr" in getattr( concat(adatas, axis=axis, pairwise=False, merge="first"), alt_attr ) def test_nan_merge(axis_name, join_type, array_type): axis, _ = merge._resolve_axis(axis_name) alt_axis, alt_axis_name = merge._resolve_axis(1 - axis) mapping_attr = f"{alt_axis_name}m" adata_shape = (20, 10) # TODO: Revert to https://github.com/scverse/anndata/blob/71fdf821919fc5ff3c864dc74c4432c370573984/tests/test_concatenate.py#L961-L970 after https://github.com/scipy/scipy/pull/23626. # The need for this handling arose as a result of # https://github.com/dask/dask/pull/11755/files#diff-65211e64fa680da306e9612b92c60f557365507d46486325f0e7e04359bce64fR456-R459 sparse_arr = sparse.random(adata_shape[alt_axis], 10, density=0.1, format="csr") sparse_arr_nan = sparse_arr.copy() with warnings.catch_warnings(): warnings.simplefilter("ignore", category=sparse.SparseEfficiencyWarning) for _ in range(10): sparse_arr_nan[ np.random.choice(sparse_arr.shape[0]), np.random.choice(sparse_arr.shape[1]), ] = np.nan arr = array_type(sparse_arr) arr_nan = array_type(sparse_arr_nan) _data = {"X": sparse.csr_matrix(adata_shape), mapping_attr: {"arr": arr_nan}} orig1 = AnnData(**_data) orig2 = AnnData(**_data) result = concat([orig1, orig2], axis=axis, join=join_type, merge="same") assert_equal(getattr(orig1, mapping_attr), getattr(result, mapping_attr)) orig_nonan = AnnData(**{ "X": sparse.csr_matrix(adata_shape), mapping_attr: {"arr": arr}, }) result_nonan = concat([orig1, orig_nonan], axis=axis, merge="same") assert len(getattr(result_nonan, mapping_attr)) == 0 def test_merge_unique(): from anndata._core.merge import merge_unique # Simple cases assert merge_unique([{"a": "b"}, {"a": "b"}]) == {"a": "b"} assert merge_unique([{"a": {"b": "c"}}, {"a": {"b": "c"}}]) == {"a": {"b": "c"}} assert merge_unique([{"a": {"b": "c"}}, {"a": {"b": "d"}}]) == {} assert merge_unique([{"a": {"b": "c", "d": "e"}}, {"a": {"b": "c", "d": "f"}}]) == { "a": {"b": "c"} } assert merge_unique([ {"a": {"b": {"c": {"d": "e"}}}}, {"a": {"b": {"c": {"d": "e"}}}}, ]) == {"a": {"b": {"c": {"d": "e"}}}} assert ( merge_unique([ {"a": {"b": {"c": {"d": "e"}}}}, {"a": {"b": {"c": {"d": "f"}}}}, {"a": {"b": {"c": {"d": "e"}}}}, ]) == {} ) assert merge_unique([{"a": 1}, {"b": 2}]) == {"a": 1, "b": 2} assert merge_unique([{"a": 1}, {"b": 2}, {"a": 1, "b": {"c": 2, "d": 3}}]) == { "a": 1 } # Test equivalency between arrays and lists assert list( merge_unique([{"a": np.ones(5)}, {"a": list(np.ones(5))}])["a"] ) == list(np.ones(5)) assert merge_unique([{"a": np.ones(5)}, {"a": list(np.ones(4))}]) == {} def test_merge_same(): from anndata._core.merge import merge_same # Same as unique for a number of cases: assert merge_same([{"a": "b"}, {"a": "b"}]) == {"a": "b"} assert merge_same([{"a": {"b": "c"}}, {"a": {"b": "c"}}]) == {"a": {"b": "c"}} assert merge_same([{"a": {"b": "c"}}, {"a": {"b": "d"}}]) == {} assert merge_same([{"a": {"b": "c", "d": "e"}}, {"a": {"b": "c", "d": "f"}}]) == { "a": {"b": "c"} } assert merge_same([{"a": {"b": "c"}, "d": "e"}, {"a": {"b": "c"}, "d": 2}]) == { "a": {"b": "c"} } assert merge_same([ {"a": {"b": {"c": {"d": "e"}}}}, {"a": {"b": {"c": {"d": "e"}}}}, ]) == {"a": {"b": {"c": {"d": "e"}}}} assert merge_same([{"a": 1}, {"b": 2}]) == {} assert merge_same([{"a": 1}, {"b": 2}, {"a": 1, "b": {"c": 2, "d": 3}}]) == {} # Test equivalency between arrays and lists assert list(merge_same([{"a": np.ones(5)}, {"a": list(np.ones(5))}])["a"]) == list( np.ones(5) ) def test_merge_first(): from anndata._core.merge import merge_first assert merge_first([{"a": "b"}, {"a": "b"}]) == {"a": "b"} assert merge_first([{"a": {"b": "c"}}, {"a": {"b": "c"}}]) == {"a": {"b": "c"}} assert merge_first([{"a": 1}, {"a": 2}]) == {"a": 1} assert merge_first([{"a": 1}, {"a": {"b": {"c": {"d": "e"}}}}]) == {"a": 1} assert merge_first([{"a": {"b": {"c": {"d": "e"}}}}, {"a": 1}]) == { "a": {"b": {"c": {"d": "e"}}} } # Helpers for test_concatenate_uns def uns_ad(uns): return AnnData(np.zeros((10, 10)), uns=uns) def map_values(mapping, path, key, old_parent, new_parent, new_items): ret = default_exit(path, key, old_parent, new_parent, new_items) for k, v in ret.items(): if isinstance(v, Hashable) and v in mapping: ret[k] = mapping[v] return ret def permute_nested_values(dicts: list[dict], gen_val: Callable[[int], Any]): """ This function permutes the values of a nested mapping, for testing that out merge method work regardless of the values types. Assumes the initial dictionary had integers for values. """ dicts = deepcopy(dicts) initial_values = [ x[1] for x in research(dicts, query=lambda p, k, v: isinstance(v, int)) ] mapping = {k: gen_val(k) for k in initial_values} return [remap(d, exit=partial(map_values, mapping)) for d in dicts] def gen_df(n): return helpers.gen_typed_df(n) def gen_array(n): return np.random.randn(n) def gen_list(n): return list(gen_array(n)) def gen_sparse(n): return sparse.random( np.random.randint(1, 100), np.random.randint(1, 100), format="csr" ) def gen_something(n): options = [gen_df, gen_array, gen_list, gen_sparse] return np.random.choice(options)(n) def gen_3d_numeric_array(n): return np.random.randn(n, n, n) def gen_3d_recarray(_): # Ignoring n as it can get quite slow return gen_vstr_recarray(8, 3).reshape(2, 2, 2) def gen_concat_params(unss, compat2result): value_generators = [ lambda x: x, gen_df, gen_array, gen_list, gen_sparse, gen_something, gen_3d_numeric_array, gen_3d_recarray, ] for gen, (mode, result) in product(value_generators, compat2result.items()): yield pytest.param(unss, mode, result, gen) @pytest.mark.parametrize( ("unss", "merge_strategy", "result", "value_gen"), chain( gen_concat_params( [{"a": 1}, {"a": 2}], {None: {}, "first": {"a": 1}, "unique": {}, "same": {}, "only": {}}, ), gen_concat_params( [{"a": 1}, {"b": 2}], { None: {}, "first": {"a": 1, "b": 2}, "unique": {"a": 1, "b": 2}, "same": {}, "only": {"a": 1, "b": 2}, }, ), gen_concat_params( [ {"a": {"b": 1, "c": {"d": 3}}}, {"a": {"b": 1, "c": {"e": 4}}}, ], { None: {}, "first": {"a": {"b": 1, "c": {"d": 3, "e": 4}}}, "unique": {"a": {"b": 1, "c": {"d": 3, "e": 4}}}, "same": {"a": {"b": 1}}, "only": {"a": {"c": {"d": 3, "e": 4}}}, }, ), gen_concat_params( [ {"a": 1}, {"a": 1, "b": 2}, {"a": 1, "b": {"b.a": 1}, "c": 3}, {"d": 4}, ], { None: {}, "first": {"a": 1, "b": 2, "c": 3, "d": 4}, "unique": {"a": 1, "c": 3, "d": 4}, "same": {}, "only": {"c": 3, "d": 4}, }, ), gen_concat_params( [{"a": i} for i in range(15)], {None: {}, "first": {"a": 0}, "unique": {}, "same": {}, "only": {}}, ), gen_concat_params( [{"a": 1} for i in range(10)] + [{"a": 2}], {None: {}, "first": {"a": 1}, "unique": {}, "same": {}, "only": {}}, ), ), ) def test_concatenate_uns(unss, merge_strategy, result, value_gen): """ Test that concatenation works out for different strategies and sets of values. Params ------ unss Set of patterns for values in uns. compat Strategy to use for merging uns. result Pattern we expect to see for the given input and strategy. value_gen Maps values in unss and results to another set of values. This is for checking that we're comparing values correctly. For example `[{"a": 1}, {"a": 1}]` may get mapped to `[{"a": [1, 2, 3]}, {"a": [1, 2, 3]}]`. """ # So we can see what the initial pattern was meant to be print(merge_strategy, "\n", unss, "\n", result) result, *unss = permute_nested_values([result, *unss], value_gen) adatas = [uns_ad(uns) for uns in unss] with pytest.warns(FutureWarning, match=r"concatenate is deprecated"): merged = AnnData.concatenate(*adatas, uns_merge=merge_strategy).uns assert_equal(merged, result, elem_name="uns") def test_transposed_concat( array_type, axis_name, join_type, merge_strategy, use_xdataset, force_lazy, ): axis, axis_name = merge._resolve_axis(axis_name) alt_axis = 1 - axis lhs = gen_adata( (10, 10), X_type=array_type, obs_xdataset=use_xdataset, var_xdataset=use_xdataset, **GEN_ADATA_DASK_ARGS, ) rhs = gen_adata( (10, 12), X_type=array_type, obs_xdataset=use_xdataset, var_xdataset=use_xdataset, **GEN_ADATA_DASK_ARGS, ) a = concat( [lhs, rhs], axis=axis, join=join_type, merge=merge_strategy, force_lazy=force_lazy, ) b = concat( [lhs.T, rhs.T], axis=alt_axis, join=join_type, merge=merge_strategy, force_lazy=force_lazy, ).T assert_equal(a, b) def test_batch_key(axis_name, use_xdataset, force_lazy): """Test that concat only adds a label if the key is provided""" get_annot = attrgetter(axis_name) lhs = gen_adata( (10, 10), obs_xdataset=use_xdataset, var_xdataset=use_xdataset, **GEN_ADATA_DASK_ARGS, ) rhs = gen_adata( (10, 12), obs_xdataset=use_xdataset, var_xdataset=use_xdataset, **GEN_ADATA_DASK_ARGS, ) # There is probably a prettier way to do this annot = get_annot(concat([lhs, rhs], axis=axis_name, force_lazy=force_lazy)) assert ( list( annot.columns.difference( get_annot(lhs).columns.union(get_annot(rhs).columns) ) ) == [] ) batch_annot = get_annot( concat([lhs, rhs], axis=axis_name, label="batch", force_lazy=force_lazy) ) assert list( batch_annot.columns.difference( get_annot(lhs).columns.union(get_annot(rhs).columns) ) ) == ["batch"] def test_concat_categories_from_mapping(use_xdataset, force_lazy): mapping = { "a": gen_adata((10, 10), obs_xdataset=use_xdataset, var_xdataset=use_xdataset), "b": gen_adata((10, 10), obs_xdataset=use_xdataset, var_xdataset=use_xdataset), } keys = list(mapping.keys()) adatas = list(mapping.values()) mapping_call = partial(concat, mapping, force_lazy=force_lazy) iter_call = partial(concat, adatas, keys=keys, force_lazy=force_lazy) assert_equal(mapping_call(), iter_call()) assert_equal(mapping_call(label="batch"), iter_call(label="batch")) assert_equal(mapping_call(index_unique="-"), iter_call(index_unique="-")) assert_equal( mapping_call(label="group", index_unique="+"), iter_call(label="group", index_unique="+"), ) def test_concat_categories_maintain_dtype(): a = AnnData( X=np.ones((5, 1)), obs=pd.DataFrame( { "cat": pd.Categorical(list("aabcc")), "cat_ordered": pd.Categorical(list("aabcc"), ordered=True), }, index=[f"cell{i:02}" for i in range(5)], ), ) b = AnnData( X=np.ones((5, 1)), obs=pd.DataFrame( { "cat": pd.Categorical(list("bccdd")), "cat_ordered": pd.Categorical(list("bccdd"), ordered=True), }, index=[f"cell{i:02}" for i in range(5, 10)], ), ) c = AnnData( X=np.ones((5, 1)), obs=pd.DataFrame( { "cat_ordered": pd.Categorical(list("bccdd"), ordered=True), }, index=[f"cell{i:02}" for i in range(5, 10)], ), ) result = concat({"a": a, "b": b, "c": c}, join="outer") assert isinstance(result.obs["cat"].dtype, pd.CategoricalDtype), ( f"Was {result.obs['cat'].dtype}" ) assert pd.api.types.is_string_dtype(result.obs["cat_ordered"]) def test_concat_ordered_categoricals_retained(): a = AnnData( X=np.ones((5, 1)), obs=pd.DataFrame( { "cat_ordered": pd.Categorical(list("aabcd"), ordered=True), }, index=[f"cell{i:02}" for i in range(5)], ), ) b = AnnData( X=np.ones((5, 1)), obs=pd.DataFrame( { "cat_ordered": pd.Categorical(list("abcdd"), ordered=True), }, index=[f"cell{i:02}" for i in range(5, 10)], ), ) c = concat([a, b]) assert isinstance(c.obs["cat_ordered"].dtype, pd.CategoricalDtype) assert c.obs["cat_ordered"].cat.ordered def test_concat_categorical_dtype_promotion(): """https://github.com/scverse/anndata/issues/1170 When concatenating categorical with other dtype, defer to pandas. """ a = AnnData( np.ones((3, 3)), obs=pd.DataFrame( {"col": pd.Categorical(["a", "a", "b"])}, index=[f"cell_{i:02d}" for i in range(3)], ), ) b = AnnData( np.ones((3, 3)), obs=pd.DataFrame( {"col": ["c", "c", "c"]}, index=[f"cell_{i:02d}" for i in range(3, 6)], ), ) result = concat([a, b]) expected = pd.concat([a.obs, b.obs]) assert_equal(result.obs, expected) def test_bool_promotion(): np_bool = AnnData( np.ones((5, 1)), obs=pd.DataFrame({"bool": [True] * 5}, index=[f"cell{i:02}" for i in range(5)]), ) missing = AnnData( np.ones((5, 1)), obs=pd.DataFrame(index=[f"cell{i:02}" for i in range(5, 10)]), ) result = concat({"np_bool": np_bool, "b": missing}, join="outer", label="batch") assert pd.api.types.is_bool_dtype(result.obs["bool"]) assert pd.isnull(result.obs.loc[result.obs["batch"] == "missing", "bool"]).all() # Check that promotion doesn't occur if it doesn't need to: np_bool_2 = AnnData( np.ones((5, 1)), obs=pd.DataFrame( {"bool": [True] * 5}, index=[f"cell{i:02}" for i in range(5, 10)] ), ) result = concat( {"np_bool": np_bool, "np_bool_2": np_bool_2}, join="outer", label="batch" ) assert result.obs["bool"].dtype == np.dtype(bool) @pytest.mark.parametrize( ("index_unique", "expect_unique"), [ pytest.param(None, False, id="default"), pytest.param("-", True, id="force_unique"), ], ) @pytest.mark.parametrize("with_missing", [True, False], ids=["missing", "no_missing"]) def test_concat_names( *, axis_name: Literal["obs", "var"], use_xdataset: bool, force_lazy: bool, index_unique: str | None, expect_unique: bool, with_missing: bool, ) -> None: get_annot: attrgetter[pd.DataFrame] = attrgetter(axis_name) lhs = gen_adata((10, 10), obs_xdataset=use_xdataset, var_xdataset=use_xdataset) rhs = gen_adata((10, 10), obs_xdataset=use_xdataset, var_xdataset=use_xdataset) if with_missing: for s in [lhs, rhs]: new_index = pd.Index([*get_annot(s).index[:5], *([pd.NA] * 5)]) setattr(s, f"{axis_name}_names", new_index) cat = concat( [lhs, rhs], axis=axis_name, index_unique=index_unique, force_lazy=force_lazy ) assert get_annot(cat).index[~get_annot(cat).index.isna()].is_unique is expect_unique if with_missing: assert get_annot(cat).index.isna().sum() == 10 def axis_labels(adata: AnnData, axis: Literal[0, 1]) -> pd.Index: return (adata.obs_names, adata.var_names)[axis] def expected_shape( a: AnnData, b: AnnData, axis: Literal[0, 1], join: Literal["inner", "outer"] ) -> tuple[int, int]: alt_axis = 1 - axis labels = partial(axis_labels, axis=alt_axis) shape = [None, None] shape[axis] = a.shape[axis] + b.shape[axis] if join == "inner": shape[alt_axis] = len(labels(a).intersection(labels(b))) elif join == "outer": shape[alt_axis] = len(labels(a).union(labels(b))) else: raise ValueError() return tuple(shape) @pytest.mark.parametrize( "shape", [pytest.param((8, 0), id="no_var"), pytest.param((0, 10), id="no_obs")] ) def test_concat_size_0_axis( axis_name, join_type, merge_strategy, shape, use_xdataset, force_lazy ): """Regression test for https://github.com/scverse/anndata/issues/526""" axis, axis_name = merge._resolve_axis(axis_name) alt_axis = 1 - axis col_dtypes = (*DEFAULT_COL_TYPES, pd.StringDtype) a = gen_adata( (5, 7), obs_dtypes=col_dtypes, var_dtypes=col_dtypes, obs_xdataset=use_xdataset, var_xdataset=use_xdataset, ) b = gen_adata( shape, obs_dtypes=col_dtypes, var_dtypes=col_dtypes, obs_xdataset=use_xdataset, var_xdataset=use_xdataset, ) expected_size = expected_shape(a, b, axis=axis, join=join_type) result = concat( {"a": a, "b": b}, axis=axis, join=join_type, merge=merge_strategy, pairwise=True, index_unique="-", force_lazy=force_lazy, ) assert result.shape == expected_size if join_type == "outer": # Check new entries along axis of concatenation axis_new_inds = axis_labels(result, axis).str.endswith("-b") altaxis_new_inds = ~axis_labels(result, alt_axis).isin(axis_labels(a, alt_axis)) axis_idx = make_idx_tuple(axis_new_inds, axis) altaxis_idx = make_idx_tuple(altaxis_new_inds, 1 - axis) check_filled_like(result.X[axis_idx], elem_name="X") check_filled_like(result.X[altaxis_idx], elem_name="X") for k, elem in result.layers.items(): check_filled_like(elem[axis_idx], elem_name=f"layers/{k}") check_filled_like(elem[altaxis_idx], elem_name=f"layers/{k}") if shape[axis] > 0: b_result = result[axis_idx].copy() mapping_elem = f"{axis_name}m" setattr(b_result, f"{axis_name}_names", getattr(b, f"{axis_name}_names")) for k, result_elem in getattr(b_result, mapping_elem).items(): elem_name = f"{mapping_elem}/{k}" # pd.concat can have unintuitive return types. is similar to numpy promotion if isinstance(result_elem, pd.DataFrame): assert_equal( getattr(b, mapping_elem)[k].astype(object), result_elem.astype(object), elem_name=elem_name, ) else: assert_equal( getattr(b, mapping_elem)[k], result_elem, elem_name=elem_name, ) @pytest.mark.parametrize("elem", ["sparse", "array", "df", "da"]) @pytest.mark.parametrize("axis", ["obs", "var"]) def test_concat_outer_aligned_mapping(elem, axis, use_xdataset, force_lazy): a = gen_adata( (5, 5), obs_xdataset=use_xdataset, var_xdataset=use_xdataset, **GEN_ADATA_DASK_ARGS, ) b = gen_adata( (3, 5), obs_xdataset=use_xdataset, var_xdataset=use_xdataset, **GEN_ADATA_DASK_ARGS, ) del getattr(b, f"{axis}m")[elem] concated = concat( {"a": a, "b": b}, join="outer", label="group", axis=axis, force_lazy=force_lazy ) mask = getattr(concated, axis)["group"] == "b" result = getattr( concated[(mask, slice(None)) if axis == "obs" else (slice(None), mask)], f"{axis}m", )[elem] check_filled_like(result, elem_name=f"{axis}m/{elem}") @mark_legacy_concatenate def test_concatenate_size_0_axis(): # https://github.com/scverse/anndata/issues/526 a = gen_adata((5, 10)) b = gen_adata((5, 0)) # Mostly testing that this doesn't error assert a.concatenate([b]).shape == (10, 0) assert b.concatenate([a]).shape == (10, 0) def test_concat_null_X(use_xdataset): adatas_orig = { k: gen_adata((20, 10), obs_xdataset=use_xdataset, var_xdataset=use_xdataset) for k in list("abc") } adatas_no_X = {} for k, v in adatas_orig.items(): v = v.copy() del v.X adatas_no_X[k] = v orig = concat(adatas_orig, index_unique="-") no_X = concat(adatas_no_X, index_unique="-") del orig.X assert_equal(no_X, orig) # https://github.com/scverse/ehrapy/issues/151#issuecomment-1016753744 @pytest.mark.parametrize("sparse_indexer_type", [np.int64, np.int32]) def test_concat_X_dtype(cpu_array_type, sparse_indexer_type): adatas_orig = { k: AnnData(cpu_array_type(np.ones((20, 10), dtype=np.int8))) for k in list("abc") } for adata in adatas_orig.values(): adata.raw = AnnData(cpu_array_type(np.ones((20, 30), dtype=np.float64))) if sparse.issparse(adata.X): adata.X.indptr = adata.X.indptr.astype(sparse_indexer_type) adata.X.indices = adata.X.indices.astype(sparse_indexer_type) result = concat(adatas_orig, index_unique="-") assert result.X.dtype == np.int8 assert result.raw.X.dtype == np.float64 if sparse.issparse(result.X): # https://github.com/scipy/scipy/issues/20389 was merged in 1.15 but is still an issue with matrix if sparse_indexer_type == np.int64 and ( ( (issubclass(cpu_array_type, CSArray) or adata.X.format == "csc") and Version(version("scipy")) < Version("1.15.0") ) or issubclass(cpu_array_type, CSMatrix) ): pytest.xfail( "Data type int64 is not maintained for sparse matrices or csc array" ) assert result.X.indptr.dtype == sparse_indexer_type, result.X assert result.X.indices.dtype == sparse_indexer_type # Leaving out for now. See definition of these values for explanation # def test_concatenate_uns_types(): # from anndata._core.merge import UNS_STRATEGIES, UNS_STRATEGIES_TYPE # assert set(UNS_STRATEGIES.keys()) == set(UNS_STRATEGIES_TYPE.__args__) # Tests how dask plays with other types on concatenation. def test_concat_different_types_dask(merge_strategy, array_type): import dask.array as da from scipy import sparse import anndata as ad varm_array = sparse.random(5, 20, density=0.5, format="csr") ad1 = ad.AnnData(X=np.ones((5, 5)), varm={"a": varm_array}) ad1_other = ad.AnnData(X=np.ones((5, 5)), varm={"a": array_type(varm_array)}) ad2 = ad.AnnData(X=np.zeros((5, 5)), varm={"a": da.ones(5, 20)}) result1 = ad.concat([ad1, ad2], merge=merge_strategy) target1 = ad.concat([ad1_other, ad2], merge=merge_strategy) result2 = ad.concat([ad2, ad1], merge=merge_strategy) target2 = ad.concat([ad2, ad1_other], merge=merge_strategy) assert_equal(result1, target1) assert_equal(result2, target2) def test_concat_missing_elem_dask_join(join_type): import dask.array as da import anndata as ad ad1 = ad.AnnData(X=np.ones((5, 10))) ad2 = ad.AnnData(X=np.zeros((5, 5)), layers={"a": da.ones((5, 5))}) ad_in_memory_with_layers = ad2.to_memory() result1 = ad.concat([ad1, ad2], join=join_type) result2 = ad.concat([ad1, ad_in_memory_with_layers], join=join_type) assert_equal(result1, result2) def test_impute_dask(axis_name): import dask.array as da from anndata._core.merge import _resolve_axis, missing_element axis, _ = _resolve_axis(axis_name) els = [da.ones((5, 5))] missing = missing_element(6, els, axis=axis, off_axis_size=17) assert isinstance(missing, DaskArray) in_memory = missing.compute() assert np.all(np.isnan(in_memory)) assert in_memory.shape[axis] == 6 assert in_memory.shape[axis - 1] == 17 def test_outer_concat_with_missing_value_for_df(): # https://github.com/scverse/anndata/issues/901 # TODO: Extend this test to cover all cases of missing values # TODO: Check values a_idx = ["a", "b", "c", "d", "e"] b_idx = ["f", "g", "h", "i", "j", "k", "l", "m"] a = AnnData( np.ones((5, 5)), obs=pd.DataFrame(index=a_idx), ) b = AnnData( np.zeros((8, 9)), obs=pd.DataFrame(index=b_idx), obsm={"df": pd.DataFrame({"col": np.arange(8)}, index=b_idx)}, ) concat([a, b], join="outer") def test_outer_concat_outputs_nullable_bool_writable(tmp_path): a = gen_adata((5, 5), obsm_types=(pd.DataFrame,)) b = gen_adata((3, 5), obsm_types=(pd.DataFrame,)) del b.obsm["df"] adatas = concat({"a": a, "b": b}, join="outer", label="group") adatas.write(tmp_path / "test.h5ad") def test_concat_duplicated_columns(join_type): # https://github.com/scverse/anndata/issues/483 a = AnnData( obs=pd.DataFrame( np.ones((5, 2)), columns=["a", "a"], index=[str(x) for x in range(5)] ) ) b = AnnData( obs=pd.DataFrame( np.ones((5, 1)), columns=["a"], index=[str(x) for x in range(5, 10)] ) ) with pytest.raises(pd.errors.InvalidIndexError, match=r"'a'"): concat([a, b], join=join_type) @pytest.mark.gpu def test_error_on_mixed_device(): """https://github.com/scverse/anndata/issues/1083""" import cupy import cupyx.scipy.sparse as cupy_sparse cp_adata = AnnData( cupy.random.randn(10, 10), obs=pd.DataFrame(index=[f"cell_{i:02d}" for i in range(10)]), ) cp_sparse_adata = AnnData( cupy_sparse.random(10, 10, format="csr", density=0.2), obs=pd.DataFrame(index=[f"cell_{i:02d}" for i in range(10, 20)]), ) np_adata = AnnData( np.random.randn(10, 10), obs=pd.DataFrame(index=[f"cell_{i:02d}" for i in range(20, 30)]), ) sparse_adata = AnnData( sparse.random(10, 10, format="csr", density=0.2), obs=pd.DataFrame(index=[f"cell_{i:02d}" for i in range(30, 40)]), ) adatas = { "cupy": cp_adata, "cupy_sparse": cp_sparse_adata, "numpy": np_adata, "sparse": sparse_adata, } for p in map(dict, permutations(adatas.items())): print(list(p.keys())) with pytest.raises( NotImplementedError, match=r"Cannot concatenate a cupy array with other" ): concat(p) for p in permutations([cp_adata, cp_sparse_adata]): concat(p) def test_concat_on_var_outer_join(array_type): # https://github.com/scverse/anndata/issues/1286 a = AnnData( obs=pd.DataFrame(index=[f"cell_{i:02d}" for i in range(10)]), var=pd.DataFrame(index=[f"gene_{i:02d}" for i in range(10)]), layers={ "X": array_type(np.ones((10, 10))), }, ) b = AnnData( obs=pd.DataFrame(index=[f"cell_{i:02d}" for i in range(10)]), var=pd.DataFrame(index=[f"gene_{i:02d}" for i in range(10, 20)]), ) # This shouldn't error # TODO: specify expected result while accounting for null value _ = concat([a, b], join="outer", axis=1) def test_concat_dask_sparse_matches_memory(join_type, merge_strategy): import dask.array as da X = sparse.random(50, 20, density=0.5, format="csr") X_dask = da.from_array(X, chunks=(5, 20)) var_names_1 = [f"gene_{i}" for i in range(20)] var_names_2 = [f"gene_{i}{'_foo' if (i % 2) else ''}" for i in range(20)] ad1 = AnnData(X=X, var=pd.DataFrame(index=var_names_1)) ad2 = AnnData(X=X, var=pd.DataFrame(index=var_names_2)) ad1_dask = AnnData(X=X_dask, var=pd.DataFrame(index=var_names_1)) ad2_dask = AnnData(X=X_dask, var=pd.DataFrame(index=var_names_2)) res_in_memory = concat([ad1, ad2], join=join_type, merge=merge_strategy) res_dask = concat([ad1_dask, ad2_dask], join=join_type, merge=merge_strategy) assert_equal(res_in_memory, res_dask) def test_1d_concat(): adata = AnnData(np.ones((5, 20)), obsm={"1d-array": np.ones(5)}) concated = concat([adata, adata]) assert concated.obsm["1d-array"].shape == (10, 1) scverse-anndata-b796d59/tests/test_concatenate_disk.py000066400000000000000000000245141512025555600232300ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Mapping from typing import TYPE_CHECKING import awkward as ak import h5py import numpy as np import pandas as pd import pytest from scipy import sparse import anndata as ad from anndata import AnnData, concat, settings from anndata._core import merge from anndata._core.merge import _resolve_axis from anndata.compat import is_zarr_v2 from anndata.experimental.merge import as_group, concat_on_disk from anndata.io import read_elem, write_elem from anndata.tests.helpers import assert_equal, check_all_sharded, gen_adata from anndata.utils import asarray if TYPE_CHECKING: from pathlib import Path from typing import Literal GEN_ADATA_OOC_CONCAT_ARGS = dict( obsm_types=( sparse.csr_matrix, np.ndarray, pd.DataFrame, ), varm_types=(sparse.csr_matrix, np.ndarray, pd.DataFrame), layers_types=(sparse.csr_matrix, np.ndarray, pd.DataFrame), ) @pytest.fixture(params=list(merge.MERGE_STRATEGIES.keys())) def merge_strategy(request): return request.param @pytest.fixture(params=[0, 1]) def axis(request) -> Literal[0, 1]: return request.param @pytest.fixture(params=["array", "sparse", "sparse_array"]) def array_type(request) -> Literal["array", "sparse", "sparse_array"]: return request.param @pytest.fixture(params=["inner", "outer"]) def join_type(request) -> Literal["inner", "outer"]: return request.param @pytest.fixture(params=["zarr", "h5ad"]) def file_format(request) -> Literal["zarr", "h5ad"]: return request.param # 5 is enough to guarantee that the feature is being used since the # `test_anndatas` generates a minimum of 5 on at least one of the axes. # Thus there will be at least 5 elems. @pytest.fixture(params=[5, 1_000_000]) def max_loaded_elems(request) -> int: return request.param def _adatas_to_paths(adatas, tmp_path, file_format): """ Gets list of adatas, writes them and returns their paths as zarr """ paths = None if isinstance(adatas, Mapping): paths = {} for k, v in adatas.items(): p = tmp_path / (f"{k}." + file_format) with as_group(p, mode="a") as f: write_elem(f, "", v) paths[k] = p else: paths = [] for i, a in enumerate(adatas): p = tmp_path / (f"{i}." + file_format) with as_group(p, mode="a") as f: write_elem(f, "", a) paths += [p] return paths def assert_eq_concat_on_disk( adatas, tmp_path: Path, file_format: Literal["zarr", "h5ad"], max_loaded_elems: int | None = None, *args, merge_strategy: merge.StrategiesLiteral | None = None, **kwargs, ): # create one from the concat function res1 = concat(adatas, *args, merge=merge_strategy, **kwargs) # create one from the on disk concat function paths = _adatas_to_paths(adatas, tmp_path, file_format) out_name = tmp_path / f"out.{file_format}" if max_loaded_elems is not None: kwargs["max_loaded_elems"] = max_loaded_elems concat_on_disk(paths, out_name, *args, merge=merge_strategy, **kwargs) with as_group(out_name, mode="r") as rg: res2 = read_elem(rg) assert_equal(res1, res2, exact=False) def get_array_type(array_type, axis): if array_type == "sparse": return sparse.csr_matrix if axis == 0 else sparse.csc_matrix if array_type == "sparse_array": return sparse.csr_array if axis == 0 else sparse.csc_array if array_type == "array": return asarray msg = f"array_type {array_type} not implemented" raise NotImplementedError(msg) @pytest.mark.parametrize("reindex", [True, False], ids=["reindex", "no_reindex"]) @pytest.mark.filterwarnings("ignore:Misaligned chunks detected") def test_anndatas( *, axis: Literal[0, 1], array_type: Literal["array", "sparse", "sparse_array"], join_type: Literal["inner", "outer"], tmp_path: Path, max_loaded_elems: int, file_format: Literal["zarr", "h5ad"], reindex: bool, merge_strategy: merge.StrategiesLiteral, ): _, off_axis_name = _resolve_axis(1 - axis) random_axes = {0, 1} if reindex else {axis} sparse_fmt = "csr" if axis == 0 else "csc" kw = ( GEN_ADATA_OOC_CONCAT_ARGS if not reindex else dict( obsm_types=(get_array_type("sparse", 1 - axis), np.ndarray, pd.DataFrame), varm_types=(get_array_type("sparse", 1 - axis), np.ndarray, pd.DataFrame), layers_types=(get_array_type("sparse", axis), np.ndarray, pd.DataFrame), ) ) adatas = [] for i in range(3): M, N = (np.random.randint(5, 10) if a in random_axes else 50 for a in (0, 1)) a = gen_adata( (M, N), X_type=get_array_type(array_type, axis), sparse_fmt=sparse_fmt, obs_dtypes=[pd.CategoricalDtype(ordered=False)], var_dtypes=[pd.CategoricalDtype(ordered=False)], **kw, ) # ensure some names overlap, others do not, for the off-axis so that inner/outer is properly tested off_names = getattr(a, f"{off_axis_name}_names").array off_names[1::2] = f"{i}-" + off_names[1::2] setattr(a, f"{off_axis_name}_names", off_names) adatas.append(a) assert_eq_concat_on_disk( adatas, tmp_path, file_format, max_loaded_elems, axis=axis, join=join_type, merge_strategy=merge_strategy, ) def test_concat_ordered_categoricals_retained(tmp_path, file_format): a = AnnData( X=np.ones((5, 1)), obs=pd.DataFrame( { "cat_ordered": pd.Categorical(list("aabcd"), ordered=True), }, index=[f"cell{i:02}" for i in range(5)], ), ) b = AnnData( X=np.ones((5, 1)), obs=pd.DataFrame( { "cat_ordered": pd.Categorical(list("abcdd"), ordered=True), }, index=[f"cell{i:02}" for i in range(5, 10)], ), ) adatas = [a, b] assert_eq_concat_on_disk(adatas, tmp_path, file_format) @pytest.fixture def xxxm_adatas(): def gen_index(n): return [f"cell{i}" for i in range(n)] return [ AnnData( X=sparse.csr_matrix((3, 5)), obs=pd.DataFrame(index=gen_index(3)), obsm={ "dense": np.arange(6).reshape(3, 2), "sparse": sparse.csr_matrix(np.arange(6).reshape(3, 2)), "df": pd.DataFrame( { "a": np.arange(3), "b": list("abc"), "c": pd.Categorical(list("aab")), }, index=gen_index(3), ), }, ), AnnData( X=sparse.csr_matrix((4, 10)), obs=pd.DataFrame(index=gen_index(4)), obsm=dict( dense=np.arange(12).reshape(4, 3), df=pd.DataFrame(dict(a=np.arange(3, 7)), index=gen_index(4)), ), ), AnnData( X=sparse.csr_matrix((2, 100)), obs=pd.DataFrame(index=gen_index(2)), obsm={ "sparse": sparse.csr_matrix(np.arange(8).reshape(2, 4)), "dense": np.arange(4, 8).reshape(2, 2), "df": pd.DataFrame( { "a": np.arange(7, 9), "b": list("cd"), "c": pd.Categorical(list("ab")), }, index=gen_index(2), ), }, ), ] def test_concatenate_xxxm(xxxm_adatas, tmp_path, file_format, join_type): if join_type == "outer": for i in range(len(xxxm_adatas)): xxxm_adatas[i] = xxxm_adatas[i].T xxxm_adatas[i].X = sparse.csr_matrix(xxxm_adatas[i].X) assert_eq_concat_on_disk(xxxm_adatas, tmp_path, file_format, join=join_type) @pytest.mark.skipif(is_zarr_v2(), reason="auto sharding is allowed only for zarr v3.") def test_concatenate_zarr_v3_shard(xxxm_adatas, tmp_path): import zarr with settings.override(auto_shard_zarr_v3=True, zarr_write_format=3): assert_eq_concat_on_disk(xxxm_adatas, tmp_path, file_format="zarr") g = zarr.open(tmp_path) assert g.metadata.zarr_format == 3 def visit(key: str, arr: zarr.Array | zarr.Group): if isinstance(arr, zarr.Array) and arr.shape != (): assert arr.shards is not None check_all_sharded(g) def test_output_dir_exists(tmp_path): in_pth = tmp_path / "in.h5ad" out_pth = tmp_path / "does_not_exist" / "out.h5ad" AnnData(X=np.ones((5, 1))).write_h5ad(in_pth) with pytest.raises(FileNotFoundError, match=str(out_pth)): concat_on_disk([in_pth], out_pth) def test_no_open_h5_file_handles_after_error(tmp_path): in_pth = tmp_path / "in.h5ad" in_pth2 = tmp_path / "in2.h5ad" out_pth = tmp_path / "out.h5ad" adata = AnnData( X=np.ones((2, 1)), obsm={ "awk": ak.Array([ [{"a": 1, "b": "foo"}], [{"a": 2, "b": "bar"}], ]) }, ) adata.write_h5ad(in_pth) adata.write_h5ad(in_pth2) # Intentionally write an unsupported array type, which could leave dangling file handles: # https://github.com/scverse/anndata/issues/2198 try: concat_on_disk([in_pth, in_pth2], out_pth) except NotImplementedError: for path in [in_pth, in_pth2, out_pth]: # should not error out because there are no file handles open f = h5py.File(path, mode="w") f.close() def test_write_using_groups(tmp_path, file_format): in_pth = tmp_path / f"in.{file_format}" in_pth2 = tmp_path / f"in2.{file_format}" out_pth = tmp_path / f"out.{file_format}" adata = AnnData(X=np.ones((2, 1))) getattr(adata, f"write_{file_format}")(in_pth) getattr(adata, f"write_{file_format}")(in_pth2) with ( as_group(in_pth, mode="r") as f1, as_group(in_pth2, mode="r") as f2, as_group(out_pth, mode="w") as fout, ): concat_on_disk([f1, f2], fout) adata_out = getattr(ad, f"read_{file_format}")(out_pth) assert_equal(adata_out, concat([adata, adata])) def test_failure_w_no_args(tmp_path): with pytest.raises(ValueError, match=r"No objects to concatenate"): concat_on_disk([], tmp_path / "out.h5ad") scverse-anndata-b796d59/tests/test_dask.py000066400000000000000000000307111512025555600206500ustar00rootroot00000000000000""" For tests using dask """ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING import numpy as np import pandas as pd import pytest from scipy import sparse import anndata as ad from anndata._core.anndata import AnnData from anndata.compat import CupyArray, DaskArray from anndata.experimental.merge import as_group from anndata.tests.helpers import ( GEN_ADATA_DASK_ARGS, as_cupy_sparse_dask_array, as_dense_cupy_dask_array, as_dense_dask_array, as_sparse_dask_matrix, assert_equal, check_all_sharded, gen_adata, ) if TYPE_CHECKING: from pathlib import Path from typing import Literal pytest.importorskip("dask.array") @pytest.fixture( params=[ [(2000, 1000), (100, 100)], [(200, 100), (100, 100)], [(200, 100), (100, 100)], [(20, 10), (1, 1)], [(20, 10), (1, 1)], ] ) def sizes(request): return request.param @pytest.fixture def adata(sizes): import dask.array as da import numpy as np (M, N), chunks = sizes X = da.random.random((M, N), chunks=chunks) obs = pd.DataFrame( {"batch": np.random.choice(["a", "b"], M)}, index=[f"cell{i:03d}" for i in range(M)], ) var = pd.DataFrame(index=[f"gene{i:03d}" for i in range(N)]) return AnnData(X, obs=obs, var=var) def test_dask_X_view(): import dask.array as da M, N = 50, 30 adata = ad.AnnData( obs=pd.DataFrame(index=[f"cell{i:02}" for i in range(M)]), var=pd.DataFrame(index=[f"gene{i:02}" for i in range(N)]), ) adata.X = da.ones((M, N)) view = adata[:30] view.copy() def test_dask_write(adata, tmp_path, diskfmt): import dask.array as da import numpy as np pth = tmp_path / f"test_write.{diskfmt}" write = lambda x, y: getattr(x, f"write_{diskfmt}")(y) read = lambda x: getattr(ad, f"read_{diskfmt}")(x) M, N = adata.X.shape adata.obsm["a"] = da.random.random((M, 10)) adata.obsm["b"] = da.random.random((M, 10)) adata.varm["a"] = da.random.random((N, 10)) orig = adata write(orig, pth) curr = read(pth) with pytest.raises(AssertionError): assert_equal(curr.obsm["a"], curr.obsm["b"]) assert_equal(curr.varm["a"], orig.varm["a"]) assert_equal(curr.obsm["a"], orig.obsm["a"]) assert isinstance(curr.X, np.ndarray) assert isinstance(curr.obsm["a"], np.ndarray) assert isinstance(curr.varm["a"], np.ndarray) assert isinstance(orig.X, DaskArray) assert isinstance(orig.obsm["a"], DaskArray) assert isinstance(orig.varm["a"], DaskArray) @pytest.mark.xdist_group("dask") @pytest.mark.dask_distributed @pytest.mark.parametrize( "auto_shard_zarr_v3", [pytest.param(True, id="shard"), pytest.param(False, id="no-shard")], ) def test_dask_distributed_write( adata: AnnData, tmp_path: Path, diskfmt: Literal["h5ad", "zarr"], local_cluster_addr: str, *, auto_shard_zarr_v3: bool, ) -> None: if auto_shard_zarr_v3 and ad.settings.zarr_write_format == 2: pytest.skip(reason="Cannot shard v2 data") import dask.array as da import dask.distributed as dd import numpy as np pth = tmp_path / f"test_write.{diskfmt}" with as_group(pth, mode="w") as g, dd.Client(local_cluster_addr): M, N = adata.X.shape adata.obsm["a"] = da.random.random((M, 10)) adata.obsm["b"] = da.random.random((M, 10)) adata.varm["a"] = da.random.random((N, 10)) orig = adata with ad.settings.override(auto_shard_zarr_v3=auto_shard_zarr_v3): ad.io.write_elem(g, "", orig) # TODO: See https://github.com/zarr-developers/zarr-python/issues/2716 with as_group(pth, mode="r") as g: if auto_shard_zarr_v3: check_all_sharded(g) curr = ad.io.read_elem(g) with pytest.raises(AssertionError): assert_equal(curr.obsm["a"], curr.obsm["b"]) assert_equal(curr.varm["a"], orig.varm["a"]) assert_equal(curr.obsm["a"], orig.obsm["a"]) assert_equal(curr.X, orig.X) assert isinstance(curr.X, np.ndarray) assert isinstance(curr.obsm["a"], np.ndarray) assert isinstance(curr.varm["a"], np.ndarray) assert isinstance(orig.X, DaskArray) assert isinstance(orig.obsm["a"], DaskArray) assert isinstance(orig.varm["a"], DaskArray) def test_dask_to_memory_check_array_types(adata, tmp_path, diskfmt): import dask.array as da import numpy as np pth = tmp_path / f"test_write.{diskfmt}" write = lambda x, y: getattr(x, f"write_{diskfmt}")(y) read = lambda x: getattr(ad, f"read_{diskfmt}")(x) M, N = adata.X.shape adata.obsm["a"] = da.random.random((M, 10)) adata.obsm["b"] = da.random.random((M, 10)) adata.varm["a"] = da.random.random((N, 10)) orig = adata write(orig, pth) curr = read(pth) assert isinstance(orig.X, DaskArray) assert isinstance(orig.obsm["a"], DaskArray) assert isinstance(orig.varm["a"], DaskArray) mem = orig.to_memory() with pytest.raises(AssertionError): assert_equal(curr.obsm["a"], curr.obsm["b"]) assert_equal(curr.varm["a"], orig.varm["a"]) assert_equal(curr.obsm["a"], orig.obsm["a"]) assert_equal(mem.obsm["a"], orig.obsm["a"]) assert_equal(mem.varm["a"], orig.varm["a"]) assert isinstance(curr.X, np.ndarray) assert isinstance(curr.obsm["a"], np.ndarray) assert isinstance(curr.varm["a"], np.ndarray) assert isinstance(mem.X, np.ndarray) assert isinstance(mem.obsm["a"], np.ndarray) assert isinstance(mem.varm["a"], np.ndarray) assert isinstance(orig.X, DaskArray) assert isinstance(orig.obsm["a"], DaskArray) assert isinstance(orig.varm["a"], DaskArray) def test_dask_to_memory_copy_check_array_types(adata, tmp_path, diskfmt): import dask.array as da import numpy as np pth = tmp_path / f"test_write.{diskfmt}" write = lambda x, y: getattr(x, f"write_{diskfmt}")(y) read = lambda x: getattr(ad, f"read_{diskfmt}")(x) M, N = adata.X.shape adata.obsm["a"] = da.random.random((M, 10)) adata.obsm["b"] = da.random.random((M, 10)) adata.varm["a"] = da.random.random((N, 10)) orig = adata write(orig, pth) curr = read(pth) mem = orig.to_memory(copy=True) with pytest.raises(AssertionError): assert_equal(curr.obsm["a"], curr.obsm["b"]) assert_equal(curr.varm["a"], orig.varm["a"]) assert_equal(curr.obsm["a"], orig.obsm["a"]) assert_equal(mem.obsm["a"], orig.obsm["a"]) assert_equal(mem.varm["a"], orig.varm["a"]) assert isinstance(curr.X, np.ndarray) assert isinstance(curr.obsm["a"], np.ndarray) assert isinstance(curr.varm["a"], np.ndarray) assert isinstance(mem.X, np.ndarray) assert isinstance(mem.obsm["a"], np.ndarray) assert isinstance(mem.varm["a"], np.ndarray) assert isinstance(orig.X, DaskArray) assert isinstance(orig.obsm["a"], DaskArray) assert isinstance(orig.varm["a"], DaskArray) def test_dask_copy_check_array_types(adata): import dask.array as da M, N = adata.X.shape adata.obsm["a"] = da.random.random((M, 10)) adata.obsm["b"] = da.random.random((M, 10)) adata.varm["a"] = da.random.random((N, 10)) orig = adata curr = adata.copy() with pytest.raises(AssertionError): assert_equal(curr.obsm["a"], curr.obsm["b"]) assert_equal(curr.varm["a"], orig.varm["a"]) assert_equal(curr.obsm["a"], orig.obsm["a"]) assert isinstance(curr.X, DaskArray) assert isinstance(curr.obsm["a"], DaskArray) assert isinstance(curr.varm["a"], DaskArray) assert isinstance(orig.X, DaskArray) assert isinstance(orig.obsm["a"], DaskArray) assert isinstance(orig.varm["a"], DaskArray) def test_assign_X(adata): """Check if assignment works""" import dask.array as da import numpy as np from anndata.compat import DaskArray adata.X = da.ones(adata.X.shape) prev_type = type(adata.X) adata_copy = adata.copy() adata.X = -1 * da.ones(adata.X.shape) assert prev_type is DaskArray assert type(adata_copy.X) is DaskArray assert_equal(adata.X, -1 * np.ones(adata.X.shape)) assert_equal(adata_copy.X, np.ones(adata.X.shape)) # Test if dask arrays turn into numpy arrays after to_memory is called @pytest.mark.parametrize( ("array_func", "mem_type"), [ pytest.param(as_dense_dask_array, np.ndarray, id="dense_dask_array"), pytest.param(as_sparse_dask_matrix, sparse.csr_matrix, id="sparse_dask_matrix"), pytest.param( as_dense_cupy_dask_array, CupyArray, id="cupy_dense_dask_array", marks=pytest.mark.gpu, ), ], ) def test_dask_to_memory_unbacked(array_func, mem_type): orig = gen_adata((15, 10), X_type=array_func, **GEN_ADATA_DASK_ARGS) orig.uns = {"da": {"da": array_func(np.ones((4, 12)))}} assert isinstance(orig.X, DaskArray) assert isinstance(orig.obsm["da"], DaskArray) assert isinstance(orig.layers["da"], DaskArray) assert isinstance(orig.varm["da"], DaskArray) assert isinstance(orig.uns["da"]["da"], DaskArray) curr = orig.to_memory() assert_equal(orig, curr) assert isinstance(curr.X, mem_type) assert isinstance(curr.obsm["da"], np.ndarray) assert isinstance(curr.varm["da"], np.ndarray) assert isinstance(curr.layers["da"], np.ndarray) assert isinstance(curr.uns["da"]["da"], mem_type) assert isinstance(orig.X, DaskArray) assert isinstance(orig.obsm["da"], DaskArray) assert isinstance(orig.layers["da"], DaskArray) assert isinstance(orig.varm["da"], DaskArray) assert isinstance(orig.uns["da"]["da"], DaskArray) @pytest.mark.parametrize( "array_func", [ pytest.param(as_dense_dask_array, id="dense_dask_array"), pytest.param(as_sparse_dask_matrix, id="sparse_dask_matrix"), pytest.param( as_dense_cupy_dask_array, id="cupy_dense_dask_array", marks=pytest.mark.gpu, ), pytest.param( as_cupy_sparse_dask_array, id="cupy_sparse_dask_array", marks=pytest.mark.gpu, ), ], ) def test_dask_to_disk_view(array_func, diskfmt, tmp_path): random_state = np.random.default_rng() orig = ad.AnnData( # need to change type for cupy array_func(random_state.binomial(100, 0.005, (20, 15)).astype("float32")) ) orig = orig[orig.shape[0] // 2] path = tmp_path / f"test.{diskfmt}" getattr(orig, f"write_{diskfmt}")(path) roundtrip = getattr(ad.io, f"read_{diskfmt}")(path) assert_equal(roundtrip, orig) # Test if dask arrays turn into numpy arrays after to_memory is called def test_dask_to_memory_copy_unbacked(): import numpy as np orig = gen_adata((15, 10), X_type=as_dense_dask_array, **GEN_ADATA_DASK_ARGS) orig.uns = {"da": {"da": as_dense_dask_array(np.ones(12))}} curr = orig.to_memory(copy=True) assert_equal(orig, curr) assert isinstance(curr.X, np.ndarray) assert isinstance(curr.obsm["da"], np.ndarray) assert isinstance(curr.varm["da"], np.ndarray) assert isinstance(curr.layers["da"], np.ndarray) assert isinstance(curr.uns["da"]["da"], np.ndarray) assert isinstance(orig.X, DaskArray) assert isinstance(orig.obsm["da"], DaskArray) assert isinstance(orig.layers["da"], DaskArray) assert isinstance(orig.varm["da"], DaskArray) assert isinstance(orig.uns["da"]["da"], DaskArray) def test_to_memory_raw(): import dask.array as da import numpy as np orig = gen_adata((20, 10), **GEN_ADATA_DASK_ARGS) orig.X = da.ones((20, 10)) with_raw = orig[:, ::2].copy() with_raw.raw = orig.copy() assert isinstance(with_raw.raw.X, DaskArray) assert isinstance(with_raw.raw.varm["da"], DaskArray) curr = with_raw.to_memory() assert isinstance(with_raw.raw.X, DaskArray) assert isinstance(with_raw.raw.varm["da"], DaskArray) assert isinstance(curr.raw.X, np.ndarray) assert isinstance(curr.raw.varm["da"], np.ndarray) def test_to_memory_copy_raw(): import dask.array as da import numpy as np orig = gen_adata((20, 10), **GEN_ADATA_DASK_ARGS) orig.X = da.ones((20, 10)) with_raw = orig[:, ::2].copy() with_raw.raw = orig.copy() assert isinstance(with_raw.raw.X, DaskArray) assert isinstance(with_raw.raw.varm["da"], DaskArray) curr = with_raw.to_memory(copy=True) assert isinstance(with_raw.raw.X, DaskArray) assert isinstance(with_raw.raw.varm["da"], DaskArray) assert isinstance(curr.raw.X, np.ndarray) assert isinstance(curr.raw.varm["da"], np.ndarray) scverse-anndata-b796d59/tests/test_dask_view_mem.py000066400000000000000000000114301512025555600225350ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import pytest import anndata as ad if TYPE_CHECKING: import pandas as pd pytest.importorskip("pytest_memray") # ------------------------------------------------------------------------------ # Some test data # ------------------------------------------------------------------------------ @pytest.fixture(params=["layers", "obsm", "varm"]) def mapping_name(request): return request.param @pytest.fixture(params=["obs", "var"]) def attr_name(request): return request.param @pytest.fixture(params=[True, False]) def give_chunks(request): return request.param # ------------------------------------------------------------------------------ # The test functions # ------------------------------------------------------------------------------ # Does some stuff so that dask can cache the # subclasscheck before the run. @pytest.fixture def _alloc_cache(): import dask.array as da N = 2**6 size = ((N, N), (N, N)) adata = ad.AnnData( da.random.random(*size), layers=dict(m=da.random.random(*size)), obsm=dict(m=da.random.random(*size)), obs=dict(m=da.random.random(N)), var=dict(m=da.random.random(N)), varm=dict(m=da.random.random(*size)), ) subset = adata[:10, :][:, :10] for mn in ["varm", "obsm", "layers"]: m = getattr(subset, mn)["m"] m[0, 0] = 100 _ = adata.to_memory(copy=False) # Theoretically this is expected to allocate: # N*N*4 bytes per matrix (we have 2). # N*4 bytes per index (we have 1). # N*N*(2**3) + N*(2**2) bytes # N*N*(2**3) + N*(2**2) bytes # 2**19 + 2**10 # if we put a 2 factor on 2**19 # the results seems more accurate with the experimental results # For example from dask.random we allocate 1mb # As of 2025.09.* dask, this needs a bit more than the previous 1.5mb. # TODO: Why? @pytest.mark.usefixtures("_alloc_cache") @pytest.mark.limit_memory("2.2 MB") def test_size_of_view(mapping_name, give_chunks): import dask.array as da N = 2**8 size = ((N, N), (N, N)) if give_chunks else ((N, N), "auto") adata = ad.AnnData( da.random.random(*size), **{mapping_name: dict(m=da.random.random(*size))}, ) _ = adata.to_memory(copy=False) # Normally should expect something around 90 kbs # Pandas does some indexing stuff that requires more sometimes # since the array we allocated would be 4mb for both arrays + 2mb # Thus, if we allocated it all it should at least have 6mb # experimentally we should at least have 10mb # for index this should be ok @pytest.mark.usefixtures("_alloc_cache") @pytest.mark.limit_memory("1.5 MB") def test_modify_view_mapping_component_memory(mapping_name, give_chunks): import dask.array as da N = 2**8 M = 2**9 size = ((M, M), (M, M)) if give_chunks else ((M, M), "auto") adata = ad.AnnData( da.random.random(*size), **{mapping_name: dict(m=da.random.random(*size))}, ) subset = adata[:N, :N] assert subset.is_view m = getattr(subset, mapping_name)["m"] m[0, 0] = 100 # Normally should expect something around 90 kbs # Pandas does some indexing stuff that requires more sometimes # since the array we allocated would be 4mb for both arrays + 2mb # Thus, if we allocated it all it should at least have 6mb # experimentally we should at least have 10mb # for index this should be ok @pytest.mark.usefixtures("_alloc_cache") @pytest.mark.limit_memory("1.5 MB") def test_modify_view_X_memory(mapping_name, give_chunks): import dask.array as da N = 2**8 M = 2**9 size = ((M, M), (M, M)) if give_chunks else ((M, M), "auto") adata = ad.AnnData( da.random.random(*size), **{mapping_name: dict(m=da.random.random(*size))}, ) subset = adata[:N, :N] assert subset.is_view m = subset.X with pytest.warns( ad.ImplicitModificationWarning, match=r"Trying to modify attribute `.X` of view, initializing view as actual.", ): m[0, 0] = 100 # Normally should expect something around 90 kbs # Pandas does some indexing stuff that requires more sometimes # since the array we allocated would be 4mb for both arrays + 2mb # Thus, if we allocated it all it should at least have 6mb # experimentally we should at least have 10mb # for index this should be ok @pytest.mark.usefixtures("_alloc_cache") @pytest.mark.limit_memory("1.5 MB") def test_modify_view_mapping_obs_var_memory(attr_name, give_chunks): import dask.array as da N = 2**8 M = 2**9 size = ((M, M), (M, M)) if give_chunks else ((M, M), "auto") adata = ad.AnnData( da.random.random(*size), **{attr_name: dict(m=da.random.random(M))}, ) subset = adata[:N, :N] assert subset.is_view m: pd.Series = getattr(subset, attr_name)["m"] m.iloc[0] = 100 scverse-anndata-b796d59/tests/test_deprecations.py000066400000000000000000000102761512025555600224120ustar00rootroot00000000000000"""\ This file contains tests for deprecated functions. This includes correct behaviour as well as throwing warnings. """ from __future__ import annotations import warnings import h5py import numpy as np import pytest from scipy import sparse import anndata.experimental from anndata import AnnData from anndata.tests.helpers import assert_equal @pytest.fixture def adata(): adata = AnnData( X=sparse.csr_matrix([[0, 2, 3], [0, 5, 6]], dtype=np.float32), obs=dict(obs_names=["s1", "s2"], anno1=["c1", "c2"]), var=dict(var_names=["a", "b", "c"]), ) adata.raw = adata.copy() adata.layers["x2"] = adata.X * 2 adata.var["anno2"] = ["p1", "p2", "p3"] adata.X = adata.X / 2 return adata def test_get_obsvar_array_warn(adata): with pytest.warns(FutureWarning): adata._get_obs_array("a") with pytest.warns(FutureWarning): adata._get_var_array("s1") @pytest.mark.filterwarnings("ignore::FutureWarning") def test_get_obsvar_array(adata): assert np.allclose(adata._get_obs_array("a"), adata.obs_vector("a")) assert np.allclose( adata._get_obs_array("a", layer="x2"), adata.obs_vector("a", layer="x2"), ) assert np.allclose( adata._get_obs_array("a", use_raw=True), adata.raw.obs_vector("a") ) assert np.allclose(adata._get_var_array("s1"), adata.var_vector("s1")) assert np.allclose( adata._get_var_array("s1", layer="x2"), adata.var_vector("s1", layer="x2"), ) assert np.allclose( adata._get_var_array("s1", use_raw=True), adata.raw.var_vector("s1") ) def test_obsvar_vector_Xlayer(adata): with pytest.warns(FutureWarning): adata.var_vector("s1", layer="X") with pytest.warns(FutureWarning): adata.obs_vector("a", layer="X") adata = adata.copy() adata.layers["X"] = adata.X * 3 with warnings.catch_warnings(): warnings.simplefilter("error") adata.var_vector("s1", layer="X") adata.obs_vector("a", layer="X") # This should break in 0.9 def test_dtype_warning(): # Tests a warning is thrown with pytest.warns(FutureWarning): a = AnnData(np.ones((3, 3)), dtype=np.float32) assert a.X.dtype == np.float32 # This shouldn't warn, shouldn't copy with warnings.catch_warnings(record=True) as record: b_X = np.ones((3, 3), dtype=np.float64) b = AnnData(b_X) assert not record assert b_X is b.X assert b.X.dtype == np.float64 # Should warn, should copy c_X = np.ones((3, 3), dtype=np.float32) with pytest.warns(FutureWarning): c = AnnData(c_X, dtype=np.float64) assert not record assert c_X is not c.X assert c.X.dtype == np.float64 def test_deprecated_write_attribute(tmp_path): pth = tmp_path / "file.h5" A = np.random.randn(20, 10) from anndata._io.utils import read_attribute, write_attribute from anndata.io import read_elem with h5py.File(pth, "w") as f, pytest.warns(FutureWarning, match=r"write_elem"): write_attribute(f, "written_attribute", A) with h5py.File(pth, "r") as f: elem_A = read_elem(f["written_attribute"]) with pytest.warns(FutureWarning, match=r"read_elem"): attribute_A = read_attribute(f["written_attribute"]) assert_equal(elem_A, attribute_A) assert_equal(A, attribute_A) @pytest.mark.parametrize( ("old_name", "new_name", "module"), ( (old_name, new_name, module) for module in [anndata, anndata.experimental] for (old_name, new_name) in module._DEPRECATED.items() ), ) def test_warn_on_import_with_redirect(old_name: str, new_name: str, module): with pytest.warns(FutureWarning, match=rf"Importing {old_name}.*is deprecated"): getattr(module, old_name) def test_warn_on_deprecated__io_module(): with pytest.warns( FutureWarning, match=r"Importing read_h5ad from `anndata._io` is deprecated" ): from anndata._io import read_h5ad # noqa @pytest.mark.parametrize("name", ["obs", "var", "obsm", "varm", "uns"]) def test_keys_function_warns(adata: AnnData, name) -> None: with pytest.warns(FutureWarning, match=rf"{name}_keys is deprecated"): getattr(adata, f"{name}_keys")() scverse-anndata-b796d59/tests/test_extensions.py000066400000000000000000000164171512025555600221340ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING import numpy as np import pytest import anndata as ad from anndata._core import extensions if TYPE_CHECKING: from collections.abc import Generator @pytest.fixture(autouse=True) def _cleanup_dummy() -> Generator[None, None, None]: """Automatically cleanup dummy namespace after each test.""" original = getattr(ad.AnnData, "dummy", None) yield if original is not None: ad.AnnData.dummy = original elif hasattr(ad.AnnData, "dummy"): delattr(ad.AnnData, "dummy") @pytest.fixture def dummy_namespace() -> type: """Create a basic dummy namespace class.""" ad.AnnData._accessors = set() @ad.register_anndata_namespace("dummy") class DummyNamespace: def __init__(self, adata: ad.AnnData) -> None: self._adata = adata def greet(self) -> str: return "hello" return DummyNamespace @pytest.fixture def adata() -> ad.AnnData: """Create a basic AnnData object for testing.""" rng = np.random.default_rng(42) return ad.AnnData(X=rng.poisson(1, size=(10, 10))) def test_find_stacklevel() -> None: """Test that find_stacklevel returns a positive integer. This function helps determine the correct stacklevel for warnings, so we just need to verify it returns a sensible value. """ level = extensions.find_stacklevel() assert isinstance(level, int) # It should be at least 1, otherwise something is wrong. assert level > 0 def test_accessor_namespace() -> None: """Test the behavior of the AccessorNameSpace descriptor. This test verifies that: - When accessed at the class level (i.e., without an instance), the descriptor returns the namespace type. - When accessed via an instance, the descriptor instantiates the namespace, passing the instance to its constructor. - The instantiated namespace is then cached on the instance such that subsequent accesses of the same attribute return the cached namespace instance. """ # Define a dummy namespace class to be used via the descriptor. class DummyNamespace: def __init__(self, adata: ad.AnnData) -> None: self._adata = adata def foo(self) -> str: return "foo" class Dummy: pass descriptor = extensions.AccessorNameSpace("dummy", DummyNamespace) # When accessed on the class, it should return the namespace type. ns_class = descriptor.__get__(None, Dummy) assert ns_class is DummyNamespace # When accessed via an instance, it should instantiate DummyNamespace. dummy_obj = Dummy() ns_instance = descriptor.__get__(dummy_obj, Dummy) assert isinstance(ns_instance, DummyNamespace) assert ns_instance._adata is dummy_obj # __get__ should cache the namespace instance on the object. # Subsequent access should return the same cached instance. assert dummy_obj.dummy is ns_instance def test_descriptor_instance_caching(dummy_namespace: type, adata: ad.AnnData) -> None: """Test that namespace instances are cached on individual AnnData objects.""" # First access creates the instance ns_instance = adata.dummy # Subsequent accesses should return the same instance assert adata.dummy is ns_instance def test_register_namespace_basic(dummy_namespace: type, adata: ad.AnnData) -> None: """Test basic namespace registration and access.""" assert adata.dummy.greet() == "hello" def test_register_namespace_override(dummy_namespace: type) -> None: """Test namespace registration and override behavior.""" assert "dummy" in ad.AnnData._accessors # Override should warn and update the namespace with pytest.warns( UserWarning, match="Overriding existing custom namespace 'dummy'" ): @ad.register_anndata_namespace("dummy") class DummyNamespaceOverride: def __init__(self, adata: ad.AnnData) -> None: self._adata = adata def greet(self) -> str: return "world" # Verify the override worked adata = ad.AnnData(X=np.random.poisson(1, size=(10, 10))) assert adata.dummy.greet() == "world" @pytest.mark.parametrize( "attr", ["X", "obs", "var", "uns", "obsm", "varm", "layers", "copy", "write"], ) def test_register_existing_attributes(attr: str) -> None: """ Test that registering an accessor with a name that is a reserved attribute of AnnData raises an attribute error. We only test a representative sample of important attributes rather than all of them. """ # Test a representative sample of key AnnData attributes with pytest.raises( AttributeError, match=f"cannot override reserved attribute {attr!r}", ): @ad.register_anndata_namespace(attr) class DummyNamespace: def __init__(self, adata: ad.AnnData) -> None: self._adata = adata def test_valid_signature() -> None: """Test that a namespace with valid signature is accepted.""" @ad.register_anndata_namespace("valid") class ValidNamespace: def __init__(self, adata: ad.AnnData) -> None: self.adata = adata def test_missing_param() -> None: """Test that a namespace missing the second parameter is rejected.""" with pytest.raises( TypeError, match=r"Namespace initializer must accept an AnnData instance as the second parameter\.", ): @ad.register_anndata_namespace("missing_param") class MissingParamNamespace: def __init__(self) -> None: pass def test_wrong_name() -> None: """Test that a namespace with wrong parameter name is rejected.""" with pytest.raises( TypeError, match=r"Namespace initializer's second parameter must be named 'adata', got 'notadata'\.", ): @ad.register_anndata_namespace("wrong_name") class WrongNameNamespace: def __init__(self, notadata: ad.AnnData) -> None: self.notadata = notadata def test_wrong_annotation() -> None: """Test that a namespace with wrong parameter annotation is rejected.""" with pytest.raises( TypeError, match=r"Namespace initializer's second parameter must be annotated as the 'AnnData' class, got 'int'\.", ): @ad.register_anndata_namespace("wrong_annotation") class WrongAnnotationNamespace: def __init__(self, adata: int) -> None: self.adata = adata def test_missing_annotation() -> None: """Test that a namespace with missing parameter annotation is rejected.""" with pytest.raises(AttributeError): @ad.register_anndata_namespace("missing_annotation") class MissingAnnotationNamespace: def __init__(self, adata) -> None: self.adata = adata def test_both_wrong() -> None: """Test that a namespace with both wrong name and annotation is rejected.""" with pytest.raises( TypeError, match=( r"Namespace initializer's second parameter must be named 'adata', got 'info'\. " r"And must be annotated as 'AnnData', got 'str'\." ), ): @ad.register_anndata_namespace("both_wrong") class BothWrongNamespace: def __init__(self, info: str) -> None: self.info = info scverse-anndata-b796d59/tests/test_get_vector.py000066400000000000000000000044701512025555600220720ustar00rootroot00000000000000from __future__ import annotations import numpy as np import pandas as pd import pytest from scipy import sparse import anndata as ad def test_amgibuous_keys(): """Tests that an error is raised if obs_vector or var_vector is ambiguous.""" var_keys = ["The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"] obs_keys = [ "Lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit", ] adata = ad.AnnData( X=sparse.random(len(obs_keys), len(var_keys), format="csr"), layers={"layer": sparse.random(len(obs_keys), len(var_keys), format="csr")}, obs=pd.DataFrame( np.random.randn(len(obs_keys), len(obs_keys) + len(var_keys)), index=obs_keys, columns=obs_keys + var_keys, ), var=pd.DataFrame( np.random.randn(len(var_keys), len(obs_keys) + len(var_keys)), index=var_keys, columns=var_keys + obs_keys, ), ) adata.raw = adata.copy() for k in var_keys: # These are mostly to check that the test is working assert k in adata.var_names assert k in adata.obs.columns # Now the actual checks: with pytest.raises(ValueError, match=r".*var_names.*obs\.columns.*"): adata.obs_vector(k) with pytest.raises(ValueError, match=r".*var_names.*obs\.columns.*"): adata.obs_vector(k, layer="layer") # Should uniquely select column from in adata.var assert list(adata.var[k]) == list(adata.var_vector(k)) assert list(adata.var[k]) == list(adata.var_vector(k, layer="layer")) assert list(adata.raw.var[k]) == list(adata.raw.var_vector(k)) for k in obs_keys: assert k in adata.obs_names assert k in adata.var.columns with pytest.raises(ValueError, match=r".*obs_names.*var\.columns"): adata.var_vector(k) with pytest.raises(ValueError, match=r".*obs_names.*var\.columns"): adata.var_vector(k, layer="layer") assert list(adata.obs[k]) == list(adata.obs_vector(k)) assert list(adata.obs[k]) == list(adata.obs_vector(k, layer="layer")) with pytest.raises(ValueError, match=r".*obs_names.*var\.columns*"): adata.raw.var_vector(k) scverse-anndata-b796d59/tests/test_gpu.py000066400000000000000000000015731512025555600205250ustar00rootroot00000000000000from __future__ import annotations import pytest from scipy import sparse from anndata import AnnData, Raw @pytest.mark.gpu def test_gpu(): """ For testing that the gpu mark works """ import cupy # This test shouldn't run if cupy isn't installed cupy.ones(1) @pytest.mark.gpu def test_adata_raw_gpu(): import cupy as cp from cupyx.scipy import sparse as cupy_sparse adata = AnnData( X=cupy_sparse.random(500, 50, density=0.01, format="csr", dtype=cp.float32) ) adata.raw = adata.copy() assert isinstance(adata.raw.X, sparse.csr_matrix) @pytest.mark.gpu def test_raw_gpu(): import cupy as cp from cupyx.scipy import sparse as cupy_sparse adata = AnnData( X=cupy_sparse.random(500, 50, density=0.01, format="csr", dtype=cp.float32) ) araw = Raw(adata) assert isinstance(araw.X, sparse.csr_matrix) scverse-anndata-b796d59/tests/test_helpers.py000066400000000000000000000241571512025555600213770ustar00rootroot00000000000000from __future__ import annotations from string import ascii_letters import numpy as np import pandas as pd import pytest from scipy import sparse import anndata as ad from anndata.compat import CupyArray, CupyCSRMatrix, DaskArray from anndata.tests.helpers import ( BASE_MATRIX_PARAMS, CUPY_MATRIX_PARAMS, DASK_MATRIX_PARAMS, DEFAULT_COL_TYPES, as_cupy, as_cupy_sparse_dask_array, as_dense_cupy_dask_array, as_dense_dask_array, asarray, assert_equal, gen_adata, gen_awkward, gen_random_column, issubdtype, report_name, ) from anndata.utils import axis_len # Testing to see if all error types can have the key name appended. # Currently fails for 22/118 since they have required arguments. Not sure what to do about that. # # @singledispatch # def iswarning(x): # return iswarning(type(x)) # @iswarning.register(type) # def _notwarning(x): # return False # @iswarning.register(Warning) # def _iswarning(x): # return True # @pytest.mark.parametrize("exception", list(filter(lambda t: not iswarning(t), Exception.__subclasses__()))) # def test_report_name_types(exception): # def throw(e): # raise e() # tag = "".join(np.random.permutation(list(ascii_letters))) # with pytest.raises(exception) as err: # report_name(throw)(exception, _elem_name=tag) # assert tag in str(err.value) @pytest.fixture def reusable_adata(): """Reusable anndata for when tests shouldn’t mutate it""" return gen_adata((10, 10)) @pytest.mark.parametrize( ("shape", "datashape"), [ ((4, 2), "4 * 2 * int32"), ((100, 200, None), "100 * 200 * var * int32"), ((4, None), "4 * var * int32"), ((0, 4), "0 * 4 * int32"), ((4, 0), "4 * 0 * int32"), ((8, None, None), "8 * var * var * int32"), ((8, None, None, None), "8 * var * var * var * int32"), ((4, None, 8), "4 * var * 8 * int32"), ((100, 200, 4), "100 * 200 * 4 * int32"), ((4, 0, 0), "4 * 0 * 0 * int32"), ((0, 0, 0), "0 * 0 * 0 * int32"), ((0, None), "0 * var * int32"), ], ) def test_gen_awkward(shape, datashape): import awkward as ak arr = gen_awkward(shape) for i, s in enumerate(shape): assert axis_len(arr, i) == s arr_type = ak.types.from_datashape(datashape) assert arr.type == arr_type @pytest.mark.parametrize("dtype", [*DEFAULT_COL_TYPES, pd.StringDtype]) def test_gen_random_column(dtype): _, col = gen_random_column(10, dtype) assert len(col) == 10 # CategoricalDtypes are the only one specified as instances currently if isinstance(dtype, pd.CategoricalDtype): assert issubdtype(col.dtype, pd.CategoricalDtype) assert col.dtype.ordered == dtype.ordered else: assert issubdtype(col.dtype, dtype) # Does this work for every warning? def test_report_name(): def raise_error(): msg = "an error occurred!" raise Exception(msg) letters = np.array(list(ascii_letters)) tag = "".join(np.random.permutation(letters)) with pytest.raises(Exception, match=r"an error occurred!") as e1: raise_error() with pytest.raises(Exception, match=r"an error occurred!") as e2: report_name(raise_error)(_elem_name=tag) assert str(e2.value).startswith(str(e1.value)) assert tag in str(e2.value) def test_assert_equal(): # ndarrays assert_equal(np.ones((10, 10)), np.ones((10, 10))) assert_equal( # Should this require an exact test? np.ones((10, 10), dtype="i8"), np.ones((10, 10), dtype="f8") ) assert_equal( np.array(list(ascii_letters)), np.array(list(ascii_letters)), exact=True ) with pytest.raises(AssertionError): assert_equal(np.array(list(ascii_letters)), np.array(list(ascii_letters))[::-1]) adata = gen_adata((10, 10)) adata.raw = adata.copy() assert_equal(adata, adata.copy(), exact=True) # TODO: I’m not sure this is good behaviour, I’ve disabled in for now. # assert_equal( # adata, # adata[ # np.random.permutation(adata.obs_names), # np.random.permutation(adata.var_names), # ].copy(), # exact=False, # ) adata2 = adata.copy() to_modify = next(iter(adata2.layers.keys())) del adata2.layers[to_modify] with pytest.raises(AssertionError) as missing_layer_error: assert_equal(adata, adata2) assert "layers" in str(missing_layer_error.value) # `to_modify` will be in pytest info adata2 = adata.copy() adata2.layers[to_modify][0, 0] = adata2.layers[to_modify][0, 0] + 1 with pytest.raises(AssertionError) as changed_layer_error: assert_equal(adata, adata2) assert "layers" in str(changed_layer_error.value) assert to_modify in str(changed_layer_error.value) assert_equal(adata.obs, adata.obs.copy(), exact=True) csr = sparse.random(100, 100, format="csr") csc = csr.tocsc() dense = csr.toarray() assert_equal(csr, csc) assert_equal(csc, dense) assert_equal(dense, csc) unordered_cat = pd.Categorical(list("aabdcc"), ordered=False) ordered_cat = pd.Categorical(list("aabdcc"), ordered=True) assert_equal(unordered_cat, unordered_cat.copy()) assert_equal(ordered_cat, ordered_cat.copy()) assert_equal(ordered_cat, unordered_cat, exact=False) with pytest.raises(AssertionError): assert_equal(ordered_cat, unordered_cat, exact=True) def test_assert_equal_raw(): base = gen_adata((10, 10)) orig = base.copy() orig.raw = base.copy() mod = base.copy() mod.X[0, 0] = mod.X[0, 0] + 1 to_compare = base.copy() to_compare.raw = mod.copy() with pytest.raises(AssertionError): assert_equal(orig, to_compare) mod = base.copy() mod.var["new_val"] = 1 to_compare = base.copy() to_compare.raw = mod.copy() with pytest.raises(AssertionError): assert_equal(orig, to_compare) def test_assert_equal_raw_presence(): # This was causing some testing issues during # https://github.com/scverse/anndata/pull/542 a = gen_adata((10, 20)) b = a.copy() a.raw = a.copy() assert b.raw is None with pytest.raises(AssertionError): assert_equal(a, b) with pytest.raises(AssertionError): assert_equal(b, a) # TODO: Should views be equal to actual? # Should they not be if an exact comparison is made? def test_assert_equal_aligned_mapping(): adata1 = gen_adata((10, 10)) adata2 = adata1.copy() for attr in ["obsm", "varm", "layers", "obsp", "varp"]: assert_equal(getattr(adata1, attr), getattr(adata2, attr)) # Checking that subsetting other axis only changes some attrs obs_subset = adata2[:5, :] for attr in ["obsm", "layers", "obsp"]: with pytest.raises(AssertionError): assert_equal(getattr(adata1, attr), getattr(obs_subset, attr)) for attr in ["varm", "varp"]: assert_equal(getattr(adata1, attr), getattr(obs_subset, attr)) var_subset = adata2[:, 5:] for attr in ["varm", "layers", "varp"]: with pytest.raises(AssertionError): assert_equal(getattr(adata1, attr), getattr(var_subset, attr)) for attr in ["obsm", "obsp"]: assert_equal(getattr(adata1, attr), getattr(var_subset, attr)) def test_assert_equal_aligned_mapping_empty(): chars = np.array(list(ascii_letters)) adata = ad.AnnData( X=np.zeros((10, 10)), obs=pd.DataFrame([], index=np.random.choice(chars[:20], 10, replace=False)), var=pd.DataFrame([], index=np.random.choice(chars[:20], 10, replace=False)), ) diff_idx = ad.AnnData( X=np.zeros((10, 10)), obs=pd.DataFrame([], index=np.random.choice(chars[20:], 10, replace=False)), var=pd.DataFrame([], index=np.random.choice(chars[20:], 10, replace=False)), ) same_idx = ad.AnnData(adata.X, obs=adata.obs.copy(), var=adata.var.copy()) for attr in ["obsm", "varm", "layers", "obsp", "varp"]: with pytest.raises(AssertionError): assert_equal(getattr(adata, attr), getattr(diff_idx, attr)) assert_equal(getattr(adata, attr), getattr(same_idx, attr)) def test_assert_equal_dask_arrays(): import dask.array as da a = da.from_array([[1, 2, 3], [4, 5, 6]]) b = da.from_array([[1, 2, 3], [4, 5, 6]]) assert_equal(a, b) c = da.ones(10, dtype="int32") d = da.ones(10, dtype="int64") assert_equal(c, d) def test_assert_equal_dask_sparse_arrays(): import dask.array as da from scipy import sparse x = sparse.random(10, 10, format="csr", density=0.1) y = da.from_array(asarray(x)) assert_equal(x, y) assert_equal(y, x) @pytest.mark.parametrize( "input_type", BASE_MATRIX_PARAMS + DASK_MATRIX_PARAMS + CUPY_MATRIX_PARAMS ) @pytest.mark.parametrize( ( "as_dask_type", "mem_type", ), [ pytest.param( as_dense_cupy_dask_array, CupyArray, id="cupy_dense", marks=pytest.mark.gpu ), pytest.param(as_dense_dask_array, np.ndarray, id="numpy_dense"), pytest.param( as_cupy_sparse_dask_array, CupyCSRMatrix, id="cupy_csr", marks=pytest.mark.gpu, ), ], ) def test_as_dask_functions(input_type, as_dask_type, mem_type): SHAPE = (1000, 100) rng = np.random.default_rng(42) X_source = rng.poisson(size=SHAPE).astype(np.float32) X_input = input_type(X_source) X_output = as_dask_type(X_input) X_computed = X_output.compute() assert isinstance(X_output, DaskArray) assert X_output.shape == SHAPE assert X_output.dtype == X_input.dtype assert isinstance(X_computed, mem_type) assert_equal(asarray(X_computed), X_source) @pytest.mark.parametrize( "dask_matrix_type", DASK_MATRIX_PARAMS, ) @pytest.mark.gpu def test_as_cupy_dask(dask_matrix_type): SHAPE = (100, 10) rng = np.random.default_rng(42) X_cpu = dask_matrix_type(rng.normal(size=SHAPE)) X_gpu_roundtripped = as_cupy(X_cpu).map_blocks(lambda x: x.get(), meta=X_cpu._meta) assert isinstance(X_gpu_roundtripped._meta, type(X_cpu._meta)) assert isinstance(X_gpu_roundtripped.compute(), type(X_cpu.compute())) assert_equal(X_gpu_roundtripped.compute(), X_cpu.compute()) scverse-anndata-b796d59/tests/test_inplace_subset.py000066400000000000000000000057231512025555600227330ustar00rootroot00000000000000from __future__ import annotations import numpy as np import pytest from scipy import sparse from anndata.tests.helpers import ( as_dense_dask_array, assert_equal, gen_adata, ) from anndata.utils import asarray @pytest.fixture( params=[ np.array, sparse.csr_matrix, sparse.csc_matrix, sparse.csr_array, sparse.csc_array, as_dense_dask_array, ], ids=[ "np_array", "scipy_csr", "scipy_csc", "scipy_csr_array", "scipy_csc_array", "dask_array", ], ) def matrix_type(request): return request.param def subset_dim(adata, *, obs=slice(None), var=slice(None)): # Should probably get used for test_inplace_subset_var and test_inplace_subset_obs from anndata._core.index import _subset return _subset(adata, (obs, var)) # TODO: Test values of .uns def test_inplace_subset_var(matrix_type, subset_func): orig = gen_adata((30, 30), X_type=matrix_type) subset_idx = subset_func(orig.var_names) modified = orig.copy() from_view = orig[:, subset_idx].copy() modified._inplace_subset_var(subset_idx) assert_equal(asarray(from_view.X), asarray(modified.X), exact=True) assert_equal(from_view.obs, modified.obs, exact=True) assert_equal(from_view.var, modified.var, exact=True) for k in from_view.obsm: assert_equal(from_view.obsm[k], modified.obsm[k], exact=True) assert_equal(orig.obsm[k], modified.obsm[k], exact=True) for k in from_view.varm: assert_equal(from_view.varm[k], modified.varm[k], exact=True) for k in from_view.layers: assert_equal(from_view.layers[k], modified.layers[k], exact=True) def test_inplace_subset_obs(matrix_type, subset_func): orig = gen_adata((30, 30), X_type=matrix_type) subset_idx = subset_func(orig.obs_names) modified = orig.copy() from_view = orig[subset_idx, :].copy() modified._inplace_subset_obs(subset_idx) assert_equal(asarray(from_view.X), asarray(modified.X), exact=True) assert_equal(from_view.obs, modified.obs, exact=True) assert_equal(from_view.var, modified.var, exact=True) for k in from_view.obsm: assert_equal(from_view.obsm[k], modified.obsm[k], exact=True) for k in from_view.varm: assert_equal(from_view.varm[k], modified.varm[k], exact=True) assert_equal(orig.varm[k], modified.varm[k], exact=True) for k in from_view.layers: assert_equal(from_view.layers[k], modified.layers[k], exact=True) @pytest.mark.parametrize("dim", ["obs", "var"]) def test_inplace_subset_no_X(subset_func, dim): orig = gen_adata((30, 30)) del orig.X subset_idx = subset_func(getattr(orig, f"{dim}_names")) modified = orig.copy() # TODO: apart from this test, `_subset` is never called with strings, lists, … from_view = subset_dim(orig, **{dim: subset_idx}).copy() getattr(modified, f"_inplace_subset_{dim}")(subset_idx) assert_equal(modified, from_view, exact=True) scverse-anndata-b796d59/tests/test_io_backwards_compat.py000066400000000000000000000052131512025555600237200ustar00rootroot00000000000000from __future__ import annotations from pathlib import Path from subprocess import run from typing import TYPE_CHECKING import pandas as pd import pytest import zarr import zarr.storage from scipy import sparse import anndata as ad from anndata.compat import is_zarr_v2 from anndata.tests.helpers import assert_equal if TYPE_CHECKING: from typing import Literal ARCHIVE_PTH = Path(__file__).parent / "data/archives" @pytest.fixture(params=list(ARCHIVE_PTH.glob("v*")), ids=lambda x: x.name) def archive_dir(request: pytest.FixtureRequest) -> Path: assert isinstance(request.param, Path) if request.param.name == "v0.5.0": reason = "v0.5.0 has no zarr and very different format" request.applymarker(pytest.mark.xfail(reason=reason)) return request.param def read_archive( archive_dir: Path, format: Literal["h5ad", "zarr"] ) -> tuple[ad.AnnData, Path]: if format == "h5ad": path = archive_dir / "adata.h5ad" return ad.read_h5ad(path), path if format == "zarr": path = archive_dir / "adata.zarr.zip" store = path if is_zarr_v2() else zarr.storage.ZipStore(path) return ad.read_zarr(store), path pytest.fail(f"Unknown format: {format}") @pytest.mark.filterwarnings("ignore::anndata.OldFormatWarning") def test_backwards_compat_files(archive_dir: Path) -> None: from_h5ad, _ = read_archive(archive_dir, "h5ad") from_zarr, _ = read_archive(archive_dir, "zarr") assert_equal(from_h5ad, from_zarr, exact=True) def test_no_diff(tmp_path: Path, archive_dir: Path) -> None: if archive_dir.name in {"v0.7.8", "v0.7.0"}: pytest.skip("DataFrame encoding changed between 0.7 and now") adata, in_path = read_archive(archive_dir, "h5ad") with ad.settings.override(allow_write_nullable_strings=False): adata.write_h5ad(out_path := tmp_path / "adata.h5ad") diff_proc = run( ["h5diff", "-c", in_path, out_path], check=False, capture_output=True, text=True ) assert diff_proc.returncode == 0, diff_proc.stdout def test_clean_uns_backwards_compat(tmp_path, diskfmt): pth = tmp_path / f"test_write.{diskfmt}" write = lambda x, y: getattr(x, f"write_{diskfmt}")(y) read = lambda x: getattr(ad, f"read_{diskfmt}")(x) orig = ad.AnnData( sparse.csr_matrix((3, 5), dtype="float32"), obs=pd.DataFrame( {"a": pd.Categorical(list("aab")), "b": [1, 2, 3]}, index=[f"cell_{i}" for i in range(3)], ), uns={ "a_categories": "some string", "b_categories": "another string", }, ) write(orig, pth) from_disk = read(pth) assert_equal(orig, from_disk) scverse-anndata-b796d59/tests/test_io_conversion.py000066400000000000000000000077721512025555600226150ustar00rootroot00000000000000"""\ This file contains tests for conversion made during io. """ from __future__ import annotations import h5py import numpy as np import pytest from scipy import sparse import anndata as ad from anndata.compat import CSMatrix from anndata.tests.helpers import GEN_ADATA_NO_XARRAY_ARGS, assert_equal, gen_adata @pytest.fixture( params=[sparse.csr_matrix, sparse.csc_matrix, np.array], ids=["scipy-csr", "scipy-csc", "np-array"], ) def mtx_format(request): return request.param @pytest.fixture( params=[sparse.csr_matrix, sparse.csc_matrix], ids=["scipy-csr", "scipy-csc"], ) def spmtx_format(request): return request.param @pytest.fixture(params=[("raw/X",), ("X",), ("X", "raw/X")]) def to_convert(request): return request.param def test_sparse_to_dense_disk(tmp_path, mtx_format, to_convert): mem_pth = tmp_path / "orig.h5ad" dense_from_mem_pth = tmp_path / "dense_mem.h5ad" dense_from_disk_pth = tmp_path / "dense_disk.h5ad" mem = gen_adata((50, 50), mtx_format, **GEN_ADATA_NO_XARRAY_ARGS) mem.raw = mem.copy() mem.write_h5ad(mem_pth) disk = ad.read_h5ad(mem_pth, backed="r") mem.write_h5ad(dense_from_mem_pth, as_dense=to_convert) disk.write_h5ad(dense_from_disk_pth, as_dense=to_convert) with h5py.File(dense_from_mem_pth, "r") as f: for k in to_convert: assert isinstance(f[k], h5py.Dataset) with h5py.File(dense_from_disk_pth, "r") as f: for k in to_convert: assert isinstance(f[k], h5py.Dataset) for backed in [None, "r"]: from_mem = ad.read_h5ad(dense_from_mem_pth, backed=backed) from_disk = ad.read_h5ad(dense_from_disk_pth, backed=backed) assert_equal(mem, from_mem) assert_equal(mem, from_disk) assert_equal(disk, from_mem) assert_equal(disk, from_disk) def test_sparse_to_dense_inplace(tmp_path, spmtx_format): pth = tmp_path / "adata.h5ad" orig = gen_adata((50, 50), spmtx_format, **GEN_ADATA_NO_XARRAY_ARGS) orig.raw = orig.copy() orig.write(pth) backed = ad.read_h5ad(pth, backed="r+") backed.write(as_dense=("X", "raw/X")) new = ad.read_h5ad(pth) assert_equal(orig, new) assert_equal(backed, new) assert isinstance(new.X, np.ndarray) assert isinstance(new.raw.X, np.ndarray) assert isinstance(orig.X, spmtx_format) assert isinstance(orig.raw.X, spmtx_format) assert isinstance(backed.X, h5py.Dataset) assert isinstance(backed.raw.X, h5py.Dataset) def test_sparse_to_dense_errors(tmp_path): adata = ad.AnnData(X=sparse.random(50, 50, format="csr")) adata.layers["like_X"] = adata.X.copy() with pytest.raises(ValueError, match=r"Cannot specify writing"): adata.write_h5ad(tmp_path / "failure.h5ad", as_dense=("raw/X",)) with pytest.raises(NotImplementedError): adata.write_h5ad(tmp_path / "failure.h5ad", as_dense=("raw", "X")) with pytest.raises(NotImplementedError): adata.write_h5ad(tmp_path / "failure.h5ad", as_dense=("layers/like_X",)) def test_dense_to_sparse_memory(tmp_path, spmtx_format, to_convert): dense_path = tmp_path / "dense.h5ad" orig = gen_adata((50, 50), np.array, **GEN_ADATA_NO_XARRAY_ARGS) orig.raw = orig.copy() orig.write_h5ad(dense_path) assert not isinstance(orig.X, CSMatrix) assert not isinstance(orig.raw.X, CSMatrix) curr = ad.read_h5ad(dense_path, as_sparse=to_convert, as_sparse_fmt=spmtx_format) if "X" in to_convert: assert isinstance(curr.X, spmtx_format) if "raw/X" in to_convert: assert isinstance(curr.raw.X, spmtx_format) assert_equal(orig, curr) def test_dense_to_sparse_errors(tmp_path): dense_pth = tmp_path / "dense.h5ad" adata = ad.AnnData(X=np.ones((50, 50))) adata.layers["like_X"] = adata.X.copy() adata.write(dense_pth) with pytest.raises(NotImplementedError): ad.read_h5ad(dense_pth, as_sparse=("X",), as_sparse_fmt=sparse.coo_matrix) with pytest.raises(NotImplementedError): ad.read_h5ad(dense_pth, as_sparse=("layers/like_X",)) scverse-anndata-b796d59/tests/test_io_dispatched.py000066400000000000000000000166071512025555600225350ustar00rootroot00000000000000from __future__ import annotations import re from typing import TYPE_CHECKING import h5py import pytest import scipy.sparse as sp import zarr import anndata as ad from anndata._io.zarr import open_write_group from anndata.compat import CSArray, CSMatrix, ZarrGroup, is_zarr_v2 from anndata.experimental import read_dispatched, write_dispatched from anndata.tests.helpers import ( GEN_ADATA_NO_XARRAY_ARGS, assert_equal, gen_adata, visititems_zarr, ) if TYPE_CHECKING: from pathlib import Path from typing import Literal @pytest.mark.zarr_io def test_read_dispatched_w_regex(tmp_path: Path): def read_only_axis_dfs(func, elem_name: str, elem, iospec): if iospec.encoding_type == "anndata" or re.match( r"^/((obs)|(var))?(/.*)?$", elem_name ): return func(elem) else: return None adata = gen_adata((1000, 100), **GEN_ADATA_NO_XARRAY_ARGS) z = open_write_group(tmp_path) ad.io.write_elem(z, "/", adata) # TODO: see https://github.com/zarr-developers/zarr-python/issues/2716 if not is_zarr_v2() and isinstance(z, ZarrGroup): z = zarr.open(z.store) expected = ad.AnnData(obs=adata.obs, var=adata.var) actual = read_dispatched(z, read_only_axis_dfs) assert_equal(expected, actual) @pytest.mark.zarr_io def test_read_dispatched_dask(tmp_path: Path): import dask.array as da def read_as_dask_array(func, elem_name: str, elem, iospec): if iospec.encoding_type in { "dataframe", "csr_matrix", "csc_matrix", "awkward-array", }: # Preventing recursing inside of these types return ad.io.read_elem(elem) elif iospec.encoding_type == "array": return da.from_zarr(elem) else: return func(elem) adata = gen_adata((1000, 100), **GEN_ADATA_NO_XARRAY_ARGS) z = open_write_group(tmp_path) ad.io.write_elem(z, "/", adata) # TODO: see https://github.com/zarr-developers/zarr-python/issues/2716 if not is_zarr_v2() and isinstance(z, ZarrGroup): z = zarr.open(z.store) dask_adata = read_dispatched(z, read_as_dask_array) assert isinstance(dask_adata.layers["array"], da.Array) assert isinstance(dask_adata.obsm["array"], da.Array) assert isinstance(dask_adata.uns["nested"]["nested_further"]["array"], da.Array) expected = ad.io.read_elem(z) actual = dask_adata.to_memory(copy=False) assert_equal(expected, actual) @pytest.mark.zarr_io def test_read_dispatched_null_case(tmp_path: Path): adata = gen_adata((100, 100), **GEN_ADATA_NO_XARRAY_ARGS) z = open_write_group(tmp_path) ad.io.write_elem(z, "/", adata) # TODO: see https://github.com/zarr-developers/zarr-python/issues/2716 if not is_zarr_v2() and isinstance(z, ZarrGroup): z = zarr.open(z.store) expected = ad.io.read_elem(z) actual = read_dispatched(z, lambda _, __, x, **___: ad.io.read_elem(x)) assert_equal(expected, actual) @pytest.mark.zarr_io @pytest.mark.parametrize("sparse_format", ["csr", "csc"]) def test_write_dispatched_csr_dataset( tmp_path: Path, sparse_format: Literal["csr", "csc"] ): ad.io.write_elem( open_write_group(tmp_path / "arr.zarr"), "/", sp.random(10, 10, format=sparse_format), ) X = ad.io.sparse_dataset(zarr.open(tmp_path / "arr.zarr")) def zarr_writer(func, store, elem_name: str, elem, iospec, dataset_kwargs): assert iospec.encoding_type == f"{sparse_format}_matrix" write_dispatched(zarr.open(tmp_path / "check.zarr", mode="w"), "/X", X, zarr_writer) @pytest.mark.zarr_io def test_write_dispatched_chunks(tmp_path: Path): from itertools import chain, repeat def determine_chunks(elem_shape, specified_chunks): chunk_iterator = chain(specified_chunks, repeat(None)) return tuple( e if c is None else c for e, c in zip(elem_shape, chunk_iterator, strict=False) ) adata = gen_adata((100, 50), **GEN_ADATA_NO_XARRAY_ARGS) M, N = 13, 8 def write_chunked(func, store, k, elem, dataset_kwargs, iospec): def set_copy(d, **kwargs): d = dict(d) d.update(kwargs) return d # TODO: Should the passed path be absolute? path = "/" + store.path + "/" + k if hasattr(elem, "shape") and not isinstance( elem, CSMatrix | CSArray | ad.AnnData ): if re.match(r"^/((X)|(layers)).*", path): chunks = (M, N) elif path.startswith("/obsp"): chunks = (M, M) elif path.startswith("/obs"): chunks = (M,) elif path.startswith("/varp"): chunks = (N, N) elif path.startswith("/var"): chunks = (N,) else: chunks = dataset_kwargs.get("chunks", ()) func( store, k, elem, dataset_kwargs=set_copy( dataset_kwargs, chunks=determine_chunks(elem.shape, chunks) ), ) else: func(store, k, elem, dataset_kwargs=dataset_kwargs) z = open_write_group(tmp_path) write_dispatched(z, "/", adata, callback=write_chunked) def check_chunking(k: str, v: ZarrGroup | zarr.Array): if ( not isinstance(v, zarr.Array) or v.shape == () or any(k.endswith(x) for x in ("data", "indices", "indptr")) ): return if re.match(r"obs[mp]?/\w+", k): assert v.chunks[0] == M elif re.match(r"var[mp]?/\w+", k): assert v.chunks[0] == N if is_zarr_v2(): z.visititems(check_chunking) else: visititems_zarr(z, check_chunking) @pytest.mark.zarr_io def test_io_dispatched_keys(tmp_path: Path): h5ad_write_keys = [] zarr_write_keys = [] h5ad_read_keys = [] zarr_read_keys = [] h5ad_path = tmp_path / "test.h5ad" zarr_path = tmp_path / "test.zarr" def h5ad_writer(func, store, k, elem, dataset_kwargs, iospec): h5ad_write_keys.append(k if is_zarr_v2() else k.strip("/")) func(store, k, elem, dataset_kwargs=dataset_kwargs) def zarr_writer(func, store, k, elem, dataset_kwargs, iospec): zarr_write_keys.append( k if is_zarr_v2() else f"{store.name.strip('/')}/{k.strip('/')}".strip("/") ) func(store, k, elem, dataset_kwargs=dataset_kwargs) def h5ad_reader(func, elem_name: str, elem, iospec): h5ad_read_keys.append(elem_name if is_zarr_v2() else elem_name.strip("/")) return func(elem) def zarr_reader(func, elem_name: str, elem, iospec): zarr_read_keys.append(elem_name if is_zarr_v2() else elem_name.strip("/")) return func(elem) adata = gen_adata((50, 100), **GEN_ADATA_NO_XARRAY_ARGS) with h5py.File(h5ad_path, "w") as f: write_dispatched(f, "/", adata, callback=h5ad_writer) _ = read_dispatched(f, h5ad_reader) f = open_write_group(zarr_path) write_dispatched(f, "/", adata, callback=zarr_writer) _ = read_dispatched(f, zarr_reader) assert sorted(h5ad_read_keys) == sorted(zarr_read_keys) assert sorted(h5ad_write_keys) == sorted(zarr_write_keys) for sub_sparse_key in ["data", "indices", "indptr"]: assert f"/X/{sub_sparse_key}" not in h5ad_read_keys assert f"/X/{sub_sparse_key}" not in h5ad_write_keys scverse-anndata-b796d59/tests/test_io_elementwise.py000066400000000000000000000712601512025555600227420ustar00rootroot00000000000000""" Tests that each element in an anndata is written correctly """ from __future__ import annotations import re from contextlib import ExitStack, nullcontext from importlib.metadata import version from pathlib import Path from typing import TYPE_CHECKING import h5py import numpy as np import pandas as pd import pytest import zarr from packaging.version import Version from scipy import sparse import anndata as ad from anndata._io.specs import _REGISTRY, IOSpec, get_spec from anndata._io.specs.registry import IORegistryError from anndata._io.zarr import open_write_group from anndata.compat import CSArray, CSMatrix, ZarrGroup, _read_attr, is_zarr_v2 from anndata.experimental import read_elem_lazy from anndata.io import read_elem, write_elem from anndata.tests.helpers import ( GEN_ADATA_NO_XARRAY_ARGS, as_cupy, as_cupy_sparse_dask_array, as_dense_cupy_dask_array, assert_equal, check_all_sharded, gen_adata, visititems_zarr, ) if TYPE_CHECKING: from collections.abc import Generator from pathlib import Path from typing import Literal, TypeVar from anndata._types import GroupStorageType from anndata.compat import H5Group G = TypeVar("G", H5Group, ZarrGroup) PANDAS_3 = Version(version("pandas")) >= Version("3rc0") @pytest.fixture def exit_stack() -> Generator[ExitStack, None, None]: with ExitStack() as stack: yield stack @pytest.fixture def store(diskfmt, tmp_path) -> H5Group | ZarrGroup: if diskfmt == "h5ad": file = h5py.File(tmp_path / "test.h5ad", "w") store = file["/"] elif diskfmt == "zarr": store = open_write_group(tmp_path / "test.zarr") else: pytest.fail(f"Unknown store type: {diskfmt}") try: yield store finally: if diskfmt == "h5ad": file.close() sparse_formats = ["csr", "csc"] SIZE = 50 DEFAULT_SHAPE = (SIZE, SIZE * 2) @pytest.fixture(params=sparse_formats) def sparse_format(request: pytest.FixtureRequest) -> Literal["csr", "csc"]: return request.param def create_dense_store( store: str, *, shape: tuple[int, ...] = DEFAULT_SHAPE ) -> H5Group | ZarrGroup: X = np.random.randn(*shape) write_elem(store, "X", X) return store def create_sparse_store( sparse_format: Literal["csc", "csr"], store: G, shape=DEFAULT_SHAPE ) -> G: """Returns a store Parameters ---------- sparse_format store Returns ------- A store with a key, `X` that is simply a sparse matrix, and `X_dask` where that same array is wrapped by dask """ import dask.array as da X = sparse.random( shape[0], shape[1], format=sparse_format, density=0.01, random_state=np.random.default_rng(), ) X_dask = da.from_array( X, chunks=(100 if format == "csr" else SIZE, SIZE * 2 if format == "csr" else 100), ) write_elem(store, "X", X) write_elem(store, "X_dask", X_dask) return store @pytest.mark.parametrize( ("value", "encoding_type"), [ pytest.param(None, "null", id="none"), pytest.param("hello world", "string", id="py_str"), pytest.param(np.str_("hello world"), "string", id="np_str"), pytest.param(np.array([1, 2, 3]), "array", id="np_arr_int"), pytest.param( np.array(["hello", "world"], dtype=object), "string-array", id="np_arr_str" ), pytest.param(1, "numeric-scalar", id="py_int"), pytest.param(True, "numeric-scalar", id="py_bool"), pytest.param(1.0, "numeric-scalar", id="py_float"), pytest.param({"a": 1}, "dict", id="py_dict"), pytest.param( lambda: gen_adata((3, 2), **GEN_ADATA_NO_XARRAY_ARGS), "anndata", id="anndata", ), pytest.param( sparse.random(5, 3, format="csr", density=0.5), "csr_matrix", id="sp_mat_csr", ), pytest.param( sparse.random(5, 3, format="csc", density=0.5), "csc_matrix", id="sp_mat_csc", ), pytest.param(pd.DataFrame({"a": [1, 2, 3]}), "dataframe", id="pd_df"), pytest.param( pd.Categorical([*"aabccedd", pd.NA]), "categorical", id="pd_cat_np_str", ), pytest.param( pd.Categorical(list("aabccedd"), ordered=True), "categorical", id="pd_cat_np_str_ord", ), pytest.param( pd.array([*"aabccedd", pd.NA], dtype="string").astype("category"), "categorical", id="pd_cat_pd_str", ), pytest.param( pd.Categorical([1, 2, 1, 3], ordered=True), "categorical", id="pd_cat_num" ), pytest.param( pd.array(["hello", "world"], dtype="string"), "nullable-string-array", id="pd_arr_str", ), pytest.param( pd.array(["hello", "world", pd.NA], dtype="string"), "nullable-string-array", id="pd_arr_str_mask", ), pytest.param( pd.arrays.IntegerArray( np.ones(5, dtype=int), mask=np.array([True, False, True, False, True]) ), "nullable-integer", id="pd_arr_int_mask", ), pytest.param(pd.array([1, 2, 3]), "nullable-integer", id="pd_arr_int"), pytest.param( pd.arrays.BooleanArray( np.random.randint(0, 2, size=5, dtype=bool), mask=np.random.randint(0, 2, size=5, dtype=bool), ), "nullable-boolean", id="pd_arr_bool_mask", ), pytest.param( pd.array([True, False, True, True]), "nullable-boolean", id="pd_arr_bool" ), pytest.param( zarr.ones((100, 100), chunks=(10, 10)), "array", id="zarr_dense_array", ), pytest.param( create_dense_store( h5py.File("test1.h5", mode="w", driver="core", backing_store=False) )["X"], "array", id="h5_dense_array", ), # pytest.param(bytes, b"some bytes", "bytes", id="py_bytes"), # Does not work for zarr # TODO consider how specific encodings should be. Should we be fully describing the written type? # Currently the info we add is: "what you wouldn't be able to figure out yourself" # but that's not really a solid rule. # pytest.param(bool, True, "bool", id="py_bool"), # pytest.param(bool, np.bool_(False), "bool", id="np_bool"), ], ) def test_io_spec(store: GroupStorageType, value, encoding_type) -> None: if callable(value): value = value() key = f"key_for_{encoding_type}" with ad.settings.override(allow_write_nullable_strings=True): write_elem(store, key, value, dataset_kwargs={}) assert encoding_type == _read_attr(store[key].attrs, "encoding-type") from_disk = read_elem(store[key]) assert_equal(value, from_disk) assert get_spec(store[key]) == _REGISTRY.get_spec(value) @pytest.mark.parametrize( ("value", "encoding_type"), [ pytest.param(np.asarray(1), "numeric-scalar", id="scalar_int"), pytest.param(np.asarray(1.0), "numeric-scalar", id="scalar_float"), pytest.param(np.asarray(True), "numeric-scalar", id="scalar_bool"), # noqa: FBT003 pytest.param(np.asarray("test"), "string", id="scalar_string"), ], ) def test_io_spec_compressed_scalars(store: G, value: np.ndarray, encoding_type: str): key = f"key_for_{encoding_type}" write_elem( store, key, value, dataset_kwargs={"compression": "gzip", "compression_opts": 5} ) assert encoding_type == _read_attr(store[key].attrs, "encoding-type") from_disk = read_elem(store[key]) assert_equal(value, from_disk) # Can't instantiate cupy types at the top level, so converting them within the test @pytest.mark.gpu @pytest.mark.parametrize( ("value", "encoding_type"), [ (np.array([1, 2, 3]), "array"), (np.arange(12).reshape(4, 3), "array"), (sparse.random(5, 3, format="csr", density=0.5), "csr_matrix"), (sparse.random(5, 3, format="csc", density=0.5), "csc_matrix"), ], ) @pytest.mark.parametrize("as_dask", [False, True]) def test_io_spec_cupy(store, value, encoding_type, as_dask): if as_dask: if isinstance(value, CSMatrix): value = as_cupy_sparse_dask_array(value, format=encoding_type[:3]) else: value = as_dense_cupy_dask_array(value) else: value = as_cupy(value) key = f"key_for_{encoding_type}" write_elem(store, key, value, dataset_kwargs={}) assert encoding_type == _read_attr(store[key].attrs, "encoding-type") from_disk = as_cupy(read_elem(store[key])) assert_equal(value, from_disk) assert get_spec(store[key]) == _REGISTRY.get_spec(value) def test_dask_write_sparse(sparse_format, store): x_sparse_store = create_sparse_store(sparse_format, store) X_from_disk = read_elem(x_sparse_store["X"]) X_dask_from_disk = read_elem(x_sparse_store["X_dask"]) assert_equal(X_from_disk, X_dask_from_disk) assert_equal(dict(x_sparse_store["X"].attrs), dict(x_sparse_store["X_dask"].attrs)) assert x_sparse_store["X_dask/indptr"].dtype == np.int64 assert x_sparse_store["X_dask/indices"].dtype == np.int64 def test_read_lazy_2d_dask(sparse_format, store): arr_store = create_sparse_store(sparse_format, store) X_dask_from_disk = read_elem_lazy(arr_store["X"]) X_from_disk = read_elem(arr_store["X"]) assert_equal(X_from_disk, X_dask_from_disk) random_int_indices = np.random.randint(0, SIZE, (SIZE // 10,)) random_int_indices.sort() index_slice = slice(0, SIZE // 10) for index in [random_int_indices, index_slice]: assert_equal(X_from_disk[index, :], X_dask_from_disk[index, :]) assert_equal(X_from_disk[:, index], X_dask_from_disk[:, index]) random_bool_mask = np.random.randn(SIZE) > 0 assert_equal( X_from_disk[random_bool_mask, :], X_dask_from_disk[random_bool_mask, :] ) random_bool_mask = np.random.randn(SIZE * 2) > 0 assert_equal( X_from_disk[:, random_bool_mask], X_dask_from_disk[:, random_bool_mask] ) assert arr_store["X_dask/indptr"].dtype == np.int64 assert arr_store["X_dask/indices"].dtype == np.int64 @pytest.mark.parametrize( ("n_dims", "chunks"), [ (1, (10,)), (1, (40,)), (2, (10, 10)), (2, (40, 40)), (2, (20, 40)), (1, None), (2, None), (2, (40, -1)), (2, (40, None)), ], ) def test_read_lazy_subsets_nd_dask(store, n_dims, chunks): arr_store = create_dense_store(store, shape=DEFAULT_SHAPE[:n_dims]) X_dask_from_disk = read_elem_lazy(arr_store["X"], chunks=chunks) X_from_disk = read_elem(arr_store["X"]) assert_equal(X_from_disk, X_dask_from_disk) random_int_indices = np.random.randint(0, SIZE, (SIZE // 10,)) random_int_indices.sort() random_bool_mask = np.random.randn(SIZE) > 0 index_slice = slice(0, SIZE // 10) for index in [random_int_indices, index_slice, random_bool_mask]: assert_equal(X_from_disk[index], X_dask_from_disk[index]) @pytest.mark.xdist_group("dask") @pytest.mark.dask_distributed def test_read_lazy_h5_cluster( sparse_format: Literal["csr", "csc"], tmp_path: Path, local_cluster_addr: str, ) -> None: import dask.distributed as dd with h5py.File(tmp_path / "test.h5", "w") as file: store = file["/"] arr_store = create_sparse_store(sparse_format, store) X_dask_from_disk = read_elem_lazy(arr_store["X"]) X_from_disk = read_elem(arr_store["X"]) with dd.Client(local_cluster_addr): assert_equal(X_from_disk, X_dask_from_disk) def test_undersized_shape_to_default(store: H5Group | ZarrGroup): shape = (1000, 50) arr_store = create_dense_store(store, shape=shape) X_dask_from_disk = read_elem_lazy(arr_store["X"]) assert all(c <= s for c, s in zip(X_dask_from_disk.chunksize, shape, strict=True)) assert X_dask_from_disk.shape == shape @pytest.mark.parametrize( ("arr_type", "chunks", "expected_chunksize"), [ pytest.param("dense", (10, 10), (10, 10), id="dense"), pytest.param("csc", (SIZE, 1), (SIZE, 1), id="singleton-minor"), pytest.param( "csr", (1, SIZE * 2), (1, SIZE * 2), id="singleton-major", ), pytest.param("csc", None, (SIZE, 100), id="csc-default"), pytest.param("csr", None, DEFAULT_SHAPE, id="csr-default"), pytest.param( "csr", (10, -1), (10, SIZE * 2), id="minus_one_minor", ), pytest.param("csc", (-1, 10), (SIZE, 10), id="minus_one_major"), pytest.param("csr", (10, None), (10, SIZE * 2), id="none_minor"), pytest.param("csc", (None, 10), (SIZE, 10), id="none_major"), pytest.param("csc", (None, None), DEFAULT_SHAPE, id="csc-none_all"), pytest.param("csr", (None, None), DEFAULT_SHAPE, id="csr-none_all"), pytest.param("csr", (-1, -1), DEFAULT_SHAPE, id="csr-minus_one_all"), pytest.param("csc", (-1, -1), DEFAULT_SHAPE, id="csc-minus_one_all"), ], ) def test_read_lazy_2d_chunk_kwargs( store: H5Group | ZarrGroup, arr_type: Literal["csr", "csc", "dense"], chunks: None | tuple[int | None, int | None], expected_chunksize: tuple[int, int], ): if arr_type == "dense": arr_store = create_dense_store(store) X_dask_from_disk = read_elem_lazy(arr_store["X"], chunks=chunks) else: arr_store = create_sparse_store(arr_type, store) X_dask_from_disk = read_elem_lazy(arr_store["X"], chunks=chunks) assert X_dask_from_disk.chunksize == expected_chunksize X_from_disk = read_elem(arr_store["X"]) assert_equal(X_from_disk, X_dask_from_disk) def test_read_lazy_bad_chunk_kwargs(tmp_path): arr_type = "csr" with h5py.File(tmp_path / "test.h5", "w") as file: store = file["/"] arr_store = create_sparse_store(arr_type, store) with pytest.raises( ValueError, match=r"`chunks` must be a tuple of two integers" ): read_elem_lazy(arr_store["X"], chunks=(SIZE,)) with pytest.raises(ValueError, match=r"Only the major axis can be chunked"): read_elem_lazy(arr_store["X"], chunks=(SIZE, 10)) @pytest.mark.parametrize("sparse_format", ["csr", "csc"]) def test_write_indptr_dtype_override(store, sparse_format): X = sparse.random( 100, 100, format=sparse_format, density=0.1, random_state=np.random.default_rng(), ) write_elem(store, "X", X, dataset_kwargs=dict(indptr_dtype="int64")) assert store["X/indptr"].dtype == np.int64 assert X.indptr.dtype == np.int32 np.testing.assert_array_equal(store["X/indptr"][...], X.indptr) def test_io_spec_raw(store): adata = gen_adata((3, 2), **GEN_ADATA_NO_XARRAY_ARGS) adata.raw = adata.copy() write_elem(store, "adata", adata) assert _read_attr(store["adata/raw"].attrs, "encoding-type") == "raw" from_disk = read_elem(store["adata"]) assert_equal(from_disk.raw, adata.raw) def test_write_anndata_to_root(store): adata = gen_adata((3, 2), **GEN_ADATA_NO_XARRAY_ARGS) write_elem(store, "/", adata) # TODO: see https://github.com/zarr-developers/zarr-python/issues/2716 if not is_zarr_v2() and isinstance(store, ZarrGroup): store = zarr.open(store.store) from_disk = read_elem(store) assert _read_attr(store.attrs, "encoding-type") == "anndata" assert_equal(from_disk, adata) @pytest.mark.parametrize( ("attribute", "value"), [ ("encoding-type", "floob"), ("encoding-version", "10000.0"), ], ) def test_read_iospec_not_found(store, attribute, value): adata = gen_adata((3, 2), **GEN_ADATA_NO_XARRAY_ARGS) write_elem(store, "/", adata) store["obs"].attrs.update({attribute: value}) with pytest.raises(IORegistryError) as exc_info: read_elem(store) msg = str(exc_info.value) assert "No read method registered for IOSpec" in msg assert f"{attribute.replace('-', '_')}='{value}'" in msg @pytest.mark.parametrize( "obj", [(b"x",)], ) def test_write_io_error(store, obj): full_pattern = re.compile( rf"No method registered for writing {type(obj)} into .*Group" ) with pytest.raises(IORegistryError, match=r"while writing key '/el'") as exc_info: write_elem(store, "/el", obj) msg = str(exc_info.value) assert re.search(full_pattern, msg) @pytest.mark.parametrize( ("ad_setting", "pd_setting", "expected_missing", "expected_no_missing"), [ # when explicitly disabled, we expect an error when trying to write an array with missing values # and expect an array without missing values to be written in the old, non-nullable format *( pytest.param( False, pd_ignored, *( [ ( RuntimeError, r"`anndata.settings.allow_write_nullable_strings` is False", ) ] * 2 ), id=f"off-explicit-{int(pd_ignored)}", ) for pd_ignored in [False, True] ), # when enabled, we expect arrays to be written in the nullable format pytest.param(True, False, *(["nullable-string-array"] * 2), id="on-explicit-0"), pytest.param(True, True, *(["nullable-string-array"] * 2), id="on-explicit-1"), ], ) @pytest.mark.parametrize("missing", [True, False], ids=["missing", "no_missing"]) def test_write_nullable_string( *, store: GroupStorageType, ad_setting: bool, pd_setting: bool, expected_missing: tuple[type[Exception], str] | str, expected_no_missing: tuple[type[Exception], str] | str, missing: bool, ) -> None: expected = expected_missing if missing else expected_no_missing with ( ad.settings.override(allow_write_nullable_strings=ad_setting), pd.option_context("future.infer_string", pd_setting), ( nullcontext() if isinstance(expected, str) else pytest.raises(expected[0], match=expected[1]) ), ): write_elem(store, "/el", pd.array([pd.NA if missing else "a"], dtype="string")) if isinstance(expected, str): assert store["el"].attrs["encoding-type"] == expected @pytest.mark.parametrize("infer_string", [True, False], ids=["infer_string", "default"]) @pytest.mark.parametrize( "index", [ pd.array([1, 2, pd.NA]), pd.array([1, 2, np.nan]), pd.RangeIndex(3), ], ids=["NA", "nan", "range"], ) def test_nullable_string_from_non_string_index( *, infer_string: bool, index: pd.api.extensions.ExtensionArray ) -> None: with pd.option_context("future.infer_string", infer_string): adata = ad.AnnData(obs=pd.DataFrame({"foo": [1, 2, 3]}, index=index)) assert ("string" if infer_string else object) == adata.obs_names.dtype def test_categorical_order_type(store): # https://github.com/scverse/anndata/issues/853 cat = pd.Categorical([0, 1], ordered=True) write_elem(store, "ordered", cat) write_elem(store, "unordered", cat.set_ordered(False)) assert isinstance(read_elem(store["ordered"]).ordered, bool) assert read_elem(store["ordered"]).ordered is True assert isinstance(read_elem(store["unordered"]).ordered, bool) assert read_elem(store["unordered"]).ordered is False def test_override_specification(): """ Test that trying to overwrite an existing encoding raises an error. """ from copy import deepcopy registry = deepcopy(_REGISTRY) with pytest.raises(TypeError): @registry.register_write( ZarrGroup, ad.AnnData, IOSpec("some new type", "0.1.0") ) def _(store, key, adata): pass @pytest.mark.parametrize( "value", [ pytest.param({"a": 1}, id="dict"), pytest.param( lambda: gen_adata((3, 2), **GEN_ADATA_NO_XARRAY_ARGS), id="anndata" ), pytest.param(sparse.random(5, 3, format="csr", density=0.5), id="csr_matrix"), pytest.param(sparse.random(5, 3, format="csc", density=0.5), id="csc_matrix"), pytest.param(pd.DataFrame({"a": [1, 2, 3]}), id="dataframe"), pytest.param(pd.Categorical(list("aabccedd")), id="categorical"), pytest.param( pd.Categorical(list("aabccedd"), ordered=True), id="categorical-ordered" ), pytest.param( pd.Categorical([1, 2, 1, 3], ordered=True), id="categorical-numeric" ), pytest.param( pd.arrays.IntegerArray( np.ones(5, dtype=int), mask=np.array([True, False, True, False, True]) ), id="nullable-integer", ), pytest.param(pd.array([1, 2, 3]), id="nullable-integer-no-nulls"), pytest.param( pd.arrays.BooleanArray( np.random.randint(0, 2, size=5, dtype=bool), mask=np.random.randint(0, 2, size=5, dtype=bool), ), id="nullable-boolean", ), pytest.param( pd.array([True, False, True, True]), id="nullable-boolean-no-nulls" ), ], ) def test_write_to_root(store: GroupStorageType, value): """ Test that elements which are written as groups can we written to the root group. """ if callable(value): value = value() write_elem(store, "/", value) # See: https://github.com/zarr-developers/zarr-python/issues/2716 if isinstance(store, ZarrGroup) and not is_zarr_v2(): store = zarr.open(store.store) result = read_elem(store) assert_equal(result, value) @pytest.mark.parametrize("consolidated", [True, False]) @pytest.mark.zarr_io def test_read_zarr_from_group(tmp_path, consolidated): # https://github.com/scverse/anndata/issues/1056 pth = tmp_path / "test.zarr" adata = gen_adata((3, 2), **GEN_ADATA_NO_XARRAY_ARGS) z = open_write_group(pth) write_elem(z, "table/table", adata) if consolidated: zarr.consolidate_metadata(z.store) read_func = zarr.open_consolidated if consolidated else zarr.open z = read_func(pth) expected = ad.read_zarr(z["table/table"]) assert_equal(adata, expected) def test_dataframe_column_uniqueness(store): repeated_cols = pd.DataFrame(np.ones((3, 2)), columns=["a", "a"]) with pytest.raises( ValueError, match=r"Found repeated column names: \['a'\]\. Column names must be unique\.", ): write_elem(store, "repeated_cols", repeated_cols) index_shares_col_name = pd.DataFrame( {"col_name": [1, 2, 3]}, index=pd.Index([1, 3, 2], name="col_name") ) with pytest.raises( ValueError, match=r"DataFrame\.index\.name \('col_name'\) is also used by a column whose values are different\.", ): write_elem(store, "index_shares_col_name", index_shares_col_name) index_shared_okay = pd.DataFrame( {"col_name": [1, 2, 3]}, index=pd.Index([1, 2, 3], name="col_name") ) write_elem(store, "index_shared_okay", index_shared_okay) result = read_elem(store["index_shared_okay"]) assert_equal(result, index_shared_okay) @pytest.mark.parametrize( "copy_on_write", [ pytest.param(True, id="cow"), pytest.param( False, marks=pytest.mark.skipif( PANDAS_3, reason="Can’t disable copy-on-write in pandas 3+." ), id="nocow", ), ], ) def test_io_pd_cow( *, exit_stack: ExitStack, store: GroupStorageType, copy_on_write: bool ) -> None: """See .""" if not PANDAS_3: # Setting copy_on_write always warns in pandas 3, and does nothing exit_stack.enter_context(pd.option_context("mode.copy_on_write", copy_on_write)) orig = gen_adata((3, 2), **GEN_ADATA_NO_XARRAY_ARGS) write_elem(store, "adata", orig) from_store = read_elem(store["adata"]) assert_equal(orig, from_store) def test_read_sparse_array( tmp_path: Path, sparse_format: Literal["csr", "csc"], diskfmt: Literal["h5ad", "zarr"], ): path = tmp_path / f"test.{diskfmt.replace('ad', '')}" a = sparse.random(100, 100, format=sparse_format) f = open_write_group(path, mode="a") if diskfmt == "zarr" else h5py.File(path, "a") ad.io.write_elem(f, "mtx", a) ad.settings.use_sparse_array_on_read = True mtx = ad.io.read_elem(f["mtx"]) assert issubclass(type(mtx), CSArray) @pytest.mark.parametrize( ("chunks", "expected_chunks"), [((1,), (1,)), ((-1,), (120,)), (None, (25,))], ids=["small", "minus_one_uses_full", "none_uses_ondisk_chunking"], ) @pytest.mark.parametrize( "arr", [np.arange(120), np.array(["a"] * 120)], ids=["numeric", "string"] ) def test_chunking_1d_array( store: H5Group | ZarrGroup, arr: np.ndarray, chunks: tuple[int] | None, expected_chunks: tuple[int], ): write_elem(store, "foo", arr, dataset_kwargs={"chunks": 25}) arr = read_elem_lazy(store["foo"], chunks=chunks) assert arr.chunksize == expected_chunks @pytest.mark.parametrize( ("chunks", "expected_chunks"), [ ((1, 50), (1, 50)), ((10, -1), (10, 50)), ((10, None), (10, 50)), (None, (25, 25)), ], ids=[ "small", "minus_one_uses_full", "none_on_axis_uses_full", "none_uses_ondisk_chunking", ], ) def test_chunking_2d_array( store: H5Group | ZarrGroup, chunks: tuple[int] | None, expected_chunks: tuple[int], ): write_elem( store, "foo", np.arange(100 * 50).reshape((100, 50)), dataset_kwargs={"chunks": (25, 25)}, ) arr = read_elem_lazy(store["foo"], chunks=chunks) assert arr.chunksize == expected_chunks @pytest.mark.parametrize( ("shape", "expected_chunks"), [((100, 50), (100, 50)), ((1100, 1100), (1000, 1000))], ids=["under_default", "over_default"], ) def test_h5_unchunked( shape: tuple[int, int], expected_chunks: tuple[int, int], tmp_path: Path, ): with h5py.File(tmp_path / "foo.h5ad", mode="w") as f: write_elem( f, "foo", np.arange(shape[0] * shape[1]).reshape(shape), ) arr = read_elem_lazy(f["foo"]) assert arr.chunksize == expected_chunks @pytest.mark.zarr_io def test_write_auto_sharded(tmp_path: Path): if is_zarr_v2(): with ( pytest.raises( ValueError, match=r"Cannot use sharding with `zarr-python<3`." ), ad.settings.override(auto_shard_zarr_v3=True), ): pass else: path = tmp_path / "check.zarr" adata = gen_adata((1000, 100), **GEN_ADATA_NO_XARRAY_ARGS) with ad.settings.override(auto_shard_zarr_v3=True, zarr_write_format=3): adata.write_zarr(path) check_all_sharded(zarr.open(path)) @pytest.mark.zarr_io @pytest.mark.skipif(is_zarr_v2(), reason="auto sharding is allowed only for zarr v3.") def test_write_auto_sharded_against_v2_format(): with pytest.raises(ValueError, match=r"Cannot shard v2 format data."): # noqa: PT012, SIM117 with ad.settings.override(zarr_write_format=2): with ad.settings.override(auto_shard_zarr_v3=True): pass @pytest.mark.zarr_io @pytest.mark.skipif(is_zarr_v2(), reason="auto sharding is allowed only for zarr v3.") def test_write_auto_cannot_set_v2_format_after_sharding(): with pytest.raises(ValueError, match=r"Cannot set `zarr_write_format` to 2"): # noqa: PT012, SIM117 with ad.settings.override(zarr_write_format=3): with ad.settings.override(auto_shard_zarr_v3=True): with ad.settings.override(zarr_write_format=2): pass @pytest.mark.zarr_io @pytest.mark.skipif(is_zarr_v2(), reason="auto sharding is allowed only for zarr v3.") def test_write_auto_sharded_does_not_override(tmp_path: Path): z = open_write_group(tmp_path / "arr.zarr", zarr_format=3) X = sparse.random( 100, 100, density=0.1, format="csr", rng=np.random.default_rng(42) ) with ad.settings.override(auto_shard_zarr_v3=True, zarr_write_format=3): ad.io.write_elem(z, "X_default", X) shards_default = z["X_default"]["indices"].shards new_shards = shards_default[0] // 2 new_shards = int(new_shards - new_shards % 2) ad.io.write_elem( z, "X_manually_set", X, dataset_kwargs={ "shards": (new_shards,), "chunks": (int(new_shards / 2),), }, ) def visitor(key: str, array: zarr.Array): assert array.shards == (new_shards,) visititems_zarr(z["X_manually_set"], visitor) scverse-anndata-b796d59/tests/test_io_partial.py000066400000000000000000000060661512025555600220570ustar00rootroot00000000000000from __future__ import annotations from importlib.util import find_spec from pathlib import Path import h5py import numpy as np import pytest import zarr from scipy.sparse import csr_matrix from anndata import AnnData, settings from anndata._io.specs.registry import read_elem_partial from anndata.io import read_elem, write_h5ad, write_zarr X = np.array([[1.0, 0.0, 3.0], [4.0, 0.0, 6.0], [0.0, 8.0, 0.0]], dtype="float32") X_check = np.array([[4.0, 0.0], [0.0, 8.0]], dtype="float32") WRITER = dict(h5ad=write_h5ad, zarr=write_zarr) READER = dict(h5ad=h5py.File, zarr=zarr.open) @pytest.mark.parametrize("typ", [np.asarray, csr_matrix]) def test_read_partial_X(tmp_path, typ, diskfmt): adata = AnnData(X=typ(X)) path = Path(tmp_path) / ("test_tp_X." + diskfmt) WRITER[diskfmt](path, adata) store = READER[diskfmt](path, mode="r") if diskfmt == "zarr": X_part = read_elem_partial(store["X"], indices=([1, 2], [0, 1])) else: # h5py doesn't allow fancy indexing across multiple dimensions X_part = read_elem_partial(store["X"], indices=([1, 2],)) X_part = X_part[:, [0, 1]] store.close() assert np.all(X_check == X_part) @pytest.mark.skipif(not find_spec("scanpy"), reason="Scanpy is not installed") def test_read_partial_adata(tmp_path, diskfmt): import scanpy as sc adata = sc.datasets.pbmc68k_reduced() path = Path(tmp_path) / ("test_rp." + diskfmt) # we’re not adding things to read_partial anymore, so it can only read non-nullable strings. # therefore force writing old format here with settings.override(allow_write_nullable_strings=False): WRITER[diskfmt](path, adata) storage = READER[diskfmt](path, mode="r") obs_idx = [1, 2] var_idx = [0, 3] adata_sbs = adata[obs_idx, var_idx] if diskfmt == "zarr": part = read_elem_partial(storage["X"], indices=(obs_idx, var_idx)) else: # h5py doesn't allow fancy indexing across multiple dimensions part = read_elem_partial(storage["X"], indices=(obs_idx,)) part = part[:, var_idx] assert np.all(part == adata_sbs.X) part = read_elem_partial(storage["obs"], indices=(obs_idx,)) assert np.all(part.keys() == adata_sbs.obs.keys()) assert np.all(part.index == adata_sbs.obs.index) part = read_elem_partial(storage["var"], indices=(var_idx,)) assert np.all(part.keys() == adata_sbs.var.keys()) assert np.all(part.index == adata_sbs.var.index) for key in storage["obsm"]: part = read_elem_partial(storage["obsm"][key], indices=(obs_idx,)) assert np.all(part == adata_sbs.obsm[key]) for key in storage["varm"]: part = read_elem_partial(storage["varm"][key], indices=(var_idx,)) np.testing.assert_equal(part, adata_sbs.varm[key]) for key in storage["obsp"]: part = read_elem_partial(storage["obsp"][key], indices=(obs_idx, obs_idx)) part = part.toarray() assert np.all(part == adata_sbs.obsp[key]) # check uns just in case np.testing.assert_equal(read_elem(storage["uns"]).keys(), adata.uns.keys()) scverse-anndata-b796d59/tests/test_io_utils.py000066400000000000000000000067411512025555600215630ustar00rootroot00000000000000from __future__ import annotations from contextlib import AbstractContextManager, nullcontext from typing import TYPE_CHECKING import h5py import numpy as np import pandas as pd import pytest import zarr import anndata as ad from anndata._io.specs.registry import IORegistryError from anndata._io.utils import report_read_key_on_error from anndata.compat import _clean_uns if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path @pytest.mark.parametrize( "group_fn", [ pytest.param(lambda _: zarr.group(), id="zarr"), pytest.param(lambda p: h5py.File(p / "test.h5", mode="a"), id="h5py"), ], ) @pytest.mark.parametrize("nested", [True, False], ids=["nested", "root"]) def test_key_error( *, tmp_path, group_fn: Callable[[Path], zarr.Group | h5py.Group], nested: bool ): @report_read_key_on_error def read_attr(_): raise NotImplementedError() group = group_fn(tmp_path) with group if isinstance(group, AbstractContextManager) else nullcontext(): if nested: group = group.create_group("nested") path = "/nested" else: path = "/" group["X"] = np.array([1, 2, 3]) group.create_group("group") with pytest.raises( NotImplementedError, match=rf"reading key 'X'.*from {path}$" ): read_attr(group["X"]) with pytest.raises( NotImplementedError, match=rf"reading key 'group'.*from {path}$" ): read_attr(group["group"]) def test_write_error_info(diskfmt, tmp_path): pth = tmp_path / f"failed_write.{diskfmt}" write = lambda x: getattr(x, f"write_{diskfmt}")(pth) # Assuming we don't define a writer for tuples a = ad.AnnData(uns={"a": {"b": {"c": (1, 2, 3)}}}) with pytest.raises( IORegistryError, match=r"Error raised while writing key 'c'.*to /uns/a/b" ): write(a) def test_clean_uns(): adata = ad.AnnData( uns=dict(species_categories=["a", "b"]), obs=pd.DataFrame({"species": [0, 1, 0]}, index=["a", "b", "c"]), var=pd.DataFrame({"species": [0, 1, 0, 2]}, index=["a", "b", "c", "d"]), ) _clean_uns(adata) assert "species_categories" not in adata.uns assert isinstance(adata.obs["species"].dtype, pd.CategoricalDtype) assert adata.obs["species"].tolist() == ["a", "b", "a"] # var’s categories were overwritten by obs’s, # which we can detect here because var has too high codes assert pd.api.types.is_integer_dtype(adata.var["species"]) @pytest.mark.parametrize( "group_fn", [ pytest.param(lambda _: zarr.group(), id="zarr"), pytest.param(lambda p: h5py.File(p / "test.h5", mode="a"), id="h5py"), ], ) def test_only_child_key_reported_on_failure(tmp_path, group_fn): class Foo: pass group = group_fn(tmp_path) # This regex checks that the pattern inside the (?!...) group does not exist in the string # (?!...) is a negative lookahead # (?s) enables the dot to match newlines # https://stackoverflow.com/a/406408/130164 <- copilot suggested lol pattern = r"(?s)^((?!Error raised while writing key '/?a').)*$" with pytest.raises(IORegistryError, match=pattern): ad.io.write_elem(group, "/", {"a": {"b": Foo()}}) ad.io.write_elem(group, "/", {"a": {"b": [1, 2, 3]}}) group["a/b"].attrs["encoding-type"] = "not a real encoding type" with pytest.raises(IORegistryError, match=pattern): ad.io.read_elem(group) scverse-anndata-b796d59/tests/test_io_warnings.py000066400000000000000000000025241512025555600222460ustar00rootroot00000000000000from __future__ import annotations import re import warnings from pathlib import Path import pytest import anndata as ad from anndata.tests.helpers import GEN_ADATA_NO_XARRAY_ARGS, gen_adata HERE = Path(__file__).parent DATA_DIR = HERE / "data" def test_old_format_warning_thrown() -> None: def msg_re(entry: str) -> str: return re.escape( f"Moving element from .uns['neighbors'][{entry!r}] to .obsp[{entry!r}]." ) pth = DATA_DIR / "archives/v0.5.0/adata.h5ad" warnings.simplefilter("default", FutureWarning) with ( pytest.warns(FutureWarning, match=msg_re("distances")), pytest.warns(FutureWarning, match=msg_re("connectivities")), pytest.warns(ad.OldFormatWarning), ): ad.read_h5ad(pth) def test_old_format_warning_not_thrown(tmp_path: Path) -> None: pth = tmp_path / "current.h5ad" adata = gen_adata((20, 10), **GEN_ADATA_NO_XARRAY_ARGS) adata.write_h5ad(pth) with warnings.catch_warnings(record=True) as record: warnings.simplefilter("always", ad.OldFormatWarning) ad.read_h5ad(pth) if len(record) != 0: msg_content = "\n".join([ f"\t{w.category.__name__}('{w.message}')" for w in record ]) pytest.fail( f"Warnings were thrown when they shouldn't be. Got:\n\n{msg_content}" ) scverse-anndata-b796d59/tests/test_layers.py000066400000000000000000000061501512025555600212250ustar00rootroot00000000000000from __future__ import annotations import warnings import numpy as np import pandas as pd import pytest from anndata import AnnData, ImplicitModificationWarning, read_h5ad from anndata.tests.helpers import gen_typed_df_t2_size X_ = np.arange(12).reshape((3, 4)) L = np.arange(12).reshape((3, 4)) + 12 @pytest.fixture(params=[X_, None]) def X(request): return request.param def test_creation(X: np.ndarray | None): adata = AnnData(X=X, layers=dict(L=L.copy())) assert list(adata.layers.keys()) == ["L"] assert "L" in adata.layers assert "X" not in adata.layers assert "some_other_thing" not in adata.layers assert (adata.layers["L"] == L).all() assert adata.shape == L.shape def test_views(): adata = AnnData(X=X_, layers=dict(L=L.copy())) adata_view = adata[1:, 1:] assert adata_view.layers.is_view assert adata_view.layers.parent_mapping == adata.layers assert adata_view.layers.keys() == adata.layers.keys() assert (adata_view.layers["L"] == adata.layers["L"][1:, 1:]).all() adata.layers["S"] = X_ assert adata_view.layers.keys() == adata.layers.keys() assert (adata_view.layers["S"] == adata.layers["S"][1:, 1:]).all() with pytest.warns(ImplicitModificationWarning): adata_view.layers["T"] = X_[1:, 1:] assert not adata_view.layers.is_view assert not adata_view.is_view @pytest.mark.parametrize( ("df", "homogenous", "dtype"), [ (lambda: gen_typed_df_t2_size(*X_.shape), True, np.object_), (lambda: pd.DataFrame(X_**2), False, np.int_), ], ) def test_set_dataframe(homogenous, df, dtype): adata = AnnData(X_) if homogenous: with pytest.warns(UserWarning, match=r"Layer 'df'.*dtype object"): adata.layers["df"] = df() else: with warnings.catch_warnings(): warnings.simplefilter("error") adata.layers["df"] = df() assert isinstance(adata.layers["df"], np.ndarray) assert np.issubdtype(adata.layers["df"].dtype, dtype) def test_readwrite(X: np.ndarray | None, backing_h5ad): adata = AnnData(X=X, layers=dict(L=L.copy())) adata.write(backing_h5ad) adata_read = read_h5ad(backing_h5ad) assert adata.layers.keys() == adata_read.layers.keys() assert (adata.layers["L"] == adata_read.layers["L"]).all() def test_backed(): # backed mode for layers isn’t implemented, layers stay in memory pass def test_copy(): adata = AnnData(X=X_, layers=dict(L=L.copy())) bdata = adata.copy() # check that we don’t create too many references assert bdata._layers is bdata.layers._data # check that we have a copy adata.layers["L"] += 10 assert np.all(adata.layers["L"] != bdata.layers["L"]) # 201 def test_shape_error(): adata = AnnData(X=X_) with pytest.raises( ValueError, match=( r"Value passed for key 'L' is of incorrect shape\. " r"Values of layers must match dimensions \('obs', 'var'\) of parent\. " r"Value had shape \(4, 4\) while it should have had \(3, 4\)\." ), ): adata.layers["L"] = np.zeros((X_.shape[0] + 1, X_.shape[1])) scverse-anndata-b796d59/tests/test_obsmvarm.py000066400000000000000000000127571512025555600215660ustar00rootroot00000000000000from __future__ import annotations from functools import partial import joblib import numpy as np import pandas as pd import pytest from scipy import sparse from anndata import AnnData from anndata.compat import CupyArray from anndata.tests.helpers import as_cupy, get_multiindex_columns_df M, N = (100, 100) @pytest.fixture( params=[ pytest.param( partial(as_cupy, typ=CupyArray), id="cupy_array", marks=pytest.mark.gpu ), pytest.param(np.array, id="numpy_array"), ], ids=["cupy", "numpy"], ) def array_type(request): return request.param @pytest.fixture def adata(): X = np.zeros((M, N)) obs = pd.DataFrame( dict(batch=np.array(["a", "b"])[np.random.randint(0, 2, M)]), index=[f"cell{i:03d}" for i in range(N)], ) var = pd.DataFrame(index=[f"gene{i:03d}" for i in range(N)]) return AnnData(X, obs=obs, var=var) def test_assignment_dict(adata: AnnData): d_obsm = dict( a=pd.DataFrame( dict(a1=np.ones(M), a2=[f"a{i}" for i in range(M)]), index=adata.obs_names, ), b=np.zeros((M, 2)), ) d_varm = dict( a=pd.DataFrame( dict(a1=np.ones(N), a2=[f"a{i}" for i in range(N)]), index=adata.var_names, ), b=np.zeros((N, 2)), ) adata.obsm = d_obsm for k, v in d_obsm.items(): assert np.all(adata.obsm[k] == v) adata.varm = d_varm for k, v in d_varm.items(): assert np.all(adata.varm[k] == v) def test_setting_ndarray(adata: AnnData): adata.obsm["a"] = np.ones((M, 10)) adata.varm["a"] = np.ones((N, 10)) assert np.all(adata.obsm["a"] == np.ones((M, 10))) assert np.all(adata.varm["a"] == np.ones((N, 10))) h = joblib.hash(adata) with pytest.raises(ValueError, match=r"incorrect shape"): adata.obsm["b"] = np.ones((int(M / 2), 10)) with pytest.raises(ValueError, match=r"incorrect shape"): adata.obsm["b"] = np.ones((int(M * 2), 10)) with pytest.raises(ValueError, match=r"incorrect shape"): adata.varm["b"] = np.ones((int(N / 2), 10)) with pytest.raises(ValueError, match=r"incorrect shape"): adata.varm["b"] = np.ones((int(N * 2), 10)) assert h == joblib.hash(adata) def test_setting_dataframe(adata: AnnData): obsm_df = pd.DataFrame(dict(b_1=np.ones(M), b_2=["a"] * M), index=adata.obs_names) varm_df = pd.DataFrame(dict(b_1=np.ones(N), b_2=["a"] * N), index=adata.var_names) adata.obsm["b"] = obsm_df assert np.all(adata.obsm["b"] == obsm_df) adata.varm["b"] = varm_df assert np.all(adata.varm["b"] == varm_df) bad_obsm_df = obsm_df.copy() bad_obsm_df.reset_index(inplace=True) with pytest.raises(ValueError, match=r"index does not match.*obs names"): adata.obsm["c"] = bad_obsm_df bad_varm_df = varm_df.copy() bad_varm_df.reset_index(inplace=True) with pytest.raises(ValueError, match=r"index does not match.*var names"): adata.varm["c"] = bad_varm_df def test_setting_sparse(adata: AnnData): obsm_sparse = sparse.random(M, 100, format="csr") adata.obsm["a"] = obsm_sparse assert not np.any((adata.obsm["a"] != obsm_sparse).data) varm_sparse = sparse.random(N, 100, format="csr") adata.varm["a"] = varm_sparse assert not np.any((adata.varm["a"] != varm_sparse).data) h = joblib.hash(adata) bad_obsm_sparse = sparse.random(M * 2, M, format="csr") with pytest.raises(ValueError, match=r"incorrect shape"): adata.obsm["b"] = bad_obsm_sparse bad_varm_sparse = sparse.random(N * 2, N, format="csr") with pytest.raises(ValueError, match=r"incorrect shape"): adata.varm["b"] = bad_varm_sparse assert h == joblib.hash(adata) def test_setting_daskarray(adata: AnnData): import dask.array as da adata.obsm["a"] = da.ones((M, 10)) adata.varm["a"] = da.ones((N, 10)) assert da.all(adata.obsm["a"] == da.ones((M, 10))) assert da.all(adata.varm["a"] == da.ones((N, 10))) assert isinstance(adata.obsm["a"], da.Array) assert isinstance(adata.varm["a"], da.Array) h = joblib.hash(adata) with pytest.raises(ValueError, match=r"incorrect shape"): adata.obsm["b"] = da.ones((int(M / 2), 10)) with pytest.raises(ValueError, match=r"incorrect shape"): adata.obsm["b"] = da.ones((int(M * 2), 10)) with pytest.raises(ValueError, match=r"incorrect shape"): adata.varm["b"] = da.ones((int(N / 2), 10)) with pytest.raises(ValueError, match=r"incorrect shape"): adata.varm["b"] = da.ones((int(N * 2), 10)) assert h == joblib.hash(adata) def test_shape_error(adata: AnnData): with pytest.raises( ValueError, match=( r"Value passed for key 'b' is of incorrect shape\. " r"Values of obsm must match dimensions \('obs',\) of parent\. " r"Value had shape \(101,\) while it should have had \(100,\)\." ), ): adata.obsm["b"] = np.zeros((adata.shape[0] + 1, adata.shape[0])) def test_error_set_multiindex_df(adata: AnnData): df = get_multiindex_columns_df((adata.shape[0], 20)) with pytest.raises(ValueError, match=r"MultiIndex columns are not supported"): adata.obsm["df"] = df def test_1d_declaration(array_type): adata = AnnData(np.ones((5, 20)), obsm={"1d-array": array_type(np.ones(5))}) assert adata.obsm["1d-array"].shape == (5, 1) def test_1d_set(adata, array_type): adata.varm["1d-array"] = array_type(np.ones(adata.shape[1])) assert adata.varm["1d-array"].shape == (adata.shape[1], 1) scverse-anndata-b796d59/tests/test_obspvarp.py000066400000000000000000000115151512025555600215630ustar00rootroot00000000000000# TODO: These tests should share code with test_layers, and test_obsmvarm from __future__ import annotations import warnings import joblib import numpy as np import pandas as pd import pytest from scipy import sparse from anndata import AnnData from anndata.tests.helpers import gen_typed_df_t2_size from anndata.utils import asarray M, N = (200, 100) @pytest.fixture def adata(): X = np.zeros((M, N)) obs = pd.DataFrame( dict(batch=np.array(["a", "b"])[np.random.randint(0, 2, M)]), index=[f"cell{i:03d}" for i in range(M)], ) var = pd.DataFrame(index=[f"gene{i:03d}" for i in range(N)]) return AnnData(X, obs=obs, var=var) def test_assigmnent_dict(adata: AnnData): d_obsp = dict( a=pd.DataFrame(np.ones((M, M)), columns=adata.obs_names, index=adata.obs_names), b=np.zeros((M, M)), c=sparse.random(M, M, format="csr"), ) d_varp = dict( a=pd.DataFrame(np.ones((N, N)), columns=adata.var_names, index=adata.var_names), b=np.zeros((N, N)), c=sparse.random(N, N, format="csr"), ) adata.obsp = d_obsp for k, v in d_obsp.items(): assert np.all(asarray(adata.obsp[k]) == asarray(v)) adata.varp = d_varp for k, v in d_varp.items(): assert np.all(asarray(adata.varp[k]) == asarray(v)) def test_setting_ndarray(adata: AnnData): adata.obsp["a"] = np.ones((M, M)) adata.varp["a"] = np.ones((N, N)) assert np.all(adata.obsp["a"] == np.ones((M, M))) assert np.all(adata.varp["a"] == np.ones((N, N))) h = joblib.hash(adata) with pytest.raises(ValueError, match=r"incorrect shape"): adata.obsp["b"] = np.ones((int(M / 2), M)) with pytest.raises(ValueError, match=r"incorrect shape"): adata.obsp["b"] = np.ones((M, int(M * 2))) with pytest.raises(ValueError, match=r"incorrect shape"): adata.varp["b"] = np.ones((int(N / 2), 10)) with pytest.raises(ValueError, match=r"incorrect shape"): adata.varp["b"] = np.ones((N, int(N * 2))) assert h == joblib.hash(adata) def test_setting_sparse(adata: AnnData): obsp_sparse = sparse.random(M, M, format="csr") adata.obsp["a"] = obsp_sparse assert not np.any((adata.obsp["a"] != obsp_sparse).data) varp_sparse = sparse.random(N, N, format="csr") adata.varp["a"] = varp_sparse assert not np.any((adata.varp["a"] != varp_sparse).data) h = joblib.hash(adata) bad_obsp_sparse = sparse.random(M * 2, M, format="csr") with pytest.raises(ValueError, match=r"incorrect shape"): adata.obsp["b"] = bad_obsp_sparse bad_varp_sparse = sparse.random(N * 2, N, format="csr") with pytest.raises(ValueError, match=r"incorrect shape"): adata.varp["b"] = bad_varp_sparse assert h == joblib.hash(adata) @pytest.mark.parametrize(("field", "dim"), [("obsp", M), ("varp", N)]) @pytest.mark.parametrize( ("df", "homogenous", "dtype"), [ (lambda dim: gen_typed_df_t2_size(dim, dim), True, np.object_), (lambda dim: pd.DataFrame(np.random.randn(dim, dim)), False, np.floating), ], ids=["heterogeneous", "homogeneous"], ) def test_setting_dataframe(adata: AnnData, field, dim, homogenous, df, dtype): if homogenous: with pytest.warns(UserWarning, match=rf"{field.title()} 'df'.*dtype object"): getattr(adata, field)["df"] = df(dim) else: with warnings.catch_warnings(): warnings.simplefilter("error") getattr(adata, field)["df"] = df(dim) assert isinstance(getattr(adata, field)["df"], np.ndarray) assert np.issubdtype(getattr(adata, field)["df"].dtype, dtype) def test_setting_daskarray(adata: AnnData): import dask.array as da adata.obsp["a"] = da.ones((M, M)) adata.varp["a"] = da.ones((N, N)) assert da.all(adata.obsp["a"] == da.ones((M, M))) assert da.all(adata.varp["a"] == da.ones((N, N))) assert isinstance(adata.obsp["a"], da.Array) assert isinstance(adata.varp["a"], da.Array) h = joblib.hash(adata) with pytest.raises(ValueError, match=r"incorrect shape"): adata.obsp["b"] = da.ones((int(M / 2), M)) with pytest.raises(ValueError, match=r"incorrect shape"): adata.obsp["b"] = da.ones((M, int(M * 2))) with pytest.raises(ValueError, match=r"incorrect shape"): adata.varp["b"] = da.ones((int(N / 2), 10)) with pytest.raises(ValueError, match=r"incorrect shape"): adata.varp["b"] = da.ones((N, int(N * 2))) assert h == joblib.hash(adata) def test_shape_error(adata: AnnData): with pytest.raises( ValueError, match=( r"Value passed for key 'a' is of incorrect shape\. " r"Values of obsp must match dimensions \('obs', 'obs'\) of parent\. " r"Value had shape \(201, 200\) while it should have had \(200, 200\)\." ), ): adata.obsp["a"] = np.zeros((adata.shape[0] + 1, adata.shape[0])) scverse-anndata-b796d59/tests/test_raw.py000066400000000000000000000122521512025555600205170ustar00rootroot00000000000000from __future__ import annotations import numpy as np import pytest import anndata as ad from anndata import ImplicitModificationWarning from anndata.tests.helpers import GEN_ADATA_DASK_ARGS, assert_equal, gen_adata # ------------------------------------------------------------------------------- # Some test data # ------------------------------------------------------------------------------- data = [ [1, 2, 3], [4, 5, 6], [7, 8, 9], ] # data matrix of shape n_obs × n_vars obs_dict = dict( # annotation of observations / rows row_names=["name1", "name2", "name3"], # row annotation oanno1=["cat1", "cat2", "cat2"], # categorical annotation oanno2=["o1", "o2", "o3"], # string annotation oanno3=[2.1, 2.2, 2.3], # float annotation ) var_dict = dict( # annotation of variables / columns col_names=["var1", "var2", "var3"], vanno1=[3.1, 3.2, 3.3] ) uns_dict = dict( # unstructured annotation oanno1_colors=["#000000", "#FFFFFF"], uns2=["some annotation"] ) @pytest.fixture def adata_raw() -> ad.AnnData: adata = ad.AnnData( np.array(data, dtype="int32"), obs=obs_dict, var=var_dict, uns=uns_dict ) adata.raw = adata.copy() # Make them different shapes adata = adata[:, [0, 1]].copy() return adata # ------------------------------------------------------------------------------- # The test functions # ------------------------------------------------------------------------------- def test_raw_init(adata_raw: ad.AnnData): assert adata_raw.var_names.tolist() == ["var1", "var2"] assert adata_raw.raw.var_names.tolist() == ["var1", "var2", "var3"] assert adata_raw.raw[:, 0].X.tolist() == [[1], [4], [7]] def test_raw_del(adata_raw: ad.AnnData): del adata_raw.raw assert adata_raw.raw is None def test_raw_set_as_none(adata_raw: ad.AnnData): # Test for scverse/anndata#445 a = adata_raw b = adata_raw.copy() del a.raw b.raw = None assert_equal(a, b) def test_raw_of_view(adata_raw: ad.AnnData): adata_view = adata_raw[adata_raw.obs["oanno1"] == "cat2"] assert adata_view.raw.X.tolist() == [ [4, 5, 6], [7, 8, 9], ] def test_raw_rw(adata_raw: ad.AnnData, backing_h5ad): adata_raw.write(backing_h5ad) adata_read = ad.read_h5ad(backing_h5ad) assert_equal(adata_read, adata_raw, exact=True) assert adata_raw.var_names.tolist() == ["var1", "var2"] assert adata_raw.raw.var_names.tolist() == ["var1", "var2", "var3"] assert adata_raw.raw[:, 0].X.tolist() == [[1], [4], [7]] def test_raw_view_rw(adata_raw: ad.AnnData, backing_h5ad): # Make sure it still writes correctly if the object is a view adata_raw_view = adata_raw[:, adata_raw.var_names] assert_equal(adata_raw_view, adata_raw) with pytest.warns( ImplicitModificationWarning, match=r"initializing view as actual" ): adata_raw_view.write(backing_h5ad) adata_read = ad.read_h5ad(backing_h5ad) assert_equal(adata_read, adata_raw_view, exact=True) assert adata_raw.var_names.tolist() == ["var1", "var2"] assert adata_raw.raw.var_names.tolist() == ["var1", "var2", "var3"] assert adata_raw.raw[:, 0].X.tolist() == [[1], [4], [7]] def test_raw_backed(adata_raw: ad.AnnData, backing_h5ad): adata_raw.filename = backing_h5ad assert adata_raw.var_names.tolist() == ["var1", "var2"] assert adata_raw.raw.var_names.tolist() == ["var1", "var2", "var3"] if adata_raw.raw[:, 0].X.shape[1] != 1: pytest.xfail("Raw is broken for backed slices") assert adata_raw.raw[:, 0].X[:].tolist() == [[1], [4], [7]] def test_raw_view_backed(adata_raw: ad.AnnData, backing_h5ad): adata_raw.filename = backing_h5ad assert adata_raw.var_names.tolist() == ["var1", "var2"] assert adata_raw.raw.var_names.tolist() == ["var1", "var2", "var3"] if adata_raw.raw[:, 0].X.shape[1] != 1: pytest.xfail("Raw is broken for backed slices") assert adata_raw.raw[:, 0].X[:].tolist() == [[1], [4], [7]] def test_raw_as_parent_view(): # https://github.com/scverse/anndata/issues/288 a = ad.AnnData(np.ones((4, 3))) a.varm["PCs"] = np.ones((3, 3)) a.raw = a.copy() # create a Raw containing views. This used to trigger #288. b = a.raw[:, "0"] # actualize b.varm["PCs"] = np.array([[1, 2, 3]]) def test_to_adata(): # https://github.com/scverse/anndata/pull/404 adata = gen_adata((20, 10), **GEN_ADATA_DASK_ARGS) with_raw = adata[:, ::2].copy() with_raw.raw = adata.copy() # Raw doesn't do layers or varp currently # Deleting after creation so we know to rewrite the test if they are supported del adata.layers, adata.varp assert_equal(adata, with_raw.raw.to_adata()) def test_to_adata_populates_obs(): adata = gen_adata((20, 10), **GEN_ADATA_DASK_ARGS) del adata.layers, adata.uns, adata.varp adata_w_raw = adata.copy() raw = adata.copy() del raw.obs, raw.obsm, raw.obsp, raw.uns adata_w_raw.raw = raw from_raw = adata_w_raw.raw.to_adata() assert_equal(adata, from_raw) def test_no_copy(): adata = gen_adata((20, 10), X_type=np.asarray) adata.raw = adata # no .copy() herer np.log1p(adata.X, out=adata.X) assert adata.X is adata.raw.X scverse-anndata-b796d59/tests/test_readwrite.py000066400000000000000000000754731512025555600217320ustar00rootroot00000000000000from __future__ import annotations import re import warnings from contextlib import contextmanager, nullcontext from functools import partial from importlib.util import find_spec from pathlib import Path from string import ascii_letters from typing import TYPE_CHECKING import h5py import numpy as np import pandas as pd import pytest import zarr import zarr.convenience from scipy.sparse import csc_array, csc_matrix, csr_array, csr_matrix import anndata as ad from anndata._io.specs.registry import IORegistryError from anndata._io.zarr import open_write_group from anndata.compat import ( CSArray, CSMatrix, DaskArray, ZarrArray, ZarrGroup, _read_attr, is_zarr_v2, ) from anndata.tests.helpers import ( GEN_ADATA_NO_XARRAY_ARGS, as_dense_dask_array, assert_equal, gen_adata, ) if TYPE_CHECKING: from collections.abc import Callable, Generator from typing import Literal HERE = Path(__file__).parent # ------------------------------------------------------------------------------ # Some test data # ------------------------------------------------------------------------------ X_sp = csr_matrix([[1, 0, 0], [3, 0, 0], [5, 6, 0], [0, 0, 0], [0, 0, 0]]) X_list = [[1, 0], [3, 0], [5, 6]] # data matrix of shape n_obs x n_vars obs_dict = dict( # annotation of observations / rows row_names=["name1", "name2", "name3"], # row annotation oanno1=["cat1", "cat2", "cat2"], # categorical annotation oanno1b=["cat1", "cat1", "cat1"], # categorical annotation with one category oanno1c=["cat1", "cat1", np.nan], # categorical annotation with a missing value oanno2=["o1", "o2", "o3"], # string annotation oanno3=[2.1, 2.2, 2.3], # float annotation oanno4=[3.3, 1.1, 2.2], # float annotation ) var_dict = dict( # annotation of variables / columns vanno1=[3.1, 3.2], vanno2=["cat1", "cat1"], # categorical annotation vanno3=[2.1, 2.2], # float annotation vanno4=[3.3, 1.1], # float annotation ) uns_dict = dict( # unstructured annotation oanno1_colors=["#000000", "#FFFFFF"], uns2=["some annotation"], uns3="another annotation", uns4=dict( a=1, b=[2, 3], c="4", d=["some", "strings"], e=np.ones(5), f=np.int32(7), g=[1, np.float32(2.5)], ), uns5=None, ) @pytest.fixture(params=[{}, dict(compression="gzip")]) def dataset_kwargs(request): return request.param @pytest.fixture def rw(backing_h5ad): M, N = 100, 101 orig = gen_adata((M, N), **GEN_ADATA_NO_XARRAY_ARGS) orig.write(backing_h5ad) curr = ad.read_h5ad(backing_h5ad) return curr, orig @contextmanager def open_store( path: Path, diskfmt: Literal["h5ad", "zarr"] ) -> Generator[h5py.File | zarr.Group, None, None]: f = zarr.open_group(path) if diskfmt == "zarr" else h5py.File(path, "r") with f if isinstance(f, h5py.File) else nullcontext(): yield f @pytest.fixture(params=[np.uint8, np.int32, np.int64, np.float32, np.float64]) def dtype(request): return request.param # ------------------------------------------------------------------------------ # The test functions # ------------------------------------------------------------------------------ @pytest.mark.parametrize("typ", [np.array, csr_matrix, csr_array, as_dense_dask_array]) def test_readwrite_roundtrip(typ, tmp_path, diskfmt, diskfmt2): pth1 = tmp_path / f"first.{diskfmt}" write1 = lambda x: getattr(x, f"write_{diskfmt}")(pth1) read1 = lambda: getattr(ad, f"read_{diskfmt}")(pth1) pth2 = tmp_path / f"second.{diskfmt2}" write2 = lambda x: getattr(x, f"write_{diskfmt2}")(pth2) read2 = lambda: getattr(ad, f"read_{diskfmt2}")(pth2) adata1 = ad.AnnData(typ(X_list), obs=obs_dict, var=var_dict, uns=uns_dict) write1(adata1) adata2 = read1() write2(adata2) adata3 = read2() assert_equal(adata2, adata1) assert_equal(adata3, adata1) assert_equal(adata2, adata1) @pytest.mark.zarr_io def test_readwrite_roundtrip_async(tmp_path): import asyncio async def _do_test(): zarr_path = tmp_path / "first.zarr" adata1 = ad.AnnData( csr_matrix(X_list), obs=obs_dict, var=var_dict, uns=uns_dict ) adata1.write_zarr(zarr_path) adata2 = ad.read_zarr(zarr_path) assert_equal(adata2, adata1) # This test ensures our file i/o never calls `asyncio.run` internally asyncio.run(_do_test()) @pytest.mark.parametrize("storage", ["h5ad", "zarr"]) @pytest.mark.parametrize("typ", [np.array, csr_matrix, csr_array, as_dense_dask_array]) def test_readwrite_kitchensink(tmp_path, storage, typ, backing_h5ad, dataset_kwargs): X = typ(X_list) adata_src = ad.AnnData(X, obs=obs_dict, var=var_dict, uns=uns_dict) assert not isinstance(adata_src.obs["oanno1"].dtype, pd.CategoricalDtype) adata_src.raw = adata_src.copy() if storage == "h5ad": adata_src.write(backing_h5ad, **dataset_kwargs) adata_mid = ad.read_h5ad(backing_h5ad) adata_mid.write(tmp_path / "mid.h5ad", **dataset_kwargs) adata = ad.read_h5ad(tmp_path / "mid.h5ad") else: adata_src.write_zarr(tmp_path / "test_zarr_dir") adata = ad.read_zarr(tmp_path / "test_zarr_dir") assert isinstance(adata.obs["oanno1"].dtype, pd.CategoricalDtype) assert not isinstance(adata.obs["oanno2"].dtype, pd.CategoricalDtype) assert adata.obs.index.tolist() == ["name1", "name2", "name3"] assert adata.obs["oanno1"].cat.categories.tolist() == ["cat1", "cat2"] assert adata.obs["oanno1c"].cat.categories.tolist() == ["cat1"] assert isinstance(adata.raw.var["vanno2"].dtype, pd.CategoricalDtype) pd.testing.assert_frame_equal(adata.obs, adata_src.obs) pd.testing.assert_frame_equal(adata.var, adata_src.var) assert_equal(adata.var.index, adata_src.var.index) assert adata.var.index.dtype == adata_src.var.index.dtype # Dev. Note: # either load as same type or load the convert DaskArray to array # since we tested if assigned types and loaded types are DaskArray # this would also work if they work if isinstance(adata_src.raw.X, CSArray): assert isinstance(adata.raw.X, CSMatrix) else: assert isinstance(adata_src.raw.X, type(adata.raw.X) | DaskArray) assert isinstance( adata_src.uns["uns4"]["c"], type(adata.uns["uns4"]["c"]) | DaskArray ) assert isinstance(adata_src.varm, type(adata.varm) | DaskArray) assert_equal(adata.raw.X, adata_src.raw.X) pd.testing.assert_frame_equal(adata.raw.var, adata_src.raw.var) assert isinstance(adata.uns["uns4"]["a"], int | np.integer) assert isinstance(adata_src.uns["uns4"]["a"], int | np.integer) assert_equal(adata, adata_src) @pytest.mark.parametrize("typ", [np.array, csr_matrix, csr_array, as_dense_dask_array]) def test_readwrite_maintain_X_dtype(typ, backing_h5ad): X = typ(X_list).astype("int8") adata_src = ad.AnnData(X) adata_src.write(backing_h5ad) adata = ad.read_h5ad(backing_h5ad) assert adata.X.dtype == adata_src.X.dtype def test_read_write_maintain_obsmvarm_dtypes(rw): curr, orig = rw assert type(orig.obsm["array"]) is type(curr.obsm["array"]) assert np.all(orig.obsm["array"] == curr.obsm["array"]) assert np.all(orig.varm["array"] == curr.varm["array"]) assert type(orig.obsm["sparse"]) is type(curr.obsm["sparse"]) assert not np.any((orig.obsm["sparse"] != curr.obsm["sparse"]).toarray()) assert not np.any((orig.varm["sparse"] != curr.varm["sparse"]).toarray()) assert type(orig.obsm["df"]) is type(curr.obsm["df"]) assert np.all(orig.obsm["df"] == curr.obsm["df"]) assert np.all(orig.varm["df"] == curr.varm["df"]) def test_maintain_layers(rw): curr, orig = rw assert type(orig.layers["array"]) is type(curr.layers["array"]) assert np.all(orig.layers["array"] == curr.layers["array"]) assert type(orig.layers["sparse"]) is type(curr.layers["sparse"]) assert not np.any((orig.layers["sparse"] != curr.layers["sparse"]).toarray()) @pytest.mark.parametrize("typ", [np.array, csr_matrix, csr_array, as_dense_dask_array]) def test_readwrite_h5ad_one_dimension(typ, backing_h5ad): X = typ(X_list) adata_src = ad.AnnData(X, obs=obs_dict, var=var_dict, uns=uns_dict) adata_one = adata_src[:, 0].copy() adata_one.write(backing_h5ad) adata = ad.read_h5ad(backing_h5ad) assert adata.shape == (3, 1) assert_equal(adata, adata_one) @pytest.mark.parametrize("typ", [np.array, csr_matrix, csr_array, as_dense_dask_array]) def test_readwrite_backed(typ, backing_h5ad): X = typ(X_list) adata_src = ad.AnnData(X, obs=obs_dict, var=var_dict, uns=uns_dict) adata_src.filename = backing_h5ad # change to backed mode adata_src.write() adata = ad.read_h5ad(backing_h5ad) assert isinstance(adata.obs["oanno1"].dtype, pd.CategoricalDtype) assert not isinstance(adata.obs["oanno2"].dtype, pd.CategoricalDtype) assert adata.obs.index.tolist() == ["name1", "name2", "name3"] assert adata.obs["oanno1"].cat.categories.tolist() == ["cat1", "cat2"] assert_equal(adata, adata_src) @pytest.mark.parametrize( "typ", [np.array, csr_matrix, csc_matrix, csr_array, csc_array] ) def test_readwrite_equivalent_h5ad_zarr(tmp_path, typ): h5ad_pth = tmp_path / "adata.h5ad" zarr_pth = tmp_path / "adata.zarr" M, N = 100, 101 adata = gen_adata((M, N), X_type=typ, **GEN_ADATA_NO_XARRAY_ARGS) adata.raw = adata.copy() adata.write_h5ad(h5ad_pth) adata.write_zarr(zarr_pth) from_h5ad = ad.read_h5ad(h5ad_pth) from_zarr = ad.read_zarr(zarr_pth) assert_equal(from_h5ad, from_zarr, exact=True) @contextmanager def store_context(path: Path): if path.suffix == ".zarr": store = open_write_group(path, mode="r+") else: file = h5py.File(path, "r+") store = file["/"] yield store if "file" in locals(): file.close() @pytest.mark.parametrize( ("name", "read", "write"), [ ("adata.h5ad", ad.read_h5ad, ad.AnnData.write_h5ad), ("adata.zarr", ad.read_zarr, ad.AnnData.write_zarr), ], ) def test_read_full_io_error(tmp_path, name, read, write): adata = gen_adata((4, 3), **GEN_ADATA_NO_XARRAY_ARGS) path = tmp_path / name write(adata, path) with store_context(path) as store: if not is_zarr_v2() and isinstance(store, ZarrGroup): # see https://github.com/zarr-developers/zarr-python/issues/2716 for the issue # with re-opening without syncing attributes explicitly # TODO: Having to fully specify attributes to not override fixed in zarr v3.0.5 # See https://github.com/zarr-developers/zarr-python/pull/2870 store["obs"].update_attributes({ **dict(store["obs"].attrs), "encoding-type": "invalid", }) zarr.consolidate_metadata(store.store) else: store["obs"].attrs["encoding-type"] = "invalid" with pytest.raises( IORegistryError, match=r"raised while reading key 'obs'.*from /$", ) as exc_info: read(path) assert re.search( r"No read method registered for IOSpec\(encoding_type='invalid', encoding_version='0.2.0'\)", str(exc_info.value), ) @pytest.mark.parametrize( ("compression", "compression_opts"), [ (None, None), ("lzf", None), ("gzip", None), ("gzip", 8), ], ) def test_hdf5_compression_opts(tmp_path, compression, compression_opts): # https://github.com/scverse/anndata/issues/497 pth = Path(tmp_path) / "adata.h5ad" adata = gen_adata((10, 8), **GEN_ADATA_NO_XARRAY_ARGS) kwargs = {} if compression is not None: kwargs["compression"] = compression if compression_opts is not None: kwargs["compression_opts"] = compression_opts not_compressed = [] adata.write_h5ad(pth, **kwargs) def check_compressed(key, value): if not isinstance(value, h5py.Dataset) or value.shape == (): return if (compression is not None and value.compression != compression) or ( compression_opts is not None and value.compression_opts != compression_opts ): not_compressed.append(key) with h5py.File(pth) as f: f.visititems(check_compressed) if not_compressed: sep = "\n\t" msg = ( f"These elements were not compressed correctly:{sep}" f"{sep.join(not_compressed)}" ) raise AssertionError(msg) expected = ad.read_h5ad(pth) assert_equal(adata, expected) @pytest.mark.parametrize("zarr_write_format", [2, 3]) def test_zarr_compression(tmp_path, zarr_write_format): if zarr_write_format == 3 and is_zarr_v2(): pytest.xfail("Cannot write zarr v3 format with v2 package") ad.settings.zarr_write_format = zarr_write_format pth = str(Path(tmp_path) / "adata.zarr") adata = gen_adata((10, 8), **GEN_ADATA_NO_XARRAY_ARGS) if zarr_write_format == 2 or is_zarr_v2(): from numcodecs import Blosc compressor = Blosc(cname="zstd", clevel=3, shuffle=Blosc.BITSHUFFLE) else: from zarr.codecs import ZstdCodec # Don't use Blosc since it's defaults can change: # https://github.com/zarr-developers/zarr-python/pull/3545 compressor = ZstdCodec(level=3, checksum=True) not_compressed = [] ad.io.write_zarr(pth, adata, compressor=compressor) def check_compressed(value, key): if not isinstance(value, ZarrArray) or value.shape == (): return None (read_compressor,) = value.compressors if zarr_write_format == 2: if read_compressor != compressor: not_compressed.append(key) return None if read_compressor.to_dict() != compressor.to_dict(): print(read_compressor.to_dict(), compressor.to_dict()) not_compressed.append(key) if is_zarr_v2(): with zarr.open(pth, "r") as f: f.visititems(check_compressed) else: f = zarr.open(pth, mode="r") for key, value in f.members(max_depth=None): check_compressed(value, key) if not_compressed: sep = "\n\t" msg = ( f"These elements were not compressed correctly:{sep}" f"{sep.join(not_compressed)}" ) raise AssertionError(msg) expected = ad.read_zarr(pth) assert_equal(adata, expected) def test_changed_obs_var_names(tmp_path, diskfmt): filepth = tmp_path / f"test.{diskfmt}" orig = gen_adata((10, 10), **GEN_ADATA_NO_XARRAY_ARGS) orig.obs_names.name = "obs" orig.var_names.name = "var" modified = orig.copy() modified.obs_names.name = "cells" modified.var_names.name = "genes" getattr(orig, f"write_{diskfmt}")(filepth) read = getattr(ad, f"read_{diskfmt}")(filepth) assert_equal(orig, read, exact=True) assert orig.var.index.name == "var" assert read.obs.index.name == "obs" with pytest.raises(AssertionError): assert_equal(orig, modified, exact=True) with pytest.raises(AssertionError): assert_equal(read, modified, exact=True) def test_read_csv(): adata = ad.io.read_csv(HERE / "data" / "adata.csv") assert adata.obs_names.tolist() == ["r1", "r2", "r3"] assert adata.var_names.tolist() == ["c1", "c2"] assert adata.X.tolist() == X_list def test_read_tsv_strpath(): adata = ad.io.read_text(str(HERE / "data" / "adata-comments.tsv"), "\t") assert adata.obs_names.tolist() == ["r1", "r2", "r3"] assert adata.var_names.tolist() == ["c1", "c2"] assert adata.X.tolist() == X_list def test_read_tsv_iter(): with (HERE / "data" / "adata-comments.tsv").open() as f: adata = ad.io.read_text(f, "\t") assert adata.obs_names.tolist() == ["r1", "r2", "r3"] assert adata.var_names.tolist() == ["c1", "c2"] assert adata.X.tolist() == X_list @pytest.mark.parametrize("typ", [np.array, csr_matrix]) def test_write_csv(typ, tmp_path): X = typ(X_list) adata = ad.AnnData(X, obs=obs_dict, var=var_dict, uns=uns_dict) adata.write_csvs(tmp_path / "test_csv_dir", skip_data=False) @pytest.mark.parametrize("typ", [np.array, csr_matrix]) def test_write_csv_view(typ, tmp_path): # https://github.com/scverse/anndata/issues/401 import hashlib def md5_path(pth: Path) -> bytes: checksum = hashlib.md5() with pth.open("rb") as f: while True: buf = f.read(checksum.block_size * 100) if not buf: break checksum.update(buf) return checksum.digest() def hash_dir_contents(dir: Path) -> dict[str, bytes]: root_pth = str(dir) return { str(k)[len(root_pth) :]: md5_path(k) for k in dir.rglob("*") if k.is_file() } adata = ad.AnnData(typ(X_list), obs=obs_dict, var=var_dict, uns=uns_dict) # Test writing a view view_pth = tmp_path / "test_view_csv_dir" copy_pth = tmp_path / "test_copy_csv_dir" adata[::2].write_csvs(view_pth, skip_data=False) adata[::2].copy().write_csvs(copy_pth, skip_data=False) assert hash_dir_contents(view_pth) == hash_dir_contents(copy_pth) @pytest.mark.parametrize( ("read", "write", "name"), [ pytest.param(ad.read_h5ad, ad.io.write_h5ad, "test_empty.h5ad"), pytest.param( ad.io.read_loom, ad.io.write_loom, "test_empty.loom", marks=pytest.mark.xfail(reason="Loom can’t handle 0×0 matrices"), ), pytest.param(ad.read_zarr, ad.io.write_zarr, "test_empty.zarr"), ], ) def test_readwrite_empty(read, write, name, tmp_path): adata = ad.AnnData(uns=dict(empty=np.array([], dtype=float))) write(tmp_path / name, adata) ad_read = read(tmp_path / name) assert ad_read.uns["empty"].shape == (0,) def test_read_excel(): with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=r"datetime.datetime.utcnow\(\) is deprecated", category=DeprecationWarning, ) adata = ad.io.read_excel(HERE / "data/excel.xlsx", "Sheet1", dtype=int) assert adata.X.tolist() == X_list def test_read_umi_tools(): adata = ad.io.read_umi_tools(HERE / "data/umi_tools.tsv.gz") assert adata.obs_names.name == "cell" assert adata.var_names.name == "gene" assert adata.shape == (2, 13) assert "ENSG00000070404.9" in adata.var_names assert set(adata.obs_names) == {"ACAAGG", "TTCACG"} @pytest.mark.parametrize("s2c", [True, False], ids=["str2cat", "preserve"]) def test_write_categorical( *, tmp_path: Path, diskfmt: Literal["h5ad", "zarr"], s2c: bool ) -> None: adata_pth = tmp_path / f"adata.{diskfmt}" obs = dict( str=pd.array(["a", "a", "b", pd.NA, pd.NA], dtype="string"), cat=pd.Categorical(["a", "a", "b", np.nan, np.nan]), **(dict(obj=["a", "a", "b", np.nan, np.nan]) if s2c else {}), ) orig = ad.AnnData(obs=pd.DataFrame(obs)) with ad.settings.override(allow_write_nullable_strings=True): getattr(orig, f"write_{diskfmt}")( adata_pth, convert_strings_to_categoricals=s2c ) curr: ad.AnnData = getattr(ad, f"read_{diskfmt}")(adata_pth) assert np.all(orig.obs.notna() == curr.obs.notna()) assert np.all(orig.obs.stack().dropna() == curr.obs.stack().dropna()) assert curr.obs["str"].dtype == ("category" if s2c else "string") assert curr.obs["cat"].dtype == "category" def test_write_categorical_index(tmp_path, diskfmt): adata_pth = tmp_path / f"adata.{diskfmt}" orig = ad.AnnData( uns={"df": pd.DataFrame({}, index=pd.Categorical(list("aabcd")))}, ) getattr(orig, f"write_{diskfmt}")(adata_pth) curr = getattr(ad, f"read_{diskfmt}")(adata_pth) # Also covered by next assertion, but checking this value specifically pd.testing.assert_index_equal( orig.uns["df"].index, curr.uns["df"].index, exact=True ) assert_equal(orig, curr, exact=True) @pytest.mark.parametrize("colname", ["_index"]) @pytest.mark.parametrize("attr", ["obs", "varm_df"]) def test_dataframe_reserved_columns(tmp_path, diskfmt, colname, attr): adata_pth = tmp_path / f"adata.{diskfmt}" orig = ad.AnnData( obs=pd.DataFrame(index=np.arange(5)), var=pd.DataFrame(index=np.arange(5)) ) to_write = orig.copy() if attr == "obs": to_write.obs[colname] = np.ones(5) elif attr == "varm_df": to_write.varm["df"] = pd.DataFrame( {colname: list("aabcd")}, index=to_write.var_names ) else: pytest.fail(f"Unexpected attr: {attr}") with pytest.raises(ValueError, match=rf"{colname}.*reserved name"): getattr(to_write, f"write_{diskfmt}")(adata_pth) def test_write_large_categorical(tmp_path, diskfmt): M = 30_000 N = 1000 ls = np.array(list(ascii_letters)) def random_cats(n): cats = { "".join(np.random.choice(ls, np.random.choice(range(5, 30)))) for _ in range(n) } while len(cats) < n: # For the rare case that there’s duplicates cats |= random_cats(n - len(cats)) return cats cats = np.array(sorted(random_cats(10_000))) adata_pth = tmp_path / f"adata.{diskfmt}" n_cats = len(np.unique(cats)) orig = ad.AnnData( csr_matrix(([1], ([0], [0])), shape=(M, N)), obs=dict( cat1=cats[np.random.choice(n_cats, M)], cat2=pd.Categorical.from_codes(np.random.choice(n_cats, M), cats), ), ) getattr(orig, f"write_{diskfmt}")(adata_pth) curr = getattr(ad, f"read_{diskfmt}")(adata_pth) assert_equal(orig, curr) def test_write_string_type_error(tmp_path, diskfmt): adata = ad.AnnData(obs=dict(obs_names=list("abc"))) adata.obs[b"c"] = np.zeros(3) # This should error, and tell you which key is at fault with pytest.raises(TypeError, match=r"writing key 'obs'") as exc_info: getattr(adata, f"write_{diskfmt}")(tmp_path / f"adata.{diskfmt}") assert "b'c'" in str(exc_info.value) @pytest.mark.parametrize( "teststring", ["teststring", np.asarray(["test1", "test2", "test3"], dtype="object")], ) @pytest.mark.parametrize("encoding", ["ascii", "utf-8"]) @pytest.mark.parametrize("length", [None, 15]) def test_hdf5_attribute_conversion(tmp_path, teststring, encoding, length): with h5py.File(tmp_path / "attributes.h5", "w") as file: dset = file.create_dataset("dset", data=np.arange(10)) attrs = dset.attrs attrs.create( "string", teststring, dtype=h5py.h5t.string_dtype(encoding=encoding, length=length), ) assert_equal(teststring, _read_attr(attrs, "string")) @pytest.mark.zarr_io def test_zarr_chunk_X(tmp_path): import zarr zarr_pth = Path(tmp_path) / "test.zarr" adata = gen_adata((100, 100), X_type=np.array, **GEN_ADATA_NO_XARRAY_ARGS) adata.write_zarr(zarr_pth, chunks=(10, 10)) z = zarr.open(zarr_pth) assert z["X"].chunks == (10, 10) from_zarr = ad.read_zarr(zarr_pth) assert_equal(from_zarr, adata) def test_write_x_none(tmp_path: Path, diskfmt: Literal["h5ad", "zarr"]) -> None: adata = ad.AnnData(shape=(10, 10), obs={"a": np.ones(10)}, var={"b": np.ones(10)}) p = tmp_path / f"adata.{diskfmt}" write = getattr(adata, f"write_{diskfmt}") write(p) with open_store(p, diskfmt) as f: root_keys = list(f.keys()) assert "X" not in root_keys ################################ # Round-tripping scanpy datasets ################################ def _do_roundtrip( adata: ad.AnnData, pth: Path, diskfmt: Literal["h5ad", "zarr"] ) -> ad.AnnData: getattr(adata, f"write_{diskfmt}")(pth) return getattr(ad, f"read_{diskfmt}")(pth) @pytest.fixture def roundtrip( diskfmt: Literal["h5ad", "zarr"], ) -> Callable[[ad.AnnData, Path], ad.AnnData]: return partial(_do_roundtrip, diskfmt=diskfmt) @pytest.fixture def roundtrip2(diskfmt2): return partial(_do_roundtrip, diskfmt=diskfmt2) def test_write_string_types(tmp_path, diskfmt, roundtrip): # https://github.com/scverse/anndata/issues/456 adata_pth = tmp_path / f"adata.{diskfmt}" adata = ad.AnnData( obs=pd.DataFrame( np.ones((3, 2)), columns=["a", np.str_("b")], index=["a", "b", "c"], ), ) from_disk = roundtrip(adata, adata_pth) assert_equal(adata, from_disk) @pytest.mark.skipif(not find_spec("scanpy"), reason="Scanpy is not installed") def test_scanpy_pbmc68k(tmp_path, diskfmt, roundtrip, diskfmt2, roundtrip2): import scanpy as sc with warnings.catch_warnings(): warnings.simplefilter("ignore", ad.OldFormatWarning) pbmc = sc.datasets.pbmc68k_reduced() # Do we read okay from_disk1 = roundtrip(pbmc, tmp_path / f"test1.{diskfmt}") # Can we round trip from_disk2 = roundtrip2(from_disk1, tmp_path / f"test2.{diskfmt2}") assert_equal(pbmc, from_disk1) # Not expected to be exact due to `nan`s assert_equal(pbmc, from_disk2) @pytest.mark.filterwarnings(r"ignore:Observation names are not unique:UserWarning") @pytest.mark.skipif(not find_spec("scanpy"), reason="Scanpy is not installed") def test_scanpy_krumsiek11( tmp_path: Path, diskfmt: Literal["h5ad", "zarr"], roundtrip: Callable[[ad.AnnData, Path], ad.AnnData], ) -> None: import scanpy as sc with warnings.catch_warnings(): warnings.filterwarnings( "ignore", r".*first_column_names.*no longer positional", FutureWarning ) orig = sc.datasets.krumsiek11() del orig.uns["highlights"] # Can’t write int keys # Depending on pd.options.future.infer_string, this becomes either `object` or `'string'` orig.var.columns = orig.var.columns.astype(str) with ad.settings.override(allow_write_nullable_strings=True): curr = roundtrip(orig, tmp_path / f"test.{diskfmt}") assert_equal(orig, curr, exact=True) # Checking if we can read legacy zarr files # TODO: Check how I should add this file to the repo @pytest.mark.filterwarnings("ignore::anndata.OldFormatWarning") @pytest.mark.skipif(not find_spec("scanpy"), reason="Scanpy is not installed") @pytest.mark.skipif( not Path(HERE / "data/pbmc68k_reduced_legacy.zarr.zip").is_file(), reason="File not present.", ) def test_backwards_compat_zarr() -> None: import scanpy as sc import zarr pbmc_orig = sc.datasets.pbmc68k_reduced() # Old zarr writer couldn’t do sparse arrays pbmc_orig.raw._X = pbmc_orig.raw.X.toarray() del pbmc_orig.uns["neighbors"] # Since these have moved, see PR #337 del pbmc_orig.obsp["distances"] del pbmc_orig.obsp["connectivities"] # This was written out with anndata=0.6.22.post1 zarrpth = HERE / "data/pbmc68k_reduced_legacy.zarr.zip" with zarr.ZipStore(zarrpth, mode="r") as z: pbmc_zarr = ad.read_zarr(z) assert_equal(pbmc_zarr, pbmc_orig) def test_adata_in_uns(tmp_path, diskfmt, roundtrip): pth = tmp_path / f"adatas_in_uns.{diskfmt}" orig = gen_adata((4, 5), **GEN_ADATA_NO_XARRAY_ARGS) orig.uns["adatas"] = { "a": gen_adata((1, 2), **GEN_ADATA_NO_XARRAY_ARGS), "b": gen_adata((2, 3), **GEN_ADATA_NO_XARRAY_ARGS), } another_one = gen_adata((2, 5), **GEN_ADATA_NO_XARRAY_ARGS) another_one.raw = gen_adata((2, 7), **GEN_ADATA_NO_XARRAY_ARGS) orig.uns["adatas"]["b"].uns["another_one"] = another_one curr = roundtrip(orig, pth) assert_equal(orig, curr) @pytest.mark.parametrize( "uns_val", [ pytest.param(dict(base=None), id="dict_val"), pytest.param( lambda: pd.DataFrame( dict(col_0=pd.array(["string", None], dtype="string")) ), id="df", ), ], ) def test_none_dict_value_in_uns(diskfmt, tmp_path, roundtrip, uns_val): if callable(uns_val): uns_val = uns_val() pth = tmp_path / f"adata_dtype.{diskfmt}" orig = ad.AnnData(np.ones((3, 4)), uns=dict(val=uns_val)) with ad.settings.override(allow_write_nullable_strings=True): curr = roundtrip(orig, pth) if isinstance(orig.uns["val"], pd.DataFrame): pd.testing.assert_frame_equal(curr.uns["val"], orig.uns["val"]) else: assert curr.uns["val"] == orig.uns["val"] def test_io_dtype(tmp_path, diskfmt, dtype, roundtrip): pth = tmp_path / f"adata_dtype.{diskfmt}" orig = ad.AnnData(np.ones((5, 8), dtype=dtype)) curr = roundtrip(orig, pth) assert curr.X.dtype == dtype def test_h5py_attr_limit(tmp_path): N = 10_000 a = ad.AnnData(np.ones((5, 10))) a.obsm["df"] = pd.DataFrame( np.ones((5, N)), index=a.obs_names, columns=[str(i) for i in range(N)] ) a.write(tmp_path / "tmp.h5ad") @pytest.mark.parametrize( "elem_key", ["obs", "var", "obsm", "varm", "layers", "obsp", "varp", "uns"] ) @pytest.mark.parametrize("store_type", ["zarr", "h5ad"]) @pytest.mark.parametrize("disallow_forward_slash_in_h5ad", [True, False]) def test_forward_slash_key( elem_key: Literal["obs", "var", "obsm", "varm", "layers", "obsp", "varp", "uns"], tmp_path: Path, store_type: Literal["zarr", "h5ad"], *, disallow_forward_slash_in_h5ad: bool, ): a = ad.AnnData(np.ones((10, 10))) getattr(a, elem_key)["bad/key"] = np.ones( (10,) if elem_key in ["obs", "var"] else (10, 10) ) with ( ad.settings.override( disallow_forward_slash_in_h5ad=disallow_forward_slash_in_h5ad ), pytest.raises(ValueError, match=r"Forward slashes") if store_type == "zarr" or disallow_forward_slash_in_h5ad else pytest.warns(FutureWarning, match=r"Forward slashes"), ): getattr(a, f"write_{store_type}")(tmp_path / "does_not_matter_the_path.h5ad") @pytest.mark.skipif( find_spec("xarray"), reason="Xarray is installed so `read_{elem_}lazy` will not error", ) @pytest.mark.parametrize( "func", [ad.experimental.read_lazy, ad.experimental.read_elem_lazy] ) def test_read_lazy_import_error(func, tmp_path): ad.AnnData(np.ones((10, 10))).write_zarr(tmp_path) with pytest.raises(ImportError, match="xarray"): func( zarr.open( tmp_path if func is ad.experimental.read_lazy else tmp_path / "obs" ) ) @pytest.mark.zarr_io def test_write_elem_consolidated(tmp_path: Path): ad.AnnData(np.ones((10, 10))).write_zarr(tmp_path) g = ( zarr.convenience.open_consolidated(tmp_path) if is_zarr_v2() else zarr.open(tmp_path) ) with pytest.raises( ValueError, match="Cannot overwrite/edit a store with consolidated metadata" ): ad.io.write_elem(g["obs"], "foo", np.arange(10)) @pytest.mark.zarr_io @pytest.mark.skipif(is_zarr_v2(), reason="zarr v3 package test") def test_write_elem_version_mismatch(tmp_path: Path): zarr_path = tmp_path / "foo.zarr" adata = ad.AnnData(np.ones((10, 10))) g = zarr.open_group( zarr_path, mode="w", zarr_format=2 if ad.settings.zarr_write_format == 3 else 3, ) ad.io.write_elem(g, "/", adata) adata_roundtripped = ad.read_zarr(g) assert_equal(adata_roundtripped, adata) scverse-anndata-b796d59/tests/test_repr.py000066400000000000000000000032671512025555600207040ustar00rootroot00000000000000from __future__ import annotations import re from string import ascii_letters import numpy as np import pandas as pd import pytest import anndata as ad ADATA_ATTRS = ("obs", "var", "varm", "obsm", "layers", "obsp", "varp", "uns") @pytest.fixture def adata(): return ad.AnnData( np.zeros((20, 10)), obs=pd.DataFrame( dict(obs_key=list(ascii_letters[:20])), index=[f"cell{i}" for i in range(20)], ), var=pd.DataFrame( dict(var_key=np.arange(10)), index=[f"gene{i}" for i in range(10)] ), varm=dict(varm_key=np.zeros((10, 20))), obsm=dict(obsm_key=np.zeros((20, 20))), layers=dict(layers_key=np.zeros((20, 10))), obsp=dict(obsp_key=np.zeros((20, 20))), varp=dict(varp_key=np.zeros((10, 10))), uns=dict(uns_key=dict(zip("abc", range(3), strict=True))), ) @pytest.fixture(params=ADATA_ATTRS) def adata_attr(request): return request.param def test_anndata_repr(adata): assert f"{adata.n_obs} × {adata.n_vars}" in repr(adata) for idxr in [ (slice(10, 20), 9), (12, 9), (["cell1", "cell2"], slice(10, 15)), ]: v = adata[idxr] v_repr = repr(v) assert f"{v.n_obs} × {v.n_vars}" in v_repr assert "View of" in v_repr for attr in ADATA_ATTRS: assert re.search( rf"^\s+{attr}:[^$]*{attr}_key.*$", v_repr, flags=re.MULTILINE ) def test_removal(adata, adata_attr): attr = adata_attr assert re.search(rf"^\s+{attr}:.*$", repr(adata), flags=re.MULTILINE) delattr(adata, attr) assert re.search(rf"^\s+{attr}:.*$", repr(adata), flags=re.MULTILINE) is None scverse-anndata-b796d59/tests/test_settings.py000066400000000000000000000211331512025555600215640ustar00rootroot00000000000000from __future__ import annotations import importlib import os import re import types from enum import Enum from pathlib import Path import pytest import anndata as ad from anndata._settings import ( SettingsManager, check_and_get_bool, check_and_get_environ_var, validate_bool, ) option = "test_var" default_val = False description = "My doc string!" option_2 = "test_var_2" default_val_2 = False description_2 = "My doc string 2!" option_3 = "test_var_3" default_val_3 = [1, 2] description_3 = "My doc string 3!" type_3 = list[int] def validate_int_list(val: list, settings: SettingsManager) -> bool: if not isinstance(val, list) or not [isinstance(type(e), int) for e in val]: msg = f"{val!r} is not a valid int list" raise TypeError(msg) return True @pytest.fixture def settings() -> SettingsManager: settings = SettingsManager() settings.register( option, default_value=default_val, description=description, validate=validate_bool, ) settings.register( option_2, default_value=default_val_2, description=description_2, validate=validate_bool, ) settings.register( option_3, default_value=default_val_3, description=description_3, validate=validate_int_list, option_type=type_3, ) return settings def test_register_option_default(settings: SettingsManager): assert getattr(settings, option) == default_val assert description in settings.describe(option) def test_register_with_env(settings: SettingsManager, monkeypatch: pytest.MonkeyPatch): option_env = "test_var_env" default_val_env = False description_env = "My doc string env!" option_env_var = "ANNDATA_" + option_env.upper() monkeypatch.setenv(option_env_var, "1") settings.register( option_env, default_value=default_val_env, description=description_env, validate=validate_bool, get_from_env=check_and_get_bool, ) assert settings.test_var_env def test_register_with_env_enum( settings: SettingsManager, monkeypatch: pytest.MonkeyPatch ): option_env = "test_var_env" default_val_env = False description_env = "My doc string env!" option_env_var = "ANNDATA_" + option_env.upper() monkeypatch.setenv(option_env_var, "b") class TestEnum(Enum): a = False b = True def check_and_get_bool_enum(option, default_value): return check_and_get_environ_var( "ANNDATA_" + option.upper(), "a", cast=TestEnum ).value settings.register( option_env, default_value=default_val_env, description=description_env, validate=validate_bool, get_from_env=check_and_get_bool_enum, ) assert settings.test_var_env def test_register_bad_option(settings: SettingsManager): with pytest.raises(TypeError, match=r"'foo' is not a valid int list"): settings.register( "test_var_4", default_value="foo", # should be a list of ints description=description_3, validate=validate_int_list, option_type=type_3, ) def test_set_option(settings: SettingsManager): setattr(settings, option, not default_val) assert getattr(settings, option) == (not default_val) settings.reset(option) assert getattr(settings, option) == default_val def test_dir(settings: SettingsManager): assert {option, option_2, option_3} <= set(dir(settings)) assert dir(settings) == sorted(dir(settings)) def test_reset_multiple(settings: SettingsManager): setattr(settings, option, not default_val) setattr(settings, option_2, not default_val_2) settings.reset([option, option_2]) assert getattr(settings, option) == default_val assert getattr(settings, option_2) == default_val_2 def test_get_unregistered_option(settings: SettingsManager): with pytest.raises(AttributeError): setattr(settings, option + "_different", default_val) def test_override(settings: SettingsManager): with settings.override(**{option: not default_val}): assert getattr(settings, option) == (not default_val) assert getattr(settings, option) == default_val def test_override_multiple(settings: SettingsManager): with settings.override(**{option: not default_val, option_2: not default_val_2}): assert getattr(settings, option) == (not default_val) assert getattr(settings, option_2) == (not default_val_2) assert getattr(settings, option) == default_val assert getattr(settings, option_2) == default_val_2 def test_deprecation(settings: SettingsManager): warning = "This is a deprecation warning!" version = "0.1.0" settings.deprecate(option, version, warning) described_option = settings.describe(option, should_print_description=False) # first line is message, second two from deprecation default_deprecation_message = f"{option} will be removed in {version}.*" assert described_option.endswith(default_deprecation_message) described_option = ( described_option.rstrip().removesuffix(default_deprecation_message).rstrip() ) assert described_option.endswith(warning) with pytest.warns( FutureWarning, match=r"'test_var' will be removed in 0\.1\.0\. This is a deprecation warning!", ): assert getattr(settings, option) == default_val def test_deprecation_no_message(settings: SettingsManager): version = "0.1.0" settings.deprecate(option, version) described_option = settings.describe(option, should_print_description=False) # first line is message, second from deprecation version assert described_option.endswith(f"{option} will be removed in {version}.*") def test_option_typing(settings: SettingsManager): assert settings._registered_options[option_3].type == type_3 assert str(type_3) in settings.describe(option_3, should_print_description=False) def test_check_and_get_environ_var(monkeypatch: pytest.MonkeyPatch): option_env_var = "ANNDATA_OPTION" assert hash("foo") == check_and_get_environ_var( option_env_var, "foo", ["foo", "bar"], lambda x: hash(x) ) monkeypatch.setenv(option_env_var, "bar") assert hash("bar") == check_and_get_environ_var( option_env_var, "foo", ["foo", "bar"], lambda x: hash(x) ) monkeypatch.setenv(option_env_var, "Not foo or bar") with pytest.warns( match=f"Value '{re.escape(os.environ[option_env_var])}' is not in allowed" ): check_and_get_environ_var( option_env_var, "foo", ["foo", "bar"], lambda x: hash(x) ) assert hash("Not foo or bar") == check_and_get_environ_var( option_env_var, "foo", cast=lambda x: hash(x) ) def test_check_and_get_bool(monkeypatch: pytest.MonkeyPatch): option_env_var = f"ANNDATA_{option.upper()}" assert not check_and_get_bool(option, default_val) monkeypatch.setenv(option_env_var, "1") assert check_and_get_bool(option, default_val) monkeypatch.setenv(option_env_var, "Not 0 or 1") with pytest.warns( match=f"Value '{re.escape(os.environ[option_env_var])}' is not in allowed" ): check_and_get_bool(option, default_val) def test_check_and_get_bool_enum(monkeypatch: pytest.MonkeyPatch): option_env_var = f"ANNDATA_{option.upper()}" monkeypatch.setenv(option_env_var, "b") class TestEnum(Enum): a = False b = True assert check_and_get_environ_var(option_env_var, "a", cast=TestEnum).value @pytest.mark.parametrize( ("as_rst", "expected"), [ pytest.param( True, ( ".. attribute:: settings.test_var_3\n" " :type: list[int]\n" " :value: [1, 2]\n" "\n" " My doc string 3!" ), id="rst", ), pytest.param( False, "test_var_3: `list[int]`\n My doc string 3! (default: `[1, 2]`).", id="plain", ), ], ) def test_describe(*, as_rst: bool, expected: str, settings: SettingsManager): assert settings.describe("test_var_3", as_rst=as_rst) == expected def test_hints(): settings = ad.settings types_loader = importlib.machinery.SourceFileLoader( "settings_types", Path(ad.__file__).parent / "_settings.pyi", ) settings_types_mod = types.ModuleType(types_loader.name) types_loader.exec_module(settings_types_mod) obj_attrs, typing_attrs = ( {k for k in dir(o) if not k.startswith("_")} for o in (settings, settings_types_mod._AnnDataSettingsManager) ) assert obj_attrs == typing_attrs scverse-anndata-b796d59/tests/test_structured_arrays.py000066400000000000000000000040641512025555600235150ustar00rootroot00000000000000from __future__ import annotations import warnings from itertools import combinations, product from typing import TYPE_CHECKING import numpy as np import anndata as ad from anndata import AnnData from anndata.compat import is_zarr_v2 from anndata.tests.helpers import gen_vstr_recarray if TYPE_CHECKING: from typing import Literal def assert_str_contents_equal(A, B): lA = [ [str(el) if not isinstance(el, bytes) else el.decode("utf-8") for el in a] for a in A ] lB = [ [str(el) if not isinstance(el, bytes) else el.decode("utf-8") for el in b] for b in B ] assert lA == lB def test_io( tmp_path, diskfmt: Literal["zarr", "h5ad"], diskfmt2: Literal["zarr", "h5ad"] ) -> None: if not is_zarr_v2(): from zarr.core.dtype.common import UnstableSpecificationWarning warnings.filterwarnings( # raised by “S10” dtype in the recarray below "default", r".*NullTerminatedBytes", UnstableSpecificationWarning ) read1 = lambda pth: getattr(ad, f"read_{diskfmt}")(pth) write1 = lambda adata, pth: getattr(adata, f"write_{diskfmt}")(pth) read2 = lambda pth: getattr(ad, f"read_{diskfmt2}")(pth) write2 = lambda adata, pth: getattr(adata, f"write_{diskfmt2}")(pth) filepth1 = tmp_path / f"test1.{diskfmt}" filepth2 = tmp_path / f"test2.{diskfmt2}" str_recarray = gen_vstr_recarray(3, 5) u_recarray = str_recarray.astype([(n, "U10") for n in str_recarray.dtype.fields]) s_recarray = str_recarray.astype([(n, "S10") for n in str_recarray.dtype.fields]) initial = AnnData(np.zeros((3, 3))) initial.uns = dict(str_rec=str_recarray, u_rec=u_recarray, s_rec=s_recarray) write1(initial, filepth1) disk_once = read1(filepth1) write2(disk_once, filepth2) disk_twice = read2(filepth2) adatas = [initial, disk_once, disk_twice] keys = [ "str_rec", "u_rec", # "s_rec" ] for (ad1, key1), (ad2, key2) in combinations(product(adatas, keys), 2): assert_str_contents_equal(ad1.uns[key1], ad2.uns[key2]) scverse-anndata-b796d59/tests/test_transpose.py000066400000000000000000000055461512025555600217540ustar00rootroot00000000000000from __future__ import annotations import numpy as np import pytest from scipy import sparse import anndata as ad from anndata.tests.helpers import assert_equal, gen_adata, shares_memory def test_transpose_orig(): """ Original test for transpose, should be covered by more thorough tests below, but keeping around just in case. """ adata = gen_adata((5, 3)) adata.varp = {f"varp_{k}": v for k, v in adata.varp.items()} adata1 = adata.T adata1.uns["test123"] = 1 assert "test123" in adata.uns assert_equal(adata1.X.shape, (3, 5)) assert_equal(adata1.obsp.keys(), adata.varp.keys()) def _add_raw(adata, *, var_subset=slice(None)): new = adata[:, var_subset].copy() new.raw = adata.copy() return new # TODO: Cases to add: # * Views # * X is None should have the xfail marker removed # * Backed @pytest.fixture( params=[ pytest.param(gen_adata((50, 20)), id="csr_X"), pytest.param(gen_adata((50, 20), sparse.csc_matrix), id="csc_X"), pytest.param(_add_raw(gen_adata((50, 20))), id="with_raw"), pytest.param(gen_adata((20, 10), X_type=None), id="None_X"), ] ) def adata(request): return request.param def test_transpose_doesnt_copy(): adata = ad.AnnData( sparse.random(50, 20, format="csr"), layers={ "sparse": sparse.random(50, 20, format="csc"), "dense": np.random.rand(50, 20), }, obsm={ "sparse": sparse.random(50, 10, format="csc"), "dense": np.random.rand(50, 10), }, obsp={ "sparse": sparse.random(50, 50, format="csc"), "dense": np.random.rand(50, 50), }, ) t = adata.T assert shares_memory(adata.X, t.X) for k in adata.obsm: assert shares_memory(adata.obsm[k], t.varm[k]) for k in adata.obsp: assert shares_memory(adata.obsp[k], t.varp[k]) for k in adata.layers: assert shares_memory(adata.layers[k], t.layers[k]) def test_transpose_removes_raw(adata): """ Since Raw must have the same `obs_names` as AnnData, but does not have the same `var_names`, transpose doesn't really make sense for Raw. So it should just get deleted. """ assert adata.T.raw is None def test_transposed_contents(adata): t = adata.T if adata.X is not None: assert_equal(adata.X.T, t.X) else: assert adata.X is t.X is None assert_equal({k: v.T for k, v in adata.layers.items()}, dict(t.layers)) assert_equal(adata.obs, t.var) assert_equal(adata.var, t.obs) assert_equal(dict(adata.obsm), dict(t.varm)) assert_equal(dict(adata.varm), dict(t.obsm)) assert_equal(dict(adata.obsp), dict(t.varp)) assert_equal(dict(adata.varp), dict(t.obsp)) assert_equal(adata.uns, t.uns) def test_transpose_roundtrip(adata): del adata.raw assert_equal(adata, adata.T.T) scverse-anndata-b796d59/tests/test_uns.py000066400000000000000000000031051512025555600205300ustar00rootroot00000000000000from __future__ import annotations import numpy as np import pandas as pd import pytest from anndata import AnnData from anndata.tests.helpers import assert_equal def test_uns_color_subset(): # Tests for https://github.com/scverse/anndata/issues/257 obs = pd.DataFrame( { "cat1": pd.Categorical(list("aabcd")), "cat2": pd.Categorical(list("aabbb")), }, index=[f"cell{i}" for i in range(5)], ) # If number of categories does not match number of colors, they should be reset wrong_color_length_adata = AnnData( np.ones((5, 5)), obs=obs, uns={ "cat1_colors": ["red", "green", "blue"], "cat2_colors": ["red", "green", "blue"], }, ) v = wrong_color_length_adata[:, [0, 1]] assert "cat1_colors" not in v.uns assert "cat2_colors" not in v.uns # Otherwise the colors should still match after resetting cat1_colors = np.array(["red", "green", "blue", "yellow"], dtype=object) adata = AnnData(np.ones((5, 5)), obs=obs, uns={"cat1_colors": cat1_colors.copy()}) for color, idx in [("red", [0, 1]), ("green", [2]), ("blue", [3]), ("yellow", [4])]: v = adata[idx, :] assert len(v.uns["cat1_colors"]) == 1 assert v.uns["cat1_colors"][0] == color c = v.copy() assert_equal(v.uns, c.uns, elem_name="uns") with pytest.raises(AssertionError): assert_equal(adata.uns, c.uns, elem_name="uns") # But original object should not change assert list(adata.uns["cat1_colors"]) == list(cat1_colors) scverse-anndata-b796d59/tests/test_utils.py000066400000000000000000000036331512025555600210710ustar00rootroot00000000000000from __future__ import annotations from itertools import repeat import pandas as pd import pytest from scipy import sparse import anndata as ad from anndata.tests.helpers import gen_typed_df from anndata.utils import make_index_unique def test_make_index_unique() -> None: index = pd.Index(["val", "val", pd.NA, "val-1", "val-1", pd.NA], dtype="string") with pytest.warns( UserWarning, match=r"Suffix used.*index values difficult to interpret" ): result = make_index_unique(index) expected = pd.Index( ["val", "val-2", pd.NA, "val-1", "val-1-1", pd.NA], dtype="string" ) pd.testing.assert_index_equal(result, expected) assert result[~result.isna()].is_unique def test_adata_unique_indices() -> None: m, n = (10, 20) obs_index = pd.Index(repeat("a", m), name="obs") var_index = pd.Index(repeat("b", n), name="var") adata = ad.AnnData( X=sparse.random(m, n, format="csr"), obs=gen_typed_df(m, index=obs_index), var=gen_typed_df(n, index=var_index), obsm={"df": gen_typed_df(m, index=obs_index)}, varm={"df": gen_typed_df(n, index=var_index)}, ) pd.testing.assert_index_equal(adata.obsm["df"].index, adata.obs_names) pd.testing.assert_index_equal(adata.varm["df"].index, adata.var_names) adata.var_names_make_unique() adata.obs_names_make_unique() assert adata.obs_names.name == "obs" assert adata.var_names.name == "var" assert len(pd.unique(adata.obs_names)) == m assert len(pd.unique(adata.var_names)) == n pd.testing.assert_index_equal(adata.obsm["df"].index, adata.obs_names) pd.testing.assert_index_equal(adata.varm["df"].index, adata.var_names) v = adata[:5, :5] assert v.obs_names.name == "obs" assert v.var_names.name == "var" pd.testing.assert_index_equal(v.obsm["df"].index, v.obs_names) pd.testing.assert_index_equal(v.varm["df"].index, v.var_names) scverse-anndata-b796d59/tests/test_views.py000066400000000000000000000706561512025555600210770ustar00rootroot00000000000000from __future__ import annotations from contextlib import nullcontext from copy import deepcopy from importlib.metadata import version from operator import mul from typing import TYPE_CHECKING import joblib import numpy as np import pandas as pd import pytest from dask.base import tokenize from packaging.version import Version from scipy import sparse import anndata as ad from anndata._core.index import _normalize_index from anndata._core.views import ( ArrayView, SparseCSCArrayView, SparseCSCMatrixView, SparseCSRArrayView, SparseCSRMatrixView, ) from anndata.compat import CSArray, CupyCSCMatrix, DaskArray from anndata.tests.helpers import ( BASE_MATRIX_PARAMS, CUPY_MATRIX_PARAMS, DASK_MATRIX_PARAMS, GEN_ADATA_DASK_ARGS, assert_equal, gen_adata, single_int_subset, single_subset, slice_int_subset, subset_func, ) from anndata.utils import asarray if TYPE_CHECKING: from types import EllipsisType IGNORE_SPARSE_EFFICIENCY_WARNING = pytest.mark.filterwarnings( "ignore:Changing the sparsity structure:scipy.sparse.SparseEfficiencyWarning" ) # ------------------------------------------------------------------------------ # Some test data # ------------------------------------------------------------------------------ # data matrix of shape n_obs x n_vars X_list = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] # annotation of observations / rows obs_dict = dict( row_names=["name1", "name2", "name3"], # row annotation oanno1=["cat1", "cat2", "cat2"], # categorical annotation oanno2=["o1", "o2", "o3"], # string annotation oanno3=[2.1, 2.2, 2.3], # float annotation ) # annotation of variables / columns var_dict = dict(vanno1=[3.1, 3.2, 3.3]) # unstructured annotation uns_dict = dict(oanno1_colors=["#000000", "#FFFFFF"], uns2=["some annotation"]) subset_func2 = subset_func class NDArraySubclass(np.ndarray): def view(self, dtype=None, typ=None): return self @pytest.fixture def adata() -> ad.AnnData: adata = ad.AnnData(np.zeros((100, 100))) adata.obsm["o"] = np.zeros((100, 50)) adata.varm["o"] = np.zeros((100, 50)) return adata @pytest.fixture(scope="session") def adata_gen_session(matrix_type) -> ad.AnnData: adata = gen_adata((30, 15), X_type=matrix_type) adata.raw = adata.copy() return adata @pytest.fixture def adata_gen(adata_gen_session: ad.AnnData) -> ad.AnnData: return adata_gen_session.copy() @pytest.fixture( params=BASE_MATRIX_PARAMS + DASK_MATRIX_PARAMS + CUPY_MATRIX_PARAMS, scope="session", ) def matrix_type(request): return request.param @pytest.fixture(params=BASE_MATRIX_PARAMS + DASK_MATRIX_PARAMS, scope="session") def matrix_type_no_gpu(request): return request.param @pytest.fixture(params=BASE_MATRIX_PARAMS, scope="session") def matrix_type_base(request): return request.param @pytest.fixture(params=["layers", "obsm", "varm"], scope="session") def mapping_name(request): return request.param # ------------------------------------------------------------------------------ # The test functions # ------------------------------------------------------------------------------ def test_views(): X = np.array(X_list, dtype="int32") adata = ad.AnnData(X, obs=obs_dict, var=var_dict, uns=uns_dict) assert adata[:, 0].is_view assert adata[:, 0].X.tolist() == np.reshape([1, 4, 7], (3, 1)).tolist() adata[:2, 0].X = [0, 0] assert adata[:, 0].X.tolist() == np.reshape([0, 0, 7], (3, 1)).tolist() adata_subset = adata[:2, [0, 1]] assert adata_subset.is_view # now transition to actual object with pytest.warns(ad.ImplicitModificationWarning, match=r".*\.obs.*"): adata_subset.obs["foo"] = range(2) assert not adata_subset.is_view assert adata_subset.obs["foo"].tolist() == list(range(2)) def test_convert_error(): adata = ad.AnnData(np.array([[1, 2], [3, 0]])) no_array = [[1], []] with pytest.raises(ValueError, match=r"Failed to convert"): adata[:, 0].X = no_array def test_view_subset_shapes(): adata = gen_adata((20, 10), **GEN_ADATA_DASK_ARGS) view = adata[:, ::2] assert view.var.shape == (5, 8) assert {k: v.shape[0] for k, v in view.varm.items()} == dict.fromkeys(view.varm, 5) def test_modify_view_component(matrix_type, mapping_name, request): adata = ad.AnnData( np.zeros((10, 10)), **{mapping_name: dict(m=matrix_type(asarray(sparse.random(10, 10))))}, ) # Fix if and when dask supports tokenizing GPU arrays # https://github.com/dask/dask/issues/6718 if isinstance(matrix_type(np.zeros((1, 1))), DaskArray): hash_func = tokenize else: hash_func = joblib.hash init_hash = hash_func(adata) subset = adata[:5, :][:, :5] assert subset.is_view m = getattr(subset, mapping_name)["m"] with pytest.warns(ad.ImplicitModificationWarning, match=rf".*\.{mapping_name}.*"): m[0, 0] = 100 assert not subset.is_view # TODO: Remove `raises` after https://github.com/scipy/scipy/pull/23626 becomes minimum version i.e., scipy 1.17. is_dask_with_broken_view_setting = ( "sparse_dask" in request.node.callspec.id and Version(version("dask")) >= Version("2025.02.0") ) is_sparse_array_in_lower_dask_version = ( not is_dask_with_broken_view_setting and isinstance(m, DaskArray) and isinstance(m._meta, CSArray) ) with ( pytest.raises(ValueError, match=r"shape mismatch") if Version(version("scipy")) < Version("1.17.0rc0") and (is_sparse_array_in_lower_dask_version or is_dask_with_broken_view_setting) else nullcontext() ): assert getattr(subset, mapping_name)["m"][0, 0] == 100 assert init_hash == hash_func(adata) @pytest.mark.parametrize("attr", ["obsm", "varm"]) def test_set_obsm_key(adata, attr): init_hash = joblib.hash(adata) orig_val = getattr(adata, attr)["o"].copy() subset = adata[:50] if attr == "obsm" else adata[:, :50] assert subset.is_view with pytest.warns(ad.ImplicitModificationWarning, match=rf".*\.{attr}\['o'\].*"): getattr(subset, attr)["o"] = new_val = np.ones((50, 20)) assert not subset.is_view assert np.all(getattr(adata, attr)["o"] == orig_val) assert np.any(getattr(subset, attr)["o"] == new_val) assert init_hash == joblib.hash(adata) def test_set_obs(adata, subset_func): init_hash = joblib.hash(adata) subset = adata[subset_func(adata.obs_names), :] new_obs = pd.DataFrame( dict(a=np.ones(subset.n_obs), b=np.ones(subset.n_obs)), index=subset.obs_names, ) assert subset.is_view subset.obs = new_obs assert not subset.is_view assert np.all(subset.obs == new_obs) assert joblib.hash(adata) == init_hash def test_set_var(adata, subset_func): init_hash = joblib.hash(adata) subset = adata[:, subset_func(adata.var_names)] new_var = pd.DataFrame( dict(a=np.ones(subset.n_vars), b=np.ones(subset.n_vars)), index=subset.var_names, ) assert subset.is_view subset.var = new_var assert not subset.is_view assert np.all(subset.var == new_var) assert joblib.hash(adata) == init_hash def test_drop_obs_column(): adata = ad.AnnData(np.array(X_list, dtype="int32"), obs=obs_dict) subset = adata[:2] assert subset.is_view # returns a copy of obs assert subset.obs.drop(columns=["oanno1"]).columns.tolist() == ["oanno2", "oanno3"] assert subset.is_view # would modify obs, so it should actualize subset and not modify adata subset.obs.drop(columns=["oanno1"], inplace=True) assert not subset.is_view assert subset.obs.columns.tolist() == ["oanno2", "oanno3"] assert adata.obs.columns.tolist() == ["oanno1", "oanno2", "oanno3"] def test_set_obsm(adata): init_hash = joblib.hash(adata) dim0_size = np.random.randint(2, adata.shape[0] - 1) dim1_size = np.random.randint(1, 99) orig_obsm_val = adata.obsm["o"].copy() subset_idx = np.random.choice(adata.obs_names, dim0_size, replace=False) subset = adata[subset_idx, :] assert subset.is_view subset.obsm = dict(o=np.ones((dim0_size, dim1_size))) assert not subset.is_view assert np.all(orig_obsm_val == adata.obsm["o"]) # Checking for mutation assert np.all(subset.obsm["o"] == np.ones((dim0_size, dim1_size))) subset = adata[subset_idx, :] subset_hash = joblib.hash(subset) with pytest.raises(ValueError, match=r"incorrect shape"): subset.obsm = dict(o=np.ones((dim0_size + 1, dim1_size))) with pytest.raises(ValueError, match=r"incorrect shape"): subset.varm = dict(o=np.ones((dim0_size - 1, dim1_size))) assert subset_hash == joblib.hash(subset) # Only modification have been made to a view assert init_hash == joblib.hash(adata) def test_set_varm(adata): init_hash = joblib.hash(adata) dim0_size = np.random.randint(2, adata.shape[1] - 1) dim1_size = np.random.randint(1, 99) orig_varm_val = adata.varm["o"].copy() subset_idx = np.random.choice(adata.var_names, dim0_size, replace=False) subset = adata[:, subset_idx] assert subset.is_view subset.varm = dict(o=np.ones((dim0_size, dim1_size))) assert not subset.is_view assert np.all(orig_varm_val == adata.varm["o"]) # Checking for mutation assert np.all(subset.varm["o"] == np.ones((dim0_size, dim1_size))) subset = adata[:, subset_idx] subset_hash = joblib.hash(subset) with pytest.raises(ValueError, match=r"incorrect shape"): subset.varm = dict(o=np.ones((dim0_size + 1, dim1_size))) with pytest.raises(ValueError, match=r"incorrect shape"): subset.varm = dict(o=np.ones((dim0_size - 1, dim1_size))) # subset should not be changed by failed setting assert subset_hash == joblib.hash(subset) assert init_hash == joblib.hash(adata) # TODO: Determine if this is the intended behavior, # or just the behaviour we’ve had for a while @IGNORE_SPARSE_EFFICIENCY_WARNING def test_not_set_subset_X(matrix_type_base, subset_func): adata = ad.AnnData(matrix_type_base(asarray(sparse.random(20, 20)))) init_hash = joblib.hash(adata) orig_X_val = adata.X.copy() while True: subset_idx = slice_int_subset(adata.obs_names) if len(adata[subset_idx, :]) > 2: break subset = adata[subset_idx, :] subset = adata[:, subset_idx] internal_idx = _normalize_index( subset_func(np.arange(subset.X.shape[1])), subset.var_names ) assert subset.is_view with pytest.warns(ad.ImplicitModificationWarning, match=r".*X.*"): subset.X[:, internal_idx] = 1 assert not subset.is_view assert not np.any(asarray(orig_X_val != adata.X)) assert init_hash == joblib.hash(adata) assert isinstance(subset.X, type(adata.X)) # TODO: Determine if this is the intended behavior, # or just the behaviour we’ve had for a while @IGNORE_SPARSE_EFFICIENCY_WARNING def test_not_set_subset_X_dask(matrix_type_no_gpu, subset_func): adata = ad.AnnData(matrix_type_no_gpu(asarray(sparse.random(20, 20)))) init_hash = tokenize(adata) orig_X_val = adata.X.copy() while True: subset_idx = slice_int_subset(adata.obs_names) if len(adata[subset_idx, :]) > 2: break subset = adata[subset_idx, :] subset = adata[:, subset_idx] internal_idx = _normalize_index( subset_func(np.arange(subset.X.shape[1])), subset.var_names ) assert subset.is_view with pytest.warns(ad.ImplicitModificationWarning, match=r".*X.*"): subset.X[:, internal_idx] = 1 assert not subset.is_view assert not np.any(asarray(orig_X_val != adata.X)) assert init_hash == tokenize(adata) assert isinstance(subset.X, type(adata.X)) @IGNORE_SPARSE_EFFICIENCY_WARNING def test_set_scalar_subset_X(matrix_type, subset_func): adata = ad.AnnData(matrix_type(np.zeros((10, 10)))) orig_X_val = adata.X.copy() subset_idx = subset_func(adata.obs_names) adata_subset = adata[subset_idx, :] adata_subset.X = 1 assert adata_subset.is_view assert np.all(asarray(adata[subset_idx, :].X) == 1) if isinstance(adata.X, CupyCSCMatrix): # Comparison broken for CSC matrices # https://github.com/cupy/cupy/issues/7757 assert asarray(orig_X_val.tocsr() != adata.X.tocsr()).sum() == mul( *adata_subset.shape ) else: assert asarray(orig_X_val != adata.X).sum() == mul(*adata_subset.shape) # TODO: Use different kind of subsetting for adata and view def test_set_subset_obsm(adata, subset_func): init_hash = joblib.hash(adata) orig_obsm_val = adata.obsm["o"].copy() while True: subset_idx = slice_int_subset(adata.obs_names) if len(adata[subset_idx, :]) > 2: break subset = adata[subset_idx, :] internal_idx = _normalize_index( subset_func(np.arange(subset.obsm["o"].shape[0])), subset.obs_names ) assert subset.is_view with pytest.warns(ad.ImplicitModificationWarning, match=r".*obsm.*"): subset.obsm["o"][internal_idx] = 1 assert not subset.is_view assert np.all(adata.obsm["o"] == orig_obsm_val) assert init_hash == joblib.hash(adata) def test_set_subset_varm(adata, subset_func): init_hash = joblib.hash(adata) orig_varm_val = adata.varm["o"].copy() while True: subset_idx = slice_int_subset(adata.var_names) if (adata[:, subset_idx]).shape[1] > 2: break subset = adata[:, subset_idx] internal_idx = _normalize_index( subset_func(np.arange(subset.varm["o"].shape[0])), subset.var_names ) assert subset.is_view with pytest.warns(ad.ImplicitModificationWarning, match=r".*varm.*"): subset.varm["o"][internal_idx] = 1 assert not subset.is_view assert np.all(adata.varm["o"] == orig_varm_val) assert init_hash == joblib.hash(adata) @pytest.mark.parametrize("attr", ["obsm", "varm", "obsp", "varp", "layers"]) def test_view_failed_delitem(attr): adata = gen_adata((10, 10), **GEN_ADATA_DASK_ARGS) view = adata[5:7, :][:, :5] adata_hash = joblib.hash(adata) view_hash = joblib.hash(view) with pytest.raises(KeyError): getattr(view, attr).__delitem__("not a key") assert view.is_view assert adata_hash == joblib.hash(adata) assert view_hash == joblib.hash(view) @pytest.mark.parametrize("attr", ["obsm", "varm", "obsp", "varp", "layers"]) def test_view_delitem(attr): adata = gen_adata((10, 10), **GEN_ADATA_DASK_ARGS) getattr(adata, attr)["to_delete"] = np.ones((10, 10)) # Shouldn’t be a subclass, should be an ndarray assert type(getattr(adata, attr)["to_delete"]) is np.ndarray view = adata[5:7, :][:, :5] adata_hash = joblib.hash(adata) view_hash = joblib.hash(view) with pytest.warns( ad.ImplicitModificationWarning, match=rf".*\.{attr}\['to_delete'\].*" ): getattr(view, attr).__delitem__("to_delete") assert not view.is_view assert "to_delete" not in getattr(view, attr) assert "to_delete" in getattr(adata, attr) assert adata_hash == joblib.hash(adata) assert view_hash != joblib.hash(view) @pytest.mark.parametrize( "attr", ["X", "obs", "var", "obsm", "varm", "obsp", "varp", "layers", "uns"] ) def test_view_delattr(attr, subset_func): base = gen_adata((10, 10), **GEN_ADATA_DASK_ARGS) orig_hash = tokenize(base) subset = base[subset_func(base.obs_names), subset_func(base.var_names)] empty = ad.AnnData(obs=subset.obs[[]], var=subset.var[[]]) delattr(subset, attr) assert not subset.is_view # Should now have same value as default assert_equal(getattr(subset, attr), getattr(empty, attr)) assert orig_hash == tokenize(base) # Original should not be modified @pytest.mark.parametrize( "attr", ["obs", "var", "obsm", "varm", "obsp", "varp", "layers", "uns"] ) def test_view_setattr_machinery(attr, subset_func, subset_func2): # Tests that setting attributes on a view doesn't mess anything up too bad adata = gen_adata((10, 10), **GEN_ADATA_DASK_ARGS) view = adata[subset_func(adata.obs_names), subset_func2(adata.var_names)] actual = view.copy() setattr(view, attr, getattr(actual, attr)) assert_equal(actual, view, exact=True) def test_layers_view(): X = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) L = np.array([[10, 11, 12], [13, 14, 15], [16, 17, 18]]) real_adata = ad.AnnData(X) real_adata.layers["L"] = L view_adata = real_adata[1:, 1:] real_hash = joblib.hash(real_adata) view_hash = joblib.hash(view_adata) assert view_adata.is_view with pytest.raises(ValueError, match=r"incorrect shape"): view_adata.layers["L2"] = L + 2 assert view_adata.is_view # Failing to set layer item makes adata not view assert real_hash == joblib.hash(real_adata) assert view_hash == joblib.hash(view_adata) with pytest.warns(ad.ImplicitModificationWarning, match=r".*layers.*"): view_adata.layers["L2"] = L[1:, 1:] + 2 assert not view_adata.is_view assert real_hash == joblib.hash(real_adata) assert view_hash != joblib.hash(view_adata) # TODO: less combinatoric; split up into 2 tests: # 1. each subset func produces the right `oidx`/`vidx` kind (slice, array[int], array[bool]) # 2. each `oidx`/`vidx` kind can be sliced with each subset func # going from #subset_func² to #subset_func × 3 {ov}idx kinds × 2 tests def test_view_of_view(adata_gen: ad.AnnData, subset_func, subset_func2) -> None: adata = adata_gen if subset_func in {single_subset, single_int_subset}: pytest.xfail("Other subset generating functions have trouble with this") var_s1 = subset_func(adata.var_names, min_size=4) var_view1 = adata[:, var_s1] adata[:, var_s1].X # noqa: B018 var_s2 = subset_func2(var_view1.var_names) var_view2 = var_view1[:, var_s2] assert var_view2._adata_ref is adata assert isinstance(var_view2.X, type(adata.X)) obs_s1 = subset_func(adata.obs_names, min_size=4) obs_view1 = adata[obs_s1, :] obs_s2 = subset_func2(obs_view1.obs_names) assert adata[obs_s1, :][:, var_s1][obs_s2, :]._adata_ref is adata assert isinstance(obs_view1.X, type(adata.X)) view_of_actual_copy = adata[:, var_s1].copy()[obs_s1, :].copy()[:, var_s2].copy() view_of_view_copy = adata[:, var_s1][obs_s1, :][:, var_s2].copy() assert_equal(view_of_actual_copy, view_of_view_copy, exact=True) assert isinstance(view_of_actual_copy.X, type(adata.X)) assert isinstance(view_of_view_copy.X, type(adata.X)) def test_view_of_view_modification(): adata = ad.AnnData(np.zeros((10, 10))) adata[0, :][:, 5:].X = np.ones(5) assert np.all(adata.X[0, 5:] == np.ones(5)) adata[[1, 2], :][:, [1, 2]].X = np.ones((2, 2)) assert np.all(adata.X[1:3, 1:3] == np.ones((2, 2))) adata.X = sparse.csr_matrix(adata.X) adata[0, :][:, 5:].X = np.ones(5) * 2 assert np.all(asarray(adata.X)[0, 5:] == np.ones(5) * 2) adata[[1, 2], :][:, [1, 2]].X = np.ones((2, 2)) * 2 assert np.all(asarray(adata.X)[1:3, 1:3] == np.ones((2, 2)) * 2) def test_double_index(subset_func, subset_func2): adata = gen_adata((10, 10), **GEN_ADATA_DASK_ARGS) obs_subset = subset_func(adata.obs_names) var_subset = subset_func2(adata.var_names) v1 = adata[obs_subset, var_subset] v2 = adata[obs_subset, :][:, var_subset] assert np.all(asarray(v1.X) == asarray(v2.X)) assert np.all(v1.obs == v2.obs) assert np.all(v1.var == v2.var) def test_view_different_type_indices(matrix_type): orig = gen_adata((30, 30), X_type=matrix_type) boolean_array_mask = np.random.randint(0, 2, 30).astype("bool") boolean_list_mask = boolean_array_mask.tolist() integer_array_mask = np.where(boolean_array_mask)[0] integer_list_mask = integer_array_mask.tolist() assert_equal(orig[integer_array_mask, :], orig[boolean_array_mask, :]) assert_equal(orig[integer_list_mask, :], orig[boolean_list_mask, :]) assert_equal(orig[integer_list_mask, :], orig[integer_array_mask, :]) assert_equal(orig[:, integer_array_mask], orig[:, boolean_array_mask]) assert_equal(orig[:, integer_list_mask], orig[:, boolean_list_mask]) assert_equal(orig[:, integer_list_mask], orig[:, integer_array_mask]) # check that X element is same independent of access assert_equal(orig[:, integer_list_mask].X, orig.X[:, integer_list_mask]) assert_equal(orig[:, boolean_list_mask].X, orig.X[:, boolean_list_mask]) assert_equal(orig[:, integer_array_mask].X, orig.X[:, integer_array_mask]) assert_equal(orig[:, integer_list_mask].X, orig.X[:, integer_list_mask]) assert_equal(orig[integer_list_mask, :].X, orig.X[integer_list_mask, :]) assert_equal(orig[boolean_list_mask, :].X, orig.X[boolean_list_mask, :]) assert_equal(orig[integer_array_mask, :].X, orig.X[integer_array_mask, :]) assert_equal(orig[integer_list_mask, :].X, orig.X[integer_list_mask, :]) def test_view_retains_ndarray_subclass(): adata = ad.AnnData(np.zeros((10, 10))) adata.obsm["foo"] = np.zeros((10, 5)).view(NDArraySubclass) view = adata[:5, :] assert isinstance(view.obsm["foo"], NDArraySubclass) assert view.obsm["foo"].shape == (5, 5) def test_modify_uns_in_copy(): # https://github.com/scverse/anndata/issues/571 adata = ad.AnnData(np.ones((5, 5)), uns={"parent": {"key": "value"}}) adata_copy = adata[:3].copy() adata_copy.uns["parent"]["key"] = "new_value" assert adata.uns["parent"]["key"] != adata_copy.uns["parent"]["key"] @pytest.mark.parametrize("index", [-101, 100, (slice(None), -101), (slice(None), 100)]) def test_invalid_scalar_index(adata, index): # https://github.com/scverse/anndata/issues/619 with pytest.raises(IndexError, match=r".*index.* out of range\."): _ = adata[index] @pytest.mark.parametrize("obs", [False, True]) @pytest.mark.parametrize("index", [-100, -50, -1]) def test_negative_scalar_index(*, adata, index: int, obs: bool): pos_index = index + (adata.n_obs if obs else adata.n_vars) if obs: adata_pos_subset = adata[pos_index] adata_neg_subset = adata[index] else: adata_pos_subset = adata[:, pos_index] adata_neg_subset = adata[:, index] np.testing.assert_array_equal( adata_pos_subset.obs_names, adata_neg_subset.obs_names ) np.testing.assert_array_equal( adata_pos_subset.var_names, adata_neg_subset.var_names ) def test_viewness_propagation_nan(): """Regression test for https://github.com/scverse/anndata/issues/239""" adata = ad.AnnData(np.random.random((10, 10))) adata = adata[:, [0, 2, 4]] v = adata.X.var(axis=0) assert not isinstance(v, ArrayView), type(v).mro() # this used to break v[np.isnan(v)] = 0 def test_viewness_propagation_allclose(adata): """Regression test for https://github.com/scverse/anndata/issues/191""" adata.varm["o"][4:10] = np.tile(np.nan, (10 - 4, adata.varm["o"].shape[1])) a = adata[:50].copy() b = adata[:50] # .copy() turns view to ndarray, so this was fine: assert np.allclose(a.varm["o"], b.varm["o"].copy(), equal_nan=True) # Next line triggered the mutation: assert np.allclose(a.varm["o"], b.varm["o"], equal_nan=True) # Showing that the mutation didn’t happen: assert np.allclose(a.varm["o"], b.varm["o"].copy(), equal_nan=True) spmat = [sparse.csr_matrix, sparse.csc_matrix, sparse.csr_array, sparse.csc_array] @pytest.mark.parametrize("spmat", spmat) def test_deepcopy_subset(adata, spmat: type): adata.obsp["arr"] = np.zeros((adata.n_obs, adata.n_obs)) adata.obsp["spmat"] = spmat((adata.n_obs, adata.n_obs)) adata = deepcopy(adata[:10].copy()) assert isinstance(adata.obsp["arr"], np.ndarray) assert not isinstance(adata.obsp["arr"], ArrayView) np.testing.assert_array_equal(adata.obsp["arr"].shape, (10, 10)) assert isinstance(adata.obsp["spmat"], spmat) view_type = ( SparseCSRMatrixView if spmat is sparse.csr_matrix else SparseCSCMatrixView ) view_type = SparseCSRArrayView if spmat is sparse.csr_array else SparseCSCArrayView assert not isinstance( adata.obsp["spmat"], view_type, ) np.testing.assert_array_equal(adata.obsp["spmat"].shape, (10, 10)) array_type = [ asarray, sparse.csr_matrix, sparse.csc_matrix, sparse.csr_array, sparse.csc_array, ] # https://github.com/scverse/anndata/issues/680 @pytest.mark.parametrize("array_type", array_type) @pytest.mark.parametrize("attr", ["X", "layers", "obsm", "varm", "obsp", "varp"]) def test_view_mixin_copies_data(adata, array_type: type, attr): N = 100 adata = ad.AnnData( obs=pd.DataFrame(index=np.arange(N).astype(str)), var=pd.DataFrame(index=np.arange(N).astype(str)), ) X = array_type(sparse.eye(N, N).multiply(np.arange(1, N + 1))) if attr == "X": adata.X = X else: getattr(adata, attr)["arr"] = X view = adata[:50] arr_view = view.X if attr == "X" else getattr(view, attr)["arr"] arr_view_copy = arr_view.copy() if sparse.issparse(X): assert not np.shares_memory(arr_view.indices, arr_view_copy.indices) assert not np.shares_memory(arr_view.indptr, arr_view_copy.indptr) assert not np.shares_memory(arr_view.data, arr_view_copy.data) arr_view_copy.data[0] = -5 assert not np.array_equal(arr_view_copy.data, arr_view.data) else: assert not np.shares_memory(arr_view, arr_view_copy) arr_view_copy[0, 0] = -5 assert not np.array_equal(arr_view_copy, arr_view) def test_copy_X_dtype(): adata = ad.AnnData(sparse.eye(50, dtype=np.float64, format="csr")) adata_c = adata[::2].copy() assert adata_c.X.dtype == adata.X.dtype def test_x_none(): orig = ad.AnnData(obs=pd.DataFrame(index=np.arange(50))) assert orig.shape == (50, 0) view = orig[2:4] assert view.shape == (2, 0) assert view.obs_names.tolist() == ["2", "3"] new = view.copy() assert new.shape == (2, 0) assert new.obs_names.tolist() == ["2", "3"] def test_empty_list_subset(): orig = gen_adata((10, 10)) subset = orig[:, []] assert subset.X.shape == (10, 0) assert subset.obsm["sparse"].shape == (10, 100) assert subset.varm["sparse"].shape == (0, 100) def test_dataframe_view_index_setting(): a1 = ad.AnnData( X=np.array([[1, 2, 3], [4, 5, 6]]), obs={"obs_names": ["aa", "bb"], "property": [True, True]}, var={"var_names": ["c", "d", "e"]}, ) a2 = a1[:, ["c", "d"]] with pytest.warns( ad.ImplicitModificationWarning, match=r"Trying to modify index.*" ): a2.obs.index = a2.obs.index.map(lambda x: x[-1]) assert not isinstance(a2.obs, ad._core.views.DataFrameView) assert isinstance(a2.obs, pd.DataFrame) assert a1.obs.index.values.tolist() == ["aa", "bb"] assert a2.obs.index.values.tolist() == ["a", "b"] def test_ellipsis_index( ellipsis_index: tuple[EllipsisType | slice, ...] | EllipsisType, equivalent_ellipsis_index: tuple[slice, slice], matrix_type, ): adata = gen_adata((10, 10), X_type=matrix_type, **GEN_ADATA_DASK_ARGS) subset_ellipsis = adata[ellipsis_index] subset = adata[equivalent_ellipsis_index] assert_equal(subset_ellipsis, subset) @pytest.mark.parametrize( ("index", "expected_error"), [ ((..., 0, ...), r"only have a single ellipsis"), ((0, 0, 0), r"Received a length 3 index"), ], ids=["ellipsis-int-ellipsis", "int-int-int"], ) def test_index_3d_errors(index: tuple[int | EllipsisType, ...], expected_error: str): with pytest.raises(IndexError, match=expected_error): gen_adata((10, 10))[index] @pytest.mark.parametrize( "index", [ pytest.param(sparse.csr_matrix(np.random.random((1, 10))), id="sparse"), pytest.param([1.2, 3.4], id="list"), *( pytest.param(np.array([1.2, 2.3], dtype=dtype), id=f"ndarray-{dtype}") for dtype in [np.float32, np.float64] ), ], ) def test_index_float_sequence_raises_error(index): with pytest.raises(IndexError, match=r"has floating point values"): gen_adata((10, 10))[index] # @pytest.mark.parametrize("dim", ["obs", "var"]) # @pytest.mark.parametrize( # ("idx", "pat"), # [ # pytest.param( # [1, "cell_c"], r"Mixed type list indexers not supported", id="mixed" # ), # pytest.param( # [[1, 2], [2]], r"setting an array element with a sequence", id="nested" # ), # ], # ) # def test_subset_errors(dim, idx, pat): # orig = gen_adata((10, 10)) # with pytest.raises(ValueError, match=pat): # if dim == "obs": # orig[idx, :].X # elif dim == "var": # orig[:, idx].X scverse-anndata-b796d59/tests/test_x.py000066400000000000000000000132571512025555600202030ustar00rootroot00000000000000"""Tests for the attribute .X""" from __future__ import annotations import numpy as np import pandas as pd import pytest from scipy import sparse import anndata as ad from anndata import AnnData from anndata._warnings import ImplicitModificationWarning from anndata.tests.helpers import GEN_ADATA_NO_XARRAY_ARGS, assert_equal, gen_adata from anndata.utils import asarray UNLABELLED_ARRAY_TYPES = [ pytest.param(sparse.csr_matrix, id="csr"), pytest.param(sparse.csc_matrix, id="csc"), pytest.param(sparse.csr_array, id="csr_array"), pytest.param(sparse.csc_array, id="csc_array"), pytest.param(asarray, id="ndarray"), ] SINGULAR_SHAPES = [ pytest.param(shape, id=str(shape)) for shape in [(1, 10), (10, 1), (1, 1)] ] @pytest.mark.parametrize("shape", SINGULAR_SHAPES) @pytest.mark.parametrize("orig_array_type", UNLABELLED_ARRAY_TYPES) @pytest.mark.parametrize("new_array_type", UNLABELLED_ARRAY_TYPES) def test_setter_singular_dim(shape, orig_array_type, new_array_type): # https://github.com/scverse/anndata/issues/500 adata = gen_adata(shape, X_type=orig_array_type) to_assign = new_array_type(np.ones(shape)) adata.X = to_assign np.testing.assert_equal(asarray(adata.X), 1) assert isinstance(adata.X, type(to_assign)) def test_repeat_indices_view(): adata = gen_adata((10, 10), X_type=np.asarray) subset = adata[[0, 0, 1, 1], :] mat = np.array([np.ones(adata.shape[1]) * i for i in range(4)]) with pytest.warns( FutureWarning, match=r"You are attempting to set `X` to a matrix on a view which has non-unique indices", ): subset.X = mat @pytest.mark.parametrize("orig_array_type", UNLABELLED_ARRAY_TYPES) @pytest.mark.parametrize("new_array_type", UNLABELLED_ARRAY_TYPES) def test_setter_view(orig_array_type, new_array_type): adata = gen_adata((10, 10), X_type=orig_array_type) orig_X = adata.X to_assign = new_array_type(np.ones((9, 9))) if isinstance(orig_X, np.ndarray) and sparse.issparse(to_assign): # https://github.com/scverse/anndata/issues/500 pytest.xfail("Cannot set a dense array with a sparse array") view = adata[:9, :9] view.X = to_assign np.testing.assert_equal(asarray(view.X), np.ones((9, 9))) assert isinstance(view.X, type(orig_X)) ############################### # Tests for `adata.X is None` # ############################### def test_set_x_is_none(): # test setter and getter adata = AnnData(np.array([[1, 2, 3], [4, 5, 6]]), dict(o1=[1, 2], o2=[3, 4])) adata.X = None assert adata.X is None def test_del_set_equiv_X(): """Tests that `del adata.X` is equivalent to `adata.X = None`""" # test setter and deleter orig = gen_adata((10, 10)) copy = orig.copy() del orig.X copy.X = None assert orig.X is None assert_equal(orig, copy) # Check that deleting again is still fine del orig.X assert orig.X is None @pytest.mark.parametrize( ("obs", "var", "shape_expected"), [ pytest.param(dict(obs_names=["1", "2"]), None, (2, 0), id="obs"), pytest.param(None, dict(var_names=["a", "b"]), (0, 2), id="var"), pytest.param( dict(obs_names=["1", "2", "3"]), dict(var_names=["a", "b"]), (3, 2), id="both", ), ], ) def test_init_x_as_none_shape_from_obs_var(obs, var, shape_expected): adata = AnnData(None, obs, var) assert adata.X is None assert adata.shape == shape_expected def test_init_x_as_none_explicit_shape(): shape = (3, 5) adata = AnnData(None, uns=dict(test=np.array((3, 3))), shape=shape) assert adata.X is None assert adata.shape == shape @pytest.mark.parametrize("shape", [*SINGULAR_SHAPES, pytest.param((5, 3), id="(5, 3)")]) def test_transpose_with_X_as_none(shape): adata = gen_adata(shape, X_type=lambda x: None) adataT = adata.transpose() assert_equal(adataT.shape, shape[::-1]) assert_equal(adataT.obsp.keys(), adata.varp.keys()) assert_equal(adataT.T, adata) def test_copy(): adata = AnnData( None, obs=pd.DataFrame(index=[f"cell{i:03}" for i in range(100)]), var=pd.DataFrame(index=[f"gene{i:03}" for i in range(200)]), ) assert_equal(adata.copy(), adata) def test_copy_view(): adata = AnnData( None, obs=pd.DataFrame(index=[f"cell{i:03}" for i in range(100)]), var=pd.DataFrame(index=[f"gene{i:03}" for i in range(200)]), ) v = adata[::-2, ::-2] assert_equal(v.copy(), v) ############ # IO tests # ############ def test_io_missing_X(tmp_path, diskfmt): file_pth = tmp_path / f"x_none_adata.{diskfmt}" write = lambda obj, pth: getattr(obj, f"write_{diskfmt}")(pth) read = lambda pth: getattr(ad, f"read_{diskfmt}")(pth) adata = gen_adata((20, 30), **GEN_ADATA_NO_XARRAY_ARGS) del adata.X write(adata, file_pth) from_disk = read(file_pth) assert_equal(from_disk, adata) def test_set_dense_x_view_from_sparse(): x = np.zeros((100, 30)) x1 = np.ones((100, 30)) orig = ad.AnnData(x) view = orig[:30] with ( pytest.warns( UserWarning, match=r"Trying to set a dense array with a sparse array on a view", ), pytest.warns( ImplicitModificationWarning, match=r"Modifying `X` on a view results" ), ): view.X = sparse.csr_matrix(x1[:30]) assert_equal(view.X, x1[:30]) assert_equal(orig.X[:30], x1[:30]) # change propagates through assert_equal(orig.X[30:], x[30:]) # change propagates through def test_fail_on_non_csr_csc_matrix(): X = sparse.eye(100, format="coo") with pytest.raises( ValueError, match=r"Only CSR and CSC.*", ): ad.AnnData(X=X) scverse-anndata-b796d59/tests/test_xarray.py000066400000000000000000000237041512025555600212400ustar00rootroot00000000000000from __future__ import annotations import string import numpy as np import pandas as pd import pytest from anndata._core.xarray import Dataset2D from anndata.compat import XDataArray, XDataset, XVariable from anndata.tests.helpers import gen_typed_df pytest.importorskip("xarray") @pytest.fixture def df(): return gen_typed_df(10) @pytest.fixture def dataset2d(df): return Dataset2D(XDataset.from_dataframe(df)) def test_shape(df, dataset2d): assert dataset2d.shape == df.shape def test_columns(df, dataset2d): assert np.all(dataset2d.columns.sort_values() == df.columns.sort_values()) def test_to_memory(df, dataset2d): memory_df = dataset2d.to_memory() assert np.all(df == memory_df) assert np.all(df.index == memory_df.index) assert np.all(df.columns.sort_values() == memory_df.columns.sort_values()) def test_getitem(df, dataset2d): col = df.columns[0] assert np.all(dataset2d[col] == df[col]) def test_getitem_empty(df, dataset2d): empty_dset = dataset2d[[]] assert empty_dset.shape == (df.shape[0], 0) assert np.all(empty_dset.index == dataset2d.index) def test_backed_property(dataset2d): assert not dataset2d.is_backed dataset2d.is_backed = True assert dataset2d.is_backed dataset2d.is_backed = False assert not dataset2d.is_backed def test_true_index_dim_column_subset(dataset2d, df): col_iter = iter(dataset2d.keys()) col = next(col_iter) dataset2d.true_index_dim = col # Ensure we can actually select columns properly that are not the index column cols = [next(col_iter), next(col_iter)] df_expected = dataset2d[cols].to_memory() # account for the fact that we manually set `true_index_dim` df.index = df[col] df.index.name = None pd.testing.assert_frame_equal(df_expected, df[cols]) def test_index_dim(dataset2d): assert dataset2d.index_dim == "index" assert dataset2d.true_index_dim == dataset2d.index_dim col = next(iter(dataset2d.keys())) dataset2d.true_index_dim = col assert dataset2d.index_dim == "index" assert dataset2d.true_index_dim == col with pytest.raises(ValueError, match=r"Unknown variable `test`\."): dataset2d.true_index_dim = "test" dataset2d.true_index_dim = None assert dataset2d.true_index_dim == dataset2d.index_dim def test_index(dataset2d): alphabet = np.asarray( list(string.ascii_letters + string.digits + string.punctuation) ) new_idx = pd.Index( [ "".join(np.random.choice(alphabet, size=10)) for _ in range(dataset2d.shape[0]) ], name="test_index", ) col = next(iter(dataset2d.keys())) dataset2d.true_index_dim = col dataset2d.index = new_idx assert np.all(dataset2d.index == new_idx) assert dataset2d.true_index_dim == dataset2d.index_dim == new_idx.name assert list(dataset2d.ds.coords.keys()) == [new_idx.name] @pytest.fixture def dataset_2d_one_column(): return Dataset2D( XDataset( {"foo": ("obs_names", pd.array(["a", "b", "c"], dtype="category"))}, coords={"obs_names": [1, 2, 3]}, ) ) def test_dataset_2d_set_dataarray(dataset_2d_one_column): da = XDataArray( np.arange(3), coords={"obs_names": [1, 2, 3]}, dims=("obs_names"), name="bar" ) dataset_2d_one_column["bar"] = da assert dataset_2d_one_column["bar"].dims == ("obs_names",) assert dataset_2d_one_column["bar"].equals(da) def test_dataset_2d_set_dataset(dataset_2d_one_column): ds = XDataset( data_vars={ "foo": ("obs_names", np.arange(3)), "bar": ("obs_names", np.arange(3) + 3), }, coords={"obs_names": [1, 2, 3]}, ) key = ["foo", "bar"] dataset_2d_one_column[key] = ds assert tuple(dataset_2d_one_column[key].ds.sizes.keys()) == ("obs_names",) assert dataset_2d_one_column[key].equals(ds) @pytest.mark.parametrize( "setter", [ pd.array(["e", "f", "g"], dtype="category"), ("obs_names", pd.array(["e", "f", "g"], dtype="category")), ], ids=["array", "tuple_with_array"], ) def test_dataset_2d_set_extension_array(dataset_2d_one_column, setter): dataset_2d_one_column["bar"] = setter assert dataset_2d_one_column["bar"].dims == ("obs_names",) assert ( dataset_2d_one_column["bar"].data is setter[1] if isinstance(setter, tuple) else setter ) @pytest.mark.parametrize( ("da", "pattern"), [ pytest.param( XDataset( data_vars={"bar": ("obs_names", np.arange(3))}, coords={"foo": ("obs_names", np.arange(3))}, ), r"Dataset should have coordinate obs_names", id="coord_name_dataset", ), pytest.param( XDataArray( np.arange(3), coords={"foo": ("obs_names", np.arange(3))}, dims="obs_names", name="bar", ), r"DataArray should have coordinate obs_names", id="coord_name", ), pytest.param( XDataArray( np.arange(3), coords={"obs_names": np.arange(3)}, dims=("obs_names",), name="not_bar", ), r"DataArray should have name bar, found not_bar", id="dataarray_name", ), pytest.param( XDataset( data_vars={ "foo": (["obs_names", "not_obs_names"], np.arange(9).reshape(3, 3)) }, coords={"obs_names": np.arange(3), "not_obs_names": np.arange(3)}, ), r"Dataset should have only one dimension", id="multiple_dims_dataset", ), pytest.param( XDataArray( np.arange(9).reshape(3, 3), coords={"obs_names": np.arange(3), "not_obs_names": np.arange(3)}, dims=("obs_names", "not_obs_names"), ), r"DataArray should have only one dimension", id="multiple_dims_dataarray", ), pytest.param( XVariable( data=np.arange(9).reshape(3, 3), dims=("obs_names", "not_obs_names"), ), r"Variable should have only one dimension", id="multiple_dims_variable", ), pytest.param( XDataset( data_vars={"foo": ("other", np.arange(3))}, coords={"obs_names": ("other", np.arange(3))}, ), r"Dataset should have dimension obs_names", id="name_conflict_dataset", ), pytest.param( XVariable( data=np.arange(3), dims="not_obs_names", ), r"Variable should have dimension obs_names, found not_obs_names", id="name_conflict_variable", ), pytest.param( XDataArray( np.arange(3), coords=[np.arange(3)], dims="not_obs_names", ), r"DataArray should have dimension obs_names, found not_obs_names", id="name_conflict_dataarray", ), pytest.param( ("not_obs_names", [1, 2, 3]), r"Setting value tuple should have first entry", id="tuple_bad_dim", ), pytest.param( (("not_obs_names",), [1, 2, 3]), r"Dimension tuple should have only", id="nested_tuple_bad_dim", ), pytest.param( (("obs_names", "bar"), [1, 2, 3]), r"Dimension tuple is too long", id="nested_tuple_too_long", ), ], ) def test_dataset_2d_set_with_bad_obj(da, pattern, dataset_2d_one_column): with pytest.raises(ValueError, match=pattern): dataset_2d_one_column["bar"] = da @pytest.mark.parametrize( "data", [np.arange(3), XDataArray(np.arange(3), dims="obs_names", name="obs_names")] ) def test_dataset_2d_set_index(data, dataset_2d_one_column): with pytest.raises( KeyError, match="Cannot set the index dimension obs_names", ): dataset_2d_one_column["obs_names"] = data @pytest.mark.parametrize( ("ds", "pattern", "error"), [ pytest.param( XDataset( {"foo": ("obs_names", pd.array(["a", "b", "c"], dtype="category"))}, coords={"obs_names": ("not_obs_names", [1, 2, 3])}, ), r"Dataset should have exactly one dimension", ValueError, id="more_than_one_dimension", ), pytest.param( XDataset( {"foo": ("obs_names", pd.array(["a", "b", "c"], dtype="category"))}, coords={ "obs_names": ("obs_names", [1, 2, 3]), "not_obs_names": ("obs_names", [1, 2, 3]), }, ), r"Dataset should have exactly one coordinate", ValueError, id="more_than_one_coord", ), pytest.param( XDataset( {"foo": ("not_obs_names", pd.array(["a", "b", "c"], dtype="category"))}, coords={ "obs_names": ("not_obs_names", [1, 2, 3]), }, ), r"does not match coordinate", ValueError, id="coord_dim_mismatch", ), pytest.param( XDataset( {"foo": (("obs", "obs1"), np.arange(9).reshape(3, 3))}, coords={ "obs_names": (("obs", "obs1"), np.arange(9).reshape(3, 3)), }, ), r"Dataset should have exactly one", ValueError, id="multi_dim_coord", ), pytest.param( dict(foo="bar"), r"Expected an xarray Dataset", TypeError, id="non_ds_init", ), ], ) def test_init_errors(ds, pattern, error): with pytest.raises(error, match=pattern): Dataset2D(ds)