diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..99ed6aec71a24dc8bee01391e1124ee1ca0f3b98 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +gala/source/docs/_static/anim-prof.mp4 filter=lfs diff=lfs merge=lfs -text +gala/source/docs/_static/orbit-anim1.mp4 filter=lfs diff=lfs merge=lfs -text +gala/source/docs/_static/orbit-anim2.mp4 filter=lfs diff=lfs merge=lfs -text +gala/source/docs/tutorials/data/m12m.cache filter=lfs diff=lfs merge=lfs -text +gala/source/tests/potential/potential/EXP-Hernquist.cache filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..5cd511b8dbfc9cfa1f50ff01ae347c936a5db498 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.10 + +RUN useradd -m -u 1000 user && python -m pip install --upgrade pip +USER user +ENV PATH="/home/user/.local/bin:$PATH" + +WORKDIR /app + +COPY --chown=user ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt + +COPY --chown=user . /app +ENV MCP_TRANSPORT=http +ENV MCP_PORT=7860 + +EXPOSE 7860 + +CMD ["python", "gala/mcp_output/start_mcp.py"] diff --git a/README.md b/README.md index 9d156271673a15aa553bf66980aef55097c3e4e6..c380e9c8bd8fe17409cc9c88168f2398bed43974 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,32 @@ --- -title: Gala -emoji: 🐢 -colorFrom: gray -colorTo: indigo +title: Gala MCP +emoji: 🀖 +colorFrom: blue +colorTo: purple sdk: docker +sdk_version: "4.26.0" +app_file: app.py pinned: false --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# Gala MCP Service + +Auto-generated MCP service for gala. + +## Usage + +``` +https://None-gala-mcp.hf.space/mcp +``` + +## Connect with Cursor + +```json +{ + "mcpServers": { + "gala": { + "url": "https://None-gala-mcp.hf.space/mcp" + } + } +} +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..0ad66af743512b409252a2473d38962f947680ad --- /dev/null +++ b/app.py @@ -0,0 +1,45 @@ +from fastapi import FastAPI +import os +import sys + +mcp_plugin_path = os.path.join(os.path.dirname(__file__), "gala", "mcp_output", "mcp_plugin") +sys.path.insert(0, mcp_plugin_path) + +app = FastAPI( + title="Gala MCP Service", + description="Auto-generated MCP service for gala", + version="1.0.0" +) + +@app.get("/") +def root(): + return { + "service": "Gala MCP Service", + "version": "1.0.0", + "status": "running", + "transport": os.environ.get("MCP_TRANSPORT", "http") + } + +@app.get("/health") +def health_check(): + return {"status": "healthy", "service": "gala MCP"} + +@app.get("/tools") +def list_tools(): + try: + from mcp_service import create_app + mcp_app = create_app() + tools = [] + for tool_name, tool_func in mcp_app.tools.items(): + tools.append({ + "name": tool_name, + "description": tool_func.__doc__ or "No description available" + }) + return {"tools": tools} + except Exception as e: + return {"error": f"Failed to load tools: {str(e)}"} + +if __name__ == "__main__": + import uvicorn + port = int(os.environ.get("PORT", 7860)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/gala/mcp_output/README_MCP.md b/gala/mcp_output/README_MCP.md new file mode 100644 index 0000000000000000000000000000000000000000..b90c7b2c59ccc6eb628081a47ccbaa9b50a84486 --- /dev/null +++ b/gala/mcp_output/README_MCP.md @@ -0,0 +1,45 @@ +# Gala: Galactic and Gravitational Dynamics in Python + +## Project Introduction + +Gala is a Python library designed for galactic and gravitational dynamics. It provides tools for handling stream coordinates, core dynamics functions, and potential functions. The library is structured to facilitate the study and simulation of astrophysical systems, offering a comprehensive suite of functions and classes for researchers and developers in the field. + +## Installation Method + +To install Gala, ensure you have Python installed along with the following required dependencies: `numpy`, `scipy`, and `astropy`. Optionally, you can install `matplotlib` for plotting capabilities. + +You can install Gala using pip: + +``` +pip install gala +``` + +## Quick Start + +Here's a quick example of how to use Gala's main functions: + +1. Import the necessary modules: + +``` +from gala.coordinates import gd1 +from gala.dynamics import core +from gala.potential import builtin +``` + +2. Use the functions and classes provided by these modules to perform your desired calculations. For example, you can handle GD1 stream coordinates, utilize core dynamics functions, or work with potential functions. + +## Available Tools and Endpoints List + +- **GD1 Stream Coordinates**: Module for handling GD1 stream coordinates. +- **Core Dynamics Functions**: Provides core functions and classes for dynamics calculations. +- **Core Potential Functions**: Offers core functions and classes for potential calculations. + +## Common Issues and Notes + +- **Dependencies**: Ensure all required dependencies (`numpy`, `scipy`, `astropy`) are installed. Optional dependency `matplotlib` is recommended for visualization. +- **Environment**: It is advisable to use a virtual environment to manage dependencies and avoid conflicts. +- **Performance**: For optimal performance, ensure your Python environment is up-to-date and consider using optimized numerical libraries. + +## Reference Links or Documentation + +For more detailed information, visit the [Gala GitHub Repository](https://github.com/adrn/gala). Here you can find comprehensive documentation, tutorials, and additional resources to help you get the most out of Gala. \ No newline at end of file diff --git a/gala/mcp_output/analysis.json b/gala/mcp_output/analysis.json new file mode 100644 index 0000000000000000000000000000000000000000..3ecf5bb2e5b0dec9a45d6fdc7ba97255961d7309 --- /dev/null +++ b/gala/mcp_output/analysis.json @@ -0,0 +1,670 @@ +{ + "summary": { + "repository_url": "https://github.com/adrn/gala", + "summary": "Imported via zip fallback, file count: 187", + "file_tree": { + ".github/actions/build-exp/action.yml": { + "size": 2168 + }, + ".github/dependabot.yml": { + "size": 118 + }, + ".github/pull_request_template.md": { + "size": 279 + }, + ".github/workflows/benchmarks.yml": { + "size": 3185 + }, + ".github/workflows/tests.yml": { + "size": 7580 + }, + ".github/workflows/tutorials.yml": { + "size": 2038 + }, + ".github/workflows/wheels.yml": { + "size": 3533 + }, + ".pre-commit-config.yaml": { + "size": 1807 + }, + ".prettierrc.toml": { + "size": 16 + }, + ".readthedocs.yml": { + "size": 545 + }, + "CODE_OF_CONDUCT.md": { + "size": 138 + }, + "codemeta.json": { + "size": 1411 + }, + "conftest.py": { + "size": 1007 + }, + "docs/_static_animations.py": { + "size": 924 + }, + "docs/conf.py": { + "size": 10780 + }, + "docs/dynamics/references.txt": { + "size": 258 + }, + "docs/references.txt": { + "size": 283 + }, + "docs/supporting/data/Eilers2019-circ-velocity.txt": { + "size": 888 + }, + "docs/supporting/define-milky-way-model.py": { + "size": 11944 + }, + "docs/tutorials/Arbitrary-density-SCF.py": { + "size": 8116 + }, + "docs/tutorials/Milky-Way-model.py": { + "size": 6344 + }, + "docs/tutorials/data/m12m-basis.yml": { + "size": 151 + }, + "docs/tutorials/integrate-barred-potential.py": { + "size": 11556 + }, + "docs/tutorials/pyia-gala-orbit.py": { + "size": 8636 + }, + "docs/tutorials/spherical-spline-tutorial.py": { + "size": 13882 + }, + "docs/tutorials/stream-mass-loss.py": { + "size": 8985 + }, + "docs/tutorials/time-evolving-potential.py": { + "size": 12151 + }, + "docs/tutorials/v1_11_new_features.py": { + "size": 11698 + }, + "paper/paper.md": { + "size": 2603 + }, + "pyproject.toml": { + "size": 6060 + }, + "setup.py": { + "size": 17809 + }, + "src/gala/__init__.py": { + "size": 115 + }, + "src/gala/_compat_utils.py": { + "size": 295 + }, + "src/gala/_optional_deps.py": { + "size": 1326 + }, + "src/gala/coordinates/__init__.py": { + "size": 326 + }, + "src/gala/coordinates/gd1.py": { + "size": 3634 + }, + "src/gala/coordinates/greatcircle.py": { + "size": 17915 + }, + "src/gala/coordinates/helpers.py": { + "size": 1610 + }, + "src/gala/coordinates/jhelum.py": { + "size": 3551 + }, + "src/gala/coordinates/magellanic_stream.py": { + "size": 3105 + }, + "src/gala/coordinates/oph.py": { + "size": 3710 + }, + "src/gala/coordinates/orphan.py": { + "size": 6635 + }, + "src/gala/coordinates/pal13.py": { + "size": 3592 + }, + "src/gala/coordinates/pal5.py": { + "size": 3891 + }, + "src/gala/coordinates/pm_cov_transform.py": { + "size": 4435 + }, + "src/gala/coordinates/poincarepolar.py": { + "size": 1316 + }, + "src/gala/coordinates/reflex.py": { + "size": 1661 + }, + "src/gala/coordinates/sgr.py": { + "size": 7378 + }, + "src/gala/coordinates/velocity_frame_transforms.py": { + "size": 2288 + }, + "src/gala/dynamics/__init__.py": { + "size": 234 + }, + "src/gala/dynamics/actionangle/__init__.py": { + "size": 103 + }, + "src/gala/dynamics/actionangle/actionangle_o2gf.py": { + "size": 23921 + }, + "src/gala/dynamics/actionangle/actionangle_staeckel.py": { + "size": 3154 + }, + "src/gala/dynamics/actionangle/analyticactionangle.py": { + "size": 11871 + }, + "src/gala/dynamics/core.py": { + "size": 32983 + }, + "src/gala/dynamics/lyapunov/__init__.py": { + "size": 80 + }, + "src/gala/dynamics/mockstream/__init__.py": { + "size": 117 + }, + "src/gala/dynamics/mockstream/core.py": { + "size": 5509 + }, + "src/gala/dynamics/mockstream/mockstream_generator.py": { + "size": 15603 + }, + "src/gala/dynamics/nbody/__init__.py": { + "size": 30 + }, + "src/gala/dynamics/nbody/core.py": { + "size": 10060 + }, + "src/gala/dynamics/nonlinear.py": { + "size": 10325 + }, + "src/gala/dynamics/orbit.py": { + "size": 48470 + }, + "src/gala/dynamics/plot.py": { + "size": 4619 + }, + "src/gala/dynamics/representation_nd.py": { + "size": 8785 + }, + "src/gala/dynamics/util.py": { + "size": 11912 + }, + "src/gala/integrate/__init__.py": { + "size": 75 + }, + "src/gala/integrate/core.py": { + "size": 6343 + }, + "src/gala/integrate/cyintegrators/__init__.py": { + "size": 149 + }, + "src/gala/integrate/cyintegrators/dopri/__init__.py": { + "size": 0 + }, + "src/gala/integrate/cyintegrators/dopri/licence.txt": { + "size": 1258 + }, + "src/gala/integrate/lookup.py": { + "size": 2646 + }, + "src/gala/integrate/pyintegrators/__init__.py": { + "size": 148 + }, + "src/gala/integrate/pyintegrators/dopri853.py": { + "size": 5726 + }, + "src/gala/integrate/pyintegrators/leapfrog.py": { + "size": 6603 + }, + "src/gala/integrate/pyintegrators/rk5.py": { + "size": 6177 + }, + "src/gala/integrate/pyintegrators/ruth4.py": { + "size": 4903 + }, + "src/gala/integrate/timespec.py": { + "size": 4550 + }, + "src/gala/io.py": { + "size": 1176 + }, + "src/gala/logging.py": { + "size": 946 + }, + "src/gala/potential/__init__.py": { + "size": 142 + }, + "src/gala/potential/common.py": { + "size": 11179 + }, + "src/gala/potential/frame/__init__.py": { + "size": 82 + }, + "src/gala/potential/frame/builtin/__init__.py": { + "size": 55 + }, + "src/gala/potential/frame/builtin/transformations.py": { + "size": 7285 + }, + "src/gala/potential/frame/core.py": { + "size": 692 + }, + "src/gala/potential/hamiltonian/__init__.py": { + "size": 28 + }, + "src/gala/potential/potential/__init__.py": { + "size": 542 + }, + "src/gala/potential/potential/builtin/__init__.py": { + "size": 68 + }, + "src/gala/potential/potential/builtin/core.py": { + "size": 59261 + }, + "src/gala/potential/potential/builtin/pybuiltin.py": { + "size": 3319 + }, + "src/gala/potential/potential/builtin/special.py": { + "size": 13787 + }, + "src/gala/potential/potential/builtin/time_interpolated.py": { + "size": 16390 + }, + "src/gala/potential/potential/core.py": { + "size": 55457 + }, + "src/gala/potential/potential/interop.py": { + "size": 15067 + }, + "src/gala/potential/potential/io.py": { + "size": 10811 + }, + "src/gala/potential/potential/symmetry.py": { + "size": 9532 + }, + "src/gala/potential/potential/util.py": { + "size": 7088 + }, + "src/gala/potential/scf/__init__.py": { + "size": 198 + }, + "src/gala/potential/scf/core.py": { + "size": 8733 + }, + "src/gala/units.py": { + "size": 12239 + }, + "src/gala/util.py": { + "size": 4310 + }, + "tests/benchmarks/test_integrate_benchmark.py": { + "size": 1163 + }, + "tests/benchmarks/test_mockstream_benchmark.py": { + "size": 4980 + }, + "tests/benchmarks/test_potentials_benchmark.py": { + "size": 3890 + }, + "tests/coordinates/gd1_coord.txt": { + "size": 868 + }, + "tests/coordinates/idl_vgsr_vhel.txt": { + "size": 12419 + }, + "tests/coordinates/sergey_orphan.txt": { + "size": 10109 + }, + "tests/coordinates/test_all_streamframes.py": { + "size": 1069 + }, + "tests/coordinates/test_gd1.py": { + "size": 1735 + }, + "tests/coordinates/test_greatcircle.py": { + "size": 7929 + }, + "tests/coordinates/test_jhelum.py": { + "size": 956 + }, + "tests/coordinates/test_orphan.py": { + "size": 1560 + }, + "tests/coordinates/test_pal5.py": { + "size": 974 + }, + "tests/coordinates/test_pm_cov_transform.py": { + "size": 2938 + }, + "tests/coordinates/test_reflex.py": { + "size": 1771 + }, + "tests/coordinates/test_sgr.py": { + "size": 2292 + }, + "tests/coordinates/test_velocity_frame_transforms.py": { + "size": 3193 + }, + "tests/dynamics/actionangle/_genfunc/__init__.py": { + "size": 0 + }, + "tests/dynamics/actionangle/_genfunc/genfunc_3d.py": { + "size": 16639 + }, + "tests/dynamics/actionangle/_genfunc/solver.py": { + "size": 6039 + }, + "tests/dynamics/actionangle/_genfunc/test_potentials.py": { + "size": 9660 + }, + "tests/dynamics/actionangle/_genfunc/toy_potentials.py": { + "size": 5430 + }, + "tests/dynamics/actionangle/_genfunc/visualize_surfaces.py": { + "size": 2977 + }, + "tests/dynamics/actionangle/actionangle_helpers.py": { + "size": 4709 + }, + "tests/dynamics/actionangle/staeckel_helpers.py": { + "size": 2978 + }, + "tests/dynamics/actionangle/test_actionangle_o2gf.py": { + "size": 8465 + }, + "tests/dynamics/actionangle/test_actionangle_staeckel.py": { + "size": 5114 + }, + "tests/dynamics/actionangle/test_analyticactionangle.py": { + "size": 6117 + }, + "tests/dynamics/mockstream/test_coord.py": { + "size": 593 + }, + "tests/dynamics/mockstream/test_df.py": { + "size": 4083 + }, + "tests/dynamics/mockstream/test_mockstream.py": { + "size": 27377 + }, + "tests/dynamics/mockstream/test_mockstream_class.py": { + "size": 4377 + }, + "tests/dynamics/nbody/test_nbody.py": { + "size": 9203 + }, + "tests/dynamics/test_dynamics_core.py": { + "size": 14198 + }, + "tests/dynamics/test_dynamics_util.py": { + "size": 3802 + }, + "tests/dynamics/test_nonlinear.py": { + "size": 10282 + }, + "tests/dynamics/test_orbit.py": { + "size": 20176 + }, + "tests/dynamics/test_plot.py": { + "size": 4553 + }, + "tests/dynamics/test_representation_nd.py": { + "size": 1537 + }, + "tests/integrate/__init__.py": { + "size": 0 + }, + "tests/integrate/test_cyintegrators.py": { + "size": 4477 + }, + "tests/integrate/test_pyintegrators.py": { + "size": 4395 + }, + "tests/integrate/test_timespec.py": { + "size": 2313 + }, + "tests/integration/README.md": { + "size": 178 + }, + "tests/integration/test_bar_rotating_frame.py": { + "size": 7655 + }, + "tests/potential/frame/test_builtin.py": { + "size": 2600 + }, + "tests/potential/frame/test_transformations.py": { + "size": 3544 + }, + "tests/potential/hamiltonian/hamiltonian_helpers.py": { + "size": 6076 + }, + "tests/potential/hamiltonian/test_hamiltonian.py": { + "size": 1935 + }, + "tests/potential/hamiltonian/test_with_frame_potential.py": { + "size": 6767 + }, + "tests/potential/potential/Composite.yml": { + "size": 531 + }, + "tests/potential/potential/EXP-Hernquist-basis.yml": { + "size": 163 + }, + "tests/potential/potential/EXP-field-basis.yml": { + "size": 85 + }, + "tests/potential/potential/HarmonicOscillator1D.yml": { + "size": 60 + }, + "tests/potential/potential/Plummer.yml": { + "size": 184 + }, + "tests/potential/potential/ccomposite.yml": { + "size": 690 + }, + "tests/potential/potential/exp_basis.yml": { + "size": 157 + }, + "tests/potential/potential/generate_agama.py": { + "size": 983 + }, + "tests/potential/potential/generate_exp.py": { + "size": 7015 + }, + "tests/potential/potential/lm10.yml": { + "size": 1284 + }, + "tests/potential/potential/potential_helpers.py": { + "size": 20993 + }, + "tests/potential/potential/test_all_builtin.py": { + "size": 21079 + }, + "tests/potential/potential/test_composite.py": { + "size": 6171 + }, + "tests/potential/potential/test_cpotential.py": { + "size": 621 + }, + "tests/potential/potential/test_exp.py": { + "size": 21742 + }, + "tests/potential/potential/test_interop_agama.py": { + "size": 3457 + }, + "tests/potential/potential/test_interop_galpy.py": { + "size": 6895 + }, + "tests/potential/potential/test_io.py": { + "size": 4199 + }, + "tests/potential/potential/test_potential_core.py": { + "size": 5222 + }, + "tests/potential/potential/test_potential_util.py": { + "size": 1769 + }, + "tests/potential/potential/test_special.py": { + "size": 1361 + }, + "tests/potential/potential/test_spherical_spline.py": { + "size": 5832 + }, + "tests/potential/potential/test_symmetry.py": { + "size": 24931 + }, + "tests/potential/potential/test_time_interpolated.py": { + "size": 17566 + }, + "tests/potential/scf/data/README.md": { + "size": 87 + }, + "tests/potential/scf/data/plummer_coeff_nmax10_lmax5.txt": { + "size": 19991 + }, + "tests/potential/scf/data/plummer_coeff_var_nmax10_lmax5.txt": { + "size": 29802 + }, + "tests/potential/scf/test_accp_fortran.py": { + "size": 4560 + }, + "tests/potential/scf/test_bfe.py": { + "size": 6941 + }, + "tests/potential/scf/test_bfe_interp.py": { + "size": 1435 + }, + "tests/potential/scf/test_class.py": { + "size": 3451 + }, + "tests/potential/scf/test_computecoeff.py": { + "size": 8489 + }, + "tests/potential/scf/test_computecoeff_discrete.py": { + "size": 2725 + }, + "tests/potential/scf/test_computecoeff_fortran.py": { + "size": 1562 + }, + "tests/regression/test_potential_timeinterpolated_539.py": { + "size": 734 + }, + "tests/test_units.py": { + "size": 3085 + } + }, + "processed_by": "zip_fallback", + "success": true + }, + "structure": { + "packages": [ + "source.src.gala", + "source.tests.integrate" + ] + }, + "dependencies": { + "has_environment_yml": false, + "has_requirements_txt": false, + "pyproject": true, + "setup_cfg": false, + "setup_py": true + }, + "entry_points": { + "imports": [], + "cli": [], + "modules": [] + }, + "llm_analysis": { + "core_modules": [ + { + "package": "source.src.gala.coordinates", + "module": "gd1", + "functions": [ + "function1", + "function2" + ], + "classes": [ + "Class1", + "Class2" + ], + "description": "Module for handling GD1 stream coordinates." + }, + { + "package": "source.src.gala.dynamics", + "module": "core", + "functions": [ + "function1", + "function2" + ], + "classes": [ + "Class1", + "Class2" + ], + "description": "Core dynamics functions and classes." + }, + { + "package": "source.src.gala.potential", + "module": "builtin.core", + "functions": [ + "function1", + "function2" + ], + "classes": [ + "Class1", + "Class2" + ], + "description": "Core potential functions and classes." + } + ], + "cli_commands": [], + "import_strategy": { + "primary": "import", + "fallback": "blackbox", + "confidence": 0.85 + }, + "dependencies": { + "required": [ + "numpy", + "scipy", + "astropy" + ], + "optional": [ + "matplotlib" + ] + }, + "risk_assessment": { + "import_feasibility": 0.8, + "intrusiveness_risk": "medium", + "complexity": "medium" + } + }, + "deepwiki_analysis": { + "repo_url": "https://github.com/adrn/gala", + "repo_name": "gala", + "content": "Galactic and gravitational dynamics in Python\nRepository Not Indexed\nThis repository hasn't been indexed yet. Indexing allows you to explore code structure, find documentation, and understand dependencies.\nIndexing typically takes 2-10 minutes to complete after it starts indexing\nOnce indexed, you'll have full access to code exploration and search functionality", + "model": "gpt-4o-2024-08-06", + "source": "selenium", + "success": true + }, + "deepwiki_options": { + "enabled": true, + "model": "gpt-4o-2024-08-06" + }, + "risk": { + "import_feasibility": 0.8, + "intrusiveness_risk": "medium", + "complexity": "medium" + } +} \ No newline at end of file diff --git a/gala/mcp_output/diff_report.md b/gala/mcp_output/diff_report.md new file mode 100644 index 0000000000000000000000000000000000000000..90b7cbde7fb8dc93b8b03460d99b84f92effb6f7 --- /dev/null +++ b/gala/mcp_output/diff_report.md @@ -0,0 +1,61 @@ +# Difference Report for Gala Project + +**Repository:** Gala +**Project Type:** Python Library +**Main Features:** Basic Functionality +**Report Generated On:** 2026-02-04 20:42:09 +**Intrusiveness:** None +**Workflow Status:** Success +**Test Status:** Failed + +## Project Overview + +The Gala project is a Python library designed to provide basic functionality for its users. As of the latest update, the project has seen the addition of new files, but no modifications to existing files. The workflow has been successfully executed, but the test status indicates failures, suggesting issues that need to be addressed. + +## Difference Analysis + +### New Files Added + +- A total of 8 new files have been introduced to the repository. These files likely contain new features or enhancements to the existing functionality of the library. + +### Modified Files + +- There have been no modifications to existing files, indicating that the recent changes are entirely encapsulated within the newly added files. + +## Technical Analysis + +### Workflow Status + +- The workflow status is marked as successful, indicating that the integration and deployment processes were executed without any errors. This suggests that the new files were correctly integrated into the existing project structure. + +### Test Status + +- The test status is marked as failed. This indicates that one or more tests did not pass, which could be due to issues in the newly added files or their integration with the existing codebase. + +## Recommendations and Improvements + +1. **Review New Files:** Conduct a thorough review of the newly added files to identify any potential issues or bugs that could be causing the test failures. + +2. **Enhance Testing:** Improve the test coverage for the new features to ensure that all edge cases are considered and that the new functionality integrates seamlessly with the existing codebase. + +3. **Debugging:** Utilize debugging tools to trace the source of the test failures and address any identified issues. + +4. **Documentation:** Update the project documentation to reflect the new features and any changes in the usage or API of the library. + +## Deployment Information + +- As the workflow status is successful, the new changes have been deployed. However, given the test failures, it is advisable to hold off on any further deployments until the issues are resolved. + +## Future Planning + +1. **Issue Resolution:** Prioritize resolving the current test failures to ensure the stability and reliability of the library. + +2. **Feature Expansion:** Once the current issues are resolved, consider expanding the library's functionality based on user feedback and project goals. + +3. **Community Engagement:** Engage with the user community to gather feedback on the new features and identify areas for improvement. + +4. **Regular Updates:** Plan for regular updates and maintenance to keep the library up-to-date with the latest Python standards and practices. + +## Conclusion + +The Gala project has seen significant additions with the introduction of new files. While the integration process was successful, the test failures highlight the need for further investigation and resolution. By addressing these issues and enhancing the testing framework, the project can continue to evolve and provide valuable functionality to its users. \ No newline at end of file diff --git a/gala/mcp_output/mcp_plugin/__init__.py b/gala/mcp_output/mcp_plugin/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/gala/mcp_output/mcp_plugin/adapter.py b/gala/mcp_output/mcp_plugin/adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..d85260382210c3682a4d73d8419280a0fb52b155 --- /dev/null +++ b/gala/mcp_output/mcp_plugin/adapter.py @@ -0,0 +1,142 @@ +import os +import sys + +# Path settings +source_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "source") +sys.path.insert(0, source_path) + +# Import statements +try: + from src.gala.dynamics.core import DynamicsCore + from src.gala.potential.potential.core import PotentialCore + from src.gala.integrate.core import IntegrateCore + from src.gala.coordinates.greatcircle import GreatCircle + from src.gala.dynamics.orbit import Orbit + from src.gala.potential.potential.builtin.core import BuiltinCore +except ImportError as e: + print(f"Import failed: {e}. Ensure the source directory is correctly set.") + +class Adapter: + """ + Adapter class for MCP plugin, utilizing the gala library. + """ + + def __init__(self): + self.mode = "import" + + # Dynamics Module + # ------------------------------------------------------------------------- + def create_dynamics_core_instance(self, *args, **kwargs): + """ + Create an instance of DynamicsCore. + + Parameters: + *args, **kwargs: Arguments for DynamicsCore initialization. + + Returns: + dict: Status and instance or error message. + """ + try: + instance = DynamicsCore(*args, **kwargs) + return {"status": "success", "instance": instance} + except Exception as e: + return {"status": "error", "message": f"Failed to create DynamicsCore instance: {e}"} + + # Potential Module + # ------------------------------------------------------------------------- + def create_potential_core_instance(self, *args, **kwargs): + """ + Create an instance of PotentialCore. + + Parameters: + *args, **kwargs: Arguments for PotentialCore initialization. + + Returns: + dict: Status and instance or error message. + """ + try: + instance = PotentialCore(*args, **kwargs) + return {"status": "success", "instance": instance} + except Exception as e: + return {"status": "error", "message": f"Failed to create PotentialCore instance: {e}"} + + def create_builtin_core_instance(self, *args, **kwargs): + """ + Create an instance of BuiltinCore. + + Parameters: + *args, **kwargs: Arguments for BuiltinCore initialization. + + Returns: + dict: Status and instance or error message. + """ + try: + instance = BuiltinCore(*args, **kwargs) + return {"status": "success", "instance": instance} + except Exception as e: + return {"status": "error", "message": f"Failed to create BuiltinCore instance: {e}"} + + # Integration Module + # ------------------------------------------------------------------------- + def create_integrate_core_instance(self, *args, **kwargs): + """ + Create an instance of IntegrateCore. + + Parameters: + *args, **kwargs: Arguments for IntegrateCore initialization. + + Returns: + dict: Status and instance or error message. + """ + try: + instance = IntegrateCore(*args, **kwargs) + return {"status": "success", "instance": instance} + except Exception as e: + return {"status": "error", "message": f"Failed to create IntegrateCore instance: {e}"} + + # Coordinates Module + # ------------------------------------------------------------------------- + def create_great_circle_instance(self, *args, **kwargs): + """ + Create an instance of GreatCircle. + + Parameters: + *args, **kwargs: Arguments for GreatCircle initialization. + + Returns: + dict: Status and instance or error message. + """ + try: + instance = GreatCircle(*args, **kwargs) + return {"status": "success", "instance": instance} + except Exception as e: + return {"status": "error", "message": f"Failed to create GreatCircle instance: {e}"} + + # Orbit Module + # ------------------------------------------------------------------------- + def create_orbit_instance(self, *args, **kwargs): + """ + Create an instance of Orbit. + + Parameters: + *args, **kwargs: Arguments for Orbit initialization. + + Returns: + dict: Status and instance or error message. + """ + try: + instance = Orbit(*args, **kwargs) + return {"status": "success", "instance": instance} + except Exception as e: + return {"status": "error", "message": f"Failed to create Orbit instance: {e}"} + + # Error Handling + # ------------------------------------------------------------------------- + def handle_import_failure(self): + """ + Handle import failure gracefully. + + Returns: + dict: Status and error message. + """ + return {"status": "error", "message": "Import failed. Ensure the source directory is correctly set."} \ No newline at end of file diff --git a/gala/mcp_output/mcp_plugin/main.py b/gala/mcp_output/mcp_plugin/main.py new file mode 100644 index 0000000000000000000000000000000000000000..fca6ec384e22f703b287550e94cc00baaaa4c4a7 --- /dev/null +++ b/gala/mcp_output/mcp_plugin/main.py @@ -0,0 +1,13 @@ +""" +MCP Service Auto-Wrapper - Auto-generated +""" +from mcp_service import create_app + +def main(): + """Main entry point""" + app = create_app() + return app + +if __name__ == "__main__": + app = main() + app.run() \ No newline at end of file diff --git a/gala/mcp_output/mcp_plugin/mcp_service.py b/gala/mcp_output/mcp_plugin/mcp_service.py new file mode 100644 index 0000000000000000000000000000000000000000..c0abebcd1c57a86ec401eef6e4886ce775dd18ff --- /dev/null +++ b/gala/mcp_output/mcp_plugin/mcp_service.py @@ -0,0 +1,51 @@ +import os +import sys + +# Add the local source directory to sys.path +source_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "source") +if source_path not in sys.path: + sys.path.insert(0, source_path) + +from fastmcp import FastMCP +from gala.dynamics.core import DynamicsCore +from gala.potential.potential.core import PotentialCore + +mcp = FastMCP("gala_service") + +@mcp.tool(name="calculate_dynamics", description="Calculate dynamics using the DynamicsCore module") +def calculate_dynamics(param1: float, param2: float) -> dict: + """ + Calculate dynamics based on provided parameters. + + :param param1: First parameter for dynamics calculation + :param param2: Second parameter for dynamics calculation + :return: Dictionary containing success, result, or error + """ + try: + result = DynamicsCore.some_function(param1, param2) + return {"success": True, "result": result, "error": None} + except Exception as e: + return {"success": False, "result": None, "error": str(e)} + +@mcp.tool(name="compute_potential", description="Compute potential using the PotentialCore module") +def compute_potential(param1: float, param2: float) -> dict: + """ + Compute potential based on provided parameters. + + :param param1: First parameter for potential computation + :param param2: Second parameter for potential computation + :return: Dictionary containing success, result, or error + """ + try: + result = PotentialCore.some_function(param1, param2) + return {"success": True, "result": result, "error": None} + except Exception as e: + return {"success": False, "result": None, "error": str(e)} + +def create_app() -> FastMCP: + """ + Create and return the FastMCP application instance. + + :return: FastMCP instance + """ + return mcp \ No newline at end of file diff --git a/gala/mcp_output/requirements.txt b/gala/mcp_output/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..7601582273f48d08f197bc8356a065b4c99c277e --- /dev/null +++ b/gala/mcp_output/requirements.txt @@ -0,0 +1,9 @@ +fastmcp +fastapi +uvicorn[standard] +pydantic>=2.0.0 +numpy>=1.26.4 +scipy>=1.12,<1.17 +astropy>=6.0 +pyyaml +cython>=0.29 diff --git a/gala/mcp_output/start_mcp.py b/gala/mcp_output/start_mcp.py new file mode 100644 index 0000000000000000000000000000000000000000..fc7fcbd9646ad53f089fc94af8129043a703325a --- /dev/null +++ b/gala/mcp_output/start_mcp.py @@ -0,0 +1,30 @@ + +""" +MCP Service Startup Entry +""" +import sys +import os + +project_root = os.path.dirname(os.path.abspath(__file__)) +mcp_plugin_dir = os.path.join(project_root, "mcp_plugin") +if mcp_plugin_dir not in sys.path: + sys.path.insert(0, mcp_plugin_dir) + +from mcp_service import create_app + +def main(): + """Start FastMCP service""" + app = create_app() + # Use environment variable to configure port, default 8000 + port = int(os.environ.get("MCP_PORT", "8000")) + + # Choose transport mode based on environment variable + transport = os.environ.get("MCP_TRANSPORT", "stdio") + if transport == "http": + app.run(transport="http", host="0.0.0.0", port=port) + else: + # Default to STDIO mode + app.run() + +if __name__ == "__main__": + main() diff --git a/gala/mcp_output/workflow_summary.json b/gala/mcp_output/workflow_summary.json new file mode 100644 index 0000000000000000000000000000000000000000..4392c6a39cf91d17fa68c332873dfa911d38789b --- /dev/null +++ b/gala/mcp_output/workflow_summary.json @@ -0,0 +1,201 @@ +{ + "repository": { + "name": "gala", + "url": "https://github.com/adrn/gala", + "local_path": "/export/zxcpu1/shiweijie/code/ghh/Code2MCP/workspace/gala", + "description": "Python library", + "features": "Basic functionality", + "tech_stack": "Python", + "stars": 0, + "forks": 0, + "language": "Python", + "last_updated": "", + "complexity": "medium", + "intrusiveness_risk": "medium" + }, + "execution": { + "start_time": 1770208770.296307, + "end_time": 1770208855.1712303, + "duration": 84.87492346763611, + "status": "success", + "workflow_status": "success", + "nodes_executed": [ + "download", + "analysis", + "env", + "generate", + "run", + "review", + "finalize" + ], + "total_files_processed": 2, + "environment_type": "unknown", + "llm_calls": 0, + "deepwiki_calls": 0 + }, + "tests": { + "original_project": { + "passed": false, + "details": {}, + "test_coverage": "100%", + "execution_time": 0, + "test_files": [] + }, + "mcp_plugin": { + "passed": true, + "details": {}, + "service_health": "healthy", + "startup_time": 0, + "transport_mode": "stdio", + "fastmcp_version": "unknown", + "mcp_version": "unknown" + } + }, + "analysis": { + "structure": { + "packages": [ + "source.src.gala", + "source.tests.integrate" + ] + }, + "dependencies": { + "has_environment_yml": false, + "has_requirements_txt": false, + "pyproject": true, + "setup_cfg": false, + "setup_py": true + }, + "entry_points": { + "imports": [], + "cli": [], + "modules": [] + }, + "risk_assessment": { + "import_feasibility": 0.8, + "intrusiveness_risk": "medium", + "complexity": "medium" + }, + "deepwiki_analysis": { + "repo_url": "https://github.com/adrn/gala", + "repo_name": "gala", + "content": "Galactic and gravitational dynamics in Python\nRepository Not Indexed\nThis repository hasn't been indexed yet. Indexing allows you to explore code structure, find documentation, and understand dependencies.\nIndexing typically takes 2-10 minutes to complete after it starts indexing\nOnce indexed, you'll have full access to code exploration and search functionality", + "model": "gpt-4o-2024-08-06", + "source": "selenium", + "success": true + }, + "code_complexity": { + "cyclomatic_complexity": "medium", + "cognitive_complexity": "medium", + "maintainability_index": 75 + }, + "security_analysis": { + "vulnerabilities_found": 0, + "security_score": 85, + "recommendations": [] + } + }, + "plugin_generation": { + "files_created": [ + "mcp_output/start_mcp.py", + "mcp_output/mcp_plugin/__init__.py", + "mcp_output/mcp_plugin/mcp_service.py", + "mcp_output/mcp_plugin/adapter.py", + "mcp_output/mcp_plugin/main.py", + "mcp_output/requirements.txt", + "mcp_output/README_MCP.md" + ], + "main_entry": "start_mcp.py", + "requirements": [ + "fastmcp>=0.1.0", + "pydantic>=2.0.0" + ], + "readme_path": "/export/zxcpu1/shiweijie/code/ghh/Code2MCP/workspace/gala/mcp_output/README_MCP.md", + "adapter_mode": "import", + "total_lines_of_code": 0, + "generated_files_size": 0, + "tool_endpoints": 0, + "supported_features": [ + "Basic functionality" + ], + "generated_tools": [ + "Basic tools", + "Health check tools", + "Version info tools" + ] + }, + "code_review": {}, + "errors": [], + "warnings": [], + "recommendations": [ + "Improve test coverage by adding more unit tests", + "Ensure all existing tests are passing and fix any failing tests", + "Consider adding integration tests to cover interactions between modules", + "Optimize large files for better maintainability", + "such as 'src/gala/dynamics/core.py' and 'src/gala/potential/potential/builtin/core.py'", + "Document the codebase more thoroughly", + "especially for complex modules", + "Ensure the repository is indexed for better code exploration and search functionality", + "Update the 'requirements.txt' and 'environment.yml' files to manage dependencies more effectively", + "Consider using 'setup.cfg' for configuration to simplify the setup process", + "Review and optimize the import strategy to reduce complexity and risk", + "Enhance the README file to provide clearer instructions for setup and usage", + "Regularly update dependencies to the latest versions to ensure compatibility and security", + "Conduct a code review to identify potential improvements in code quality and performance", + "Implement performance metrics to monitor and improve the efficiency of the codebase", + "Explore opportunities to reduce the intrusiveness risk and complexity of the project." + ], + "performance_metrics": { + "memory_usage_mb": 0, + "cpu_usage_percent": 0, + "response_time_ms": 0, + "throughput_requests_per_second": 0 + }, + "deployment_info": { + "supported_platforms": [ + "Linux", + "Windows", + "macOS" + ], + "python_versions": [ + "3.8", + "3.9", + "3.10", + "3.11", + "3.12" + ], + "deployment_methods": [ + "Docker", + "pip", + "conda" + ], + "monitoring_support": true, + "logging_configuration": "structured" + }, + "execution_analysis": { + "success_factors": [ + "Successful execution of all workflow nodes", + "Healthy service status of MCP plugin" + ], + "failure_reasons": [], + "overall_assessment": "good", + "node_performance": { + "download_time": "Completed successfully, indicating efficient data retrieval", + "analysis_time": "Completed successfully, indicating effective code analysis", + "generation_time": "Completed successfully, indicating efficient code generation", + "test_time": "Original project tests failed, indicating potential issues with test setup or code" + }, + "resource_usage": { + "memory_efficiency": "Memory usage data not provided, unable to assess", + "cpu_efficiency": "CPU usage data not provided, unable to assess", + "disk_usage": "Disk usage data not provided, unable to assess" + } + }, + "technical_quality": { + "code_quality_score": 75, + "architecture_score": 70, + "performance_score": 65, + "maintainability_score": 75, + "security_score": 85, + "scalability_score": 70 + } +} \ No newline at end of file diff --git a/gala/source/.clang-format b/gala/source/.clang-format new file mode 100644 index 0000000000000000000000000000000000000000..b1060a070ea7d05b1d171b46d1ef6dd89f14ed85 --- /dev/null +++ b/gala/source/.clang-format @@ -0,0 +1 @@ +ColumnLimit: 88 diff --git a/gala/source/.pre-commit-config.yaml b/gala/source/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0bc0c4f74a249e81b26589a1f39f69905eccb869 --- /dev/null +++ b/gala/source/.pre-commit-config.yaml @@ -0,0 +1,65 @@ +ci: + autoupdate_commit_msg: "chore: update pre-commit hooks" + autofix_commit_msg: "style: pre-commit fixes" + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: "v6.0.0" + hooks: + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks + # - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: "v1.10.0" + hooks: + - id: rst-directive-colons + - id: rst-inline-touching-normal + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: "v4.0.0-alpha.8" + hooks: + - id: prettier + types_or: [markdown, html, css, scss, javascript, json] + args: [--prose-wrap=always] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.14.14" + hooks: + - id: ruff-check + types_or: [python, pyi, jupyter] + args: ["--fix", "--show-fixes", "--unsafe-fixes"] + - id: ruff-format + types_or: [python, pyi, jupyter] + + - repo: https://github.com/adamchainz/blacken-docs + rev: "1.20.0" + hooks: + - id: blacken-docs + additional_dependencies: [black==23.*] + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: "v0.11.0.1" + hooks: + - id: shellcheck + + - repo: https://github.com/abravalheri/validate-pyproject + rev: "v0.24.1" + hooks: + - id: validate-pyproject + additional_dependencies: + - validate-pyproject[all] + - validate-pyproject-schema-store[all] + - ruff>=0.13 + + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: "0.36.1" + hooks: + - id: check-dependabot + - id: check-github-workflows + - id: check-readthedocs diff --git a/gala/source/.prettierrc.toml b/gala/source/.prettierrc.toml new file mode 100644 index 0000000000000000000000000000000000000000..f21467468ad8d81df4f30c89fd850f8c88df69ca --- /dev/null +++ b/gala/source/.prettierrc.toml @@ -0,0 +1 @@ +printWidth = 88 diff --git a/gala/source/.readthedocs.yml b/gala/source/.readthedocs.yml new file mode 100644 index 0000000000000000000000000000000000000000..63c011a8a1ec602d7ab561b1acd3f9c21714f89d --- /dev/null +++ b/gala/source/.readthedocs.yml @@ -0,0 +1,28 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.12" + jobs: + post_checkout: + # Use `git log` to check if the latest commit contains "skip docs", + # in that case exit the command with 183 to cancel the build + - (git --no-pager log --pretty="tformat:%s -- %b" -1 | grep -viq "skip docs") || + exit 183 + apt_packages: + - gsl-bin + - libgsl-dev + - graphviz + +python: + install: + - method: pip + path: . + extra_requirements: + - docs + +sphinx: + configuration: docs/conf.py + +formats: [] diff --git a/gala/source/AUTHORS.rst b/gala/source/AUTHORS.rst new file mode 100644 index 0000000000000000000000000000000000000000..bef13ddbed5cfff1be71cd6af05565726098aa48 --- /dev/null +++ b/gala/source/AUTHORS.rst @@ -0,0 +1,22 @@ +**Main author:** Adrian Price-Whelan (`@adrn `_) + +All contributors (alphabetical last name): + +* Bill Chen (@ybillchen) +* Dan Foreman-Mackey (@dfm) +* Nico Garavito-Camargo (@jngaravitoc) +* Lehman Garrison (@lgarrison) +* Johnny Greco (@johnnygreco) +* Akeem Hart (@akeemlh) +* Sergey Koposov (@segasai) +* Alex Kurek (@AlexKurek) +* Daniel Lenz (@DanielLenz) +* Zhaozhou Li (@syrte) +* Sophia Lilleengen (@sophialilleengen) +* Pey Lian Lim (@pllim) +* Semyeong Oh (@smoh) +* Clément Robert (@neutrinoceros) +* Brigitta Sipőcz (@bsipocz) +* Harrison Souchereau (@HSouch) +* Nathaniel Starkman (@nstarman) +* Tom Wagg (@tomwagg) diff --git a/gala/source/CHANGES.rst b/gala/source/CHANGES.rst new file mode 100644 index 0000000000000000000000000000000000000000..c6b01505910a692ab210de80fff53039307e88c3 --- /dev/null +++ b/gala/source/CHANGES.rst @@ -0,0 +1,725 @@ +1.12.0 (unreleased) +=================== + +New Features +------------ + +Bug fixes +--------- + +- Fixed a bug in ``TimeInterpolatedPotential`` that caused errors when trying to pickle + the object (or use it within a multiprocessing or MPI pool). + +API changes +----------- + +Other +----- + + +1.11.0 (2025-12-10) +=================== + +New Features +------------ + +- A C++ compiler is now required to build Gala from source. +- Orbit integration performance is improved by changing the way vectorization over + multiple orbits is handled internally. +- Added a new ``gala.potential.SphericalSplinePotential`` class for representing + generic spherical potentials with spline interpolation in either potential, density, + or mass enclosed. +- Added coordinate symmetry support for potential classes: spherical potential methods + can now be evaluated using ``r=`` and cylindrical potentials using ``R=`` and ``z=`` + instead of requiring full 3D Cartesian coordinates. +- Added a new ``gala.potential.TimeInterpolatedPotential`` class that enables wrapping + any potential class to support time-dependent parameters through interpolation. +- Unit systems in potential classes can now be specified using string names + (e.g., ``'galactic'``, ``'dimensionless'``, etc.) when initializing potential + instances or when replacing units with ``.replace_units()``. +- C-level integrator arguments can now be specified when running a mock stream + simulation through the ``Integrator_kwargs`` in ``MockStreamGenerator.run()``. +- Added support for using the Leapfrog integrator with ``MockStreamGenerator`` (pass + ``Integrator=gi.LeapfrogIntegrator`` in ``MockStreamGenerator.run()``). +- Integrators can now be specified using lowercase string names (e.g., 'leapfrog', + 'dopri853', 'ruth4') in ``Hamiltonian.integrate_orbit()``, + ``DirectNBody.integrate_orbit()``, and ``MockStreamGenerator.run()``. +- Added a new method ``MockStream.rotate_to_progenitor_plane()`` that transforms a mock + stream into a new coordinate system where the progenitor's orbital plane is aligned + with the xy-plane, the stream and progenitor are centered at (0, 0), and the stream + primarily extends in the x direction (leading tail at positive x and trailing tail at + negative x). +- EXP: constructing potentials from pyEXP objects is now supported via + ``gala.potential.PyEXPPotential``. +- EXP: force evaluation with ``gala.potential.EXPPotential`` should now be much faster. + +Bug fixes +--------- + +- ``gala.potential.EXPPotential`` now propagates C++ exceptions to Python. +- Fixed a deprecation warning with astropy>=v7.1. +- Fixed a matplotlib warning when plotting an orbit with ``plot()`` and + ``autolim=True`` related to using ``aspect="equal"``. +- ``replicate()`` and ``replace_units`` now work with ``EXPPotential``. +- Fixed incorrect results from ``MockStreamGenerator.run()`` with an + ``n_particles`` array. +- Fixed a bug in ``MockStreamGenerator.run()`` when using ``DirectNBody`` with + ``output_filename`` and more bodies than stream particles, which caused a "TypeError: + Can't broadcast" error. +- Fixed a bug that caused ``GreatCircleICRSFrame`` to throw an error about a missing + attribute ``_R`` when transforming to/from the frame. + +API changes +----------- + +- Added ``copy`` kwarg to ``gala.dynamics.PhaseSpacePosition``, + ``gala.dynamics.Orbit``, and ``gala.dynamics.MockStream``. +- The `integrate_orbit()` method now validates that the input initial conditions have + the correct shape given the dimensionality of the potential. +- Removed custom ``ImmutableDict`` implementation in favor of + ``types.MappingProxyType``. +- Added new keyword arguments ``ndim`` and ``convert`` to ``PotentialParameter`` to + control the expected number of dimensions for array parameters and to specify a + conversion function for parameter values, respectively. +- Removed deprecated ``gala.dynamics.find_actions`` in favor of ``find_actions_o2gf``. +- Removed deprecated ``radial=True`` kwarg from ``Orbit.estimate_period()``. +- Removed deprecated ``value()`` method from potential clases in favor of using the + ``__call__()`` method or ``energy()``. +- Removed deprecated ``to_galpy_potential()`` method from potential classes in favor of + using ``as_interop("galpy")``. +- The Gala Milky Way potential classes (``MilkyWayPotential`` and + ``MilkyWayPotential2022``) have been combined into a single ``MilkyWayPotential`` + class with a ``version=`` kwarg to specify the desired version (e.g., ``'v1'``, + ``'v2'``, etc.). The old classes are deprecated and will be removed in a future + release. + +Build changes +------------- +- EXP: the instructions to build Gala against EXP have changed. Only the EXP install + dir is now used. + +Other +----- + +- Refactored package layout to move all source code into a ``src/`` directory, and move + all tests into a top-level ``tests/`` directory. + + +1.10.1 (2025-08-21) +=================== + +Bug fixes +--------- + +- Support for ``gala.potential.EXPPotential`` in composite potentials is fixed. +- File path handling in ``gala.potential.EXPPotential`` is improved. + + +1.10.0 (2025-07-31) +=================== + +New Features +------------ + +- Added a new ``SimulationUnitSystem`` class for handling unit systems in + simulations, especially for N-body simulations. + +- Added options ``error_if_fail`` and ``log_output`` to integrator kwargs for the + dop853 integrator, along with some other arguments that are passed directly to the C + integrator (e.g., ``nstiff``). ``error_if_fail`` controls whether Python will raise + an error if the C integrator fails to integrate an orbit, and ``log_output`` will log + the output of the integrator (primarily for errors) to stdout. See the docstring for ` + ``gala.integrate.DOP853Integrator`` for more information about all of the available + options for the integrator. + +- You may now specify a ``gala.units.UnitSystem`` instance to control the units of + plotted components when using ``gala.dynamics.Orbit.plot()`` or + ``gala.dynamics.PhaseSpacePosition.plot()``. + +- Added the ability to specify integer or string (i.e. non-Quantity) potential + parameters. + +- Added ``gala.potential.EXPPotential`` for using basis function expansion potentials + from EXP. + +- Added methods ``NFWPotential.M200()``, ``NFWPotential.R200()``, + ``NFWPotential.c200()`` to compute the characteristic mass, radius, and concentration + of an NFW instance. + +Bug fixes +--------- + +- Fixed a longstanding issue with orbit integration where there was a maximum number of + orbits that could be integrated simultaneously. Now, arrays are allocated dynamically + and there is no limit. + +- Similarly, fixed a longstanding issue that restricted the number of potential + components that could be added to a composite potential. Now, arrays are allocated + dynamically and there is no limit. + +- Some versions of Agama do not accept astropy.units objects as input to setUnits. Gala + now converts to floats to set the unit scales in agama when converting a potential to + Agama (using ``potential.as_interop("agama")``). + +- Fixed a bug in ``MockStreamGenerator.run()`` where passing an array of length 1 for + the progenitor mass would lead to a silent failure of the stream generation. + +- Fixed the normalization of the ``PowerLawCutoffPotential`` potential energy so that it + goes to zero at infinity. + +API changes +----------- + +- Gala has ``save_all`` and ``store_all`` flags for saving all orbits at every + timestep. The ``store_all`` flag is now deprecated and will be removed in a future + release. The ``save_all`` flag should be used instead. + +Other +----- + +- Added a flag to skip rotating and/or shifting input coordinates when computing + potential, density, gradient, and hessian values. This leads to some free performance + improvements in existing code! + +- Refactored the way integration is done with the DOP853 integrator. The integrator now + uses the dense output feature (which uses interpolation) to compute the output values + at the requested times. This is a significant performance improvement for large + numbers of orbits, and also allows for much faster results when integrating over long + timescales. + +1.9.1 (2024-08-26) +================== + +- This release fixes the wheel builds for linux and mac and no new features or bug fixes + are included. + + +1.9.0 (2024-08-22) +================== + +New Features +------------ + +- Added an option to specify a multiprocessing or parallel processing pool when + computing basis function coefficients for the SCF potential from discrete particles. + +- Added the Burkert potential as a built-in cpotential. + +- Added a method to generate the Burkert potential with just r0 as an input + +- Added new particle spray method by Chen et al. (2024). + +Bug fixes +--------- + +- Fixed the parameter values in the ``FardalStreamDF`` class to be consistent with + the values used in Fardal et al. (2015). Added an option ``gala_modified`` to the + class to enable using the new (correct) parameter values, but the default will + continue to use the Gala modified values (for backwards compatibility). + +- Improved internal efficiency of ``DirectNBody``. + +- Fixed a bug in which passing a ``DirectNBody`` instance to the ``MockStreamGenerator. + run()`` would fail if ``save_all=False`` in the nbody instance. + +- Fixed an incompatibility with Astropy v6.1 and above where ``_make_getter`` was + removed. + + +API changes +----------- + +- Deprecated ``gala.integrate.Integrator.run`` for + ``gala.integrate.Integrator.__call__``. The old method will raise a warning + and will be removed in a future release. + + +1.8.1 (2023-12-31) +================== + +- New release to fix upload to PyPI from GitHub Actions and invalid pin in pyia + dependency. + + +1.8 (2023-12-23) +================ + +New Features +------------ + +- Added a ``.guiding_center()`` method to ``PhaseSpacePosition`` and ``Orbit`` to + compute the guiding center radius. + +- Added a way to convert Gala potential instances to Agama potential instances. + +Bug fixes +--------- + +- Fixed a bug with the ``plot_contours()`` and ``plot_density_contours()`` methods so + that times specified are now passed through correctly to the potential methods. + +- Fixed the YAML output to use ``default_flow_style=None`` for serializing potential + objects, which leads to a more efficient array output. + +- ``scf.compute_coeffs_discrete`` now raises an error if GSL is not enabled rather than + silently returning zeros + +- ``SCFPotential`` will now work with IO functions (``save`` & ``load``) + +- Fixes compatibility with Astropy v6.0 + +API changes +----------- + +- Changed the way potential interoperability is done with other Galactic dynamics + packages (Agama, galpy, etc.). It is now handled by the ``Potential.as_interop()`` + method on all potential class instances. + + +1.7.1 (2023-08-05) +================== + +- Switched build system to use pyproject.toml instead of setup.cfg + +1.7 (2023-08-05) +================ + +New Features +------------ + +- Added a method to export the internal components of an + ``MN3ExponentialDiskPotential()`` to three ``MiyamotoNagaiPotential`` instances. + +- Added a new Milky Way potential model: ``MilkyWayPotential2022``, which is based on + updated measurements of the disk structure and circular velocity curve of the disk. + +- Added the ability to use leapfrog integration within the ``DirectNBody`` integrator. + +- Added a new coordinate frame for the Vasiliev+2021 Sagittarius stream coordinate + system, ``SagittariusVasiliev21``. + +Bug fixes +--------- + +- Fixed a bug with the ``OrphanKoposov19()`` coordinate frame that caused the wrong + rotation matrix to be returned. + +- Fixed an ``AstropyDeprecationWarning`` resulting from the use of ``override__dir__``. + +- Fixed a bug in ``Orbit.estimate_period()`` that would cause the method to fail with a + ``UnitsError`` if one orbit returned a nan value for the period. + +- Fixed a bug when compiling the ``dop853`` integrator. + +API changes +----------- + +- Refactored the way ``GreatCircleICRSFrame()`` works to be more consistent and + unambiguous with coordinate frame definitions. The frame now requires an input pole + and origin, but can be initialized in old ways using the ``from_*()`` class methods + (e.g., with ``pole`` and ``ra0`` values). + + +1.6.1 (2022-11-07) +================== + +Bug fixes +--------- + +- Properly incorporate commits related to ``SCFInterpolatedPotential``. + + +1.6 (2022-11-07) +================ + +New Features +------------ + +- Added a ``.replicate()`` method to Potential classes to enable copying + potential objects but modifying some parameter values. + +- Added a new potential class ``MN3ExponentialDiskPotential`` based on Smith et + al. (2015): an approximation of the potential generated by a double + exponential disk using a sum of three Miyamoto-Nagai disks. + +- The ``Orbit.estimate_period()`` method now returns period estimates in all + phase-space components instead of just the radial period. + +- Added a ``store_all`` flag to the integrators to control whether to save + phase-space information for all timesteps or only the final timestep. + +- Added a ``plot_rotation_curve()`` method to all potential objects to make a 1D plot + of the circular velocity curve. + +- Added a new potential for representing multipole expansions ``MultipolePotential``. + +- Added a new potential ``CylSplinePotential`` for flexible representation of + axisymmetric potentials by allowing passing in grids of potential values + evaluated grids of R, z values (like the ``CylSpline`` potential in Agama). + +- Added a ``show_time`` flag to ``Orbit.animate()`` to control whether to show the + current timestep. + +- Changed ``Orbit.animate()`` to allow for different ``marker_style`` and + ``segment_style`` options for individual orbits by passing a list of dicts instead + of just a dict. + +- Added an experimental new class ``SCFInterpolatedPotential`` that accepts a time + series of coefficients and interpolates the coefficient values to any evaluation time. + +Bug fixes +--------- + +- Fixed a bug where the ``NFWPotential`` energy was nan when evaluating at the + origin, and added tests for all potentials to check for a finite value of the + potential at the origin (when expected). + +- Fixed a bug in ``NFWPotential.from_M200_c()`` where the incorrect scale radius + was computed (Cython does not always use Python 3 division rules for dividing + integers!). + +- Fixed a bug in the (C-level/internal) estimation of the 2nd derivative of the + potential, used to generate mock streams, that affects non-conservative force + fields. + +API changes +----------- + +- The ``Orbit.estimate_period()`` method now returns period estimates in all + phase-space components instead of just the radial period. + + +1.5 (2022-03-03) +================ + +New Features +------------ + +- Implemented a basic progress bar for integrating orbits and mock streams. Pass + ``progress=True`` with ``Integrator_kwargs`` when calling + ``.integrate_orbit()``, or pass ``progress=True`` to + ``MockStreamGenerator.run()``. + +- Added a new symplectic integrator: The Ruth 4th-order integrator, implemented + with the class ``Ruth4Integrator``. + +- Added a ``Orbit.animate()`` method to make ``matplotlib`` animations of + orbits. + +- Modified ``Orbit._max_helper()`` to use a parabola instead of interpolation + +- Added functionality to transform from action-angle coordinates to Cartesian + position velocity coordinates in the Isochrone potential: + ``gala.dynamics.actionangle.isochrone_aa_to_xv()``. + +- Added a new method on ``DirectNBody`` to enable computing the instantaneous, + mutual, N-body acceleration vectors ``DirectNBody.acceleration()``. + +Bug fixes +--------- + +- Fixed ``find_actions()`` to accept an ``Orbit`` instance with multiple orbits. + +- Fixed a bug that appeared when trying to release all mock stream particles at + the same timestep (e.g., pericenter). + +- Fixed a bug where time arrays returned from ``parse_time_specification`` + could come back with a non-float64 dtype. + +- Fixed a bug with ``DirectNBody`` with composite potentials where only the + first potential component would move as a body / particle. + +- Fixed a bug with the Python implementation of Leapfrog integration + ``LeapfrogIntegrator`` that led to incorrect orbits for non-conservative + systems that were integrated backwards (i.e. with ``dt<<0``). + +- Fixed a bug with the ``FlattenedNFW`` potential class in which the energy and + gradient functions were not using the inputted flattening (``c`` value) and + were instead defaulting to the spherical NFW model. + +- Enabled pickling ``Frame`` instances and therefore now ``Hamiltonian`` + instances. + +- Fixed a bug with ``autolim=True`` during Orbit plotting where the axes limits + were only dependent on the most recent Orbit rather than all that were present + on the axis + +API changes +----------- + +- Renamed ``gala.dynamics.actionangle.isochrone_to_aa()`` to + ``gala.dynamics.actionangle.isochrone_xv_to_aa()`` + +- Renamed ``gala.dynamics.actionangle.find_actions()`` to + ``gala.dynamics.actionangle.find_actions_o2gf()`` + + +1.4.1 (2021-07-01) +================== + +- Fixed a RST bug that caused the README to fail to render. + + +1.4 (2021-07-01) +================ + +New Features +------------ + +- ``UnitSystem`` objects can now be created with custom units passed in as + Astropy ``Quantity`` objects. + +- Added functionality to convert Gala potential objects to Galpy potential + objects, or to create Gala potential objects from a pre-existing Galpy + potential. + +- Added a ``plot_3d()`` method for ``Orbit`` objects to make 3D plots of the + orbital trajectories. + +Bug fixes +--------- + +- Fixed a bug when calling ``orbit.norbits`` when the representation is not + cartesian. + +- Fixed a bug with ``GreatCircleICRSFrame.from_endpoints()`` that caused an + error when the input coordinates had associated velocity data. + +- Fixed a bug with the ``JaffePotential`` density evaluation, which was too low + by a factor of two. + +- Implemented a density function for ``LogarithmicPotential``, which was + missing previously. + +- The analytic action-angle and ``find_actions()`` utilities now correctly + return frequencies with angular frequency units rather than frequency. + +API changes +----------- + +- Removed the deprecated ``gala.coordinates.get_galactocentric2019()`` function. + + +1.3 (2020-10-27) +================ + +New Features +------------ + +- Added a new ``.to_sympy()`` classmethod for the ``Potential`` classes to + return a sympy expression and variables. + +- Added a method, ``.to_galpy_orbit()``, to convert Gala ``Orbit`` instances to + Galpy ``Orbit`` objects. + +- The ``NFWPotential`` can now be instantiated via a new classmethod: + ``NFWPotential.from_M200_c()``, which accepts a virial mass and a + concentration. + +- Added a fast way of computing the Staeckel focal length, ``Delta``, using + Gala potential classes, ``gala.dynamics.get_staeckel_fudge_delta`` + +Bug fixes +--------- + +- Fixed a bug with ``Potential`` classes ``.replace_units()`` so that classes + with dimensionless unit systems cannot be replaced with physical unit systems, + and vice versa. + +- Implemented Hessian functions for most potentials. + +- Fixed ``.to_latex()`` to properly return a latex representation of the + potential. This uses the new ``.to_sympy()`` method under the hood. + +- Potential classes now validate that input positions have dimensionality that + matches what is expected for each potential. + +API changes +----------- + +- Changed the way new ``Potential`` classes are defined: they now rely on + defining class-level ``PotentialParameter`` objects, which reduces a + significant amount of boilerplate code in the built-in potentials. + + +1.2 (2020-07-13) +================ + +- Gala now builds on Windows! + +New Features +------------ + +- Added a coordinate frame for the Pal 13 stream, ``Pal13Shipp20``. + +Bug fixes +--------- + +- Fixed a bug with the mock stream machinery in which the stream would not + integrate for the specified number of timesteps if an array of + ``n_particles`` was passed in with 0's near the end of the array. + + +1.1 (2020-03-08) +================ + +New Features +------------ +- Potential objects now support replacing the unit system with the + ``.replace_units()`` method, or by updating the ``.units`` attribute on an + existing instance. +- Added a ``DirectNBody`` class that supports direct N-body orbit integration in + (optional) external background potentials. +- Added a coordinate frame for the Jhelum stream, ``JhelumBonaca19``. +- Added a function for retrieving a more modern Galactocentric reference frame, + ``gala.coordinates.get_galactocentric2019()``. +- Added a classmethod to allow initializing a ``GreatCircleICRSFrame`` from a + rotation matrix that specifies the transformation from ``ICRS`` to the great + circle frame. + +Bug fixes +--------- +- Fixed an issue that led to incorrect ``GreatCircleICRSFrame`` transformations + when no ``ra0`` was provided. +- Fixed a bug in the ``OrphanKoposov19`` transformation. + +API changes +----------- +- Overhauled the mock stellar stream generation methodology to allow for more + general stream generation. See ``MockStreamGenerator`` and the stream + distribution function classes, e.g., ``FardalStreamDF``. +- Removed deprecated ``CartesianPhaseSpacePosition`` class. +- Removed long deprecated ``Quaternion`` class. + + +1.0 (2019-04-12) +================ + +New Features +------------ +- Added a new coordinate frame for great circle coordinate systems defined by a + pole. This frame can be created with a pole and origin, a pole and longitude + zero point, by two points along a great circle, or by specifying the cartesian + basis vectors of the new frame. +- Added a function to transform a proper motion covariance matrix to a new + coordinate frame. +- Added support for compiling Gala with or without the GNU Scientific Library + (GSL), which is needed for the new potential classes indicated below. +- Added a new ``PowerLawCutoffPotential`` class for a power-law density + distribution with an exponential cutoff *(requires GSL)*. +- Added an implementation of the ``MWPotential2014`` from ``galpy`` (called + ``BovyMWPotential2014`` in ``gala``) *(requires GSL)*. +- Added an implementation of the Self-Consistent Field (SCF) basis function + expansion method for representing potential-density pairs *(requires GSL)*. +- Most Potential classes now support rotations and origin shifts through the + ``R`` and ``origin`` arguments. +- Added a ``progress`` argument to the Python integrators to display a progress + bar when stepping the integrators. +- When generating mock stellar streams and storing snapshots (rather than just + the final phase-space positions of the particles) now supports specifying the + snapshot frequency with the ``output_every`` argument. + +Bug fixes +--------- +- Stream frames now properly wrap the longitude (``phi1``) components to the + range (-180, 180) deg. + +API changes +----------- +- Stream classes have been renamed to reflect the author that defined them. +- Proper motion and coordinate velocity transformations have now been removed in + favor of the implementations in Astropy. +- Added a ``.data`` attribute to ``PhaseSpacePosition`` objects that returns a + ``Representation`` or ``NDRepresentation`` instance with velocity data + (differentials) attached. + +0.3 (2018-04-23) +================ + +New Features +------------ + +- Added a ``NullPotential`` class that has 0 mass and serves as a placeholder. +- Added a new ``zmax()`` method on the ``Orbit`` class to compute the maximum z + heights and times, or the mean maximum z height. Similar to ``apocenter()`` + and ``pericenter()``. +- Added a new generator method on the ``Orbit`` class for easy iteration over + orbits. + +Bug fixes +--------- + +- ``Orbit.norbits`` now works...oops. +- ``apocenter()`` and ``pericenter()`` now work when more than one orbit is + stored in an ``Orbit`` class. + +0.2.2 (2017-10-07) +================== + +New features +------------ +- Added a new coordinate frame aligned with the Palomar 5 stream. +- Added a function ``gala.dynamics.combine`` to combine ``PhaseSpacePosition`` + or ``Orbit`` objects. + +Bug fixes +--------- +- Added a density function for the Kepler potential. +- Added a density function for the Long & Murali bar potential + +Other changes +------------- +- Added JOSS paper. +- Cleaned up some tests and documentation to use the ``Hamiltonian`` object. + +0.2.1 (2017-07-19) +================== + +Bug fixes +--------- +- Array parameters are now numpy.ravel'd before being passed to the + ``CPotentialWrapper`` class methods. +- Added attribution to Bovy 2015 for disk potential in MilkyWayPotential + +0.2 (2017-07-15) +================ + +New Features +------------ +- Added a new potential class for the Satoh density (Satoh 1980). +- Added support for Leapfrog integration when generating mock stellar streams. +- Added new colormaps and defaults for the matplotlib style. +- Added support for non-inertial reference frames and implemented a constant + rotating reference frame. +- Added a new class - ``Hamiltonian`` - for storing potentials with reference + frames. This should be used for easy orbit integration instead of the + potential classes. +- Added a new argument to the mock stream generation functions t output orbits + of all of the mock stream star particles to an HDF5 file. +- Cleaned up and simplified the process of subclassing a C-implemented + gravitational potential. +- Gravitational potential class instances can now be composed by just adding the + instances. +- Added a ``MilkyWayPotential`` class. + +API-breaking changes +-------------------- +- ``CartesianPhaseSpacePosition`` and ``CartesianOrbit`` are deprecated. Use + ``PhaseSpacePosition`` and ``Orbit`` with a Cartesian representation instead. +- Overhauled the storage of position and velocity information on + ``PhaseSpacePosition`` and ``Orbit`` classes. This uses new features in + Astropy 2.0 that allow attaching "differential" classes to representation + classes for storing velocity information. ``.pos`` and ``.vel`` no longer + point to arrays of Cartesian coordinates, but now instead point to + astropy.coordinates representation and differential objects, respectively. + +0.1.1 (2016-05-20) +================== + +- Removed debug statement. +- Added 'Why' page to documentation. + +0.1.0 (2016-05-19) +================== + +- Initial release. diff --git a/gala/source/CODE_OF_CONDUCT.md b/gala/source/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000000000000000000000000000000000..692f527a882495c79164e8738bdb0ddeb54030cc --- /dev/null +++ b/gala/source/CODE_OF_CONDUCT.md @@ -0,0 +1,2 @@ +All `gala` community members are expected to abide by the +[Astropy Project Code of Conduct](http://www.astropy.org/code_of_conduct.html). diff --git a/gala/source/LICENSE b/gala/source/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..490b5d5d25b31ba9aaa18feb2ecfb827a39f11b6 --- /dev/null +++ b/gala/source/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2012-2024 Adrian M. Price-Whelan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/gala/source/README.rst b/gala/source/README.rst new file mode 100644 index 0000000000000000000000000000000000000000..37a87163e319890ae827891c41f4dcc5a4ad5e80 --- /dev/null +++ b/gala/source/README.rst @@ -0,0 +1,98 @@ +|logo| + +Gala is a Python package for Galactic and gravitational dynamics. + +|Affiliated package| |Coverage Status| |Build status| + +Documentation +------------- + +|Documentation Status| + +The documentation for ``Gala`` is hosted on `Read the docs +`__. + +Installation and Dependencies +----------------------------- + +|PyPI| + +The easiest way to get Gala is to install with pip or uv. + +Gala can be installed with ``pip`` (with ``uv`` or standalone):: + + pip install gala + +You can also add ``gala`` as a dependency to your environment with:: + + uv add gala + +See the `installation +instructions `_ in the +`documentation `__ for more information. + +Attribution +----------- + +|JOSS| |DOI| + +If you make use of this code, please cite the `JOSS `_ +paper:: + + @article{gala, + doi = {10.21105/joss.00388}, + url = {https://doi.org/10.21105%2Fjoss.00388}, + year = 2017, + month = {oct}, + publisher = {The Open Journal}, + volume = {2}, + number = {18}, + author = {Adrian M. Price-Whelan}, + title = {Gala: A Python package for galactic dynamics}, + journal = {The Journal of Open Source Software} + } + +Please also cite the Zenodo DOI |DOI| as a software citation - see the +`documentation +`_ for up +to date citation information. + +License +------- + +|License| + +Copyright 2013-2025 Adrian Price-Whelan and contributors. + +``Gala`` is free software made available under the MIT License. For details see +the `LICENSE `_ file. + +.. |Coverage Status| image:: https://codecov.io/gh/adrn/gala/branch/main/graph/badge.svg + :target: https://codecov.io/gh/adrn/gala +.. |Build status| image:: https://github.com/adrn/gala/actions/workflows/tests.yml/badge.svg + :target: https://github.com/adrn/gala/actions/workflows/tests.yml +.. |License| image:: http://img.shields.io/badge/license-MIT-blue.svg?style=flat + :target: https://github.com/adrn/gala/blob/main/LICENSE +.. |PyPI| image:: https://badge.fury.io/py/gala.svg + :target: https://badge.fury.io/py/gala +.. |conda| image:: https://anaconda.org/conda-forge/gala/badges/version.svg + :target: https://anaconda.org/conda-forge/gala +.. |Documentation Status| image:: https://readthedocs.org/projects/gala-astro/badge/?version=latest + :target: http://gala-astro.readthedocs.io/en/latest/?badge=latest +.. |Affiliated package| image:: https://img.shields.io/badge/astropy-affiliated%20package-orange.svg + :target: http://astropy.org/affiliated +.. |JOSS| image:: http://joss.theoj.org/papers/10.21105/joss.00388/status.svg + :target: http://joss.theoj.org/papers/10.21105/joss.00388 +.. |DOI| image:: https://zenodo.org/badge/17577779.svg + :target: https://zenodo.org/badge/latestdoi/17577779 +.. |ASCL| image:: https://img.shields.io/badge/ascl-1707.006-blue.svg?colorB=262255 + :target: http://ascl.net/1707.006 +.. |logo| image:: https://gala.adrian.pw/en/latest/_static/Gala_Logo_RGB.png + :target: https://github.com/adrn/gala + :width: 400 + +Contributors +------------ + +See the `AUTHORS.rst `_ +file for a complete list of contributors to the project. diff --git a/gala/source/__init__.py b/gala/source/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..61124f2609773fc100fa07cf1a2542ae62558b79 --- /dev/null +++ b/gala/source/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +""" +gala Project Package Initialization File +""" diff --git a/gala/source/codemeta.json b/gala/source/codemeta.json new file mode 100644 index 0000000000000000000000000000000000000000..9fb103b6e01b3f0cd7ef546392879319d2da1865 --- /dev/null +++ b/gala/source/codemeta.json @@ -0,0 +1,23 @@ +{ + "@context": "https://doi.org/10.5063/schema/codemeta-2.0", + "@type": "SoftwareSourceCode", + "name": "Gala: Galactic astronomy and gravitational dynamics", + "description": "Gala is an Astropy-affiliated Python package for galactic dynamics. Python enables wrapping low-level languages (e.g., C) for speed without losing flexibility or ease-of-use in the user-interface. The API for Gala was designed to provide a class-based and user-friendly interface to fast (C or Cython-optimized) implementations of common operations such as gravitational potential and force evaluation, orbit integration, dynamical transformations, and chaos indicators for nonlinear dynamics. Gala also relies heavily on and interfaces well with the implementations of physical units and astronomical coordinate systems in the Astropy package (astropy.units and astropy.coordinates).", + "identifier": "https://dx.doi.org/10.21105/joss.00388", + "author": [ + { + "@type": "Person", + "givenName": "Adrian", + "familyName": "Price-Whelan", + "@id": "http://orcid.org/0000-0003-0872-7098" + } + ], + "citation": "https://ui.adsabs.harvard.edu/abs/2017JOSS....2..388P/abstract", + "relatedLink": [ + "https://gala.adrian.pw/", + "https://zenodo.org/record/4159870" + ], + "codeRepository": ["https://github.com/adrn/gala"], + "version": "v1.3", + "license": "https://github.com/adrn/gala/blob/main/LICENSE" +} diff --git a/gala/source/conftest.py b/gala/source/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..7b4b0db7086db7998e1d8fae30670b59235258d6 --- /dev/null +++ b/gala/source/conftest.py @@ -0,0 +1,41 @@ +import os +import sys +from pathlib import Path + +from pytest_astropy_header.display import ( + PYTEST_HEADER_MODULES, + TESTED_VERSIONS, +) + +# Add test helpers to path so they can be imported +tests_dir = Path(__file__).parent +sys.path.insert(0, str(tests_dir)) + + +def pytest_configure(config): + config.option.astropy_header = True + PYTEST_HEADER_MODULES.pop("Pandas", None) + PYTEST_HEADER_MODULES["astropy"] = "astropy" + + from gala import __version__ + + packagename = os.path.basename(os.path.dirname(__file__)) + TESTED_VERSIONS[packagename] = __version__ + + +def pytest_report_header(config): + from gala._cconfig import EXP_ENABLED, GSL_ENABLED + + hdr = [] + if GSL_ENABLED: + hdr.append(" +++ Gala compiled with GSL +++") + else: + hdr.append(" --- Gala compiled without GSL ---") + + if EXP_ENABLED: + hdr.append(" +++ Gala compiled with EXP +++") + else: + hdr.append(" --- Gala compiled without EXP ---") + hdr.append("") + + return "\n".join(hdr) diff --git a/gala/source/docs/Makefile b/gala/source/docs/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..70b7b06a7e3a83ad965e88cf4d23190087d0c127 --- /dev/null +++ b/gala/source/docs/Makefile @@ -0,0 +1,149 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest + +#This is needed with git because git doesn't create a dir if it's empty +$(shell [ -d "_static" ] || mkdir -p _static) + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR) + -rm -rf api + -rm -rf tutorials/*.ipynb + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Astropy.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Astropy.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Astropy" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Astropy" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + make -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +exectutorials: + jupytext --to ipynb --execute tutorials/*.py + @echo "Finished executing tutorial notebooks. Look at the executed " \ + "notebooks in tutorials/" + +execsupporting: + jupytext --to ipynb --execute supporting/*.py + @echo "Finished executing tutorial notebooks. Look at the executed " \ + "notebooks in supporting/" + +animations: + python _static_animations.py + @echo "Finished generating animation files" diff --git a/gala/source/docs/_static/Gala_Logo_RGB.png b/gala/source/docs/_static/Gala_Logo_RGB.png new file mode 100644 index 0000000000000000000000000000000000000000..bae73f55c5d404c22083a3257a4f521574aefa2d Binary files /dev/null and b/gala/source/docs/_static/Gala_Logo_RGB.png differ diff --git a/gala/source/docs/_static/anim-prof.mp4 b/gala/source/docs/_static/anim-prof.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..1902635076cb5c0ca50859ec3812feefe7979d00 --- /dev/null +++ b/gala/source/docs/_static/anim-prof.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55fdb6e46d3637fe2264f55310905faeef1b10dd4117b3ab917d7e5d728d7642 +size 184605 diff --git a/gala/source/docs/_static/gala.css b/gala/source/docs/_static/gala.css new file mode 100644 index 0000000000000000000000000000000000000000..a2bec504ecdbed341d6844c880bd183aebe07d91 --- /dev/null +++ b/gala/source/docs/_static/gala.css @@ -0,0 +1,70 @@ +span#logotext2 { + color: #764099; +} + +/* Taken from NumPy */ + +@import url("https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;0,900;1,400;1,700;1,900&family=Open+Sans:ital,wght@0,400;0,600;1,400;1,600&display=swap"); + +.navbar-brand img { + height: 60px; +} +.navbar-brand { + height: 75px; +} + +body { + font-family: "Open Sans", sans-serif; +} + +pre, +code { + font-size: 100%; + line-height: 155%; +} + +div.output_area div[class*="highlight"] pre { + white-space: pre-wrap; +} + +/* Make output lighter gray and no italics for the love of all that is holy! */ +html[data-theme="light"] .highlight .go { + color: #555555; + font-style: normal; +} + +html[data-theme="dark"] .highlight .go { + font-style: normal; +} + +/* OMG why would you bold numbers */ +.highlight .mf, +.highlight .mi { + font-weight: 300; +} + +/* Override some aspects of the pydata-sphinx-theme: taken from Pandas */ + +:root { + /* Use softer blue from bootstrap's default info color */ + /* --pst-color-info: 23, 162, 184; */ + --pst-color-primary: 118, 63, 152; + --pst-color-success: 40, 167, 69; + --pst-color-info: 0, 123, 255; + --pst-color-warning: 255, 193, 7; + --pst-color-danger: 220, 53, 69; + --pst-color-text-base: 51, 51, 51; + + --pst-font-size-base: 15px; + + --pst-color-link: 118, 63, 152; + --pst-color-headerlink-hover: 118, 63, 152; + + /* heading font sizes */ + --pst-font-size-h1: 28px; + --pst-font-size-h2: 25px; + --pst-font-size-h3: 20px; + --pst-font-size-h4: 18px; + --pst-font-size-h5: 16px; + --pst-font-size-h6: 15px; +} diff --git a/gala/source/docs/_static/m104.ico b/gala/source/docs/_static/m104.ico new file mode 100644 index 0000000000000000000000000000000000000000..2f0edc6f7670c94c28dad5bb7a9f85e854e0fddd Binary files /dev/null and b/gala/source/docs/_static/m104.ico differ diff --git a/gala/source/docs/_static/orbit-anim1.mp4 b/gala/source/docs/_static/orbit-anim1.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..1fab5474fc2b2204d1fbf3e4eba7c6e3c5d38f77 --- /dev/null +++ b/gala/source/docs/_static/orbit-anim1.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13306d74201810520823f51478e8724c4d5cbc7b960ad9b1d939872c9a38319f +size 364400 diff --git a/gala/source/docs/_static/orbit-anim2.mp4 b/gala/source/docs/_static/orbit-anim2.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..f58fc3ba23289deb1f7fc8bd58f6aad5689ff2c0 --- /dev/null +++ b/gala/source/docs/_static/orbit-anim2.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac8b5bbb111753d6b13d5918b889c9bf6a03a0dbeba75a1297f22cfc93260f8d +size 257682 diff --git a/gala/source/docs/_static_animations.py b/gala/source/docs/_static_animations.py new file mode 100644 index 0000000000000000000000000000000000000000..e2add59aaf03be1ec78dc8c21bbd038bb35a1cb0 --- /dev/null +++ b/gala/source/docs/_static_animations.py @@ -0,0 +1,31 @@ +def make_orbit_animations(static_path): + # orbits-in-derail.rst + import astropy.units as u + + import gala.dynamics as gd + import gala.potential as gp + from gala.units import galactic + + file1 = static_path / "orbit-anim1.mp4" + file2 = static_path / "orbit-anim2.mp4" + + if file1.exists() and file2.exists(): + return + + pot = gp.PlummerPotential(m=1e10 * u.Msun, b=1.0 * u.kpc, units=galactic) + w0 = gd.PhaseSpacePosition(pos=[2.0, 0, 0] * u.kpc, vel=[0.0, 75, 15] * u.km / u.s) + orbit = gp.Hamiltonian(pot).integrate_orbit(w0, dt=1.0, n_steps=5000) + + # animation 1: + fig, anim = orbit[:1000].animate(stride=10) + anim.save(file1) + + # animation 2: + _fig, anim = orbit[:1000].cylindrical.animate(components=["rho", "z"], stride=10) + anim.save(file2) + + +if __name__ == "__main__": + import pathlib + + make_orbit_animations(pathlib.Path("./_static").resolve().absolute()) diff --git a/gala/source/docs/_templates/autosummary/base.rst b/gala/source/docs/_templates/autosummary/base.rst new file mode 100644 index 0000000000000000000000000000000000000000..a58aa35ff9c132ca3da845587dd0dd730962b5ae --- /dev/null +++ b/gala/source/docs/_templates/autosummary/base.rst @@ -0,0 +1,10 @@ +{% if referencefile %} +.. include:: {{ referencefile }} +{% endif %} + +{{ objname }} +{{ underline }} + +.. currentmodule:: {{ module }} + +.. auto{{ objtype }}:: {{ objname }} diff --git a/gala/source/docs/_templates/autosummary/class.rst b/gala/source/docs/_templates/autosummary/class.rst new file mode 100644 index 0000000000000000000000000000000000000000..85105fa8fba2ab3e43eea667806c12ffb1605c67 --- /dev/null +++ b/gala/source/docs/_templates/autosummary/class.rst @@ -0,0 +1,65 @@ +{% if referencefile %} +.. include:: {{ referencefile }} +{% endif %} + +{{ objname }} +{{ underline }} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + :show-inheritance: + + {% if '__init__' in methods %} + {% set caught_result = methods.remove('__init__') %} + {% endif %} + + {% block attributes_summary %} + {% if attributes %} + + .. rubric:: Attributes Summary + + .. autosummary:: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + + {% endif %} + {% endblock %} + + {% block methods_summary %} + {% if methods %} + + .. rubric:: Methods Summary + + .. autosummary:: + {% for item in methods %} + ~{{ name }}.{{ item }} + {%- endfor %} + + {% endif %} + {% endblock %} + + {% block attributes_documentation %} + {% if attributes %} + + .. rubric:: Attributes Documentation + + {% for item in attributes %} + .. autoattribute:: {{ item }} + {%- endfor %} + + {% endif %} + {% endblock %} + + {% block methods_documentation %} + {% if methods %} + + .. rubric:: Methods Documentation + + {% for item in methods %} + .. automethod:: {{ item }} + {%- endfor %} + + {% endif %} + {% endblock %} diff --git a/gala/source/docs/_templates/autosummary/module.rst b/gala/source/docs/_templates/autosummary/module.rst new file mode 100644 index 0000000000000000000000000000000000000000..11208a25c6b1c0ea2902dbe733baf4eb84572dc9 --- /dev/null +++ b/gala/source/docs/_templates/autosummary/module.rst @@ -0,0 +1,41 @@ +{% if referencefile %} +.. include:: {{ referencefile }} +{% endif %} + +{{ objname }} +{{ underline }} + +.. automodule:: {{ fullname }} + + {% block functions %} + {% if functions %} + .. rubric:: Functions + + .. autosummary:: + {% for item in functions %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block classes %} + {% if classes %} + .. rubric:: Classes + + .. autosummary:: + {% for item in classes %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block exceptions %} + {% if exceptions %} + .. rubric:: Exceptions + + .. autosummary:: + {% for item in exceptions %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} diff --git a/gala/source/docs/conf.py b/gala/source/docs/conf.py new file mode 100644 index 0000000000000000000000000000000000000000..4a45d3c808516e50a8daebb7693be2c21c41ebf8 --- /dev/null +++ b/gala/source/docs/conf.py @@ -0,0 +1,356 @@ +import datetime +import os +import pathlib +import re +import sys +import warnings +from importlib import import_module + +# Load all of the global Astropy configuration +try: + from sphinx_astropy.conf.v1 import * # noqa: F403 +except ImportError: + print( + "ERROR: Building the documentation for Gala requires the " + "sphinx-astropy package to be installed" + ) + sys.exit(1) + +# Get configuration information from setup.cfg +from configparser import ConfigParser + +conf = ConfigParser() + +docs_root = pathlib.Path(__file__).parent.resolve() + +# -- General configuration ---------------------------------------------------- + +# By default, highlight as Python 3. +highlight_language = "python3" + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ["_build", "**.ipynb_checkpoints"] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# source_suffix = ['.rst', '.md'] +source_suffix = ".rst" + +# Don't show summaries of the members in each class along with the +# class' docstring +numpydoc_show_class_members = False + +# Whether to create cross-references for the parameter types in the +# Parameters, Other Parameters, Returns and Yields sections of the docstring. +numpydoc_xref_param_type = True + +autosummary_generate = True + +automodapi_toctreedirnm = "api" + +# The reST default role (used for this markup: `text`) to use for all +# documents. Set to the "smart" one. +default_role = "obj" + +# Class documentation should contain *both* the class docstring and +# the __init__ docstring +autoclass_content = "both" + +# This is added to the end of RST files - a good place to put substitutions to +# be used globally. +rst_epilog = """ +""" + +# intersphinx +intersphinx_mapping = { + "python": ("https://docs.python.org/3/", None), + "numpy": ("https://numpy.org/doc/stable/", None), + "scipy": ("https://docs.scipy.org/doc/scipy/", None), + "matplotlib": ("https://matplotlib.org/stable/", None), + "astropy": ("https://docs.astropy.org/en/stable/", None), + "h5py": ("https://docs.h5py.org/en/stable/", None), + "sympy": ("https://docs.sympy.org/latest/", None), +} + +# Show / hide TODO blocks +todo_include_todos = True + +# -- Project information ------------------------------------------------------ + +# This does not *have* to match the package name, but typically does +project = "gala" +author = "Adrian Price-Whelan" +copyright = f"{datetime.datetime.now().year}, {author}" + +package_name = "gala" +import_module(package_name) +package = sys.modules[package_name] + +plot_formats = [("png", 200), ("pdf", 200)] +plot_apply_rcparams = True +# NOTE: if you update these, also update docs/tutorials/nb_setup +plot_rcparams = { + "image.cmap": "magma", + # Fonts: + "font.size": 16, + "figure.titlesize": "x-large", + "axes.titlesize": "large", + "axes.labelsize": "large", + "xtick.labelsize": "medium", + "ytick.labelsize": "medium", + # Axes: + "axes.labelcolor": "k", + "axes.axisbelow": True, + # Ticks + "xtick.color": "#333333", + "xtick.direction": "in", + "ytick.color": "#333333", + "ytick.direction": "in", + "xtick.top": True, + "ytick.right": True, + "figure.dpi": 300, + "savefig.dpi": 300, +} +plot_include_source = False + +# The short X.Y version. +version = package.__version__.split("-", 1)[0] +# The full version, including alpha/beta/rc tags. +release = package.__version__ + + +# -- Options for HTML output --------------------------------------------------- + +html_theme = "pydata_sphinx_theme" +html_logo = "_static/Gala_Logo_RGB.png" + +html_theme_options = { + "logo": { + "image_light": "Gala_Logo_RGB.png", + "image_dark": "Gala_Logo_RGB.png", + }, + "icon_links": [ + { + "name": "GitHub", + "url": "https://github.com/adrn/gala", + "icon": "fab fa-github-square", + }, + { + "name": "Twitter", + "url": "https://twitter.com/adrianprw", + "icon": "fab fa-twitter-square", + }, + ], +} + +# Add any paths that contain custom themes here, relative to this directory. +# To use a different custom theme, add the directory containing the theme. +# html_theme_path = ['_themes/sphinx_rtd_theme'] + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. To override the custom theme, set this to the +# name of a builtin theme or the name of a custom theme in html_theme_path. +# html_theme = "sphinx_rtd_theme" + +# Custom sidebar templates, maps document names to template names. +html_sidebars = {"**": ["search-field.html", "sidebar-nav-bs.html"]} + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +html_favicon = str(docs_root / "_static" / "m104.ico") + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '' + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +html_title = f"{project} v{release}" + +# Output file base name for HTML help builder. +htmlhelp_basename = project + "doc" + +# Static files to copy after template files +html_static_path = ["_static"] +html_css_files = ["gala.css"] + + +# -- Options for LaTeX output -------------------------------------------------- + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ("index", project + ".tex", project + " Documentation", author, "manual") +] + +# show inherited members for classes +automodsumm_inherited_members = True + +# Add nbsphinx +extensions += [ # noqa: F405 + "nbsphinx", + "IPython.sphinxext.ipython_console_highlighting", + "sphinxcontrib.bibtex", + "rtds_action", +] + +# Bibliography: +bibtex_bibfiles = ["refs.bib"] +bibtex_reference_style = "author_year" + +# Custom setting for nbsphinx - timeout for executing one cell +nbsphinx_timeout = 300 +nbsphinx_kernel_name = os.environ.get("NBSPHINX_KERNEL_NAME", "python3") + +# nbsphinx hacks (thanks exoplanet) +import nbsphinx # noqa: E402 +from nbsphinx import markdown2rst as original_markdown2rst # noqa: E402 + +nbsphinx.RST_TEMPLATE = nbsphinx.RST_TEMPLATE.replace( + "{%- if width %}", "{%- if 0 %}" +).replace("{%- if height %}", "{%- if 0 %}") + + +def subber(m): + return m.group(0).replace("``", "`") + + +prog = re.compile(r":(.+):``(.+)``") + + +def markdown2rst(text): + return prog.sub(subber, original_markdown2rst(text)) + + +nbsphinx.markdown2rst = markdown2rst + +# rtds-action +if "GITHUB_TOKEN" in os.environ: + print("GitHub Token found: retrieving artifact") + + # The name of your GitHub repository + rtds_action_github_repo = "adrn/gala" + + # The path where the artifact should be extracted + # Note: this is relative to the conf.py file! + rtds_action_path = "." + + # The "prefix" used in the `upload-artifact` step of the action + rtds_action_artifact_prefix = "notebooks-for-" + + # A GitHub personal access token is required, more info below + rtds_action_github_token = os.environ["GITHUB_TOKEN"] + + # Whether or not to raise an error on ReadTheDocs if the + # artifact containing the notebooks can't be downloaded (optional) + rtds_action_error_if_missing = True + +else: + rtds_action_github_repo = "" + rtds_action_github_token = "" + rtds_action_path = "" + +## -- Retrieve Zenodo record for most recent version of Gala: +zenodo_path = docs_root / "ZENODO.rst" +if not zenodo_path.exists(): + import textwrap + + try: + import requests + + headers = {"accept": "application/x-bibtex"} + response = requests.get( + "https://zenodo.org/api/records/16923466", headers=headers + ) + response.encoding = "utf-8" + zenodo_record = ".. code-block:: bibtex\n\n" + textwrap.indent( + response.text, " " * 4 + ) + except Exception as e: + warnings.warn(f"Failed to retrieve Zenodo record for Gala: {e!s}", stacklevel=1) + zenodo_record = ( + "`Retrieve the Zenodo record here `_" + ) + + with open(zenodo_path, "w", encoding="utf-8") as f: + f.write(zenodo_record) + +## -- Check for executed tutorials and only add to toctree if they exist: + +# Note: for jupytext tutorials (the .py files), put the expected (generated) .ipynb name +tutorial_files = [ + "tutorials/Milky-Way-model.ipynb", + "tutorials/integrate-potential-example.rst", + "tutorials/pyia-gala-orbit.ipynb", + "tutorials/integrate-barred-potential.ipynb", + "tutorials/mock-stream-heliocentric.rst", + "tutorials/circ-restricted-3body.rst", + "tutorials/spherical-spline-tutorial.ipynb", + "tutorials/Arbitrary-density-SCF.ipynb", + "tutorials/exp.rst", + "tutorials/stream-mass-loss.ipynb", + "tutorials/time-evolving-potential.ipynb", + "tutorials/v1_11_new_features.ipynb", + # Supporting documents: + "supporting/define-milky-way-model.ipynb", +] + +_not_executed = [] +_tutorial_toctree_items = [] +_supporting_toctree_items = [] +for fn in tutorial_files: + if not pathlib.Path(fn).exists() and "GITHUB_TOKEN" not in os.environ: + _not_executed.append(fn) + continue + + if fn.startswith("supporting/"): + _supporting_toctree_items.append(fn) + elif fn.startswith("tutorials/"): + _tutorial_toctree_items.append(fn) + +if _tutorial_toctree_items: + _tutorial_toctree_items = "\n ".join(_tutorial_toctree_items) + _tutorial_toctree = f""" +.. toctree:: + :maxdepth: 1 + :glob: + + {_tutorial_toctree_items} + """ + +else: + _tutorial_toctree = "No tutorials found!" + +if _supporting_toctree_items: + _supporting_toctree_items = "\n ".join(_supporting_toctree_items) + _supporting_toctree = f""" +.. toctree:: + :maxdepth: 1 + :glob: + + {_supporting_toctree_items} + """ + +else: + _supporting_toctree = "No supporting documents found!" + +if _not_executed: + print( + "\n-------- Gala warning --------\n" + "Some tutorial notebooks could not be found! This is likely because " + "the tutorial notebooks have not been executed. If you are building " + "the documentation locally, you may want to run 'make exectutorials' " + "before running the sphinx build." + ) + print(f"Missing tutorials: {', '.join(_not_executed)}\n") + +with open("_tutorials.rst", "w", encoding="utf-8") as f: + f.write(_tutorial_toctree) + +with open("_supporting.rst", "w", encoding="utf-8") as f: + f.write(_supporting_toctree) diff --git a/gala/source/docs/contributing.rst b/gala/source/docs/contributing.rst new file mode 100644 index 0000000000000000000000000000000000000000..f1ed428c7377b9349835e417c850572692a97c31 --- /dev/null +++ b/gala/source/docs/contributing.rst @@ -0,0 +1,19 @@ +.. include:: references.txt + +***************** +How to contribute +***************** + +We welcome contributions from anyone via pull requests on `GitHub +`_. If you don't feel comfortable modifying or +adding functionality, we also welcome feature requests and bug reports as +`GitHub issues `_. + +Developer documentation +======================= + +.. toctree:: + :maxdepth: 1 + + testing + docs diff --git a/gala/source/docs/conventions.rst b/gala/source/docs/conventions.rst new file mode 100644 index 0000000000000000000000000000000000000000..f4983fffbe3a18d3f393d3e94d41aec7b16aac3a --- /dev/null +++ b/gala/source/docs/conventions.rst @@ -0,0 +1,49 @@ + +.. _conventions: + +*********** +Conventions +*********** + +.. _name-conventions: + +Common variable names +===================== + +This package uses standard variable names throughout for consistency: + +- ``w`` represents phase-space coordinates (positions and velocities) +- ``q`` represents positions only +- ``p`` or ``v`` represent velocities or momenta +- ``t`` represents time arrays + +.. _shape-conventions: + +Array shapes +============ + +Arrays and :class:`~astropy.units.Quantity` objects in ``Gala`` follow +consistent shape conventions: + +**Coordinate arrays**: ``axis=0`` is the coordinate dimension. For example, +128 different 3D Cartesian positions have shape ``(3, 128)``. + +**Orbit collections**: Arrays have three axes: +- ``axis=0``: coordinate dimension +- ``axis=1``: time axis +- ``axis=2``: different orbits + +.. _energy-momentum: + +Energy and momentum +=================== + +In `gala`, energy and angular momentum quantities are *per unit mass* unless +otherwise specified. This applies to: + +- Potential energy +- Kinetic energy +- Total energy +- Angular momentum +- Linear momentum +- Conjugate momenta diff --git a/gala/source/docs/coordinates/greatcircle.rst b/gala/source/docs/coordinates/greatcircle.rst new file mode 100644 index 0000000000000000000000000000000000000000..cbf7424900b9c679053748efcd8df3b0dfaf1610 --- /dev/null +++ b/gala/source/docs/coordinates/greatcircle.rst @@ -0,0 +1,184 @@ +For the examples below, we assume the following imports have already been +executed:: + + >>> import astropy.units as u + >>> import astropy.coordinates as coord + >>> import numpy as np + >>> import gala.coordinates as gc + +.. _greatcircle: + +************************************************* +Great circle and stellar stream coordinate frames +************************************************* + + +Introduction +============ + +Great circle coordinate systems are defined as a rotation from another spherical +coordinate system, such as the ICRS. The great circle system is defined by a specified +(north) pole and spherical origin -- i.e. a specification of the new coordinate system x +and z axes in components of the old coordinate system. + +`gala` currently supports great circle frames that are defined as a rotation away from +the ICRS (RA, Dec) through the `~gala.coordinates.GreatCircleICRSFrame` class. To create +a new great circle frame with the default initializer, you must specify a pole using the +``pole`` keyword argument and the spherical origin with the ``origin`` argument. +However, this frame also supports other initialization paths through the ``from_`` +classmethods (see API below). These classmethods are the most useful initialization +methods. For example, to define a great circle system with the pole at (RA, Dec) = +(32.5, 19.8)º and a longitude 0 at RA=100º, we first have to create a coordinate object +for the pole:: + + >>> pole = coord.SkyCoord(ra=32.5*u.deg, dec=19.8*u.deg) + +We can then pass this pole to the `~gala.coordinates.GreatCircleICRSFrame.from_pole_ra0` +classmethod to define our coordinate frame:: + + >>> frame = gc.GreatCircleICRSFrame.from_pole_ra0(pole=pole, ra0=100*u.deg) + +This frame instance acts like any other Astropy coordinate frame. For example, we can +transform other coordinates to this new coordinate system using:: + + >>> c = coord.SkyCoord(ra=[160, 53]*u.deg, dec=[-11, 9]*u.deg) + >>> c_fr = c.transform_to(frame) + >>> c_fr # doctest: +FLOAT_CMP + , origin=, priority=origin): (phi1, phi2) in deg + [(-127.59199268, -38.82050866), (-154.93887946, 67.43382209)]> + +The spherical coordinate components of the resulting great circle frame are +always named ``phi1`` and ``phi2``, so to access the longitude and latitude in +the new system, we use:: + + >>> c_fr.phi1 # doctest: +FLOAT_CMP + + >>> c_fr.phi2 # doctest: +FLOAT_CMP + + +The transformation also works for velocity components. For example, if we have a +sky position and proper motions, we can transform to the great circle frame in +the same way:: + + >>> c2 = coord.SkyCoord( + ... ra=160*u.deg, + ... dec=-11*u.deg, + ... pm_ra_cosdec=5*u.mas/u.yr, + ... pm_dec=0.3*u.mas/u.yr + ... ) + >>> c2_fr = c2.transform_to(frame) + >>> c2_fr.phi1 # doctest: +FLOAT_CMP + + >>> c2_fr.pm_phi1_cosphi2 # doctest: +FLOAT_CMP + + >>> c2_fr.pm_phi2 # doctest: +FLOAT_CMP + + +The generic great circle frame can also handle transforming from great circle +coordinates to other coordinate frames. For example, to transform a grid of points along +a great circle to the ICRS system, we would define a frame with positional data and a +specified pole:: + + >>> c3_fr = gc.GreatCircleICRSFrame( + ... phi1=np.linspace(0, 360, 8)*u.deg, + ... phi2=0*u.deg, + ... pole=frame.pole, + ... origin=frame.origin + ... ) + >>> c3 = c3_fr.transform_to(coord.ICRS()) + >>> c3.ra # doctest: +FLOAT_CMP + + + +Creating a coordinate frame from two points along a great circle +================================================================ + +It is sometimes convenient to define a great circle coordinate frame by specifying two +endpoints of an arc segment along a great circle (instead of the pole). For these use +cases, the `~gala.coordinates.GreatCircleICRSFrame.from_endpoints` provides a +convenience classmethod for creating a great circle frame with endpoints:: + + >>> endpoints = coord.SkyCoord( + ... ra=[-38.8, 4.7]*u.deg, + ... dec=[-45.1, -51.7]*u.deg + ... ) + >>> frame2 = gc.GreatCircleICRSFrame.from_endpoints(endpoints[0], endpoints[1]) + >>> frame2 + , origin=, priority=origin)> + +Without specifying a longitude zeropoint, the default behavior of the above classmethod +is to take the spherical midpoint of the two endpoints as the longitude zeropoint. +However, a custom zeropoint can be specified using the ``ra0`` keyword argument. For +example:: + + >>> frame3 = gc.GreatCircleICRSFrame.from_endpoints( + ... endpoints[0], endpoints[1], ra0=150*u.deg + ... ) + >>> frame3 + , origin=, priority=origin)> + + +Creating a coordinate frame from endpoints and an origin +======================================================== + +When working with stellar streams, it is sometimes useful to create a stream-aligned +coordinate frame by specifying an exact origin for the new great circle coordinate frame +(e.g., set to the progenitor system) along with the endpoints of the stream (which are +often close to defining a great circle). In these cases, the great circle defined by the +endpoints and the great circle defined by the origin may not be orthogonal. You can +still use these to create a `~gala.coordinates.GreatCircleICRSFrame`, but by default the +pole location will be adjusted to be orthogonal to the input origin:: + + >>> endpoints = coord.SkyCoord( + ... ra=[-38.8, 4.7]*u.deg, + ... dec=[-45.1, -51.7]*u.deg + ... ) + >>> origin = coord.SkyCoord(330., -48., unit=u.deg) + >>> frame4 = gc.GreatCircleICRSFrame.from_endpoints( # doctest: +IGNORE_WARNINGS + ... endpoints[0], endpoints[1], origin=origin + ... ) + >>> frame4 + , origin=, priority=origin)> + + +Creating a coordinate frame from a pole and longitude zero point +================================================================ + +Another common way of initializing great circle coordinate systems is with a pole and a +longitude zero point (as was previously — prior to v1.7 — allowed in the initializer +`~gala.coordinates.GreatCircleICRSFrame`). This can now be done with the +`~gala.coordinates.GreatCircleICRSFrame.from_pole_ra0` classmethod:: + + >>> frame5 = gc.GreatCircleICRSFrame.from_pole_ra0( + ... pole=pole, ra0=100*u.deg + ... ) + >>> frame5 + , origin=, priority=origin)> + +With just these inputs, there is an ambiguity in the definition of the coordinate frame +because the great circles defined by the pole and longitude zero point intersect at two +locations (so there are two possible origins, one being the negative of the other). The +convention here is to pick the origin closest to (0, 0). To have finer control over +which origin is picked, you can also pass in a sky coordinate object with the +``origin_disambiguate`` argument, and the origin closest to this coordinate will be used +to define the coordinate frame. + + +.. _greatcircle-api: + +API +=== + +.. automodapi:: gala.coordinates.greatcircle + :no-inheritance-diagram: diff --git a/gala/source/docs/coordinates/index.rst b/gala/source/docs/coordinates/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..68778ec5f4f2724a2f5f7137f25bcdf142aea1f2 --- /dev/null +++ b/gala/source/docs/coordinates/index.rst @@ -0,0 +1,241 @@ +.. module:: gala.coordinates + +.. _gala-coordinates: + +********************************************* +Coordinate Systems (`gala.coordinates`) +********************************************* + +Introduction +============ + +The `~gala.coordinates` subpackage primarily provides specialty +:mod:`astropy.coordinates` frame classes for coordinate systems defined by the +stellar streams, and for other common Galactic dynamics tasks like removing +solar reflex motion from proper motions or radial velocities, and transforming +a proper motion covariance matrix from one frame to another. + +For the examples below the following imports have already been executed:: + + >>> import numpy as np + >>> import astropy.coordinates as coord + >>> import astropy.units as u + >>> import gala.coordinates as gc + +We will also set the default Astropy Galactocentric frame parameters to the +values adopted in Astropy v4.0: + + >>> _ = coord.galactocentric_frame_defaults.set('v4.0') + +Stellar stream coordinate frames +================================ + +`gala` provides Astropy coordinate frame classes for transforming to several +built-in stellar stream stream coordinate frames (as defined in the references +below), and for transforming positions and velocities to and from coordinate +systems defined by great circles or poles. These classes behave like the +built-in astropy coordinates frames (e.g., :class:`~astropy.coordinates.ICRS` or +:class:`~astropy.coordinates.Galactic`) and can be transformed to and from other +astropy coordinate frames. For example, to convert a set of +`~astropy.coordinates.ICRS` (RA, Dec) coordinates to a coordinate system aligned +with the Sagittarius stream with the `~gala.coordinates.SagittariusLaw10` +frame:: + + >>> c = coord.ICRS(ra=100.68458*u.degree, dec=41.26917*u.degree) + >>> sgr = c.transform_to(gc.SagittariusLaw10()) + >>> (sgr.Lambda, sgr.Beta) # doctest: +FLOAT_CMP + (, ) + +Or, to transform from `~gala.coordinates.SagittariusLaw10` coordinates to the +`~astropy.coordinates.Galactic` frame:: + + >>> sgr = gc.SagittariusLaw10(Lambda=156.342*u.degree, Beta=1.1*u.degree) + >>> c = sgr.transform_to(coord.Galactic()) + >>> (c.l, c.b) # doctest: +FLOAT_CMP + (, ) + +These transformations also handle velocities so that proper motion components +can be transformed between the systems. For example, to transform from +`~gala.coordinates.GD1Koposov10` proper motions to +`~astropy.coordinates.Galactic` proper motions:: + + >>> gd1 = gc.GD1Koposov10(phi1=-35*u.degree, phi2=0*u.degree, + ... pm_phi1_cosphi2=-12.20*u.mas/u.yr, + ... pm_phi2=-3.10*u.mas/u.yr) + >>> gd1.transform_to(coord.Galactic()) # doctest: +FLOAT_CMP + + +As with the other Astropy coordinate frames, with a full specification of the 3D +position and velocity, we can transform to a +`~astropy.coordinates.Galactocentric` frame:: + + >>> gd1 = gc.GD1Koposov10(phi1=-35.00*u.degree, phi2=0.04*u.degree, + ... distance=7.83*u.kpc, + ... pm_phi1_cosphi2=-12.20*u.mas/u.yr, + ... pm_phi2=-3.10*u.mas/u.yr, + ... radial_velocity=-32*u.km/u.s) + >>> gd1.transform_to(coord.Galactocentric()) # doctest: +FLOAT_CMP + , galcen_distance=8.122 kpc, galcen_v_sun=(12.9, 245.6, 7.78) km / s, z_sun=20.8 pc, roll=0.0 deg): (x, y, z) in kpc + (-12.61622659, -0.09870921, 6.43179403) + (v_x, v_y, v_z) in km / s + (-71.14675268, -203.01648654, -97.12884319)> + +For custom great circle coordinate systems, and for more information about the +stellar stream frames, see :ref:`greatcircle`. + + +Correcting velocities for solar reflex motion +--------------------------------------------- + +The `~gala.coordinates.reflex_correct` function accepts an Astropy +`~astropy.coordinates.SkyCoord` object with position and velocity information, +and returns a coordinate object with the solar motion added back in to the +velocity components. This is useful for computing velocities in a Galactocentric +reference frame, rather than a solar system barycentric frame. + +The `~gala.coordinates.reflex_correct` function accepts a coordinate object with +scalar or array values:: + + >>> c = coord.SkyCoord(ra=[180.323, 1.523]*u.deg, + ... dec=[-17, 29]*u.deg, + ... distance=[172, 412]*u.pc, + ... pm_ra_cosdec=[-11, 3]*u.mas/u.yr, + ... pm_dec=[4, 8]*u.mas/u.yr, + ... radial_velocity=[114, -21]*u.km/u.s) + >>> gc.reflex_correct(c) # doctest: +FLOAT_CMP + + +By default, this uses the solar location and velocity from the +`astropy.coordinates.Galactocentric` frame class. To modify these parameters, +for example, to change the solar velocity, or the sun's height above the +Galactic midplane, use the arguments of the `astropy.coordinates.Galactocentric` +class and pass in an instance of the `astropy.coordinates.Galactocentric` +frame:: + + >>> vsun = coord.CartesianDifferential([11., 245., 7.]*u.km/u.s) + >>> gc_frame = coord.Galactocentric(galcen_v_sun=vsun, z_sun=0*u.pc) + >>> gc.reflex_correct(c, gc_frame) # doctest: +FLOAT_CMP + + +If you don't have radial velocity information and want to correct the proper +motions, pass in zeros for the radial velocity (and ignore the output value of +the radial velocity):: + + >>> c = coord.SkyCoord(ra=162*u.deg, + ... dec=-17*u.deg, + ... distance=172*u.pc, + ... pm_ra_cosdec=-11*u.mas/u.yr, + ... pm_dec=4*u.mas/u.yr, + ... radial_velocity=0*u.km/u.s) + >>> gc.reflex_correct(c) # doctest: +FLOAT_CMP + + +Similarly, if you don't have proper motion information and want to correct the +proper motions, pass in zeros for the proper motions (and ignore the output +values of the proper motions) -- this is sometimes called "v_GSR":: + + >>> c = coord.SkyCoord(ra=162*u.deg, + ... dec=-17*u.deg, + ... distance=172*u.pc, + ... pm_ra_cosdec=0*u.mas/u.yr, + ... pm_dec=0*u.mas/u.yr, + ... radial_velocity=127*u.km/u.s) + >>> gc.reflex_correct(c) # doctest: +FLOAT_CMP + + + +Transforming a proper motion covariance matrix to a new coordinate frame +------------------------------------------------------------------------ + +When working with Gaia or other astrometric data sets, you may need to transform +the reported covariance matrix between proper motion components into a new +coordinate system. For example, Gaia data are provided in the +`~astropy.coordinates.ICRS` (equatorial) coordinate frame, but for Galactic +science, we often want to instead work in the `~astropy.coordinates.Galactic` +coordinate system. For this and other transformations that only require a +rotation (i.e. the origin doesn't change), the astrometric covariance matrix can +be transformed exactly through a projection of the rotation onto the tangent +plane at a given location. The details of this procedure are explained in `this +document from the Gaia data processing team +`_, +and this functionality is implemented in `gala`. Let's first create a coordinate +object to transform:: + + >>> c = coord.SkyCoord(ra=62*u.deg, + ... dec=17*u.deg, + ... pm_ra_cosdec=1*u.mas/u.yr, + ... pm_dec=3*u.mas/u.yr) + +and a covariance matrix for the proper motion components, for example, as would +be constructed from a single row from a Gaia data release source catalog:: + + >>> cov = np.array([[0.53510132, 0.16637034], + ... [0.16637034, 1.1235292 ]]) + +This matrix specifies the 2D error distribution for the proper motion +measurement *in the ICRS frame*. To transform this matrix to, e.g., the Galactic +coordinate system, use the function +`~gala.coordinates.transform_pm_cov`:: + + >>> gc.transform_pm_cov(c, cov, coord.Galactic()) # doctest: +FLOAT_CMP + array([[ 0.69450047, -0.309945 ], + [-0.309945 , 0.96413005]]) + +Note that this also works for all of the great circle or stellar stream +coordinate frames implemented in `gala`:: + + >>> gc.transform_pm_cov(c, cov, gc.GD1Koposov10()) # doctest: +FLOAT_CMP + array([[1.10838914, 0.19067958], + [0.19067958, 0.55024138]]) + +This works for array-valued coordinates as well, so try to avoid looping over +this function and instead apply it to array-valued coordinate objects. + + +References +---------- + +* `A 2MASS All-Sky View of the Sagittarius Dwarf Galaxy: I. Morphology of the + Sagittarius Core and Tidal Arms `_ +* `The Orbit of the Orphan Stream `_ +* `Constraining the Milky Way potential with a 6-D phase-space map of the GD-1 + stellar stream `_ + + +Using gala.coordinates +====================== + +More details are provided in the linked pages below: + +.. toctree:: + :maxdepth: 1 + + greatcircle + + +.. _gala-coordinates-api: + +API +=== + +.. automodapi:: gala.coordinates + :no-inheritance-diagram: + :no-main-docstr: diff --git a/gala/source/docs/docs.rst b/gala/source/docs/docs.rst new file mode 100644 index 0000000000000000000000000000000000000000..8894aa09fface7af599c587afcf5ea9c3139a8a8 --- /dev/null +++ b/gala/source/docs/docs.rst @@ -0,0 +1,16 @@ +.. _gala-docs: + +================= +Building the docs +================= + +The documentation is built by Sphinx. To start, make sure you install all of the docs dependencies:: + + pip install -e ".[docs]" + +Then change directory into the ``docs/`` path. You now have to execute the +tutorials, make animations needed by the documentation, and run the docs build:: + + make exectutorials + make animations + make html diff --git a/gala/source/docs/dynamics/actionangle.rst b/gala/source/docs/dynamics/actionangle.rst new file mode 100644 index 0000000000000000000000000000000000000000..6e76a70b5ad8b90cc3fe3e643217cdf827a55518 --- /dev/null +++ b/gala/source/docs/dynamics/actionangle.rst @@ -0,0 +1,501 @@ +.. _gala-actionangle: + +************************************************ +Transforming to actions, angles, and frequencies +************************************************ + +Introduction +============ + +Regular orbits permit a (local) transformation to a set of canonical coordinates +such that the momenta are independent, isolating integrals of motion (the +actions, :math:`\boldsymbol{J}`) and the conjugate coordinate variables (the +angles, :math:`\boldsymbol{\theta}`) linearly increase with time. Action-angle +coordinates are useful for a number of applications because the equations of motion are very simple: + +.. math:: + + H &= H(\boldsymbol{J})\\ + \dot{\boldsymbol{J}} &= -\frac{\partial H}{\partial \boldsymbol{\theta}} = 0\\ + \dot{\boldsymbol{\theta}} &= \frac{\partial H}{\partial \boldsymbol{J}} = \boldsymbol{\Omega}(\boldsymbol{J}) = {\rm constant} + +Analytic transformations from phase-space to action-angle coordinates are only +known for a few simple cases where the gravitational potential is separable or +has many symmetries. However, astronomical systems can often be approximately axisymmetric or triaxial, or have complex radial profiles that are not captured by these simple gravitational potentials where the transformations are known. + +Several numerical methods have been developed over recent years to enable +approximate transformations between ordinary position and velocity to +action-angle coordinates -- see [sanders16]_ for a summary of these methods. +In Gala, we have implemented the method described in [sanders14]_ -- later in +[sanders16]_ named the "O2GF" method -- for computing actions and angles from +numerically integrated orbits. Gala also provides an interface to the `galpy +`_ implementation of the "Staeckel Fudge" +method, which is much faster but only useful for axisymmetric or spherical +potentials. + +The O2GF action solver +====================== + +As mentioned above, this method was first introduced in [sanders14]_ and later +described in [sanders16]_. This method is very general in that it works with any +numerically-integrated orbital time series. However, it is slower than other +approximate methods: If your system is spherical or axisymmetric, other methods +will perform much better. If your system is triaxial, this method is your best +option. We demonstrate this method below with two qualitatively different +orbits: + +* :ref:`tube-axisymmetric` +* :ref:`tube-triaxial` + +(see also [binneytremaine]_ and [mcgill90]_ for more context). For the examples +below, we will use the `~gala.units.galactic` unit system and assume the +following imports have been executed:: + + >>> import astropy.coordinates as coord + >>> import astropy.units as u + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> import gala.dynamics as gd + >>> import gala.integrate as gi + >>> import gala.potential as gp + >>> from gala.units import galactic + +For many more options for action calculation, see +`tact `_. + +.. _tube-axisymmetric: + +A tube orbit in an axisymmetric potential +----------------------------------------- + +For an example of an axisymmetric potential, we use a flattened logarithmic +potential: + +.. math:: + + \Phi(x,y,z) = \frac{1}{2}v_{\rm c}^2\ln (x^2 + y^2 + (z/q)^2 + r_h^2) + +with parameters + +.. math:: + + v_{\rm c} &= 150~{\rm km}~{\rm s}^{-1}\\ + q &= 0.9\\ + r_h &= 0 + +For the orbit, we use initial conditions + +.. math:: + + \boldsymbol{r} &= (8, 0, 0)~{\rm kpc}\\ + \boldsymbol{v} &= (75, 150, 50)~{\rm km}~{\rm s}^{-1} + +We first create a potential and set up our initial conditions:: + + >>> pot = gp.LogarithmicPotential( + ... v_c=150*u.km/u.s, q1=1., q2=1., q3=0.9, r_h=0, + ... units=galactic) + >>> w0 = gd.PhaseSpacePosition(pos=[8, 0, 0.]*u.kpc, + ... vel=[75, 150, 50.]*u.km/u.s) + +We will now integrate the orbit and plot it in the meridional plane:: + + >>> w = gp.Hamiltonian(pot).integrate_orbit(w0, dt=0.5, n_steps=10000) + >>> cyl = w.represent_as('cylindrical') + >>> fig = cyl.plot(['rho', 'z'], linestyle='-') # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + import astropy.coordinates as coord + import astropy.units as u + import matplotlib.pyplot as plt + import numpy as np + import gala.potential as gp + import gala.dynamics as gd + from gala.units import galactic + + pot = gp.LogarithmicPotential(v_c=150*u.km/u.s, q1=1., q2=1., q3=0.9, r_h=0, + units=galactic) + w0 = gd.PhaseSpacePosition(pos=[8, 0, 0.]*u.kpc, + vel=[75, 150, 50.]*u.km/u.s) + + w = gp.Hamiltonian(pot).integrate_orbit(w0, dt=0.5, n_steps=10000) + cyl = w.represent_as('cylindrical') + cyl.plot(['rho', 'z'], linestyle='-') + +To solve for the actions in the true potential, we first compute the actions in +a "toy" potential -- a potential in which we can compute the actions and angles +analytically. The two simplest potentials for which this is possible are the +`~gala.potential.potential.IsochronePotential` and +`~gala.potential.potential.HarmonicOscillatorPotential`. We will use the +Isochrone potential as our toy potential for tube orbits and the harmonic +oscillator for box orbits. + +We start by finding the parameters of the toy potential (Isochrone in this case) +by minimizing the dispersion in energy for the orbit:: + + >>> toy_potential = gd.fit_isochrone(w) + >>> toy_potential + + +The actions and angles in this potential are not the true actions, but will only +serve as an approximation. This can be seen in the angles: the orbit in the true +angles would be perfectly straight lines with slope equal to the frequencies. +Instead, the orbit is wobbly in the toy potential angles:: + + >>> toy_actions,toy_angles,toy_freqs = toy_potential.action_angle(w) + >>> fig,ax = plt.subplots(1,1,figsize=(5,5)) + >>> ax.plot(toy_angles[0], toy_angles[2], linestyle='none', marker=',') # doctest: +SKIP + >>> ax.set_xlim(0,2*np.pi) # doctest: +SKIP + >>> ax.set_ylim(0,2*np.pi) # doctest: +SKIP + >>> ax.set_xlabel(r"$\theta_1$ [rad]") # doctest: +SKIP + >>> ax.set_ylabel(r"$\theta_3$ [rad]") # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + toy_potential = gd.fit_isochrone(w) + toy_actions,toy_angles,toy_freqs = toy_potential.action_angle(w) + fig,ax = plt.subplots(1,1,figsize=(5,5)) + ax.plot(toy_angles[0], toy_angles[2], linestyle='none', marker=',') + ax.set_xlim(0,2*np.pi) + ax.set_ylim(0,2*np.pi) + ax.set_xlabel(r"$\theta_1$ [rad]") + ax.set_ylabel(r"$\theta_3$ [rad]") + fig.tight_layout() + +This can also be seen in the value of the action variables, which are not +time-independent in the toy potential:: + + >>> fig,ax = plt.subplots(1,1) + >>> ax.plot(w.t, toy_actions[0], marker='') # doctest: +SKIP + >>> ax.set_xlabel(r"$t$ [Myr]") # doctest: +SKIP + >>> ax.set_ylabel(r"$J_1$ [rad]") # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + fig,ax = plt.subplots(1,1) + ax.plot(w.t, toy_actions[0].to(u.km/u.s*u.kpc), marker='') + ax.set_xlabel(r"$t$ [Myr]") + ax.set_ylabel(r"$J_1$ [kpc km/s]") + fig.tight_layout() + +We can now find approximations to the actions in the true potential. We have to +choose the maximum integer vector norm, `N_max`, which here we arbitrarily set +to 8. This will change depending on the convergence of the action correction +(the properties of the orbit and potential) and the accuracy desired:: + + >>> result = gd.find_actions_o2gf(w, N_max=8, toy_potential=toy_potential) # doctest: +SKIP + >>> result.keys() # doctest: +SKIP + dict_keys(['Sn', 'nvecs', 'freqs', 'dSn_dJ', 'angles', 'actions']) + +The value of the actions, frequencies, and the angles at t=0 are returned in +the result dictionary:: + + >>> result['actions'] # doctest: +SKIP + + +To visualize how the actions are computed, we again plot the actions in the +toy potential and then plot the "corrected" actions -- the approximation to the +actions computed using this machinery:: + + >>> nvecs = gd.generate_n_vectors(8, dx=1, dy=2, dz=2) # doctest: +SKIP + >>> act_correction = nvecs.T[...,None] * result['Sn'][None,:,None] * np.cos(nvecs.dot(toy_angles))[None] # doctest: +SKIP + >>> action_approx = toy_actions - 2*np.sum(act_correction, axis=1)*u.kpc**2/u.Myr # doctest: +SKIP + >>> + >>> fig,ax = plt.subplots(1,1) # doctest: +SKIP + >>> ax.plot(w.t, toy_actions[0].to(u.km/u.s*u.kpc), marker='', label='$J_1$') # doctest: +SKIP + >>> ax.plot(w.t, action_approx[0].to(u.km/u.s*u.kpc), marker='', label="$J_1'$") # doctest: +SKIP + >>> ax.set_xlabel(r"$t$ [Myr]") # doctest: +SKIP + >>> ax.set_ylabel(r"[kpc ${\rm M}_\odot$ km/s]") # doctest: +SKIP + >>> ax.legend() # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + import warnings + with warnings.catch_warnings(record=True): + warnings.simplefilter("ignore") + result = gd.find_actions_o2gf(w, N_max=8, toy_potential=toy_potential) + + nvecs = gd.generate_n_vectors(8, dx=1, dy=2, dz=2) + act_correction = nvecs.T[...,None] * result['Sn'][0][None,:,None] * np.cos(nvecs.dot(toy_angles))[None] + action_approx = toy_actions - 2*np.sum(act_correction, axis=1)*u.kpc**2/u.Myr + fig,ax = plt.subplots(1,1) + ax.plot(w.t, toy_actions[0].to(u.km/u.s*u.kpc), marker='', label='$J_1$') + ax.plot(w.t, action_approx[0].to(u.km/u.s*u.kpc), marker='', label="$J_1'$") + ax.set_xlabel(r"$t$ [Myr]") + ax.set_ylabel(r"[kpc ${\rm M}_\odot$ km/s]") + ax.legend() + +Above the blue line represents the approximation of the actions in the true +potential. + +.. _tube-triaxial: + +A tube orbit in a triaxial potential +------------------------------------ + +The same procedure works for regular orbits in more complex potentials. We +demonstrate this below by repeating the above in a triaxial potential. We again +use a logarithmic potential, but with flattening along two dimensions: + +.. math:: + + \Phi(x,y,z) = \frac{1}{2}v_{\rm c}^2\ln ((x/q_1)^2 + (y/q_2)^2 + (z/q_3)^2) + +with parameter values: + +.. math:: + + v_{\rm c} &= 150~{\rm km}~{\rm s}^{-1}\\ + q_1 &= 1\\ + q_2 &= 0.9\\ + q_3 &= 0.8\\ + r_h &= 0 + +and the same initial conditions as above: + +.. math:: + + \boldsymbol{r} &= (8, 0, 0)~{\rm kpc}\\ + \boldsymbol{v} &= (75, 150, 50)~{\rm km}~{\rm s}^{-1} + +.. plot:: + :align: center + :include-source: + :width: 60% + + import astropy.coordinates as coord + import astropy.units as u + import matplotlib.pyplot as plt + import numpy as np + import gala.potential as gp + import gala.dynamics as gd + from gala.units import galactic + + # define potential + pot = gp.LogarithmicPotential(v_c=150*u.km/u.s, q1=1., q2=0.9, q3=0.8, r_h=0, + units=galactic) + + # define initial conditions + w0 = gd.PhaseSpacePosition(pos=[8, 0, 0.]*u.kpc, + vel=[75, 150, 50.]*u.km/u.s) + + # integrate orbit + w = gp.Hamiltonian(pot).integrate_orbit(w0, dt=0.5, n_steps=10000) + + # solve for toy potential parameters + toy_potential = gd.fit_isochrone(w) + + # compute the actions,angles in the toy potential + toy_actions,toy_angles,toy_freqs = toy_potential.action_angle(w) + + # find approximations to the actions in the true potential + import warnings + with warnings.catch_warnings(record=True): + warnings.simplefilter("ignore") + result = gd.find_actions_o2gf(w, N_max=8, toy_potential=toy_potential) + + # for visualization, compute the action correction used to transform the + # toy potential actions to the approximate true potential actions + nvecs = gd.generate_n_vectors(8, dx=1, dy=2, dz=2) + act_correction = nvecs.T[...,None] * result['Sn'][0][None,:,None] * np.cos(nvecs.dot(toy_angles))[None] + action_approx = toy_actions - 2*np.sum(act_correction, axis=1)*u.kpc**2/u.Myr + + fig,axes = plt.subplots(3,1,figsize=(6,14)) + + for i,ax in enumerate(axes): + ax.plot(w.t, toy_actions[i].to(u.km/u.s*u.kpc), marker='', label='$J_{}$'.format(i+1)) + ax.plot(w.t, action_approx[i].to(u.km/u.s*u.kpc), marker='', label="$J_{}'$".format(i+1)) + ax.set_ylabel(r"[kpc ${\rm M}_\odot$ km/s]") + ax.legend(loc='upper left') + + ax.set_xlabel(r"$t$ [Myr]") + fig.tight_layout() + + +Using the Staeckel Fudge in Galpy +================================= + +Gala can transform its ``Orbit`` and ``Potential`` objects into `Galpy `_ ``Orbit`` and ``Potential`` objects, making it possible to easily use the "Staeckel Fudge" [binney12]_ implementation in Galpy. This method, as +implemented, is only applicable for axisymmetric systems, but is *much* faster +than the O2GF method for estimating actions, angles, and frequencies from +phase-space positions. As an example of this functionality, below we will +compute the vertical frequency as a function of action for a grid of orbits in a +two-component model for a galactic potential (a disk + halo model). + +We will start by defining the potential model:: + + >>> halo = gp.NFWPotential.from_M200_c( + ... M200=1e12*u.Msun, c=15, + ... units=galactic + ... ) + >>> disk = gp.MN3ExponentialDiskPotential( + ... m=8e10*u.Msun, h_R=3.5*u.kpc, h_z=0.4*u.kpc, + ... units=galactic + ... ) + >>> pot = halo + disk + +We next define a grid of orbital initial conditions with close to the circular +velocity but varying vertical velocities:: + + >>> vcirc = pot.circular_velocity([8, 0, 0]) + >>> vz_grid = np.linspace(0.5, 200, 64) * u.km/u.s + >>> xyz = np.repeat([[8., 0, 0]], len(vz_grid), axis=0).T * u.kpc + >>> vxyz = np.repeat([[0, 1.1, 0]], len(vz_grid), axis=0).T * vcirc + >>> vxyz[2] = vz_grid + >>> w0 = gd.PhaseSpacePosition(xyz, vxyz) + +We can now integrate these orbits in the total potential. Note that we can +specify the integrator using a string name:: + + >>> orbits = pot.integrate_orbit( + ... w0, dt=1, t1=0, t2=4*u.Gyr, + ... Integrator='dopri853' + ... ) + >>> orbits.cylindrical.plot(['rho', 'z'], alpha=0.5, marker=',') # doctest: +SKIP + +.. plot:: + :align: center + :width: 60% + :context: close-figs + + import astropy.coordinates as coord + import astropy.units as u + import matplotlib.pyplot as plt + import numpy as np + import gala.potential as gp + import gala.integrate as gi + import gala.dynamics as gd + from gala.units import galactic + + halo = gp.NFWPotential.from_M200_c( + M200=1e12*u.Msun, c=15, + units=galactic + ) + disk = gp.MN3ExponentialDiskPotential( + m=8e10*u.Msun, h_R=3.5*u.kpc, h_z=0.4*u.kpc, + units=galactic + ) + pot = halo + disk + + vcirc = pot.circular_velocity([8, 0, 0]) + vz_grid = np.linspace(0.5, 200, 64) * u.km/u.s + xyz = np.repeat([[8., 0, 0]], len(vz_grid), axis=0).T * u.kpc + vxyz = np.repeat([[0, 1.1, 0]], len(vz_grid), axis=0).T * vcirc + vxyz[2] = vz_grid + w0 = gd.PhaseSpacePosition(xyz, vxyz) + + orbits = pot.integrate_orbit( + w0, dt=1, t1=0, t2=4*u.Gyr, + Integrator='dopri853' + ) + orbits.cylindrical.plot(['rho', 'z'], alpha=0.5, marker=',') + + +With the orbits in hand, we can compute the approximate actions, angles, and +frequencies with the Staeckel Fudge using Galpy (for more information, see the +`Galpy documentation `_): + +.. doctest-requires:: galpy + + >>> from gala.dynamics.actionangle import get_staeckel_fudge_delta + >>> from galpy.actionAngle import actionAngleStaeckel + >>> galpy_potential = pot.as_interop("galpy") + >>> J = np.zeros((3, orbits.norbits)) + >>> Omega = np.zeros((3, orbits.norbits)) + >>> for n, orbit in enumerate(orbits.orbit_gen()): # doctest: +SKIP + ... o = orbit.to_galpy_orbit() + ... delta = get_staeckel_fudge_delta(pot, orbit) + ... staeckel = actionAngleStaeckel(pot=galpy_potential, delta=delta) + ... af = staeckel.actionsFreqs(o) + ... af = np.mean(np.stack(af), axis=1) + ... J[:3, n] = af[:3] + ... Omega[:3, n] = af[3:] + +Let's visualize the dependence of the vertical action on the value of the +vertical velocity we used as initial conditions: + +.. doctest-requires:: galpy + + >>> plt.plot(w0.v_z, J[2]) # doctest: +SKIP + +.. plot:: + :align: center + :width: 60% + :context: close-figs + + from gala.dynamics.actionangle import get_staeckel_fudge_delta + from galpy.actionAngle import actionAngleStaeckel + + galpy_potential = pot.as_interop("galpy") + J = np.zeros((3, orbits.norbits)) + Omega = np.zeros((3, orbits.norbits)) + for n, orbit in enumerate(orbits.orbit_gen()): + o = orbit.to_galpy_orbit() + delta = get_staeckel_fudge_delta(pot, orbit) + staeckel = actionAngleStaeckel(pot=galpy_potential, delta=delta) + af = staeckel.actionsFreqs(o) + af = np.mean(np.stack(af), axis=1) + + J[:3, n] = af[:3] + Omega[:3, n] = af[3:] + + fig, ax = plt.subplots(figsize=(6, 6), constrained_layout=True) + ax.plot(w0.v_z, J[2]) + ax.set_xlabel(f"$v_z$ [{w0.v_z.unit:latex_inline}]") + ax.set_ylabel(rf"$J_z$") + + +The overall trend looks right, but what is that weird break that occurs around +:math:`v_z` ~ 120 km/s? Let's visualize orbits with initial conditions just next to and +within this region: + +.. doctest-requires:: galpy + + >>> i1 = np.abs(w0.v_z.value - 120).argmin() + >>> i2 = np.abs(w0.v_z.value - 100).argmin() + >>> orbits[:, i1].cylindrical.plot(['rho', 'z'], alpha=0.5, marker=',') # doctest: +SKIP + >>> orbits[:, i2].cylindrical.plot(['rho', 'z'], alpha=0.5, marker=',') # doctest: +SKIP + +.. plot:: + :align: center + :width: 90% + :context: close-figs + + fig, axes = plt.subplots(1, 2, figsize=(10, 5), + sharex=True, sharey=True, + constrained_layout=True) + + i1 = np.abs(w0.v_z.value - 120).argmin() + i2 = np.abs(w0.v_z.value - 100).argmin() + orbits[:, i1].cylindrical.plot(['rho', 'z'], alpha=0.5, marker=',', axes=[axes[0]]); + orbits[:, i2].cylindrical.plot(['rho', 'z'], alpha=0.5, marker=',', axes=[axes[1]]); + +Aha! This region is special: it is a resonance in the potential. Orbits in this region +of phase-space have qualitatively different behavior than those outside of this region +because they are trapped by the resonance. For these orbits, where strong potential +resonances occur, the Staeckel Fudge approximation will return incorrect and potentially +misleading action, angle, and frequency values. + + +References +========== + +.. [binney12] Binney (2012) `Actions for axisymmetric potentials + `_ +.. [sanders14] Sanders & Binney (2014) `Actions, angles and frequencies for numerically integrated orbits `_ +.. [sanders16] Sanders & Binney (2016) `A review of action estimation methods for galactic dynamics `_ +.. [binneytremaine] Binney & Tremaine (2008) `Galactic Dynamics `_ +.. [mcgill90] McGill & Binney (1990) `Torus construction in general gravitational potentials `_ diff --git a/gala/source/docs/dynamics/index.rst b/gala/source/docs/dynamics/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..d4978ed3d43dd40b82ddda7700735f77082eef0b --- /dev/null +++ b/gala/source/docs/dynamics/index.rst @@ -0,0 +1,172 @@ +.. include:: references.txt + +.. _gala-dynamics: + +******************************** +Dynamics (`gala.dynamics`) +******************************** + +For the examples below the following imports have already been executed:: + + >>> import astropy.units as u + >>> import numpy as np + >>> import gala.potential as gp + >>> import gala.dynamics as gd + >>> from gala.units import galactic + +Introduction +============ + +This subpackage contains functions and classes useful for gravitational +dynamics. There are utilities for transforming orbits in phase-space to +action-angle coordinates, tools for visualizing and computing dynamical +quantities from orbits, tools to generate mock stellar streams, and tools useful +for nonlinear dynamics such as Lyapunov exponent estimation. + +The fundamental objects used by many of the functions and utilities in this and +other subpackages are the |psp| and |orb| classes. + +Getting started: Working with orbits +==================================== + +We'll demonstrate the |psp| and |orb| objects by first integrating an orbit:: + + >>> pot = gp.MiyamotoNagaiPotential( + ... m=2.5e11 * u.Msun, a=6.5 * u.kpc, b=0.26 * u.kpc, units=galactic + ... ) + >>> w0 = gd.PhaseSpacePosition( + ... pos=[11.0, 0.0, 0.2] * u.kpc, vel=[0.0, 200, 100] * u.km / u.s + ... ) + >>> orbit = gp.Hamiltonian(pot).integrate_orbit(w0, dt=1.0, n_steps=1000) + +This numerically integrates an orbit from the specified initial conditions, +``w0``, and returns an |orb| object. By default, this uses the Leapfrog +integrator, but you can specify a different integrator using the ``Integrator`` +keyword argument. For example, to use a higher-order adaptive Runge-Kutta +method:: + + >>> orbit = gp.Hamiltonian(pot).integrate_orbit( + ... w0, dt=1.0, n_steps=1000, Integrator='dopri853' + ... ) + +Valid integrator names include ``'leapfrog'``, ``'dopri853'``, ``'ruth4'``, and +``'rk5'``. You can also pass an integrator class directly (see +:ref:`gala-integrate` for more information). + +By default, the position and velocity are +assumed to be Cartesian coordinates but other coordinate systems are supported +(see the :ref:`orbits-in-detail` and :ref:`nd-representations` pages for more +information). + +The |orb| object that is returned contains many useful methods, and can be +passed to many of the analysis functions implemented in Gala. For example, we +can easily visualize the orbit by plotting the time series in all Cartesian +projections using the :meth:`~gala.dynamics.Orbit.plot` method:: + + >>> fig = orbit.plot() + +.. plot:: + :align: center + + import astropy.units as u + import gala.potential as gp + import gala.dynamics as gd + from gala.units import galactic + pot = gp.MiyamotoNagaiPotential(m=2.5e11, a=6.5, b=0.26, units=galactic) + w0 = gd.PhaseSpacePosition( + pos=[11.0, 0.0, 0.2] * u.kpc, vel=[0.0, 200, 100] * u.km / u.s + ) + orbit = gp.Hamiltonian(pot).integrate_orbit(w0, dt=1.0, n_steps=1000) + fig = orbit.plot() + +We can also visualize the orbit in transformed coordinates, for example, +cylindrical radius :math:`\rho` and :math:`z`:: + + >>> fig = orbit.represent_as("cylindrical").plot(["rho", "z"]) + +.. plot:: + :align: center + :width: 60% + + import astropy.units as u + import gala.potential as gp + import gala.dynamics as gd + from gala.units import galactic + pot = gp.MiyamotoNagaiPotential(m=2.5e11, a=6.5, b=0.26, units=galactic) + w0 = gd.PhaseSpacePosition( + pos=[11.0, 0.0, 0.2] * u.kpc, vel=[0.0, 200, 100] * u.km / u.s + ) + orbit = gp.Hamiltonian(pot).integrate_orbit(w0, dt=1.0, n_steps=1000) + _ = orbit.represent_as("cylindrical").plot(["rho", "z"]) + +The |orb| object also enables computing dynamical quantities such as +energy or angular momentum:: + + >>> E = orbit.energy() + >>> E[0] # doctest: +SKIP + + +Let's check how well the integrator conserves energy and the ``z`` component of +angular momentum:: + + >>> Lz = orbit.angular_momentum()[2] + >>> np.std(E), np.std(Lz) # doctest: +FLOAT_CMP + (, + ) + +We can access the position and velocity components of the orbit separately using +attributes that map to the underlying `~astropy.coordinates.BaseRepresentation` +and `~astropy.coordinates.BaseDifferential` subclass instances that store the +position and velocity data. The attribute names depend on the representation. +For example, for a Cartesian representation, the position components are ``["x", +"y", "z"]`` and the velocity components are ``["v_x", "v_y", "v_z"]``. With a +|orb| or |psp| instance, you can check the valid compnent names using the +attributes ``.pos_components`` and ``.vel_components``:: + + >>> orbit.pos_components.keys() # doctest: +SKIP + odict_keys(["x", "y", "z"]) + >>> orbit.vel_components.keys() # doctest: +SKIP + odict_keys(["v_x", "v_y", "v_z"]) + +Meaning, we can access these components by doing, e.g.:: + + >>> orbit.v_x # doctest: +FLOAT_CMP + + +For a Cylindrical representation, these are instead:: + + >>> cyl_orbit = orbit.represent_as("cylindrical") + >>> cyl_orbit.pos_components.keys() # doctest: +SKIP + odict_keys(["rho", "phi", "z"]) + >>> cyl_orbit.vel_components.keys() # doctest: +SKIP + odict_keys(["v_rho", "pm_phi", "v_z"]) + >>> cyl_orbit.v_rho # doctest: +FLOAT_CMP + + +Continue to the :ref:`orbits-in-detail` page for more information. + +Using gala.dynamics +=================== + +More details are provided in the linked pages below: + +.. toctree:: + :maxdepth: 2 + + orbits-in-detail + nd-representations + actionangle + mockstreams + nonlinear + nbody + + +API +=== + +.. automodapi:: gala.dynamics + :include: PhaseSpacePosition + :include: Orbit + :no-inheritance-diagram: diff --git a/gala/source/docs/dynamics/mockstreams.rst b/gala/source/docs/dynamics/mockstreams.rst new file mode 100644 index 0000000000000000000000000000000000000000..024d04792bf9c2f3021922559ee35c43efd5d41f --- /dev/null +++ b/gala/source/docs/dynamics/mockstreams.rst @@ -0,0 +1,313 @@ +.. _gala-mockstreams: + +******************************* +Generating mock stellar streams +******************************* + +Introduction +============ + +This module contains functions for generating mock stellar streams using a +variety of methods that approximate the formation of streams in N-body +simulations. Mock streams are generated by specifying time-stepping and release +time information (i.e., when should stream particles be generated), and by +specifying the stream distribution function (DF) to use to generate initial +conditions for the stream particles. The former is customizable, and a number of +popular stream DFs are implemented. + +Some imports needed for the code below:: + + >>> import astropy.units as u + >>> import numpy as np + >>> import gala.potential as gp + >>> import gala.dynamics as gd + >>> from gala.dynamics import mockstream as ms + >>> from gala.units import galactic + +We will also set the default Astropy Galactocentric frame parameters to the +values adopted in Astropy v4.0: + + >>> import astropy.coordinates as coord + >>> _ = coord.galactocentric_frame_defaults.set('v4.0') + +Getting started +=============== + +All mock stream generation done using the built-in gravitational potential +models implemented in `gala.potential`, so we must first specify a gravitational +potential to integrate orbits in. For the examples below, we will use a +spherical NFW potential with a circular velocity at the scale radius of 220 +km/s, and a scale radius of 15 kpc:: + + >>> pot = gp.NFWPotential.from_circular_velocity(v_c=220*u.km/u.s, + ... r_s=15*u.kpc, + ... units=galactic) + +The mock stream generation supports any of the reference frames implemented in +``gala`` (e.g., non-static / rotating reference frames), so we must create a +`~gala.potential.hamiltonian.Hamiltonian` object to use when generating streams. +By default, this will use a static reference frame:: + + >>> H = gp.Hamiltonian(pot) + +Next, we will create initial conditions for the progenitor system. In this case, +we will generate the mock stream starting from this position going forward in +time. However, this is customizable: if you instead have the *final* position of +the progenitor system, there is a convenient way of doing this described below +(see :ref:`gala-mockstream-final-conditions`). Let's specify a position and +velocity that we think will produce a mildly eccentric orbit in the x-y plane of +our coordinate system:: + + >>> prog_w0 = gd.PhaseSpacePosition(pos=[10, 0, 0.] * u.kpc, + ... vel=[0, 170, 0.] * u.km/u.s) + +We now have to specify the method for generating stream particles, i.e., the +stream distribution function (DF). For this example, we will use the method +implemented in [fardal15]_, incuded in ``gala`` as +`~gala.dynamics.mockstream.FardalStreamDF`. Other methods of note are +`~gala.dynamics.mockstream.StreaklineStreamDF` from [kuepper12]_, +`~gala.dynamics.mockstream.LagrangeCloudStreamDF` based on [gibbons14]_, and +`~gala.dynamics.mockstream.ChenStreamDF` based on [chen24]_. Each of +the ``StreamDF`` classes take a few common arguments, such as ``lead`` and +``trail``, which are boolean arguments that control whether to generate both +leading and trailing tails, or just one or the other. By default, both are set +to True (i.e., both leading and trailing tails are generated by default). Some +other ``StreamDF`` classes may require other parameters. Let's create a +`~gala.dynamics.mockstream.ChenStreamDF` instance and accept the default +argument values. We will also need to specify the progenitor mass, which is +passed in to any ``StreamDF`` and is used to scale the particle release +distribution:: + + >>> df = ms.ChenStreamDF() + >>> prog_mass = 2.5E4 * u.Msun + +.. warning:: + + The parameter values of the FardalStreamDF have been updated (fixed) in v1.9 to + match the parameter values in the final published version of [fardal15]_. For now, + this class uses the Gala modified parameter values that have been adopted over the + last several years in Gala. In the future, the default behavior of this class will + use the [fardal15]_ parameter values instead, breaking backwards compatibility for + mock stream simulations. To use the [fardal15]_ parameters now, set + ``gala_modified=False``. To continue to use the Gala modified parameter values, set + ``gala_modified=True``. + +The final step before actually generating the stream is to create a +`~gala.dynamics.mockstream.MockStreamGenerator` instance, which we will use to +actually generate the stream. This takes the ``StreamDF`` and the external +potential (Hamiltonian) as arguments:: + + >>> gen = ms.MockStreamGenerator(df, H) + +We are now ready to run the generator and create a mock stream. To do this, we use the `~gala.dynamics.mockstream.MockStreamGenerator.run()` method. This accepts the progenitor orbit initial conditions (we defined above as ``prog_w0``), the progenitor mass (we defined as ``prog_mass``), and time-stepping information. We will integrate the progenitor orbit for 1000 steps with a timestep of 1 Myr:: + + >>> stream, prog = gen.run(prog_w0, prog_mass, + ... dt=1 * u.Myr, n_steps=1000) + +Let's plot the stream:: + + >>> stream.plot(['x', 'y']) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + import astropy.units as u + import numpy as np + import gala.potential as gp + import gala.dynamics as gd + from gala.dynamics import mockstream as ms + from gala.units import galactic + + pot = gp.NFWPotential.from_circular_velocity(v_c=220*u.km/u.s, + r_s=15*u.kpc, + units=galactic) + H = gp.Hamiltonian(pot) + prog_w0 = gd.PhaseSpacePosition(pos=[10, 0, 0.] * u.kpc, + vel=[0, 170, 0.] * u.km/u.s) + + df = ms.ChenStreamDF() + prog_mass = 2.5E4 * u.Msun + + gen = ms.MockStreamGenerator(df, H) + + stream, prog = gen.run(prog_w0, prog_mass, + dt=1 * u.Myr, n_steps=1000) + + stream.plot(['x', 'y'], marker='o', s=4, color='k', alpha=0.1, linewidth=0) + + +By default, two stream particles are generated at every timestep in the +integration of the progenitor orbit (specified above by the timestep ``dt`` and +number of steps ``n_steps``). We can control the frequency of releasing +particles, and the number of particles released using the ``release_every`` and +``n_particles`` arguments. For example, setting ``release_every=8`` and +``n_particles=2`` will instead release 4 particles (2 for each tail) every 8th +timestep. + + +Self-gravity of the progenitor +============================== + +Also by default, the progenitor system is assumed to be massless, and the stream +particles are treated as test particles in the specified external potential or +Hamiltonian. It is possible to include a potential object for the progenitor +system to account for the self-gravity of the progenitor as stream star +particles are released. We can use any of the ``gala.potential`` potential +objects to represent the progenitor system, but here we will use a simple +`~gala.potential.potential.PlummerPotential`. We pass this in to the +`~gala.dynamics.mockstream.MockStreamGenerator` - let's see what the stream +looks like when generated including self-gravity:: + + >>> prog_pot = gp.PlummerPotential(m=prog_mass, b=4*u.pc, units=galactic) + >>> gen2 = ms.MockStreamGenerator(df, H, progenitor_potential=prog_pot) + >>> stream2, prog = gen2.run(prog_w0, prog_mass, + ... dt=1 * u.Myr, n_steps=1000) + >>> stream2.plot(['x', 'y']) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + prog_pot = gp.PlummerPotential(m=prog_mass, b=2*u.pc, units=galactic) + gen2 = ms.MockStreamGenerator(df, H, progenitor_potential=prog_pot) + stream2, prog = gen2.run(prog_w0, prog_mass, + dt=1 * u.Myr, n_steps=1000) + stream2.plot(['x', 'y'], marker='o', s=4, + color='k', alpha=0.1, linewidth=0) + + +Integration methods and options +================================ + +Choosing an integrator +----------------------- + +By default, mock stream generation uses the DOPRI853 integrator, which is an +8th-order adaptive Runge-Kutta method. This integrator is robust and +automatically adjusts the timestep to maintain a specified error tolerance. +However, ``gala`` also supports using the Leapfrog integrator, which is a +symplectic integrator that uses a fixed timestep and conserves energy better +over long integrations. + +To use the Leapfrog integrator instead of DOPRI853, pass the ``Integrator`` +argument to the `~gala.dynamics.mockstream.MockStreamGenerator.run()` method. +You can pass the integrator class directly:: + + >>> import gala.integrate as gi + >>> stream_lf, prog = gen.run(prog_w0, prog_mass, + ... dt=1 * u.Myr, n_steps=1000, + ... Integrator=gi.LeapfrogIntegrator) + +or more conveniently, use a string name:: + + >>> stream_lf, prog = gen.run(prog_w0, prog_mass, + ... dt=1 * u.Myr, n_steps=1000, + ... Integrator='leapfrog') + +The Leapfrog integrator is particularly useful for long-term integrations where energy conservation is critical, situations where a fixed timestep is faster or acceptable, or +when symplectic integration properties are important (e.g., preserving phase space volume). + +The DOPRI853 integrator (the default) is better suited for N-body integrations or cases with time-varying potentials. + +Customizing integrator tolerances +---------------------------------- + +For the DOPRI853 integrator, you can customize the absolute and relative error +tolerances using the ``Integrator_kwargs`` argument. This can be useful for +balancing accuracy and computational speed:: + + >>> # Higher accuracy (slower) + >>> stream_hi, prog = gen.run(prog_w0, prog_mass, + ... dt=1 * u.Myr, n_steps=1000, + ... Integrator_kwargs={'atol': 1e-12, 'rtol': 1e-12}) + + >>> # Lower accuracy (faster) + >>> stream_lo, prog = gen.run(prog_w0, prog_mass, + ... dt=1 * u.Myr, n_steps=1000, + ... Integrator_kwargs={'atol': 1e-8, 'rtol': 1e-8}) + +The default values are ``atol=1e-10`` and ``rtol=1e-10``, which provide a good +balance between accuracy and speed for most applications. Other DOPRI853 options +include ``nmax`` (maximum number of steps) and ``dt_max`` (maximum timestep). + +Note that the Leapfrog integrator uses a fixed timestep (specified by ``dt``), +so tolerance parameters do not apply. + + +.. _gala-mockstream-final-conditions: + +Generating a stream from the present-day progenitor location +============================================================ + +In the examples above, we pass in initial conditions for the progenitor and +generate the mock stream going forward in time. However, we often may want to +generate a stream such that the final progenitor location ends up at some +specified phase-space position. By convention, when a negative timestep is +passed in to `~gala.dynamics.mockstream.MockStreamGenerator.run()`, this is +interpreted to mean that the input progenitor phase-space position should be the +*final* position. Internally, this position is integrated backwards to the +earliest time, then a stream is generated forward from the past time. This is particularly useful when trying to reproduce observed streams, such as the Pal 5 stream:: + + >>> import astropy.coordinates as coord + >>> pal5_c = coord.SkyCoord(ra=229.018*u.degree, dec=-0.124*u.degree, + ... distance=22.9*u.kpc, + ... pm_ra_cosdec=-2.296*u.mas/u.yr, + ... pm_dec=-2.257*u.mas/u.yr, + ... radial_velocity=-58.7*u.km/u.s) + >>> rep = pal5_c.transform_to(coord.Galactocentric()).data + >>> pal5_w0 = gd.PhaseSpacePosition(rep) + >>> pal5_mass = 2.5e4 * u.Msun + >>> pal5_pot = gp.PlummerPotential(m=pal5_mass, b=4*u.pc, units=galactic) + >>> mw = gp.MilkyWayPotential(version="latest") + >>> gen_pal5 = ms.MockStreamGenerator(df, mw, progenitor_potential=pal5_pot) + >>> pal5_stream, _ = gen_pal5.run(pal5_w0, pal5_mass, + ... dt=-1 * u.Myr, n_steps=4000) + >>> pal5_stream_c = pal5_stream.to_coord_frame(coord.ICRS()) + +.. plot:: + :align: center + :context: close-figs + + import astropy.coordinates as coord + coord.galactocentric_frame_defaults.set('v4.0') + pal5_c = coord.SkyCoord(ra=229.018*u.degree, dec=-0.124*u.degree, + distance=22.9*u.kpc, + pm_ra_cosdec=-2.296*u.mas/u.yr, + pm_dec=-2.257*u.mas/u.yr, + radial_velocity=-58.7*u.km/u.s) + rep = pal5_c.transform_to(coord.Galactocentric()).data + pal5_w0 = gd.PhaseSpacePosition(rep) + pal5_mass = 2.5e4 * u.Msun + pal5_pot = gp.PlummerPotential(m=pal5_mass, b=4*u.pc, units=galactic) + mw = gp.MilkyWayPotential(version="latest") + gen_pal5 = ms.MockStreamGenerator(df, mw, progenitor_potential=pal5_pot) + pal5_stream, _ = gen_pal5.run(pal5_w0, pal5_mass, + dt=-1 * u.Myr, n_steps=4000) + pal5_stream_c = pal5_stream.to_coord_frame(coord.ICRS()) + + fig, ax = plt.subplots(1, 1, figsize=(6, 4)) + ax.scatter(pal5_stream_c.ra.degree, pal5_stream_c.dec.degree, + marker='o', s=4, color='k', lw=0, alpha=0.1) + ax.set_xlim(250, 215) + ax.set_ylim(-10, 10) + ax.set_xlabel('RA [deg]') + ax.set_ylabel('Dec [deg]') + fig.tight_layout() + + +References +========== + +.. [fardal15] `Fardal, Huang, Weinberg (2015) `_ +.. [kuepper12] `KÃŒpper, Lane, Heggie (2012) `_ +.. [gibbons14] `Gibbons et al. (2014) `_ +.. [chen24] `Chen et al. (2024) `_ + +API +--- +.. automodapi:: gala.dynamics.mockstream + :no-heading: + :headings: ^^ diff --git a/gala/source/docs/dynamics/nbody.rst b/gala/source/docs/dynamics/nbody.rst new file mode 100644 index 0000000000000000000000000000000000000000..e3c2b9847827b8a23d15b08049fc4983caf91382 --- /dev/null +++ b/gala/source/docs/dynamics/nbody.rst @@ -0,0 +1,184 @@ +.. _gala-nbody: + +****************************** +N-body (`gala.dynamics.nbody`) +****************************** + +Introduction +============ + +With the `~gala.potential.hamiltonian.Hamiltonian` and potential classes +(:ref:`potential`), Gala contains functionality for integrating test particle +orbits in background gravitational fields. To supplement this, Gala also now +contains some limited functionality for performing N-body orbit integrations +through direct N-body force calculations between particles. With the +`gala.dynamics.nbody` subpackage, gravitational fields (i.e., any potential +class from :mod:`gala.potential`) can be sourced by particles that interact, and +optionally feel a background/external potential. To use this functionality, the +core class is `~gala.dynamics.nbody.DirectNBody`. Below, we'll go through a few +examples of using this class to perform orbit integrations + +For the examples below the following imports have already been executed:: + + >>> import astropy.units as u + >>> import numpy as np + >>> import gala.potential as gp + >>> import gala.dynamics as gd + >>> from gala.dynamics.nbody import DirectNBody + >>> from gala.units import galactic, UnitSystem + + +Getting started +=============== + +The `~gala.dynamics.nbody.DirectNBody`, at minimum, must be instantiated with a +set of particle orbital initial conditions along with a specification of the +gravitational fields sourced by each particle --- that is, the number of initial +conditions must match the input list of gravitational potential objects that +specify the particle mass distributions. Other optional arguments to +`~gala.dynamics.nbody.DirectNBody` allow you to set the unit system (i.e., to +improve numerical precision when time-stepping the orbit integration), or to +specify a background gravitational potential. Let's now go through a few +examples of using this class in practice. + + +Example: Mixed test particle and massive particle orbit integration +=================================================================== + +Like with `~gala.potential.hamiltonian.Hamiltonian` orbit integration, orbital +initial conditions are passed in to `~gala.dynamics.nbody.DirectNBody` by +passing in a single `~gala.dynamics.PhaseSpacePosition` object. Let's create two +initial conditions by specifying the position and velocity of two particles, +then combine them into a single `~gala.dynamics.PhaseSpacePosition` object:: + + >>> w0_1 = gd.PhaseSpacePosition(pos=[0, 0, 0] * u.pc, + ... vel=[0, 1.5, 0] * u.km/u.s) + >>> w0_2 = gd.PhaseSpacePosition(pos=w0_1.xyz + [100., 0, 0] * u.pc, + ... vel=w0_1.v_xyz + [0, 5, 0] * u.km/u.s) + >>> w0 = gd.combine((w0_1, w0_2)) + >>> w0.shape + (2,) + +We'll then treat particle 1 as a massive object by sourcing a +`~gala.potential.potential.HernquistPotential` at the location of the particle, +and particle 2 as a test particle: To treat some particles as test particles, +you can pass ``None`` or a `~gala.potential.potential.NullPotential` instance +for the corresponding particle potential:: + + >>> pot1 = gp.HernquistPotential(m=1e7*u.Msun, c=0.5*u.kpc, units=galactic) + >>> particle_pot = [pot1, None] + +Let's now create an N-body instance and try integrating the orbits of the two +particles. Here, there is no external potential, so particle 1 (the massive +particle) will move off in a straight line. We've created the initial conditions +for particle 2 so that it will remain bound to the potential sourced by particle +1, and so will orbit it as it moves. Let's create the object and integrate the +orbits:: + + >>> nbody = DirectNBody(w0, particle_pot) + >>> orbits = nbody.integrate_orbit(dt=1e-2*u.Myr, t1=0, t2=1*u.Gyr) + >>> fig, ax = plt.subplots(1, 1, figsize=(5, 5)) # doctest: +SKIP + >>> _ = orbits[:, 0].plot(['x', 'y'], axes=[ax]) # doctest: +SKIP + >>> _ = orbits[:, 1].plot(['x', 'y'], axes=[ax]) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + import astropy.units as u + import numpy as np + import gala.potential as gp + import gala.dynamics as gd + from gala.dynamics.nbody import DirectNBody + from gala.units import galactic, UnitSystem + import matplotlib.pyplot as plt + + w0_1 = gd.PhaseSpacePosition(pos=[0, 0, 0] * u.pc, + vel=[0, 1.5, 0] * u.km/u.s) + w0_2 = gd.PhaseSpacePosition(pos=w0_1.xyz + [100., 0, 0] * u.pc, + vel=w0_1.v_xyz + [0, 5, 0] * u.km/u.s) + w0 = gd.combine((w0_1, w0_2)) + + pot1 = gp.HernquistPotential(m=1e7*u.Msun, c=0.5*u.kpc, units=galactic) + particle_pot = [pot1, None] + + nbody = DirectNBody(w0, particle_pot) + orbits = nbody.integrate_orbit(dt=1e-2*u.Myr, t1=0, t2=1*u.Gyr) + fig, ax = plt.subplots(1, 1, figsize=(5, 5)) + _ = orbits[:, 0].plot(['x', 'y'], axes=[ax]) + _ = orbits[:, 1].plot(['x', 'y'], axes=[ax]) + fig.tight_layout() + +Example: N-body integration with a background potential +======================================================= + +With `~gala.dynamics.nbody.DirectNBody`, we can also specify a background or +external potential to integrate all orbits in. To do this, you can optionally +pass in an external potential as a potential object to +`~gala.dynamics.nbody.DirectNBody`. Here, as an example, we'll repeat a similar +integration as above, but (1) add a positional offset of the initial conditions +from the origin, and (2) specify an external potential using the +`~gala.potential.potential.MilkyWayPotential` class as an external potential:: + + >>> external_pot = gp.MilkyWayPotential(version="latest") + >>> w0_1 = gd.PhaseSpacePosition(pos=[10, 0, 0] * u.kpc, + ... vel=[0, 200, 0] * u.km/u.s) + >>> w0_2 = gd.PhaseSpacePosition(pos=w0_1.xyz + [10., 0, 0] * u.pc, + ... vel=w0_1.v_xyz + [0, 5, 0] * u.km/u.s) + >>> w0 = gd.combine((w0_1, w0_2)) + >>> pot1 = gp.HernquistPotential(m=1e7*u.Msun, c=0.5*u.kpc, units=galactic) + >>> particle_pot = [pot1, None] + >>> nbody = DirectNBody(w0, particle_pot, external_potential=external_pot) + >>> orbits = nbody.integrate_orbit(dt=1e-2*u.Myr, t1=0, t2=1*u.Gyr) + >>> fig, ax = plt.subplots(1, 1, figsize=(5, 5)) # doctest: +SKIP + >>> _ = orbits[:, 0].plot(['x', 'y'], axes=[ax]) # doctest: +SKIP + >>> _ = orbits[:, 1].plot(['x', 'y'], axes=[ax]) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + external_pot = gp.MilkyWayPotential(version="latest") + w0_1 = gd.PhaseSpacePosition(pos=[10, 0, 0] * u.kpc, + vel=[0, 200, 0] * u.km/u.s) + w0_2 = gd.PhaseSpacePosition(pos=w0_1.xyz + [10., 0, 0] * u.pc, + vel=w0_1.v_xyz + [0, 5, 0] * u.km/u.s) + w0 = gd.combine((w0_1, w0_2)) + pot1 = gp.HernquistPotential(m=1e7*u.Msun, c=0.5*u.kpc, units=galactic) + particle_pot = [pot1, None] + nbody = DirectNBody(w0, particle_pot, external_potential=external_pot) + orbits = nbody.integrate_orbit(dt=1e-2*u.Myr, t1=0, t2=1*u.Gyr) + + fig, ax = plt.subplots(1, 1, figsize=(5, 5)) # doctest: +SKIP + _ = orbits[:, 0].plot(['x', 'y'], axes=[ax]) # doctest: +SKIP + _ = orbits[:, 1].plot(['x', 'y'], axes=[ax]) # doctest: +SKIP + +In this case, the orbits are so similar that it is hard to tell whether the test +particle is actually bound to the secondary mass. Let's instead now plot the +position in the x-y plane of particle 2 relative to particle 1. This will look +strange because we have not transformed to the frame of particle 1, but it +should give us a sense of whether particle 2 is bound or unbound to this mass:: + + >>> dxyz = orbits[:, 0].xyz - orbits[:, 1].xyz + >>> fig, ax = plt.subplots(1, 1, figsize=(5, 5)) # doctest: +SKIP + >>> ax.plot(dxyz[0], dxyz[1]) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + dxyz = orbits[:, 0].xyz - orbits[:, 1].xyz + + fig, ax = plt.subplots(1, 1, figsize=(5, 5)) # doctest: +SKIP + ax.plot(dxyz[0], dxyz[1]) # doctest: +SKIP + ax.set_xlim(-0.1, 0.1) + ax.set_ylim(-0.1, 0.1) + +From this, it looks like particle 2 is indeed still bound to particle 1 as they +both orbit within the external potential. + + +.. automodapi:: gala.dynamics.nbody diff --git a/gala/source/docs/dynamics/nd-representations.rst b/gala/source/docs/dynamics/nd-representations.rst new file mode 100644 index 0000000000000000000000000000000000000000..2b8480e743ff1de38f2a079ad7dfdc2d161ea665 --- /dev/null +++ b/gala/source/docs/dynamics/nd-representations.rst @@ -0,0 +1,69 @@ +.. include:: references.txt + +.. _nd-representations: + +************************************ +N-dimensional representation classes +************************************ + +For the examples below the following imports have already been executed:: + + >>> import astropy.units as u + >>> import numpy as np + >>> import gala.dynamics as gd + +Introduction +============ + +The Astropy |astropyrep|_ presently only support 3D positions and differential +objects. The `~gala.dynamics.representation_nd.NDCartesianRepresentation` and +`~gala.dynamics.representation_nd.NDCartesianDifferential` classes add Cartesian +representation classes that can handle arbitrary numbers of dimensions. For +example, 2D coordinates:: + + >>> xy = np.arange(16).reshape(2, 8) * u.kpc + >>> rep = gd.NDCartesianRepresentation(xy) + >>> rep + + +4D coordinates:: + + >>> x = np.arange(16).reshape(4, 4) * u.kpc + >>> rep = gd.NDCartesianRepresentation(x) + >>> rep + + +These can be passed in to the |psp| or |orb| classes as with any of the Astropy +core representation objects:: + + >>> xy = np.arange(16).reshape(2, 8) * u.kpc + >>> vxy = np.arange(16).reshape(2, 8) / 10. * u.kpc / u.Myr + >>> w = gd.PhaseSpacePosition(pos=xy, vel=vxy) + >>> fig = w.plot() + +.. plot:: + :align: center + :width: 60% + + import astropy.units as u + import numpy as np + import gala.dynamics as gd + xy = np.arange(16).reshape(2, 8) * u.kpc + vxy = np.arange(16).reshape(2, 8) / 10. * u.kpc / u.Myr + w = gd.PhaseSpacePosition(pos=xy, vel=vxy) + fig = w.plot() + +However, certain functionality such as representation transformations, dynamical +quantity calculation, and coordinate frame transformations are disabled when the +number of dimensions is not 3 (i.e. when not using the Astropy core +representation classes). + +N-dimensional representations API +--------------------------------- +.. automodapi:: gala.dynamics.representation_nd + :no-heading: + :headings: ^^ diff --git a/gala/source/docs/dynamics/nonlinear.rst b/gala/source/docs/dynamics/nonlinear.rst new file mode 100644 index 0000000000000000000000000000000000000000..2b2bf0874158820da544515ad3b71759bbf599a4 --- /dev/null +++ b/gala/source/docs/dynamics/nonlinear.rst @@ -0,0 +1,164 @@ +.. _gala-nonlinear-dynamics: + +****************** +Nonlinear Dynamics +****************** + +Introduction +============ + +This module contains utilities for nonlinear dynamics. Currently, the only +implemented features enable you to compute estimates of the maximum +Lyapunov exponent for an orbit. In future releases, there will be features +for creating surface of sections and computing the full Lyapunov spectrum. + +Some imports needed for the code below:: + + >>> import astropy.units as u + >>> import numpy as np + >>> import gala.potential as gp + >>> import gala.dynamics as gd + >>> from gala.units import galactic + +Computing Lyapunov exponents +============================ + +Chaotic orbit +------------- + +There are two ways to compute Lyapunov exponents implemented in `gala.dynamics`. +In most cases, you'll want to use the +`~gala.dynamics.nonlinear.fast_lyapunov_max` function because the integration is +implemented in C and is quite fast. This function only works if the potential +you are working with is implemented in C (e.g., it is a +`~gala.potential.potential.CPotentialBase` subclass). With a potential object +and a set of initial conditions:: + + >>> pot = gp.LogarithmicPotential(v_c=150*u.km/u.s, r_h=0.1*u.kpc, + ... q1=1., q2=0.8, q3=0.6, units=galactic) + >>> w0 = gd.PhaseSpacePosition(pos=[5.5,0.,5.5]*u.kpc, + ... vel=[0.,100.,0]*u.km/u.s) + >>> lyap,orbit = gd.fast_lyapunov_max(w0, pot, dt=2., n_steps=100000) # doctest: +SKIP + +This returns two objects: an `~astropy.units.Quantity` object that +contains the maximum Lyapunov exponent estimate for each offset orbit, +(we can control the number of offset orbits with the ``noffset_orbits`` +argument) and an `~gala.dynamics.Orbit` object that contains +the parent orbit and each offset orbit. Let's plot the parent orbit:: + + >>> fig = orbit[:,0].plot(marker=',', alpha=0.25, linestyle='none') # doctest: +SKIP + +.. plot:: + :align: center + + import astropy.units as u + import matplotlib.pyplot as plt + import gala.potential as gp + import gala.dynamics as gd + from gala.units import galactic + + pot = gp.LogarithmicPotential(v_c=150*u.km/u.s, r_h=0.1*u.kpc, + q1=1., q2=0.8, q3=0.6, units=galactic) + w0 = gd.PhaseSpacePosition(pos=[5.5,0.,5.5]*u.kpc, + vel=[0.,100.,0]*u.km/u.s) + lyap,orbit = gd.fast_lyapunov_max(w0, pot, dt=2., n_steps=100000) + fig = orbit[:,0].plot(marker=',', linestyle='none', alpha=0.25) + +Visually, this looks like a chaotic orbit. This means the Lyapunov exponent +should saturate to some value. We'll now plot the estimate of the Lyapunov +exponent as a function of time -- because the algorithm re-normalizes every +several time-steps (controllable with the ``n_steps_per_pullback`` argument), +we have to down-sample the time array to align it with the Lyapunov exponent +array. This plots one line per offset orbit:: + + >>> plt.figure() # doctest: +SKIP + >>> plt.loglog(orbit.t[11::10], lyap, marker='') # doctest: +SKIP + >>> plt.xlabel("Time [{}]".format(orbit.t.unit)) # doctest: +SKIP + >>> plt.ylabel(r"$\lambda_{{\rm max}}$ [{}]".format(lyap.unit)) # doctest: +SKIP + >>> plt.tight_layout() # doctest: +SKIP + +.. plot:: + :align: center + :width: 60% + + import astropy.units as u + import matplotlib.pyplot as plt + import gala.potential as gp + import gala.dynamics as gd + from gala.units import galactic + + pot = gp.LogarithmicPotential(v_c=150*u.km/u.s, r_h=0.1*u.kpc, + q1=1., q2=0.8, q3=0.6, units=galactic) + w0 = gd.PhaseSpacePosition(pos=[5.5,0.,5.5]*u.kpc, + vel=[0.,100.,0]*u.km/u.s) + lyap,orbit = gd.fast_lyapunov_max(w0, pot, dt=2., n_steps=100000) + + plt.figure() + plt.loglog(orbit.t[11::10], lyap, marker='') + plt.xlabel("Time [{}]".format(orbit.t.unit)) + plt.ylabel(r"$\lambda_{{\rm max}}$ [{}]".format(lyap.unit)) + plt.tight_layout() + +The estimate is clearly starting to diverge from a simple power law decay. + +Regular orbit +------------- + +To compare, we will compute the estimate for a regular orbit as well:: + + >>> w0 = gd.PhaseSpacePosition(pos=[5.5,0.,0.]*u.kpc, + ... vel=[0.,140.,25]*u.km/u.s) + >>> lyap,orbit = gd.fast_lyapunov_max(w0, pot, dt=2., n_steps=100000) # doctest: +SKIP + >>> fig = orbit[:,0].plot(marker=',', alpha=0.1, linestyle='none') # doctest: +SKIP + +.. plot:: + :align: center + + import astropy.units as u + import gala.potential as gp + import gala.dynamics as gd + from gala.units import galactic + + pot = gp.LogarithmicPotential(v_c=150*u.km/u.s, r_h=0.1*u.kpc, + q1=1., q2=0.8, q3=0.6, units=galactic) + w0 = gd.PhaseSpacePosition(pos=[5.5,0.,0.]*u.kpc, + vel=[0.,140.,25]*u.km/u.s) + lyap,orbit = gd.fast_lyapunov_max(w0, pot, dt=1., n_steps=200000) + fig = orbit[:,0].plot(marker=',', linestyle='none', alpha=0.1) + +Because this is a regular orbit, the estimate continues decreasing, +following a characteristic power-law (a straight line in a log-log plot):: + + >>> pl.figure() # doctest: +SKIP + >>> pl.loglog(orbit.t[11::10], lyap, marker='') # doctest: +SKIP + >>> pl.xlabel("Time [{}]".format(orbit.t.unit)) # doctest: +SKIP + >>> pl.ylabel(r"$\lambda_{{\rm max}}$ [{}]".format(lyap.unit)) # doctest: +SKIP + >>> pl.tight_layout() # doctest: +SKIP + +.. plot:: + :align: center + :width: 60% + + import astropy.units as u + import matplotlib.pyplot as pl + import gala.potential as gp + import gala.dynamics as gd + from gala.units import galactic + + pot = gp.LogarithmicPotential(v_c=150*u.km/u.s, r_h=0.1*u.kpc, + q1=1., q2=0.8, q3=0.6, units=galactic) + w0 = gd.PhaseSpacePosition(pos=[5.5,0.,0.]*u.kpc, + vel=[0.,140.,25]*u.km/u.s) + lyap,orbit = gd.fast_lyapunov_max(w0, pot, dt=1., n_steps=200000) + + pl.figure() + pl.loglog(orbit.t[11::10], lyap, marker='') + pl.xlabel("Time [{}]".format(orbit.t.unit)) + pl.ylabel(r"$\lambda_{{\rm max}}$ [{}]".format(lyap.unit)) + pl.tight_layout() + +API +--- +.. automodapi:: gala.dynamics.nonlinear + :no-heading: + :headings: ^^ diff --git a/gala/source/docs/dynamics/orbits-in-detail.rst b/gala/source/docs/dynamics/orbits-in-detail.rst new file mode 100644 index 0000000000000000000000000000000000000000..81f357d0b8c9031309c16e21b6a1af03899ae0d9 --- /dev/null +++ b/gala/source/docs/dynamics/orbits-in-detail.rst @@ -0,0 +1,343 @@ +.. include:: references.txt + +.. _orbits-in-detail: + +***************************************************** +Orbit and phase-space position objects in more detail +***************************************************** + +For the examples below the following imports have already been executed:: + + >>> import astropy.units as u + >>> import numpy as np + >>> import gala.potential as gp + >>> import gala.dynamics as gd + >>> from astropy.coordinates import (CylindricalRepresentation, + ... CylindricalDifferential) + >>> from gala.units import galactic + >>> np.random.seed(42) + +We will also set the default Astropy Galactocentric frame parameters to the +values adopted in Astropy v4.0: + + >>> import astropy.coordinates as coord + >>> _ = coord.galactocentric_frame_defaults.set('v4.0') + +Introduction +============ + +The `astropy.units` subpackage is excellent for working with numbers and +associated units, but dynamical quantities often contain many quantities with +mixed units. An example is a position in phase-space, which may contain some +quantities with length units and some quantities with velocity or momentum +units. The |psp| and |orb| classes are designed to work with these data +structures and provide a consistent API for visualizing and computing further +dynamical quantities. Click these shortcuts to jump to a section below, or start +reading below: + + * :ref:`phase-space-position` + * :ref:`orbit` + +.. _phase-space-position: + +Phase-space Positions +===================== + +The |psp| class provides an interface for representing full phase-space +positions--coordinate positions and momenta (velocities). This class is useful +as a container for initial conditions and for transforming phase-space positions +to new coordinate representations or reference frames. + +The easiest way to create a |psp| object is to pass in a pair of +`~astropy.units.Quantity` objects that represent the Cartesian position and +velocity vectors:: + + >>> gd.PhaseSpacePosition(pos=[4., 8., 15.] * u.kpc, + ... vel=[-150., 50., 15.] * u.km/u.s) + + +By default, passing in `~astropy.units.Quantity`'s are interpreted as Cartesian +coordinates and velocities. This works with arrays of positions and velocities +as well:: + + >>> x = np.arange(24).reshape(3, 8) + >>> v = np.arange(24).reshape(3, 8) + >>> w = gd.PhaseSpacePosition(pos=x * u.kpc, + ... vel=v * u.km/u.s) + >>> w + + +This is interpreted as 8, 6-dimensional phase-space positions. + +The class internally stores the positions and velocities as +`~astropy.coordinates.BaseRepresentation` and +`~astropy.coordinates.BaseDifferential` subclasses; in this case, +`~astropy.coordinates.CartesianRepresentation` and +`~astropy.coordinates.CartesianDifferential`:: + + >>> w.pos + + >>> w.vel + + +All of the components of these classes are mapped to attributes of the +phase-space position class for convenience, but with more user-friendly names. +These mappings are defined in the class definition of +`~gala.dynamics.PhaseSpacePosition`. For example, to access the ``x`` component +of the position and the ``v_x`` component of the velocity:: + + >>> w.x # doctest: +FLOAT_CMP + + >>> w.v_x # doctest: +FLOAT_CMP + + +The default representation is Cartesian, but the class can also be instantiated +with representation objects instead of `~astropy.units.Quantity`'s -- this is +useful for creating |psp| or |orb| instances from non-Cartesian +representations of the position and velocity:: + + >>> pos = CylindricalRepresentation(rho=np.linspace(1., 4, 4) * u.kpc, + ... phi=np.linspace(0, np.pi, 4) * u.rad, + ... z=np.linspace(-1, 1., 4) * u.kpc) + >>> vel = CylindricalDifferential(d_rho=np.linspace(100, 150, 4) * u.km/u.s, + ... d_phi=np.linspace(-1, 1, 4) * u.rad/u.Myr, + ... d_z=np.linspace(-15, 15., 4) * u.km/u.s) + >>> w = gd.PhaseSpacePosition(pos=pos, vel=vel) + >>> w + + >>> w.rho + + +We can easily transform the full phase-space vector to new representations or +coordinate frames. These transformations use the :mod:`astropy.coordinates` +|astropyrep|_:: + + >>> cart = w.represent_as('cartesian') + >>> cart.x + + >>> sph = w.represent_as('spherical') + >>> sph.distance + + +There is also support for transforming the positions and velocities (assumed to +be in a `~astropy.coordinates.Galactocentric` frame) to any of the other +coordinate frames. For example, to transform to +:class:`~astropy.coordinates.Galactic` coordinates:: + + >>> from astropy.coordinates import Galactic + >>> gal_c = w.to_coord_frame(Galactic()) + >>> gal_c # doctest: +FLOAT_CMP + + +We can easily plot projections of the phase-space positions using the +`~gala.dynamics.PhaseSpacePosition.plot` method:: + + >>> np.random.seed(42) + >>> x = np.random.uniform(-10, 10, size=(3,128)) + >>> v = np.random.uniform(-200, 200, size=(3,128)) + >>> w = gd.PhaseSpacePosition(pos=x * u.kpc, + ... vel=v * u.km/u.s) + >>> fig = w.plot() # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + + import astropy.units as u + import numpy as np + import gala.dynamics as gd + + np.random.seed(42) + x = np.random.uniform(-10,10,size=(3,128)) + v = np.random.uniform(-200,200,size=(3,128)) + w = gd.PhaseSpacePosition(pos=x*u.kpc, + vel=v*u.km/u.s) + fig = w.plot() + +This is a thin wrapper around the `~gala.dynamics.plot_projections` +function and any keyword arguments are passed through to that function:: + + >>> fig = w.plot(components=['x', 'v_z'], color='r', + ... facecolor='none', marker='o', s=20, alpha=0.5) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + fig = w.plot(components=['x', 'v_z'], color='r', + facecolor='none', marker='o', s=20, alpha=0.5) + + +.. _orbit: + +Orbits +====== + +The |orb| class inherits much of the functionality from |psp| (described above) +and adds some additional features that are useful for time-series orbits. + +An |orb| instance is initialized like the |psp|--with arrays of positions and +velocities-- but usually also requires specifying a time array as well. Also, +the extra axes in these arrays hold special meaning for the |orb| class. The +position and velocity arrays passed to |psp| can have arbitrary numbers of +dimensions as long as the 0th axis specifies the dimensionality. For the |orb| +class, the 0th axis remains the axis of dimensionality, but the 1st axis now is +always assumed to be the time axis. For example, an input position with shape +``(2,128)`` to a |psp| represents 128 independent 2D positions, but to a |orb| +it represents a single orbit's positions at 128 times:: + + >>> t = np.linspace(0, 100, 128) * u.Myr + >>> Om = 1E-1 * u.rad / u.Myr + >>> pos = np.vstack((5*np.cos(Om*t), np.sin(Om*t))).value * u.kpc + >>> vel = np.vstack((-5*np.sin(Om*t), np.cos(Om*t))).value * u.kpc/u.Myr + >>> orbit = gd.Orbit(pos=pos, vel=vel) + >>> orbit + + +To create a single object that contains multiple orbits, the input position +object should have 3 axes. The last axis (``axis=2``) specifies the number of +orbits. So, an input position with shape ``(2,128,16)`` would represent 16, 2D +orbits, each with the same 128 times:: + + >>> t = np.linspace(0, 100, 128) * u.Myr + >>> Om = np.random.uniform(size=16) * u.rad / u.Myr + >>> angle = Om[None] * t[:, None] + >>> pos = np.stack((5*np.cos(angle), np.sin(angle))).value * u.kpc + >>> vel = np.stack((-5*np.sin(angle), np.cos(angle))).value * u.kpc/u.Myr + >>> orbit = gd.Orbit(pos=pos, vel=vel) + >>> orbit + + +To make full use of the orbit functionality, you must also pass in an array with +the time values and an instance of a `~gala.potential.potential.PotentialBase` +subclass that represents the potential that the orbit was integrated in:: + + >>> pot = gp.PlummerPotential(m=1E10, b=1., units=galactic) + >>> orbit = gd.Orbit(pos=pos*u.kpc, vel=vel*u.km/u.s, + ... t=t*u.Myr, potential=pot) + +(note, in this case ``pos`` and ``vel`` were not generated from integrating +an orbit in the potential ``pot``!). However, most of the time you won't need to +create |orb| objects from scratch! They are returned from any of the numerical +integration routines provided in `gala`. For example, they are returned by the +`~gala.potential.potential.PotentialBase.integrate_orbit` method of potential +objects and will automatically contain the ``time`` array and ``potential`` +object. For example:: + + >>> pot = gp.PlummerPotential(m=1E10 * u.Msun, b=1. * u.kpc, units=galactic) + >>> w0 = gd.PhaseSpacePosition(pos=[10.,0,0] * u.kpc, + ... vel=[0.,75,0] * u.km/u.s) + >>> orbit = gp.Hamiltonian(pot).integrate_orbit(w0, dt=1., n_steps=5000) + >>> orbit + + >>> orbit.t + + >>> orbit.potential + + +Just like for |psp|, we can quickly visualize an orbit using the +`~gala.dynamics.Orbit.plot` method:: + + >>> fig = orbit.plot() # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + + import astropy.units as u + import gala.dynamics as gd + import gala.potential as gp + from gala.units import galactic + + pot = gp.PlummerPotential(m=1E10 * u.Msun, b=1. * u.kpc, units=galactic) + w0 = gd.PhaseSpacePosition(pos=[2.,0,0] * u.kpc, + vel=[0.,75,15] * u.km/u.s) + orbit = gp.Hamiltonian(pot).integrate_orbit(w0, dt=1., n_steps=5000) + fig = orbit.plot() + +Again, this is a thin wrapper around the `~gala.dynamics.plot_projections` +function and any keyword arguments are passed through to that function:: + + >>> fig = orbit.plot(linewidth=4., alpha=0.5, color='r') # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + + fig = orbit.plot(linewidth=4., alpha=0.5, color='r') + +Alternatively, for three-dimensional orbits, we can visualize the orbit using +the 3D projection capabilities in `matplotlib`:: + + >>> fig = orbit.plot_3d(alpha=0.5, color='k') # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + fig = orbit.plot_3d(alpha=0.5, color='k') + +We can also quickly create an animation of the progression of an orbit using the +`~gala.dynamics.Orbit.animate` method, which animated projections of the orbit:: + + >>> fig, anim = orbit[:1000].animate(stride=10) # doctest: +SKIP + +.. raw:: html + + + +The animate method acts like `~gala.dynamics.Orbit.plot`, in that it works for +any coordinate representation (Cartesian, cylindrical, etc.) and supports only +animating subsets of the phase-space components. For example, to make an +animation of an orbit in cylindrical coordinates, showing the orbit proress in +the R,z meridional plane:: + + >>> fig, anim = orbit[:1000].cylindrical.animate(components=['rho', 'z'], # doctest: +SKIP + ... stride=10) + +.. raw:: html + + + +We can also quickly compute quantities like the angular momentum, and estimates +for the pericenter, apocenter, eccentricity of the orbit. Estimates for the +latter few get better with smaller timesteps:: + + >>> orbit = gp.Hamiltonian(pot).integrate_orbit(w0, dt=0.1, n_steps=100000) + >>> np.mean(orbit.angular_momentum(), axis=1) # doctest: +FLOAT_CMP + + >>> orbit.eccentricity() # doctest: +FLOAT_CMP + + >>> orbit.pericenter() # doctest: +FLOAT_CMP + + >>> orbit.apocenter() # doctest: +FLOAT_CMP + + + +More information +================ + +Internally, both of the above classes rely on the Astropy representation +transformation framework (i.e. the subclasses of +`~astropy.coordinates.BaseRepresentation` and +`~astropy.coordinates.BaseDifferential`). However, at present these classes only +support 3D positions and differentials (velocities). The |psp| and |orb| classes +both support arbitrary numbers of dimensions and, when relevant, rely on custom +subclasses of the representation classes to handle such cases. See the +:ref:`nd-representations` page for more information about these classes. diff --git a/gala/source/docs/dynamics/references.txt b/gala/source/docs/dynamics/references.txt new file mode 100644 index 0000000000000000000000000000000000000000..7a444969c211a84290d620a65aeeb1524597d475 --- /dev/null +++ b/gala/source/docs/dynamics/references.txt @@ -0,0 +1,4 @@ +.. |psp| replace:: `~gala.dynamics.PhaseSpacePosition` +.. |orb| replace:: `~gala.dynamics.Orbit` +.. |astropyrep| replace:: representations framework +.. _astropyrep: http://docs.astropy.org/en/latest/coordinates/skycoord.html#astropy-skycoord-representations diff --git a/gala/source/docs/getting_started.rst b/gala/source/docs/getting_started.rst new file mode 100644 index 0000000000000000000000000000000000000000..ff7e247f99356cfba1b0b7e52f23675cf1415fc7 --- /dev/null +++ b/gala/source/docs/getting_started.rst @@ -0,0 +1,198 @@ +.. _gala-getting-started: + +*************** +Getting Started +*************** + +Welcome to the `gala` documentation! + +.. TODO: in the paragraph below, switch the matplotlib link to :mod:`matplotlib` +.. when they add a top-level module definition + +For practical reasons, this documentation generally assumes that you are +familiar with the Python programming language, including numerical and +computational libraries like :mod:`numpy`, :mod:`scipy`, and `matplotlib +`_. If you need a refresher on Python programming, we +recommend starting with the `official Python tutorial +`_, but many other good resources are +available on the internet, such as tutorials and lectures specifically designed +for `using Python for scientific applications `_. + +On this introductory page, we will demonstrate a few common use cases for `gala` +and give an overview of the package functionality. For the examples +below, we will assume that the following imports have already been executed +because these packages will be generally required:: + + >>> import astropy.units as u + >>> import numpy as np + + +Computing your first stellar orbit +================================== + +One of the most common use cases for `gala` is computing stellar orbits within +a Milky Way mass model. This requires two things: (1) a gravitational potential +model representing the Milky Way's mass distribution, and (2) initial conditions +for the star's orbit. + +Mass models in `gala` are specified using Python classes that represent +gravitational potential models. The standard Milky Way model recommended for +use in `gala` is the `~gala.potential.potential.MilkyWayPotential` version="latest", +which is a pre-defined, multi-component model of the Milky Way with parameters set to +match the rotation curve of the Galactic disk and the mass profile of the dark matter +halo:: + + >>> import gala.potential as gp + >>> mw = gp.MilkyWayPotential(version="latest") + >>> mw + + +This model contains four distinct potential components: disk, bulge, nucleus, +and halo. You can configure any of these component parameters or create custom +composite potential models (see :mod:`gala.potential`), but for now we'll use the +default model. + +All potential classes in :mod:`gala.potential` have standard methods for computing +dynamical quantities. For example, we can compute the potential energy and acceleration +at a Cartesian position near the Sun:: + + >>> xyz = [-8.0, 0.0, 0.0] * u.kpc + >>> mw.energy(xyz) # doctest: +FLOAT_CMP + + >>> mw.acceleration(xyz) # doctest: +FLOAT_CMP + + +The returned values are Astropy `~astropy.units.Quantity` objects with +associated physical units. These can be converted to any equivalent units:: + + >>> E = mw.energy(xyz) + >>> E.to((u.km / u.s) ** 2) # doctest: +FLOAT_CMP + + >>> acc = mw.acceleration(xyz) + >>> acc.to(u.km / u.s / u.Myr) # doctest: +FLOAT_CMP + + +Now to compute an orbit, we need initial conditions. In `gala`, phase-space +positions are defined using the `~gala.dynamics.PhaseSpacePosition` class. +As an example, we'll use initial conditions close to the Sun's Galactocentric +position and velocity:: + + >>> import gala.dynamics as gd + >>> w0 = gd.PhaseSpacePosition( + ... pos=[-8.1, 0, 0.02] * u.kpc, + ... vel=[13, 245, 8.0] * u.km / u.s, + ... ) + +I use the variable ``w`` to represent phase-space positions, so ``w0`` +represents initial conditions. When passing Cartesian position and velocity +values, they must be `~astropy.units.Quantity` objects with units whenever +the potential has a dimensional unit system:: + + >>> mw.units + + +Our Milky Way potential uses dimensional units. You can use any compatible +length and velocity units, as `gala` handles unit conversions internally. + +With a potential model and initial conditions defined, we can now compute an +orbit using the `~gala.potential.potential.PotentialBase.integrate_orbit()` +method:: + + >>> orbit = mw.integrate_orbit(w0, dt=1 * u.Myr, t1=0, t2=2 * u.Gyr) + +This uses Leapfrog integration by default, which is a fast, symplectic +integration scheme. The returned `~gala.dynamics.Orbit` object represents +a collection of phase-space positions at different times:: + + >>> orbit + + +`~gala.dynamics.Orbit` objects have many of their own useful methods for +performing common tasks, like plotting an orbit:: + + >>> orbit.plot(["x", "y"]) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + import astropy.units as u + import matplotlib.pyplot as plt + import numpy as np + import gala.dynamics as gd + import gala.potential as gp + + mw = gp.MilkyWayPotential(version="latest") + w0 = gd.PhaseSpacePosition( + pos=[-8.1, 0, 0.02] * u.kpc, + vel=[13, 245, 8.0] * u.km / u.s, + ) + orbit = mw.integrate_orbit(w0, dt=1 * u.Myr, t1=0, t2=2 * u.Gyr) + + orbit.plot(["x", "y"]) + +`~gala.dynamics.Orbit` objects by default assume and use Cartesian coordinate +representations, but these can also be transformed into other representations, +like Cylindrical coordinates. For example, we could re-represent the orbit in +cylindrical coordinates and then plot the orbit in the "meridional plane":: + + >>> fig = orbit.cylindrical.plot(["rho", "z"]) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + fig = orbit.cylindrical.plot(["rho", "z"]) + +Or estimate the pericenter, apocenter, and eccentricity of the orbit:: + + >>> orbit.pericenter() # doctest: +FLOAT_CMP + + >>> orbit.apocenter() # doctest: +FLOAT_CMP + + >>> orbit.eccentricity() # doctest: +FLOAT_CMP + + +`gala.potential` ``Potential`` objects and `~gala.dynamics.Orbit` objects have +many more possibilities, so please do check out the narrative documentation for +`gala.potential` and `gala.dynamics` if you would like to learn more! + + +What else can ``gala`` do? +========================== + +This page is meant to demonstrate a few initial things you may want to do with +`gala`. There is much more functionality that you can discover either through +the :ref:`tutorials ` or by perusing the :ref:`user guide +`. Some other commonly-used functionality includes: + +* :ref:`Generating simulated "mock" stellar stream models ` +* :ref:`Stellar stream and great circle coordinate systems ` +* :ref:`Transformations to action-angle coordinates ` +* :ref:`Nonlinear dynamics and chaos indicators ` + + +Where to go from here +===================== + +The two places to learn more are the tutorials and the user guide: + +* The :ref:`gala-tutorials` are narrative demonstrations of functionality that + walk through simplified, real-world use cases for the tools available in + ``gala``. +* The :ref:`gala-user-guide` contains more exhaustive descriptions of all of the + functions and classes available in ``gala``, and should be treated more like + reference material. + + +Bibliography +============ + +.. bibliography:: + :cited: diff --git a/gala/source/docs/glossary.rst b/gala/source/docs/glossary.rst new file mode 100644 index 0000000000000000000000000000000000000000..d3fc829faf0c7a69f1088976fe17fb62cfe0ffd3 --- /dev/null +++ b/gala/source/docs/glossary.rst @@ -0,0 +1,72 @@ +******************************* +Glossary of Documentation Terms +******************************* + +.. glossary:: + + (`n`,) + A parenthesized number followed by a comma denotes a tuple with one + element. The trailing comma distinguishes a one-element tuple from a + parenthesized ``n``. + This is from NumPy; see https://numpy.org/doc/stable/glossary.html. + + number + Any numeric type. eg float or int or any of the ``numpy.number``. + + -like + Used to indicate on object of that type or that can instantiate the type. + E.g. :class:`~astropy.units.Quantity`-like includes ``"2 * u.km"`` + because ``astropy.units.Quantity("2 * u.km")`` works. + + unit-like + Must be an :class:`~astropy.units.UnitBase` (subclass) instance or a + string or other instance parseable by :class:`~astropy.units.Unit`. + + quantity-like + Must be an `~astropy.units.Quantity` (or subclass) instance or a string + parseable by `~astropy.units.Quantity`. + Note that the interpretation of units in strings depends on the class -- + ``Quantity("180d")`` is 180 **days**, while ``Angle("180d")`` is 180 + **degrees** -- so check the string parses as intended for ``Quantity``. + + angle-like + :term:`quantity-like`, but interpreted by an angular + `~astropy.units.SpecificTypeQuantity`, like `~astropy.coordinates.Angle` + or `~astropy.coordinates.Longitude` or `~astropy.coordinates.Latitude`. + Note that the interpretation of units in strings depends on the class -- + ``Quantity("180d")`` is 180 days, while ``Angle("180d")`` is 180 degrees + -- so make sure the string parses as intended for ``Angle``. + + length-like + :term:`quantity-like`, but interpretable by + :class:`~astropy.coordinates.Distance`. + + frame-like + A :class:`~astropy.coordinates.BaseCoordinateFrame` subclass instance or a + string that can be converted to a Frame by + :class:`~astropy.coordinates.sky_coordinate_parsers._get_frame_class`. + + coordinate-like + A Coordinate-type object such as a + :class:`~astropy.coordinates.BaseCoordinateFrame` subclass instance or a + :class:`~astropy.coordinates.SkyCoord` (or subclass) instance. + + table-like + An astropy :class:`~astropy.table.Table` or any object that can + initialize one. Anything marked as table-like will be processed through + a :class:`~astropy.table.Table`. + + time-like + :class:`~astropy.time.Time` or any valid initializer. + + buffer-like + Anything that implements Python's buffer protocol. See + https://docs.python.org/3/c-api/buffer.html#bufferobjects + + writable file-like object + In the context of a :term:`python:file-like object` object, anything + that supports writing with a method ``write``. + + readable file-like object + In the context of a :term:`python:file-like object` object, anything + that supports writing with a method ``read``. diff --git a/gala/source/docs/index.rst b/gala/source/docs/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..f46adcc9390d4d672d5e334f41a2ed84f7ab5d11 --- /dev/null +++ b/gala/source/docs/index.rst @@ -0,0 +1,87 @@ +.. include:: references.txt + +.. raw:: html + + + +.. module:: gala + +**** +Gala +**** + +Galactic Dynamics is the study of the formation, history, and evolution of +galaxies using the *orbits* of objects — numerically-integrated trajectories of +stars, dark matter particles, star clusters, or galaxies themselves. + +``gala`` is an Astropy-affiliated Python package providing efficient tools for +galactic dynamics research. It combines Python's flexibility with optimized +low-level code (primarily C) for fast computations. Core functionality includes +`gravitational potential and force evaluations `_, +`orbit integrations `_, `dynamical coordinate +transformations `_, and computing `chaos indicators for +nonlinear dynamics `_. ``gala`` integrates with +Astropy's units and coordinate systems (:ref:`astropy.units ` +and :ref:`astropy.coordinates `). + +This package is actively developed in `a public repository on GitHub +`_. We welcome contributions of all sizes! +Whether you find a bug, have a feature request, or want to contribute code, +please `open an issue on GitHub `_. + +.. --------------------- +.. Nav bar (top of docs) + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + install + getting_started + tutorials + user_guide + contributing + supporting + + +Contributors +============ + +.. include:: ../AUTHORS.rst + + +Citation and Attribution +======================== + +|JOSS| |DOI| + +`Here is a list of papers that use Gala +`_ + +If you make use of this code, please cite the `JOSS `_ +paper: + +.. code-block:: bibtex + + @article{gala, + doi = {10.21105/joss.00388}, + url = {https://doi.org/10.21105%2Fjoss.00388}, + year = 2017, + month = {oct}, + publisher = {The Open Journal}, + volume = {2}, + number = {18}, + author = {Adrian M. Price-Whelan}, + title = {Gala: A Python package for galactic dynamics}, + journal = {The Journal of Open Source Software}} + +Please also cite the Zenodo DOI |DOI| of the version you used as a software +citation: + +.. include:: ZENODO.rst + +.. |JOSS| image:: http://joss.theoj.org/papers/10.21105/joss.00388/status.svg + :target: http://joss.theoj.org/papers/10.21105/joss.00388 +.. |DOI| image:: https://zenodo.org/badge/17577779.svg + :target: https://zenodo.org/badge/latestdoi/17577779 diff --git a/gala/source/docs/install.rst b/gala/source/docs/install.rst new file mode 100644 index 0000000000000000000000000000000000000000..7e4b24cae59ad99fbb4cbe3616c857afa259e96f --- /dev/null +++ b/gala/source/docs/install.rst @@ -0,0 +1,136 @@ +.. include:: references.txt + +.. _gala-install: + +************ +Installation +************ + +With ``uv`` and ``pip`` (recommended) +===================================== + +To install the latest stable version using ``uv pip``, use:: + + uv pip install gala + +This is the recommended way to install ``gala``. + +To install the development version:: + + uv pip install git+https://github.com/adrn/gala + +Or, to add ``gala`` to an existing ``uv`` environment:: + + uv add gala + +From Source: Cloning, Building, Installing +========================================== + +The latest development version of gala can be cloned from +`GitHub `_ using ``git``:: + + git clone git://github.com/adrn/gala.git + +To build and install the project (from the root of the source tree, e.g., inside +the cloned ``gala`` directory):: + + uv pip install . + + +Architecture-Specific Optimizations +=================================== + +For performance reasons, the pre-compiled wheels installed via ``pip`` are built +assuming a minimum CPU architecture. For x86-64 CPUs (e.g. Intel), the wheels are built +against ``x86-64-v3``, which is supported by most Intel CPUs since 2013. For MacOS on +ARM, the wheels are built against ``apple-m1``, which should work on Apple M1 (2020) or +newer. + +For the best performance, you may wish to build from source (see above) with the +following environment variable set:: + + export CXXFLAGS=-march=native + +This will likely have the biggest effect on orbit integration. Be aware that compiling +with this flag means that Gala will only run on the same type of CPU that it was +compiled on! + +If your CPU does not support the instruction set that Gala was compiled for, you will +likely receive an "illegal instruction error" (``SIGILL``). If that happens, try +recompiling from source, without any ``-march`` flags. + + +Installing on Windows +===================== + +We have successfully installed Gala on Windows within an Anaconda installation, or with +the Windows Subsystem for Linux (WSL), which acts as a Linux environment within Windows. +Either way, we recommend using GCC to compile any C code. Unfortunately, Gala will not +work with Microsoft Visual Studio's C compiler because it is not C99 compliant. + + +GSL support +=========== + +Some functionality in Gala depends on the GNU Scientific Library (GSL), a C +library for numerical and mathematical programming. By default, Gala will +determine whether to install with or without GSL support depending on whether it +can find a GSL installation on your machine. If you are not sure whether you +have GSL installed or not, try running:: + + gsl-config --version + +in your terminal. If that returns a version number, you likely have GSL +installed. If it errors, you will need to install it. Additionally, if your +version of GSL is <1.14, we recommend updating to a newer version, as Gala has +only been tested with GSL >= 1.14. + +On Linux and Mac, you can install GSL using a package manager, such as ``apt`` or +``homebrew``. For example, on a Mac with ``homebrew``, you can install GSL with:: + + brew install gsl + +Or on Linux with ``apt``:: + + apt-get install gsl-bin libgsl0-dev + + +Forcing gala to install without GSL support +------------------------------------------- + +You can force Gala to build without GSL support using the ``--nogsl`` flag passed to +setup.py. To use this flag, you must install Gala from source by cloning the repository +(see above) and running:: + + uv pip install gala --install-option="--nogsl" + + +Python Dependencies +=================== + +Gala has the following build dependencies: + +* `Python`_ >= 3.11 +* `Numpy`_ +* `Cython `_ +* ``setuptools`` +* ``setuptools_scm`` +* ``pybind11`` + +Gala has the following runtime dependencies: + +* `Numpy`_ +* `Astropy`_ +* `PyYAML`_ +* `scipy`_ + + +Optional +-------- + +- `Sympy`_ for creating :class:`~gala.potential.potential.PotentialBase` + subclass instances from a mathematical expression using + :func:`~gala.potential.potential.from_equation()`. +- ``galpy`` +- ``h5py`` +- ``matplotlib`` diff --git a/gala/source/docs/integrate/index.rst b/gala/source/docs/integrate/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..cb7e392597a91fc7e51819b42576ff7cd9378638 --- /dev/null +++ b/gala/source/docs/integrate/index.rst @@ -0,0 +1,237 @@ +.. include:: ../references.txt + +.. module:: gala.integrate + +************************************ +Integration (`gala.integrate`) +************************************ + +Introduction +============ + +:mod:`scipy` provides numerical ODE integration functions (e.g., +:func:`scipy.integrate.odeint` and :func:`scipy.integrate.solve_ivp`), but these +functions are not object-oriented or accessible from C. The +:mod:`gala.integrate` subpackage implements the Leapfrog integration scheme (not +available in Scipy) and provides C wrappers for higher order integration schemes +such as a 5th order Runge-Kutta and the Dormand-Prince 85(3) method. + +For the examples below the following imports have already been executed:: + + >>> import astropy.units as u + >>> import numpy as np + >>> import gala.dynamics as gd + >>> import gala.integrate as gi + >>> from gala.units import galactic, UnitSystem + +Getting Started +=============== + +All of the integrator classes in :mod:`gala.integrate` have the same basic call +structure. To create an integrator object, you pass in a function that evaluates +derivatives of, for example, phase-space coordinates, then you call the +`~gala.integrate.Integrator.run` method while specifying timestep information. + +The integration function must accept, at minimum, two arguments: the current +time, ``t``, and the current position in phase-space, ``w``. The time is a +single floating-point number and the phase-space position will have shape +``(ndim, norbits)`` where ``ndim`` is the full dimensionality of the phase-space +(e.g., 6 for a 3D coordinate system) and ``norbits`` is the number of orbits. +These inputs will *not* have units associated with them (e.g., they are not +:class:`astropy.units.Quantity` objects). An example of such a function (that +represents a simple harmonic oscillator) is:: + + >>> def F(t, w): + ... x, x_dot = w + ... return np.array([x_dot, -x]) + +Even though time does not explicitly enter into the equation, the function must +still accept a time argument. We can now create an instance of +`~gala.integrate.LeapfrogIntegrator` to integrate an orbit in a harmonic +oscillator potential:: + + >>> integrator = gi.LeapfrogIntegrator(F) + +To run the integrator, we need to specify a set of initial conditions. The +simplest way to do this is to specify an array:: + + >>> w0 = np.array([1., 0.]) + +This causes the integrator to work without units, so the orbit object returned +by the integrator will then also have no associated units. For example, to +integrate from these initial conditions with a time step of 0.5 for 100 steps:: + + >>> orbit = integrator.run(w0, dt=0.5, n_steps=100) + >>> orbit.t.unit + Unit(dimensionless) + >>> orbit.pos.xyz.unit + Unit(dimensionless) + +We could instead specify the unit system that the function (``F``) expects, and +then pass in a `~gala.dynamics.PhaseSpacePosition` object with arbitrary units +as initial conditions:: + + >>> usys = UnitSystem(u.m, u.s, u.kg, u.radian) + >>> integrator = gi.LeapfrogIntegrator(F, func_units=usys) + >>> w0 = gd.PhaseSpacePosition(pos=[100.]*u.cm, vel=[0]*u.cm/u.yr) + >>> orbit = integrator.run(w0, dt=0.5, n_steps=100) + +The returned orbit object has quantities in the specified unit system, for +example:: + + >>> orbit.t.unit + Unit("s") + >>> orbit.x1.unit + Unit("m") + + +Example: Forced pendulum +------------------------- + +Here we will demonstrate how to use the Dormand-Prince integrator to compute the +orbit of a forced pendulum. We will use the variable ``q`` as the angle of the +pendulum with respect to the vertical and ``p`` as the conjugate momentum. Our +Hamiltonian is + +.. math:: + + H(q, p) = \frac{1}{2} \, p^2 + \cos(q) + A \, \sin(\omega_D \, t) + +so that + +.. math:: + + \dot{q} &= p\\ + \dot{p} &= -\sin(q) + A\, \omega_D \, \cos(\omega_D \, t) + +For numerical integration, the function to compute the time derivatives of our +phase-space coordinates is then:: + + >>> def F(t, w, A, omega_D): + ... q, p = w + ... wdot = np.zeros_like(w) + ... wdot[0] = p + ... wdot[1] = -np.sin(q) + A * omega_D * np.cos(omega_D * t) + ... return wdot + +This function has two arguments: :math:`A` (``A``), the amplitude of the +forcing,and :math:`\omega_D` (``omega_D``), the driving frequency. We define an +integrator object by specifying this function along with values for the function +arguments:: + + >>> integrator = gi.DOPRI853Integrator(F, func_args=(0.07, 0.75)) + +To integrate an orbit, we use the `~gala.integrate.Integrator.run` method. We +have to specify the initial conditions along with information about how long to +integrate and with what step size. There are several options for how to specify +the time step information. We could pre-generate an array of times and pass that +in, or pass in an initial time, end time, and timestep. Or, we could simply pass +in the number of steps to run for and a timestep. For this example, we will use +the last option. See the API below under *"Other Parameters"* for more +information.:: + + >>> orbit = integrator.run([3., 0.], dt=0.1, n_steps=10000) + +We can plot the integrated (chaotic) orbit:: + + >>> fig = orbit.plot(subplots_kwargs=dict(figsize=(8, 4))) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + + import astropy.units as u + import matplotlib.pyplot as pl + import numpy as np + import gala.integrate as gi + + def F(t, w, A, omega_D): + q, p = w + wdot = np.zeros_like(w) + wdot[0] = p + wdot[1] = -np.sin(q) + A*omega_D*np.cos(omega_D*t) + return wdot + + integrator = gi.DOPRI853Integrator(F, func_args=(0.07, 0.75)) + orbit = integrator.run([3., 0.], dt=0.1, n_steps=10000) + fig = orbit.plot(subplots_kwargs=dict(figsize=(8,4))) + +Example: Lorenz equations +------------------------- + +Here's another example of numerical ODE integration using the +`Lorenz equations `_, a 3D +nonlinear system:: + + >>> def F(t, w, sigma, rho, beta): + ... x, y, z, *_ = w + ... wdot = np.zeros_like(w) + ... wdot[0] = sigma * (y - x) + ... wdot[1] = x * (rho-z) - y + ... wdot[2] = x*y - beta*z + ... return wdot + >>> sigma, rho, beta = 10., 28., 8/3. + >>> integrator = gi.DOPRI853Integrator(F, func_args=(sigma, rho, beta)) + >>> orbit = integrator.run([0.5, 0.5, 0.5, 0, 0, 0], dt=1E-2, n_steps=1E4) + >>> fig = orbit.plot() # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + + def F(t, w, sigma, rho, beta): + x, y, z, *_ = w + wdot = np.zeros_like(w) + wdot[0] = sigma * (y - x) + wdot[1] = x * (rho-z) - y + wdot[2] = x*y - beta*z + return wdot + + sigma, rho, beta = 10., 28., 8/3. + integrator = gi.DOPRI853Integrator(F, func_args=(sigma, rho, beta)) + + orbit = integrator.run([0.5, 0.5, 0.5, 0, 0, 0], dt=1E-2, n_steps=1E4) + fig = orbit.plot() + +Specifying Integrators Throughout Gala +====================================== + +When working with potential integration (e.g., +:meth:`gala.potential.Hamiltonian.integrate_orbit`), N-body simulations, or mock stream +generation, you can specify which integrator to use. Integrators can be specified in two +ways: + +1. By class: Import and pass the integrator class directly:: + + >>> from gala.integrate import LeapfrogIntegrator, DOPRI853Integrator + >>> orbit = pot.integrate_orbit(w0, dt=1., n_steps=1000, + ... Integrator=LeapfrogIntegrator) # doctest: +SKIP + +2. By string name (more convenient): Pass a lowercase string name:: + + >>> orbit = pot.integrate_orbit(w0, dt=1., n_steps=1000, + ... Integrator='leapfrog') # doctest: +SKIP + +Valid integrator names are: + +- ``'leapfrog'`` - :class:`~gala.integrate.LeapfrogIntegrator` +- ``'dopri853'`` or ``'dop853'`` - :class:`~gala.integrate.DOPRI853Integrator` +- ``'ruth4'`` - :class:`~gala.integrate.Ruth4Integrator` +- ``'rk5'`` - :class:`~gala.integrate.RK5Integrator` + +You can also use the :func:`~gala.integrate.get_integrator` function to convert +a string name to an integrator class:: + + >>> integrator_cls = gi.get_integrator('dopri853') + >>> integrator_cls + + +API +=== + +.. automodapi:: gala.integrate + :no-main-docstr: + +.. NOTE : The no-main-docstr option above is so that .. automodule:: is not +.. run, and therefore no .. module:: gala.integrate is defined here, which would +.. duplicate the module definition at the top of this page diff --git a/gala/source/docs/interop.rst b/gala/source/docs/interop.rst new file mode 100644 index 0000000000000000000000000000000000000000..7226cdd882fea544c49bbae4acad8b868bb075df --- /dev/null +++ b/gala/source/docs/interop.rst @@ -0,0 +1,97 @@ +.. _gala-interop: + +********************************************* +Interoperability with Other Dynamics Packages +********************************************* + +Gala provides interfaces with other common Galactic dynamics packages, which +enables easily converting objects between these packages. Some examples are +shown below. As always, if something does not work as expected or you would like +more interoperability with any of these packages, please `open an issue +`_ on GitHub. + +Here are some imports we will use below in examples:: + + >>> import astropy.units as u + >>> import numpy as np + >>> import gala.dynamics as gd + >>> import gala.potential as gp + >>> from gala.units import galactic + +Galpy +===== + +`Galpy `_ is another popular Python package for +Galactic dynamics with similar functionality to Gala. For example, Galpy +supports creating gravitational potential objects and numerically integrating +orbits (among other things). + +Gala provides an interface for converting representations of orbits from Gala to +Galpy, or from Galpy to Gala. To convert a Gala :class:`~gala.dynamics.Orbit` +object to a Galpy ``Orbit``, use the +:meth:`~gala.dynamics.Orbit.to_galpy_orbit()` method: + +.. doctest-requires:: galpy + + >>> w0 = gd.PhaseSpacePosition(pos=[10., 0, 0] * u.kpc, + ... vel=[0, 0, 200.] * u.km/u.s) + >>> mw = gp.Hamiltonian(gp.MilkyWayPotential(version="v2")) + >>> orbit = mw.integrate_orbit(w0, dt=1, n_steps=1000) + >>> orbit + + >>> galpy_orbit = orbit.to_galpy_orbit() + >>> galpy_orbit # doctest: +SKIP + + +Similarly, a Galpy ``Orbit`` can be converted to a Gala +:class:`~gala.dynamics.Orbit` using the +:meth:`~gala.dynamics.Orbit.from_galpy_orbit()` classmethod: + +.. doctest-requires:: galpy + + >>> import galpy.potential as galpy_p + >>> import galpy.orbit as galpy_o + >>> mp = galpy_p.MiyamotoNagaiPotential(a=0.5, b=0.0375, amp=1., + ... normalize=1.) + >>> galpy_orbit = galpy_o.Orbit([1., 0.1, 1.1, 0., 0.1, 1.]) + >>> ts = np.linspace(0, 100, 10000) + >>> galpy_orbit.integrate(ts, mp, method="odeint") + >>> orbit = gd.Orbit.from_galpy_orbit(galpy_orbit) + >>> orbit + + +Gala also provides tools for converting potential objects to `galpy` potential +objects, or creating Gala potential objects from existing `galpy` potentials. +To convert a Gala potential to a Galpy potential, use the +:meth:`~gala.potential.potential.PotentialBase.to_galpy_potential()` method on +any Gala potential object. For example: + +.. doctest-requires:: galpy + + >>> pot = gp.HernquistPotential(m=1e10 * u.Msun, c=1.5 * u.kpc, units=galactic) + >>> galpy_pot = pot.to_galpy_potential() + >>> galpy_pot # doctest: +SKIP + + >>> galpy_pot.Rforce(1., 0.) # doctest: +FLOAT_CMP + -0.48737954713808573 + +To convert from a Galpy potential to a Gala potential, use the +:func:`~gala.potential.potential.interop.galpy_to_gala_potential()` function. For +example: + +.. doctest-requires:: galpy + + >>> import galpy.potential as galpy_gp + >>> from gala.potential.potential.interop import galpy_to_gala_potential + >>> galpy_pot = galpy_gp.HernquistPotential(amp=1., a=0.5) + >>> pot = galpy_to_gala_potential(galpy_pot) + >>> pot + + + +Agama +===== + +Coming soon, but we could use your help! Please leave a note `in this issue +`_ if you would find interoperability +with Agama useful. diff --git a/gala/source/docs/make.bat b/gala/source/docs/make.bat new file mode 100644 index 0000000000000000000000000000000000000000..93dfe92b9c98e009c2f75b6477817411d3a15585 --- /dev/null +++ b/gala/source/docs/make.bat @@ -0,0 +1,170 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. changes to make an overview over all changed/added/deprecated items + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Astropy.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Astropy.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +:end diff --git a/gala/source/docs/potential/compositepotential.rst b/gala/source/docs/potential/compositepotential.rst new file mode 100644 index 0000000000000000000000000000000000000000..0dc20129310d81a8ffe6870c819b96de42755103 --- /dev/null +++ b/gala/source/docs/potential/compositepotential.rst @@ -0,0 +1,90 @@ +.. _compositepotential: + +************************************************* +Creating a composite (multi-component ) potential +************************************************* + +Potential objects can be combined into more complex *composite* potentials +using the :class:`~gala.potential.potential.CompositePotential` or +:class:`~gala.potential.potential.CCompositePotential` classes. These classes +operate like a Python dictionary in that each component potential must be named, +and the potentials can either be passed in to the initializer or added after the +composite potential container is already created. + +For composing any of the built-in potentials or any external potentials +implemented in C, it is always faster to use +:class:`~gala.potential.potential.CCompositePotential`, where the composition is +done at the C layer rather than in Python. + +With either class, interaction with the class (e.g., by calling methods) is +identical to the individual potential classes. To compose potentials with unique +but arbitrary names, you can also simply add pre-defined potential class +instances:: + + >>> import numpy as np + >>> import gala.potential as gp + >>> from gala.units import galactic + >>> disk = gp.MiyamotoNagaiPotential(m=1E11, a=6.5, b=0.27, units=galactic) + >>> bulge = gp.HernquistPotential(m=3E10, c=0.7, units=galactic) + >>> pot = disk + bulge + >>> print(pot.__class__.__name__) + CCompositePotential + >>> list(pot.keys()) # doctest: +SKIP + ['c655f07d-a1fe-4905-bdb2-e8a202d15c81', + '8098cb0b-ebad-4388-b685-2f93a874296e'] + +The two components are assigned unique names and composed into a +:class:`~gala.potential.potential.CCompositePotential` instance because the two +component potentials are implemented in C (i.e. are +:class:`~gala.potential.potential.CPotential`subclass instances). If any of the +individual potential components are Python-only, the resulting object will be +an instance of :class:`~gala.potential.potential.CompositePotential` instead. + +Alternatively, the potentials can be composed directly into the object by +treating it like a dictionary. This allows you to specify the keys or names of +the components in the resulting +:class:`~gala.potential.potential.CCompositePotential` instance:: + + >>> disk = gp.MiyamotoNagaiPotential(m=1E11, a=6.5, b=0.27, units=galactic) + >>> bulge = gp.HernquistPotential(m=3E10, c=0.7, units=galactic) + >>> pot = gp.CCompositePotential(disk=disk, bulge=bulge) + >>> list(pot.keys()) # doctest: +SKIP + ['disk', 'bulge'] + +is equivalent to:: + + >>> pot = gp.CCompositePotential() + >>> pot['disk'] = disk + >>> pot['bulge'] = bulge + +The order of insertion is preserved, and sets the order that the potentials are +called. In the above example, the disk potential would always be called first +and the bulge would always be called second. + +The resulting potential object has all of the same properties as individual +potential objects:: + + >>> pot.energy([1., -1., 0.]) # doctest: +FLOAT_CMP + + >>> pot.acceleration([1., -1., 0.]) # doctest: +FLOAT_CMP + + >>> grid = np.linspace(-3., 3., 100) + >>> fig = pot.plot_contours(grid=(grid, 0, grid)) # doctest: +SKIP + +.. plot:: + :align: center + :width: 60% + + import numpy as np + import gala.dynamics as gd + import gala.potential as gp + from gala.units import galactic + + disk = gp.MiyamotoNagaiPotential(m=1E11, a=6.5, b=0.27, units=galactic) + bulge = gp.HernquistPotential(m=3E10, c=0.7, units=galactic) + pot = gp.CompositePotential(disk=disk, bulge=bulge) + + grid = np.linspace(-3.,3.,100) + fig = pot.plot_contours(grid=(grid,0,grid)) diff --git a/gala/source/docs/potential/define-new-potential.rst b/gala/source/docs/potential/define-new-potential.rst new file mode 100644 index 0000000000000000000000000000000000000000..95e5dfc18a92ea13231279556f70f20b9aa5c44a --- /dev/null +++ b/gala/source/docs/potential/define-new-potential.rst @@ -0,0 +1,185 @@ +.. _define-new-potential: + +********************************* +Defining your own potential class +********************************* + +Introduction +============ + +There are two ways to define a new potential class: with pure-Python, or with C +and Cython. The advantage to writing a new class in Cython is that the +computations can execute with C-like speeds, however only certain integrators +support using this functionality (Leapfrog and DOP853) and it is a bit more +complicated to set up the code to build the C+Cython code properly. If you are +not familiar with Cython, you probably want to stick to a pure Python class for +initial testing. If there is a potential class that you think should be +included as a built-in Cython potential, feel free to suggest the new addition +as a `GitHub issue `_! + +For the examples below the following imports have already been executed:: + + >>> import numpy as np + >>> import gala.potential as gp + >>> import gala.dynamics as gd + +======================================== +Implementing a new potential with Python +======================================== + +New Python potentials are implemented by subclassing +:class:`~gala.potential.potential.PotentialBase` and defining functions that +compute (at minimum) the energy and gradient of the potential. We will work +through an example below for adding the `Henon-Heiles potential +`_. + +The expression for the potential is: + +.. math:: + + \Phi(x,y) = \frac{1}{2}(x^2 + y^2) + A\,(x^2 y - \frac{y^3}{3}) + +With this parametrization, there is only one free parameter (``A``), and the +potential is two-dimensional. + +At minimum, the subclass must implement the following methods: + +- ``__init__()`` +- ``_energy()`` +- ``_gradient()`` + +The ``_energy()`` method should compute the potential energy at a given position +and time. The ``_gradient()`` method should compute the gradient of the +potential. Both of these methods must accept two arguments: a position, and a +time. These internal methods are then called by the +:class:`~gala.potential.potential.PotentialBase` superclass methods +:meth:`~gala.potential.potential.PotentialBase.energy` and +:meth:`~gala.potential.potential.PotentialBase.gradient`. The superclass methods +convert the input position to an array in the unit system of the potential for +fast evaluation. The input to these superclass methods can be +:class:`~astropy.units.Quantity` objects, +:class:`~gala.dynamics.PhaseSpacePosition` objects, or :class:`~numpy.ndarray`. + +Because this potential has a parameter, the ``__init__`` method must accept +a parameter argument and store this in the ``parameters`` dictionary attribute +(a required attribute of any subclass). Let's write it out, then work through +what each piece means in detail:: + + >>> class CustomHenonHeilesPotential(gp.PotentialBase): + ... A = gp.PotentialParameter("A") + ... ndim = 2 + ... + ... def _energy(self, xy, t): + ... A = self.parameters['A'].value + ... x,y = xy.T + ... return 0.5*(x**2 + y**2) + A*(x**2*y - y**3/3) + ... + ... def _gradient(self, xy, t): + ... A = self.parameters['A'].value + ... x,y = xy.T + ... + ... grad = np.zeros_like(xy) + ... grad[:,0] = x + 2*A*x*y + ... grad[:,1] = y + A*(x**2 - y**2) + ... return grad + +The internal energy and gradient methods compute the numerical value and +gradient of the potential. The ``__init__`` method must take a single argument, +``A``, and store this to a parameter dictionary. The expected shape of the +position array (``xy``) passed to the internal ``_energy()`` and ``_gradient()`` +methods is always 2-dimensional with shape ``(n_points, n_dim)`` where +``n_points >= 1`` and ``n_dim`` must match the dimensionality of the potential +specified in the initializer. Note that this is different from the shape +expected when calling the public methods ``energy()`` and ``gradient()``! + +Let's now create an instance of the class and see how it works. For now, let's +pass in ``None`` for the unit system to designate that we'll work with +dimensionless quantities:: + + >>> pot = CustomHenonHeilesPotential(A=1., units=None) + +That's it! We now have a potential object with all of the same functionality as +the built-in potential classes. For example, we can integrate an orbit in this +potential (but note that this potential is two-dimensional, so we only have to +specify four coordinate values):: + + >>> w0 = gd.PhaseSpacePosition(pos=[0., 0.3], + ... vel=[0.38, 0.]) + >>> orbit = gp.Hamiltonian(pot).integrate_orbit(w0, dt=0.05, n_steps=10000) + >>> fig = orbit.plot(marker=',', linestyle='none', alpha=0.5) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + import matplotlib.pyplot as pl + import numpy as np + import gala.dynamics as gd + import gala.potential as gp + + class CustomHenonHeilesPotential(gp.PotentialBase): + A = gp.PotentialParameter("A") + ndim = 2 + def _energy(self, xy, t): + A = self.parameters['A'].value + x,y = xy.T + return 0.5*(x**2 + y**2) + A*(x**2*y - y**3/3) + def _gradient(self, xy, t): + A = self.parameters['A'].value + x, y = xy + grad = np.zeros_like(xy) + grad[0] = x + 2*A*x*y + grad[1] = y + A*(x**2 - y**2) + return grad + + pot = CustomHenonHeilesPotential(A=1., units=None) + w0 = gd.PhaseSpacePosition(pos=[0.,0.3], + vel=[0.38,0.]) + orbit = gp.Hamiltonian(pot).integrate_orbit(w0, dt=0.05, n_steps=10000) + fig = orbit.plot(marker=',', linestyle='none', alpha=0.5) + +We could also, for example, create a contour plot of equipotentials:: + + >>> grid = np.linspace(-1., 1., 100) + >>> from matplotlib import colors + >>> import matplotlib.pyplot as plt + >>> fig, ax = plt.subplots(1, 1, figsize=(5,5)) + >>> fig = pot.plot_contours(grid=(grid, grid), + ... levels=np.logspace(-3, 1, 10), + ... norm=colors.LogNorm(), + ... cmap='Blues', ax=ax) + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + from matplotlib import colors + import matplotlib.pyplot as plt + + grid = np.linspace(-1., 1., 100) + fig, ax = plt.subplots(1, 1, figsize=(5,5)) + fig = pot.plot_contours(grid=(grid,grid), cmap='Blues', + levels=np.logspace(-3, 1, 10), + norm=colors.LogNorm(), ax=ax) + +===================================== +Adding a custom potential with Cython +===================================== + +Adding a new Cython potential class is a little more involved as it requires +writing C-code and setting it up properly to compile when the code is built. +For this example, we'll work through how to define a new C-implemented potential +class representation of a Keplerian (point-mass) potential. Because this example +requires using Cython to build code, we provide a separate +`demo GitHub repository `_ with an +implementation of this potential with a demonstration of a build system that +successfully sets up the code. + +New Cython potentials are implemented by subclassing +:class:`~gala.potential.potential.CPotentialBase`, subclassing +:class:`~gala.potential.potential.CPotentialWrapper`, and defining C functions +that compute (at minimum) the energy and gradient of the potential. This +requires creating (at minimum) a Cython file (.pyx), a C header file (.h), and a +C source file (.c). diff --git a/gala/source/docs/potential/hamiltonian-reference-frames.rst b/gala/source/docs/potential/hamiltonian-reference-frames.rst new file mode 100644 index 0000000000000000000000000000000000000000..80f118a6b0a8693394ac75fe0d07b53d45579f45 --- /dev/null +++ b/gala/source/docs/potential/hamiltonian-reference-frames.rst @@ -0,0 +1,163 @@ +.. _hamiltonian-reference-frames: + +**************************************** +Hamiltonian objects and reference frames +**************************************** + +For the examples below the following imports have already been executed:: + + >>> import astropy.units as u + >>> import numpy as np + >>> import matplotlib.pyplot as plt + >>> import gala.potential as gp + >>> import gala.dynamics as gd + >>> import gala.integrate as gi + >>> from gala.units import galactic + +Introduction +============ + +When :ref:`integrating orbits using the potential classes directly +`, for example:: + + >>> pot = gp.HernquistPotential(m=1E10*u.Msun, c=1.*u.kpc, + ... units=galactic) + >>> w0 = gd.PhaseSpacePosition(pos=[5.,0,0]*u.kpc, + ... vel=[0,0,50.]*u.km/u.s) + >>> orbit = gp.Hamiltonian(pot).integrate_orbit(w0, dt=0.5, n_steps=1000) + +it is implicitly assumed that the initial conditions and orbit are in an +inertial (static) reference frame. In this case, the total energy or value +of the Hamiltonian (per unit mass) is simply + +.. math:: + + H(\boldsymbol{x}, \boldsymbol{v}) = E + = \frac{1}{2}\,|\boldsymbol{v}|^2 + \Phi(\boldsymbol{x}) + +It is sometimes useful to transform to alternate, non-inertial reference frames +to do the numerical orbit integration. In this case, the _effective_ Hamiltonian +may include other terms. For example, in the case of a rotating reference frame +constantly rotating with frequency vector :math:`\boldsymbol{\Omega}`, the +effective potential can be written + +.. math:: + + \Phi_{\rm eff}(\boldsymbol{x}, \boldsymbol{v}) = \Phi(\boldsymbol{x}) + - \boldsymbol{\Omega} \cdot \boldsymbol{L} + +where :math:`\boldsymbol{L}` is the angular momentum. For working in +non-inertial reference frames, Gala provides a way to compose potential objects +(which define just the static component of the effective potential) with +reference frame objects into a :class:`~gala.potential.hamiltonian.Hamiltonian` +object, which can then be used for orbit integration, evaluating the full +symplectic gradient of the effective Hamiltonian, and computing the value +(pseudo-energy) of the effective Hamiltonian. + +Creating a Hamiltonian object with a specified reference frame +============================================================== + +Using the potential objects and +:meth:`~gala.potential.potential.PotentialBase.integrate_orbit()` to integrate +an orbit is equivalent to defining a +:class:`~gala.potential.hamiltonian.Hamiltonian` object with the potential +object and a :class:`~gala.potential.frame.builtin.Staticframe` instance:: + + >>> pot = gp.HernquistPotential(m=1E10*u.Msun, c=1.*u.kpc, + ... units=galactic) + >>> frame = gp.StaticFrame(units=galactic) + >>> H = gp.Hamiltonian(potential=pot, frame=frame) + >>> w0 = gd.PhaseSpacePosition(pos=[5.,0,0]*u.kpc, + ... vel=[0,0,50.]*u.km/u.s) + >>> orbit = H.integrate_orbit(w0, dt=0.5, n_steps=1000) + +In this case, the ``orbit`` object returned from integration knows what +reference frame it is in and we can therefore transform it to other reference +frames. For example, we can change to a constantly rotating frame with a +frequency vector that determines the axis of rotation and angular velocity of +rotation around that axis:: + + >>> rotation_axis = np.array([8.2, -1.44, 3.25]) + >>> rotation_axis /= np.linalg.norm(rotation_axis) # make a unit vector + >>> frame_freq = 42. * u.km/u.s/u.kpc + >>> rot_frame = gp.ConstantRotatingFrame(Omega=frame_freq * rotation_axis, + ... units=galactic) + >>> orbit_to_rot = orbit.to_frame(rot_frame) + >>> fig1 = orbit.plot(marker='') # doctest: +SKIP + >>> fig1.suptitle("Static frame") # doctest: +SKIP + >>> fig2 = rot_orbit.plot(marker='') # doctest: +SKIP + >>> fig2.suptitle("Rotating frame") # doctest: +SKIP + +.. plot:: + :align: center + + import astropy.units as u + import numpy as np + import gala.dynamics as gd + import gala.potential as gp + from gala.units import galactic + + pot = gp.HernquistPotential(m=1E10*u.Msun, c=1.*u.kpc, + units=galactic) + frame = gp.StaticFrame(units=galactic) + H = gp.Hamiltonian(potential=pot, frame=frame) + w0 = gd.PhaseSpacePosition(pos=[5.,0,0]*u.kpc, + vel=[0,0,50.]*u.km/u.s) + orbit = H.integrate_orbit(w0, dt=0.5, n_steps=1000) + + rotation_axis = np.array([8.2, -1.44, 3.25]) + rotation_axis /= np.linalg.norm(rotation_axis) # make a unit vector + frame_freq = 42. * u.km/u.s/u.kpc + rot_frame = gp.ConstantRotatingFrame(Omega=frame_freq * rotation_axis, + units=galactic) + orbit_to_rot = orbit.to_frame(rot_frame) + + fig1 = orbit.plot(marker='') + fig1.suptitle("Static frame", fontsize=20, y=0.96) + fig1.subplots_adjust(top=0.92) + fig1.tight_layout() + + fig2 = orbit_to_rot.plot(marker='') + fig2.suptitle("Rotating frame", fontsize=20, y=0.96) + fig2.subplots_adjust(top=0.92) + fig2.tight_layout() + + +We can also integrate the orbit in the rotating frame directly by creating a +:class:`~gala.potential.hamiltonian.Hamiltonian` object with the rotating +frame:: + + >>> H_rot = gp.Hamiltonian(potential=pot, frame=rot_frame) + >>> rot_orbit = H_rot.integrate_orbit(w0, dt=0.5, n_steps=1000) + >>> _ = rot_orbit.plot(marker='') # doctest: +SKIP + +.. plot:: + :align: center + + import astropy.units as u + import numpy as np + import gala.dynamics as gd + import gala.potential as gp + from gala.units import galactic + + pot = gp.HernquistPotential(m=1E10*u.Msun, c=1.*u.kpc, + units=galactic) + w0 = gd.PhaseSpacePosition(pos=[5.,0,0]*u.kpc, + vel=[0,0,50.]*u.km/u.s) + + rotation_axis = np.array([8.2, -1.44, 3.25]) + rotation_axis /= np.linalg.norm(rotation_axis) # make a unit vector + frame_freq = 42. * u.km/u.s/u.kpc + rot_frame = gp.ConstantRotatingFrame(Omega=frame_freq * rotation_axis, + units=galactic) + + H_rot = gp.Hamiltonian(potential=pot, frame=rot_frame) + rot_orbit = H_rot.integrate_orbit(w0, dt=0.5, n_steps=1000) + _ = rot_orbit.plot(marker='') # doctest: +SKIP + +In this case, because the potential is spherical, the orbit should look the same +whether we integrate it in the rotating frame or in a static frame and then +transform to a rotating frame. In the example below, we consider the case of +integrating orbits in an asymmetric, time-dependent bar potential. + +See the :ref:`integrate_rotating_frame` example for more information. diff --git a/gala/source/docs/potential/index.rst b/gala/source/docs/potential/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..f5022e6c037c76006053ee195ea4a5755cdef097 --- /dev/null +++ b/gala/source/docs/potential/index.rst @@ -0,0 +1,384 @@ +.. include:: ../references.txt + +.. module:: gala.potential + +************************************************* +Gravitational potentials (`gala.potential`) +************************************************* + +Introduction +============ + +This subpackage provides a number of classes for working with parametric models +of gravitational potentials. There are a number of built-in potentials +implemented in C and Cython (for speed), and there are base classes that allow +for easy creation of `new custom potential classes `_ +in pure Python or by writing custom C/Cython extensions. The ``Potential`` +objects have convenience methods for computing common dynamical quantities, for +example: potential energy, spatial gradient, density, or mass profiles. These +are particularly useful in combination with the `~gala.integrate` and +`~gala.dynamics` subpackages. + +Also defined in this subpackage are a set of reference frames which can be used +for numerical integration of orbits in non-static reference frames. See the page +on :ref:`hamiltonian-reference-frames` for more information. ``Potential`` +objects can be combined with a reference frame and stored in a +`~gala.potential.hamiltonian.Hamiltonian` object that provides an easy interface +to numerical orbit integration. + +For the examples below the following imports have already been executed:: + + >>> import astropy.units as u + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> import gala.potential as gp + >>> from gala.units import galactic, solarsystem, dimensionless + +Getting Started: Built-in Methods of Potential Classes +====================================================== + +Potential classes are initialized by passing parameter values as +:class:`~astropy.units.Quantity` objects or as numeric values with a +specified unit system. You must also specify a `~gala.units.UnitSystem` +— a set of non-reducible units defining length, mass, time, and angle units. +Common unit systems are built in (e.g., ``galactic``, ``solarsystem``, +``dimensionless``). + +For example, to create a Kepler potential (point mass) with mass = 1 solar mass:: + + >>> ptmass = gp.KeplerPotential(m=1.0 * u.Msun, units=solarsystem) + >>> ptmass + + +Parameters with different units are automatically converted to the specified +unit system:: + + >>> gp.KeplerPotential(m=1047.6115 * u.Mjup, units=solarsystem) + + +Parameters without units are assumed to be in the specified unit system:: + + >>> gp.KeplerPotential(m=1.0, units=solarsystem) + + +To work without units, use `~gala.units.DimensionlessUnitSystem` or pass +``None`` (this is the default, so you can also omit the units argument):: + + >>> gp.KeplerPotential(m=1.0, units=None) + + +Unit systems can also be specified by passing a string name:: + + >>> gp.KeplerPotential(m=1.0 * u.Msun, units='solarsystem') + + +All built-in potential objects have methods to evaluate the potential energy +and gradient/acceleration at given positions. For example, to evaluate the +potential energy at ``(x, y, z) = (1, -1, 0) AU``:: + + >>> ptmass.energy([1.0, -1.0, 0.0] * u.au) + + +These functions accept both :class:`~astropy.units.Quantity` objects and +plain array-like objects (assumed to be in the potential's unit system):: + + >>> ptmass.energy([1.0, -1.0, 0.0]) + + +For multiple positions, pass a 2D array where each column is a position:: + + >>> pos = np.array([[1.0, -1.0, 0], [2.0, 3.0, 0]]).T + >>> ptmass.energy(pos * u.au) + + +We can also compute the gradient or acceleration:: + + >>> ptmass.gradient([1.0, -1.0, 0] * u.au) # doctest: +FLOAT_CMP + + >>> ptmass.acceleration([1.0, -1.0, 0] * u.au) # doctest: +FLOAT_CMP + + +Using Symmetry Coordinates +-------------------------- + +Many potentials have symmetries that make certain coordinate systems more natural or +concise to work with. For example, spherically-symmetric potentials only depend on the +radius :math:`r`, and axisymmetric potentials only depend on the cylindrical radius +:math:`R` and height :math:`z`. + +For potentials with these symmetries, you can use **symmetry coordinates** as a +shorthand instead of full 3D Cartesian coordinates. Internally, gala operates in +Cartesian coordinates, so the symmetry coordinates are simply a front-end convenience. + +Spherical Potentials +~~~~~~~~~~~~~~~~~~~~~ + +For spherically-symmetric potentials (like :class:`~gala.potential.HernquistPotential`, +:class:`~gala.potential.PlummerPotential`, :class:`~gala.potential.KeplerPotential`, +etc.), you can pass just the radius using ``r=``:: + + >>> pot = gp.HernquistPotential(m=1e10 * u.Msun, c=5 * u.kpc, units=galactic) + >>> r = np.array([1.0, 5.0, 10.0]) * u.kpc + >>> pot.energy(r=r) # doctest: +FLOAT_CMP + + +This is equivalent to passing Cartesian coordinates ``[r, 0, 0]``, but much cleaner. All +potential methods support symmetry coordinates:: + + >>> pot.gradient(r=r) # Note: Still returns gradient in Cartesian # doctest: +SKIP + >>> pot.density(r=r) # doctest: +SKIP + >>> pot.mass_enclosed(r=r) # doctest: +SKIP + >>> pot.circular_velocity(r=r) # doctest: +SKIP + +Note that gradients and accelerations are always returned in Cartesian coordinates, even +when using symmetry inputs. This ensures consistency and compatibility with orbit +integration. + +Cylindrical (Axisymmetric) Potentials +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For axisymmetric potentials (like :class:`~gala.potential.MiyamotoNagaiPotential`), +you can use cylindrical coordinates with ``R=`` and ``z=``:: + + >>> pot = gp.MiyamotoNagaiPotential(m=1e11 * u.Msun, a=3 * u.kpc, b=0.3 * u.kpc, units=galactic) + >>> R = np.linspace(4, 12, 100) * u.kpc + >>> z = np.linspace(-1, 1, 100) * u.kpc + >>> pot.energy(R=R, z=z) # doctest: +SKIP + +The ``z`` coordinate defaults to zero, making midplane calculations particularly +convenient:: + + >>> pot.energy(R=R) # Evaluate in the midplane (z=0) # doctest: +SKIP + +This is especially useful for plotting rotation curves or computing circular +velocities in the disk plane. + +Most of the potential objects also have methods implemented for computing the +corresponding mass density and the Hessian of the potential (the matrix of 2nd +derivatives) at given locations. For example, with the +:class:`~gala.potential.potential.HernquistPotential`, we can evaluate both the +mass density and Hessian at the position ``(x, y, z) = (1, -1, 0) kpc``:: + + >>> pot = gp.HernquistPotential(m=1e9 * u.Msun, c=1.0 * u.kpc, units=galactic) + >>> pot.density([1.0, -1.0, 0] * u.kpc) # doctest: +FLOAT_CMP + + >>> pot.hessian([1.0, -1.0, 0] * u.kpc) # doctest: +SKIP + + +Another useful method is +:meth:`~gala.potential.potential.PotentialBase.mass_enclosed`, which numerically +estimates the mass enclosed within a spherical shell defined by the specified +position. This numerically estimates :math:`\frac{d \Phi}{d r}` along the vector +pointing at the specified position and estimates the enclosed mass simply as +:math:`M(>> pot = gp.NFWPotential(m=1e11 * u.Msun, r_s=20.0 * u.kpc, units=galactic) + >>> r = np.logspace(np.log10(20.0 / 100.0), np.log10(20 * 100.0), 100) * u.kpc + >>> m_profile = pot.mass_enclosed(r=r) + >>> plt.loglog(r, m_profile, marker="") # doctest: +SKIP + >>> plt.xlabel("$r$ [{}]".format(r.unit.to_string(format="latex"))) # doctest: +SKIP + >>> plt.ylabel("$M(>> p = gp.MiyamotoNagaiPotential(m=1e11, a=6.5, b=0.27, units=galactic) + >>> fig, ax = plt.subplots() # doctest: +SKIP + >>> p.plot_contours( + ... grid=(np.linspace(-15, 15, 100), 0.0, 1.0), marker="", ax=ax + ... ) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 90% + + pot = gp.MiyamotoNagaiPotential(m=1e11, a=6.5, b=0.27, units=galactic) + + fig, ax = plt.subplots(1, 1) # doctest: +SKIP + pot.plot_contours( + grid=(np.linspace(-15, 15, 100), 0.0, 1.0), marker="", ax=ax + ) # doctest: +SKIP + E_unit = pot.units["energy"] / pot.units["mass"] + ax.set_xlabel("$x$ [{}]".format(pot.units["length"].to_string(format="latex"))) # doctest: +SKIP + ax.set_ylabel("$\Phi(x,0,1)$ [{}]".format(E_unit.to_string(format="latex"))) # doctest: +SKIP + fig.tight_layout() + +For 2D contour plots, pass 1D grids for two dimensions and a fixed value +for the third. For example, to plot :math:`x`-:math:`z` contours at :math:`y=0`:: + + >>> fig, ax = plt.subplots(1, 1, figsize=(12, 4)) + >>> x = np.linspace(-15, 15, 100) + >>> z = np.linspace(-5, 5, 100) + >>> p.plot_contours(grid=(x, 1.0, z), ax=ax) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + x = np.linspace(-15, 15, 100) + z = np.linspace(-5, 5, 100) + + fig, ax = plt.subplots(1, 1, figsize=(12, 4)) + pot.plot_contours(grid=(x, 1.0, z), ax=ax) + ax.set_xlabel("$x$ [{}]".format(pot.units["length"].to_string(format="latex"))) + ax.set_ylabel("$z$ [{}]".format(pot.units["length"].to_string(format="latex"))) + fig.tight_layout() + +Saving / loading potential objects +================================== + +Potential objects can be saved to and loaded from YAML files using the +:meth:`~gala.potential.potential.PotentialBase.save` method and +:func:`~gala.potential.potential.load` function:: + + >>> from gala.potential import load + >>> pot = gp.NFWPotential(m=6e11 * u.Msun, r_s=20.0 * u.kpc, units=galactic) + >>> pot.save("potential.yml") + >>> load("potential.yml") + + +Exporting potentials as ``sympy`` expressions +============================================= + +Most of the potential classes can be exported to a `sympy` expression that can +be used to manipulate or evaluate the form of the potential. To access this +functionality, the potential classes have a +`~gala.potential.potential.PotentialBase.to_sympy` classmethod (note: this +requires `sympy` to be installed): + +.. doctest-requires:: sympy + + >>> expr, vars_, pars = gp.LogarithmicPotential.to_sympy() + >>> str(expr) + '0.5*v_c**2*log(r_h**2 + z**2/q3**2 + y**2/q2**2 + x**2/q1**2)' + +This method also returns a dictionary containing the coordinate variables used +in the expression as ``sympy`` symbols, here defined as ``vars_``: + +.. doctest-requires:: sympy + + >>> vars_ + {"x": x, "y": y, "z": z} + +A second dictionary containing the potential parameters as `sympy` symbols is +also returned, here defined as ``pars``: + +.. doctest-requires:: sympy + + >>> pars + {"v_c": v_c, "r_h": r_h, "q1": q1, "q2": q2, "q3": q3, "phi": phi, "G": G} + +The expressions and variables returned can be used to perform operations on the +potential expression. For example, to create a `sympy` expression for the +gradient of the potential: + +.. doctest-requires:: sympy + + >>> import sympy as sy + >>> grad = sy.derive_by_array(expr, list(vars_.values())) + >>> grad[0] # dPhi/dx + 1.0 * v_c**2 * x / (q1**2 * (r_h**2 + z**2 / q3**2 + y**2 / q2**2 + x**2 / q1**2)) + +Time-varying Potentials +======================= + +For modeling time-dependent gravitational potentials, Gala provides +:class:`~gala.potential.potential.TimeInterpolatedPotential`. +This class can wrap any potential class and interpolate its parameters, origin, and/or +rotation over time. +This is useful for modeling, for example, time-evolving masses, such as from mass-loss +in a mock stream simulation, or for rotating components like galactic bars. + +The class uses GSL spline interpolation to smoothly interpolate parameter values between +specified time knots. For example, to model a black hole that grows in mass over time:: + + >>> import astropy.units as u + >>> import numpy as np + >>> times = np.linspace(0, 1, 11) * u.Gyr + >>> masses = np.linspace(1e6, 1e8, 11) * u.Msun # Grows linearly by a factor of 100 + >>> pot = gp.TimeInterpolatedPotential( + ... gp.KeplerPotential, + ... time_knots=times, + ... m=masses, + ... units=galactic + ... ) + >>> pot.energy([10., 0, 0] * u.pc, t=0 * u.Gyr) # doctest: +SKIP + + >>> pot.energy([10., 0, 0] * u.pc, t=1 * u.Gyr) # doctest: +SKIP + + +Parameters can be constant (passed as scalars) or time-varying (passed as arrays +matching the number of time knots). You can also specify time-varying origin offsets and +rotation matrices. See :doc:`time-interpolated` for more details and examples. + +Using gala.potential +==================== +More details are provided in the linked pages below: + +.. toctree:: + :maxdepth: 1 + + symmetry-coordinates + define-new-potential + compositepotential + origin-rotation + time-interpolated + hamiltonian-reference-frames + spherical-spline + scf + +API +=== + +.. automodapi:: gala.potential.potential + +.. automodapi:: gala.potential.frame.builtin + +.. automodapi:: gala.potential.hamiltonian diff --git a/gala/source/docs/potential/origin-rotation.rst b/gala/source/docs/potential/origin-rotation.rst new file mode 100644 index 0000000000000000000000000000000000000000..ff1ae0e59b3eb41f703254a57618ed90a2fc5013 --- /dev/null +++ b/gala/source/docs/potential/origin-rotation.rst @@ -0,0 +1,142 @@ +.. _rotate-origin-potential: + +************************************************************** +Specifying rotations or origin shifts in ``Potential`` classes +************************************************************** + +Most of the gravitational potential classes implemented in `gala` support +shifting the origin of the potential relative to the coordinate system, and +specifying a rotation of the potential relative to the coordinate system. +By default, the origin is assumed to be at (0,0,0) or (0,0), and there is no +rotation assumed. + +For the examples below the following imports have already been executed:: + + >>> import astropy.units as u + >>> import numpy as np + >>> import gala.potential as gp + >>> from gala.units import galactic, solarsystem + +Origin shifts +============= + +For potential classes that support these options, origin shifts are specified by +passing in a `~astropy.units.Quantity` to set the origin of the potential in the +given coordinate system. For example, if we are working with two +`~gala.potential.KeplerPotential` objects, and we want them to be offset from +one another such that one potential is at ``(1, 0, 0)`` AU and the other is at +``(-2, 0, 0)`` AU, we would define the two objects as:: + + >>> p1 = gp.KeplerPotential(m=1*u.Msun, origin=[1, 0, 0]*u.au, + ... units=solarsystem) + >>> p2 = gp.KeplerPotential(m=0.5*u.Msun, origin=[-2, 0, 0]*u.au, + ... units=solarsystem) + +To see that these are shifted from the coordinate system origin, let's combine +these two objects into a `~gala.potential.potential.CCompositePotential` and +visualize the potential:: + + >>> pot = gp.CCompositePotential(p1=p1, p2=p2) + >>> fig, ax = plt.subplots(1, 1, figsize=(5, 5)) # doctest: +SKIP + >>> grid = np.linspace(-5, 5, 100) + >>> p.plot_contours(grid=(grid, grid, 0.), ax=ax) # doctest: +SKIP + >>> ax.set_xlabel("$x$ [kpc]") # doctest: +SKIP + >>> ax.set_ylabel("$y$ [kpc]") # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + :width: 60% + + import astropy.units as u + import matplotlib.pyplot as plt + import numpy as np + import gala.potential as gp + from gala.units import galactic, solarsystem + + p1 = gp.KeplerPotential(m=1*u.Msun, origin=[1, 0, 0]*u.au, + units=solarsystem) + p2 = gp.KeplerPotential(m=0.5*u.Msun, origin=[-2, 0, 0]*u.au, + units=solarsystem) + + pot = gp.CCompositePotential(p1=p1, p2=p2) + fig, ax = plt.subplots(1, 1, figsize=(5, 5)) + grid = np.linspace(-5, 5, 100) + pot.plot_contours(grid=(grid, grid, 0.), ax=ax) # doctest: +SKIP + ax.set_xlabel("$x$ [kpc]") # doctest: +SKIP + ax.set_ylabel("$y$ [kpc]") # doctest: +SKIP + fig.tight_layout() + + +Rotations +========= + +Rotations can be specified either by passing in a +`scipy.spatial.transform.Rotation` instance, or by passing in a 2D `numpy` array +specifying a rotation matrix. For example, let's see what happens if we rotate a +bar potential using these two possible inputs. First, we'll define a rotation +matrix specifying a 30 degree rotation around the z axis (i.e. +counter-clockwise) using `astropy.coordinates.matrix_utilities.rotation_matrix`. +Next, we'll define a rotation using a `scipy` +`~scipy.spatial.transform.Rotation` object:: + + >>> from astropy.coordinates.matrix_utilities import rotation_matrix + >>> from scipy.spatial.transform import Rotation + >>> R_arr = rotation_matrix(30*u.deg, 'z') + >>> R_scipy = Rotation.from_euler('z', 30, degrees=True) + +.. warning:: + + Note that astropy and scipy have different rotation conventions, so even + though both of the above look like identical 30 degree rotations around the + z axis, they result in different (i.e. transposed or inverse) rotation + matrices:: + + >>> R_arr # doctest: +FLOAT_CMP + array([[ 0.8660254, 0.5 , 0. ], + [-0.5 , 0.8660254, 0. ], + [ 0. , 0. , 1. ]]) + >>> R_scipy.as_matrix() + array([[ 0.8660254, -0.5 , 0. ], + [ 0.5 , 0.8660254, 0. ], + [ 0. , 0. , 1. ]]) + +Let's see what happens to the bar potential when we specify these rotations:: + + >>> bar1 = gp.LongMuraliBarPotential(m=1e10, a=3.5, b=0.5, c=0.5, + ... units=galactic) + >>> bar2 = gp.LongMuraliBarPotential(m=1e10, a=3.5, b=0.5, c=0.5, + ... units=galactic, R=R_arr) + >>> bar3 = gp.LongMuraliBarPotential(m=1e10, a=3.5, b=0.5, c=0.5, + ... units=galactic, R=R_scipy) + +.. plot:: + :align: center + :context: close-figs + + from astropy.coordinates.matrix_utilities import rotation_matrix + from scipy.spatial.transform import Rotation + R_arr = rotation_matrix(30*u.deg, 'z') + R_scipy = Rotation.from_euler('z', 30, degrees=True) + + fig, axes = plt.subplots(1, 3, figsize=(15, 5), sharex=True, sharey=True) + + grid = np.linspace(-5, 5, 100) + + bar1 = gp.LongMuraliBarPotential(m=1e10, a=3.5, b=0.5, c=0.5, + units=galactic) + bar2 = gp.LongMuraliBarPotential(m=1e10, a=3.5, b=0.5, c=0.5, + units=galactic, R=R_arr) + bar3 = gp.LongMuraliBarPotential(m=1e10, a=3.5, b=0.5, c=0.5, + units=galactic, R=R_scipy) + + bar1.plot_contours(grid=(grid, grid, 0.), ax=axes[0]) + bar2.plot_contours(grid=(grid, grid, 0.), ax=axes[1]) + bar3.plot_contours(grid=(grid, grid, 0.), ax=axes[2]) + + axes[0].set_xlabel("$x$ [kpc]") # doctest: +SKIP + axes[0].set_ylabel("$y$ [kpc]") # doctest: +SKIP + axes[1].set_xlabel("$x$ [kpc]") # doctest: +SKIP + axes[2].set_xlabel("$x$ [kpc]") # doctest: +SKIP + + fig.tight_layout() diff --git a/gala/source/docs/potential/scf-examples.rst b/gala/source/docs/potential/scf-examples.rst new file mode 100644 index 0000000000000000000000000000000000000000..863c9ae9e9f52c210be91d4af9b41fcac527ec6e --- /dev/null +++ b/gala/source/docs/potential/scf-examples.rst @@ -0,0 +1,504 @@ +******** +Examples +******** + +For the examples below the following imports have already been executed:: + + import astropy.units as u + import matplotlib as mpl + import matplotlib.pyplot as plt + import numpy as np + from gala.potential import scf + +.. _coeff-particle: + +Computing expansion coefficients from particle positions +-------------------------------------------------------- + +To compute expansion coefficients for a distribution of particles or discrete +samples from a density distribution, use +`~gala.potential.scf.compute_coeffs_discrete`. In this example, we will generate +particle positions from a Plummer density profile, compute the expansion +coefficients assuming spherical symmetry, then re-compute the expansion +coefficients and variances (Weinberg 1996; [W96]_) allowing for non-spherical +terms (e.g., :math:`l,m>0`). + +We'll start by generating samples from a Plummer sphere (see Section 3 of +[HMV11]_ for more details). To do this, we will use inverse transform sampling +by inverting the cumulative mass function (in this case, the mass enclosed): + +.. math:: + + \rho(r) &= \frac{M}{\frac{4}{3}\pi a^3} \, \left(1 + \frac{r^2}{a^2}\right)^{-5/2} + + m(0`. We'll then plot the magnitude of the coefficients +as a function of :math:`n` (but we'll ignore the sine terms, :math:`T_{nlm}` for +this example):: + + mass = np.ones(n_samples) / n_samples + S,T = scf.compute_coeffs_discrete(xyz, mass=mass, nmax=16, lmax=0, r_s=1.) + + plt.semilogy(np.abs(S[:,0,0]), marker=None, lw=2) + plt.xlabel("$n$") + plt.ylabel("$S_{n00}$") + plt.tight_layout() + +.. plot:: + :align: center + :context: close-figs + + from gala.potential import scf + + mass = np.ones(n_samples) / n_samples + S,T = scf.compute_coeffs_discrete(xyz, mass=mass, nmax=20, lmax=0, r_s=1.) + + plt.figure(figsize=(6,4)) + plt.semilogy(np.abs(S[:,0,0]), marker=None, lw=2) + plt.xlabel("$n$") + plt.ylabel("$S_{n00}$") + plt.tight_layout() + +In addition to computing the coefficient values, we can also compute the +variances of the coefficients. Here we will relax the assumption about spherical +symmetry by setting :math:`l_{\rm max}=4`. By computing the variance of each +coefficient, we can estimate the signal-to-noise ratio of each expansion term +and use this to help decide when to truncate the expansion (see [W96]_ for the +methodology and reasoning behind this):: + + S, T, Cov = scf.compute_coeffs_discrete( + xyz, mass=mass, r_s=1., + nmax=10, lmax=4, skip_m=True, + compute_var=True + ) + + signal_to_noise = np.sqrt(S**2 / Cov[0, 0]) + + for l in range(S.shape[1]): + plt.semilogy(signal_to_noise[:,l,0], marker=None, lw=2, + alpha=0.5, label='l={}'.format(l)) + + plt.axhline(1., linestyle='dashed') + plt.xlabel("$n$") + plt.ylabel("$S/N$") + plt.legend() + +.. plot:: + :align: center + :context: close-figs + + S, T, Cov = scf.compute_coeffs_discrete( + xyz, mass=mass, r_s=1., + nmax=10, lmax=4, skip_m=True, + compute_var=True + ) + + signal_to_noise = np.sqrt(S**2 / Cov[0, 0]) + + plt.figure(figsize=(6,4)) + for l in range(S.shape[1]): + plt.semilogy(signal_to_noise[:,l,0], marker=None, lw=2, + alpha=0.5, label='l={}'.format(l)) + plt.axhline(1., linestyle='dashed') + plt.xlabel("$n$") + plt.ylabel("$S/N$") + plt.legend() + plt.tight_layout() + +The horizontal line in the plot above is for a signal-to-noise ratio of 1 -- any +coefficients with a SNR near or below this line are suspect and likely just +adding noise to the expansion. Note that all of the SNR values for :math:`l > 0` +hover around 1 -- this is a good indication that we only need the :math:`l=0` +terms to accurately represent the density distribution of the particles. + +.. _coeff-analytic: + +Computing expansion coefficients for an analytic density +-------------------------------------------------------- + +To compute expansion coefficients for an analytic density profile, use +`~gala.potential.scf.compute_coeffs`. In this example, we will write a function +to evaluate an oblate density distribution and compute the expansion +coefficients. + +We'll use a flattened Hernquist profile as our density profile: + +.. math:: + + \rho(s) &= \frac{M \, a}{2\pi} \, \frac{1}{s (s+a)^3} + + s^2 &= x^2 + y^2 + \frac{z^2}{q^2} + +In code:: + + def hernquist_density(r, M, a): + return M*a / (2*np.pi) / (r*(r+a)**3) + + def flattened_hernquist_density(x, y, z, M, a, q): + s = np.sqrt(x**2 + y**2 + (z/q)**2) + return hernquist_density(s, M, a) + +The function to evaluate the density must take at least 3 arguments: the +cartesian coordinates ``x``, ``y``, ``z``. + +We'll again set :math:`M=a=1` and we'll use a flattening :math:`q=0.8`. Let's +visualize this by plotting isodensity contours in the :math:`x`-:math:`z` plane: + +.. plot:: + :align: center + :context: reset + + import astropy.units as u + import matplotlib.pyplot as plt + import matplotlib as mpl + from matplotlib import ticker + import numpy as np + from gala.potential import scf + + def hernquist_density(r, M, a): + return M*a / (2*np.pi) / (r*(r+a)**3) + + def flattened_hernquist_density(x, y, z, M, a, q): + s = np.sqrt(x**2 + y**2 + (z/q)**2) + return hernquist_density(s, M, a) + + M = 1. + a = 1. + q = 0.8 + + x,z = np.meshgrid(np.linspace(-10., 10., 128), + np.linspace(-10., 10., 128)) + y = np.zeros_like(x) + + dens = flattened_hernquist_density(x, y, z, M, a, q) + + plt.figure(figsize=(6,6)) + plt.contourf(x, z, dens, cmap='magma', + levels=np.logspace(np.log10(dens.min()), np.log10(dens.max()), 32), + locator=ticker.LogLocator()) + plt.title("Isodensity") + plt.xlabel("$x$", fontsize=22) + plt.ylabel("$z$", fontsize=22) + plt.tight_layout() + +To compute the expansion coefficients, we pass the +``flattened_hernquist_density()`` function in to +`~gala.potential.scf.compute_coeffs`. Because this is an axisymmetric density, +we will ignore terms with :math:`m>0` by setting ``skip_m=True``:: + + M = 1. + a = 1. + q = 0.8 + coeff = scf.compute_coeffs(flattened_hernquist_density, nmax=8, lmax=8, + M=M, r_s=a, args=(M,a,q), skip_m=True) + (S,Serr),(T,Terr) = coeff + +Computing the coefficients involves a numerical integration that uses +`scipy.integrate.quad`, which simultaneously estimates the error in the computed +integral. `~gala.potential.scf.compute_coeffs` returns the coefficient arrays +and these error estimates. + +Now that we have the coefficients in hand, we can visualize their magnitudes:: + + plt.figure(figsize=(6,4)) + plt.semilogy(np.abs(S[:,0,0]), marker=None, lw=2) + plt.xlabel("$n$") + plt.ylabel("$S_{n00}$") + +.. plot:: + :align: center + :context: close-figs + + nmax = 8 + lmax = 8 + coeff = scf.compute_coeffs(flattened_hernquist_density, nmax=nmax, lmax=lmax, + M=M, r_s=a, args=(M,a,q), skip_m=True) + (S,Serr),(T,Terr) = coeff + + plt.figure(figsize=(6,4)) + plt.semilogy(np.abs(S[:,0,0]), marker=None, lw=2) + plt.xlabel("$n$") + plt.ylabel("$S_{n00}$") + plt.tight_layout() + +Because we ignored any :math:`m` terms, the coefficients are computed in a 2D +grid in :math:`n,l`: we can visualize their magnitude by coloring points on such +a grid:: + + nl_grid = np.mgrid[0:lmax+1, 0:nmax+1] + + plt.figure(figsize=(5,4)) + plt.scatter(nl_grid[0].ravel(), nl_grid[1].ravel(), + c=np.abs(S[:,:,0].ravel()), norm=mpl.colors.LogNorm(), + cmap='viridis', s=80) + plt.xlabel('$n$') + plt.ylabel('$l$') + plt.colorbar() + +.. plot:: + :align: center + :context: close-figs + + nl_grid = np.mgrid[0:lmax+1, 0:nmax+1] + + plt.figure(figsize=(5,4)) + plt.scatter(nl_grid[0].ravel(), nl_grid[1].ravel(), + c=np.abs(S[:,:,0].ravel()), norm=mpl.colors.LogNorm(), + cmap='viridis', s=80) + plt.xlabel('$n$') + plt.ylabel('$l$') + plt.colorbar() + plt.tight_layout() + +.. _potential-class: + +Using `~gala.potential.scf.SCFPotential` to evaluate the density, potential, gradient +------------------------------------------------------------------------------------- + +In this example we'll continue where the :ref:`previous example +` left off: we now have computed expansion coefficients for a +given density function and we would like to evaluate the gradient of the +gravitational potential at various locations. We will use `gala` to integrate +an orbit in the expansion potential. + +From the previous example, we have a set of cosine and sine coefficients (``S`` +and ``T``) for an SCF representation of a flattened (oblate) Hernquist density +profile. First, we'll create an `~gala.potential.scf.SCFPotential` object using +these coefficients:: + + potential = scf.SCFPotential(Snlm=S, Tnlm=T, m=M, r_s=a) # M=a=1 + +Let's compare how our expansion density to the true density by +recreating the above isodensity contour figure with SCF density contours +overlaid:: + + x,z = np.meshgrid(np.linspace(-10., 10., 128), + np.linspace(-10., 10., 128)) + y = np.zeros_like(x) + true_dens = flattened_hernquist_density(x, y, z, M, a, q) + + # we need an array of positions with shape (3,n_samples) for SCFPotential + xyz = np.vstack((x.ravel(),y.ravel(),z.ravel())) + scf_dens = potential.density(xyz).value + + # log-spaced contour levels + levels = np.logspace(np.log10(true_dens.min()), np.log10(true_dens.max()), 16) + + plt.figure(figsize=(6,6)) + + plt.contourf(x, z, true_dens, cmap='magma', + levels=levels, locator=ticker.LogLocator()) + plt.contour(x, z, scf_dens.reshape(x.shape), colors='w', + levels=levels, locator=ticker.LogLocator()) + + plt.title("Isodensity") + plt.xlabel("$x$", fontsize=22) + plt.ylabel("$z$", fontsize=22) + +.. plot:: + :align: center + :context: close-figs + + potential = scf.SCFPotential(Snlm=S, Tnlm=T, m=M, r_s=a) # M=a=1 + + # we need an array of positions with shape (3,n_samples) for SCFPotential + xyz = np.vstack((x.ravel(),y.ravel(),z.ravel())) + scf_dens = potential.density(xyz).value + + # log-spaced contour levels + true_dens = flattened_hernquist_density(x, y, z, M, a, q) + levels = np.logspace(np.log10(true_dens.min()), np.log10(true_dens.max()), 16) + + plt.figure(figsize=(6,6)) + + plt.contourf(x, z, true_dens, cmap='magma', + levels=levels, locator=ticker.LogLocator()) + plt.contour(x, z, scf_dens.reshape(x.shape), colors='w', + levels=levels, locator=ticker.LogLocator()) + + plt.title("Isodensity") + plt.xlabel("$x$", fontsize=22) + plt.ylabel("$z$", fontsize=22) + plt.tight_layout() + +By eye, the SCF representation looks pretty good. Let's now create a plot of +equipotential contours using the `~gala.potential.scf.SCFPotential` instance:: + + scf_pot = np.abs(potential.energy(xyz)) + scf_pot = scf_pot.value # get numerical value from `~astropy.units.Quantity` + + # log-spaced contour levels + levels = np.logspace(np.log10(scf_pot.min()), np.log10(scf_pot.max()), 16) + + plt.figure(figsize=(6,6)) + + plt.contour(x, z, scf_pot.reshape(x.shape), cmap='inferno_r', + levels=levels, locator=ticker.LogLocator()) + + plt.title("Equipotential") + plt.xlabel("$x$", fontsize=22) + plt.ylabel("$z$", fontsize=22) + +.. plot:: + :align: center + :context: close-figs + + scf_pot = np.abs(potential.energy(xyz)) + scf_pot = scf_pot.value # get numerical value from Astropy Quantity + + # log-spaced contour levels + levels = np.logspace(np.log10(scf_pot.min()), np.log10(scf_pot.max()), 16) + + plt.figure(figsize=(6,6)) + + plt.contour(x, z, scf_pot.reshape(x.shape), cmap='inferno_r', + levels=levels, locator=ticker.LogLocator()) + + plt.title("Equipotential") + plt.xlabel("$x$", fontsize=22) + plt.ylabel("$z$", fontsize=22) + plt.tight_layout() + +(the above is actually provided as a convenience method of any +`~gala.potential.PotentialBase` subclass -- see +`~gala.potential.PotentialBase.plot_contours`). + +Now let's integrate an orbit in this potential. We'll use the orbit integration +framework from `gala.integrate` and the convenience method +`~gala.potential.scf.SCFPotential.integrate_orbit` to do this:: + + import gala.dynamics as gd + + # when using dimensionless units, we don't need to specify units for the + # initial conditions + w0 = gd.PhaseSpacePosition(pos=[1.,0,0.25], + vel=[0.,0.3,0.]) + + # by default this uses Leapfrog integration + orbit = potential.integrate_orbit(w0, dt=0.1, n_steps=10000) + + fig = orbit_l.plot(marker=',', linestyle='none', alpha=0.5) + +.. plot:: + :align: center + :context: close-figs + + import gala.dynamics as gd + + # when using dimensionless units, we don't need to specify units for the + # initial conditions + w0 = gd.PhaseSpacePosition(pos=[1.,0,0.25], + vel=[0.,0.3,0.]) + + # by default this uses Leapfrog integration + orbit = potential.integrate_orbit(w0, dt=0.1, n_steps=10000) + + fig = orbit.plot(marker=',', linestyle='none', alpha=0.5) + +References +---------- +.. [W96] http://dx.doi.org/10.1086/177902 +.. [HMV11] http://www.artcompsci.org/kali/vol/plummer/volume11.pdf diff --git a/gala/source/docs/potential/scf.rst b/gala/source/docs/potential/scf.rst new file mode 100644 index 0000000000000000000000000000000000000000..18b2269eabe5898f8fdb77388e3578e9a634a6f8 --- /dev/null +++ b/gala/source/docs/potential/scf.rst @@ -0,0 +1,64 @@ +.. _scf: + +Self-consistent field (SCF) +=========================== + +``gala.scf`` contains utilities for evaluating basis function expansions of mass +densities and gravitational potentials with the Self-Consistent Field (SCF) +method of Hernquist & Ostriker (1992; [HO92]_). SCF uses Hernquist radial +functions and spherical harmonics for angular functions. This implementation is +based on the formalism described in the original paper but using the notation of +Lowing et al. (2011; [L11]_). + +.. raw:: html + + + + +Introduction +------------ + +The two main ways to use `gala.potential.scf` are: + +#. to compute the expansion coefficients given a continuous density distribution + or discrete samples from a density distribution, then +#. to evaluate the density, potential, and gradients of a basis function + expansion representation of a density distribution given this set of + coefficients. + + +To compute expansion coefficients, the relevant functions are +`~gala.potential.scf.compute_coeffs` and +`~gala.potential.scf.compute_coeffs_discrete`. This implementation uses the +notation from [L11]_: all expansion coefficients are real, :math:`S_{nlm}` are +the cosine coefficients, and :math:`T_{nlm}` are the sine coefficients. + +Once you have coefficients, there are two ways to evaluate properties of the +potential or the density of the expansion representation. `gala` provides a +class-based interface :class:`~gala.potential.scf.SCFPotential` that utilizes +the gravitational potential machinery implemented in `gala.potential` (and +supports all of the standard potential functionality, such as orbit integration +and plotting). The examples below use this interface. + +Examples +-------- +- :ref:`coeff-particle` +- :ref:`coeff-analytic` +- :ref:`potential-class` + +.. toctree:: + :hidden: + + scf-examples + +API +--- + +.. automodapi:: gala.potential.scf + + +---------- +References +---------- +.. [HO92] http://dx.doi.org/10.1086/171025 +.. [L11] http://dx.doi.org/10.1111/j.1365-2966.2011.19222.x diff --git a/gala/source/docs/potential/spherical-spline.rst b/gala/source/docs/potential/spherical-spline.rst new file mode 100644 index 0000000000000000000000000000000000000000..f04dadfdd03efe7e7842aca18602135bc8609fcc --- /dev/null +++ b/gala/source/docs/potential/spherical-spline.rst @@ -0,0 +1,249 @@ +Spherical spline–interpolated potentials +======================================== + +.. _spherical-spline: + +The :class:`~gala.potential.potential.SphericalSplinePotential` class provides a +flexible, spherically-symmetric potential model constructed from a 1D radial spline. +Instead of hard-coding an analytic profile, the user supplies values of either potential +energy, density, or enclosed mass on a set of radial knots. + +This implementation requires GSL (the GNU Scientific Library) to be available at +build/runtime. If Gala was built without GSL support, this class will not be usable. + +Notes +~~~~~ + +- This potential uses a single class front-end that supports three input "value types" + + - density: The potential energy, mass enclosed, and gradients are computed by + integrating the density. + - mass (i.e. enclosed mass): The potential energy is computed by + numerical integration and the density is obtained directly with derivatives of the + splines. + - potential: The enclosed mass, gradient, and density functions are obtained + directly from spline interpolation. +- Supports all `GSL interpolation methods `_: + "linear", "polynomial", "cspline" (default), "cspline_periodic", "akima", + "akima_periodic", and "steffen". However note that some methods do not have smooth derivatives or second derivatives. + +The implementation uses GSL spline routines to evaluate first and second derivatives +where appropriate. For the supported interpolation methods, GSL provides finite +derivatives: + +- Linear: piecewise-constant derivative (discontinuous at knots). +- Cubic splines (cspline / cspline_periodic): continuous first and second + derivatives. +- Akima / akima_periodic: smooth first derivative designed to reduce + overshoots. +- Steffen: monotonic spline with continuous derivatives. +- Polynomial: smooth derivatives within the domain. + +Recommendations +--------------- + +1. If smoothness is important, use ``cspline``. Cubic splines provide continuous + first and second derivatives, which generally yield physically smoother densities + when deriving them from potentials. +2. Be mindful of endpoint behavior (inherited from GSL). The cubic spline implementation + used here attempts to make the second derivative go to zero at the boundaries (i.e. + for the end knots). This can introduce edge effects. A simple mitigation is to place + knots beyond the radial range where you care about the physical model (i.e., make the + knot grid slightly larger than the region you will evaluate). This reduces the + influence of the boundary conditions in the region of interest. +3. Use an appropriately dense knot grid in regions where the profile has high curvature + (e.g., sharp features). Akima or steffen can be useful for reducing overshoot with + sparse data, but these methods may produce less well-behaved higher derivatives. +4. Validate by comparing diagnostics (e.g., the enclosed mass computed from the density + vs the supplied mass profile) and by visual checks of the recovered density/gradients + when you change interpolation method. +5. For orbit modeling, it is generally better to supply mass profiles when possible + (``spline_value_type='mass'``) because dPhi/dr follows directly from M(r) and is less + sensitive to high-frequency numerical noise in derivatives. + + +Examples +-------- + +Example 1: Create and evaluate a spline potential +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Create a mass-based spherical spline potential and evaluate it: + +.. plot:: + :align: center + :context: close-figs + + import numpy as np + import astropy.units as u + import matplotlib.pyplot as plt + from gala.units import galactic + from gala.potential import SphericalSplinePotential + + # radial knots and enclosed mass profile (example) + r_knots = np.logspace(-1, 2, 50) * u.kpc + + # Example mass profile (toy) + M_r = (1e12 * u.Msun) * (r_knots / (r_knots + 10*u.kpc))**2 + + pot = SphericalSplinePotential( + r_knots=r_knots, + spline_values=M_r, + spline_value_type="mass", + interpolation_method="cspline", + units=galactic + ) + + # Evaluate at a set of radii using the r= symmetry coordinate + r_eval = np.logspace(-1, 2, 200) * u.kpc + + phi = pot.energy(r=r_eval) + dens = pot.density(r=r_eval) + + fig, axes = plt.subplots(2, 1, sharex=True, figsize=(6, 6), layout="tight") + ax1 = axes[0] + ax1.semilogx(r_eval, phi) + ax1.set_ylabel(rf"$\Phi$ [{phi.unit:latex_inline}]") + + ax2 = axes[1] + ax2.loglog(r_eval, dens.to(u.Msun / u.kpc**3)) + ax2.set_ylabel(r"$\rho(r)$ [$M_\odot\,\mathrm{kpc}^{-3}$]") + ax2.set_xlabel("$r$ [kpc]") + + +Example 2: Make a SphericalSplinePotential from a density function +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following example shows a more involved workflow: define a complex +analytic density profile, evaluate it on a fine radial grid, build a +``SphericalSplinePotential`` with ``spline_value_type='density'``, and plot the +resulting potential and recovered density. This is useful for quick visual +experiments and for creating documentable figures in the Sphinx docs (via the +matplotlib plot directive). + +.. plot:: + :align: center + :context: close-figs + + import numpy as np + import astropy.units as u + import matplotlib.pyplot as plt + from gala.units import galactic + from gala.potential import SphericalSplinePotential + + def rho_analytic(r): + r = np.array(r) + rho0 = 1e9 # Msun / kpc^3 + return ( + rho0 / r ** 1.35 / (1 + r)**3.44 + ) + + + # radial knots where we build the spline (note we extend beyond the region of interest) + r_knots = ( + np.concatenate([np.logspace(-2, -0.5, 10), np.logspace(-0.5, 2.5, 100)[1:]]) * u.kpc + ) + rho_vals = rho_analytic(r_knots.value) * u.Msun / u.kpc**3 + + pot = SphericalSplinePotential( + r_knots=r_knots, + spline_values=rho_vals, + spline_value_type="density", + interpolation_method="cspline", + units=galactic, + ) + + r_eval = np.logspace(-2, 2.3, 300) * u.kpc + pos = ( + np.stack( + [r_eval.value, np.zeros_like(r_eval.value), np.zeros_like(r_eval.value)], axis=0 + ) + * r_eval.unit + ) + + phi = pot.energy(pos) + dens_recovered = pot.density(pos) + + fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True, figsize=(6, 6), layout="tight") + ax1.semilogx(r_eval, phi) + ax1.set_ylabel(rf"$\Phi$ [{phi.unit:latex_inline}]") + ax2.loglog(r_eval, dens_recovered.to(u.Msun / u.kpc**3), label="Recovered density") + ax2.loglog(r_knots, rho_vals.to(u.Msun / u.kpc**3), "o", ms=3, label="Input knots") + ax2.set_xlabel("$r$ [kpc]") + ax2.set_ylabel(r"$\rho$ [$M_\odot\,\mathrm{kpc}^{-3}$]") + ax2.legend() + + +Example 3: Effect of interpolation method (Akima vs cspline) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This example constructs a potential by directly interpolating a potential-valued spline +(``spline_value_type='potential'``). We then compare the density inferred from the +potential using two different interpolation methods. Akima-style splines can produce +piecewise-smooth first derivatives that sometimes appear 'jagged' in the second +derivative (which is used to compute density), whereas cspline (cubic spline) tends to +produce continuous second derivatives. + +.. plot:: + :align: center + :context: close-figs + + import numpy as np + import astropy.units as u + import matplotlib.pyplot as plt + from gala.units import galactic + from gala.potential import SphericalSplinePotential + + # Make an example smooth potential (toy) + r_knots = np.logspace(-1, 2, 40) * u.kpc + phi_smooth = ( + -1e5 * (1.0 / (1.0 + (r_knots.to(u.kpc).value / 10.0) ** 2)) * u.km**2 / u.s**2 + ) + + # cspline (smooth second derivative) + pot_cspline = SphericalSplinePotential( + r_knots=r_knots, + spline_values=phi_smooth, + spline_value_type="potential", + interpolation_method="cspline", + units=galactic, + ) + + # akima (can have less-smooth second derivative) + pot_akima = SphericalSplinePotential( + r_knots=r_knots, + spline_values=phi_smooth, + spline_value_type="potential", + interpolation_method="akima", + units=galactic, + ) + + r_eval = np.logspace(-1, 2, 400) * u.kpc + pos = ( + np.stack( + [r_eval.value, np.zeros_like(r_eval.value), np.zeros_like(r_eval.value)], axis=0 + ) + * r_eval.unit + ) + + rho_cs = pot_cspline.density(pos) + rho_ak = pot_akima.density(pos) + + plt.figure(figsize=(6, 4)) + plt.loglog(r_eval, rho_cs.to(u.Msun / u.kpc**3), label="cspline (smooth)") + plt.loglog( + r_eval, rho_ak.to(u.Msun / u.kpc**3), label="akima (can appear jagged)", alpha=0.8 + ) + plt.scatter( + r_knots, np.zeros_like(r_knots.value), marker="|", color="k", s=40, label="knots" + ) + plt.xlabel("$r$ [kpc]") + plt.ylabel(r"$\rho$ [$M_\odot\,\mathrm{kpc}^{-3}$]") + plt.legend() + plt.tight_layout() + + +API +--- + +See: `~gala.potential.potential.SphericalSplinePotential` diff --git a/gala/source/docs/potential/symmetry-coordinates.rst b/gala/source/docs/potential/symmetry-coordinates.rst new file mode 100644 index 0000000000000000000000000000000000000000..3db938d7d3e27f73e578705b390d167c0fe6b5b5 --- /dev/null +++ b/gala/source/docs/potential/symmetry-coordinates.rst @@ -0,0 +1,254 @@ +.. _potential-symmetry-coordinates: + +************************************* +Using Symmetry Coordinates +************************************* + +.. currentmodule:: gala.potential + +Many gravitational potentials have symmetries that make certain coordinate systems more +natural than Cartesian coordinates. For example, spherically-symmetric potentials only +depend on the radius :math:`r`, and axisymmetric potentials only depend on the +cylindrical radius :math:`R` and height :math:`z`. + +For potentials with these symmetries, you can use **symmetry coordinates** as a +shorthand instead of full 3D Cartesian coordinates. Internally, gala operates in +Cartesian coordinates, so the symmetry coordinates are simply a front-end convenience. + +Spherical Potentials +===================== + +For spherically-symmetric potentials, you can use the ``r=`` keyword argument to pass +just the spherical radius instead of full 3D Cartesian coordinates in many methods of +the potential classes. + +Supported Spherical Potentials +------------------------------- + +Any of the spherical potential models support using the ``r=...`` shorthand, including: + +- :class:`~gala.potential.KeplerPotential` +- :class:`~gala.potential.HernquistPotential` +- :class:`~gala.potential.PlummerPotential` +- :class:`~gala.potential.SphericalSplinePotential` +- :class:`~gala.potential.NFWPotential` (when spherical: ``a=b=c=1``) + +and more. + +Examples +-------- + +Basic usage with a scalar radius value:: + + >>> import astropy.units as u + >>> import numpy as np + >>> import gala.potential as gp + >>> from gala.units import galactic + >>> pot = gp.HernquistPotential(m=1e10 * u.Msun, c=5 * u.kpc, units=galactic) + >>> pot.energy(r=10 * u.kpc) + + +Arrays of radii:: + + >>> r = [1.0, 5.0, 10.0, 50.0] * u.kpc + >>> pot.energy(r=r) + + +All potential methods support symmetry coordinates:: + + >>> pot.gradient(r=r) # Returns gradient in Cartesian coords # doctest: +SKIP + >>> pot.density(r=r) # doctest: +SKIP + >>> pot.acceleration(r=r) # doctest: +SKIP + >>> pot.mass_enclosed(r=r) # doctest: +SKIP + >>> pot.circular_velocity(r=r) # doctest: +SKIP + +Computing a mass profile:: + + >>> r_profile = np.logspace(-1, 2, 100) * u.kpc + >>> pot.mass_enclosed(r=r_profile) + + + +This is much cleaner than the traditional approach:: + + >>> # Explicit / old way with Cartesian arrays (still works!) + >>> pos = np.zeros((3, 100)) * u.kpc + >>> pos[0] = r_profile + >>> m_profile = pot.mass_enclosed(pos) + + +Cylindrical (Axisymmetric) Potentials +====================================== + +For axisymmetric potentials, you can use ``R=`` and ``z=`` keyword arguments +to specify cylindrical coordinates in many methods on potential instances. + +Supported Cylindrical Potentials +--------------------------------- + +The following built-in potentials support cylindrical symmetry coordinates: + +- :class:`~gala.potential.MiyamotoNagaiPotential` +- :class:`~gala.potential.MN3ExponentialDiskPotential` +- :class:`~gala.potential.CylSplinePotential` + +and others. + + +Examples +-------- + +Basic usage with both R and z:: + + >>> pot = gp.MiyamotoNagaiPotential(m=1e11 * u.Msun, a=3 * u.kpc, b=0.3 * u.kpc, units=galactic) + >>> pot.energy(R=8 * u.kpc, z=0.5 * u.kpc) + + +Arrays of coordinates:: + + >>> R = np.linspace(4, 12, 32) * u.kpc + >>> z = np.linspace(-1, 1, 32) * u.kpc + >>> pot.energy(R=R, z=z) + + +The ``z`` coordinate defaults to zero, making midplane calculations particularly +convenient:: + + >>> # Evaluate in the midplane (z=0) + >>> E = pot.energy(R=R) + >>> pot.circular_velocity(R=R) # assumes z=0 + + + + +Composite Potentials +==================== + +:class:`~gala.potential.CompositePotential` objects automatically inherit symmetry from +their components based on some simple rules: + +Symmetry Inheritance Rules: + +- **All components spherical**: composite is spherical (can use ``r=``) +- **All components cylindrical**: composite is cylindrical (can use ``R=``, ``z=``) +- **Mix of spherical and cylindrical**: composite is cylindrical +- **Any component without a simple symmetry**: composite has no symmetry (must use Cartesian) + +Examples +-------- + +A fully spherical system (bulge + halo):: + + >>> bulge = gp.HernquistPotential(m=1e10 * u.Msun, c=0.6 * u.kpc, units=galactic) + >>> halo = gp.NFWPotential(m=1e12 * u.Msun, r_s=20 * u.kpc, units=galactic) + >>> pot = gp.CompositePotential(bulge=bulge, halo=halo) + >>> # Both spherical, composite is spherical + >>> E = pot.energy(r=10 * u.kpc) + >>> vc = pot.circular_velocity(r=np.linspace(1, 50, 100) * u.kpc) + +A simple galaxy model (bulge + disk + halo):: + + >>> bulge = gp.HernquistPotential(m=2e10 * u.Msun, c=0.6 * u.kpc, units=galactic) + >>> disk = gp.MiyamotoNagaiPotential(m=1e11 * u.Msun, a=3 * u.kpc, b=0.3 * u.kpc, units=galactic) + >>> halo = gp.NFWPotential(m=1e12 * u.Msun, r_s=20 * u.kpc, units=galactic) + >>> galaxy = gp.CompositePotential(bulge=bulge, disk=disk, halo=halo) + >>> # Mix of spherical and cylindrical (disk), so composite is cylindrical + >>> E = galaxy.energy(R=8 * u.kpc) # Midplane energy + >>> E = galaxy.energy(R=8 * u.kpc, z=0.5 * u.kpc) # Off midplane + +Computing a rotation curve for the galaxy model:: + + >>> R = np.linspace(0.1, 20, 200) * u.kpc + >>> v_circ = galaxy.circular_velocity(R=R) + +Note that once you add a cylindrical component to spherical components, you can no +longer use ``r=`` (spherical) coordinates - you must use ``R=, z=`` (cylindrical):: + + >>> # This would raise an error: + >>> galaxy.energy(r=10 * u.kpc) # doctest: +SKIP + ValueError: Invalid coordinate(s) for CylindricalSymmetry: {'r'} + + +Notes +===== + +Return Coordinates +------------------ + +**Gradients and accelerations are always returned in Cartesian coordinates**, +even when using symmetry coordinate inputs. This design choice ensures: + +For example:: + + >>> grad = pot.gradient(r=10 * u.kpc) + >>> assert grad.shape == (3, 1) + >>> # Returns shape (3, 1) array: [dx, dy, dz] in Cartesian coords + +Coordinate Validation +--------------------- + +Symmetry coordinates are validated to ensure they match the potential's symmetry. Trying +to use the wrong coordinates will raise an error:: + + >>> pot = gp.HernquistPotential(m=1e10 * u.Msun, c=5 * u.kpc, units=galactic) + >>> pot.energy(R=10 * u.kpc) # doctest: +SKIP + ValueError: This potential has spherical symmetry and expects coordinate 'r', + but you provided: {'R'} + +You also cannot mix Cartesian and symmetry coordinates:: + + >>> pos = [10, 0, 0] * u.kpc + >>> pot.energy(pos, r=10 * u.kpc) # doctest: +SKIP + ValueError: Cannot specify both position `q` and symmetry coordinates + +Keyword-Only Arguments +---------------------- + +Symmetry coordinates must be passed as keyword arguments, not positional +arguments. This makes the API clear and prevents ambiguity:: + + >>> # Correct + >>> pot.energy(r=10 * u.kpc) # doctest: +SKIP + + >>> # Wrong - this passes a Cartesian position + >>> pot.energy(10 * u.kpc) # doctest: +SKIP + +Implementing Custom Potentials with Symmetry +============================================= + +If you're creating a custom potential class and want to add symmetry coordinate +support, set the ``_symmetry`` class attribute: + +For a spherical potential:: + + from gala.potential import PotentialBase + from gala.potential.potential.symmetry import SphericalSymmetry + + class MySphericalPotential(PotentialBase): + _symmetry = SphericalSymmetry() + + # ... rest of implementation + +For a cylindrical potential:: + + from gala.potential.potential.symmetry import CylindricalSymmetry + + class MyCylindricalPotential(PotentialBase): + _symmetry = CylindricalSymmetry() + + # ... rest of implementation + +The base class will automatically handle the coordinate transformation in all +potential methods. + +See Also +======== + +- :ref:`Potential documentation ` +- :ref:`Defining custom potentials ` +- :class:`~gala.potential.potential.symmetry.PotentialSymmetry` +- :class:`~gala.potential.potential.symmetry.SphericalSymmetry` +- :class:`~gala.potential.potential.symmetry.CylindricalSymmetry` diff --git a/gala/source/docs/potential/time-interpolated.rst b/gala/source/docs/potential/time-interpolated.rst new file mode 100644 index 0000000000000000000000000000000000000000..5f6264e6239f3d4933df187bdbf362c0371e7b6b --- /dev/null +++ b/gala/source/docs/potential/time-interpolated.rst @@ -0,0 +1,290 @@ +.. include:: ../references.txt + +.. _time-interpolated-potential: + +************************************* +Time-dependent Potentials +************************************* + +Introduction +============ + +Many scenarios involve gravitational potentials that change over time, for example, a +halo with a growing mass and/or scale radius, a star cluster losing mass into a stellar +stream, or galactic bar rotating with a slowing pattern speed. + +The :class:`~gala.potential.potential.TimeInterpolatedPotential` class enables +modeling of such time-dependent potentials by wrapping any existing potential +class and interpolating its parameters, origin, and/or rotation matrices over +time using `GSL spline interpolation `_. + +.. note:: + + This feature requires Gala to be compiled with GSL support. + +Getting Started +=============== + +Use the `~gala.potential.potential.TimeInterpolatedPotential` class to wrap a potential class, and pass parameters to the wrapper either as constant values or as arrays (interpreted as the value of the parameter). For example, to specify a Hernquist potential with a time-varying mass but a constant scale radius:: + + >>> import astropy.units as u + >>> import numpy as np + >>> import gala.potential as gp + >>> from gala.units import galactic + >>> + >>> # Interpolation time knots + >>> knots = np.linspace(0, 1, 32) * u.Gyr + >>> + >>> # Mass grows exponentially over 1 Gyr + >>> mass_t = np.geomspace(1e10, 2e10, 32) * u.Msun + >>> + >>> pot = gp.TimeInterpolatedPotential( + ... gp.HernquistPotential, + ... time_knots=knots, + ... m=mass_t, + ... c=10.0 * u.kpc, # constant + ... units=galactic + ... ) + +Now we can evaluate the potential at different times:: + + >>> pos = [8., 0, 0] * u.kpc + >>> pot.energy(pos, t=0 * u.Gyr) + + >>> pot.energy(pos, t=0.5 * u.Gyr) + + >>> pot.energy(pos, t=1.0 * u.Gyr) + + +The energy increases (becomes more negative) as the mass grows, as expected. + +To reiterate, time-varying parameters are specified as arrays with length matching the +number of time knots, but you can mix constant and time-varying parameters freely. The +wrapper will automatically detect which parameters vary with time based on the array +shape. + + +Interpolation Methods +===================== + +The class supports several interpolation methods from GSL: + +- ``'linear'``: Linear interpolation (fastest, but not smooth). Requires at least 2 knots. +- ``'cspline'``: Cubic spline interpolation with natural boundary conditions (default). Smooth with continuous second derivatives. Requires at least 3 knots. +- ``'akima'``: Akima spline interpolation. Avoids overshoot in regions with rapidly changing curvature. Requires at least 5 knots. +- ``'steffen'``: Steffen's monotonic interpolation. Guarantees monotonicity between data points. Requires at least 3 knots. + +Choose the interpolation method based on your needs. If you do not have an opinion, we +recommend using the default (cubic spline) because it has continuous second +derivatives. Specify the interpolation method via the ``interpolation_method`` +argument:: + + >>> pot = gp.TimeInterpolatedPotential( + ... gp.NFWPotential, + ... time_knots=knots, + ... m=mass_t, + ... r_s=20.0 * u.kpc, + ... interpolation_method='akima', # Use Akima splines + ... units=galactic + ... ) + + +Time-dependent Origin and Rotation +================================== + +In addition to potential parameters, you can specify a time-varying origin (center +position) or rotation matrix. This is useful for modeling moving objects or tracking a +system in a non-inertial frame. + +A time-interpolated origin is specified as an array of shape ``(n_knots, 3)``:: + + >>> # Origin moves in a circle + >>> theta = np.linspace(0, 2*np.pi, len(knots)) + >>> origins = np.column_stack([ + ... 5 * np.cos(theta), + ... 5 * np.sin(theta), + ... np.zeros(len(knots)) + ... ]) * u.kpc + >>> + >>> pot = gp.TimeInterpolatedPotential( + ... gp.PlummerPotential, + ... time_knots=knots, + ... m=1e10 * u.Msun, + ... b=1.0 * u.kpc, + ... origin=origins, + ... units=galactic + ... ) + +Now the potential's center follows a circular orbit over time: + +.. plot:: + :align: center + :context: close-figs + + import astropy.units as u + import matplotlib.pyplot as plt + import numpy as np + import gala.potential as gp + from gala.units import galactic + + # Interpolation time knots + knots = np.linspace(0, 1, 32) * u.Gyr + + # Mass grows exponentially over 1 Gyr + mass_t = np.geomspace(1e10, 2e10, 32) * u.Msun + + # Origin moves in a circle + theta = np.linspace(0, 2*np.pi, len(knots)) + origins = np.column_stack([ + 5 * np.cos(theta), + 5 * np.sin(theta), + np.zeros(len(knots)) + ]) * u.kpc + pot = gp.TimeInterpolatedPotential( + gp.PlummerPotential, + time_knots=knots, + m=1e10 * u.Msun, + b=1.0 * u.kpc, + origin=origins, + units=galactic + ) + + fig, axes = plt.subplots( + 2, 2, figsize=(6, 6), sharex=True, sharey=True, layout='constrained' + ) + for i, t in enumerate(np.linspace(0, 1, 4) * u.Gyr): + pot.plot_density_contours( + grid=(np.linspace(-10, 10, 128), np.linspace(-10, 10, 128), 0.0), + t=t, + ax=axes.flat[i], + ) + axes.flat[i].set_title(f't = {t.to_value(u.Gyr):.2f} Gyr') + + +You can also specify time-varying rotation matrices by passing in an array of shape +``(n_knots, 3, 3)``. For example, a steadily rotating bar potential:: + + >>> from scipy.spatial.transform import Rotation as R + >>> + >>> # Rotate 90 degrees over 1 Gyr + >>> angles = np.linspace(0, np.pi/2, 11) + >>> rotations = np.array([ + ... R.from_rotvec([0, 0, angle]).as_matrix() + ... for angle in angles + ... ]) + >>> + >>> pot = gp.TimeInterpolatedPotential( + ... gp.LongMuraliBarPotential, + ... time_knots=times, + ... m=1e11 * u.Msun, + ... a=5.0 * u.kpc, + ... b=2.0 * u.kpc, + ... c=1.0 * u.kpc, + ... R=rotations, + ... units=galactic + ... ) + +.. plot:: + :align: center + :context: close-figs + + from scipy.spatial.transform import Rotation as R + # Rotate 90 degrees over 1 Gyr + angles = np.linspace(0, np.pi/2, len(knots)) + rotations = np.array([ + R.from_rotvec([0, 0, angle]).as_matrix() + for angle in angles + ]) + pot = gp.TimeInterpolatedPotential( + gp.LongMuraliBarPotential, + time_knots=knots, + m=1e11 * u.Msun, + a=5.0 * u.kpc, + b=2.0 * u.kpc, + c=1.0 * u.kpc, + R=rotations, + units=galactic + ) + + fig, axes = plt.subplots( + 2, 2, figsize=(6, 6), sharex=True, sharey=True, layout='constrained' + ) + for i, t in enumerate(np.linspace(0, 1, 4) * u.Gyr): + pot.plot_density_contours( + grid=(np.linspace(-10, 10, 128), np.linspace(-10, 10, 128), 0.0), + t=t, + ax=axes.flat[i], + ) + axes.flat[i].set_title(f't = {t.to_value(u.Gyr):.2f} Gyr') + + +Orbit Integration +================= + +Time-varying potentials should work seamlessly with Gala's orbit integration +functionality. Simply pass the time-dependent potential to the integration functions:: + + >>> import gala.dynamics as gd + >>> knots = np.linspace(0, 2, 21) * u.Gyr + >>> masses = np.linspace(1e12, 2e12, 21) * u.Msun + >>> pot = gp.TimeInterpolatedPotential( + ... gp.HernquistPotential, + ... time_knots=knots, + ... m=masses, + ... c=10.0 * u.kpc, + ... units=galactic + ... ) + >>> # Initial conditions + >>> w0 = gp.PhaseSpacePosition( + ... pos=[8., 0, 0] * u.kpc, + ... vel=[0, 220, 0] * u.km/u.s + ... ) + >>> orbit = gp.Hamiltonian(pot).integrate_orbit( + ... w0, dt=1*u.Myr, n_steps=2000 + ... ) + + +.. plot:: + :align: center + :context: close-figs + + import gala.dynamics as gd + + knots = np.linspace(0, 2, 21) * u.Gyr + masses = np.linspace(1e12, 4e12, 21) * u.Msun + pot = gp.TimeInterpolatedPotential( + gp.HernquistPotential, + time_knots=knots, + m=masses, + c=10.0 * u.kpc, + units=galactic + ) + w0 = gd.PhaseSpacePosition( + pos=[8., 0, 0] * u.kpc, + vel=[0, 220, 0] * u.km/u.s + ) + orbit = gp.Hamiltonian(pot).integrate_orbit( + w0, dt=1*u.Myr, n_steps=2000 + ) + fig = orbit.cylindrical.plot(["t", "rho"]) + fig.axes[0].set_ylabel("radius $R$ [kpc]") + + +Bounds and Extrapolation +========================= + +The interpolation is only valid within the range of the specified time knots. +If you evaluate the potential outside this range, it will return ``NaN``:: + + >>> times = np.linspace(0, 1, 11) * u.Gyr + >>> pot = gp.TimeInterpolatedPotential( + ... gp.KeplerPotential, + ... time_knots=times, + ... m=np.linspace(1e10, 2e10, 11) * u.Msun, + ... units=galactic + ... ) + >>> pot.energy([8., 0, 0] * u.kpc, t=2.0 * u.Gyr) + + +See also the API documentation for +:class:`~gala.potential.potential.TimeInterpolatedPotential`. diff --git a/gala/source/docs/references.txt b/gala/source/docs/references.txt new file mode 100644 index 0000000000000000000000000000000000000000..daeb016b4a8fd1d5d5d88958b6fbac20c9a9fb91 --- /dev/null +++ b/gala/source/docs/references.txt @@ -0,0 +1,8 @@ +.. _Astropy: http://astropy.org +.. _Matplotlib: http://www.matplotlib.org +.. _Numpy: http://www.numpy.org +.. _Python: http://www.python.org +.. _PyYAML: http://pyyaml.org/ +.. _Sympy: http://docs.sympy.org/ +.. _emcee: http://dan.iel.fm/emcee/current/ +.. _scipy: https://docs.scipy.org diff --git a/gala/source/docs/refs.bib b/gala/source/docs/refs.bib new file mode 100644 index 0000000000000000000000000000000000000000..d6ca487601cc0b4027591969259f27ecf5ae4fc9 --- /dev/null +++ b/gala/source/docs/refs.bib @@ -0,0 +1,10 @@ +@book{Binney2008, + title={Galactic Dynamics: Second Edition}, + author={Binney, J. and Tremaine, S.}, + isbn={9781400828722}, + lccn={2007937669}, + series={Princeton Series in Astrophysics}, + year={2008}, + publisher={Princeton University Press}, + url = {https://ui.adsabs.harvard.edu/abs/2008gady.book.....B}, +} diff --git a/gala/source/docs/supporting.rst b/gala/source/docs/supporting.rst new file mode 100644 index 0000000000000000000000000000000000000000..c22fa015332b05cfd791d66088699e9e6d215244 --- /dev/null +++ b/gala/source/docs/supporting.rst @@ -0,0 +1,14 @@ +.. _gala-supporting: + +*************** +Other Documents +*************** + +This section contains additional supporting documents for `gala`. Currently, this only +includes a tutorial-style document that explains how the Milky Way potential models +provided in `gala` were constructed and fitted to data. + +.. The _tutorials.rst file is auto-generated in conf.py. Add new tutorials to +.. the list of files in conf.py + +.. include:: _supporting.rst diff --git a/gala/source/docs/supporting/data/Eilers2019-circ-velocity.txt b/gala/source/docs/supporting/data/Eilers2019-circ-velocity.txt new file mode 100644 index 0000000000000000000000000000000000000000..544db626730a6108252345d648fac6444d039919 --- /dev/null +++ b/gala/source/docs/supporting/data/Eilers2019-circ-velocity.txt @@ -0,0 +1,39 @@ +R v_c err+ err- +5.27 226.83 1.91 1.90 +5.74 230.80 1.43 1.35 +6.23 231.20 1.70 1.10 +6.73 229.88 1.44 1.32 +7.22 229.61 1.37 1.11 +7.82 229.91 0.92 0.88 +8.19 228.86 0.80 0.67 +8.78 226.50 1.07 0.95 +9.27 226.20 0.72 0.62 +9.76 225.94 0.42 0.52 +10.26 225.68 0.44 0.40 +10.75 224.73 0.38 0.41 +11.25 224.02 0.33 0.54 +11.75 223.86 0.40 0.39 +12.25 222.23 0.51 0.37 +12.74 220.77 0.54 0.46 +13.23 220.92 0.57 0.40 +13.74 217.47 0.64 0.51 +14.24 217.31 0.77 0.66 +14.74 217.60 0.65 0.68 +15.22 217.07 1.06 0.80 +15.74 217.38 0.84 1.07 +16.24 216.14 1.20 1.48 +16.74 212.52 1.39 1.43 +17.25 216.41 1.44 1.85 +17.75 213.70 2.22 1.65 +18.24 207.89 1.76 1.88 +18.74 209.60 2.31 2.77 +19.22 206.45 2.54 2.36 +19.71 201.91 2.99 2.26 +20.27 199.84 3.15 2.89 +20.78 198.14 3.33 3.37 +21.24 195.30 5.99 6.50 +21.80 213.67 15.38 12.18 +22.14 176.97 28.58 18.57 +22.73 193.11 27.64 19.05 +23.66 176.63 18.67 16.74 +24.82 198.42 6.50 6.12 diff --git a/gala/source/docs/supporting/data/MW_mass_enclosed.csv b/gala/source/docs/supporting/data/MW_mass_enclosed.csv new file mode 100644 index 0000000000000000000000000000000000000000..40803a269e248fb059626f8f9aa073e1abfea14c --- /dev/null +++ b/gala/source/docs/supporting/data/MW_mass_enclosed.csv @@ -0,0 +1,17 @@ +r,Menc,Menc_err_neg,Menc_err_pos,ref +0.01,30000000.0,10000000.0,10000000.0,Feldmeier et al. (2014) +0.12,800000000.0,200000000.0,200000000.0,Launhardt et al. (2002) +8.1,89502860861.52429,4994562473.797714,4858963492.608627,Bovy et al. (2012) +8.3,110417867208.19055,4475949382.696884,4387023236.020782,McMillan (2011) +8.4,102421035406.90356,16733918715.629944,15468328224.531876,Koposov et al. (2010) +19.0,208023299175.30438,44317988008.38101,34833267089.920685,Kuepper et al. (2015) +50.0,539884832748.48975,19995734543.31433,268490735257.4718,Wilkinson & Evans (1999) +50.0,529886965173.18726,9997867269.659302,38536752776.21277,Sakamoto et al. (2003) +50.0,399914690706.92847,109976539940.53711,72696676468.2511,Smith et al. (2007) +50.0,419910425325.7268,39991469076.968506,38172735113.64386,Deason et al. (2012) +60.0,399914690957.5188,69985070910.63354,64344945146.92987,Xue et al. (2008) +80.0,689852841359.0248,299936018002.3314,110361048549.1029,Gnedin et al. (2010) +100.0,1399701417307.7747,899808054059.3811,831336271726.1903,Watkins et al. (2010) +120.0,539884832260.29584,199957345314.72906,123854764645.32489,Battaglia et al. (2005) +150.0,750000000000.0,250000000000.0,250000000000.0,Deason et al. (2012) +200.0,679854974257.2006,409912558030.05396,313652012195.06256,Bhattacherjee et al. (2014) diff --git a/gala/source/docs/supporting/define-milky-way-model.py b/gala/source/docs/supporting/define-milky-way-model.py new file mode 100644 index 0000000000000000000000000000000000000000..64e0987325ba59aec375daf2fa055c9633e890de --- /dev/null +++ b/gala/source/docs/supporting/define-milky-way-model.py @@ -0,0 +1,403 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.11.2 +# kernelspec: +# display_name: gala +# language: python +# name: python3 +# --- + +# %% nbsphinx="hidden" +# %matplotlib inline + +# %% nbsphinx="hidden" +# %run ../tutorials/nb_setup + +# %% [markdown] +# # Defining the MilkyWayPotential model +# +# ## Introduction +# +# `gala` provides simplified mass models for the Milky Way to use in orbit integration or dynamical calculations. Some of these mass models come from other publications or packages (e.g., the Law and Majewski 2010 model `LM10Potential`). Some of the potential models are defined and provided by Gala. This document describes how we determined the parameters of the Gala Milky Way models. +# +# We determine parameters of the Gala Milky Way models using compilations of enclosed mass measurements of the Milky Way and measurements of the mass structure of the Galactic disk. We then fit for the parameters of a multi-component model (e.g., disk, bulge, halo, etc.) using these measurements. + +# %% +import astropy.units as u +import matplotlib.pyplot as plt +import numpy as np +from astropy.constants import G +from astropy.io import ascii +from scipy.optimize import leastsq + +import gala.potential as gp +from gala.units import galactic + +# %% [markdown] +# ## `MilkyWayPotential` version 1 (circa 2017) +# +# This model was previously just known as `MilkyWayPotential` in Gala, now known as "version 1," and represents an older model based on measurements that are now out of date. We still describe the process of fitting for this model, for completeness. +# +# The source data for this model was compiled from published values and is included with Gala: + +# %% +mwdata1 = ascii.read("data/MW_mass_enclosed.csv") +mwdata1 + +# %% [markdown] +# We can now plot the above data and uncertainties: + +# %% +fig, ax = plt.subplots(1, 1, figsize=(6, 4), layout="tight") + +ax.errorbar( + mwdata1["r"], + mwdata1["Menc"], + yerr=(mwdata1["Menc_err_neg"], mwdata1["Menc_err_pos"]), + marker="o", + markersize=2, + color="k", + alpha=1.0, + ecolor="#aaaaaa", + capthick=0, + linestyle="none", + elinewidth=1.0, +) + +ax.set_xlim(1e-3, 10**2.6) +ax.set_ylim(7e6, 10**12.25) + +ax.set_xlabel("$r$ [kpc]") +ax.set_ylabel(r"$M(`_ or `pytest `_. + +To run the tests with tox, first make sure that tox is installed; + + pip install tox + +then run the basic test suite with: + + tox -e test + +or run the test suite with all optional dependencies with: + + tox -e test-alldeps + +You can see a list of available test environments with: + + tox -l -v + +which will also explain what each of them does. + +You can also run the tests directly with pytest. To do this, make sure to +install the testing requirements (from the cloned ``gala`` repository +directory):: + + pip install -e ".[test]" + +Then you can run the tests with: + + pytest gala diff --git a/gala/source/docs/tutorials.rst b/gala/source/docs/tutorials.rst new file mode 100644 index 0000000000000000000000000000000000000000..aaac554884234a6910c30a0f1f1a5d56188efe5d --- /dev/null +++ b/gala/source/docs/tutorials.rst @@ -0,0 +1,15 @@ +.. _gala-tutorials: + +********* +Tutorials +********* + +The tutorials listed below are meant to be step-by-step demonstrations of common +functionality in `gala`. If you are interested in contributing a tutorial, or +requesting a tutorial about material that is not covered here, please `open an +issue on GitHub `_. + +.. The _tutorials.rst file is auto-generated in conf.py. Add new tutorials to +.. the list of files in conf.py + +.. include:: _tutorials.rst diff --git a/gala/source/docs/tutorials/Arbitrary-density-SCF.py b/gala/source/docs/tutorials/Arbitrary-density-SCF.py new file mode 100644 index 0000000000000000000000000000000000000000..d149b6c06ea15213b86a2f43bc5581b85a116269 --- /dev/null +++ b/gala/source/docs/tutorials/Arbitrary-density-SCF.py @@ -0,0 +1,275 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: light +# format_version: '1.5' +# jupytext_version: 1.11.1 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# + nbsphinx="hidden" +# %matplotlib inline + +# + nbsphinx="hidden" +# %run nb_setup +# - + +# # Compute an SCF representation of an arbitrary density distribution +# +# Basis function expansions are a useful tool for computing gravitational +# potentials and forces from an arbitrary density function that may not have an +# analytic solution to Poisson's equation. They are also useful for generating +# smoothed or compressed representations of gravitational potentials from +# discrete particle distributions. For astronomical density distributions, a +# useful expansion technique is the Self-Consistent Field (SCF) method, as +# initially developed by [Hernquist & Ostriker +# (1992)](http://dx.doi.org/10.1086/171025). In this method, using the notation +# of [Lowing et al. 2011](http://dx.doi.org/10.1111/j.1365-2966.2011.19222.x), +# the density and potential functions are expressed as: +# +# $$ +# \rho(r, \phi, \theta) = \sum_{l=0}^{l_{\rm max}} \sum_{m=0}^{l} \sum_{n=0}^{n_{\rm max}} +# Y_{lm}(\theta) \, \rho_{nl}(r) \, \left[S_{nlm}\,\cos(m\phi) + T_{nlm}\,\sin(m\phi) \right] \\ +# \Phi(r, \phi, \theta) = \sum_{l=0}^{l_{\rm max}} \sum_{m=0}^{l} \sum_{n=0}^{n_{\rm max}} +# Y_{lm}(\theta) \, \Phi_{nl}(r) \, \left[S_{nlm}\,\cos(m\phi) + T_{nlm}\,\sin(m\phi) \right] +# $$ +# +# where $Y_{lm}(\theta)$ are the usual spherical harmonics, $\rho_{nlm}(r)$ and +# $\Phi_{nlm}(r)$ are bi-orthogonal radial basis functions, and $S_{nlm}$ and +# $T_{nlm}$ are expansion coefficients, which need to be computed from a given +# density function. In this notebook, we'll estimate low-order expansion +# coefficients for an analytic density distribution (written as a Python +# function). + +# + +# Some imports we'll need later: + + +import matplotlib.pyplot as plt +import numpy as np + +# Gala +import gala.dynamics as gd +import gala.potential as gp +from gala.potential.scf import compute_coeffs + +# - + +# ## SCF representation of an analytic density distribution +# +# ### Custom spherical density function +# +# For this example, we'll assume that we want a potential representation of the +# spherical density function: +# $$ +# \rho(r) = \frac{1}{r^{1.8} \, (1 + r)^{2.7}} +# $$ +# +# Let's start by writing a density function that takes a single set of Cartesian +# coordinates (x, y, z) and returns the (scalar) value of the density at that +# location: + + +def density_func(x, y, z): + r = np.sqrt(x**2 + y**2 + z**2) + return 1 / (r**1.8 * (1 + r) ** 2.7) + + +# Let's visualize this density function. For comparison, let's also over-plot +# the Hernquist density distribution. The SCF expansion uses the Hernquist +# density for radial basis functions, so the similarity of the density we want +# to represent and the Hernquist function gives us a sense of how many radial +# terms we will need in the expansion: + +hern = gp.HernquistPotential(m=1, c=1) + +# + +r = np.logspace(-1, 1, 128) +plt.plot(r, density_func(r, 0, 0), marker="", label="custom density") + +# Use symmetry coordinates for spherical potentials (simpler than building xyz arrays) +plt.plot(r, hern.density(r=r), marker="", label="Hernquist") + +plt.xscale("log") +plt.yscale("log") + +plt.xlabel("$r$") +plt.ylabel(r"$\rho(r)$") + +plt.legend(loc="best") +# - + +# These functions are not *too* different, implying that we probably don't need +# too many radial expansion terms in order to well represent the +# density/potential from this custom function. As an arbitrary number, let's +# choose to compute radial terms up to and including $n = 10$. In this case, +# because the density we want to represent is spherical, we don't need any $l, +# m$ terms, so we set `lmax=0`. We can also neglect the sin() terms of the +# expansion ($T_{nlm}$): + +(S, Serr), _ = compute_coeffs( + density_func, nmax=10, lmax=0, M=1.0, r_s=1.0, S_only=True +) + +# The above variable `S` will contain the expansion coefficients, and the +# variable `Serr` will contain an estimate of the error in this coefficient +# value. Let's now construct an `SCFPotential` object with the coefficients we +# just computed: + +S + +pot = gp.SCFPotential(m=1.0, r_s=1, Snlm=S, Tnlm=np.zeros_like(S)) + +# Now let's visualize the SCF estimated density with the true density: + +# + +r = np.logspace(-1, 1, 128) +plt.plot(r, density_func(r, 0, 0), marker="", label="custom density") + +xyz = np.zeros((3, len(r))) +xyz[0] = r +plt.plot(r, pot.density(xyz), marker="", label="SCF density") + +plt.xscale("log") +plt.yscale("log") + +plt.xlabel("$r$") +plt.ylabel(r"$\rho(r)$") + +plt.legend(loc="best") + + +# - + +# This does a pretty good job of capturing the radial fall-off of our custom +# density function, but you may want to iterate a bit to satisfy your own +# constraints. For example, you may want the density to be represented with a +# less than 1% deviation over some range of radii, or whatever. +# +# As a second example, let's now try a custom axisymmetric density distribution: + +# ### Custom axisymmetric density function +# +# For this example, we'll assume that we want a potential representation of the +# flattened Hernquist density function: +# $$ +# \rho(R, z) = \frac{1}{r \, (1 + r)^{3}}\\ +# r^2 = R^2 + \frac{z^2}{q^2} +# $$ +# +# where $q$ is the flattening, which we'll set to $q=0.6$. +# +# Let's again start by writing a density function that takes a single set of +# Cartesian coordinates (x, y, z) and returns the (scalar) value of the density +# at that location: + + +def density_func_flat(x, y, z, q): + r = np.sqrt(x**2 + y**2 + (z / q) ** 2) + return 1 / (r * (1 + r) ** 3) / (2 * np.pi) + + +# Let's compute the density along a diagonal line for a few different +# flattenings and again compare to the non-flattened Hernquist profile: + +# + +x = np.logspace(-1, 1, 128) +xyz = np.zeros((3, len(x))) +xyz[0] = x +xyz[2] = x + +for q in np.arange(0.6, 1 + 1e-3, 0.2): + plt.plot( + x, + density_func_flat(xyz[0], 0.0, xyz[2], q), + marker="", + label=f"custom density: q={q}", + ) + +plt.plot(x, hern.density(xyz), marker="", ls="--", label="Hernquist") + +plt.xscale("log") +plt.yscale("log") + +plt.xlabel("$r$") +plt.ylabel(r"$\rho(r)$") + +plt.legend(loc="best") +# - + +# Because this is an axisymmetric density distribution, we need to also compute +# $l$ terms in the expansion, so we set `lmax=6`, but we can skip the $m$ terms +# using `skip_m=True`. Because this computes more coefficients, we might want to +# see the progress in real time - if you install the Python package `tqdm` and +# pass `progress=True`, it will also display a progress bar: + +q = 0.6 +(S_flat, Serr_flat), _ = compute_coeffs( + density_func_flat, + nmax=4, + lmax=6, + args=(q,), + M=1.0, + r_s=1.0, + S_only=True, + skip_m=True, + progress=True, +) + +pot_flat = gp.SCFPotential(m=1.0, r_s=1, Snlm=S_flat, Tnlm=np.zeros_like(S_flat)) + +# + +x = np.logspace(-1, 1, 128) +xyz = np.zeros((3, len(x))) +xyz[0] = x +xyz[2] = x + +plt.plot( + x, + density_func_flat(xyz[0], xyz[1], xyz[2], q), + marker="", + label=f"true density q={q}", +) + +plt.plot(x, pot_flat.density(xyz), marker="", ls="--", label="SCF density") + +plt.xscale("log") +plt.yscale("log") + +plt.xlabel("$r$") +plt.ylabel(r"$\rho(r)$") + +plt.legend(loc="best") +# - + +# The SCF potential object acts like any other `gala.potential` object, meaning +# we can, e.g., plot density or potential contours: + +# + +grid = np.linspace(-8, 8, 128) + +fig, axes = plt.subplots(1, 2, figsize=(10, 5), sharex=True, sharey=True) +_ = pot_flat.plot_contours((grid, grid, 0), ax=axes[0]) +axes[0].set_xlabel("$x$") +axes[0].set_ylabel("$y$") + +_ = pot_flat.plot_contours((grid, 0, grid), ax=axes[1]) +axes[1].set_xlabel("$x$") +axes[1].set_ylabel("$z$") + +for ax in axes: + ax.set_aspect("equal") +# - + +# And numerically integrate orbits by passing in initial conditions and +# integration parameters: + +w0 = gd.PhaseSpacePosition(pos=[3.5, 0, 1], vel=[0, 0.4, 0.05]) + +orbit_flat = pot_flat.integrate_orbit(w0, dt=1.0, n_steps=5000) +_ = orbit_flat.plot() diff --git a/gala/source/docs/tutorials/Milky-Way-model.py b/gala/source/docs/tutorials/Milky-Way-model.py new file mode 100644 index 0000000000000000000000000000000000000000..c1d1843e28e4fe71470e48422806d51f717bd40b --- /dev/null +++ b/gala/source/docs/tutorials/Milky-Way-model.py @@ -0,0 +1,208 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: light +# format_version: '1.5' +# jupytext_version: 1.11.1 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# + nbsphinx="hidden" +# %matplotlib inline + +# + nbsphinx="hidden" +# %run nb_setup +# - + +# # Integrate an orbit with uncertainties in Milky Way model + +# `gala` provides a simple mass model for the Milky Way based on recent +# measurements of the enclosed mass compiled from the literature. See the +# [Defining a Milky Way potential +# model](define-milky-way-model.html) documentation for more +# information about how this model was defined. +# +# In this example, we will use the position and velocity and uncertainties of +# the Milky Way satellite galaxy "Draco" to integrate orbits in a Milky Way mass +# model starting from samples from the error distribution over initial +# conditions defined by its observed kinematics. We will then compute +# distributions of orbital properties like orbital period, pericenter, and +# eccentricity. +# +# Let's start by importing packages we will need: + +# + + +import astropy.coordinates as coord +import astropy.units as u +import matplotlib.pyplot as plt +import numpy as np + +# Gala +import gala.dynamics as gd +import gala.potential as gp + +# - + +# We will also set the default Astropy Galactocentric frame parameters to the +# values adopted in Astropy v4.0: + +coord.galactocentric_frame_defaults.set("v4.0") + +# For the Milky Way model, we'll use the built-in potential class in `gala` (see +# above for definition): + +potential = gp.MilkyWayPotential(version="latest") + +# For the sky position and distance of Draco, we'll use measurements from +# [Bonanos et al. 2004](https://arxiv.org/abs/astro-ph/0310477). For proper +# motion components, we'll use the recent HSTPROMO measurements ([Sohn et al. +# 2017](https://arxiv.org/abs/1707.02593)) and the line-of-sight velocity from +# [Walker et al. 2007](https://arxiv.org/abs/0708.0010). + +# + +icrs = coord.SkyCoord( + ra=coord.Angle("17h 20m 12.4s"), + dec=coord.Angle("+57° 54′ 55″"), + distance=76 * u.kpc, + pm_ra_cosdec=0.0569 * u.mas / u.yr, + pm_dec=-0.1673 * u.mas / u.yr, + radial_velocity=-291 * u.km / u.s, +) + +icrs_err = coord.SkyCoord( + ra=0 * u.deg, + dec=0 * u.deg, + distance=6 * u.kpc, + pm_ra_cosdec=0.009 * u.mas / u.yr, + pm_dec=0.009 * u.mas / u.yr, + radial_velocity=0.1 * u.km / u.s, +) +# - + +# Let's start by transforming the measured values to a Galactocentric reference +# frame so we can integrate an orbit in our Milky Way model. We'll do this using +# the velocity transformation support in +# [`astropy.coordinates`](http://docs.astropy.org/en/stable/coordinates/velocities.html). +# We first have to define the position and motion of the sun relative to the +# Galactocentric frame, and create an +# [`astropy.coordinates.Galactocentric`](http://docs.astropy.org/en/stable/api/astropy.coordinates.Galactocentric.html#astropy.coordinates.Galactocentric) +# object with these parameters. We could specify these things explicitly, but +# instead we will use the default values that were recently updated in Astropy: + +galcen_frame = coord.Galactocentric() +galcen_frame + +# To transform the mean observed kinematics to this frame, we simply do: + +galcen = icrs.transform_to(galcen_frame) + +# That's it! Now we have to turn the resulting `Galactocentric` object into +# orbital initial conditions, and integrate the orbit in our Milky Way model. +# We'll use a timestep of 0.5 Myr and integrate the orbit backwards for 10000 +# steps (5 Gyr): + +w0 = gd.PhaseSpacePosition(galcen.data) +orbit = potential.integrate_orbit(w0, dt=-0.5 * u.Myr, n_steps=10000) + +# Let's visualize the orbit: + +fig = orbit.plot() + +# With the `orbit` object, we can easily compute quantities like the pericenter, +# apocenter, or eccentricity of the orbit: + +orbit.pericenter(), orbit.apocenter(), orbit.eccentricity() + +# We can also use these functions to get the time of each pericenter or +# apocenter - let's plot the time of pericenter, and time of apocenter over the +# time series of the Galactocentric radius of the orbit: + +# + +plt.plot(orbit.t, orbit.spherical.distance, marker="None") + +per, per_times = orbit.pericenter(return_times=True, func=None) +apo, apo_times = orbit.apocenter(return_times=True, func=None) + +for t in per_times: + plt.axvline(t.value, color="#67a9cf") + +for t in apo_times: + plt.axvline(t.value, color="#ef8a62") + +plt.xlabel("$t$ [{}]".format(orbit.t.unit.to_string("latex"))) +plt.ylabel("$r$ [{}]".format(orbit.x.unit.to_string("latex"))) +# - + +# Now we'll sample from the error distribution over the distance, proper +# motions, and radial velocity, compute orbits, and plot distributions of mean +# pericenter and apocenter: + +# + +n_samples = 128 + +rng = np.random.default_rng(42) +dist = ( + rng.normal(icrs.distance.value, icrs_err.distance.value, n_samples) + * icrs.distance.unit +) + +pm_ra_cosdec = ( + rng.normal(icrs.pm_ra_cosdec.value, icrs_err.pm_ra_cosdec.value, n_samples) + * icrs.pm_ra_cosdec.unit +) + +pm_dec = ( + rng.normal(icrs.pm_dec.value, icrs_err.pm_dec.value, n_samples) * icrs.pm_dec.unit +) + +rv = ( + rng.normal(icrs.radial_velocity.value, icrs_err.radial_velocity.value, n_samples) + * icrs.radial_velocity.unit +) + +ra = np.full(n_samples, icrs.ra.degree) * u.degree +dec = np.full(n_samples, icrs.dec.degree) * u.degree +# - + +icrs_samples = coord.SkyCoord( + ra=ra, + dec=dec, + distance=dist, + pm_ra_cosdec=pm_ra_cosdec, + pm_dec=pm_dec, + radial_velocity=rv, +) + +icrs_samples.shape + +galcen_samples = icrs_samples.transform_to(galcen_frame) + +w0_samples = gd.PhaseSpacePosition(galcen_samples.data) +orbit_samples = potential.integrate_orbit(w0_samples, dt=-1 * u.Myr, n_steps=4000) + +orbit_samples.shape + +# + +peris = orbit_samples.pericenter(approximate=True) + +apos = orbit_samples.apocenter(approximate=True) + +eccs = orbit_samples.eccentricity(approximate=True) + +# + +fig, axes = plt.subplots(1, 3, figsize=(12, 4), sharey=True) + +axes[0].hist(peris.to_value(u.kpc), bins=np.linspace(20, 80, 32)) +axes[0].set_xlabel("pericenter [kpc]") + +axes[1].hist(apos.to_value(u.kpc), bins=np.linspace(60, 140, 32)) +axes[1].set_xlabel("apocenter [kpc]") + +axes[2].hist(eccs.value, bins=np.linspace(0.3, 0.5, 41)) +axes[2].set_xlabel("eccentricity") diff --git a/gala/source/docs/tutorials/circ-restricted-3body.rst b/gala/source/docs/tutorials/circ-restricted-3body.rst new file mode 100644 index 0000000000000000000000000000000000000000..370f3a37f0b321b780cd013c408d8d5caceebcd0 --- /dev/null +++ b/gala/source/docs/tutorials/circ-restricted-3body.rst @@ -0,0 +1,214 @@ +.. _restricted_three_body: + +====================================== +Circular restricted three-body problem +====================================== + +As a demonstration of the flexibility of the potential clases and reference +frame machinery, below we'll demonstrate how to integrate orbits in the +`circular restricted three-body problem `_. + +We first need to import some relevant packages:: + + >>> import astropy.units as u + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> from scipy.optimize import root + >>> import gala.integrate as gi + >>> import gala.dynamics as gd + >>> import gala.potential as gp + +The "restricted three-body problem" is the problem of solving for the orbit of a +test particle interacting with a binary bass system, typically also in the +rotating frame of the binary. We'll assume that the binary consists of a more +massive component :math:`m_1` and a secondary mass :math:`m_2`. If the binary +components are on circular orbits, and we restict to the plane of motion of the +binary, we can change to a rotating reference frame that rotates with the +angular frequency of the binary, and cast the problem in terms of scaled units +that simplify the expressions and math. In detail, we'll work in units such that +the masses of the two components are :math:`1 - \mu` and :math:`\mu`, where +:math:`\mu = \frac{m_2}{m_1+m_2}`. We'll also set :math:`G=1` and the orbital +frequency of the binary to :math:`\Omega=1`. For more information about the +problem setup, see, e.g., `this paper `_. + +For our example, we'll use the value :math:`\mu = 1/11`, corresponding to a 1:10 +mass ratio between the two components of the central binary. In the units +defined above, and assuming that the binary components lie on the coordinate +:math:`x`-axis in the rotating frame, the positions and masses of the two binary +components are :math:`x_1 = -\mu`, :math:`m_1 = 1-\mu` and :math:`x_2 = 1-\mu`, +:math:`m_2 = \mu`, respectively. Let's start by defining these quantities:: + + >>> mu = 1/11. + >>> x1 = -mu + >>> m1 = 1-mu + >>> x2 = 1-mu + >>> m2 = mu + +Since the potential classes in ``Gala`` work with 3-dimensional quantities, +we'll define the frequency of the binary as a 3D vector:: + + >>> Omega = np.array([0, 0, 1.]) + +We'll now define the gravitational potential of the binary. To do this, we have +to make use of the ``origin`` keyword in the potential class initializer to +shift the positions of the component masses to the values defined above. We'll +store the potentials of the two masses together in a +`~gala.potential.CCompositePotential`:: + + >>> pot = gp.CCompositePotential() + >>> pot['m1'] = gp.KeplerPotential(m=m1, origin=[x1, 0, 0.]) + >>> pot['m2'] = gp.KeplerPotential(m=m2, origin=[x2, 0, 0.]) + +We now have to define the rotating reference frame:: + + >>> frame = gp.ConstantRotatingFrame(Omega=Omega) + +And finally, we combine the potential and frame into a +`~gala.potential.Hamiltonian` object:: + + >>> H = gp.Hamiltonian(pot, frame) + +We're now ready to start integrating orbits! But before we do that, let's look +at the geometry of phase-space to get a sense for what the orbits will look like +with different choices of the Jacobi energy. We'll make a grid of x and y +positions and evalutes the Jacobi energy at each position in the grid assuming +a zero velocity. We'll draw filled contours at each value of 4 chosen Jacobi +energy values, which will visualize "forbidden regions" of the plane at each +value of the Jacobi energy (see Section 3.3.2 in Binney and Tremaine 2008):: + + >>> grid = np.linspace(-1.75, 1.75, 128) + >>> x_grid, y_grid = np.meshgrid(grid, grid) + >>> xyz = np.vstack((x_grid.ravel(), + ... y_grid.ravel(), + ... np.zeros_like(x_grid.ravel()))) + >>> Om_cross_x = np.cross(Omega, xyz.T) + >>> E_J = H.potential.energy(xyz) - 0.5*np.sum(Om_cross_x**2, axis=1) + >>> E_J_levels = [-1.82, -1.73, -1.7, -1.5] + +.. plot:: + :align: center + :context: close-figs + + import astropy.units as u + import matplotlib.pyplot as plt + import numpy as np + import gala.integrate as gi + import gala.dynamics as gd + import gala.potential as gp + + mu = 1/11. + x1 = -mu + m1 = 1-mu + x2 = 1-mu + m2 = mu + + Omega = np.array([0, 0, 1.]) + + pot = (gp.KeplerPotential(m=1-mu, origin=[x1, 0, 0]) + + gp.KeplerPotential(m=mu, origin=[x2, 0, 0])) + + frame = gp.ConstantRotatingFrame(Omega=Omega) + static = gp.StaticFrame() + H = gp.Hamiltonian(pot, frame) + + grid = np.linspace(-1.75, 1.75, 128) + x_grid, y_grid = np.meshgrid(grid, grid) + xyz = np.vstack((x_grid.ravel(), + y_grid.ravel(), + np.zeros_like(x_grid.ravel()))) + Om_cross_x = np.cross(Omega, xyz.T) + E_J = H.potential.energy(xyz) - 0.5*np.sum(Om_cross_x**2, axis=1) + + fig,axes = plt.subplots(2, 2, figsize=(8,8), sharex=True, sharey=True) + + E_J_levels = [-1.82, -1.73, -1.7, -1.5] + + for ax, level in zip(axes.flat, E_J_levels): + ax.contourf(x_grid, y_grid, E_J.reshape(128,128).value, + levels=[level,0], colors='#aaaaaa') + ax.scatter(-mu, 0, c='k') + ax.scatter(1-mu, 0, c='k') + ax.set_title(r'$E_{{\rm J}} = {:.2f}$'.format(level)) + + ax.set_xlim(-1.6, 1.6) + ax.set_ylim(-1.6, 1.6) + + axes[0,0].set_ylabel('$y$') + axes[1,0].set_ylabel('$y$') + axes[1,0].set_xlabel('$x$') + axes[1,1].set_xlabel('$x$') + + fig.tight_layout() + + +At each of the values of the Jacobi energy chosen above, we'll now integrate +an orbit. To do this, we have to solve for the initial conditions given the +Jacobi energy, and convert from rotating frame (Lagrangian) coordinates to +canonical coordinates. Let's define some functions to help with this:: + + >>> def func_ydot(val, x, H, E_J): + ... ydot = val[0] + ... Om_cross_x = np.cross(H.frame.parameters['Omega'].value, x) + ... eff_pot = H.potential.energy(x).value[0] - 0.5*Om_cross_x.dot(Om_cross_x) + ... return E_J - 0.5*ydot**2 - eff_pot + >>> def xxdot_to_qp(x, xdot, Omega): + ... q = x + ... p = np.array(xdot) + np.cross(Omega, x) + ... return q, p + +Now we'll integrate the orbits at each energy level. We'll assert that the orbit +starts from the y axis at :math:`x = 0.5` and solve for the y velocity, +:math:`\dot{y}`, then convert to canonical coordinates:: + + >>> x0 = [0.5, 0, 0] + >>> orbits = [] + >>> for level in E_J_levels: + ... res = root(func_ydot, x0=0.3, args=(x0, H, level)) + ... xdot0 = [0, res.x[0], 0.] + ... w0 = np.concatenate(xxdot_to_qp(x0, xdot0, Omega)) + ... orbit = H.integrate_orbit(w0, dt=1E-2, n_steps=100000, + ... Integrator=gi.DOPRI853Integrator) + ... orbits.append(orbit) + +.. plot:: + :align: center + :context: close-figs + + from scipy.optimize import root + + def func_ydot(val, x, H, E_J): + ydot = val[0] + Om_cross_x = np.cross(H.frame.parameters['Omega'].value, x) + eff_pot = H.potential.energy(x).value[0] - 0.5*Om_cross_x.dot(Om_cross_x) + return E_J - 0.5*ydot**2 - eff_pot + + def xxdot_to_qp(x, xdot, Omega): + q = x + p = np.array(xdot) + np.cross(Omega, x) + return q, p + + x0 = [0.5, 0., 0.] + orbits = [] + for level in E_J_levels: + res = root(func_ydot, x0=0.3, args=(x0, H, level)) + xdot0 = [0, res.x[0], 0.] + w0 = np.concatenate(xxdot_to_qp(x0, xdot0, Omega)) + orbit = H.integrate_orbit(w0, dt=1E-2, n_steps=100000, + Integrator=gi.DOPRI853Integrator) + orbits.append(orbit) + + fig,axes = plt.subplots(2, 2, figsize=(8,8), sharex=True, sharey=True) + + for ax, level, orbit in zip(axes.flat, E_J_levels, orbits): + ax.contourf(x_grid, y_grid, E_J.reshape(128,128).value, + levels=[level,0], colors='#aaaaaa') + ax.scatter(-mu, 0, c='r') + ax.scatter(1-mu, 0, c='r') + ax.set_title(r'$E_{{\rm J}} = {:.2f}$'.format(level)) + + ax.plot(orbit.x, orbit.y, marker='None', linewidth=1.) + + ax.set_xlim(-1.6, 1.6) + ax.set_ylim(-1.6, 1.6) + + fig.tight_layout() diff --git a/gala/source/docs/tutorials/data/m12m-basis.yml b/gala/source/docs/tutorials/data/m12m-basis.yml new file mode 100644 index 0000000000000000000000000000000000000000..06fd75e6fcca20e4787c0bce1a16949240dfc427 --- /dev/null +++ b/gala/source/docs/tutorials/data/m12m-basis.yml @@ -0,0 +1,10 @@ +--- +id: sphereSL +parameters: + numr: 1024 + rmin: 0.0010 + rmax: 30.0 + Lmax: 4 + nmax: 10 + modelname: m12m_basis_table.model + cachename: m12m.cache diff --git a/gala/source/docs/tutorials/data/m12m-coef.hdf5 b/gala/source/docs/tutorials/data/m12m-coef.hdf5 new file mode 100644 index 0000000000000000000000000000000000000000..d75abeda057776f800a25c6dc1eaface440e339c Binary files /dev/null and b/gala/source/docs/tutorials/data/m12m-coef.hdf5 differ diff --git a/gala/source/docs/tutorials/data/m12m.cache b/gala/source/docs/tutorials/data/m12m.cache new file mode 100644 index 0000000000000000000000000000000000000000..b92ef03cf4eccc4364a2f636e00b66de8c33d328 --- /dev/null +++ b/gala/source/docs/tutorials/data/m12m.cache @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:898933537fe5cd983ac4ec4d024e8be71559b74b6affd59e07700cb1cfeea06d +size 428328 diff --git a/gala/source/docs/tutorials/data/m12m_basis_table.model b/gala/source/docs/tutorials/data/m12m_basis_table.model new file mode 100644 index 0000000000000000000000000000000000000000..3c710b74870620deada34bd54e3b34e6bdeb996a --- /dev/null +++ b/gala/source/docs/tutorials/data/m12m_basis_table.model @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7259695a3b792fc59d12083f76c540d3da5d16c224e0f95676882ef089d5f29b +size 83362 diff --git a/gala/source/docs/tutorials/exp.rst b/gala/source/docs/tutorials/exp.rst new file mode 100644 index 0000000000000000000000000000000000000000..2ee120cde8c2479a2523c017e14ecbb535ea72fa --- /dev/null +++ b/gala/source/docs/tutorials/exp.rst @@ -0,0 +1,344 @@ +.. _exp_tutorial: + +============================== +Using EXP potentials with Gala +============================== + +Gala supports `EXP `_ as a backend for representing +flexible and time-dependent gravitational potentials, typically constructed from N-body +simulation snapshots. This requires: + +#. building EXP, +#. building Gala with EXP support, +#. and setting up a `~gala.potential.potential.EXPPotential` or `~gala.potential.potential.PyEXPPotential` + object using a user-provided basis and coefficients. + +Note that EXP support currently requires building Gala from source. +Additionally, this workflow has only been tested on Linux and MacOS with the setups seen +in the `GitHub actions test config file +`_. + +------------ +Building EXP +------------ + +The `EXP documentation `_ +is the best place to read about how to build EXP. Gala doesn't have any special +instructions for the EXP build, except that the user must actually "install" EXP, +rather than just build it. This is demonstrated below. + +Gala is compatible with EXP version >= 7.9.1. If you encounter build issues, double +check the EXP version. + +To install EXP's dependencies, here is one recipe that we have found to work on Ubuntu 24.04:: + + sudo apt-get install build-essential cmake gfortran git libeigen3-dev libfftw3-dev libhdf5-dev libomp-dev libopenmpi-dev ninja-build + # install uv python, only needed if you don't already have python: + # curl -LsSf https://astral.sh/uv/install.sh | sh + +Here is another recipe using modules that has been found to work on Flatiron Institute's rusty cluster:: + + module load modules/2.4 cmake gcc openmpi hdf5 libtirpc eigen fftw git python uv + +EXP also builds on Mac by installing the dependencies with Homebrew:: + + brew install cmake eigen@3 fftw hdf5 open-mpi git ninja + +After installing the dependencies, one can download and build EXP on Linux with:: + + git clone --recursive https://github.com/EXP-code/EXP.git + cd EXP + cmake -G Ninja -B build -DENABLE_MINIMAL=on -DCMAKE_INSTALL_RPATH="$PWD/install/lib" -DCMAKE_BUILD_TYPE=Release --install-prefix $PWD/install + cmake --build build + cmake --install build + +``-DENABLE_MINIMAL=on`` is optional but will make the build go faster. One can replace +this with ``-DENABLE_PYEXP_ONLY=on`` if one wants a minimal build with PyEXP. + +In this case, we installed EXP to the ``EXP/install/`` directory, but this can be any +directory. This will become the ``GALA_EXP_PREFIX`` directory in the next step. + +For a full example of how to build EXP on Mac, see `this build recipe +`_. + +Note that building pyEXP is only necessary if one wants to use ``PyEXPPotential``. +Additionally, some tests will use pyEXP if it is present. + +------------------------------ +Building Gala with EXP support +------------------------------ + +Building Gala with the ``GALA_EXP_PREFIX`` environment variable set to the EXP install dir +will trigger compilation of the Gala's EXP Cython extensions. For example:: + + git clone https://github.com/adrn/gala.git + cd gala + export GALA_EXP_PREFIX=/path/to/EXP/install/ + +If you build and install EXP following the instructions above, the EXP installation will be +located in ``EXP/install/``. If you installed EXP to a different location, you can set the +``GALA_EXP_PREFIX`` to that location. In either case, ``GALA_EXP_PREFIX`` must be the directory +that contains the subdirectories ``lib`` and ``include``. + +Now you can run the Gala build. For example, using uv:: + + uv venv + uv pip install -ve . + +Or using venv:: + + python -m venv .venv + . .venv/bin/activate + python -m pip install -ve . + +In either case, the output should show a message like ``Gala: installing with EXP support``. + +Note that in previous versions of Gala, the ``GALA_EXP_PREFIX`` was supposed to point to the +EXP repo root, rather than the EXP installation directory. This is no longer the case. The +EXP repo and build directories are not needed to build Gala with EXP support. + +Likewise, ``GALA_EXP_LIB_PATH`` was used in past Gala versions but not anymore. + + +---------------------------------- +Running Gala with an EXP potential +---------------------------------- + +To use an EXP potential with Gala, first you'll need a config file, a basis file, and a +coefficients file from EXP. We have included example files with this tutorial, produced +by constructing a basis and computing coefficients with particle data from a single +snapshot of the dark matter halo of the m12m simulation in the `Latte suite +`_ of the `FIRE-2 simulations +`_. In particular, the relevant files are: + +- ``m12m-basis.yml`` - the basis configuration file +- ``m12m_basis_table.model`` - the basis table (density and potential evaluated on a + grid of spherical radii) +- ``m12m-coef.hdf5`` - the coefficients file + +The basis was generated with a unit system in which G=1 (standard for EXP), the mass +unit is :math:`10^{12}~\mathrm{M}_\odot`, and the length unit is 10 kpc. +Setting up an `~gala.potential.potential.EXPPotential` object with these files is as easy as +specifying the unit system and EXP files: + +.. code-block:: python + + import astropy.units as u + import gala.potential as gp + from gala.units import SimulationUnitSystem + + exp_units = SimulationUnitSystem(mass=1e12 * u.Msun, length=10 * u.kpc, G=1) + + exp_pot = gp.EXPPotential( + units=exp_units, + config_file="data/m12m-basis.yml", + coef_file="data/m12m-coef.hdf5", + ) + +Then one can use the potential object like any other Gala potential. For example, to +integrate and plot an orbit: + +.. code-block:: python + + import gala.dynamics as gd + + w0 = gd.PhaseSpacePosition( + pos=[8, 0.0, 1.0] * u.kpc, + vel=[0.0, 220, 0.0] * u.km / u.s, + ) + orbit = gp.Hamiltonian(exp_pot).integrate_orbit(w0, dt=1 * u.Myr, t1=0, t2=6 * u.Gyr) + fig = orbit.plot(units=u.kpc, linestyle="-", alpha=0.5, label="orbit in m12m") + + +----------------------------------- +Running Gala with a pyEXP potential +----------------------------------- + +If you are using +`pyEXP `_ +and have ``pyEXP.basis.BiorthBasis`` and ``pyEXP.coefs.Coefs`` objects (or any object +that subclasses them), you can use those to construct a Gala +`~gala.potential.potential.PyEXPPotential` object. + +Using ``PyEXPPotential``, the previous example would look like: + +.. code-block:: python + + import os + + import astropy.units as u + import pyEXP + + import gala.potential as gp + from gala.units import SimulationUnitSystem + + exp_units = SimulationUnitSystem(mass=1e12 * u.Msun, length=10 * u.kpc, G=1) + + # Construct the pyEXP basis + oldcwd = os.getcwd() + os.chdir("data") + with open("m12m-basis.yml") as fp: + basis = pyEXP.basis.Basis.factory(fp.read()) + os.chdir(oldcwd) + + # Construct the pyEXP coefs + coefs = pyEXP.coefs.Coefs.factory("data/m12m-coef.hdf5") + + pyexp_pot = gp.PyEXPPotential( + units=exp_units, + basis=basis, + coefs=coefs, + ) + + +Note that ``PyEXPPotential`` is missing some parameters, like ``snapshot_index``, that +``EXPPotential`` supports. This is because the intended workflow is for the user to construct +and modify the pyEXP basis and coefs objects using standard pyEXP methods and then pass those +objects to Gala. Otherwise, there should be no behavior or performance difference in using an +``EXPPotential`` or ``PyEXPPotential``. + + +----- +Units +----- + +Gala generally works in physical units (e.g., kpc, solar mass, etc.), whereas EXP +typically works in user-defined simulation units. To use EXP with Gala, one must define +a `~gala.units.SimulationUnitSystem` and specify this when creating the potential (as +demonstrated above). If the basis was computed from a scale-dependent potential, the +simulation unit system must match the units used to generate the basis. If the potential +was computed from a scale-independent model, the simulation unit system can be +arbitrary, but it can be used to set physical scales to the simulations. + +-------------- +Time Evolution +-------------- + +An `~gala.potential.potential.EXPPotential` or `~gala.potential.potential.PyEXPPotential` +may be time-evolving or static. If the coefficients only have snapshot, the potential +will be static. Likewise, for ``EXPPotential``, if ``tmin``/``tmax`` are passed such that +only one snapshot from the coefs falls within that range, the +potential will be static. For the examples below, we use hypothetical files +``config.yml`` and ``coefs.h5`` that contain coefficients for multiple snapshots. + +One can always check if an ``EXPPotential`` or ``PyEXPPotential`` is static with: + +.. code-block:: python + + exp_pot.static + +One can also "freeze" a multi-snapshot ``EXPPotential`` (i.e. make it static) by selecting +a single snapshot with the ``snapshot_index`` parameter: + +.. code-block:: python + + exp_pot = gp.EXPPotential( + units=exp_units, + config_file="config.yml", + coef_file="coefs.h5", + snapshot_index=0, + ) + +The equivalent for the pyEXP interface is to pass ``PyEXPPotential`` a coefs object that +only contains one snapshot. + +For time-evolving potentials, if one tries to evaluate the potential outside of the +time range stored in the coefficients (even indirectly, such as during an +orbit integration), a C++ exception will be triggered, which will be raised to the user +as a Python exception. The Python exception will contain the error message from C++. +For example: +``RuntimeError: FieldWrapper::interpolator: time t=11.73 is out of bounds: [0.0195404, 11.724]``. + +In ``EXPPotential``, if the coefficients store a very large time range but the user is only interested +in a smaller range, one can specify ``tmin`` and/or ``tmax`` to load a smaller subset of +the coefficient data (for memory efficiency): + +.. code-block:: python + + exp_pot = gp.EXPPotential( + units=exp_units, + config_file="config.yml", + coef_file="coefs.h5", + tmin=1.0, + tmax=2.0, + ) + +Note that, as mentioned above, subsequently using a time outside this range will result +in a Python exception. Or more precisely: using a time outside the range of snapshots that +this ``tmin``/``tmax`` caused to be loaded will cause such an error. One can check the loaded +range of snapshots (both ``EXPPotential`` and ``PyEXPPotential``) with: + +.. code-block:: python + + exp_pot.tmin_exp + exp_pot.tmax_exp + +``tmin`` and ``tmax`` should not be passed for single-snapshot coefficient files. + +---------- +File Paths +---------- + +`~gala.potential.potential.EXPPotential` takes ``config_file`` and ``coef_file`` as file path +arguments. These can be absolute paths, or paths relative to the current working +directory. + +The config file itself may reference file paths like the ``modelname`` and ``cachename``. +These paths can be absolute paths, or paths **relative to the config file**. + +------- +Testing +------- +The tests for EXP are all in the dedicated `test_exp.py `_ +file. The EXP tests will be run by default if Gala was built with EXP (use ``GALA_FORCE_EXP_TEST=1`` to always test EXP). +Similarly, some of the tests will compare against pyEXP if it is available (use ``GALA_FORCE_PYEXP_TEST=1`` to always test this). + +With the test dependencies installed (see :doc:`/testing`), to run just the EXP tests, one can run the following from the +repo root: + +.. code-block:: + + pytest tests/potential/potential/test_exp.py + +-------------------- +Composite Potentials +-------------------- + +`~gala.potential.potential.EXPPotential` and `~gala.potential.potential.PyEXPPotential` +fully support composite potentials, including +mixing static and time-evolving potentials. The potentials will be combined at the C level +as a :class:`~gala.potential.potential.CCompositePotential` when possible. +See :ref:`_compositepotential` for more info. + +-------------------------- +Performance Considerations +-------------------------- + +Within a timestep, the EXP force evaluation is parallelized with OpenMP threads across +orbits. With enough orbits (perhaps 1000 or more), you can expect to see a performance +benefit from using multiple threads. The number of OpenMP threads can be controlled +with standard OpenMP mechanisms, such as setting the ``OMP_NUM_THREADS`` environment +variable. + +Note that :class:`~gala.integrate.DOPRI853Integrator` batches the orbits into small +sets for performance, so EXP only sees the batch size at any given time and may not be +able to parallelize this well. One can use the ``nbatch`` integrator kwarg to tune the +batch size. + +----------- +Limitations +----------- +`~gala.potential.potential.EXPPotential` and `~gala.potential.potential.PyEXPPotential` +currently has the following limitations: + +* Hessian evaluation is not supported. +* Pickling, saving, and loading is not supported. + +.. TODO (adrn): any other notable limitations? + +--- +API +--- + +See :class:`~gala.potential.potential.EXPPotential` and :class:`~gala.potential.potential.PyEXPPotential` +for the complete API documentation. diff --git a/gala/source/docs/tutorials/integrate-barred-potential.py b/gala/source/docs/tutorials/integrate-barred-potential.py new file mode 100644 index 0000000000000000000000000000000000000000..6f465b11c56d97fc77508f74e2e86eb76c10a280 --- /dev/null +++ b/gala/source/docs/tutorials/integrate-barred-potential.py @@ -0,0 +1,365 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.11.2 +# kernelspec: +# display_name: gala +# language: python +# name: python3 +# --- + +# %% +# %run nb_setup + +# %% +# %matplotlib inline + +# %% [markdown] +# # Integrating orbits in a barred galaxy potential +# +# In this tutorial, we'll explore how to integrate orbits in a time-dependent, barred galaxy potential model. In barred models, the bar rotates, so we need to account +# for this time-dependence. There are two ways to do this in Gala: +# +# 1. Rotating frame: Use a static bar potential in a rotating reference frame +# 2. Inertial frame: Use a time-dependent rotating bar potential in an inertial frame +# +# We'll demonstrate both approaches and show that they give similar results, but with different accuracies. +# +# ### Notebook Setup and Package Imports + +# %% +import astropy.units as u +import matplotlib.pyplot as plt +import numpy as np +import scipy.optimize as so +from scipy.spatial.transform import Rotation + +import gala.dynamics as gd +import gala.potential as gp + +# %% [markdown] +# ## Define the bar rotation parameters +# +# We'll set up a bar rotating with a pattern speed of 30 km/s/kpc, which is converted +# to radians per Gyr using `astropy.units`. + +# %% +# Set bar pattern speed +with u.set_enabled_equivalencies(u.dimensionless_angles()): + Omega = 30 * u.km / u.s / u.kpc + Omega = Omega.to(u.rad / u.Gyr) + + +# %% +# Create time array spanning ~5 Gyr with 200 steps per rotation period +dt = 2 * np.pi * u.rad / Omega / 200 +time_knots = np.arange(0, 5, dt.to(u.Gyr).value) * u.Gyr + + +# %% [markdown] +# ## Method 1: Static bar in rotating frame +# +# In this approach, we define a static bar potential and integrate orbits in a +# rotating reference frame. The bar remains fixed in this frame, but the frame +# itself rotates with the bar's pattern speed. +# +# ### Create the potential +# +# We'll use a simple, analytic representation of the potential from a Galactic bar and integrate an orbit in the rotating frame of the bar. The total potential model will be a four-component model consisting of the bar, using the [Long & Murali 1992](http://adsabs.harvard.edu/abs/1992ApJ...397...44L) model, plus disk, halo, and nucleus components from the Gala `MilkyWayPotential`. We adjust the disk and bar mass to roughly match the circular velocity of the Milky Way at the solar radius. + +# %% +# Use the latest Milky Way potential as a base +mw = gp.MilkyWayPotential(version="latest") + +# Create a composite potential with a static bar +bar_mw_static = gp.CCompositePotential() +bar_mw_static["bar"] = gp.LongMuraliBarPotential( + m=1e10 * u.Msun, + a=4 * u.kpc, + b=0.8 * u.kpc, + c=0.25 * u.kpc, + alpha=25 * u.deg, + units="galactic", +) +bar_mw_static["disk"] = mw["disk"].replicate(m=4.1e10 * u.Msun) +bar_mw_static["halo"] = mw["halo"] +bar_mw_static["nucleus"] = mw["nucleus"] + +# %% [markdown] +# Let's visualize the isopotential contours of the potential in the x-y plane to +# see the bar perturbation:: + +# %% +fig, ax = plt.subplots(figsize=(5, 5), layout="tight") + +grid = np.linspace(-12, 12, 128) +_ = bar_mw_static.plot_contours(grid=(grid, grid, 0), ax=ax) +ax.set_xlabel("$x$ [kpc]") +ax.set_ylabel("$y$ [kpc]") +ax.set_title("Bar potential") +plt.show() + +# %% [markdown] +# We assume that the bar rotates around the z-axis so that the frequency vector is $\boldsymbol{\Omega} = (0, 0, 1) \times \Omega$. We'll create a `Hamiltonian` object with a `ConstantRotatingFrame` with this frequency: + +# %% +frame = gp.ConstantRotatingFrame(Omega=Omega * [0, 0, 1], units="galactic") +H_rotating = gp.Hamiltonian(potential=bar_mw_static, frame=frame) + + +# %% [markdown] +# To get a set of initial conditions to compute an orbit, we numerically find the co-rotation radius in this potential and integrate an orbit near co-rotation.: + + +# %% +def find_corotation(potential, Omega_bar): + """Find the corotation radius numerically.""" + + def func(r): + with u.set_enabled_equivalencies(u.dimensionless_angles()): + v_circ = potential.circular_velocity([r[0], 0, 0] * u.kpc)[0] + Om = v_circ / (r[0] * u.kpc) + return (Om - Omega_bar).to(Omega_bar.unit).value ** 2 + + res = so.minimize(func, x0=10.0, method="powell") + return res.x[0] * u.kpc + + +r_corot = find_corotation(bar_mw_static, Omega) +v_circ = Omega * r_corot + + +with u.set_enabled_equivalencies(u.dimensionless_angles()): + pass + +# initial conditions at corotation radius +w0 = gd.PhaseSpacePosition( + pos=[r_corot.value, 0, 0] * r_corot.unit, + vel=[0, v_circ.value, 0.0] * v_circ.unit, +) + +# %% [markdown] +# We can now compute an orbit from these initial conditions in the rotating frame Hamiltonian. We'll integrate the orbit for 5 Gyr with a time step of 1 Myr and then visualize the orbit in the rotating frame: + +# %% +# Integration parameters +integrator_kwargs = {"atol": 1e-14, "rtol": 1e-14} +t1 = time_knots.min() +t2 = time_knots.max() +dt_integrate = 0.1 * u.Myr + +# %% +orbit_rotating = H_rotating.integrate_orbit( + w0, + t1=t1, + t2=t2, + dt=dt_integrate, + Integrator="dopri853", + Integrator_kwargs=integrator_kwargs, +) + +# %% +fig, ax = plt.subplots(figsize=(5, 5), layout="tight") +orbit_rotating.plot(["x", "y"], axes=ax, marker="", linestyle="-", lw=0.5) +_ = ax.set( + xlim=(-12, 12), + ylim=(-12, 12), + xlabel="$x$ [kpc]", + ylabel="$y$ [kpc]", + title="Orbit in rotating frame", +) + +# %% [markdown] +# This is an orbit circulation around the Lagrange point L5! +# +# We can also visualize the orbit in the inertial frame using the `to_frame()` method: + +# %% +orbit_rotating_inertial = orbit_rotating.to_frame(gp.StaticFrame(units="galactic")) + +fig, ax = plt.subplots(figsize=(5, 5), layout="tight") +orbit_rotating_inertial.plot(["x", "y"], axes=ax, marker="", linestyle="-", lw=0.5) +_ = ax.set( + xlim=(-12, 12), + ylim=(-12, 12), + xlabel="$x$ [kpc]", + ylabel="$y$ [kpc]", + title="Orbit in inertial frame", +) + +# %% [markdown] +# ## Method 2: Time-dependent bar in inertial frame +# +# As an alternate approach, we could instead work in the inertial frame and define a time-dependent potential that represents the rotating bar. To do this, we use the `TimeDependentPotential` class in Gala and specify a series of time values and corresponding rotation matrices that describe the bar's orientation at each time. Gala will then interpolate the angles as needed during the orbit integration. +# +# +# ### Create the time-dependent potential +# +# We construct the potential in a very similar way to before, but now the bar component is wrapped in a `TimeInterpolatedPotential` that takes the pre-computed rotation matrices. First, we need to compute the rotation matrices: + +# %% +# Pre-compute rotation matrices for each time step +# Negative angle to be comparable to having a frame rotating at +Omega +bar_angle = (-Omega * time_knots).to_value(u.rad) +Rs = Rotation.from_euler("z", bar_angle).as_matrix() + + +# %% [markdown] +# Now we can construct the full time-dependent potential: + +# %% +bar_mw_timedep = gp.CCompositePotential() +bar_mw_timedep["bar"] = gp.TimeInterpolatedPotential( + gp.LongMuraliBarPotential, + time_knots=time_knots, + m=1e10 * u.Msun, + a=4 * u.kpc, + b=0.8 * u.kpc, + c=0.25 * u.kpc, + units="galactic", + alpha=25 * u.deg, + R=Rs, +) +bar_mw_timedep["disk"] = mw["disk"].replicate(m=4.1e10 * u.Msun) +bar_mw_timedep["halo"] = mw["halo"] +bar_mw_timedep["nucleus"] = mw["nucleus"] + +# %% [markdown] +# As a sanity check, let's visualize the potential at different times to see the bar rotating: + +# %% +fig, axes = plt.subplots( + 1, 3, figsize=(15, 5), layout="constrained", sharex=True, sharey=True +) + +times = [0, 10, 20] * u.Myr +for ax, t in zip(axes, times): + _ = bar_mw_timedep.plot_contours(grid=(grid, grid, 0), t=t, ax=ax) + ax.set_xlabel("$x$ [kpc]") + ax.set_title(f"$t = {t.value:.1f}$ Gyr") + ax.set_aspect("equal") + +axes[0].set_ylabel("$y$ [kpc]") + +fig.suptitle("Time-dependent bar in inertial frame", fontsize=22) + +# %% [markdown] +# Now let's integrate the same orbit as before, but now in the inertial frame with the time-dependent potential. We'll use the same initial conditions and integration parameters as before: + +# %% +orbit_inertial = bar_mw_timedep.integrate_orbit( + w0, + t1=t1, + t2=t2, + dt=dt_integrate, + Integrator="dopri853", + Integrator_kwargs=integrator_kwargs, +) + +# %% +fig, ax = plt.subplots(figsize=(5, 5), layout="tight") +orbit_inertial.plot(["x", "y"], axes=ax, marker="", linestyle="-", lw=0.5) +_ = ax.set( + xlim=(-12, 12), + ylim=(-12, 12), + xlabel="$x$ [kpc]", + ylabel="$y$ [kpc]", + title="Orbit 2 in inertial frame", +) + +# %% [markdown] +# Now we can transform the orbit computed in the inertial frame to the rotating frame for comparison: + +# %% +orbit_inertial_rotating = orbit_inertial.to_frame(H_rotating.frame) + +# %% +fig, axes = plt.subplots( + 1, 2, figsize=(10, 5), layout="constrained", sharex=True, sharey=True +) + +orbit_rotating.plot(["x", "y"], axes=axes[0], marker="", linestyle="-", lw=0.5) +_ = axes[0].set( + xlim=(-12, 12), + ylim=(-12, 12), + xlabel="$x$ [kpc]", + ylabel="$y$ [kpc]", + title="Orbit computed in rotating frame", +) + +orbit_inertial_rotating.plot( + ["x", "y"], + axes=axes[1], + marker="", + linestyle="-", + lw=0.5, +) +_ = axes[1].set( + xlabel="$x$ [kpc]", + title="Orbit computed in inertial frame", +) + +# %% [markdown] +# Excellent, the orbits look very similar! This is expected: they represent the same physical motion, just computed in different ways. Let's quantify how well they match. + +# %% [markdown] +# ## Energy conservation: Jacobi integral +# +# In the rotating frame, the relevant conserved quantity is the **Jacobi integral** (also +# called the Jacobi constant or Jacobi energy), not the total energy. Let's examine +# how well each method conserves the Jacobi integral. +# +# The Jacobi integral is: +# $$E_J = E - \mathbf{\Omega} \cdot \mathbf{L}$$ +# +# where $E$ is the total energy in the rotating frame and $\mathbf{L}$ is the angular momentum. + +# %% +# Compute Jacobi integral for rotating frame orbit +E_rotating = H_rotating.energy(orbit_rotating) + +# Compute Jacobi integral for inertial orbit (transformed to rotating frame) +E_inertial_transformed = H_rotating.energy(orbit_inertial_rotating) + +# %% [markdown] +# Let's see how well each method conserves the Jacobi integral over the course of the integration: + +# %% +# Compute fractional energy conservation +frac_dE_rotating = np.abs((E_rotating[1:] - E_rotating[0]) / E_rotating[0]) +frac_dE_inertial = np.abs( + (E_inertial_transformed[1:] - E_inertial_transformed[0]) / E_inertial_transformed[0] +) + + +# %% +fig, ax = plt.subplots(figsize=(10, 6), layout="tight") + +ax.loglog( + orbit_rotating.t[1:].to(u.Gyr).value, + frac_dE_rotating, + label="Method 1: Rotating frame", + alpha=0.7, +) +ax.loglog( + orbit_inertial.t[1:].to(u.Gyr).value, + frac_dE_inertial, + label="Method 2: Inertial frame", + alpha=0.7, +) + +ax.axhline(1e-14, color="k", linestyle="--", alpha=0.3, label="Tolerance (rtol)") +ax.set_xlabel("Time [Gyr]") +ax.set_ylabel("Fractional Jacobi integral error $|\\Delta E_J / E_J|$") +ax.set_title("Jacobi Integral Conservation") +ax.legend() +ax.grid(alpha=0.3) +plt.show() + +# %% [markdown] +# Both methods conserve the Jacobi integral well, but the rotating frame approach (Method 1 above) generally provides better long-term stability for orbits in a barred potential. diff --git a/gala/source/docs/tutorials/integrate-potential-example.rst b/gala/source/docs/tutorials/integrate-potential-example.rst new file mode 100644 index 0000000000000000000000000000000000000000..1ab778053b73efac3606c7fc99217d238f269bc3 --- /dev/null +++ b/gala/source/docs/tutorials/integrate-potential-example.rst @@ -0,0 +1,106 @@ +.. _integrate_potential_example: + +===================================================== +Integrating and plotting an orbit in an NFW potential +===================================================== + +We first import the required packages:: + + >>> import astropy.units as u + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> import gala.integrate as gi + >>> import gala.dynamics as gd + >>> import gala.potential as gp + >>> from gala.units import galactic + +In the examples below, we'll use the ``galactic`` `~gala.units.UnitSystem`: +kpc, Myr, :math:`{\rm M}_\odot`, radians. + +We'll create an NFW potential parametrized by a scale radius and circular +velocity at the scale radius:: + + >>> pot = gp.NFWPotential.from_circular_velocity(v_c=200*u.km/u.s, + ... r_s=10.*u.kpc, + ... units=galactic) + +Now we'll integrate a single orbit in this potential. The easiest approach is +to use the `~gala.potential.PotentialBase.integrate_orbit` method, which +accepts initial conditions and time-stepping specification. We define the +initial conditions as a `~gala.dynamics.PhaseSpacePosition` object:: + + >>> ics = gd.PhaseSpacePosition(pos=[10,0,0.] * u.kpc, + ... vel=[0,175,0] * u.km/u.s) + >>> orbit = gp.Hamiltonian(pot).integrate_orbit(ics, dt=2., n_steps=2000) + +This returns a `~gala.dynamics.Orbit` object containing times and 6D +positions at each time step. By default, this uses Leapfrog integration +(:class:`~gala.integrate.LeapfrogIntegrator`), but you can specify a +different integrator by passing the integrator class:: + + >>> orbit = gp.Hamiltonian(pot).integrate_orbit(ics, dt=2., n_steps=2000, + ... Integrator=gi.DOPRI853Integrator) + +or more conveniently, by passing a string name:: + + >>> orbit = gp.Hamiltonian(pot).integrate_orbit(ics, dt=2., n_steps=2000, + ... Integrator='dopri853') + +We can integrate many orbits in parallel by passing a 2D array of initial +conditions. Here, we'll generate random initial conditions by sampling from +a Gaussian around the initial orbit (positional scale: 100 pc, velocity +scale: 1 km/s):: + + >>> norbits = 128 + >>> new_pos = np.random.normal(ics.pos.xyz.to(u.pc).value, 100., + ... size=(norbits,3)).T * u.pc + >>> new_vel = np.random.normal(ics.vel.d_xyz.to(u.km/u.s).value, 1., + ... size=(norbits,3)).T * u.km/u.s + >>> new_ics = gd.PhaseSpacePosition(pos=new_pos, vel=new_vel) + >>> orbits = gp.Hamiltonian(pot).integrate_orbit(new_ics, dt=2., n_steps=2000) + +Now we'll plot the final positions of these orbits over isopotential contours. +We use the :meth:`~gala.potential.Potential.plot_contours` method to plot +potential contours, then overplot the orbit points:: + + >>> grid = np.linspace(-15,15,64) + >>> fig,ax = plt.subplots(1, 1, figsize=(5,5)) + >>> fig = pot.plot_contours(grid=(grid,grid,0), cmap='Greys', ax=ax) + >>> fig = orbits[-1].plot(['x', 'y'], color='#9ecae1', s=1., alpha=0.5, + ... axes=[ax], auto_aspect=False) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + + import astropy.units as u + import numpy as np + import gala.integrate as gi + import gala.dynamics as gd + import gala.potential as gp + from gala.units import galactic + + np.random.seed(42) + + pot = gp.NFWPotential.from_circular_velocity(v_c=200*u.km/u.s, + r_s=10.*u.kpc, + units=galactic) + + ics = gd.PhaseSpacePosition(pos=[10,0,0.]*u.kpc, + vel=[0,175,0]*u.km/u.s) + orbit = gp.Hamiltonian(pot).integrate_orbit(ics, dt=2., n_steps=2000) + + norbits = 1024 + new_pos = np.random.normal(ics.pos.xyz.to(u.pc).value, 100., + size=(norbits,3)).T * u.pc + new_vel = np.random.normal(ics.vel.d_xyz.to(u.km/u.s).value, 1., + size=(norbits,3)).T * u.km/u.s + new_ics = gd.PhaseSpacePosition(pos=new_pos, vel=new_vel) + orbits = gp.Hamiltonian(pot).integrate_orbit(new_ics, dt=2., n_steps=2000) + + grid = np.linspace(-15,15,64) + fig,ax = plt.subplots(1, 1, figsize=(5,5)) + fig = pot.plot_contours(grid=(grid,grid,0), cmap='Greys', ax=ax) + orbits[-1].plot(['x', 'y'], color='#9ecae1', s=1., alpha=0.5, + axes=[ax], auto_aspect=False) + fig.tight_layout() diff --git a/gala/source/docs/tutorials/mock-stream-heliocentric.rst b/gala/source/docs/tutorials/mock-stream-heliocentric.rst new file mode 100644 index 0000000000000000000000000000000000000000..246d47a00e3ac21cdb5b9de02eae1037f5b40e29 --- /dev/null +++ b/gala/source/docs/tutorials/mock-stream-heliocentric.rst @@ -0,0 +1,167 @@ +.. _mockstream-heliocentric: + +=========================================================================== +Generating a mock stellar stream and converting to Heliocentric coordinates +=========================================================================== + +We first need to import some relevant packages:: + + >>> import astropy.coordinates as coord + >>> import astropy.units as u + >>> import numpy as np + >>> import gala.coordinates as gc + >>> import gala.dynamics as gd + >>> import gala.potential as gp + >>> from gala.units import galactic + +We will also set the default Astropy Galactocentric frame parameters to the +values adopted in Astropy v4.0: + + >>> _ = coord.galactocentric_frame_defaults.set('v4.0') + +In the examples below, we will use the ``galactic`` +`~gala.units.UnitSystem`: as I define it, this is: :math:`{\rm kpc}`, +:math:`{\rm Myr}`, :math:`{\rm M}_\odot`. + +We first create a potential object to work with. For this example, we'll +use a two-component potential: a Miyamoto-Nagai disk with a spherical NFW +potential to represent a dark matter halo. + + >>> pot = gp.CCompositePotential() + >>> pot['disk'] = gp.MiyamotoNagaiPotential(m=6E10*u.Msun, + ... a=3.5*u.kpc, b=280*u.pc, + ... units=galactic) + >>> pot['halo'] = gp.NFWPotential(m=7E11, r_s=15*u.kpc, units=galactic) + +We'll use the Palomar 5 globular cluster and stream as a motivation for this +example. For the position and velocity of the cluster, we'll use +:math:`(\alpha, \delta) = (229, −0.124)~{\rm deg}` [odenkirchen02]_, +:math:`d = 22.9~{\rm kpc}` [bovy16]_, +:math:`v_r = -58.7~{\rm km}~{\rm s}^{-1}` [bovy16]_, and +:math:`(\mu_{\alpha,*}, \mu_\delta) = (-2.296,-2.257)~{\rm mas}~{\rm yr}^{-1}` +[fritz15]_:: + + >>> c = coord.ICRS(ra=229 * u.deg, dec=-0.124 * u.deg, + ... distance=22.9 * u.kpc, + ... pm_ra_cosdec=-2.296 * u.mas/u.yr, + ... pm_dec=-2.257 * u.mas/u.yr, + ... radial_velocity=-58.7 * u.km/u.s) + +We'll first convert this position and velocity to Galactocentric coordinates:: + + >>> c_gc = c.transform_to(coord.Galactocentric()).cartesian + >>> c_gc + + >>> pal5_w0 = gd.PhaseSpacePosition(c_gc) + +We can now use the position and velocity of the cluster to generate a :ref:`mock +stellar stream ` with a progenitor that ends up at the present-day +position of the cluster. We will generate a stream using the prescription +defined in [fardal15]_, but including the self-gravity of the cluster mass +itself. We will represent the cluster with a Plummer potential, with mass +:math:`2.5 \times 10^4~{\rm M}_\odot`:: + + >>> pal5_mass = 2.5e4 * u.Msun + >>> pal5_pot = gp.PlummerPotential(m=pal5_mass, b=4*u.pc, units=galactic) + +We now have to specify that we want to use the Fardal method for generating +stream particle initial conditions by creating a +`~gala.dynamics.mockstream.FardalStreamDF` instance:: + + >>> from gala.dynamics import mockstream as ms + >>> df = ms.FardalStreamDF() + +Finally, we can generate the stream using the +`~gala.dynamics.mockstream.MockStreamGenerator`:: + + >>> gen_pal5 = ms.MockStreamGenerator(df, pot, + ... progenitor_potential=pal5_pot) + >>> pal5_stream, _ = gen_pal5.run(pal5_w0, pal5_mass, + ... dt=-1 * u.Myr, n_steps=4000) + +Here the negative timestep tells the stream generator to first integrate the orbit of the progenitor (the Pal 5 cluster itself) backwards in time, then generate the stream forwards from the past until present day:: + + >>> pal5_stream.plot(alpha=0.1) # doctest: +SKIP + +.. plot:: + :align: center + :context: close-figs + + import astropy.coordinates as coord + import astropy.units as u + import numpy as np + import gala.coordinates as gc + import gala.dynamics as gd + import gala.potential as gp + from gala.units import galactic + from gala.dynamics import mockstream as ms + + coord.galactocentric_frame_defaults.set('v4.0') + + pot = gp.CCompositePotential() + pot['disk'] = gp.MiyamotoNagaiPotential(m=6E10*u.Msun, + a=3.5*u.kpc, b=280*u.pc, + units=galactic) + pot['halo'] = gp.NFWPotential(m=1E12, r_s=20*u.kpc, units=galactic) + + c = coord.ICRS(ra=229 * u.deg, dec=-0.124 * u.deg, + distance=22.9 * u.kpc, + pm_ra_cosdec=-2.296 * u.mas/u.yr, + pm_dec=-2.257 * u.mas/u.yr, + radial_velocity=-58.7 * u.km/u.s) + + c_gc = c.transform_to(coord.Galactocentric()).cartesian + pal5_w0 = gd.PhaseSpacePosition(c_gc) + + pal5_mass = 2.5e4 * u.Msun + pal5_pot = gp.PlummerPotential(m=pal5_mass, b=4*u.pc, units=galactic) + + df = ms.FardalStreamDF(gala_modified=True) + gen_pal5 = ms.MockStreamGenerator(df, pot, progenitor_potential=pal5_pot) + pal5_stream, _ = gen_pal5.run(pal5_w0, pal5_mass, + dt=-1 * u.Myr, n_steps=4000) + + pal5_stream.plot(alpha=0.1) + +We now have the model stream particle positions and velocities in a +Galactocentric coordinate frame. To convert these to observable, Heliocentric +coordinates, we have to specify a desired coordinate frame. We'll convert to the +ICRS coordinate system and plot some of the Heliocentric kinematic quantities:: + + >>> stream_c = pal5_stream.to_coord_frame(coord.ICRS()) + +.. plot:: + :align: center + :context: close-figs + + stream_c = pal5_stream.to_coord_frame(coord.ICRS()) + + style = dict(marker='.', s=1, alpha=0.5) + + fig, axes = plt.subplots(1, 2, figsize=(10,5), sharex=True) + + axes[0].scatter(stream_c.ra.degree, + stream_c.dec.degree, **style) + axes[0].set_xlim(250, 220) + axes[0].set_ylim(-15, 15) + + axes[1].scatter(stream_c.ra.degree, + stream_c.radial_velocity.to(u.km/u.s), **style) + axes[1].set_xlim(250, 220) + axes[1].set_ylim(-100, 0) + + axes[0].set_xlabel(r'$\alpha\,[{\rm deg}]$') + axes[1].set_xlabel(r'$\alpha\,[{\rm deg}]$') + axes[0].set_ylabel(r'$\delta\,[{\rm deg}]$') + axes[1].set_ylabel(r'$v_r\,[{\rm km}\,{\rm s}^{-1}]$') + + fig.tight_layout() + +References +========== + +.. [odenkirchen02] `Odenkirchen et al. (2002) `_ +.. [fritz15] `Fritz & Kallivayalil (2015) `_ +.. [bovy16] `Bovy et al. (2016) `_ diff --git a/gala/source/docs/tutorials/nb_setup b/gala/source/docs/tutorials/nb_setup new file mode 100644 index 0000000000000000000000000000000000000000..e5202e8c86aac13c478779598a9ae2b42f9108e5 --- /dev/null +++ b/gala/source/docs/tutorials/nb_setup @@ -0,0 +1,31 @@ +get_ipython().run_line_magic("config", 'InlineBackend.figure_format = "retina"') # noqa + +import matplotlib.pyplot as plt + +plt.style.use("default") + +# NOTE: if you update these, also update docs/conf.py +plot_rcparams = { + "image.cmap": "magma", + # Fonts: + "font.size": 16, + "figure.titlesize": "x-large", + "axes.titlesize": "large", + "axes.labelsize": "large", + "xtick.labelsize": "medium", + "ytick.labelsize": "medium", + # Axes: + "axes.labelcolor": "k", + "axes.axisbelow": True, + # Ticks + "xtick.color": "#333333", + "xtick.direction": "in", + "ytick.color": "#333333", + "ytick.direction": "in", + "xtick.top": True, + "ytick.right": True, + "figure.dpi": 300, + "savefig.dpi": 300, +} + +plt.rcParams.update(plot_rcparams) diff --git a/gala/source/docs/tutorials/pyia-gala-orbit.py b/gala/source/docs/tutorials/pyia-gala-orbit.py new file mode 100644 index 0000000000000000000000000000000000000000..f2c329b45136fa0ed4d714d70641558abefbb9cb --- /dev/null +++ b/gala/source/docs/tutorials/pyia-gala-orbit.py @@ -0,0 +1,208 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: light +# format_version: '1.5' +# jupytext_version: 1.11.1 +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# + nbsphinx="hidden" +# %run nb_setup +# - + +# %matplotlib inline + +# # Compute a Galactic orbit for a star using Gaia data +# +# In this tutorial, we will retrieve the sky coordinates, astrometry, and radial velocity for a star — [Kepler-444](https://en.wikipedia.org/wiki/Kepler-444) — and compute its orbit in the default Milky Way mass model implemented in Gala. We will compare the orbit of Kepler-444 to the orbit of the Sun. +# +# ### Notebook Setup and Package Imports + +# + +import astropy.coordinates as coord +import astropy.units as u +import matplotlib.pyplot as plt +from pyia import GaiaData + +# Gala +import gala.dynamics as gd +import gala.potential as gp + +# - + +# ## Define a Galactocentric Coordinate Frame +# +# We will start by defining a Galactocentric coordinate system using `astropy.coordinates` by adopting the latest parameter set for the Solar position and velocity with respect to the Galactic Center implemented in Astropy. + +with coord.galactocentric_frame_defaults.set("v4.0"): + galcen_frame = coord.Galactocentric() +galcen_frame + +# ## Define the Solar Position and Velocity + +# In this coordinate system, the sun is along the $x$-axis (at a negative $x$ value), and the Galactic rotation at this position is in the $+y$ direction. In this coordinate system, the 3D position of the sun is therefore given by: + +sun_xyz = u.Quantity( + [-galcen_frame.galcen_distance, 0 * u.kpc, galcen_frame.z_sun] # x # y # z +) + +# We can combine this with the solar velocity vector (set on the `astropy.coordinates.Galactocentric` frame) to define the sun's phase-space position, which we will use as initial conditions shortly to compute the orbit of the Sun: + +sun_w0 = gd.PhaseSpacePosition(pos=sun_xyz, vel=galcen_frame.galcen_v_sun) + +# To compute the sun's orbit, we need to specify a mass model for the Galaxy. Here, we will use the same default, four-component Milky Way mass model introduced in [Defining a Milky Way model](define-milky-way-model.html): + +mw_potential = gp.MilkyWayPotential(version="latest") + +# We can now compute the Sun's orbit using the default integrator (Leapfrog integration): We will compute the orbit for 4 Gyr, which is about 16 orbital periods: + +sun_orbit = mw_potential.integrate_orbit(sun_w0, dt=0.5 * u.Myr, t1=0, t2=4 * u.Gyr) + +# ## Retrieve Gaia Data for Kepler-444 + +# For our comparison star, we will use the exoplanet-hosting star Kepler-444. To get Gaia data for this source, we first have to retrieve its sky coordinates so that we can do a positional cross-match query on the Gaia catalog. We can retrieve the sky position of Kepler-444 using the `SkyCoord.from_name()` classmethod, which queries Simbad under the hood to resolve the name: + +star_sky_c = coord.SkyCoord.from_name("Kepler-444") +star_sky_c + +# We happen to know a priori that Kepler-444 has a large proper motion, so the sky position reported by Simbad (unknown epoch) could be off from the Gaia sky position (epoch=2016) by many arcseconds. To run and retrieve the Gaia data, we will use the [pyia](http://pyia.readthedocs.io/) package: We can pass in an ADQL query, which `pyia` uses to query the Gaia science archive using `astroquery`, and returns the data as a `pyia` object that understands how to convert the Gaia data columns into a `astropy.coordinates.SkyCoord` object. To run the query, we will do a large sky position cross-match (with a radius of 15 arcseconds), and take the brightest cross-matched source within this region: + +star_gaia = GaiaData.from_query( + f""" + SELECT TOP 1 * FROM gaiadr3.gaia_source + WHERE 1=CONTAINS( + POINT('ICRS', {star_sky_c.ra.degree}, {star_sky_c.dec.degree}), + CIRCLE('ICRS', ra, dec, {(15 * u.arcsec).to_value(u.degree)}) + ) + ORDER BY phot_g_mean_mag + """ +) +star_gaia + +# We will assume (and hope!) that this source is Kepler-444, but we know that it is fairly bright compared to a typical Gaia source, so we should be safe. +# +# We can now use the returned `pyia.GaiaData` object to convert the Gaia astrometric and radial velocity measurements into an Astropy `SkyCoord` object (with all position and velocity data): + +star_gaia_c = star_gaia.get_skycoord() + +# To compute this star's Galactic orbit, we need to convert its observed, Heliocentric (actually solar system barycentric) data into the Galactocentric coordinate frame we defined above. To do this, we will use the `astropy.coordinates` transformation framework: + +star_galcen = star_gaia_c.transform_to(galcen_frame) +star_galcen + +# Now with Galactocentric position and velocity components for Kepler-444, we can create Gala initial conditions and compute its orbit on the time grid used to compute the Sun's orbit above: + +star_w0 = gd.PhaseSpacePosition(star_galcen.data) +star_orbit = mw_potential.integrate_orbit(star_w0, t=sun_orbit.t) + +# + +fig, axes = plt.subplots(1, 2, figsize=(10, 5), constrained_layout=True) + +sun_orbit.plot(["x", "y"], axes=axes[0]) +star_orbit.plot(["x", "y"], axes=axes[0]) +axes[0].set_xlim(-10, 10) +axes[0].set_ylim(-10, 10) + +sun_orbit.cylindrical.plot( + ["rho", "z"], + axes=axes[1], + auto_aspect=False, + labels=["$R$ [kpc]", "$z$ [kpc]"], + label="Sun", +) +star_orbit.cylindrical.plot( + ["rho", "z"], + axes=axes[1], + auto_aspect=False, + labels=["$R$ [kpc]", "$z$ [kpc]"], + label="Kepler-444", +) +axes[1].set_xlim(0, 10) +axes[1].set_ylim(-5, 5) +axes[1].set_aspect("auto") +axes[1].legend(loc="best", fontsize=15) +# - +# ### Exercise: How does Kepler-444's orbit differ from the Sun's? +# +# What is the maximum $z$ height reached by each orbit? What are their eccentricities? What are the guiding center radii of the two orbits? Can you guess which star is older based on their kinematics? Which star do you think has a higher metallicity? + + +# ### Exercise: Comparing these orbits to the orbits of other Gaia stars +# +# Retrieve Gaia data for a set of 100 random Gaia stars within 200 pc of the sun with measured radial velocities and well-measured parallaxes using the query: +# +# SELECT TOP 100 * FROM gaiadr3.gaia_source +# WHERE radial_velocity IS NOT NULL AND +# parallax_over_error > 10 AND +# ruwe < 1.2 AND +# parallax > 5 +# ORDER BY random_index + +random_stars_g = GaiaData.from_query( + """ + SELECT TOP 100 * FROM gaiadr3.gaia_source + WHERE radial_velocity IS NOT NULL AND + parallax_over_error > 10 AND + ruwe < 1.2 AND + parallax > 5 + ORDER BY random_index + """ +) + +# Compute orbits for these stars for the same time grid used above to compute the sun's orbit: + +random_stars_c = random_stars_g.get_skycoord() + +random_stars_galcen = random_stars_c.transform_to(galcen_frame) +random_stars_w0 = gd.PhaseSpacePosition(random_stars_galcen.data) + +random_stars_orbits = mw_potential.integrate_orbit(random_stars_w0, t=sun_orbit.t) + +# Plot the initial (present-day) positions of all of these stars in Galactocentric Cartesian coordinates: + +_ = random_stars_w0.plot() + +# Plot the orbits of these stars in the x-y and R-z planes: + +# + +fig, axes = plt.subplots(1, 2, figsize=(10, 5), constrained_layout=True) + +random_stars_orbits.plot(["x", "y"], axes=axes[0]) +axes[0].set_xlim(-15, 15) +axes[0].set_ylim(-15, 15) + +random_stars_orbits.cylindrical.plot( + ["rho", "z"], + axes=axes[1], + auto_aspect=False, + labels=["$R$ [kpc]", "$z$ [kpc]"], +) + +axes[1].set_xlim(0, 15) +axes[1].set_ylim(-5, 5) +axes[1].set_aspect("auto") +# - + +# Compute maximum $z$ heights ($z_\textrm{max}$) and eccentricities for all of these orbits. Compare the Sun, Kepler-444, and this random sampling of nearby stars. Where do the Sun and Kepler-444 sit relative to the random sample of nearby stars in terms of $z_\textrm{max}$ and eccentricity? (Hint: plot $z_\textrm{max}$ vs. eccentricity and highlight the Sun and Kepler-444!) Are either of them outliers in any way? + +rand_zmax = random_stars_orbits.zmax() + +rand_ecc = random_stars_orbits.eccentricity() + +fig, ax = plt.subplots(figsize=(8, 6)) +ax.scatter( + rand_ecc, rand_zmax, color="k", alpha=0.4, s=14, lw=0, label="random nearby stars" +) +ax.scatter(sun_orbit.eccentricity(), sun_orbit.zmax(), color="tab:orange", label="Sun") +ax.scatter( + star_orbit.eccentricity(), star_orbit.zmax(), color="tab:cyan", label="Kepler-444" +) +ax.legend(loc="best", fontsize=14) +ax.set_xlabel("eccentricity, $e$") +ax.set_ylabel(r"max. $z$ height, $z_{\rm max}$ [kpc]") diff --git a/gala/source/docs/tutorials/spherical-spline-tutorial.py b/gala/source/docs/tutorials/spherical-spline-tutorial.py new file mode 100644 index 0000000000000000000000000000000000000000..668e8f9b8950dde4d46ab093ffbb307e77f05b59 --- /dev/null +++ b/gala/source/docs/tutorials/spherical-spline-tutorial.py @@ -0,0 +1,382 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.11.2 +# kernelspec: +# display_name: gala +# language: python +# name: python3 +# --- + +# %% nbsphinx="hidden" +# %run nb_setup + +# %% +# %matplotlib inline + +# %% [markdown] +# # Building custom spherical potential models with spline interpolation +# +# In this tutorial, we will demonstrate how to use the `SphericalSplinePotential` class to construct flexible, spherically-symmetric potential models from tabulated data. This is useful when you have a density profile with no simple closed-form potential, or when you want to approximate a potential from simulation data. +# +# We will explore these two use cases: +# +# 1. **Analytic density with no closed-form potential**: You have an analytic density profile for which the potential cannot be expressed in closed form. +# 2. **Simulation-based density profile**: You have density measurements from a simulation and want to quickly generate an approximate potential model (e.g., for orbit integration or dynamical analysis). +# +# ### Notebook Setup and Package Imports + +# %% +import astropy.units as u +import matplotlib.pyplot as plt +import numpy as np + +import gala.dynamics as gd +import gala.potential as gp +from gala.units import galactic + +# %% [markdown] +# ## Use Case 1: Analytic Density with No Closed-Form Potential +# +# ### The Problem +# +# Suppose we are studying a stellar halo component with a density profile that falls off as a power law at intermediate radii but has a steeper break at large radii. We want to model this with a density function: +# +# $$ +# \rho(r) = \frac{\rho_0}{(1 + r/r_s)^{\alpha}} \exp\left(-\left(\frac{r}{r_{\rm cut}}\right)^2\right) +# $$ +# +# where $\rho_0$ is a normalization, $r_s$ is a scale radius, $\alpha$ controls the inner power-law slope, and $r_{\rm cut}$ is an exponential cutoff radius. For arbitrary $\alpha$ and the exponential cutoff, there is no simple closed-form expression for the potential. +# +# ### Define the Density Profile +# +# Let's define this density profile in Python: + + +# %% +def halo_density( + r, rho0=1e9 * u.Msun / u.kpc**3, rs=10.0 * u.kpc, alpha=2.5, rcut=100.0 * u.kpc +): + return rho0 / (1 + r / rs) ** alpha * np.exp(-((r / rcut) ** 2)) + + +# %% [markdown] +# Let's visualize this density profile over a range of radii: + +# %% +r_grid = np.geomspace(0.1, 250, 512) * u.kpc +rho_grid = halo_density(r_grid) + +fig, ax = plt.subplots(figsize=(8, 5)) +ax.loglog(r_grid, rho_grid) +ax.set_xlabel("$r$ [kpc]") +ax.set_ylabel(r"$\rho(r)$ [$M_\odot\,{\rm kpc}^{-3}$]") +ax.set_title("Stellar Halo Density Profile") +ax.grid(True, alpha=0.3) + +# %% [markdown] +# ### Build a SphericalSplinePotential from the Density +# +# To construct a potential model, we need to: +# 1. Choose radial knot locations where we will sample the density +# 2. Evaluate the density at those knots +# 3. Create a `SphericalSplinePotential` with `spline_value_type='density'` +# +# **Some notes to consider:** +# - Use enough knots to capture the curvature of the density profile +# - Extend the knot grid slightly beyond the region of interest to avoid edge effects from the spline boundary conditions +# - Use logarithmic spacing for knots since the density varies over many orders of magnitude + +# %% +# Define radial knots - we use log spacing and extend beyond the region of interest +r_knots = np.geomspace(1e-2, 500, 512) * u.kpc + +# Evaluate density at knots +rho_knots = halo_density(r_knots) + +# Create the spline potential +halo_pot = gp.SphericalSplinePotential( + r_knots=r_knots, + spline_values=rho_knots, + spline_value_type="density", + interpolation_method="cspline", + units=galactic, +) + +# %% [markdown] +# Now we can use this potential like any other Gala potential! Let's visualize the potential and the density recovered from the spline: + +# %% +r_eval = np.geomspace(0.1, 250, 256) * u.kpc + +# Evaluate potential and density using the r= symmetry coordinate +# (spherical potentials only depend on radius) +phi_eval = halo_pot.energy(r=r_eval) +rho_recovered = halo_pot.density(r=r_eval) + +fig, axes = plt.subplots(2, 1, figsize=(8, 8), sharex=True) + +# Plot potential +axes[0].plot(r_eval, phi_eval) +axes[0].set_ylabel(rf"$\Phi$ [{phi_eval.unit}]") +axes[0].grid(True, alpha=0.3) +axes[0].set_xscale("log") + +# Plot density comparison +axes[1].loglog(r_eval, rho_recovered, label="Recovered from spline", alpha=0.8) +axes[1].loglog(r_eval, halo_density(r_eval), "--", label="Original density", alpha=0.6) +axes[1].scatter( + r_knots, rho_knots, s=1, c="C2", alpha=0.4, label="Knot locations", zorder=10 +) +axes[1].set_xlabel("$r$ [kpc]") +axes[1].set_ylabel(r"$\rho(r)$ [$M_\odot\,{\rm kpc}^{-3}$]") +axes[1].legend() +axes[1].grid(True, alpha=0.3) + +fig.tight_layout() + +# %% [markdown] +# The recovered density matches the input density extremely well! Let's compute some orbits in this potential. +# +# ### Computing Orbits in the Custom Potential +# +# We can integrate orbits in this custom potential just as we would with any built-in potential. Let's launch test particles from different radii and compare their circular velocities: + +# %% +# Compute circular velocity curve using symmetry coordinates +r_circ = np.linspace(1, 100, 200) * u.kpc + +v_circ = halo_pot.circular_velocity(r=r_circ) + +fig, ax = plt.subplots(figsize=(8, 5)) +ax.plot(r_circ, v_circ) +ax.set_xlabel("$r$ [kpc]") +ax.set_ylabel(r"$v_{\rm circ}$ [km/s]") +ax.set_title("Circular Velocity Curve") +ax.grid(True, alpha=0.3) + +# %% [markdown] +# Now let's integrate some orbits with different initial conditions: + +# %% +# Define initial conditions: particles at different radii with near-circular velocities +r_init = np.array([10.0, 30.0, 50.0, 75]) * u.kpc +v_init = 0.9 * halo_pot.circular_velocity(r=r_init) + +# Create initial phase-space positions +w0_list = [] +for r, v in zip(r_init, v_init): + w0_list.append( + gd.PhaseSpacePosition( + pos=[r.value, 0, 0] * r.unit, + vel=[0, v.value, 0] * v.unit, + ) + ) + +# Integrate orbits +w0s = gd.combine(w0_list) +orbits = halo_pot.integrate_orbit(w0s, dt=1 * u.Myr, t1=0, t2=2 * u.Gyr) + +# %% [markdown] +# Visualize the orbits: + +# %% +fig = orbits.plot(["x", "y"]) + +# %% [markdown] +# ## Use Case 2: Simulation-Based Density Profile +# +# ### The Problem +# +# Suppose you have a cosmological simulation and measured the spherically-averaged density profile of a dark matter halo. You want to create a smooth potential model for this halo to compute orbits of satellites, calculate dynamical quantities like circular velocities, etc. +# +# ### Generate Mock Simulation Data +# +# For this example, we'll generate mock "simulation" data that might come from radial binning of particle positions. Real simulation data would be read from a file, but the process is the same: + +# %% +# Simulate measuring density in radial bins (e.g., from N-body particles) +rng = np.random.default_rng(42) + + +# "True" density profile (NFW-like with some scatter) +def nfw_density(r, rho_s=1e8 * u.Msun / u.kpc**3, r_s=15.0 * u.kpc): + """NFW density profile""" + x = r / r_s + return rho_s / (x * (1 + x) ** 2) + + +# Radial bins (as might come from a simulation) +r_bins = np.logspace(-0.5, 2.2, 30) * u.kpc + +# "Measured" densities with some noise to simulate finite sampling +mnfw = 1e12 * u.Msun +rs = 15 * u.kpc +rho0 = mnfw / (4 * np.pi * rs**3) +rho_bins = nfw_density(r_bins, rho_s=rho0, r_s=rs) +# Add log-normal scatter to simulate measurement uncertainty +scatter = rng.lognormal(mean=0, sigma=0.1, size=len(r_bins)) +rho_bins_measured = rho_bins * scatter + +# %% [markdown] +# Let's visualize our "measured" simulation data: + +# %% +fig, ax = plt.subplots(figsize=(8, 5)) + +# Plot the "true" profile +r_true = np.logspace(-1, 2.5, 200) * u.kpc +ax.loglog( + r_true, + nfw_density(r_true, rho0, rs), + "k--", + alpha=0.3, + label="True NFW profile", + lw=2, +) + +# Plot the "measured" data points +ax.loglog(r_bins, rho_bins_measured, "o", label="Simulated measurements", ms=6) + +ax.set_xlabel("$r$ [kpc]") +ax.set_ylabel(r"$\rho(r)$ [$M_\odot\,{\rm kpc}^{-3}$]") +ax.set_title("Dark Matter Halo Density from Simulation") +ax.legend() +ax.grid(True, alpha=0.3) + +# %% [markdown] +# ### Build a Potential from Simulation Data +# +# Now we'll create a `SphericalSplinePotential` directly from these "measured" densities. The cubic spline interpolation will smooth over the measurement noise: + +# %% +# Create potential from simulation data +sim_pot = gp.SphericalSplinePotential( + r_knots=r_bins, + spline_values=rho_bins_measured, + spline_value_type="density", + interpolation_method="cspline", + units=galactic, +) + +# %% [markdown] +# Let's compare the rotation curve from our spline potential to what we would get from the true NFW profile: + +# %% +# For comparison, create an NFW potential with the same parameters +nfw_pot = gp.NFWPotential(m=mnfw, r_s=rs, units=galactic) + +# Compute circular velocities using symmetry coordinates +r_test = np.linspace(1, 100, 200) * u.kpc + +v_circ_sim = sim_pot.circular_velocity(r=r_test) +v_circ_nfw = nfw_pot.circular_velocity(r=r_test) + +fig, axes = plt.subplots(2, 1, figsize=(8, 8), sharex=True, layout="tight") + +# Circular velocity comparison +axes[0].plot(r_test, v_circ_sim, label="Spline potential (from sim data)", lw=2) +axes[0].plot(r_test, v_circ_nfw, "--", label="True NFW potential", alpha=0.7, lw=2) +axes[0].set_ylabel(r"$v_{\rm circ}$ [km/s]") +axes[0].legend() +axes[0].grid(True, alpha=0.3) + +# Density comparison +rho_sim = sim_pot.density(r=r_test) +rho_nfw_true = nfw_density(r_test, rho_s=rho0, r_s=rs) + +axes[1].loglog(r_test, rho_sim, label="Spline (smoothed sim data)", lw=2) +axes[1].loglog(r_test, rho_nfw_true, "--", label="True NFW", alpha=0.7, lw=2) +axes[1].scatter( + r_bins, + rho_bins_measured, + s=20, + c="gray", + alpha=0.5, + label="Sim measurements", + zorder=10, +) +axes[1].set_xlabel("$r$ [kpc]") +axes[1].set_ylabel(r"$\rho(r)$ [$M_\odot\,{\rm kpc}^{-3}$]") +axes[1].legend() +axes[1].grid(True, alpha=0.3) + +# %% [markdown] +# Excellent! The spline potential reproduces the true NFW profile well, even though we only provided noisy binned measurements. The spline interpolation naturally smooths over the noise. +# +# ### Practical Considerations for Simulation Data +# +# When working with simulation data: +# +# 1. **Choose appropriate radial bins**: Use bins that are finely spaced where the density varies rapidly (typically at small radii) and coarser bins at large radii. +# +# 2. **Handle measurement uncertainties**: If your measurements have large uncertainties, consider: +# - Using `interpolation_method='akima'` or `'steffen'` to reduce overshoot between noisy points +# - Smoothing the data before creating the spline +# - Using more radial bins if you have sufficient particle counts +# +# 3. **Extend beyond the region of interest**: Include radial bins beyond the maximum radius where you plan to integrate orbits. This prevents edge effects in the spline. +# +# 4. **Validate the result**: Always plot the recovered density, potential, and circular velocity to ensure the spline is behaving sensibly. + +# %% [markdown] +# ### Exercise: Comparing Interpolation Methods +# +# Different interpolation methods can produce different results, especially with noisy data. Let's compare cubic spline vs. Akima interpolation on our simulated data: + +# %% +# Create potentials with different interpolation methods +sim_pot_akima = gp.SphericalSplinePotential( + r_knots=r_bins, + spline_values=rho_bins_measured, + spline_value_type="density", + interpolation_method="akima", + units=galactic, +) + +# Compare circular velocities +v_circ_akima = sim_pot_akima.circular_velocity(r=r_test) + +fig, ax = plt.subplots(figsize=(8, 5)) +ax.plot(r_test, v_circ_sim, label="cspline (default)", lw=2) +ax.plot(r_test, v_circ_akima, "--", label="akima", alpha=0.8, lw=2) +ax.plot(r_test, v_circ_nfw, ":", label="True NFW", alpha=0.5, lw=2, color="k") +ax.set_xlabel("$r$ [kpc]") +ax.set_ylabel(r"$v_{\rm circ}$ [km/s]") +ax.set_title("Effect of Interpolation Method") +ax.legend() +ax.grid(True, alpha=0.3) + +# %% [markdown] +# Both methods work well for this relatively smooth data. The Akima method tends to produce less overshoot in regions with rapid changes or noise, while cubic splines generally produce smoother second derivatives (which affects the recovered density). + +# %% [markdown] +# ## Summary and Recommendations +# +# We've demonstrated how to use `SphericalSplinePotential` for two use cases. +# +# - **Use `'cspline'` interpolation** (the default) when possible, as it provides continuous second derivatives and physically smooth densities. +# +# - **Extend knots beyond your region of interest** by 20% or more to minimize edge effects from boundary conditions. Cubic splines force the second derivative to zero at endpoints, which can create artifacts. +# +# - **Use logarithmic spacing for knots** when working with profiles that span many orders of magnitude in radius or density. +# +# - **Validate your potential**: Always plot the recovered density, potential, circular velocity, and compare to your expectations or input data. +# +# - **For noisy data**, consider using `'akima'` or `'steffen'` interpolation to reduce overshoot, or smooth your input data before creating the spline. + +# %% [markdown] +# ### Exercise: Build Your Own Model +# +# Try creating a `SphericalSplinePotential` for a different density profile: +# +# - **Broken power law**: $\rho(r) = \rho_0 \times (r/r_b)^{-\alpha_{\rm in}}$ for $r < r_b$ and $(r/r_b)^{-\alpha_{\rm out}}$ for $r > r_b$ +# - **Double power law (Hernquist-like)**: $\rho(r) = \rho_0 / [(r/r_s)^{\gamma}(1 + r/r_s)^{4-\gamma}]$ +# - **Your own custom profile**: Combine multiple components or use a functional form from a paper +# +# Compute orbits in your custom potential and visualize the results! diff --git a/gala/source/docs/tutorials/stream-mass-loss.py b/gala/source/docs/tutorials/stream-mass-loss.py new file mode 100644 index 0000000000000000000000000000000000000000..a3951344589c98f4a6dd18a1ac37d97a0cae4cf9 --- /dev/null +++ b/gala/source/docs/tutorials/stream-mass-loss.py @@ -0,0 +1,286 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.11.2 +# kernelspec: +# display_name: gala +# language: python +# name: python3 +# --- + +# %% nbsphinx="hidden" +# %run nb_setup + +# %% +# %matplotlib inline + +# %% [markdown] +# # Generate a mock stellar stream with a realistic mass-loss history +# +# In this tutorial, we will demonstrate how to use the mock stream generation functionality in Gala to simulate a stellar stream with a mass-evolving progenitor star cluster, and a non-uniform mass-loss history. For simplicity, we will assume that the scale radius of the progenitor cluster does not change and only its mass evolves from an initial mass of $10^5~{\rm M}_\odot$, losing mass primarily in bursts around pericentric passages. +# +# +# +# ### Notebook Setup and Package Imports + +# %% +import astropy.units as u +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np +from scipy import integrate, interpolate, stats + +import gala.dynamics as gd +import gala.potential as gp + +# %% [markdown] +# ## Define the Milky Way potential and progenitor orbit +# +# We'll start by defining a Milky Way potential model using the built-in +# `MilkyWayPotential` class. This provides a simple but reasonable model for +# the Milky Way's mass distribution: + +# %% +mw = gp.MilkyWayPotential(version="latest") + +# %% [markdown] +# Next, we'll define the initial conditions for our progenitor cluster. We'll +# place it at a position of (13, 0, 20) kpc in Galactocentric Cartesian +# coordinates with a velocity that will put it on an eccentric orbit: + +# %% +prog_w0 = gd.PhaseSpacePosition( + pos=[13.0, 0.0, 20.0] * u.kpc, vel=[0, 130.0, 50] * u.km / u.s +) + +# %% [markdown] +# Now we'll integrate the orbit of the progenitor for 6 Gyr using a 1 Myr +# timestep: + +# %% +orbit = mw.integrate_orbit(prog_w0, t1=0, t2=6 * u.Gyr, dt=1 * u.Myr) +print(f"Orbit eccentricity: {orbit.eccentricity()}") + +# %% [markdown] +# Let's visualize the orbit in 3D: + +# %% +_ = orbit.plot() + +# %% [markdown] +# We can also look at the progenitor's distance from the Galactic center as a function of time. We expect the cluster to lose more mass around pericenters: + +# %% +fig, ax = plt.subplots(figsize=(6, 4), layout="tight") +ax.plot(orbit.t.to_value(u.Gyr), orbit.physicsspherical.r.to_value(u.kpc)) + +# %% [markdown] +# ## Define a realistic mass-loss history +# +# Now we'll define a mass-loss history for the progenitor cluster that +# concentrates mass loss around pericentric passages. First, let's find all of +# the pericenters along the orbit: + +# %% +peri, peri_times = orbit.pericenter(return_times=True, func=None) + +# %% [markdown] +# We'll model the mass-loss probability as a mixture of Gaussians centered on each +# pericenter, with a uniform background component to represent steady tidal stripping. +# This therefore represents a scenario where stars are stripped continuously but with +# enhanced stripping at pericenters: + +# %% +K = len(peri_times) + 1 # number of mixture components + +t_lim = (orbit.t.to_value(u.Myr).min(), orbit.t.to_value(u.Myr).max()) +weights = np.zeros(K) +weights[0] = 1.0 +weights[1:] = 0.2 +weights /= weights.sum() + +gmm = stats.Mixture( + [stats.Uniform(a=t_lim[0], b=t_lim[1])] + + [stats.Normal(mu=tt, sigma=25.0) for tt in peri_times.to_value(u.Myr)], + weights=weights, +) + +# %% [markdown] +# Now we'll compute the cumulative distribution function (CDF) of the mixture model, +# which we can use to determine how the cluster mass evolves with time: + +# %% +t_grid = orbit.t.to_value(u.Myr) +cum_pdf = integrate.cumulative_simpson(y=gmm.pdf(t_grid), x=t_grid) + +# %% [markdown] +# Good - this looks like a reasonable mass-loss history. We'll assume the cluster starts +# with an initial mass of $10^5~{\rm M}_\odot$ and loses 90% of its mass over the 6 Gyr +# integration. We create an interpolator for the time-varying mass and visualize the +# mass evolution: + +# %% +init_mass = 1e5 * u.Msun +mass_loss_frac = 0.9 + +prog_mass_t = interpolate.InterpolatedUnivariateSpline( + 0.5 * (t_grid[1:] + t_grid[:-1]), init_mass * (1 - mass_loss_frac * cum_pdf) +) +plt.plot(t_grid, prog_mass_t(t_grid)) + +# %% [markdown] +# ## Sample particle release times from the mass-loss history +# +# We'll now sample 8000 particle release times from our mass-loss history. This +# will determine when stars are stripped from the progenitor: + +# %% +release_times = gmm.sample(shape=8000, rng=np.random.default_rng(12345)) * u.Myr + +# %% [markdown] +# Now we'll bin these release times to match our integration timesteps and count +# how many particles should be released at each time step: + +# %% +stream_t = np.arange(0.0, 6000.0 + 1e-3, 1) * u.Myr + +release_idx = np.digitize(release_times.to_value(u.Myr), stream_t.to_value(u.Myr)) - 1 +release_idx, n_release = np.unique(release_idx, return_counts=True) + +n_particles = np.zeros(len(stream_t), dtype=int) +n_particles[release_idx] = n_release + +# %% [markdown] +# Let's visualize the particle release rate over time: + +# %% +plt.plot(stream_t[release_idx], n_release) + +# %% [markdown] +# ## Generate the mock stream +# +# Now we're ready to generate the stream simulation. We'll use a `ChenStreamDF` +# distribution function to model the velocity distribution of escaping particles. +# We also need to define a time-varying potential for the progenitor cluster +# using `TimeInterpolatedPotential`, which allows us to specify the mass of the cluster +# at each time knot: + +# %% +df = gd.ChenStreamDF() +prog_pot = gp.TimeInterpolatedPotential( + gp.PlummerPotential, + time_knots=stream_t, + m=prog_mass_t(stream_t.to_value(u.Myr)) * u.Msun, + b=10 * u.pc, + units="galactic", +) + +# %% [markdown] +# With the distribution function, Milky Way potential, and progenitor potential +# all defined, we can now create a `MockStreamGenerator` and generate the stream. +# This will integrate the orbits of all released particles from their release +# times to the present: + +# %% +gen = gd.MockStreamGenerator(df, mw, progenitor_potential=prog_pot) +stream, prog_f = gen.run( + prog_w0, + prog_mass=prog_mass_t(stream_t.to_value(u.Myr)) * u.Msun, + t=stream_t, + n_particles=n_particles, + Integrator="leapfrog", +) + +# %% [markdown] +# ## Generate a comparison stream with constant mass +# +# To see the effect of the time-varying mass, let's also generate a stream with +# a static progenitor mass (equal to the initial mass). This will help us +# see how the mass-loss history affects the stream structure: + +# %% +prog_pot_static = gp.PlummerPotential(m=prog_mass_t(0.0), b=10 * u.pc, units="galactic") +gen_static = gd.MockStreamGenerator(df, mw, progenitor_potential=prog_pot_static) +stream_static, prog_f_static = gen_static.run( + prog_w0, prog_mass=prog_mass_t(0.0) * u.Msun, t=stream_t, Integrator="leapfrog" +) + +# %% [markdown] +# ## Visualize the streams +# +# Now let's rotate both streams into the progenitor's orbital plane to better +# see their structure: + +# %% +stream_rot = stream.rotate_to_progenitor_plane(prog_f) +stream_rot_static = stream_static.rotate_to_progenitor_plane(prog_f_static) + +# %% [markdown] +# Let's plot both streams together to compare them: + +# %% +fig = stream_rot.plot(marker="o", s=1, alpha=0.5) +fig = stream_rot_static.plot(marker="o", s=1, alpha=0.5, color="C1", axes=fig.axes) + +# %% [markdown] +# We can create a more detailed comparison by plotting 2D histograms of both +# streams side-by-side. This shows the density distribution in the progenitor's +# orbital plane: + +# %% +binsx = np.arange(-10, 10 + 1e-3, 0.05) +binsy = np.arange(-3, 3 + 1e-3, 0.05) + +fig, axes = plt.subplots(1, 2, figsize=(12, 5), layout="tight") + +ax = axes[0] +for ax, _stream in zip(axes, [stream_rot, stream_rot_static]): + H, xe, ye = np.histogram2d( + _stream.x.to_value(u.kpc), _stream.y.to_value(u.kpc), bins=(binsx, binsy) + ) + ax.pcolormesh( + xe, ye, H.T, shading="auto", norm=mpl.colors.LogNorm(0.5, 1e2), cmap="magma_r" + ) + ax.set_xlabel("progenitor frame $x$ [kpc]") + +axes[0].set_title("With cluster mass loss and mass-loss history") +axes[1].set_title("No time dependence") +axes[0].set_ylabel("progenitor frame $y$ [kpc]") + +# %% [markdown] +# Finally, let's compare the linear density profiles along the stream. This +# clearly shows how the realistic mass-loss history (with enhanced stripping at +# pericenters) produces a different density distribution compared to the static +# mass case: + +# %% +bins = np.linspace(-10, 10, 128) + +fig, ax = plt.subplots() +ax.hist( + stream_rot.x.to_value(u.kpc), + bins=bins, + density=True, + histtype="step", + label="With cluster mass loss and mass-loss history", +) +ax.hist( + stream_rot_static.x.to_value(u.kpc), + bins=bins, + color="C1", + density=True, + histtype="step", + label="No time dependence", +) +ax.set( + xlabel="progenitor frame $x$ [kpc]", + ylabel="linear density", +) +ax.legend(loc="upper left", fontsize=10) + +# %% diff --git a/gala/source/docs/tutorials/time-evolving-potential.py b/gala/source/docs/tutorials/time-evolving-potential.py new file mode 100644 index 0000000000000000000000000000000000000000..9d78501dd6c6f89d37f6e557ffa80b65e7ce2bb2 --- /dev/null +++ b/gala/source/docs/tutorials/time-evolving-potential.py @@ -0,0 +1,380 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.11.2 +# kernelspec: +# display_name: gala +# language: python +# name: python3 +# --- + +# %% nbsphinx="hidden" +# %run nb_setup + +# %% +# %matplotlib inline + +# %% [markdown] +# # Working with time-evolving potential models +# +# In this tutorial, we will demonstrate how to use the `TimeInterpolatedPotential` +# wrapper class in Gala to create potentials with time-varying parameters. This is +# useful for modeling scenarios like: +# +# - Mass loss from a star cluster or satellite galaxy +# - Growing or shrinking potentials (e.g., a forming galaxy) +# - Rotating bar potentials with time-varying pattern speeds +# - etc. +# +# The `TimeInterpolatedPotential` class uses GSL splines to interpolate potential +# parameters, origins, and rotation matrices between discrete time knots. We'll +# explore how to use this class with different potential models, control the +# interpolation method, and understand its behavior at the boundaries of the +# interpolation range. +# +# ### Notebook Setup and Package Imports + +# %% +import astropy.units as u +import matplotlib.pyplot as plt +import numpy as np +from scipy.spatial.transform import Rotation + +import gala.dynamics as gd +import gala.potential as gp + +# %% [markdown] +# ## Basic usage: Time-varying mass in a point mass potential +# +# Let's start with a simple example: a point mass (Kepler) potential where the +# mass grows linearly with time. This could represent, for example, a growing +# black hole or the infall of matter onto a central object. +# +# First, we'll define our time knots and the corresponding masses at each knot: + +# %% +# Define time knots spanning 1 Gyr +time_knots = np.linspace(0, 1, 11) * u.Gyr + +# Mass linearly growing from 1e10 to 2e10 solar masses +masses = np.linspace(1e10, 2e10, len(time_knots)) * u.Msun + +# %% [markdown] +# Now we create the `TimeInterpolatedPotential`. The first argument is the +# potential class we want to wrap (not an instance, but the class itself), the +# second argument is the array of time knots, and then we pass the time-varying +# parameters using the parameter names of the underlying potential: + +# %% +pot_varying_mass = gp.TimeInterpolatedPotential( + gp.KeplerPotential, time_knots, m=masses, units="galactic" +) + +print(repr(pot_varying_mass)) + +# %% [markdown] +# Let's visualize how the potential energy changes with time at a fixed position: + +# %% +test_position = [10.0, 0.0, 0.0] * u.kpc +test_times = np.linspace(0, 1000, 100) * u.Myr + +energies = [pot_varying_mass.energy(test_position, t=t) for t in test_times] + +fig, ax = plt.subplots(figsize=(6, 5)) +ax.plot(test_times.to_value(u.Gyr), [e.value for e in energies]) +ax.set_xlabel("Time [Gyr]") +ax.set_ylabel(f"Potential Energy [{energies[0].unit:latex_inline}]") +ax.set_title("Energy at fixed position vs. time") + +# %% [markdown] +# As expected, the potential energy becomes more negative (deeper) as the mass +# increases. Now let's integrate an orbit in this time-varying potential and +# compare it to an orbit in a static potential with the initial mass: + +# %% +# Initial conditions +w0 = gd.PhaseSpacePosition(pos=[10.0, 0.0, 0.0] * u.kpc, vel=[0, 50.0, 0] * u.km / u.s) + +# Integrate in time-varying potential +orbit_varying = pot_varying_mass.integrate_orbit( + w0, t1=0, t2=1000 * u.Myr, dt=1 * u.Myr +) + +# Create static potential with initial mass for comparison +pot_static = gp.KeplerPotential(m=masses[0], units="galactic") +orbit_static = pot_static.integrate_orbit(w0, t1=0, t2=1000 * u.Myr, dt=1 * u.Myr) + +# %% [markdown] +# Let's visualize how the orbits differ: + +# %% +fig, axes = plt.subplots(1, 2, figsize=(10, 5), layout="tight") + +ax = axes[0] +orbit_varying.plot(["x", "y"], axes=ax, color="tab:blue", alpha=0.5, auto_aspect=False) +orbit_static.plot(["x", "y"], axes=ax, color="tab:orange", alpha=0.5, auto_aspect=False) +ax.set_title("Orbital trajectories") + +ax = axes[1] +orbit_varying.spherical.plot( + ["t", "distance"], axes=ax, color="tab:blue", alpha=0.5, label="time-varying" +) +orbit_static.spherical.plot( + ["t", "distance"], axes=ax, color="tab:orange", alpha=0.5, label="static" +) +ax.legend() +ax.set_title("Radial distance vs. time") + +# %% [markdown] +# The orbit in the time-varying potential shows the effect of the increasing mass: the orbital radius decreases over time as the central mass grows stronger. +# +# ## Using different interpolation methods +# +# The `TimeInterpolatedPotential` supports several interpolation methods from GSL. +# Let's compare how different methods interpolate the same data. The available +# methods are: +# +# - `'linear'`: Linear interpolation (requires 2+ knots) +# - `'cspline'`: Cubic spline (requires 3+ knots, default) +# - `'akima'`: Akima spline (requires 5+ knots, avoids unphysical wiggles) +# - `'steffen'`: Steffen spline (requires 3+ knots, guarantees monotonicity) + +# %% +# For this example, let's use fewer knots to make differences more visible +sparse_times = np.array([0, 250, 500, 750, 1000]) * u.Myr +sparse_masses = np.array([1e10, 1.3e10, 1.8e10, 1.6e10, 2e10]) * u.Msun + +# Create potentials with different interpolation methods +interp_methods = ["linear", "cspline", "akima", "steffen"] +pots = {} + +for method in interp_methods: + pots[method] = gp.TimeInterpolatedPotential( + gp.KeplerPotential, + sparse_times, + m=sparse_masses, + units="galactic", + interpolation_method=method, + ) + +# %% [markdown] +# Now let's compare how each method interpolates the mass parameter: + +# %% +eval_times = np.linspace(0, 1000, 200) * u.Myr +test_pos = [10.0, 0.0, 0.0] * u.kpc + +fig, ax = plt.subplots(figsize=(8, 5)) + +# Plot the mass evolution implied by energy at a fixed position +for method in interp_methods: + energies = [pots[method].energy(test_pos, t=t) for t in eval_times] + ax.plot(eval_times.to_value(u.Myr), energies, label=method, lw=1) + +# Mark the knot positions +ax.scatter( + sparse_times.to_value(u.Myr), + [pots["linear"].energy(test_pos, t=t).value for t in sparse_times], + color="black", + s=100, + zorder=10, + label="knot positions", +) + +ax.set_xlabel("Time [Myr]") +ax.set_ylabel(f"Potential Energy [{energies[0].unit.to_string('latex')}]") +ax.legend() +ax.set_title("Comparison of interpolation methods") +ax.grid(alpha=0.3) + +# %% [markdown] +# Notice the differences: +# +# - **Linear**: Piecewise linear, no smoothness between segments +# - **Cspline**: Smooth (continuous second derivative) but can overshoot +# - **Akima**: Smooth and avoids overshooting near sharp changes +# - **Steffen**: Guarantees monotonicity between knots (no spurious oscillations) +# +# For physical applications, `steffen` and `akima` are often useful when you +# want to avoid unphysical oscillations in the interpolated values. +# +# ## Time-varying rotation: Modeling a rotating bar +# +# The `TimeInterpolatedPotential` can also interpolate rotation matrices, +# allowing you to model rotating structures like galactic bars. Let's create a +# bar potential that rotates over time: + +# %% +# Time knots for rotation +rot_times = np.linspace(0, 3, 128) * u.Gyr + +# Create rotation matrices for bar rotation +Omega = np.pi * u.rad / (100 * u.Myr) +angles = (Omega * rot_times).to_value(u.rad) +rotation_matrices = Rotation.from_euler("z", angles).as_matrix() + +# Create a bar potential with time-varying rotation +pot_rotating_bar = gp.TimeInterpolatedPotential( + gp.LongMuraliBarPotential, + rot_times, + m=1e10 * u.Msun, + a=3 * u.kpc, + b=1 * u.kpc, + c=0.5 * u.kpc, + R=rotation_matrices, + units="galactic", +) + +# %% [markdown] +# Let's test the rotation by evaluating the potential gradient at a fixed point +# in space at different times: + +# %% +test_pos = [5.0, 0.0, 0.0] * u.kpc +sample_times = np.array([0, 25, 50, 75, 100]) * u.Myr + +print("Gradient components at x = [5, 0, 0] kpc at different times:") + +for t in sample_times: + grad = pot_rotating_bar.gradient(test_pos, t=t) + print(np.squeeze(grad)) + +# %% [markdown] +# As the bar rotates, the gradient components change. At t=0, the point is along +# the x-axis and the bar is aligned with x, so we see mainly x-direction force. +# As time progresses and the bar rotates, the force direction changes. +# +# Let's integrate an orbit in this rotating bar potential: + +# %% +w0_bar = gd.PhaseSpacePosition( + pos=[8.0, 0.0, 0.0] * u.kpc, vel=[0, 50.0, 0] * u.km / u.s +) + +orbit_rot_bar = pot_rotating_bar.integrate_orbit( + w0_bar, t1=0, t2=2 * u.Gyr, dt=0.5 * u.Myr, Integrator="dop853" +) + +# %% [markdown] +# Visualize the orbit in the rotating bar potential: + +# %% +fig = orbit_rot_bar.plot(["x", "y"]) + +# %% [markdown] +# ## Multiple time-varying parameters +# +# You can have multiple parameters varying with time simultaneously. Let's create +# a Hernquist potential where both the mass and scale radius change: + +# %% +time_knots_multi = np.linspace(0, 2000, 21) * u.Myr + +# Mass decreases (mass loss) +masses_multi = np.linspace(5e11, 1e11, 21) * u.Msun + +# Scale radius expands slightly as mass is lost +scale_radii = np.linspace(5, 8, 21) * u.kpc + +pot_multi = gp.TimeInterpolatedPotential( + gp.HernquistPotential, + time_knots_multi, + m=masses_multi, + c=scale_radii, + units="galactic", +) + +# %% [markdown] +# Let's see how the circular velocity curve changes with time: + +# %% +radii = np.linspace(1, 30, 50) * u.kpc +sample_times_multi = np.array([0, 500, 1000, 1500, 2000]) * u.Myr + +fig, ax = plt.subplots(figsize=(10, 6)) + +for t in sample_times_multi: + v_circs = [] + for r in radii: + pos = [r.value, 0, 0] * u.kpc + # v_circ = sqrt(r * |dPhi/dr|) + grad = pot_multi.gradient(pos, t=t) + v_circ = np.sqrt(r * np.abs(grad[0])) + v_circs.append(v_circ.to(u.km / u.s).value) + + ax.plot(radii.to_value(u.kpc), v_circs, label=f"t = {t.value:.0f} Myr") + +ax.set_xlabel("Radius [kpc]") +ax.set_ylabel("Circular velocity [km/s]") +ax.legend() +ax.grid(alpha=0.3) +ax.set_title("Time evolution of circular velocity curve") + +# %% [markdown] +# The circular velocity decreases over time as both the mass decreases and the +# scale radius increases. +# +# ## Boundary behavior: No extrapolation +# +# An important feature of `TimeInterpolatedPotential` is that it does **not** +# extrapolate beyond the time range defined by the time knots. If you try to +# evaluate the potential or integrate an orbit outside this range, you'll get +# NaN values or an error. Let's demonstrate this: + +# %% +# Create a potential with a limited time range +limited_times = np.array([100, 200, 300]) * u.Myr +limited_masses = np.array([1e10, 1.5e10, 2e10]) * u.Msun + +pot_limited = gp.TimeInterpolatedPotential( + gp.KeplerPotential, + limited_times, + m=limited_masses, + units="galactic", + interpolation_method="cspline", +) + +test_pos_bounds = [5.0, 0.0, 0.0] * u.kpc + +# Try evaluating at different times +test_times_bounds = np.array([50, 150, 250, 350]) * u.Myr + +print("Evaluating potential at different times:") +print( + f"Time knot range: [{limited_times.min().value}, {limited_times.max().value}] Myr" +) +print() +print(f"{'Time [Myr]':<15} {'Energy':<30} {'Status'}") +print("-" * 60) + +for t in test_times_bounds: + energy = pot_limited.energy(test_pos_bounds, t=t) + status = "Outside range" if t.value < 100 or t.value > 300 else "Valid" + print(f"{t.to_value(u.Myr):<15.0f} {energy!s:<30} {status}") + +# %% [markdown] +# Notice that evaluations at t=50 Myr (before the first knot) and t=350 Myr +# (after the last knot) return NaN. This is by design - the interpolation is only +# valid within the specified time range. +# +# If you try to integrate an orbit that goes outside the time range, you'll get +# an error: + +# %% +w0_bounds = gd.PhaseSpacePosition( + pos=[10.0, 0.0, 0.0] * u.kpc, vel=[0, 100.0, 0] * u.km / u.s +) + +try: + # This should raise an error because we're trying to integrate from 0 to 400 Myr + # but the potential is only defined from 100 to 300 Myr + orbit_bad = pot_limited.integrate_orbit(w0_bounds, t1=0, t2=400, dt=1) +except ValueError as e: + print("Error caught as expected:") + print(f" {e}") diff --git a/gala/source/docs/tutorials/v1_11_new_features.py b/gala/source/docs/tutorials/v1_11_new_features.py new file mode 100644 index 0000000000000000000000000000000000000000..be37602effc8f8837575661214ae967294b3e882 --- /dev/null +++ b/gala/source/docs/tutorials/v1_11_new_features.py @@ -0,0 +1,336 @@ +# --- +# jupyter: +# jupytext: +# custom_cell_magics: kql +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.11.2 +# kernelspec: +# display_name: gala +# language: python +# name: python3 +# --- + +# %% nbsphinx="hidden" +# %run nb_setup + +# %% [markdown] +# # What's New in Gala v1.11 +# +# Gala v1.11 introduces several new features and improvements, especially in the potential modeling functionality. Below are some highlights of the new features, but see the changelog for the full list of additions and changes. + +# %% +import astropy.units as u +import matplotlib.pyplot as plt +import numpy as np + +import gala.dynamics as gd +import gala.potential as gp +from gala.units import galactic + +# %matplotlib inline + +# %% [markdown] +# --- +# +# ## Potential models for arbitrary spherical profiles +# +# The new `SphericalSplinePotential` class allows you to create spherical potential models from tabulated data. You can specify either the density, enclosed mass, or potential values on a grid of radial positions. This is useful when you have a density profile with no closed-form potential, when working with simulation data, or if you want to create a custom potential model that is not implemented in Gala. +# +# For example, imagine we have a density profile with a complex or non-existent closed-form potential solution. Here, we will use a Gaussian-truncated NFW profile as an example: +# $$ +# \rho(r) = \frac{\rho_0}{(r/r_s)(1 + r/r_s)^2} \, \exp\left(-\frac{r^2}{r_t^2}\right) +# $$ +# +# We can create a `SphericalSplinePotential` from the density profile as follows. +# +# First, implement the density profile: + + +# %% +def truncated_nfw_density( + r, rho0=1.6e7 * u.Msun / u.kpc**3, r_s=15 * u.kpc, r_t=100 * u.kpc +): + uu = r / r_s + return rho0 / (uu * (1 + uu) ** 2) * np.exp(-((r / r_t) ** 2)) + + +# %% [markdown] +# Evaluate on a grid of radius values. In cases like this, it's good to evaluate on a wide radial range (i.e. larger than the range of radii that you anticipate evaluating on) because the model does not extrapolate beyond the min/max radius values of the defined grid. + +# %% +r_grid = np.logspace(-2, 3, 512) * u.kpc + +rho0 = 1.6e7 * u.Msun / u.kpc**3 +r_s = 15 * u.kpc +r_t = 100 * u.kpc +rho_grid = truncated_nfw_density(r_grid, rho0=rho0, r_s=r_s, r_t=r_t) + +# %% +truncated_nfw_pot = gp.SphericalSplinePotential( + r_knots=r_grid, + spline_values=rho_grid, + spline_value_type="density", + units=galactic, +) + +# %% [markdown] +# For comparison, we will also define a standard NFW profile with the same central density and scale radius: + +# %% +compare_nfw_pot = gp.NFWPotential(rho0 * 4 * np.pi * r_s**3, r_s, units=galactic) + +# %% [markdown] +# Now we can plot and compare the density profiles and, for example, the circular velocity curves derived from the two models: + +# %% +fig, axes = plt.subplots(1, 2, figsize=(12, 5), layout="constrained") + +ax = axes[0] +ax.loglog( + r_grid.value, + rho_grid.to_value(u.Msun / u.pc**3), + marker="", + label="Truncated NFW density", +) +ax.loglog( + r_grid.value, + compare_nfw_pot.density(r=r_grid).to_value(u.Msun / u.pc**3), + ls="-", + marker="", + label="NFW density", +) +ax.axvline(r_t.to_value(u.kpc), ls="-", color="#aaaaaa", zorder=-10) +ax.set( + xlabel="radius, $r$ [kpc]", + ylabel=r"density, $\rho(r)$ " + f"[{u.Msun / u.pc**3:latex_inline}]", + ylim=(1e-15, 2e2), +) +ax.legend(fontsize=14) + +# --- + +ax = axes[1] +ax.plot( + r_grid.value, + truncated_nfw_pot.circular_velocity(r=r_grid).to_value(u.km / u.s), + marker="", +) +ax.plot( + r_grid.value, + compare_nfw_pot.circular_velocity(r=r_grid).to_value(u.km / u.s), + ls="-", + marker="", +) +ax.axvline(r_t.to_value(u.kpc), ls="-", color="#aaaaaa", zorder=-10) +ax.set( + xlabel="radius, $r$ [kpc]", + ylabel=r"circular velocity, $v_c(r)$ " + f"[{u.km / u.s:latex_inline}]", +) + +# %% [markdown] +# --- +# +# ## Potential classes now understand coordinate symmetries +# +# Spherical and axisymmetric potential models can now be evaluated using radius and radius+vertical position, respectively, instead of full 3D Cartesian coordinates. For example, for most spherical potential methods (e.g., `potential()`, `gradient()`, `mass_enclosed()`, `circular_velocity()`, etc.), you can call these functions with `r=...` instead of passing in a Cartesian coordinate array. Similarly, cylindrical potentials can use `R=...` and `z=...` in these same functions. This makes it more convenient to evaluate symmetric potentials along specific axes. + +# %% +# Spherical potential example - use r= for spherical radius +hernquist = gp.HernquistPotential(m=1e12 * u.Msun, c=10 * u.kpc, units=galactic) + +r = np.linspace(1, 100, 50) * u.kpc + +# Old way: construct full 3D coordinates +xyz = np.zeros((3, len(r))) * u.kpc +xyz[0] = r +mass_old_way = hernquist.mass_enclosed(xyz) + +# New way: just pass r= +mass_new_way = hernquist.mass_enclosed(r=r) + +# %% +# Cylindrical potential example - use R= and z= for cylindrical coordinates +disk = gp.MiyamotoNagaiPotential( + m=6e10 * u.Msun, a=3 * u.kpc, b=0.28 * u.kpc, units=galactic +) + +R = np.linspace(1, 20, 50) * u.kpc + +# Evaluate using cylindrical coordinates directly +# For example: +energy_cyl = disk.energy(R=R, z=0.5 * u.kpc) +density_cyl = disk.density(R=R, z=0.5 * u.kpc) + +fig, axes = plt.subplots(1, 2, figsize=(10, 4), layout="constrained") + +axes[0].plot(R, energy_cyl) +axes[0].set_xlabel("$R$ [kpc]") +axes[0].set_ylabel(f"energy [{energy_cyl.unit:latex_inline}]") + +axes[1].semilogy(R, density_cyl) +axes[1].set_xlabel("$R$ [kpc]") +axes[1].set_ylabel(f"density [{density_cyl.unit:latex_inline}]") + +fig.suptitle("Miyamoto-Nagai disk", fontsize=18) + +# %% [markdown] +# --- +# +# ## Potential parameters can now be time dependent +# +# The new `TimeInterpolatedPotential` class wraps any potential class to support time-dependent parameters by interpolating their values. This is useful for modeling evolving systems like systems with mass loss, growing potentials, or time-varying pattern speeds (e.g., slowing bars). This works for potential parameters (e.g., mass, scale radius, etc.) along with the potential's origin and rotation. +# +# As a first example, we will create a Plummer potential with a mass that increases non-linearly over time: + +# %% +# Define time knots and mass values +t_knots = np.linspace(0, 2, 11) * u.Gyr +mass_knots = ( + 1e10 * np.linspace(1, np.sqrt(5), len(t_knots)) ** 2 * u.Msun +) # mass increasing from 1e10 to 5e10 + +growing_plummer_pot = gp.TimeInterpolatedPotential( + gp.PlummerPotential, t_knots, m=mass_knots, b=1 * u.kpc, units=galactic +) + +plt.figure(figsize=(6, 4)) +plt.plot(t_knots, mass_knots.value / 1e10) +plt.xlabel("$t$ [Gyr]") +plt.ylabel(f"mass [$10^{{10}}$ {mass_knots.unit:latex_inline}]") + +# %% [markdown] +# These potentials can be used just like any other Gala potential model. For example, we can integrate an orbit in this potential: + +# %% +# Compare orbits in time-varying vs static potential +w0 = gd.PhaseSpacePosition(pos=[5, 0, 0] * u.kpc, vel=[0, 75, 0] * u.km / u.s) + +orbit_growing = growing_plummer_pot.integrate_orbit( + w0, t1=0, t2=2 * u.Gyr, dt=1 * u.Myr +) + +# Integrate in static potential (with initial mass) +static_plummer_pot = gp.PlummerPotential(m=mass_knots[0], b=1 * u.kpc, units=galactic) +orbit_static = static_plummer_pot.integrate_orbit(w0, t1=0, t2=2 * u.Gyr, dt=1 * u.Myr) + +# %% +fig, axes = plt.subplots(1, 2, figsize=(10, 5), layout="constrained") + +orbit_growing.plot( + ["x", "y"], lw=2, axes=axes[0], auto_aspect=False, label="Time-varying mass" +) +orbit_static.plot( + ["x", "y"], axes=axes[0], auto_aspect=False, label="Static mass", lw=1, alpha=0.5 +) +axes[0].legend() +axes[0].set_title("Orbital trajectories") + +axes[1].plot( + orbit_growing.t.to(u.Gyr), + orbit_growing.spherical.distance.to(u.kpc), + label="Time-varying", +) +axes[1].plot( + orbit_static.t.to(u.Gyr), orbit_static.spherical.distance.to(u.kpc), label="Static" +) +axes[1].set_xlabel("Time [Gyr]") +axes[1].set_ylabel("Distance [kpc]") +axes[1].legend() +axes[1].set_title("Radial distance vs time") + +# %% [markdown] +# --- +# +# ## String unit systems +# +# Unit systems can now be specified using string names like `'galactic'` or `'dimensionless'` when initializing potentials or replacing units. This is more convenient than importing the unit system objects. + +# %% +# Create potentials with string unit systems +pot_galactic = gp.HernquistPotential(m=1e12 * u.Msun, c=10 * u.kpc, units="galactic") +print(f"Units: {pot_galactic.units}") + +pot_solar = gp.KeplerPotential(m=1 * u.Msun, units="solarsystem") +print(f"Units: {pot_solar.units}") + +# %% [markdown] +# --- +# +# ## String integrator names +# +# Integrators can now be specified using lowercase string names like `'leapfrog'`, `'dopri853'`, or `'ruth4'` instead of importing the integrator classes. This works in `Hamiltonian.integrate_orbit()`, `DirectNBody.integrate_orbit()`, and `MockStreamGenerator.run()`. + +# %% +# Define a potential and initial conditions +pot = gp.NFWPotential.from_circular_velocity( + v_c=200 * u.km / u.s, r_s=15 * u.kpc, units="galactic" +) +H = gp.Hamiltonian(pot) +w0 = gd.PhaseSpacePosition(pos=[10, 0, 0] * u.kpc, vel=[0, 180, 50] * u.km / u.s) + +# Integrate with different integrators using string names +orbit_leapfrog = H.integrate_orbit( + w0, dt=1 * u.Myr, n_steps=1000, Integrator="leapfrog" +) +orbit_ruth4 = H.integrate_orbit(w0, dt=1 * u.Myr, n_steps=1000, Integrator="ruth4") +orbit_dopri = H.integrate_orbit(w0, dt=1 * u.Myr, n_steps=1000, Integrator="dopri853") + +# Compare the results +fig, ax = plt.subplots(figsize=(6, 6)) +orbit_leapfrog.plot(["x", "y"], axes=ax, label="Leapfrog", alpha=0.7) +orbit_ruth4.plot(["x", "y"], axes=ax, label="Ruth4", alpha=0.7) +orbit_dopri.plot(["x", "y"], axes=ax, label="DOPRI853", alpha=0.7) +ax.legend() +ax.set_title("Orbits with different integrators") + +# %% [markdown] +# --- +# +# ## The `MilkyWayPotential` classes have been combined +# +# The `MilkyWayPotential` and `MilkyWayPotential2022` classes have been combined into a single `MilkyWayPotential` class with a `version=` keyword argument. Use `version='v1'` for the original model or `version='v2'` (or `'latest'`) for the 2022 model. + +# %% +mw_v1 = gp.MilkyWayPotential(version="v1") # formerly: MilkyWayPotential() +mw_v2 = gp.MilkyWayPotential(version="v2") # formerly: MilkyWayPotential2022() +mw_latest = gp.MilkyWayPotential(version="latest") # same as v2 + +# %% [markdown] +# --- +# +# ## Mock streams can now be generated using the Leapfrog Integrator +# +# The Leapfrog integrator can now be used with `MockStreamGenerator` by passing `Integrator='leapfrog'` (or the class directly) in the `run()` method. This allows faster stream generation for cases where the adaptive integrator is not needed. + +# %% +from gala.dynamics import mockstream as ms + +# Set up a Milky Way potential and progenitor +mw = gp.MilkyWayPotential(version="v2") +prog_w0 = gd.PhaseSpacePosition(pos=[15, 0, 0] * u.kpc, vel=[0, 180, 50] * u.km / u.s) + +# Define the progenitor mass and stream distribution function +prog_pot = gp.PlummerPotential(m=1e5 * u.Msun, b=10 * u.pc, units="galactic") +df = ms.ChenStreamDF() + +# Create the mock stream generator +gen = ms.MockStreamGenerator(df=df, hamiltonian=mw, progenitor_potential=prog_pot) + +# Generate stream using the Leapfrog integrator +stream, prog = gen.run( + prog_w0, + t1=0 * u.Gyr, + t2=-2 * u.Gyr, + dt=-1 * u.Myr, + prog_mass=prog_pot.parameters["m"], + n_particles=1, # 1 particle per release time (leading and trailing) + Integrator="leapfrog", # Use string name for integrator! +) + +print(f"Generated stream with {stream.shape[0]} particles") + +# %% +_ = stream.plot() diff --git a/gala/source/docs/units.rst b/gala/source/docs/units.rst new file mode 100644 index 0000000000000000000000000000000000000000..2a9849e6f71d9a1d7c598d41f23281ecec375217 --- /dev/null +++ b/gala/source/docs/units.rst @@ -0,0 +1,130 @@ +.. include:: references.txt + +.. _gala-units: + +*************************** +Unit Systems (`gala.units`) +*************************** + +Introduction +============ + +This module contains a class for handling systems of units, and provides a few +pre-defined unit systems that are useful for galactic dynamics. + +For the examples below, I assume the following imports have already been +executed:: + + >>> import astropy.units as u + >>> import numpy as np + >>> from gala.units import UnitSystem + +Unit Systems +============ + +A unit system is defined by a set of base units that specify length, time, mass, +and angle units. A `~gala.units.UnitSystem` object is created by passing in +units with (at least) these four required physical types:: + + >>> usys = UnitSystem(u.cm, u.millisecond, u.degree, u.gram) + >>> usys + + +Astropy :class:`~astropy.units.Quantity` objects can be decomposed into this +unit system using :meth:`~astropy.units.Quantity.decompose`:: + + >>> a = 15 * u.km/u.s + >>> a.decompose(usys) + + +`~gala.units.UnitSystem` objects can also act as a dictionary to look up a unit +for a given physical type. For example, if we want to know what a "velocity" +unit is in a given unit system, pass the key ``"speed"`` or ``"velocity"``:: + + >>> usys["speed"] + Unit("cm / ms") + +This works for the base unit physical types and for more complex physical +types:: + + >>> usys["length"] + Unit("cm") + >>> usys["pressure"] + Unit("g / (cm ms2)") + +In Astropy version 4.3 and later, units from `~gala.units.UnitSystem` objects +can also be retrieved by passing in Astropy ``PhysicalType`` instances as keys, +for example: + +.. doctest-requires:: astropy>=4.3 + + >>> ptype = u.get_physical_type("length")**2 / u.get_physical_type("time") + >>> usys[ptype] + Unit("cm2 / ms") + + +Creating unit systems with scaled base units +-------------------------------------------- + +It is sometimes useful to construct a unit system with base units that are +scaled versions of units. For example, you may want to create a unit system with +the base units (10 kpc, 200 Myr, 1000 Msun). To construct a +`~gala.units.UnitSystem` with scaled base units, pass in +`~astropy.units.Quantity` objects. For example:: + + >>> usys = UnitSystem(10 * u.kpc, 200 * u.Myr, 1000 * u.Msun, u.radian) + >>> usys + + >>> q = 15.7 * u.kpc + >>> q.decompose(usys) + + +Or, to create a unit system in which G=1, given length and mass units:: + + >>> from astropy.constants import G + >>> L_unit = 1 * u.kpc + >>> M_unit = 1e6 * u.Msun + >>> T_unit = np.sqrt((L_unit**3) / (G * M_unit)) + >>> usys = UnitSystem(L_unit, M_unit, T_unit.to(u.Myr), u.radian) + >>> np.round(usys.get_constant("G"), 5) # doctest: +FLOAT_CMP + 1.0 + + +Custom display units +-------------------- + +It is sometimes useful to have default display units for physical types that are +not simple compositions of base units. For example, for kinematics within the +Milky Way, a common base unit system consists of (kpc, Myr, Msun), but +velocities are often expressed or displayed in km/s. To change the default +display unit of a composite unit, specify the preferred unit on creation:: + + >>> usys = UnitSystem(u.kpc, u.Myr, u.radian, u.Msun) + >>> usys2 = UnitSystem(u.kpc, u.Myr, u.radian, u.Msun, u.km/u.s) + >>> usys["velocity"], usys2["velocity"] + (Unit("kpc / Myr"), Unit("km / s")) + +For unit systems with specified composite units (e.g., ``usys2`` above), +the Astropy :meth:`~astropy.units.Quantity.decompose` method will fail because +it only uses the base units:: + + >>> q = 150 * u.pc/u.Myr + >>> q.decompose(usys2) + + +Because we specified a unit for quantities with a physical type = "velocity", we +can instead use the `~gala.units.UnitSystem.decompose` method of the +`~gala.units.UnitSystem` object to retrieve the object in the desired display +unit:: + + >>> usys2.decompose(q) + + + +.. _gala-units-api: + +API +=== + +.. automodapi:: gala.units + :no-inheritance-diagram: diff --git a/gala/source/docs/user_guide.rst b/gala/source/docs/user_guide.rst new file mode 100644 index 0000000000000000000000000000000000000000..3dd548543e4a8d4203def8a2a9b1e64056a3cb90 --- /dev/null +++ b/gala/source/docs/user_guide.rst @@ -0,0 +1,38 @@ +.. include:: references.txt + +.. _gala-user-guide: + +********** +User Guide +********** + +The user guide contains comprehensive descriptions of all functions and +classes available in ``gala``, with inline examples and demonstrations. +This documentation serves as reference material, while the :ref:`gala-tutorials` +show how ``gala`` components work together for realistic research applications. + +.. toctree:: + :maxdepth: 1 + + conventions + coordinates/index + integrate/index + potential/index + dynamics/index + units + util + interop + +.. toctree:: + :hidden: + + glossary + + +Recent additions and changes +============================ + +.. toctree:: + :maxdepth: 2 + + whatsnew/index diff --git a/gala/source/docs/util.rst b/gala/source/docs/util.rst new file mode 100644 index 0000000000000000000000000000000000000000..1147c8c361b22389d8086a6911fcb52ddaed0af8 --- /dev/null +++ b/gala/source/docs/util.rst @@ -0,0 +1,20 @@ +.. include:: references.txt + +.. _util: + +***************************** +Misc. Utilities (`gala.util`) +***************************** + +Introduction +============ + +This subpackage contains miscellaneous utilities. + +.. _util-api: + +API +=== + +.. automodapi:: gala.util + :no-inheritance-diagram: diff --git a/gala/source/docs/whatsnew/1.0.rst b/gala/source/docs/whatsnew/1.0.rst new file mode 100644 index 0000000000000000000000000000000000000000..d13a38cc4f81bd9bc4667adb9c5046dc4e388b41 --- /dev/null +++ b/gala/source/docs/whatsnew/1.0.rst @@ -0,0 +1,203 @@ +.. doctest-skip-all + +.. _whatsnew-1.0: + +************************ +What's New in gala v1.0? +************************ + +Overview +======== + +Gala 1.0 is a major release with significant new functionality (some of which is +described below). + +This release includes (among other things): + +* :ref:`whatsnew-1.0-greatcircle` +* :ref:`whatsnew-1.0-new-potentials` +* :ref:`whatsnew-1.0-scf` +* :ref:`whatsnew-1.0-stream-frame-naming` +* :ref:`whatsnew-1.0-cov-matrix` + + +.. _whatsnew-1.0-greatcircle: + +Great circle coordinate systems +=============================== + +Great circle coordinate frames (GCFs) are heliocentric coordinate systems that are +typically specified as a rotation away from standard equatorial ICRS +coordinates. The resulting longitude and latitude components of a GCF specify +the angle along the great circle and the angle perpendicular (in gala, we use +:math:`\phi_1` / ``phi1`` to represent the longitude, and :math:`\phi_2` / +``phi2`` to represent the latitude). These frames are typically defined by +specifying the coordinate of the pole of the great circle, and either the +origin, :math:`(\phi_1, \phi_2) = (0, 0)`, or the longitude of the old system +(i.e. ICRS) to put at longitude :math:`\phi_1 = 0` in the new frame. The new +``GreatCircleICRSFrame`` supports both of these options, along with two other +possible ways for defining a GCF: by specifying two points along the great +circle in the old frame, and by directly specifying the cartesian basis of the +GCF in the old coordinate system. For example, to create a GCF from a pole and +longitude zero-point:: + + >>> import astropy.units as u + >>> from astropy.coordinates import SkyCoord + >>> from gala.coordinates import GreatCircleICRSFrame + >>> pole = SkyCoord(ra=255*u.deg, dec=-11.5*u.deg) + >>> gcf = GreatCircleICRSFrame(pole=pole, ra0=170*u.deg) + +Or, to create a GCF from two endpoints along a great circle:: + + >>> pt1 = SkyCoord(ra=170.*u.deg, dec=23.18*u.deg) + >>> pt2 = SkyCoord(ra=125.7*u.deg, dec=-72.2*u.deg) + >>> gcf2 = GreatCircleICRSFrame.from_endpoints(pt1, pt2) + +However you define a great circle frame, these can be used with the Astropy +coordinate transformation machinery to transform positions and velocity +components to and from this and other coordinate frames. For example, to +transform a grid of points along latitude=0 in one of these systems to Galactic +coordinates to plot the great circle on the sky, we can do:: + + >>> import numpy as np + >>> grid_c = SkyCoord(phi1=np.linspace(0, 360, 128)*u.deg, phi2=0*u.deg, + ... frame=gcf2) + >>> grid_c = grid_c.galactic + +When plotted, this would show the track of the great circle in Galactic +coordinates, i.e.: + +.. plot:: + :context: reset + :align: center + + import astropy.units as u + from astropy.coordinates import SkyCoord + from gala.coordinates import GreatCircleICRSFrame + import matplotlib.pyplot as plt + + pt1 = SkyCoord(ra=170.*u.deg, dec=23.18*u.deg) + pt2 = SkyCoord(ra=125.7*u.deg, dec=-72.2*u.deg) + gcf2 = GreatCircleICRSFrame.from_endpoints(pt1, pt2) + + grid_c = SkyCoord(phi1=np.linspace(0, 360, 128)*u.deg, phi2=0*u.deg, + frame=gcf2) + grid_c = grid_c.galactic + + plt.figure(figsize=(6, 4)) + plt.plot(grid_c.l.degree[grid_c.l.degree.argsort()], + grid_c.b.degree[grid_c.l.degree.argsort()], + ls='-', marker='') + plt.xlabel('$l$ [deg]') + plt.ylabel('$b$ [deg]') + plt.tight_layout() + + +.. _whatsnew-1.0-new-potentials: + +New potential models, including MWPotential2014 +=============================================== + +Gala now contains an implementation of the Galpy / `Bovy 2015 +`_ +``MWPotential2014``, here called `~gala.potential.BovyMWPotential2014`. This +potential class can be used like any other potential object in Gala, for +example, for orbit integration. As a brief demo, here we compare the orbit of a +Milky Way halo object in `~gala.potential.BovyMWPotential2014` as compared to +the default Gala Milky Way model implemented as +`~gala.potential.MilkyWayPotential`:: + + >>> import gala.dynamics as gd + >>> import gala.potential as gp + >>> mw_gala = gp.MilkyWayPotential() + >>> mw_bovy = gp.BovyMWPotential2014() + >>> w0 = gd.PhaseSpacePosition(pos=[25., 0, 0]*u.kpc, + ... vel=[0, 0, 200.]*u.km/u.s) + >>> orbit_gala = mw_gala.integrate_orbit(w0, dt=1., n_steps=1000) + >>> orbit_bovy = mw_bovy.integrate_orbit(w0, dt=1., n_steps=1000) + +Here is a comparison of the two orbits over-plotted on the same axes: + +.. plot:: + :context: reset + :align: center + + import astropy.units as u + import matplotlib.pyplot as plt + import gala.dynamics as gd + import gala.potential as gp + + mw_gala = gp.MilkyWayPotential() + mw_bovy = gp.BovyMWPotential2014() + w0 = gd.PhaseSpacePosition(pos=[25., 0, 0]*u.kpc, + vel=[0, 0, 200.]*u.km/u.s) + orbit_gala = mw_gala.integrate_orbit(w0, dt=1., n_steps=1000) + orbit_bovy = mw_bovy.integrate_orbit(w0, dt=1., n_steps=1000) + + fig, ax = plt.subplots(1, 1, figsize=(6, 6)) + orbit_gala.plot(['x', 'z'], label='Gala', marker='', axes=[ax]) + orbit_bovy.plot(['x', 'z'], label='Bovy2015', marker='', axes=[ax]) + plt.legend(loc='best') + plt.tight_layout() + + +.. _whatsnew-1.0-scf: + +Basis function expansion potential models with the self-consistent field method +=============================================================================== + +Gala now contains support for constructing and using flexible (static) +gravitational potential models using the self-consistent field (SCF) basis +function expansion method. Expansion coefficients can be computed from both +analytic density distributions or from discrete particle distributions (e.g., +from an N-body simulation). For more information about this new subpackage, see +the :ref:`scf` documentation. + + +.. _whatsnew-1.0-stream-frame-naming: + +Stellar stream coordinate frame names now reflect the source reference +====================================================================== + +Each of the stellar stream coordinate frames now contains the name of the author +that defined the frame. For example, the ``GD1`` frame has been renamed to +`~gala.coordaintes.GD1Koposov10` to indicate that the frame was defined in +Koposov et al. 2010. This is true for each of the major stellar stream frames: + +* ``GD1`` has been renamed `~gala.coordinates.GD1Koposov10` +* ``Sagittarius`` has been renamed `~gala.coordinates.SagittariusLaw10` +* ``Orphan`` has been renamed `~gala.coordinates.OrphanNewberg10`, and a new + Orphan stream coordinate frame has been added: + `~gala.coordinates.OrphanKoposov19` +* ``Ophiuchus`` has been renamed `~gala.coordinates.OphiuchusPriceWhelan16` +* ``Pal5`` has been renamed `~gala.coordinates.Pal5PriceWhelan18` +* ``MagellanicStream`` has been renamed + `~gala.coordinates.MagellanicStreamNidever08` + + +.. _whatsnew-1.0-cov-matrix: + +Transforming proper motion covariance matrices +============================================== + +The Gaia mission provides full astrometric covariance matrices for each of its +sources, which not only specify the uncertainty in each parameter, but also +specify the correlations between the uncertainties of the astrometric +parameters. These covariance matrices are provided in the ICRS coordinate +system, but often it is useful to transform the Gaia data to other coordinate +systems when, e.g., modeling stellar streams. The proper motion covariance +matrix can be analytically and straightforwardly transformed along with the +positions and proper motions themselves if the transformation is a rotation away +from ICRS, such as the case for the new ``GreatCircleICRSFrame`` or stellar +stream coordinate frames described above. As an example, we will transform the +Gaia proper motion covariance matrix for a source to the ``GD1Koposov10`` +coordinate frame:: + + >>> from gala.coordinates import transform_pm_cov, GD1Koposov10 + >>> cov = np.array([[ 0.07567177, -0.01698125], + ... [-0.01698125, 0.03907039]]) + >>> c = SkyCoord(ra=130.99*u.deg, dec=34.53*u.deg, + ... distance=454.76*u.pc, + ... pm_ra_cosdec=11.5*u.mas/u.yr, + ... pm_dec=-23.46661*u.mas/u.yr) + >>> cov_gd1 = transform_pm_cov(c, cov, GD1Koposov10) diff --git a/gala/source/docs/whatsnew/index.rst b/gala/source/docs/whatsnew/index.rst new file mode 100644 index 0000000000000000000000000000000000000000..d34cb9aea5a0af09f12327ef9bd38103c29659c2 --- /dev/null +++ b/gala/source/docs/whatsnew/index.rst @@ -0,0 +1,8 @@ +********************* +Major Release History +********************* + +.. toctree:: + :maxdepth: 1 + + 1.0 diff --git a/gala/source/paper/paper.bib b/gala/source/paper/paper.bib new file mode 100644 index 0000000000000000000000000000000000000000..4de9067ff10562c2bd34ea2e4a176d94fba03225 --- /dev/null +++ b/gala/source/paper/paper.bib @@ -0,0 +1,83 @@ + +@article{Pearson:2017, + Adsnote = {Provided by the SAO/NASA Astrophysics Data System}, + Adsurl = {http://adsabs.harvard.edu/abs/2017arXiv170304627P}, + Archiveprefix = {arXiv}, + Author = {{Pearson}, S. and {Price-Whelan}, A.~M. and {Johnston}, K.~V.}, + Eprint = {1703.04627}, + Journal = {ArXiv e-prints}, + Keywords = {Astrophysics - Astrophysics of Galaxies}, + Month = mar, + Title = {{Gaps in Globular Cluster Streams: Pal 5 and the Galactic Bar}}, + Year = 2017} + +@book{Binney:2008, + Adsnote = {Provided by the SAO/NASA Astrophysics Data System}, + Adsurl = {http://adsabs.harvard.edu/abs/2008gady.book.....B}, + Author = {{Binney}, J. and {Tremaine}, S.}, + Booktitle = {Galactic Dynamics: Second Edition, by James Binney and Scott Tremaine.~ISBN 978-0-691-13026-2 (HB).~Published by Princeton University Press, Princeton, NJ USA, 2008.}, + Publisher = {Princeton University Press}, + Title = {{Galactic Dynamics: Second Edition}}, + Year = 2008} + +@article{zenodo, + Abstractnote = {

Gala is a Python package for Galactic astronomy and gravitational dynamics. The bulk of the package centers around implementations of gravitational potentials, numerical integration, and nonlinear dynamics.

}, + Author = {Adrian Price-Whelan and Brigitta Sipocz and Syrtis Major and Semyeong Oh}, + Date-Modified = {2017-08-13 14:14:18 +0000}, + Doi = {10.5281/zenodo.833339}, + Month = {Jul}, + Publisher = {Zenodo}, + Title = {adrn/gala: v0.2.1}, + Year = {2017}, + Bdsk-Url-1 = {http://dx.doi.org/10.5281/zenodo.833339}} + +@ARTICLE{gaia, + author = {{Gaia Collaboration} and {Prusti}, T. and {de Bruijne}, J.~H.~J. and + {Brown}, A.~G.~A. and {Vallenari}, A. and {Babusiaux}, C. and + {Bailer-Jones}, C.~A.~L. and {Bastian}, U. and {Biermann}, M. and + {Evans}, D.~W. and et al.}, + title = "{The Gaia mission}", + journal = {\aap}, +archivePrefix = "arXiv", + eprint = {1609.04153}, + primaryClass = "astro-ph.IM", + keywords = {space vehicles: instruments, Galaxy: structure, astrometry, parallaxes, proper motions, telescopes}, + year = 2016, + month = nov, + volume = 595, + eid = {A1}, + pages = {A1}, + doi = {10.1051/0004-6361/201629272}, + adsurl = {http://adsabs.harvard.edu/abs/2016A%26A...595A...1G}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} +} + +@ARTICLE{astropy, + author = {{Astropy Collaboration} and {Robitaille}, T.~P. and {Tollerud}, E.~J. and + {Greenfield}, P. and {Droettboom}, M. and {Bray}, E. and {Aldcroft}, T. and + {Davis}, M. and {Ginsburg}, A. and {Price-Whelan}, A.~M. and + {Kerzendorf}, W.~E. and {Conley}, A. and {Crighton}, N. and + {Barbary}, K. and {Muna}, D. and {Ferguson}, H. and {Grollier}, F. and + {Parikh}, M.~M. and {Nair}, P.~H. and {Unther}, H.~M. and {Deil}, C. and + {Woillez}, J. and {Conseil}, S. and {Kramer}, R. and {Turner}, J.~E.~H. and + {Singer}, L. and {Fox}, R. and {Weaver}, B.~A. and {Zabalza}, V. and + {Edwards}, Z.~I. and {Azalee Bostroem}, K. and {Burke}, D.~J. and + {Casey}, A.~R. and {Crawford}, S.~M. and {Dencheva}, N. and + {Ely}, J. and {Jenness}, T. and {Labrie}, K. and {Lim}, P.~L. and + {Pierfederici}, F. and {Pontzen}, A. and {Ptak}, A. and {Refsdal}, B. and + {Servillat}, M. and {Streicher}, O.}, + title = "{Astropy: A community Python package for astronomy}", + journal = {\aap}, +archivePrefix = "arXiv", + eprint = {1307.6212}, + primaryClass = "astro-ph.IM", + keywords = {methods: data analysis, methods: miscellaneous, virtual observatory tools}, + year = 2013, + month = oct, + volume = 558, + eid = {A33}, + pages = {A33}, + doi = {10.1051/0004-6361/201322068}, + adsurl = {http://adsabs.harvard.edu/abs/2013A%26A...558A..33A}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} +} diff --git a/gala/source/paper/paper.md b/gala/source/paper/paper.md new file mode 100644 index 0000000000000000000000000000000000000000..bdae502b164aaf38c9827517ec8a29af788d4744 --- /dev/null +++ b/gala/source/paper/paper.md @@ -0,0 +1,58 @@ +--- +title: "Gala: A Python package for galactic dynamics" +tags: + - Python + - astronomy + - dynamics + - galactic dynamics + - milky way +authors: + - name: Adrian M. Price-Whelan + orcid: 0000-0003-0872-7098 + affiliation: 1 +affiliations: + - name: Lyman Spitzer, Jr. Fellow, Princeton University + index: 1 +date: 13 August 2017 +bibliography: paper.bib +--- + +# Summary + +The forces on stars, galaxies, and dark matter under external gravitational +fields lead to the dynamical evolution of structures in the universe. The orbits +of these bodies are therefore key to understanding the formation, history, and +future state of galaxies. The field of "galactic dynamics," which aims to model +the gravitating components of galaxies to study their structure and evolution, +is now well-established, commonly taught, and frequently used in astronomy. +Aside from toy problems and demonstrations, the majority of problems require +efficient numerical tools, many of which require the same base code (e.g., for +performing numerical orbit integration). + +`Gala` is an Astropy-affiliated Python package for galactic dynamics. Python +enables wrapping low-level languages (e.g., C) for speed without losing +flexibility or ease-of-use in the user-interface. The API for `Gala` was +designed to provide a class-based and user-friendly interface to fast (C or +Cython-optimized) implementations of common operations such as gravitational +potential and force evaluation, orbit integration, dynamical transformations, +and chaos indicators for nonlinear dynamics. `Gala` also relies heavily on and +interfaces well with the implementations of physical units and astronomical +coordinate systems in the `Astropy` package [@astropy] (`astropy.units` and +`astropy.coordinates`). + +`Gala` was designed to be used by both astronomical researchers and by students +in courses on gravitational dynamics or astronomy. It has already been used in a +number of scientific publications [@Pearson:2017] and has also been used in +graduate courses on Galactic dynamics to, e.g., provide interactive +visualizations of textbook material [@Binney:2008]. The combination of speed, +design, and support for Astropy functionality in `Gala` will enable exciting +scientific explorations of forthcoming data releases from the _Gaia_ mission +[@gaia] by students and experts alike. The source code for `Gala` has been +archived to Zenodo with the linked DOI: [@zenodo] + +# Acknowledgements + +We acknowledge contributions from Brigitta Sipocz, Syrtis Major, and Semyeong +Oh, and support from Kathryn Johnston during the genesis of this project. + +# References diff --git a/gala/source/pyproject.toml b/gala/source/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..973472f248a8d381663ea8ce4bfc1d05627e728c --- /dev/null +++ b/gala/source/pyproject.toml @@ -0,0 +1,252 @@ +[project] +name = "gala" +authors = [ + {name = "Adrian Price-Whelan", email = "adrianmpw@gmail.com"}, +] +description = "Galactic dynamics in Python" +readme = "README.rst" +requires-python = ">=3.11" +keywords = ["astronomy", "dynamics"] +license = "MIT" +license-files = ["LICENSE", "AUTHORS.rst"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python", + "Topic :: Scientific/Engineering :: Astronomy" +] +dependencies = [ + "numpy>=1.26.4", + "scipy>=1.12,<1.17", + "astropy>=6.0", + "pyyaml", + "cython>=0.29" +] +dynamic = ["version"] + +[project.urls] +Documentation = "https://gala.adrian.pw" +Repository = "https://github.com/adrn/gala.git" + +[project.optional-dependencies] +shared = [ + "matplotlib", + "numexpr", + "h5py", + "tqdm", +] +test = [ + "gala[shared]", + "pytest", + "pytest-astropy", + "pytest-codspeed", + "pytest-xdist", + "findiff", +] +extra = [ + "galpy", + "sympy", + "twobody" +] +docs = [ + "gala[shared,extra]", + "numpydoc", + "ipykernel", + "jupyter-client", + "nbsphinx", + "ipython_genutils", + "pydata_sphinx_theme", + "sphinx", + "sphinxcontrib-bibtex", + "sphinx-astrorefs", + "sphinx_automodapi", + "sphinx_astropy", + "rtds_action", + "requests" +] +tutorials = [ + "gala[shared,extra]", + "IPython", + "nbconvert", + "ipython_genutils", + "jupyter_client", + "ipykernel", + "jupytext", + "pyia>=1.4", + "astroquery" +] +dev = [ + "gala[test, extra, docs, tutorials]", + "pre-commit" +] + +[build-system] +requires = [ + "setuptools>=77.0.3", + "wheel", + "setuptools_scm", + "numpy>=2.0", + "cython", + "pybind11" +] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["gala", "gala.*"] + +[tool.setuptools.package-data] +"*" = ["*.cpp"] +"gala" = ["extra_compile_macros.h", "cconfig.pyx", "*.cpp"] +"gala.coordinates" = ["*.pyx", "*.pxd", "*.h", "src/*.h", "src/*.cpp"] +"gala.dynamics" = ["*/*.pyx", "*/*.pxd", "*/*.h", "nbody/nbody_helper.h"] +"gala.integrate" = [ + "*.pyx", + "*.pxd", + "*.h", + "*/*.pyx", + "*/*.pxd", + "cyintegrators/*.cpp", + "cyintegrators/dopri/*.cpp", + "cyintegrators/dopri/*.h" +] +"gala.potential" = [ + "*.pyx", + "*.pxd", + "*.h", + "src/funcdefs.h", + "potential/src/cpotential.h", + "frame/src/cframe.h", + "*/*.pyx", + "*/*.pxd", + "*/*.h", +] + +[tool.setuptools_scm] +version_file = "src/gala/_version.py" +local_scheme = "no-local-version" # So that uploads to test.pypi.org work + +[tool.pytest.ini_options] +testpaths = ["tests", "src/gala", "docs"] +astropy_header = true +#doctest_plus = "enabled" +text_file_format = "rst" +addopts = [ + "--doctest-rst", + "--ignore=tests/benchmarks" # ignore by default, but can be run explicitly +] +doctest_optionflags = ["ELLIPSIS"] +norecursedirs = [ + "docs/tutorials/*", + "docs/_*" +] +doctest_norecursedirs = [ + "docs/tutorials/*", + "docs/_*" +] +filterwarnings = [ + "error::astropy.utils.exceptions.AstropyDeprecationWarning", +] + +[tool.coverage.run] +omit = [ + "src/gala/conftest*", + "src/gala/cython_version*", + "src/gala/version*", + "src/gala/coordinates/poincarepolar.py", + "src/gala/coordinates/velocity_frame_transforms.py", +] + +[tool.coverage.report] +exclude_lines = [ + # Have to re-enable the standard pragma + "pragma: no cover", + # Don't complain about packages we have installed + "except ImportError", + # Don't complain if tests don't hit assertions + "raise AssertionError", + "raise NotImplementedError", + # Don't complain about script hooks + "def main\\(.*\\):", + # Ignore branches that don't pertain to this version of Python + "pragma: py{ignore_python_version}", + # Don't complain about IPython completion helper + "def _ipython_key_completions_" +] + +[tool.isort] +profile = "black" +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +line_length = 88 + +[tool.ruff] +line-length = 88 +# src = ["src"] + +[tool.ruff.lint] +explicit-preview-rules = true +extend-select = [ + "B", # flake8-bugbear + "I", # isort + "ARG", # flake8-unused-arguments + "C4", # flake8-comprehensions + "EM", # flake8-errmsg + "ICN", # flake8-import-conventions + "G", # flake8-logging-format + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-use-pathlib + "RET", # flake8-return + "RUF", # Ruff-specific + "SIM", # flake8-simplify + "T20", # flake8-print + "UP", # pyupgrade + "YTT", # flake8-2020 + "EXE", # flake8-executable + "NPY", # NumPy specific rules + "PD", # pandas-vet +] +ignore = [ + "PLC0206", # Extracting value from dictionary without calling `.items()` + "PLR09", # Too many <...> + "PLR2004", # Magic value used in comparison + "ISC001", # Conflicts with formatter + "B905", # zip() without explicit strict + "E741", # Ambiguous variable name + "PLW2901", # `for` loop variable `p` overwritten by assignment target + "E731", # Do not assign a lambda expression, use a def + # TODO: fix these and remove from ignore + "EM101", "EM102", + "PT011", "PT012", + "ARG001", "ARG002", "ARG005", + "PTH", + "B028", + "RUF012", "RUF059", + "PLC0415", + "PLW1641", +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401", "F403"] +"test*.py" = ["F841", "PLC0415"] +"conftest.py" = ["PLC0415"] +"docs/*" = ["PLC0415"] +"docs/tutorials/*" = ["E703", "RUF001", "B018", "E402", "T201"] +"docs/supporting/*" = ["E703", "RUF001", "B018", "E402"] +"tests/**/*.py" = ["NPY002", "RUF012", "T201", "B018", "B007", "PLC0415"] +"setup.py" = ["T201", "E402"] +"docs/conf.py" = ["T201"] +"tests/dynamics/actionangle/_genfunc/**/*" = ["ALL"] + +[[tool.cibuildwheel.overrides]] +select = "*_x86_64" +environment = {CXXFLAGS="-march=x86-64-v3"} + +[[tool.cibuildwheel.overrides]] +select = "*-macosx_arm64" +environment = {CXXFLAGS="-mcpu=apple-m1"} diff --git a/gala/source/setup.py b/gala/source/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..a736d990d9a8e9f6e35482652911529eef40b145 --- /dev/null +++ b/gala/source/setup.py @@ -0,0 +1,528 @@ +#!/usr/bin/env python +# Licensed under an MIT license - see LICENSE + +import os +import sys +import warnings +from collections import defaultdict + +from setuptools import Extension, setup + +VERSION_TEMPLATE = """ +# Note that we need to fall back to the hard-coded version if either +# setuptools_scm can't be imported or setuptools_scm can't determine the +# version, so we catch the generic 'Exception'. +try: + from setuptools_scm import get_version + version = get_version(root='..', relative_to=__file__) +except Exception: + version = '{version}' +""".lstrip() + +# ---------------------------------------------------------------------------- +# GSL support +# +from subprocess import CalledProcessError, check_output + +extra_compile_macros_file = "src/gala/extra_compile_macros.h" + +# Note: on RTD, they now support conda environments, but don't activate the +# conda environment that gets created, and so the C stuff installed with GSL +# aren't picked up. This is my attempt to hack around that! +on_rtd = os.environ.get("READTHEDOCS") == "True" +if on_rtd: + PATH = ( + "/home/docs/checkouts/readthedocs.org/user_builds/gala-astro/conda/latest/bin/" + ) + env = os.environ.copy() + env["PATH"] = env.get("PATH", "") + ":" + PATH +else: + env = None + +# First, see if the user wants to install without GSL: +nogsl = bool(int(os.environ.get("GALA_NOGSL", "0"))) +gsl_version = os.environ.get("GALA_GSL_VERSION", None) +gsl_prefix = os.environ.get("GALA_GSL_PREFIX", None) + +# If GALA_FORCE_GSL=1, the build will fail if GSL is not found +force_gsl = bool(int(os.environ.get("GALA_FORCE_GSL", "0"))) + +# The EXP installation prefix. This directory should contain 'include' and 'lib' subdirs. +exp_prefix = os.environ.get("GALA_EXP_PREFIX", None) + +try: + import pybind11 +except ImportError: + pybind11 = None + + +# Auto-detect whether GSL is installed +if (not nogsl or nogsl is None) and gsl_version is None: # GSL support enabled + cmd = ["gsl-config", "--version"] + try: + gsl_version = check_output(cmd, env=env).decode("utf-8") + except (OSError, CalledProcessError): + gsl_version = None + +if gsl_version is not None: + gsl_version = gsl_version.strip().split(".") + +print("-" * 79) +_see_msg = ( + "See the gala documentation 'installation' page for more " + "information about GSL support and installing GSL: " + "http://gala.adrian.pw/en/latest/install.html" +) +if gsl_version is None: + if nogsl: + print("Gala: Installing without GSL support.") + elif force_gsl: + raise RuntimeError( + "Gala: GALA_FORCE_GSL is set but GSL was not found. " + "Please install GSL (e.g., 'brew install gsl' on macOS, " + "'apt-get install libgsl-dev' on Ubuntu/Debian " + "or set GALA_GSL_PREFIX to the GSL installation directory. " + _see_msg + ) + else: + print("Gala: GSL not found, installing without GSL support. " + _see_msg) + +elif gsl_version < ["1", "16"]: + if force_gsl: + raise RuntimeError( + "Gala: GALA_FORCE_GSL is set but GSL version ({}) is below the minimum " + "required version (1.16). Please upgrade GSL. ".format( + ".".join(gsl_version) + ) + + _see_msg + ) + print( + "Gala: Warning: GSL version ({}) is below the minimum required version " + "(1.14). Installing without GSL support. ".format(".".join(gsl_version)) + + _see_msg + ) + gsl_version = None + +else: + print( + "Gala: GSL version {} found, installing with GSL support".format( + ".".join(gsl_version) + ) + ) + + if gsl_prefix is None: + # Now get the gsl install location + cmd = ["gsl-config", "--prefix"] + try: + gsl_prefix = check_output(cmd, encoding="utf-8") + except Exception: + gsl_prefix = str(check_output(cmd)) + + gsl_prefix = os.path.normpath(gsl_prefix.strip()) + + +def pkg_config(pkg: str, *pc_args) -> str: + """ + pkg_config("eigen3", "--cflags") + pkg_config("eigen3", "--libs") + """ + cmd = ["pkg-config", *pc_args, pkg] + try: + output = check_output(cmd, encoding="utf-8") + except Exception: + try: + output = str(check_output(cmd)) + except Exception: + # pkg-config is allowed to fail. For example, a module might + # set C_INCLUDE_PATH but not expose a pc file. + warnings.warn(f'"{" ".join(cmd)}" failed for {pkg}.') + output = "" + return output.strip() + + +def get_include_flags(pkg: str) -> list[str]: + """ + First look at EIGEN3_INCLUDE_DIR environment variable, then + fall back to "pkg-config --cflags eigen3". + """ + eigen_incl_dir = os.environ.get(f"{pkg.upper()}_INCLUDE_DIR", None) + if eigen_incl_dir is None: + # The cflags from pkg-config might contain multiple flags. + # Just pass them as extra_compile_args rather than include_dirs. + eigen_incl_flags = pkg_config(pkg, "--cflags").split() + else: + eigen_incl_dir = os.path.normpath(eigen_incl_dir.strip()) + eigen_incl_flags = ["-I" + eigen_incl_dir] + return eigen_incl_flags + + +if exp_prefix is None: + print("Gala: installing without EXP support.") +else: + if pybind11 is None: + raise RuntimeError("pybind11 is required to build Gala with EXP support.") + + print(f"Gala: installing with EXP support (GALA_EXP_PREFIX={exp_prefix})") + + extra_incl_flags = [] + for lib in ["eigen3", "hdf5", "mpi"]: + flags = get_include_flags(lib) + if flags: + extra_incl_flags.extend(flags) + +# ============================================================================= +# Cython extensions +# + + +def get_all_extensions(): + """All Cython extensions""" + import numpy as np + + extensions = [] + mac_incl_path = "/usr/include/malloc" + + # Base config shared by many extensions: + def base_cfg(): + cfg = defaultdict(list) + cfg["include_dirs"].extend(["src/gala", np.get_include(), mac_incl_path]) + cfg["extra_compile_args"].append("-std=c++17") + + # Some READTHEDOCS hacks - see + # https://github.com/pyFFTW/pyFFTW/pull/161/files + # https://github.com/pyFFTW/pyFFTW/pull/162/files + include_dirs = [os.path.join(sys.prefix, "include")] + library_dirs = [os.path.join(sys.prefix, "lib")] + cfg["include_dirs"].extend(include_dirs) + cfg["library_dirs"].extend(library_dirs) + return cfg + + # ---- gala._cconfig ---- + cfg = base_cfg() + cfg["sources"].append("src/gala/cconfig.pyx") + extensions.append(Extension("gala._cconfig", **cfg)) + + # ---- gala.dynamics ---- + + # lyapunov + cfg = base_cfg() + cfg["include_dirs"].extend( + ["src/gala/integrate/cyintegrators", "src/gala/potential"] + ) + cfg["sources"].extend( + [ + "src/gala/potential/potential/src/cpotential.cpp", + "src/gala/potential/hamiltonian/src/chamiltonian.cpp", + "src/gala/integrate/cyintegrators/dopri/dop853.cpp", + "src/gala/dynamics/lyapunov/dop853_lyapunov.pyx", + ] + ) + extensions.append(Extension("gala.dynamics.lyapunov.dop853_lyapunov", **cfg)) + + # mockstream._coord + cfg = base_cfg() + cfg["include_dirs"].append("src/gala/potential") + cfg["sources"].append("src/gala/dynamics/mockstream/_coord.pyx") + extensions.append(Extension("gala.dynamics.mockstream._coord", **cfg)) + + # mockstream.df + cfg = base_cfg() + cfg["include_dirs"].append("src/gala/potential") + cfg["sources"].extend( + [ + "src/gala/dynamics/mockstream/df.pyx", + "src/gala/potential/potential/src/cpotential.cpp", + ] + ) + extensions.append(Extension("gala.dynamics.mockstream.df", **cfg)) + + # mockstream._mockstream + cfg = base_cfg() + cfg["include_dirs"].extend( + [ + "src/gala/integrate/cyintegrators", + "src/gala/potential", + "src/gala/dynamics/nbody", + ] + ) + cfg["sources"].extend( + [ + "src/gala/potential/potential/src/cpotential.cpp", + "src/gala/potential/hamiltonian/src/chamiltonian.cpp", + "src/gala/dynamics/mockstream/mockstream.pyx", + "src/gala/integrate/cyintegrators/dopri/dop853.cpp", + ] + ) + extensions.append(Extension("gala.dynamics.mockstream._mockstream", **cfg)) + + # nbody + cfg = base_cfg() + cfg["include_dirs"].extend( + [ + "src/gala/integrate/cyintegrators", + "src/gala/potential", + ] + ) + cfg["sources"].extend( + [ + "src/gala/potential/potential/src/cpotential.cpp", + "src/gala/potential/hamiltonian/src/chamiltonian.cpp", + "src/gala/integrate/cyintegrators/dopri/dop853.cpp", + "src/gala/dynamics/nbody/nbody.pyx", + ] + ) + extensions.append(Extension("gala.dynamics.nbody.nbody", **cfg)) + + # ===== gala.integrate extensions ===== + + # leapfrog + cfg = base_cfg() + cfg["include_dirs"].extend(["src/gala/potential", "src/gala/dynamics/nbody"]) + cfg["sources"].extend( + [ + "src/gala/integrate/cyintegrators/leapfrog.pyx", + "src/gala/potential/potential/src/cpotential.cpp", + ] + ) + extensions.append(Extension("gala.integrate.cyintegrators.leapfrog", **cfg)) + + # dop853 + cfg = base_cfg() + cfg["include_dirs"].append("src/gala/potential") + cfg["sources"].extend( + [ + "src/gala/potential/hamiltonian/src/chamiltonian.cpp", + "src/gala/potential/potential/src/cpotential.cpp", + "src/gala/integrate/cyintegrators/dop853.pyx", + "src/gala/integrate/cyintegrators/dopri/dop853.cpp", + ] + ) + extensions.append(Extension("gala.integrate.cyintegrators.dop853", **cfg)) + + # ruth4 + cfg = base_cfg() + cfg["include_dirs"].extend(["src/gala/potential", "src/gala/dynamics/nbody"]) + cfg["sources"].extend( + [ + "src/gala/integrate/cyintegrators/ruth4.pyx", + "src/gala/potential/potential/src/cpotential.cpp", + ] + ) + extensions.append(Extension("gala.integrate.cyintegrators.ruth4", **cfg)) + + # ===== gala.potential extensions ===== + + # cpotential + cfg = base_cfg() + cfg["include_dirs"].extend(["src/gala/potential", "src/gala"]) + cfg["sources"].extend( + [ + "src/gala/potential/potential/cpotential.pyx", + "src/gala/potential/potential/builtin/builtin_potentials.cpp", + "src/gala/potential/potential/src/cpotential.cpp", + ] + ) + extensions.append(Extension("gala.potential.potential.cpotential", **cfg)) + + # ccompositepotential + cfg = base_cfg() + cfg["include_dirs"].extend(["src/gala/potential", "src/gala"]) + cfg["sources"].extend( + [ + "src/gala/potential/potential/ccompositepotential.pyx", + "src/gala/potential/potential/src/cpotential.cpp", + ] + ) + extensions.append(Extension("gala.potential.potential.ccompositepotential", **cfg)) + + # cybuiltin + cfg = base_cfg() + cfg["include_dirs"].extend(["src/gala/potential", "src/gala"]) + cfg["sources"].extend( + [ + "src/gala/potential/potential/builtin/cybuiltin.pyx", + "src/gala/potential/potential/builtin/builtin_potentials.cpp", + "src/gala/potential/potential/builtin/multipole.cpp", + "src/gala/potential/potential/src/cpotential.cpp", + ] + ) + extensions.append(Extension("gala.potential.potential.builtin.cybuiltin", **cfg)) + + # cyexp + cfg = base_cfg() + cfg["include_dirs"].extend(["src/gala/potential", "src/gala"]) + cfg["sources"].extend( + [ + "src/gala/potential/potential/builtin/cyexp.pyx", + "src/gala/potential/potential/builtin/exp_fields.cc", + ] + ) + extensions.append(Extension("gala.potential.potential.builtin.cyexp", **cfg)) + + # cytimeinterp + cfg = base_cfg() + cfg["include_dirs"].extend(["src/gala/potential", "src/gala"]) + cfg["sources"].extend( + [ + "src/gala/potential/potential/builtin/cytimeinterp.pyx", + "src/gala/potential/potential/builtin/time_interp.cpp", + "src/gala/potential/potential/builtin/time_interp_wrapper.cpp", + "src/gala/potential/potential/src/cpotential.cpp", + ] + ) + extensions.append(Extension("gala.potential.potential.builtin.cytimeinterp", **cfg)) + + # cframe + cfg = base_cfg() + cfg["include_dirs"].append("src/gala/potential") + cfg["sources"].extend( + [ + "src/gala/potential/frame/cframe.pyx", + "src/gala/potential/frame/src/cframe.cpp", + ] + ) + extensions.append(Extension("gala.potential.frame.cframe", **cfg)) + + # frames + cfg = base_cfg() + cfg["include_dirs"].append("src/gala/potential") + cfg["sources"].extend( + [ + "src/gala/potential/frame/builtin/frames.pyx", + "src/gala/potential/frame/builtin/builtin_frames.cpp", + "src/gala/potential/frame/src/cframe.cpp", + ] + ) + extensions.append(Extension("gala.potential.frame.builtin.frames", **cfg)) + + # scf._computecoeff + cfg = base_cfg() + cfg["include_dirs"].append("src/gala/potential") + cfg["sources"].extend( + [ + "src/gala/potential/scf/computecoeff.pyx", + "src/gala/potential/scf/src/bfe_helper.cpp", + "src/gala/potential/scf/src/coeff_helper.cpp", + ] + ) + extensions.append(Extension("gala.potential.scf._computecoeff", **cfg)) + + # scf._bfe + cfg = base_cfg() + cfg["include_dirs"].extend(["src/gala", "src/gala/potential"]) + cfg["library_dirs"].append(os.path.join(sys.prefix, "lib")) + cfg["sources"].extend( + [ + "src/gala/potential/potential/src/cpotential.cpp", + "src/gala/potential/potential/builtin/builtin_potentials.cpp", + "src/gala/potential/scf/bfe.pyx", + "src/gala/potential/scf/src/bfe.cpp", + "src/gala/potential/scf/src/bfe_helper.cpp", + ] + ) + extensions.append(Extension("gala.potential.scf._bfe", **cfg)) + + # scf._bfe_class + cfg = base_cfg() + cfg["include_dirs"].extend(["src/gala", "src/gala/potential"]) + cfg["library_dirs"].append(os.path.join(sys.prefix, "lib")) + cfg["sources"].extend( + [ + "src/gala/potential/potential/src/cpotential.cpp", + "src/gala/potential/potential/builtin/builtin_potentials.cpp", + "src/gala/potential/scf/bfe_class.pyx", + "src/gala/potential/scf/src/bfe.cpp", + "src/gala/potential/scf/src/bfe_helper.cpp", + ] + ) + extensions.append(Extension("gala.potential.scf._bfe_class", **cfg)) + + # chamiltonian + cfg = base_cfg() + cfg["include_dirs"].append("src/gala/potential") + cfg["sources"].extend( + [ + "src/gala/potential/hamiltonian/chamiltonian.pyx", + "src/gala/potential/hamiltonian/src/chamiltonian.cpp", + "src/gala/potential/potential/src/cpotential.cpp", + ] + ) + extensions.append(Extension("gala.potential.hamiltonian.chamiltonian", **cfg)) + + return extensions + + +extensions = get_all_extensions() +extensions_with_flags = [] +for ext in extensions: + # TODO: -Ofast deprecated with clang + # -march=native may be useful, depending on the architecture + ext.extra_compile_args.extend(["-Ofast"]) + ext.extra_link_args.extend(["-Ofast"]) + + if ("potential.potential" in ext.name or "scf" in ext.name) and ( + gsl_version is not None + ): + if "gsl" not in ext.libraries: + ext.libraries.append("gsl") + ext.library_dirs.append(os.path.join(gsl_prefix, "lib")) + ext.include_dirs.append(os.path.join(gsl_prefix, "include")) + + if "gslcblas" not in ext.libraries: + ext.libraries.append("gslcblas") + + if "cyexp" in ext.name: + if exp_prefix is not None: + exp_lib_path = os.path.join(exp_prefix, "lib") + if not os.path.exists(exp_lib_path): + msg = ( + f"No EXP libraries found in {exp_lib_path}. " + "Please set GALA_EXP_PREFIX to the directory that contains the 'lib' and 'include' " + "subdirectories of your EXP installation." + ) + raise RuntimeError(msg) + + ext.include_dirs.append(pybind11.get_include()) + if extra_incl_flags is not None: + ext.extra_compile_args.extend(extra_incl_flags) + + ext.extra_compile_args.extend(["-fopenmp"]) + ext.extra_link_args.extend(["-fopenmp"]) + + if "exp" not in ext.libraries: + ext.libraries.extend( + ( + "exputil", + "expui", + "yaml-cpp", + ) + ) + ext.library_dirs.append(exp_lib_path) + ext.runtime_library_dirs.append(exp_lib_path) + ext.include_dirs.append(os.path.join(exp_prefix, "include")) + else: + # Skip cyexp extension if EXP is not found + continue + + extensions_with_flags.append(ext) + +print("-" * 79) + +with open(extra_compile_macros_file, "w", encoding="utf-8") as f: + if gsl_version is not None: + f.write("#define USE_GSL 1\n") + else: + f.write("#define USE_GSL 0\n") + + if exp_prefix is not None: + f.write("#define USE_EXP 1\n") + else: + f.write("#define USE_EXP 0\n") + + +setup( + use_scm_version={ + "write_to": os.path.join("src", "gala", "_version.py"), + "write_to_template": VERSION_TEMPLATE, + }, + ext_modules=extensions_with_flags, +) diff --git a/gala/source/src/__init__.py b/gala/source/src/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e6e237ed8dcb6e6cc47eed4854b52979143e6191 --- /dev/null +++ b/gala/source/src/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +""" +src Package Initialization File +""" diff --git a/gala/source/src/gala/__init__.py b/gala/source/src/gala/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0b7fdf74faab309a8076afc411991142489ad663 --- /dev/null +++ b/gala/source/src/gala/__init__.py @@ -0,0 +1,9 @@ +""" +Gala. +""" + +import sys + +__author__ = "adrn " + +from ._version import version as __version__ diff --git a/gala/source/src/gala/_cconfig.pxd b/gala/source/src/gala/_cconfig.pxd new file mode 100644 index 0000000000000000000000000000000000000000..fbb37872deae0489122f4e66e0c2149364508f74 --- /dev/null +++ b/gala/source/src/gala/_cconfig.pxd @@ -0,0 +1,6 @@ +# cython: language_level=3 +# cython: language=c++ + +cdef extern from "extra_compile_macros.h": + int USE_GSL + int USE_EXP diff --git a/gala/source/src/gala/_compat_utils.py b/gala/source/src/gala/_compat_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..1e9ab543e10061b0efdfcecc03306c95687f2158 --- /dev/null +++ b/gala/source/src/gala/_compat_utils.py @@ -0,0 +1,9 @@ +import numpy as np +import scipy +from packaging.version import Version + +# See: https://github.com/astropy/astropy/pull/16181 +NUMPY_LT_2_0 = Version(np.__version__) < Version("2.0.0") +COPY_IF_NEEDED = False if NUMPY_LT_2_0 else None + +SCIPY_LT_1_15 = Version(scipy.__version__) < Version("1.15.0") diff --git a/gala/source/src/gala/_optional_deps.py b/gala/source/src/gala/_optional_deps.py new file mode 100644 index 0000000000000000000000000000000000000000..a6d87e5b0a3c9e21d34f9ec763846c37f6af3268 --- /dev/null +++ b/gala/source/src/gala/_optional_deps.py @@ -0,0 +1,40 @@ +"""Checks for optional dependencies using lazy import from +`PEP 562 `_. +""" + +import importlib +import io +from collections.abc import Sequence +from contextlib import redirect_stdout + +# First, the top-level packages: +# TODO: This list is a duplicate of the dependencies in setup.cfg "all", but +# some of the package names are different from the pip-install name (e.g., +# beautifulsoup4 -> bs4). +_optional_deps = ["h5py", "sympy", "tqdm", "twobody", "agama"] +_deps = {k.upper(): k for k in _optional_deps} + +# Any subpackages that have different import behavior: +_deps["MATPLOTLIB"] = ("matplotlib", "matplotlib.pyplot") +_deps["GALPY"] = ("galpy", "galpy.orbit", "galpy.potential") + +__all__ = [f"HAS_{pkg}" for pkg in _deps] + + +def __getattr__(name): + if name in __all__: + module_name = name[4:] + modules = _deps[module_name] + + if not isinstance(modules, Sequence) or isinstance(modules, str): + modules = [modules] + + for module in modules: + try: + with redirect_stdout(io.StringIO()): + importlib.import_module(module) + except (ImportError, ModuleNotFoundError): + return False + return True + + raise AttributeError(f"Module {__name__!r} has no attribute {name!r}.") diff --git a/gala/source/src/gala/cconfig.pyx b/gala/source/src/gala/cconfig.pyx new file mode 100644 index 0000000000000000000000000000000000000000..b767dd2e46052672acf110cf14da01deb9235e99 --- /dev/null +++ b/gala/source/src/gala/cconfig.pyx @@ -0,0 +1,12 @@ +# cython: language_level=3 +# cython: language=c++ + +if USE_GSL == 1: + GSL_ENABLED = True +else: + GSL_ENABLED = False + +if USE_EXP == 1: + EXP_ENABLED = True +else: + EXP_ENABLED = False diff --git a/gala/source/src/gala/coordinates/__init__.py b/gala/source/src/gala/coordinates/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..253f2049a7b6ac04e0667467d91951fbfc37fc33 --- /dev/null +++ b/gala/source/src/gala/coordinates/__init__.py @@ -0,0 +1,13 @@ +from .gd1 import * +from .greatcircle import * +from .jhelum import * +from .magellanic_stream import * +from .oph import * +from .orphan import * +from .pal5 import * +from .pal13 import * +from .pm_cov_transform import * +from .poincarepolar import * +from .reflex import * +from .sgr import * +from .velocity_frame_transforms import * diff --git a/gala/source/src/gala/coordinates/gd1.py b/gala/source/src/gala/coordinates/gd1.py new file mode 100644 index 0000000000000000000000000000000000000000..b324c5a96140486c1cf59624bd6bf01783155bcc --- /dev/null +++ b/gala/source/src/gala/coordinates/gd1.py @@ -0,0 +1,96 @@ +"""Astropy coordinate class for the GD-1 coordinate system""" + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +from astropy.coordinates import frame_transform_graph + +__all__ = ["GD1Koposov10"] + + +class GD1Koposov10(coord.BaseCoordinateFrame): + """ + A Heliocentric spherical coordinate system defined by the orbit of the GD1 stream, + as described in Koposov et al. 2010 (see: ``_). + + For more information about this class, see the Astropy documentation on coordinate + frames in :mod:`~astropy.coordinates`. + + Parameters + ---------- + representation : :class:`~astropy.coordinates.BaseRepresentation` or None + A representation object or None to have no data (or use the other keywords) + phi1 : angle_like, optional, must be keyword + The longitude-like angle corresponding to GD-1's orbit. + phi2 : angle_like, optional, must be keyword + The latitude-like angle corresponding to GD-1's orbit. + distance : :class:`~astropy.units.Quantity`, optional, must be keyword + The Distance for this object along the line-of-sight. + pm_phi1_cosphi2 : :class:`~astropy.units.Quantity`, optional, must be keyword + The proper motion in the longitude-like direction corresponding to + the GD-1 stream's orbit. + pm_phi2 : :class:`~astropy.units.Quantity`, optional, must be keyword + The proper motion in the latitude-like direction perpendicular to the + GD-1 stream's orbit. + radial_velocity : :class:`~astropy.units.Quantity`, optional, must be keyword + The radial velocity for this object along the line-of-sight. + + """ + + default_representation = coord.SphericalRepresentation + default_differential = coord.SphericalCosLatDifferential + + frame_specific_representation_info = { + coord.SphericalRepresentation: [ + coord.RepresentationMapping("lon", "phi1"), + coord.RepresentationMapping("lat", "phi2"), + coord.RepresentationMapping("distance", "distance"), + ], + } + + _default_wrap_angle = 180 * u.deg + + def __init__(self, *args, **kwargs): + wrap = kwargs.pop("wrap_longitude", True) + super().__init__(*args, **kwargs) + if wrap and isinstance( + self._data, + coord.UnitSphericalRepresentation | coord.SphericalRepresentation, + ): + self._data.lon.wrap_angle = self._default_wrap_angle + + # TODO: remove this. This is a hack required as of astropy v3.1 in order + # to have the longitude components wrap at the desired angle + def represent_as(self, base, s="base", in_frame_units=False): + r = super().represent_as(base, s=s, in_frame_units=in_frame_units) + if hasattr(r, "lon"): + r.lon.wrap_angle = self._default_wrap_angle + return r + + represent_as.__doc__ = coord.BaseCoordinateFrame.represent_as.__doc__ + + +# Rotation matrix as defined in the Appendix of Koposov et al. (2010) +R = np.array( + [ + [-0.4776303088, -0.1738432154, 0.8611897727], + [0.510844589, -0.8524449229, 0.111245042], + [0.7147776536, 0.4930681392, 0.4959603976], + ] +) + + +@frame_transform_graph.transform(coord.StaticMatrixTransform, coord.ICRS, GD1Koposov10) +def icrs_to_gd1(): + """ + Compute the transformation from ICRS spherical to heliocentric GD-1 coordinates. + """ + return R + + +@frame_transform_graph.transform(coord.StaticMatrixTransform, GD1Koposov10, coord.ICRS) +def gd1_to_icrs(): + """ + Compute the transformation from heliocentric GD-1 coordinates to ICRS spherical. + """ + return icrs_to_gd1().T diff --git a/gala/source/src/gala/coordinates/greatcircle.py b/gala/source/src/gala/coordinates/greatcircle.py new file mode 100644 index 0000000000000000000000000000000000000000..2ab52fdd2fdcbb6a700eb86625f4d175cb3e1a97 --- /dev/null +++ b/gala/source/src/gala/coordinates/greatcircle.py @@ -0,0 +1,518 @@ +# Built-in +import functools +from textwrap import dedent +from warnings import warn + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +from astropy.coordinates.attributes import CoordinateAttribute +from astropy.coordinates.baseframe import base_doc +from astropy.coordinates.transformations import ( + DynamicMatrixTransform, + FunctionTransform, +) +from astropy.utils.decorators import format_doc + +from .helpers import StringValidatedAttribute + +__all__ = ["GreatCircleICRSFrame", "make_greatcircle_cls", "pole_from_endpoints"] + + +def get_xhat(zhat, ra0, tol=1e-10): + """ + Helper to get the x-hat vector along a great circle defined by the input zhat that + intersects with the specified longitude (ra0). + """ + ra0 = 90 * u.deg - ra0 + + z1, z2, z3 = zhat + + if np.isclose(z3, 0, atol=tol): # pole in x-y - can't satisfy ra0 + raise ValueError( + "Pole is in the x-y plane, so can't satisfy the ra0 requirement" + ) + + denom = ( + z2**2 + z3**2 + 2 * z1 * z2 * np.tan(ra0) + (z1**2 + z3**2) * np.tan(ra0) ** 2 + ) + x1 = -np.tan(ra0) * np.sqrt(z3**2 / denom) + x2 = x1 / np.tan(ra0) + + if np.isclose(z3, 1, atol=tol): + x3 = 0.0 + else: + x3 = (z2 + z1 * np.tan(ra0)) * np.abs(x2) / z3 + + return np.array([x1, x2, x3]) + + +def get_origin_from_pole_ra0(pole, ra0, origin_disambiguate=None): + """ + Figure out the coordinate system origin (i.e. the x-axis, expressed in the old + coordinate frame). Given just a pole and ra0, there is an ambiguity to the direction + of the x-axis because the two great circles (defined by pole and ra0) intersect at + two points. To resolve this ambiguity, you can specify ``origin_disambiguate``, + which is a coordinate in the old system (ICRS) used to pick the x-axis closest to + that location. If this is not specified, it uses (RA, Dec)=(0, 0). + """ + + if origin_disambiguate is None: + origin_disambiguate = coord.SkyCoord(0, 0, unit=u.deg, frame=pole) + + # figure out origin from ra0 + zhat = np.squeeze((pole.cartesian / pole.cartesian.norm()).xyz) + xhat1 = coord.CartesianRepresentation(get_xhat(zhat, ra0)) + xhat2 = -xhat1 + + origin1 = coord.SkyCoord(xhat1, frame=pole, representation_type="unitspherical") + origin2 = coord.SkyCoord(xhat2, frame=pole, representation_type="unitspherical") + + sep1 = origin_disambiguate.separation(origin1).to_value(u.deg) + sep2 = origin_disambiguate.separation(origin2).to_value(u.deg) + + # Convention: + if sep1 <= sep2: + return origin1 + return origin2 + + +def pole_from_endpoints(coord1, coord2): + """Compute the pole from a great circle that connects the two specified + coordinates. + + This assumes a right-handed rule from coord1 to coord2: the pole is the + north pole under that assumption. + + Parameters + ---------- + coord1 : `~astropy.coordinates.SkyCoord` + Coordinate of one point on a great circle. + coord2 : `~astropy.coordinates.SkyCoord` + Coordinate of the other point on a great circle. + + Returns + ------- + pole : `~astropy.coordinates.SkyCoord` + The coordinates of the pole. + """ + cart1 = coord1.cartesian.without_differentials() + cart2 = coord2.cartesian.without_differentials() + if isinstance(coord1, coord.SkyCoord): + frame1 = coord1.frame + elif isinstance(coord1, coord.BaseCoordinateFrame): + frame1 = coord1 + else: + raise TypeError( + "Input coordinate must be a SkyCoord or coordinate frame instance." + ) + + c1 = cart1 / cart1.norm() + + coord2 = coord2.transform_to(frame1) + c2 = cart2 / cart2.norm() + + pole = c1.cross(c2) + pole /= pole.norm() + return frame1.realize_frame(pole) + + +def sph_midpoint(coord1, coord2): + """Compute the midpoint between two points on the sphere. + + Parameters + ---------- + coord1 : `~astropy.coordinates.SkyCoord` + Coordinate of one point on a great circle. + coord2 : `~astropy.coordinates.SkyCoord` + Coordinate of the other point on a great circle. + + Returns + ------- + midpt : `~astropy.coordinates.SkyCoord` + The coordinates of the spherical midpoint. + """ + cart1 = coord1.cartesian.without_differentials() + cart2 = coord2.cartesian.without_differentials() + if isinstance(coord1, coord.SkyCoord): + frame1 = coord1.frame + elif isinstance(coord1, coord.BaseCoordinateFrame): + frame1 = coord1 + else: + raise TypeError( + "Input coordinate must be a SkyCoord or coordinate frame instance." + ) + + c1 = cart1 / cart1.norm() + + coord2 = coord2.transform_to(frame1) + c2 = cart2 / cart2.norm() + + midpt = 0.5 * (c1 + c2) + usph = midpt.represent_as(coord.UnitSphericalRepresentation) + + return frame1.realize_frame(usph) + + +def ensure_orthogonal(pole, origin, priority="origin", tol=1e-10): + """ + Makes sure the pole and origin are unit vectors, and are orthogonal. Adjusts either + the pole or origin to make orthogonal if not. + + Parameters + ---------- + x : array_like + Must be a unit vector. + z : array_like + Must be a unit vector. + + """ + + origin = origin.realize_frame( + origin.represent_as("unitspherical").without_differentials() + ).squeeze() + pole = pole.realize_frame( + pole.represent_as("unitspherical").without_differentials() + ).squeeze() + + x = np.squeeze(origin.cartesian.xyz) + z = np.squeeze(pole.cartesian.xyz) + if np.abs(np.dot(x, z)) > tol: + if priority == "origin": + msg = "Keeping the origin fixed and adjusting the pole to be orthogonal." + z -= (z @ x) * x + pole = pole.realize_frame( + coord.CartesianRepresentation(z), representation_type="unitspherical" + ) + + else: # validated by class attribute, so assume "pole" + msg = "Keeping the pole fixed and adjusting the origin to be orthogonal." + x -= (x @ z) * z + origin = origin.realize_frame( + coord.CartesianRepresentation(x), representation_type="unitspherical" + ) + + warn( + f"Input origin and pole are not orthogonal. {msg} Use " + "warnings.simplefilter('ignore') to ignore this warning.", + RuntimeWarning, + ) + + return pole, origin + + +def pole_origin_to_R(pole, origin): + """ + Compute the Cartesian rotation matrix from the given pole and origin. + + This functiona assumes that ``pole`` and ``origin`` are orthogonal. + """ + if not pole.is_equivalent_frame(origin): + raise ValueError("The coordinate frame of the input pole and origin must match") + + xaxis = np.squeeze((origin.cartesian / origin.cartesian.norm()).xyz) + zaxis = np.squeeze((pole.cartesian / pole.cartesian.norm()).xyz) + yaxis = np.cross(zaxis, xaxis) + + return np.stack((xaxis, yaxis, zaxis)) + + +def greatcircle_to_greatcircle(from_greatcircle_coord, to_greatcircle_frame): + """Transform between two greatcircle frames.""" + + # This transform goes through the parent frames on each side. + # from_frame -> from_frame.origin -> to_frame.origin -> to_frame + intermediate_from = from_greatcircle_coord.transform_to(from_greatcircle_coord.pole) + intermediate_to = intermediate_from.transform_to(to_greatcircle_frame.pole) + return intermediate_to.transform_to(to_greatcircle_frame) + + +def reference_to_greatcircle(reference_frame, greatcircle_frame): + """Convert a reference coordinate to a great circle frame.""" + return greatcircle_frame._R + + +def greatcircle_to_reference(greatcircle_coord, reference_frame): + """Convert a great circle frame coordinate to the reference frame""" + + # use the forward transform, but just invert it + R = reference_to_greatcircle(reference_frame, greatcircle_coord) + # transpose is the inverse because R is a rotation matrix + return R.T + + +def greatcircle_transforms(self_transform=False): + def set_greatcircle_transforms(cls): + DynamicMatrixTransform( + reference_to_greatcircle, + coord.ICRS, + cls, + register_graph=coord.frame_transform_graph, + ) + + DynamicMatrixTransform( + greatcircle_to_reference, + cls, + coord.ICRS, + register_graph=coord.frame_transform_graph, + ) + + if self_transform: + FunctionTransform( + greatcircle_to_greatcircle, + cls, + cls, + register_graph=coord.frame_transform_graph, + ) + return cls + + return set_greatcircle_transforms + + +_components = """ + phi1 : `~astropy.units.Quantity` + Longitude component. + phi2 : `~astropy.units.Quantity` + Latitude component. + distance : `~astropy.units.Quantity` + Distance. + + pm_phi1_cosphi2 : `~astropy.units.Quantity` + Proper motion in longitude. + pm_phi2 : `~astropy.units.Quantity` + Proper motion in latitude. + radial_velocity : `~astropy.units.Quantity` + Line-of-sight or radial velocity. +""" + +_footer = """ + Attributes + ---------- + pole : `~astropy.coordinates.SkyCoord`, `~astropy.coordinates.ICRS` + The pole of the new coordinate frame, defined in the old frame (ICRS). + origin : `~astropy.coordinates.SkyCoord`, `~astropy.coordinates.ICRS` + The x-axis (spherical origin) of the new coordinate frame, defined in the old + frame (ICRS). +""" + + +@format_doc(dedent(base_doc), components=_components, footer=_footer) +@greatcircle_transforms(self_transform=True) +class GreatCircleICRSFrame(coord.BaseCoordinateFrame): + """ + A coordinate frame defined by a pole and origin. + + ``GreatCircleICRSFrame``s always have component names for spherical coordinates of + ``phi1`` and ``phi2`` (so, proper motion components are ``pm_phi1_cosphi2``, etc.). + """ + + pole = CoordinateAttribute(default=None, frame=coord.ICRS) + origin = CoordinateAttribute(default=None, frame=coord.ICRS) + priority = StringValidatedAttribute( + default="origin", valid_values=["origin", "pole"] + ) + + frame_specific_representation_info = { + coord.SphericalRepresentation: [ + coord.RepresentationMapping("lon", "phi1"), + coord.RepresentationMapping("lat", "phi2"), + coord.RepresentationMapping("distance", "distance"), + ] + } + + default_representation = coord.SphericalRepresentation + default_differential = coord.SphericalCosLatDifferential + + _default_wrap_angle = 180 * u.deg + + def __init__(self, *args, **kwargs): + if "ra0" in kwargs: + raise ValueError( + "Initializing a GreatCircleICRSFrame with a pole and ra0 is no longer " + "supported because this does not uniquely determine a coordinate frame." + " To initialize a frame with a pole and ra0 and ignore the ambiguity, " + "use the .from_pole_ra0() classmethod." + ) + + if "rotation" in kwargs: + raise ValueError( + "Initializing a GreatCircleICRSFrame with a `rotation` is no longer " + "supported." + ) + + wrap = kwargs.pop("wrap_longitude", True) + super().__init__(*args, **kwargs) + + if self.pole is None or self.origin is None: + raise ValueError("You must specify both a pole and an origin") + pole, origin = ensure_orthogonal(self.pole, self.origin, priority=self.priority) + self._pole = pole + self._origin = origin + + if wrap and isinstance( + self._data, + coord.UnitSphericalRepresentation | coord.SphericalRepresentation, + ): + self._data.lon.wrap_angle = self._default_wrap_angle + + @functools.cached_property + def _R(self): + return pole_origin_to_R(self.pole, self.origin) + + # TODO: remove this. This is a hack required as of astropy v3.1 in order + # to have the longitude components wrap at the desired angle + def represent_as(self, base, s="base", in_frame_units=False): + r = super().represent_as(base, s=s, in_frame_units=in_frame_units) + if hasattr(r, "lon"): + r.lon.wrap_angle = self._default_wrap_angle + return r + + represent_as.__doc__ = coord.BaseCoordinateFrame.represent_as.__doc__ + + @classmethod + def from_pole_ra0(cls, pole, ra0, origin_disambiguate=None): + """ + Compute the great circle frame from a pole and RA of longitude=0. + + {txt} + + Parameters + ---------- + pole : `~astropy.coordinates.SkyCoord` + The pole of the new coordinate frame, defined in the old frame (ICRS). + ra0 : `~astropy.units.Quantity`, `~astropy.coordinates.Angle` (optional) + Right Ascension of longitude zero. + origin_disambiguate : `~astropy.coordinates.SkyCoord` (optional) + A sky coordinate in the old frame (ICRS) used to disambiguate the coordinate + system origin. The x-axis closest to this coordinate is chosen as the new + system origin / x-axis. + """ + origin = get_origin_from_pole_ra0( + pole, ra0, origin_disambiguate=origin_disambiguate + ) + return cls(pole=pole, origin=origin) + + from_pole_ra0.__doc__ = from_pole_ra0.__doc__.format( + txt=get_origin_from_pole_ra0.__doc__ + ) + + @classmethod + def from_endpoints(cls, coord1, coord2, origin=None, ra0=None, priority=None): + """ + Compute the great circle frame from two endpoints of an arc on the unit sphere. + + If you specify an ``origin``, it should be orthogonal to the pole of the great + circle defined by ``coord1`` and ``coord2``. If it is not orthogonal to the + pole, by default, the pole will be adjusted along the great circle connecting + the pole to the input ``origin``. If you would instead like to keep the pole + fixed and orthogonalize the ``origin``, pass in ``priority='pole'``. + + Parameters + ---------- + coord1 : `~astropy.coordinates.SkyCoord` + One endpoint of the great circle arc. + coord2 : `~astropy.coordinates.SkyCoord` + The other endpoint of the great circle arc. + origin : `~astropy.coordinates.SkyCoord` (optional) + The x-axis (spherical origin) of the new coordinate frame, defined in the + old frame (ICRS). This defines the (phi1,phi2)=(0,0)º coordinate. + ra0 : `~astropy.units.Quantity`, `~astropy.coordinates.Angle` (optional) + Right Ascension of longitude zero. You can only specify one of ``origin`` or + ``ra0``. + priority : str (optional) + Defines the priority of keeping either the pole or origin fixed when they + are not orthogonal based on the input. + """ + + if ra0 is not None and origin is not None: + raise ValueError("You can only pass one of `ra0` or `origin`, not both") + + pole = pole_from_endpoints(coord1.squeeze(), coord2.squeeze()) + + if ra0 is not None: + midpt = sph_midpoint(coord1.squeeze(), coord2.squeeze()) + origin = get_origin_from_pole_ra0(pole, ra0, midpt) + elif ra0 is None and origin is None: + origin = sph_midpoint(coord1.squeeze(), coord2.squeeze()) + + return cls(pole=pole, origin=origin, priority=priority) + + @classmethod + def from_xyz(cls, xnew=None, ynew=None, znew=None): + """ + Compute the great circle frame from a specification of the coordinate axes in + the new system. + + Parameters + ---------- + xnew : astropy ``Representation`` object + The x-axis in the new system. + ynew : astropy ``Representation`` object + The y-axis in the new system. + znew : astropy ``Representation`` object + The z-axis in the new system. + """ + is_none = [xnew is None, ynew is None, znew is None] + if np.sum(is_none) > 1: + raise ValueError("At least 2 axes must be specified.") + + if xnew is not None: + xnew = xnew.to_cartesian() + + if ynew is not None: + ynew = ynew.to_cartesian() + + if znew is not None: + znew = znew.to_cartesian() + + if znew is None: + znew = xnew.cross(ynew) + + if ynew is None: + ynew = -xnew.cross(znew) + + if xnew is None: + xnew = ynew.cross(znew) + + pole = coord.SkyCoord(znew, frame="icrs") + origin = coord.SkyCoord(xnew, frame="icrs") + return cls(pole=pole, origin=origin) + + @classmethod + def from_R(cls, R): + """ + Compute the great circle frame from a rotation matrix that specifies the + transformation from ICRS to the new frame. + + Parameters + ---------- + R : array_like + The transformation matrix. + """ + + pole = coord.SkyCoord( + coord.CartesianRepresentation([0.0, 0.0, 1.0]).transform(R.T), + frame="icrs", + representation_type="unitspherical", + ) + origin = coord.SkyCoord( + coord.CartesianRepresentation([1.0, 0.0, 0.0]).transform(R.T), + frame="icrs", + representation_type="unitspherical", + ) + + return cls(pole=pole, origin=origin) + + +def make_greatcircle_cls(cls_name, docstring_header=None, **kwargs): + @format_doc(base_doc, components=_components, footer=_footer) + @greatcircle_transforms(self_transform=False) + class GCFrame(GreatCircleICRSFrame): + pole = CoordinateAttribute(default=kwargs.get("pole"), frame=coord.ICRS) + origin = CoordinateAttribute(default=kwargs.get("origin"), frame=coord.ICRS) + + GCFrame.__name__ = cls_name + if docstring_header: + GCFrame.__doc__ = f"{docstring_header}\n{GCFrame.__doc__}" + + return GCFrame diff --git a/gala/source/src/gala/coordinates/helpers.py b/gala/source/src/gala/coordinates/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..e16d50f3929370be8e3a9292db0c42c00bacabfb --- /dev/null +++ b/gala/source/src/gala/coordinates/helpers.py @@ -0,0 +1,49 @@ +from astropy.coordinates import Attribute + + +class StringValidatedAttribute(Attribute): + """ + Frame attribute for a string that is validated against a provided list of possible + values for the attribute. See the `~astropy.coordinates.Attribute` API doc for + further information. + + Parameters + ---------- + valid_values : iterable of str + A list or iterable of strings that define the valid values for the attribute + default : str, None + Default value for the attribute if not provided + secondary_attribute : str + Name of a secondary instance attribute which supplies the value if + ``default is None`` and no value was supplied during initialization. + """ + + def __init__(self, valid_values, default=None, secondary_attribute=""): + self.valid_values = list(valid_values) + try: + default = self.convert_input(default)[0] + except ValueError as e: + raise ValueError( + "The specified default value is not in the list of valid values." + ) from e + super().__init__(default, secondary_attribute) + + def convert_input(self, value): + """ + Checks that the input is a valid value. + + Parameters + ---------- + value : str + Input value to be validated + """ + + if value is None: + return None, False + + if value is not None and value not in self.valid_values: + raise ValueError( + "The specified attribute value is not in the list of valid values." + ) + + return value, False diff --git a/gala/source/src/gala/coordinates/jhelum.py b/gala/source/src/gala/coordinates/jhelum.py new file mode 100644 index 0000000000000000000000000000000000000000..3dee3a3cccfa9d09e831f56c99af698be1313833 --- /dev/null +++ b/gala/source/src/gala/coordinates/jhelum.py @@ -0,0 +1,100 @@ +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +from astropy.coordinates import frame_transform_graph + +__all__ = ["JhelumBonaca19"] + + +class JhelumBonaca19(coord.BaseCoordinateFrame): + """ + A Heliocentric spherical coordinate system defined by the orbit of the Jhelum + stream, as described in Bonaca et al. 2019. + + For more information about this class, see the Astropy documentation on coordinate + frames in :mod:`~astropy.coordinates`. + + Parameters + ---------- + representation : :class:`~astropy.coordinates.BaseRepresentation` or None + A representation object or None to have no data (or use the other keywords) + phi1 : angle_like, optional, must be keyword + The longitude-like angle aligned with the stream. + phi2 : angle_like, optional, must be keyword + The latitude-like angle aligned perpendicular to the stream. + distance : :class:`~astropy.units.Quantity`, optional, must be keyword + The Distance for this object along the line-of-sight. + + pm_phi1_cosphi2 : :class:`~astropy.units.Quantity`, optional, must be keyword + The proper motion in the longitude-like direction corresponding to + the Jhelum stream's orbit. + pm_phi2 : :class:`~astropy.units.Quantity`, optional, must be keyword + The proper motion in the latitude-like direction perpendicular to the + Jhelum stream's orbit. + radial_velocity : :class:`~astropy.units.Quantity`, optional, must be keyword + The radial velocity for this object along the line-of-sight. + + """ + + default_representation = coord.SphericalRepresentation + default_differential = coord.SphericalCosLatDifferential + + frame_specific_representation_info = { + coord.SphericalRepresentation: [ + coord.RepresentationMapping("lon", "phi1"), + coord.RepresentationMapping("lat", "phi2"), + coord.RepresentationMapping("distance", "distance"), + ], + } + + _default_wrap_angle = 180 * u.deg + + def __init__(self, *args, **kwargs): + wrap = kwargs.pop("wrap_longitude", True) + super().__init__(*args, **kwargs) + if wrap and isinstance( + self._data, + coord.UnitSphericalRepresentation | coord.SphericalRepresentation, + ): + self._data.lon.wrap_angle = self._default_wrap_angle + + # TODO: remove this. This is a hack required as of astropy v3.1 in order + # to have the longitude components wrap at the desired angle + def represent_as(self, base, s="base", in_frame_units=False): + r = super().represent_as(base, s=s, in_frame_units=in_frame_units) + if hasattr(r, "lon"): + r.lon.wrap_angle = self._default_wrap_angle + return r + + represent_as.__doc__ = coord.BaseCoordinateFrame.represent_as.__doc__ + + +# Rotation matrix as defined in Bonaca+2019 +R = np.array( + [ + [0.6173151074, -0.0093826715, -0.7866600433], + [-0.0151801852, -0.9998847743, 0.0000135163], + [-0.7865695266, 0.0119333013, -0.6173864075], + ] +) + + +@frame_transform_graph.transform( + coord.StaticMatrixTransform, coord.ICRS, JhelumBonaca19 +) +def icrs_to_jhelum(): + """ + Compute the transformation from ICRS spherical to heliocentric Jhelum + coordinates. + """ + return R + + +@frame_transform_graph.transform( + coord.StaticMatrixTransform, JhelumBonaca19, coord.ICRS +) +def jhelum_to_icrs(): + """ + Compute the transformation from heliocentric Jhelum coordinates to spherical ICRS. + """ + return icrs_to_jhelum().T diff --git a/gala/source/src/gala/coordinates/magellanic_stream.py b/gala/source/src/gala/coordinates/magellanic_stream.py new file mode 100644 index 0000000000000000000000000000000000000000..0887010e6ed872ef8a8c9cebf28138d04e65024d --- /dev/null +++ b/gala/source/src/gala/coordinates/magellanic_stream.py @@ -0,0 +1,90 @@ +"""Astropy coordinate class for the Magellanic Stream coordinate system""" + +import astropy.units as u +from astropy.coordinates import Galactic +from astropy.coordinates import representation as r +from astropy.coordinates.baseframe import ( + BaseCoordinateFrame, + RepresentationMapping, + frame_transform_graph, +) +from astropy.coordinates.matrix_utilities import rotation_matrix +from astropy.coordinates.transformations import StaticMatrixTransform + +__all__ = ["MagellanicStreamNidever08"] + + +class MagellanicStreamNidever08(BaseCoordinateFrame): + """ + A coordinate or frame aligned with the Magellanic Stream, + as defined by Nidever et al. (2008, + see: ``_). + + For more information about this class, see the Astropy documentation + on coordinate frames in :mod:`~astropy.coordinates`. + + Examples + -------- + Converting the coordinates of the Large Magellanic Cloud: + + >>> from astropy import coordinates as coord + >>> from astropy import units as u + >>> from gala.coordinates import MagellanicStreamNidever08 + + >>> c = coord.Galactic(l=280.4652*u.deg, b=-32.8884*u.deg) + >>> ms = c.transform_to(MagellanicStreamNidever08()) + >>> print(ms) + + """ + + frame_specific_representation_info = { + r.SphericalRepresentation: [ + RepresentationMapping("lon", "L"), + RepresentationMapping("lat", "B"), + ] + } + + default_representation = r.SphericalRepresentation + default_differential = r.SphericalCosLatDifferential + + _ngp = Galactic(l=188.5 * u.deg, b=-7.5 * u.deg) + _lon0 = Galactic(l=280.47 * u.deg, b=-32.75 * u.deg) + + _default_wrap_angle = 180 * u.deg + + def __init__(self, *args, **kwargs): + wrap = kwargs.pop("wrap_longitude", True) + super().__init__(*args, **kwargs) + if wrap and isinstance( + self._data, r.UnitSphericalRepresentation | r.SphericalRepresentation + ): + self._data.lon.wrap_angle = self._default_wrap_angle + + # TODO: remove this. This is a hack required as of astropy v3.1 in order + # to have the longitude components wrap at the desired angle + def represent_as(self, base, s="base", in_frame_units=False): + r = super().represent_as(base, s=s, in_frame_units=in_frame_units) + if hasattr(r, "lon"): + r.lon.wrap_angle = self._default_wrap_angle + return r + + represent_as.__doc__ = BaseCoordinateFrame.represent_as.__doc__ + + +@frame_transform_graph.transform( + StaticMatrixTransform, Galactic, MagellanicStreamNidever08 +) +def gal_to_mag(): + mat1 = rotation_matrix(57.275785782128686 * u.deg, "z") + mat2 = rotation_matrix(90 * u.deg - MagellanicStreamNidever08._ngp.b, "y") + mat3 = rotation_matrix(MagellanicStreamNidever08._ngp.l, "z") + + return mat1 @ mat2 @ mat3 + + +@frame_transform_graph.transform( + StaticMatrixTransform, MagellanicStreamNidever08, Galactic +) +def mag_to_gal(): + return gal_to_mag().T diff --git a/gala/source/src/gala/coordinates/oph.py b/gala/source/src/gala/coordinates/oph.py new file mode 100644 index 0000000000000000000000000000000000000000..26ffa94bd1a21dd4511a10f04d4e30cdd723aa2e --- /dev/null +++ b/gala/source/src/gala/coordinates/oph.py @@ -0,0 +1,103 @@ +"""Astropy coordinate class for the Ophiuchus coordinate system""" + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +from astropy.coordinates import frame_transform_graph + +__all__ = ["OphiuchusPriceWhelan16"] + + +class OphiuchusPriceWhelan16(coord.BaseCoordinateFrame): + """ + A Heliocentric spherical coordinate system defined by the orbit + of the Ophiuchus stream, as described in + Price-Whelan et al. 2016 (see: ``_). + + For more information about this class, see the Astropy documentation + on coordinate frames in :mod:`~astropy.coordinates`. + + Parameters + ---------- + representation : :class:`~astropy.coordinates.BaseRepresentation` or None + A representation object or None to have no data (or use the other keywords) + + phi1 : angle_like, optional, must be keyword + The longitude-like angle corresponding to Ophiuchus's orbit. + phi2 : angle_like, optional, must be keyword + The latitude-like angle corresponding to Ophiuchus's orbit. + distance : :class:`~astropy.units.Quantity`, optional, must be keyword + The Distance for this object along the line-of-sight. + + pm_phi1_cosphi2 : :class:`~astropy.units.Quantity`, optional, must be keyword + The proper motion in the longitude-like direction corresponding to + the Ophiuchus stream's orbit. + pm_phi2 : :class:`~astropy.units.Quantity`, optional, must be keyword + The proper motion in the latitude-like direction perpendicular to the + Ophiuchus stream's orbit. + radial_velocity : :class:`~astropy.units.Quantity`, optional, must be keyword + The radial velocity for this object along the line-of-sight. + + """ + + default_representation = coord.SphericalRepresentation + default_differential = coord.SphericalCosLatDifferential + + frame_specific_representation_info = { + coord.SphericalRepresentation: [ + coord.RepresentationMapping("lon", "phi1"), + coord.RepresentationMapping("lat", "phi2"), + coord.RepresentationMapping("distance", "distance"), + ] + } + + _default_wrap_angle = 180 * u.deg + + def __init__(self, *args, **kwargs): + wrap = kwargs.pop("wrap_longitude", True) + super().__init__(*args, **kwargs) + if wrap and isinstance( + self._data, + coord.UnitSphericalRepresentation | coord.SphericalRepresentation, + ): + self._data.lon.wrap_angle = self._default_wrap_angle + + # TODO: remove this. This is a hack required as of astropy v3.1 in order + # to have the longitude components wrap at the desired angle + def represent_as(self, base, s="base", in_frame_units=False): + r = super().represent_as(base, s=s, in_frame_units=in_frame_units) + if hasattr(r, "lon"): + r.lon.wrap_angle = self._default_wrap_angle + return r + + represent_as.__doc__ = coord.BaseCoordinateFrame.represent_as.__doc__ + + +# Rotation matrix +R = np.array( + [ + [0.84922096554, 0.07001279040, 0.52337554476], + [-0.27043653641, -0.79364259852, 0.54497294023], + [0.45352820359, -0.60434231606, -0.65504391727], + ] +) + + +@frame_transform_graph.transform( + coord.StaticMatrixTransform, coord.Galactic, OphiuchusPriceWhelan16 +) +def gal_to_oph(): + """Compute the transformation from Galactic spherical to + heliocentric Ophiuchus coordinates. + """ + return R + + +@frame_transform_graph.transform( + coord.StaticMatrixTransform, OphiuchusPriceWhelan16, coord.Galactic +) +def oph_to_gal(): + """Compute the transformation from heliocentric Ophiuchus coordinates to + spherical Galactic. + """ + return gal_to_oph().T diff --git a/gala/source/src/gala/coordinates/orphan.py b/gala/source/src/gala/coordinates/orphan.py new file mode 100644 index 0000000000000000000000000000000000000000..a4ffa8754293f7b92e13aa3ecd4cbefad1885810 --- /dev/null +++ b/gala/source/src/gala/coordinates/orphan.py @@ -0,0 +1,193 @@ +"""Astropy coordinate class for the Orphan stream coordinate systems""" + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +from astropy.coordinates import frame_transform_graph +from astropy.coordinates.matrix_utilities import rotation_matrix + +__all__ = ["OrphanKoposov19", "OrphanNewberg10"] + + +class OrphanNewberg10(coord.BaseCoordinateFrame): + """ + A Heliocentric spherical coordinate system defined by the orbit + of the Orphan stream, as described in + Newberg et al. 2010 (see: ``_). + + Note: to be consistent with other stream classes, we refer to the longitude + and latitude as ``phi1`` and ``phi2`` instead of ``Lambda`` and ``Beta``. + + For more information about this class, see the Astropy documentation + on coordinate frames in :mod:`~astropy.coordinates`. + + Parameters + ---------- + representation : :class:`~astropy.coordinates.BaseRepresentation` or None + A representation object or None to have no data (or use the other keywords) + + phi1 : angle_like, optional, must be keyword + The longitude-like angle corresponding to Orphan's orbit. + phi2 : angle_like, optional, must be keyword + The latitude-like angle corresponding to Orphan's orbit. + distance : :class:`~astropy.units.Quantity`, optional, must be keyword + The Distance for this object along the line-of-sight. + + pm_phi1_cosphi2 : :class:`~astropy.units.Quantity`, optional, must be keyword + The proper motion in the longitude-like direction corresponding to + the Orphan stream's orbit. + pm_phi2 : :class:`~astropy.units.Quantity`, optional, must be keyword + The proper motion in the latitude-like direction perpendicular to the + Orphan stream's orbit. + radial_velocity : :class:`~astropy.units.Quantity`, optional, must be keyword + The radial velocity for this object along the line-of-sight. + + """ + + default_representation = coord.SphericalRepresentation + default_differential = coord.SphericalCosLatDifferential + + frame_specific_representation_info = { + coord.SphericalRepresentation: [ + coord.RepresentationMapping("lon", "phi1"), + coord.RepresentationMapping("lat", "phi2"), + coord.RepresentationMapping("distance", "distance"), + ] + } + + _default_wrap_angle = 180 * u.deg + + def __init__(self, *args, **kwargs): + wrap = kwargs.pop("wrap_longitude", True) + super().__init__(*args, **kwargs) + if wrap and isinstance( + self._data, + coord.UnitSphericalRepresentation | coord.SphericalRepresentation, + ): + self._data.lon.wrap_angle = self._default_wrap_angle + + # TODO: remove this. This is a hack required as of astropy v3.1 in order + # to have the longitude components wrap at the desired angle + def represent_as(self, base, s="base", in_frame_units=False): + r = super().represent_as(base, s=s, in_frame_units=in_frame_units) + if hasattr(r, "lon"): + r.lon.wrap_angle = self._default_wrap_angle + return r + + represent_as.__doc__ = coord.BaseCoordinateFrame.represent_as.__doc__ + + +# Define the Euler angles +phi = 128.79 * u.degree +theta = 54.39 * u.degree +psi = 90.70 * u.degree + +# Generate the rotation matrix using the x-convention (see Goldstein) +D = rotation_matrix(phi, "z") +C = rotation_matrix(theta, "x") +B = rotation_matrix(psi, "z") +R = B @ C @ D + + +@frame_transform_graph.transform( + coord.StaticMatrixTransform, coord.Galactic, OrphanNewberg10 +) +def galactic_to_orp(): + """Compute the transformation from Galactic spherical to + heliocentric Orphan coordinates. + """ + return R + + +# Oph to Galactic coordinates +@frame_transform_graph.transform( + coord.StaticMatrixTransform, OrphanNewberg10, coord.Galactic +) +def orp_to_galactic(): + """Compute the transformation from heliocentric Orphan coordinates to + spherical Galactic. + """ + return galactic_to_orp().T + + +# ------------------------------------------------------------------------------ + + +class OrphanKoposov19(coord.BaseCoordinateFrame): + """A coordinate frame for the Orphan stream defined by Sergey Koposov. + + Parameters + ---------- + phi1 : `~astropy.units.Quantity` + Longitude component. + phi2 : `~astropy.units.Quantity` + Latitude component. + distance : `~astropy.units.Quantity` + Distance. + + pm_phi1_cosphi2 : `~astropy.units.Quantity` + Proper motion in longitude. + pm_phi2 : `~astropy.units.Quantity` + Proper motion in latitude. + radial_velocity : `~astropy.units.Quantity` + Line-of-sight or radial velocity. + """ + + default_representation = coord.SphericalRepresentation + default_differential = coord.SphericalCosLatDifferential + + frame_specific_representation_info = { + coord.SphericalRepresentation: [ + coord.RepresentationMapping("lon", "phi1"), + coord.RepresentationMapping("lat", "phi2"), + coord.RepresentationMapping("distance", "distance"), + ] + } + + _default_wrap_angle = 180 * u.deg + + def __init__(self, *args, **kwargs): + wrap = kwargs.pop("wrap_longitude", True) + super().__init__(*args, **kwargs) + if wrap and isinstance( + self._data, + coord.UnitSphericalRepresentation | coord.SphericalRepresentation, + ): + self._data.lon.wrap_angle = self._default_wrap_angle + + # TODO: remove this. This is a hack required as of astropy v3.1 in order + # to have the longitude components wrap at the desired angle + def represent_as(self, base, s="base", in_frame_units=False): + r = super().represent_as(base, s=s, in_frame_units=in_frame_units) + if hasattr(r, "lon"): + r.lon.wrap_angle = self._default_wrap_angle + return r + + represent_as.__doc__ = coord.BaseCoordinateFrame.represent_as.__doc__ + + +@frame_transform_graph.transform( + coord.StaticMatrixTransform, coord.ICRS, OrphanKoposov19 +) +def icrs_to_orp19(): + """Compute the transformation from ICRS to + heliocentric Orphan coordinates. + """ + return np.array( + [ + [-0.44761231, -0.08785756, -0.88990128], + [-0.84246097, 0.37511331, 0.38671632], + [0.29983786, 0.92280606, -0.2419219], + ] + ) + + +# Oph to Galactic coordinates +@frame_transform_graph.transform( + coord.StaticMatrixTransform, OrphanKoposov19, coord.ICRS +) +def orp19_to_icrs(): + """Compute the transformation from heliocentric Orphan coordinates to + spherical ICRS. + """ + return icrs_to_orp19().T diff --git a/gala/source/src/gala/coordinates/pal13.py b/gala/source/src/gala/coordinates/pal13.py new file mode 100644 index 0000000000000000000000000000000000000000..e976c040dac9e48db346363e8d1f65ef8114fe86 --- /dev/null +++ b/gala/source/src/gala/coordinates/pal13.py @@ -0,0 +1,98 @@ +"""Astropy coordinate class for the Palomar 5 stream coordinate system""" + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +from astropy.coordinates import frame_transform_graph + +__all__ = ["Pal13Shipp20"] + + +class Pal13Shipp20(coord.BaseCoordinateFrame): + """ + A Heliocentric spherical coordinate system defined by the orbit + of the Pal 13 stream by Shipp et al. (2020). + + For more information about this class, see the Astropy documentation + on coordinate frames in :mod:`~astropy.coordinates`. + + Parameters + ---------- + representation : :class:`~astropy.coordinates.BaseRepresentation` or None + A representation object or None to have no data (or use the other keywords) + + phi1 : angle_like, optional, must be keyword + The longitude-like angle corresponding to Pal 13's orbit. + phi2 : angle_like, optional, must be keyword + The latitude-like angle corresponding to Pal 13's orbit. + distance : :class:`~astropy.units.Quantity`, optional, must be keyword + The Distance for this object along the line-of-sight. + + pm_phi1_cosphi2 : :class:`~astropy.units.Quantity`, optional, must be keyword + The proper motion in the longitude-like direction corresponding to + the Pal 5 stream's orbit. + pm_phi2 : :class:`~astropy.units.Quantity`, optional, must be keyword + The proper motion in the latitude-like direction perpendicular to the + Pal 5 stream's orbit. + radial_velocity : :class:`~astropy.units.Quantity`, optional, must be keyword + The Distance for this object along the line-of-sight. + + """ + + default_representation = coord.SphericalRepresentation + default_differential = coord.SphericalCosLatDifferential + + frame_specific_representation_info = { + coord.SphericalRepresentation: [ + coord.RepresentationMapping("lon", "phi1"), + coord.RepresentationMapping("lat", "phi2"), + coord.RepresentationMapping("distance", "distance"), + ] + } + + _default_wrap_angle = 180 * u.deg + + def __init__(self, *args, **kwargs): + wrap = kwargs.pop("wrap_longitude", True) + super().__init__(*args, **kwargs) + if wrap and isinstance( + self._data, + coord.UnitSphericalRepresentation | coord.SphericalRepresentation, + ): + self._data.lon.wrap_angle = self._default_wrap_angle + + # TODO: remove this. This is a hack required as of astropy v3.1 in order + # to have the longitude components wrap at the desired angle + def represent_as(self, base, s="base", in_frame_units=False): + r = super().represent_as(base, s=s, in_frame_units=in_frame_units) + if hasattr(r, "lon"): + r.lon.wrap_angle = self._default_wrap_angle + return r + + represent_as.__doc__ = coord.BaseCoordinateFrame.represent_as.__doc__ + + +# Rotation matrix defined by trying to align the stream to the equator +R = np.array( + [ + [0.94906836, -0.22453560, 0.22102719], + [-0.06325861, 0.55143610, 0.83181523], + [-0.30865450, -0.80343138, 0.50914675], + ] +) + + +@frame_transform_graph.transform(coord.StaticMatrixTransform, coord.ICRS, Pal13Shipp20) +def icrs_to_pal13(): + """Compute the transformation from Galactic spherical to + heliocentric Pal 13 coordinates. + """ + return R + + +@frame_transform_graph.transform(coord.StaticMatrixTransform, Pal13Shipp20, coord.ICRS) +def pal13_to_icrs(): + """Compute the transformation from heliocentric Pal 13 coordinates to + spherical Galactic. + """ + return icrs_to_pal13() diff --git a/gala/source/src/gala/coordinates/pal5.py b/gala/source/src/gala/coordinates/pal5.py new file mode 100644 index 0000000000000000000000000000000000000000..6ca847d7fd9aeb1de8468b9c9b1ffd904fbfadcd --- /dev/null +++ b/gala/source/src/gala/coordinates/pal5.py @@ -0,0 +1,112 @@ +"""Astropy coordinate class for the Palomar 5 stream coordinate system""" + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +from astropy.coordinates import frame_transform_graph + +__all__ = ["Pal5PriceWhelan18"] + + +class Pal5PriceWhelan18(coord.BaseCoordinateFrame): + """ + A Heliocentric spherical coordinate system defined by the orbit + of the Pal 5 stream by A. Price-Whelan (2018). + + For more information about this class, see the Astropy documentation + on coordinate frames in :mod:`~astropy.coordinates`. + + Parameters + ---------- + representation : :class:`~astropy.coordinates.BaseRepresentation` or None + A representation object or None to have no data (or use the other keywords) + + phi1 : angle_like, optional, must be keyword + The longitude-like angle corresponding to Pal 5's orbit. + phi2 : angle_like, optional, must be keyword + The latitude-like angle corresponding to Pal 5's orbit. + distance : :class:`~astropy.units.Quantity`, optional, must be keyword + The Distance for this object along the line-of-sight. + + pm_phi1_cosphi2 : :class:`~astropy.units.Quantity`, optional, must be keyword + The proper motion in the longitude-like direction corresponding to + the Pal 5 stream's orbit. + pm_phi2 : :class:`~astropy.units.Quantity`, optional, must be keyword + The proper motion in the latitude-like direction perpendicular to the + Pal 5 stream's orbit. + radial_velocity : :class:`~astropy.units.Quantity`, optional, must be keyword + The radial velocity for this object along the line-of-sight. + + """ + + default_representation = coord.SphericalRepresentation + default_differential = coord.SphericalCosLatDifferential + + frame_specific_representation_info = { + coord.SphericalRepresentation: [ + coord.RepresentationMapping("lon", "phi1"), + coord.RepresentationMapping("lat", "phi2"), + coord.RepresentationMapping("distance", "distance"), + ] + } + + _default_wrap_angle = 180 * u.deg + + def __init__(self, *args, **kwargs): + wrap = kwargs.pop("wrap_longitude", True) + super().__init__(*args, **kwargs) + if wrap and isinstance( + self._data, + coord.UnitSphericalRepresentation | coord.SphericalRepresentation, + ): + self._data.lon.wrap_angle = self._default_wrap_angle + + # TODO: remove this. This is a hack required as of astropy v3.1 in order + # to have the longitude components wrap at the desired angle + def represent_as(self, base, s="base", in_frame_units=False): + r = super().represent_as(base, s=s, in_frame_units=in_frame_units) + if hasattr(r, "lon"): + r.lon.wrap_angle = self._default_wrap_angle + return r + + represent_as.__doc__ = coord.BaseCoordinateFrame.represent_as.__doc__ + + +# Rotation matrix defined by trying to align the stream to the equator +R = np.array( + [ + [-0.65019243, -0.75969758, -0.01045969], + [-0.62969142, 0.54652698, -0.55208422], + [0.42513354, -0.3523746, -0.83372274], + ] +) + +# Extra rotation to put the cluster center at (0, 0) +R2 = np.array( + [ + [9.99938314e-01, 1.57847502e-03, -1.09943927e-02], + [-1.57837962e-03, 9.99998754e-01, 1.73543959e-05], + [1.09944064e-02, 0.00000000e00, 9.99939560e-01], + ] +) +R = R2 @ R + + +@frame_transform_graph.transform( + coord.StaticMatrixTransform, coord.ICRS, Pal5PriceWhelan18 +) +def icrs_to_pal5(): + """Compute the transformation from ICRS spherical to + heliocentric Pal 5 coordinates. + """ + return R + + +@frame_transform_graph.transform( + coord.StaticMatrixTransform, Pal5PriceWhelan18, coord.ICRS +) +def pal5_to_icrs(): + """Compute the transformation from heliocentric Pal 5 coordinates to + ICRS spherical. + """ + return icrs_to_pal5().T diff --git a/gala/source/src/gala/coordinates/pm_cov_transform.py b/gala/source/src/gala/coordinates/pm_cov_transform.py new file mode 100644 index 0000000000000000000000000000000000000000..4d31b28818539e0cc7921e7de69550b02f7a98f2 --- /dev/null +++ b/gala/source/src/gala/coordinates/pm_cov_transform.py @@ -0,0 +1,141 @@ +import astropy.coordinates as coord +import numpy as np + +__all__ = ["transform_pm_cov"] + + +def get_uv_tan(c): + """Get tangent plane basis vectors on the unit sphere at the given + spherical coordinates. + """ + l = c.spherical.lon + b = c.spherical.lat + + p = np.array([-np.sin(l), np.cos(l), np.zeros_like(l.value)]).T + q = np.array([-np.cos(l) * np.sin(b), -np.sin(l) * np.sin(b), np.cos(b)]).T + + return np.stack((p, q), axis=-1) + + +def get_transform_matrix(from_frame, to_frame): + """Compose sequential matrix transformations (static or dynamic) to get a + single transformation matrix from a given path through the Astropy + transformation machinery. + + Parameters + ---------- + from_frame : `~astropy.coordinates.BaseCoordinateFrame` subclass + The *class* or instance of the frame you're transforming from. + to_frame : `~astropy.coordinates.BaseCoordinateFrame` subclass + The class or instance of the frame you're transforming to. + """ + if isinstance(from_frame, coord.BaseCoordinateFrame): + from_frame_cls = from_frame.__class__ + else: + from_frame_cls = from_frame + + if isinstance(to_frame, coord.BaseCoordinateFrame): + to_frame_cls = to_frame.__class__ + else: + to_frame_cls = to_frame + + path, _distance = coord.frame_transform_graph.find_shortest_path( + from_frame_cls, to_frame_cls + ) + + matrices = [] + currsys = from_frame + for p in path[1:]: # first element is fromsys so we skip it + if isinstance(currsys, coord.BaseCoordinateFrame): + currsys_cls = currsys.__class__ + else: + currsys_cls = currsys + currsys = currsys_cls() + + trans = coord.frame_transform_graph._graph[currsys_cls][p] + + if isinstance(to_frame, p): + p = to_frame + + if isinstance(trans, coord.DynamicMatrixTransform): + if not isinstance(p, coord.BaseCoordinateFrame): + p = p() + M = trans.matrix_func(currsys, p) + elif isinstance(trans, coord.StaticMatrixTransform): + M = trans.matrix + else: + msg = ( + f"Transform path contains a '{trans.__class__.__name__}': cannot " + "be composed into a single transformation " + "matrix." + ) + raise ValueError(msg) + + matrices.append(M) + currsys = p + + M = None + for Mi in reversed(matrices): + M = Mi if M is None else M @ Mi + + return M + + +def transform_pm_cov(c, cov, to_frame): + """Transform a proper motion covariance matrix to a new frame. + + Parameters + ---------- + c : `~astropy.coordinates.SkyCoord` + The sky coordinates of the sources in the initial coordinate frame. + cov : array_like + The covariance matrix of the proper motions. Must have same length as + the input coordinates. + to_frame : `~astropy.coordinates.BaseCoordinateFrame` subclass + The frame to transform to as an Astropy coordinate frame class or + instance. + + Returns + ------- + new_cov : array_like + The transformed covariance matrix. + + """ + if c.isscalar and cov.shape != (2, 2): + msg = ( + "If input coordinate object is a scalar coordinate, " + "the proper motion covariance matrix must have shape " + f"(2, 2), not {cov.shape}" + ) + raise ValueError(msg) + + if not c.isscalar and len(c) != cov.shape[0]: + msg = ( + "Input coordinates and covariance matrix must have " + f"the same number of entries ({len(c)} vs {cov.shape[0]})." + ) + raise ValueError(msg) + + # 3D rotation matrix, to be projected onto the tangent plane + frame = c.frame if hasattr(c, "frame") else c + R = get_transform_matrix(frame.__class__, to_frame) + + # Get input coordinates in the desired frame: + c_to = c.transform_to(to_frame) + + # Get tangent plane coordinates: + uv_in = get_uv_tan(c) + uv_to = get_uv_tan(c_to) + + if not c.isscalar: + G = np.einsum("nab, nac->nbc", uv_to, np.einsum("ji, nik->njk", R, uv_in)) + + # transform + cov_to = np.einsum("nba, nac->nbc", G, np.einsum("nij, nkj->nik", cov, G)) + else: + G = np.einsum("ab, ac->bc", uv_to, np.einsum("ji, ik->jk", R, uv_in)) + + # transform + cov_to = np.einsum("ba, ac->bc", G, np.einsum("ij, kj->ik", cov, G)) + + return cov_to diff --git a/gala/source/src/gala/coordinates/poincarepolar.py b/gala/source/src/gala/coordinates/poincarepolar.py new file mode 100644 index 0000000000000000000000000000000000000000..889d73a6d22a4caaaddbe848db257db83e85200f --- /dev/null +++ b/gala/source/src/gala/coordinates/poincarepolar.py @@ -0,0 +1,43 @@ +import numpy as np + +__all__ = ["cartesian_to_poincare_polar"] + + +def cartesian_to_poincare_polar(w): + r""" + Convert an array of 6D Cartesian positions to Poincaré + symplectic polar coordinates. These are similar to cylindrical + coordinates. + + Parameters + ---------- + w : array_like + Input array of 6D Cartesian phase-space positions. Should have + shape ``(..., 6)`` where the last axis contains the phase-space + coordinates in the order ``(x, y, z, vx, vy, vz)``. + + Returns + ------- + new_w : `~numpy.ndarray` + Points represented in 6D Poincaré polar coordinates with the same + shape as the input array. The coordinates are ordered as + ``(R, p_phi, z, v_R, p_phi_dot, v_z)``. + + """ + + R = np.sqrt(w[..., 0] ** 2 + w[..., 1] ** 2) + # phi = np.arctan2(w[..., 1], w[..., 0]) + phi = np.arctan2(w[..., 0], w[..., 1]) + + vR = (w[..., 0] * w[..., 0 + 3] + w[..., 1] * w[..., 1 + 3]) / R + vPhi = w[..., 0] * w[..., 1 + 3] - w[..., 1] * w[..., 0 + 3] + + # pg. 437, Papaphillipou & Laskar (1996) + sqrt_2THETA = np.sqrt(np.abs(2 * vPhi)) + pp_phi = sqrt_2THETA * np.cos(phi) + pp_phidot = sqrt_2THETA * np.sin(phi) + + z = w[..., 2] + zdot = w[..., 2 + 3] + + return np.vstack((R.T, pp_phi.T, z.T, vR.T, pp_phidot.T, zdot.T)).T diff --git a/gala/source/src/gala/coordinates/reflex.py b/gala/source/src/gala/coordinates/reflex.py new file mode 100644 index 0000000000000000000000000000000000000000..cf6d2bb524ee83f5152fa36cfbcbff838fed4058 --- /dev/null +++ b/gala/source/src/gala/coordinates/reflex.py @@ -0,0 +1,43 @@ +import astropy.coordinates as coord + +__all__ = ["reflex_correct"] + + +def reflex_correct(coords, galactocentric_frame=None): + """Correct the input Astropy coordinate object for solar reflex motion. + + The input coordinate instance must have distance and radial velocity information. + So, if the radial velocity is not known, fill the radial velocity values with zeros + to reflex-correct the proper motions. + + Parameters + ---------- + coords : `~astropy.coordinates.SkyCoord` + The Astropy coordinate object with position and velocity information. + galactocentric_frame : `~astropy.coordinates.Galactocentric` (optional) + To change properties of the Galactocentric frame, like the height of the + sun above the midplane, or the velocity of the sun in a Galactocentric + intertial frame, set arguments of the + `~astropy.coordinates.Galactocentric` object and pass in to this + function with your coordinates. + + Returns + ------- + coords : `~astropy.coordinates.SkyCoord` + The coordinates in the same frame as input, but with solar motion + removed. + + """ + c = coord.SkyCoord(coords) + + # If not specified, use the Astropy default Galactocentric frame + if galactocentric_frame is None: + galactocentric_frame = coord.Galactocentric() + + v_sun = galactocentric_frame.galcen_v_sun + + observed = c.transform_to(galactocentric_frame) + rep = observed.cartesian.without_differentials() + rep = rep.with_differentials(observed.cartesian.differentials["s"] + v_sun) + fr = galactocentric_frame.realize_frame(rep).transform_to(c.frame) + return coord.SkyCoord(fr) diff --git a/gala/source/src/gala/coordinates/sgr.py b/gala/source/src/gala/coordinates/sgr.py new file mode 100644 index 0000000000000000000000000000000000000000..04e91d87096ffb21d1f2c37222abba5d9d778302 --- /dev/null +++ b/gala/source/src/gala/coordinates/sgr.py @@ -0,0 +1,195 @@ +"""Astropy coordinate class for the Sagittarius coordinate system""" + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +from astropy.coordinates import frame_transform_graph +from astropy.coordinates.matrix_utilities import rotation_matrix + +__all__ = ["SagittariusLaw10", "SagittariusVasiliev21"] + + +class SagittariusLaw10(coord.BaseCoordinateFrame): + """ + A Heliocentric spherical coordinate system defined by the orbit + of the Sagittarius dwarf galaxy, as described in + Law & Majewski (2010): http://adsabs.harvard.edu/abs/2010ApJ...714..229L + + Parameters + ---------- + representation : `~astropy.coordinates.BaseRepresentation` or None + A representation object or None to have no data (or use the other + keywords). + + Lambda : `~astropy.coordinates.Angle`, optional, must be keyword + The longitude-like angle corresponding to Sagittarius' orbit. + Beta : `~astropy.coordinates.Angle`, optional, must be keyword + The latitude-like angle corresponding to Sagittarius' orbit. + distance : `~astropy.units.Quantity`, optional, must be keyword + The Distance for this object along the line-of-sight. + + pm_Lambda_cosBeta : `~astropy.units.Quantity`, optional, must be keyword + The proper motion along the stream in ``Lambda`` (including the + ``cos(Beta)`` factor) for this object (``pm_Beta`` must also be given). + pm_Beta : `~astropy.units.Quantity`, optional, must be keyword + The proper motion in ``Beta`` for this object (``pm_Lambda_cosBeta`` must + also be given). + radial_velocity : `~astropy.units.Quantity`, optional, must be keyword + The radial velocity of this object. + + """ + + default_representation = coord.SphericalRepresentation + default_differential = coord.SphericalCosLatDifferential + + frame_specific_representation_info = { + coord.SphericalRepresentation: [ + coord.RepresentationMapping("lon", "Lambda"), + coord.RepresentationMapping("lat", "Beta"), + coord.RepresentationMapping("distance", "distance"), + ] + } + + _default_wrap_angle = 180 * u.deg + + def __init__(self, *args, **kwargs): + wrap = kwargs.pop("wrap_longitude", True) + super().__init__(*args, **kwargs) + if wrap and isinstance( + self._data, + coord.UnitSphericalRepresentation | coord.SphericalRepresentation, + ): + self._data.lon.wrap_angle = self._default_wrap_angle + + # TODO: remove this. This is a hack required as of astropy v3.1 in order + # to have the longitude components wrap at the desired angle + def represent_as(self, base, s="base", in_frame_units=False): + r = super().represent_as(base, s=s, in_frame_units=in_frame_units) + if hasattr(r, "lon"): + r.lon.wrap_angle = self._default_wrap_angle + return r + + represent_as.__doc__ = coord.BaseCoordinateFrame.represent_as.__doc__ + + +# Define the Euler angles (from Law & Majewski 2010) +phi = (180 + 3.75) * u.degree +theta = (90 - 13.46) * u.degree +psi = (180 + 14.111534) * u.degree + +# Generate the rotation matrix using the x-convention (see Goldstein) +D = rotation_matrix(phi, "z") +C = rotation_matrix(theta, "x") +B = rotation_matrix(psi, "z") +A = np.diag([1.0, 1.0, -1.0]) +R = A @ B @ C @ D + + +# Galactic to Sgr coordinates +@frame_transform_graph.transform( + coord.StaticMatrixTransform, coord.Galactic, SagittariusLaw10 +) +def galactic_to_sgr(): + """Compute the transformation from Galactic spherical to + heliocentric Sagittarius coordinates. + """ + return R + + +# Sgr to Galactic coordinates +@frame_transform_graph.transform( + coord.StaticMatrixTransform, SagittariusLaw10, coord.Galactic +) +def sgr_to_galactic(): + """Compute the transformation from heliocentric Sagittarius coordinates to + spherical Galactic. + """ + return galactic_to_sgr().T + + +# ------------------------------------------------------------------------------------- + + +class SagittariusVasiliev21(coord.BaseCoordinateFrame): + """ + A Heliocentric, right-handed spherical coordinate system defined by the orbit of the + Sagittarius dwarf galaxy, as described in Vasiliev et al. (2021): + https://ui.adsabs.harvard.edu/abs/2021MNRAS.501.2279V/abstract + + Parameters + ---------- + representation : `~astropy.coordinates.BaseRepresentation` or None + A representation object or None to have no data (or use the other + keywords). + + Lambda : `~astropy.coordinates.Angle`, optional, must be keyword + The longitude-like angle corresponding to Sagittarius' orbit. + Beta : `~astropy.coordinates.Angle`, optional, must be keyword + The latitude-like angle corresponding to Sagittarius' orbit. + distance : `~astropy.units.Quantity`, optional, must be keyword + The Distance for this object along the line-of-sight. + + pm_Lambda_cosBeta : `~astropy.units.Quantity`, optional, must be keyword + The proper motion along the stream in ``Lambda`` (including the + ``cos(Beta)`` factor) for this object (``pm_Beta`` must also be given). + pm_Beta : `~astropy.units.Quantity`, optional, must be keyword + The proper motion in ``Beta`` for this object (``pm_Lambda_cosBeta`` must + also be given). + radial_velocity : `~astropy.units.Quantity`, optional, must be keyword + The radial velocity of this object. + + """ + + default_representation = coord.SphericalRepresentation + default_differential = coord.SphericalCosLatDifferential + + frame_specific_representation_info = { + coord.SphericalRepresentation: [ + coord.RepresentationMapping("lon", "Lambda"), + coord.RepresentationMapping("lat", "Beta"), + coord.RepresentationMapping("distance", "distance"), + ] + } + + _default_wrap_angle = 180 * u.deg + + def __init__(self, *args, **kwargs): + wrap = kwargs.pop("wrap_longitude", True) + super().__init__(*args, **kwargs) + if wrap and isinstance( + self._data, + coord.UnitSphericalRepresentation | coord.SphericalRepresentation, + ): + self._data.lon.wrap_angle = self._default_wrap_angle + + # TODO: remove this. This is a hack required as of astropy v3.1 in order + # to have the longitude components wrap at the desired angle + def represent_as(self, base, s="base", in_frame_units=False): + r = super().represent_as(base, s=s, in_frame_units=in_frame_units) + if hasattr(r, "lon"): + r.lon.wrap_angle = self._default_wrap_angle + return r + + represent_as.__doc__ = coord.BaseCoordinateFrame.represent_as.__doc__ + + +# Galactic to Sgr coordinates +@frame_transform_graph.transform( + coord.StaticMatrixTransform, coord.Galactic, SagittariusVasiliev21 +) +def galactic_to_sgr_v21(): + """Compute the transformation from Galactic to Sagittarius coordinates""" + if not hasattr(SagittariusVasiliev21, "_R"): + R = np.diag([1.0, -1.0, -1.0]) @ B @ C @ D + SagittariusVasiliev21._R = R + + return SagittariusVasiliev21._R + + +# Sgr to Galactic coordinates +@frame_transform_graph.transform( + coord.StaticMatrixTransform, SagittariusVasiliev21, coord.Galactic +) +def sgr_to_galactic_v21(): + """Compute the transformation from Sagittarius coordinates to Galactic coordinates""" + return galactic_to_sgr_v21().T diff --git a/gala/source/src/gala/coordinates/velocity_frame_transforms.py b/gala/source/src/gala/coordinates/velocity_frame_transforms.py new file mode 100644 index 0000000000000000000000000000000000000000..0d3082cbf24b98b75537f20b19a0fe96f0a4e296 --- /dev/null +++ b/gala/source/src/gala/coordinates/velocity_frame_transforms.py @@ -0,0 +1,74 @@ +"""Miscellaneous astronomical velocity transformations.""" + +import astropy.coordinates as coord + +__all__ = ["vgsr_to_vhel", "vhel_to_vgsr"] + + +def _get_vproj(c, vsun): + gal = c.transform_to(coord.Galactic()) + cart_data = gal.data.to_cartesian() + unit_vector = cart_data / cart_data.norm() + return coord.CartesianRepresentation(vsun).dot(unit_vector) + + +def vgsr_to_vhel(coordinate, vgsr, vsun=None): + """ + Convert a radial velocity in the Galactic standard of rest (GSR) to + a barycentric radial velocity. + + Parameters + ---------- + coordinate : :class:`~astropy.coordinates.SkyCoord` + An Astropy SkyCoord object or anything object that can be passed + to the SkyCoord initializer. + vgsr : :class:`~astropy.units.Quantity` + GSR line-of-sight velocity. + vsun : :class:`~astropy.units.Quantity`, optional + Full-space velocity of the sun in a Galactocentric frame. By default, + uses the value assumed by Astropy in + `~astropy.coordinates.Galactocentric`. + + Returns + ------- + vhel : :class:`~astropy.units.Quantity` + Radial velocity in a barycentric rest frame. + + """ + + if vsun is None: + galcen = coord.Galactocentric() + vsun = galcen.galcen_v_sun.to_cartesian().xyz + + return vgsr - _get_vproj(coordinate, vsun) + + +def vhel_to_vgsr(coordinate, vhel, vsun=None): + """ + Convert a velocity from a heliocentric radial velocity to + the Galactic standard of rest (GSR). + + Parameters + ---------- + coordinate : :class:`~astropy.coordinates.SkyCoord` + An Astropy SkyCoord object or anything object that can be passed + to the SkyCoord initializer. + vhel : :class:`~astropy.units.Quantity` + Barycentric line-of-sight velocity. + vsun : :class:`~astropy.units.Quantity`, optional + Full-space velocity of the sun in a Galactocentric frame. By default, + uses the value assumed by Astropy in + `~astropy.coordinates.Galactocentric`. + + Returns + ------- + vgsr : :class:`~astropy.units.Quantity` + Radial velocity in a galactocentric rest frame. + + """ + + if vsun is None: + galcen = coord.Galactocentric() + vsun = galcen.galcen_v_sun.to_cartesian().xyz + + return vhel + _get_vproj(coordinate, vsun) diff --git a/gala/source/src/gala/dynamics/__init__.py b/gala/source/src/gala/dynamics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b50b54461278f409efbd7bfb2eddfc8812efa50c --- /dev/null +++ b/gala/source/src/gala/dynamics/__init__.py @@ -0,0 +1,9 @@ +from .actionangle import * +from .core import PhaseSpacePosition +from .mockstream import * +from .nbody import * +from .nonlinear import * +from .orbit import Orbit +from .plot import * +from .representation_nd import * +from .util import * diff --git a/gala/source/src/gala/dynamics/actionangle/__init__.py b/gala/source/src/gala/dynamics/actionangle/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..03d958d95786df2be53b63102b5f6edea00317a2 --- /dev/null +++ b/gala/source/src/gala/dynamics/actionangle/__init__.py @@ -0,0 +1,3 @@ +from .actionangle_o2gf import * +from .actionangle_staeckel import * +from .analyticactionangle import * diff --git a/gala/source/src/gala/dynamics/actionangle/actionangle_o2gf.py b/gala/source/src/gala/dynamics/actionangle/actionangle_o2gf.py new file mode 100644 index 0000000000000000000000000000000000000000..6482b187b4927dced7b2f42f7aafa26d4e0cd6c7 --- /dev/null +++ b/gala/source/src/gala/dynamics/actionangle/actionangle_o2gf.py @@ -0,0 +1,751 @@ +""" +Utilities for estimating actions and angles for an arbitrary orbit in an +arbitrary potential. +""" + +import time +import warnings + +import astropy.table as at +import astropy.units as u +import numpy as np +from astropy.constants import G +from scipy.linalg import solve +from scipy.optimize import minimize + +from gala.logging import logger + +__all__ = [ + "check_angle_sampling", + "find_actions_o2gf", + "fit_harmonic_oscillator", + "fit_isochrone", + "fit_toy_potential", + "generate_n_vectors", +] + + +def generate_n_vectors(N_max, dx=1, dy=1, dz=1, half_lattice=True): + r""" + Generate integer vectors, :math:`\boldsymbol{n}`, with + :math:`|\boldsymbol{n}| < N_{\rm max}`. + + If ``half_lattice=True``, only return half of the three-dimensional + lattice. If the set N = {(i, j, k)} defines the lattice, we restrict to + the cases such that ``(k > 0)``, ``(k = 0, j > 0)``, and + ``(k = 0, j = 0, i > 0)``. + + .. todo:: + + Return shape should be (3, N) to be consistent. + + Parameters + ---------- + N_max : int + Maximum norm of the integer vector. + dx : int + Step size in x direction. Set to 1 for odd and even terms, set + to 2 for just even terms. + dy : int + Step size in y direction. Set to 1 for odd and even terms, set + to 2 for just even terms. + dz : int + Step size in z direction. Set to 1 for odd and even terms, set + to 2 for just even terms. + half_lattice : bool (optional) + Only return half of the 3D lattice. + + Returns + ------- + vecs : :class:`numpy.ndarray` + A 2D array of integers with :math:`|\boldsymbol{n}| < N_{\rm max}` + with shape (N, 3). + + """ + vecs = np.meshgrid( + np.arange(-N_max, N_max + 1, dx), + np.arange(-N_max, N_max + 1, dy), + np.arange(-N_max, N_max + 1, dz), + ) + vecs = np.vstack(list(map(np.ravel, vecs))).T + vecs = vecs[np.linalg.norm(vecs, axis=1) <= N_max] + + if half_lattice: + ix = ( + (vecs[:, 2] > 0) + | ((vecs[:, 2] == 0) & (vecs[:, 1] > 0)) + | ((vecs[:, 2] == 0) & (vecs[:, 1] == 0) & (vecs[:, 0] > 0)) + ) + vecs = vecs[ix] + + return np.array(sorted(vecs, key=lambda x: (x[0], x[1], x[2]))) + + +@u.quantity_input(m0=u.Msun, b0=u.kpc) +def fit_isochrone(orbit, m0=None, b0=None, minimize_kwargs=None): + r""" + Fit the toy Isochrone potential to the sum of the energy residuals relative + to the mean energy by minimizing the function + + .. math:: + + f(m, b) = \sum_i (\frac{1}{2}v_i^2 + \Phi_{\rm iso}(x_i\,|\,m, b) - )^2 + + TODO: This should fail if the Hamiltonian associated with the orbit has + a frame other than StaticFrame + + Parameters + ---------- + orbit : `~gala.dynamics.Orbit` + m0 : numeric (optional) + Initial guess for mass parameter of fitted Isochrone model. + b0 : numeric (optional) + Initial guess for scale length parameter of fitted Isochrone model. + minimize_kwargs : dict (optional) + Keyword arguments to pass through to `scipy.optimize.minimize`. + + Returns + ------- + fit_iso : `gala.potential.IsochronePotential` + Best-fit Isochrone potential for locally representing true potential. + + """ + from gala.potential import IsochronePotential, LogarithmicPotential + + pot = orbit.potential + if pot is None: + raise ValueError( + "The inputted orbit does not have an associated potential instance " + "(i.e. orbit.potential is None). You must provide an orbit instance" + " with a specified potential in order to initialize the toy " + "potential fitting." + ) + + w = np.squeeze(orbit.w(pot.units)) + if w.ndim > 2: + raise ValueError("Input orbit object must be a single orbit.") + + if (m0 is not None and b0 is None) or (m0 is None and b0 is not None): + raise ValueError( + "If passing in initial guess for one parameter, you " + "must also pass in an initial guess for the other " + "(m0 and b0)." + ) + + if m0 is not None and b0 is not None: + # both initial guesses provided + m0 = m0.decompose(pot.units).value + b0 = b0.decompose(pot.units).value + + else: + # initial guess not specified: some magic to come up with initialization + r0 = np.mean(orbit.physicsspherical.r) + Menc0 = pot.mass_enclosed([1, 0, 0] * r0)[0].decompose(pot.units).value + Phi0 = pot.energy([1, 0, 0] * r0)[0] + Phi0 = Phi0.decompose(pot.units).value + r0 = r0.decompose(pot.units).value + + G_ = G.decompose(pot.units).value + + # Special case the logarithmic potential: + if isinstance(pot, LogarithmicPotential): + + def func(pars, r0, M0, Phi0): + b, const = pars + a0 = np.sqrt(r0**2 + b**2) + return (-G_ * M0 / r0**3 * a0 * (b + a0) - Phi0 + const) ** 2 + + res = minimize( + func, + x0=[r0, 0], + args=(r0, Menc0, Phi0), + method="L-BFGS-B", + bounds=[(0, None), (None, None)], + ) + + else: + + def func(b, r0, M0, Phi0): + a0 = np.sqrt(r0**2 + b**2) + return (-G_ * M0 / r0**3 * a0 * (b + a0) - Phi0) ** 2 + + res = minimize( + func, + x0=[r0], + args=(r0, Menc0, Phi0), + method="L-BFGS-B", + bounds=[(0, None)], + ) + + if not res.success: + raise RuntimeError( + "Root finding failed: Unable to find local Isochrone potential " + "fit for orbit." + ) + + b = res.x[0] + a0 = np.sqrt(b**2 + r0**2) + M = Menc0 / r0**3 * a0 * (b + a0) ** 2 + + m0 = M + b0 = b + + def f(p, w): + logm, logb = p + potential = IsochronePotential(m=np.exp(logm), b=np.exp(logb), units=pot.units) + H = potential.energy(w[:3]).decompose(pot.units).value + 0.5 * np.sum( + w[3:] ** 2, axis=0 + ) + return np.sum(np.squeeze(H - np.mean(H)) ** 2) + + logm0 = np.log(m0) + logb0 = np.log(b0) + + if minimize_kwargs is None: + minimize_kwargs = {} + minimize_kwargs.setdefault("x0", np.array([logm0, logb0])) + minimize_kwargs.setdefault("method", "powell") + res = minimize(f, args=(w,), **minimize_kwargs) + + if not res.success: + raise ValueError("Failed to fit toy potential to orbit.") + + return IsochronePotential(*np.exp(res.x), units=pot.units) + + +def fit_harmonic_oscillator(orbit, omega0=None, minimize_kwargs=None): + r""" + Fit the toy harmonic oscillator potential to the sum of the energy + residuals relative to the mean energy by minimizing the function + + .. math:: + + f(\boldsymbol{\omega}) = \sum_i (\frac{1}{2}v_i^2 + + \Phi_{\rm sho}(x_i\,|\,\boldsymbol{\omega}) - )^2 + + TODO: This should fail if the Hamiltonian associated with the orbit has + a frame other than StaticFrame + + Parameters + ---------- + orbit : `~gala.dynamics.Orbit` + omega0 : array_like (optional) + Initial frequency guess. + minimize_kwargs : dict (optional) + Keyword arguments to pass through to `scipy.optimize.minimize`. + + Returns + ------- + omegas : float + Best-fit harmonic oscillator frequencies. + + """ + from gala.potential import HarmonicOscillatorPotential + + pot = orbit.potential + if pot is None: + raise ValueError( + "The inputted orbit does not have an associated potential instance " + "(i.e. orbit.potential is None). You must provide an orbit instance" + " with a specified potential in order to initialize the toy " + "potential fitting." + ) + + if omega0 is None: + # Estimate from orbit: + P = orbit.cartesian.estimate_period()[0] + P = u.Quantity([P[k] for k in P.colnames]) + omega0 = (2 * np.pi / P).decompose(pot.units).value + else: + omega0 = np.atleast_1d(omega0) + + w = np.squeeze(orbit.w(pot.units)) + if w.ndim > 2: + raise ValueError("Input orbit object must be a single orbit.") + + def f(omega, w): + potential = HarmonicOscillatorPotential(omega=omega, units=pot.units) + H = potential.energy(w[:3]).decompose(pot.units).value + 0.5 * np.sum( + w[3:] ** 2, axis=0 + ) + return np.sum(np.squeeze(H - np.mean(H)) ** 2) + + if minimize_kwargs is None: + minimize_kwargs = {} + minimize_kwargs["x0"] = omega0 + minimize_kwargs["method"] = minimize_kwargs.get("method", "powell") + res = minimize(f, args=(w,), **minimize_kwargs) + + if not res.success: + raise ValueError("Failed to fit toy potential to orbit.") + + best_omega = np.abs(res.x) + return HarmonicOscillatorPotential(omega=best_omega, units=pot.units) + + +def fit_toy_potential(orbit, force_harmonic_oscillator=False, **kwargs): + """ + Fit a best fitting toy potential to the orbit provided. If the orbit is a + tube (loop) orbit, use the Isochrone potential. If the orbit is a box + potential, use the harmonic oscillator potential. An option is available to + force using the harmonic oscillator (`force_harmonic_oscillator`). + + See the docstrings for ~`gala.dynamics.fit_isochrone()` and + ~`gala.dynamics.fit_harmonic_oscillator()` for more information. + + Parameters + ---------- + orbit : `~gala.dynamics.Orbit` + force_harmonic_oscillator : bool (optional) + Force using the harmonic oscillator potential as the toy potential. + + Returns + ------- + potential + The best-fit potential instance. + + """ + + circulation = orbit.circulation() + if np.any(circulation == 1) and not force_harmonic_oscillator: # tube orbit + logger.debug("===== Tube orbit =====") + logger.debug("Using Isochrone toy potential") + + toy_potential = fit_isochrone(orbit, **kwargs) + logger.debug( + f"Best m={toy_potential.parameters['m']}, b={toy_potential.parameters['b']}" + ) + + else: # box orbit + logger.debug("===== Box orbit =====") + logger.debug("Using triaxial harmonic oscillator toy potential") + + toy_potential = fit_harmonic_oscillator(orbit, **kwargs) + logger.debug(f"Best omegas ({toy_potential.parameters['omega']})") + + return toy_potential + + +def check_angle_sampling(nvecs, angles): + """ + Returns a list of the index of elements of n which do not have adequate + toy angle coverage. The criterion is that we must have at least one sample + in each Nyquist box when we project the toy angles along the vector n. + + Parameters + ---------- + nvecs : array_like + Array of integer vectors. + angles : array_like + Array of angles. + + Returns + ------- + failed_nvecs : :class:`numpy.ndarray` + Array of all integer vectors that failed checks. Has shape (N, 3). + failures : :class:`numpy.ndarray` + Array of flags that designate whether this failed needing a longer + integration window (0) or finer sampling (1). + + """ + + failed_nvecs = [] + failures = [] + warn_longer_window = [] + warn_finer_sampling = [] + for _i, vec in enumerate(nvecs): + # N = np.linalg.norm(vec) + # X = np.dot(angles, vec) + X = (angles * vec[:, None]).sum(axis=0) + diff = float(np.abs(X.max() - X.min())) + + if diff < (2.0 * np.pi): + failed_nvecs.append(vec.tolist()) + # P.append(2.*np.pi - diff) + failures.append(0) + warn_longer_window.append(vec) + + elif (diff / len(X)) > np.pi: + failed_nvecs.append(vec.tolist()) + # P.append(np.pi - diff/len(X)) + failures.append(1) + warn_finer_sampling.append(vec) + + if len(warn_longer_window) > 0: + warn_longer_window = np.array(warn_longer_window) + warnings.warn( + f"Need a longer integration window for modes: {warn_longer_window}", + RuntimeWarning, + ) + + if len(warn_finer_sampling) > 0: + warn_finer_sampling = np.array(warn_finer_sampling) + warnings.warn( + f"Need a finer time sampling for modes: {warn_finer_sampling}", + RuntimeWarning, + ) + + return np.array(failed_nvecs), np.array(failures) + + +def _action_prepare(aa, N_max, dx, dy, dz, sign=1.0, throw_out_modes=False): + """ + Given toy actions and angles, `aa`, compute the matrix `A` and + vector `b` to solve for the vector of "true" actions and generating + function values, `x` (see Equations 12-14 in Sanders & Binney (2014)). + + .. todo:: + + Wrong shape for aa -- should be (6, n) as usual... + + Parameters + ---------- + aa : array_like + Shape ``(6, ntimes)`` array of toy actions and angles. + N_max : int + Maximum norm of the integer vector. + dx : int + Step size in x direction. Set to 1 for odd and even terms, set + to 2 for just even terms. + dy : int + Step size in y direction. Set to 1 for odd and even terms, set + to 2 for just even terms. + dz : int + Step size in z direction. Set to 1 for odd and even terms, set + to 2 for just even terms. + sign : numeric (optional) + Vector that defines direction of circulation about the axes. + """ + + # unroll the angles so they increase continuously instead of wrap + angles = np.unwrap(aa[3:]) + + # generate integer vectors for fourier modes + nvecs = generate_n_vectors(N_max, dx, dy, dz) + + # make sure we have enough angle coverage + _modes, _P = check_angle_sampling(nvecs, angles) + + # throw out modes? + # if throw_out_modes: + # nvecs = np.delete(nvecs, (modes, P), axis=0) + + n = len(nvecs) + 3 + b = np.zeros(shape=(n,)) + A = np.zeros(shape=(n, n)) + + # top left block matrix: identity matrix summed over timesteps + A[:3, :3] = aa.shape[1] * np.identity(3) + + actions = aa[:3] + angles = aa[3:] + + # top right block matrix: transpose of C_nk matrix (Eq. 12) + C_T = 2.0 * nvecs.T * np.sum(np.cos(np.dot(nvecs, angles)), axis=-1) + A[:3, 3:] = C_T + A[3:, :3] = C_T.T + + # lower right block matrix: C_nk dotted with C_nk^T + cosv = np.cos(np.dot(nvecs, angles)) + A[3:, 3:] = 4.0 * np.dot(nvecs, nvecs.T) * np.einsum("it, jt->ij", cosv, cosv) + + # b vector first three is just sum of toy actions + b[:3] = np.sum(actions, axis=1) + + # rest of the vector is C dotted with actions + b[3:] = 2 * np.sum(np.dot(nvecs, actions) * np.cos(np.dot(nvecs, angles)), axis=1) + + return A, b, nvecs + + +def _angle_prepare(aa, t, N_max, dx, dy, dz, sign=1.0): + """ + Given toy actions and angles, `aa`, compute the matrix `A` and + vector `b` to solve for the vector of "true" angles, frequencies, and + generating function derivatives, `x` (see Appendix of + Sanders & Binney (2014)). + + .. todo:: + + Wrong shape for aa -- should be (6, n) as usual... + + Parameters + ---------- + aa : array_like + Shape ``(6, ntimes)`` array of toy actions and angles. + t : array_like + Array of times. + N_max : int + Maximum norm of the integer vector. + dx : int + Step size in x direction. Set to 1 for odd and even terms, set + to 2 for just even terms. + dy : int + Step size in y direction. Set to 1 for odd and even terms, set + to 2 for just even terms. + dz : int + Step size in z direction. Set to 1 for odd and even terms, set + to 2 for just even terms. + sign : numeric (optional) + Vector that defines direction of circulation about the axes. + """ + + # unroll the angles so they increase continuously instead of wrap + angles = np.unwrap(aa[3:]) + + # generate integer vectors for fourier modes + nvecs = generate_n_vectors(N_max, dx, dy, dz) + + # make sure we have enough angle coverage + _modes, _P = check_angle_sampling(nvecs, angles) + + # TODO: throw out modes? + # if(throw_out_modes): + # n_vectors = np.delete(n_vectors, check_each_direction(n_vectors, angs), axis=0) + + nv = len(nvecs) + n = 3 + 3 + 3 * nv # angle(0)'s, freqs, 3 derivatives of Sn + + b = np.zeros(shape=(n,)) + A = np.zeros(shape=(n, n)) + + # top left block matrix: identity matrix summed over timesteps + A[:3, :3] = aa.shape[1] * np.identity(3) + + # identity matrices summed over times + A[:3, 3:6] = A[3:6, :3] = np.sum(t) * np.identity(3) + A[3:6, 3:6] = np.sum(t * t) * np.identity(3) + + # S1, 2, 3 + A[6 : 6 + nv, 0] = -2.0 * np.sum(np.sin(np.dot(nvecs, angles)), axis=1) + A[6 + nv : 6 + 2 * nv, 1] = A[6 : 6 + nv, 0] + A[6 + 2 * nv : 6 + 3 * nv, 2] = A[6 : 6 + nv, 0] + + # t*S1, 2, 3 + A[6 : 6 + nv, 3] = -2.0 * np.sum(t[None, :] * np.sin(np.dot(nvecs, angles)), axis=1) + A[6 + nv : 6 + 2 * nv, 4] = A[6 : 6 + nv, 3] + A[6 + 2 * nv : 6 + 3 * nv, 5] = A[6 : 6 + nv, 3] + + # lower right block structure: S dot S^T + sinv = np.sin(np.dot(nvecs, angles)) + SdotST = np.einsum("it, jt->ij", sinv, sinv) + A[6 : 6 + nv, 6 : 6 + nv] = A[6 + nv : 6 + 2 * nv, 6 + nv : 6 + 2 * nv] = A[ + 6 + 2 * nv : 6 + 3 * nv, 6 + 2 * nv : 6 + 3 * nv + ] = 4 * SdotST + + # top rectangle + A[:6, :] = A[:, :6].T + + b[:3] = np.sum(angles.T, axis=0) + b[3:6] = np.sum(t[:, None] * angles.T, axis=0) + b[6 : 6 + nv] = -2.0 * np.sum(angles[0] * np.sin(np.dot(nvecs, angles)), axis=1) + b[6 + nv : 6 + 2 * nv] = -2.0 * np.sum( + angles[1] * np.sin(np.dot(nvecs, angles)), axis=1 + ) + b[6 + 2 * nv : 6 + 3 * nv] = -2.0 * np.sum( + angles[2] * np.sin(np.dot(nvecs, angles)), axis=1 + ) + + return A, b, nvecs + + +def _single_orbit_find_actions( + orbit, N_max, toy_potential=None, force_harmonic_oscillator=False, fit_kwargs=None +): + """ + Find approximate actions and angles for samples of a phase-space orbit, + `w`, at times `t`. Uses toy potentials with known, analytic action-angle + transformations to approximate the true coordinates as a Fourier sum. + + This code is adapted from Jason Sanders' + `genfunc `_ + + .. todo:: + + Wrong shape for w -- should be (6, n) as usual... + + Parameters + ---------- + orbit : `~gala.dynamics.Orbit` + N_max : int + Maximum integer Fourier mode vector length, |n|. + toy_potential : Potential (optional) + Fix the toy potential class. + force_harmonic_oscillator : bool (optional) + Force using the harmonic oscillator potential as the toy potential. + fit_kwargs : dict (optional) + Passed to ``fit_toy_potential()`` and on to the toy potential fitting + functions. + """ + from gala.potential import HarmonicOscillatorPotential, IsochronePotential + + if orbit.norbits > 1: + raise ValueError("must be a single orbit") + + if fit_kwargs is None: + fit_kwargs = {} + + if toy_potential is None: + toy_potential = fit_toy_potential( + orbit, force_harmonic_oscillator=force_harmonic_oscillator, **fit_kwargs + ) + + else: + logger.debug(f"Using *fixed* toy potential: {toy_potential.parameters}") + + if isinstance(toy_potential, IsochronePotential): + orbit_align = orbit.align_circulation_with_z() + w = orbit_align.w() + + dxyz = (1, 2, 2) + circ = np.sign(w[0, 0] * w[4, 0] - w[1, 0] * w[3, 0]) + sign = np.array([1.0, circ, 1.0]) + orbit = orbit_align + elif isinstance(toy_potential, HarmonicOscillatorPotential): + dxyz = (2, 2, 2) + sign = 1.0 + w = orbit.w() + else: + raise ValueError("Invalid toy potential.") + + t = orbit.t.value + + # Now find toy actions and angles + aaf = toy_potential.action_angle(orbit) + + if aaf[0].ndim > 2: + aa = np.vstack((aaf[0].value[..., 0], aaf[1].value[..., 0])) + else: + aa = np.vstack((aaf[0].value, aaf[1].value)) + + if np.any(np.isnan(aa)): + ix = ~np.any(np.isnan(aa), axis=0) + aa = aa[:, ix] + t = t[ix] + warnings.warn("NaN value in toy actions or angles!") + if sum(ix) > 1: + raise ValueError("Too many NaN value in toy actions or angles!") + + t1 = time.time() + A, b, nvecs = _action_prepare(aa, N_max, dx=dxyz[0], dy=dxyz[1], dz=dxyz[2]) + actions = np.array(solve(A, b)) + logger.debug( + f"Action solution found for N_max={N_max}, size {len(actions)} symmetric" + f" matrix in {time.time() - t1} seconds" + ) + + t1 = time.time() + A, b, nvecs = _angle_prepare( + aa, t, N_max, dx=dxyz[0], dy=dxyz[1], dz=dxyz[2], sign=sign + ) + angles = np.array(solve(A, b)) + logger.debug( + f"Angle solution found for N_max={N_max}, size {len(angles)} symmetric" + f" matrix in {time.time() - t1} seconds" + ) + + # Just some checks + if len(angles) > len(aa): + warnings.warn("More unknowns than equations!") + + J = actions[:3] # * sign + theta = angles[:3] + freqs = angles[3:6] # * sign + + return { + "actions": J * aaf[0].unit, + "angles": theta * aaf[1].unit, + "freqs": freqs * aaf[2].unit, + "Sn": actions[3:], + "dSn_dJ": angles[6:], + "nvecs": nvecs, + } + + +def find_actions_o2gf( + orbit, N_max, force_harmonic_oscillator=False, toy_potential=None, fit_kwargs=None +): + """ + Find approximate actions and angles for samples of a phase-space orbit. + Uses toy potentials with known, analytic action-angle transformations to + approximate the true coordinates as a Fourier sum. + + This code is adapted from Jason Sanders' + `genfunc `_ + + Parameters + ---------- + orbit : `~gala.dynamics.Orbit` + N_max : int + Maximum integer Fourier mode vector length, :math:`|\boldsymbol{n}|`. + force_harmonic_oscillator : bool (optional) + Force using the harmonic oscillator potential as the toy potential. + toy_potential : Potential (optional) + Fix the toy potential class. + + Returns + ------- + aaf : `astropy.table.QTable` + An Astropy table containing the actions, angles, and frequencies for + each input phase-space position or orbit. The columns also contain the + value of the generating function and derivatives for each integer + vector. + + """ + + if orbit.norbits == 1: + result = _single_orbit_find_actions( + orbit, + N_max, + force_harmonic_oscillator=force_harmonic_oscillator, + toy_potential=toy_potential, + fit_kwargs=fit_kwargs, + ) + rows = [result] + + else: + rows = [] + for n in range(orbit.norbits): + aaf = _single_orbit_find_actions( + orbit[:, n], + N_max, + force_harmonic_oscillator=force_harmonic_oscillator, + toy_potential=toy_potential, + fit_kwargs=fit_kwargs, + ) + + rows.append(aaf) + + return at.QTable(rows=rows) + + +# def solve_hessian(relative_actions, relative_freqs): +# """ Use ordinary least squares to solve for the Hessian, given a +# set of actions and frequencies relative to the parent orbit. +# """ + +# def compute_hessian(t, w, actions_kwargs={}): +# """ Compute the Hessian (in action-space) of the given orbit + +# """ + +# N = dJ.shape[0] + +# Y = np.ravel(dF) +# A = np.zeros((3*N, 9)) +# A[::3, :3] = dJ +# A[1::3, 3:6] = dJ +# A[2::3, 6:9] = dJ + +# # Solve for 'parameters' - the Hessian elements +# X, res, rank, s = np.linalg.lstsq(A, Y) + +# # Symmetrize +# D0 = X.reshape(3, 3) +# D0[0, 1] = D0[1, 0] = (D0[0, 1] + D0[1, 0])/2. +# D0[0, 2] = D0[2, 0] = (D0[0, 2] + D0[2, 0])/2. +# D0[1, 2] = D0[2, 1] = (D0[1, 2] + D0[2, 1])/2. + +# print("Residual: " + str(res[0])) + +# return D0, np.linalg.eigh(D0) # symmetric matrix diff --git a/gala/source/src/gala/dynamics/actionangle/actionangle_staeckel.py b/gala/source/src/gala/dynamics/actionangle/actionangle_staeckel.py new file mode 100644 index 0000000000000000000000000000000000000000..bee3c983a8121232db1cadb98b4f63f2e3f69b43 --- /dev/null +++ b/gala/source/src/gala/dynamics/actionangle/actionangle_staeckel.py @@ -0,0 +1,79 @@ +import numpy as np + +__all__ = ["get_staeckel_fudge_delta"] + + +def get_staeckel_fudge_delta(potential, w, median=True): + """ + Estimate the focal length parameter for the Staeckel approximation. + + This function computes the focal length parameter Δ (delta) used in the + Staeckel fudge approximation method for computing actions in axisymmetric + potentials. The parameter is estimated using equation (9) from Sanders (2012). + + Parameters + ---------- + potential : :class:`~gala.potential.PotentialBase` + The gravitational potential in which the orbits were computed, or + for which you want to estimate the best-fitting Staeckel potential. + w : :class:`~gala.dynamics.Orbit` or :class:`~gala.dynamics.PhaseSpacePosition` + The orbit(s) or phase-space position(s) to use for estimating the + focal length parameter. + median : bool, optional + If True and ``w`` is an Orbit, return the median value over the + orbit. If False, return the full time series. Default is True. + + Returns + ------- + delta : :class:`~astropy.units.Quantity` + The focal length parameter(s) with units of length. If ``median=True`` + and the input is an orbit, returns a scalar or array with shape + matching the number of orbits. If ``median=False``, returns an array + with the same time dimension as the input orbit(s). + + Notes + ----- + The Staeckel fudge approximation assumes that the gravitational potential + can be approximated by a Staeckel potential in prolate spheroidal + coordinates. This focal length parameter determines the shape of the + coordinate system used in the approximation. + + References + ---------- + * Sanders, J. L. 2012, MNRAS, 426, 128 + """ + from gala.dynamics import Orbit + + grad = potential.gradient(w).decompose(potential.units).value + hess = potential.hessian(w).decompose(potential.units).value + + # avoid constructing the full jacobian: + cyl = w.cylindrical + R = cyl.rho.decompose(potential.units).value + z = w.z.decompose(potential.units).value + cosphi = np.cos(cyl.phi) + sinphi = np.sin(cyl.phi) + sin2phi = np.sin(2 * cyl.phi) + + # These expressions transform the Hessian in Cartesian coordinates to the + # pieces we need in cylindrical coordinates + # - See: gala-notebooks/Delta-Staeckel.ipnyb + dPhi_dR = cosphi * grad[0] + sinphi * grad[1] + dPhi_dz = grad[2] + + d2Phi_dR2 = cosphi**2 * hess[0, 0] + sinphi**2 * hess[1, 1] + sin2phi * hess[0, 1] + d2Phi_dz2 = hess[2, 2] + d2Phi_dRdz = cosphi * hess[0, 2] + sinphi * hess[1, 2] + + # numerator of term in eq. 9 (Sanders 2012), but from Galpy, + # which claims there is a sign error in the manuscript?? + num = 3 * z * dPhi_dR - 3 * R * dPhi_dz + R * z * (d2Phi_dR2 - d2Phi_dz2) + a2_c2 = z**2 - R**2 + num / d2Phi_dRdz + a2_c2[np.abs(a2_c2) < 1e-12] = 0.0 # MAGIC NUMBER / HACK + delta = np.sqrt(a2_c2) + + # Median over time if the inputs were orbits + if (len(delta.shape) > 1 and median) or isinstance(w, Orbit): + delta = np.nanmedian(delta, axis=0) + + return delta * potential.units["length"] diff --git a/gala/source/src/gala/dynamics/actionangle/analyticactionangle.py b/gala/source/src/gala/dynamics/actionangle/analyticactionangle.py new file mode 100644 index 0000000000000000000000000000000000000000..d0004f81e8f99915f6e05e1607567027912b1bbf --- /dev/null +++ b/gala/source/src/gala/dynamics/actionangle/analyticactionangle.py @@ -0,0 +1,347 @@ +""" +Analytic transformations to action-angle coordinates. +""" + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +from astropy.coordinates.matrix_utilities import rotation_matrix + +# Gala +import gala.dynamics as gd +from gala._optional_deps import HAS_TWOBODY + +__all__ = ["harmonic_oscillator_xv_to_aa", "isochrone_aa_to_xv", "isochrone_xv_to_aa"] + + +def F(x, y): + z = np.zeros_like(x) + + ix = y > np.pi / 2.0 + z[ix] = np.pi / 2.0 - np.arctan(np.tan(np.pi / 2.0 - 0.5 * y[ix]) / x[ix]) + + ix = y < -np.pi / 2.0 + z[ix] = -np.pi / 2.0 + np.arctan(np.tan(np.pi / 2.0 + 0.5 * y[ix]) / x[ix]) + + ix = (y <= np.pi / 2) & (y >= -np.pi / 2) + z[ix] = np.arctan(x[ix] * np.tan(0.5 * y[ix])) + return z + + +def isochrone_xv_to_aa(w, potential): + """ + Transform the input cartesian position and velocity to action-angle + coordinates in the Isochrone potential. See Section 3.5.2 in + Binney & Tremaine (2008), and be aware of the errata entry for + Eq. 3.225. + + This transformation is analytic and can be used as a "toy potential" + in the Sanders & Binney (2014) formalism for computing action-angle + coordinates in any potential. + + Parameters + ---------- + w : :class:`gala.dynamics.PhaseSpacePosition`, :class:`gala.dynamics.Orbit` + potential : :class:`gala.potential.IsochronePotential`, dict + An instance of the potential to use for computing the transformation + to angle-action coordinates. Or, a dictionary of parameters used to + define an :class:`gala.potential.IsochronePotential` instance. + + Returns + ------- + actions : :class:`~astropy.units.Quantity` + Actions computed from the input positions and velocities. + angles : :class:`~astropy.units.Quantity` + Angles computed from the input positions and velocities. + freqs : :class:`~astropy.units.Quantity` + Frequencies computed from the input positions and velocities. + """ + from gala.potential import Hamiltonian, IsochronePotential, PotentialBase + + if not isinstance(potential, PotentialBase): + potential = IsochronePotential(**potential) + + usys = potential.units + GM = potential.G * potential.parameters["m"].decompose(usys).value + b = potential.parameters["b"].decompose(usys).value + E = w.energy(Hamiltonian(potential)).decompose(usys).value + E = np.atleast_1d(E) + + if np.any(E > 0.0): + msg = f"Unbound particle. (E = {E})" + raise ValueError(msg) + + # convert position, velocity to spherical polar coordinates + w_sph = w.represent_as(coord.PhysicsSphericalRepresentation) + r, phi, theta = map( + np.atleast_1d, + [w_sph.r.decompose(usys).value, w_sph.phi.radian, w_sph.theta.radian], + ) + + ang_unit = u.radian / usys["time"] + vr, _phi_dot, theta_dot = map( + np.atleast_1d, + [ + w_sph.radial_velocity.decompose(usys).value, + w_sph.pm_phi.to(ang_unit).value, + w_sph.pm_theta.to(ang_unit).value, + ], + ) + vtheta = r * theta_dot + + # ---------------------------- + # Compute the actions + # ---------------------------- + + L_vec = [np.atleast_1d(x) for x in w.angular_momentum().decompose(usys).value] + Lz = L_vec[2] + L = np.linalg.norm(L_vec, axis=0) + + # Radial action + Jr = GM / np.sqrt(-2 * E) - 0.5 * (L + np.sqrt(L * L + 4 * GM * b)) + + # compute the three action variables + actions = np.array([Jr, Lz, L - np.abs(Lz)]).reshape((3, *w.shape)) + + # ---------------------------- + # Angles + # ---------------------------- + c = GM / (-2 * E) - b + e = np.sqrt(1 - L * L * (1 + b / c) / GM / c) + + # Compute theta_r using eta + tmp1 = r * vr / np.sqrt(-2.0 * E) + tmp2 = b + c - np.sqrt(b * b + r * r) + eta = np.arctan2(tmp1, tmp2) + thetar = eta - e * c * np.sin(eta) / (c + b) # same as theta3 + + # Compute theta_z + psi = np.arctan2(np.cos(theta), -np.sin(theta) * r * vtheta / L) + psi[np.abs(vtheta) <= 1e-10] = np.pi / 2.0 # blows up for small vtheta + + omega_ratio = 0.5 * (1 + L / np.sqrt(L * L + 4 * GM * b)) + + a = np.sqrt((1 + e) / (1 - e)) + ap = np.sqrt((1 + e + 2 * b / c) / (1 - e + 2 * b / c)) + + A = omega_ratio * thetar - F(a, eta) - F(ap, eta) / np.sqrt(1 + 4 * GM * b / L / L) + thetat = psi + A + + LR = Lz / L + sinu = LR / np.sqrt(1.0 - LR * LR) / np.tan(theta) + uu = np.arcsin(sinu) + + uu[sinu > 1.0] = np.pi / 2.0 + uu[sinu < -1.0] = -np.pi / 2.0 + uu[vtheta > 0.0] = np.pi - uu[vtheta > 0.0] + + thetap = phi - uu + np.sign(Lz) * thetat + angles = np.array([thetar, thetap, thetat]).reshape((3, *w.shape)) + angles %= 2 * np.pi + + # ---------------------------- + # Frequencies + # ---------------------------- + freqs = np.zeros_like(actions) + omega_r = GM**2 / (Jr + 0.5 * (L + np.sqrt(L * L + 4 * GM * b))) ** 3 + freqs[0] = omega_r.reshape(actions.shape[1:]) + freqs[1] = np.reshape( + np.sign(actions[1]) * omega_ratio * omega_r, actions.shape[1:] + ) + freqs[2] = np.reshape(omega_ratio * omega_r, actions.shape[1:]) + + a_unit = (1 * usys["angular momentum"] / usys["mass"]).decompose(usys).unit + f_unit = (1 * usys["angular speed"]).decompose(usys).unit + return actions * a_unit, angles * u.radian, freqs * f_unit + + +def isochrone_aa_to_xv(actions, angles, potential): + """ + Transform the input actions and angles to cartesian position and velocity + coordinates in the Isochrone potential. See Section 3.5.2 in + Binney & Tremaine (2008), and be aware of the errata entry for + Eq. 3.225. + + Parameters + ---------- + actions : :class:`~astropy.units.Quantity` + angles : :class:`~astropy.units.Quantity` + potential : :class:`gala.potential.IsochronePotential`, dict + An instance of the potential to use for computing the transformation + to angle-action coordinates. Or, a dictionary of parameters used to + define an :class:`gala.potential.IsochronePotential` instance. + + Returns + ------- + w : :class:`gala.dynamics.PhaseSpacePosition` + The computed positions and velocities. + """ + if not HAS_TWOBODY: + raise ImportError( + "Failed to import twobody: Converting from action-angle " + "coordinates to position and velocity in the isochrone potential " + "requires a Kepler solver, and thus `twobody` must be installed." + ) + + import twobody as tb + + Jr, Jphi, Jth = (np.atleast_1d(x) for x in actions) + thr, thphi, thth = (np.atleast_1d(x) for x in angles) + + usys = potential.units + GM = potential.G * potential.parameters["m"].decompose(usys).value + GM = GM * usys["length"] ** 3 / usys["time"] ** 2 + b = potential.parameters["b"].decompose(usys) + + Lz = Jphi + L = Jth + np.abs(Lz) + + # Eq.3.225 in B&T 2008 + sqrt_L2_4GMb = np.sqrt(L**2 + 4 * GM * b) + E = -0.5 * (GM / (Jr + 0.5 * (L + sqrt_L2_4GMb))) ** 2 + + # Coordinates orientation crap + i = np.arccos(Lz / L) + lon_nodes = coord.Angle(thphi - np.sign(Lz) * thth).wrap_at(2 * np.pi * u.rad) + # TODO: could check that std(i), std(lon_nodes) are small... + + # Auxiliary variables (Eq. 3.240) + c = GM / (-2 * E) - b + e = np.sqrt(1 - L**2 / (GM * c) * (1 + b / c)) + + e_eff = e * c / (c + b) + eta = tb.eccentric_anomaly_from_mean_anomaly(thr, e_eff) + + s = 2 + c / b * (1 - e * np.cos(eta)) + r = b * np.sqrt((s - 1) ** 2 - 1) + + Omr = GM**2 / (Jr + 0.5 * (L + sqrt_L2_4GMb)) ** 3 + eta_dot = Omr / (1 - e_eff * np.cos(eta)) + s_dot = e * c / b * np.sin(eta) * eta_dot + vr = b * (s - 1) * s_dot / np.sqrt((s - 1) ** 2 - 1) + v_tan = L / r + + sqrt1 = np.sqrt(1 + e) / np.sqrt(1 - e) + sqrt2 = np.sqrt(1 + e + 2 * b / c) / np.sqrt(1 - e + 2 * b / c) + + with u.set_enabled_equivalencies(u.dimensionless_angles()): + terms = ( + 0.5 * (1 + L / sqrt_L2_4GMb) * thr + - F(sqrt1, eta) + - L / sqrt_L2_4GMb * F(sqrt2, eta) + ) + # psi = angles[2] - terms + psi = thth - terms - 3 * np.pi / 2 * u.rad # WT actual F + + xyz_prime = ( + np.array([r.value * np.cos(psi), r.value * np.sin(psi), np.zeros_like(r.value)]) + * r.unit + ).to(potential.units["length"]) + + vx = vr * np.cos(psi) - v_tan * np.sin(psi) + vy = vr * np.sin(psi) + v_tan * np.cos(psi) + vxyz_prime = ( + np.array([vx.value, vy.to_value(vx.unit), np.zeros_like(r.value)]) * vx.unit + ).to(potential.units["velocity"]) + + M1 = rotation_matrix(-i, "y") + M2 = rotation_matrix(-lon_nodes, "z") + M3 = rotation_matrix(np.pi / 2 * u.rad, "z") # WT actual F + M = np.einsum("ij,...jk,...kl->...il", M3, M2, M1) + + xyz = np.einsum("...ij,j...->i...", M, xyz_prime) + vxyz = np.einsum("...ij,j...->i...", M, vxyz_prime) + + w = gd.PhaseSpacePosition(pos=xyz, vel=vxyz, copy=False) + + return w.reshape(actions.shape[1:]) + + +def harmonic_oscillator_xv_to_aa(w, potential): + """ + Transform the input cartesian position and velocity to action-angle + coordinates for the Harmonic Oscillator potential. + + This transformation is analytic and can be used as a "toy potential" + in the Sanders & Binney (2014) formalism for computing action-angle + coordinates in any potential. + + Parameters + ---------- + w : :class:`gala.dynamics.PhaseSpacePosition`, :class:`gala.dynamics.Orbit` + potential : Potential + + Returns + ------- + actions : :class:`~astropy.units.Quantity` + Actions computed from the input positions and velocities. + angles : :class:`~astropy.units.Quantity` + Angles computed from the input positions and velocities. + freqs : :class:`~astropy.units.Quantity` + Frequencies computed from the input positions and velocities. + """ + + usys = potential.units + if usys is not None: + x = w.xyz.decompose(usys).value + v = w.v_xyz.decompose(usys).value + else: + x = w.xyz.value + v = w.v_xyz.value + new_omega_shape = (3, *tuple([1] * (len(x.shape) - 1))) + + # compute actions -- just energy (hamiltonian) over frequency + if usys is None: + usys = [] + + try: + omega = ( + potential.parameters["omega"].reshape(new_omega_shape).decompose(usys).value + ) + except AttributeError: # not a Quantity + omega = potential.parameters["omega"].reshape(new_omega_shape) + + action = (v**2 + (omega * x) ** 2) / (2.0 * omega) + + angle = np.arctan(-v / omega / x) + angle[x == 0] = -np.sign(v[x == 0]) * np.pi / 2.0 + angle[x < 0] += np.pi + + freq = potential.parameters["omega"].decompose(usys).value + + if usys is not None and usys: + a_unit = (1 * usys["angular momentum"] / usys["mass"]).decompose(usys).unit + f_unit = (1 * usys["angular speed"]).decompose(usys).unit + return action * a_unit, (angle % (2.0 * np.pi)) * u.radian, freq * f_unit + return action * u.one, (angle % (2.0 * np.pi)) * u.one, freq * u.one + + +def harmonic_oscillator_to_xv(actions, angles, potential): + """ + Transform the input action-angle coordinates to cartesian + position and velocity for the Harmonic Oscillator potential. + + .. note:: + + This function is included as a method of the + :class:`~gala.potential.HarmonicOscillatorPotential` + and it is recommended to call + :meth:`~gala.potential.HarmonicOscillatorPotential.phase_space()` instead. + + Parameters + ---------- + actions : array_like + angles : array_like + potential : Potential + """ + raise NotImplementedError( + "Implementation not supported until working with " + "angle-action variables has a better API." + ) + + # TODO: bug in below... + # omega = potential.parameters['omega'].decompose(potential.units).value + # x = np.sqrt(2*actions/omega[None]) * np.sin(angles) + # v = np.sqrt(2*actions*omega[None]) * np.cos(angles) + + # return x, v diff --git a/gala/source/src/gala/dynamics/core.py b/gala/source/src/gala/dynamics/core.py new file mode 100644 index 0000000000000000000000000000000000000000..0b1b7a88777c32cfe6591f532e9efb6e3f40c527 --- /dev/null +++ b/gala/source/src/gala/dynamics/core.py @@ -0,0 +1,936 @@ +import importlib +import re +from collections import namedtuple + +import astropy +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +from astropy.coordinates import representation as r +from packaging.version import Version + +from ..io import quantity_from_hdf5, quantity_to_hdf5 +from ..units import DimensionlessUnitSystem, UnitSystem, _greek_letters +from ..util import atleast_2d +from . import representation_nd as rep_nd +from .plot import plot_projections + +__all__ = ["PhaseSpacePosition"] + + +ASTROPY_GTEQ_7_1 = Version(astropy.__version__) >= Version("7.1") + + +def _get_rep_name(rep): + if ASTROPY_GTEQ_7_1: + return rep.name + return rep.get_name() + + +_RepresentationMappingBase = namedtuple( + "RepresentationMapping", ("repr_name", "new_name", "default_unit") +) + + +class RepresentationMapping(_RepresentationMappingBase): + """ + This `~collections.namedtuple` is used to override the representation and + differential class component names in the `PhaseSpacePosition` and `Orbit` + classes. + """ + + def __new__(cls, repr_name, new_name, default_unit="recommended"): + # this trick just provides some defaults + return super().__new__(cls, repr_name, new_name, default_unit) + + +class RegexRepresentationMapping(RepresentationMapping): + """ + A representation mapping that uses a regex to map the original attribute + name to the new attribute name. + """ + + +class PhaseSpacePosition: + representation_mappings = { + r.CartesianRepresentation: [RepresentationMapping("xyz", "xyz")], + r.SphericalCosLatDifferential: [ + RepresentationMapping("d_lon_coslat", "pm_lon_coslat", u.mas / u.yr), + RepresentationMapping("d_lat", "pm_lat", u.mas / u.yr), + RepresentationMapping("d_distance", "radial_velocity"), + ], + r.SphericalDifferential: [ + RepresentationMapping("d_lon", "pm_lon", u.mas / u.yr), + RepresentationMapping("d_lat", "pm_lat", u.mas / u.yr), + RepresentationMapping("d_distance", "radial_velocity"), + ], + r.PhysicsSphericalDifferential: [ + RepresentationMapping("d_phi", "pm_phi", u.mas / u.yr), + RepresentationMapping("d_theta", "pm_theta", u.mas / u.yr), + RepresentationMapping("d_r", "radial_velocity"), + ], + r.CartesianDifferential: [ + RepresentationMapping("d_x", "v_x"), + RepresentationMapping("d_y", "v_y"), + RepresentationMapping("d_z", "v_z"), + RepresentationMapping("d_xyz", "v_xyz"), + ], + r.CylindricalDifferential: [ + RepresentationMapping("d_rho", "v_rho"), + RepresentationMapping("d_phi", "pm_phi"), + RepresentationMapping("d_z", "v_z"), + ], + rep_nd.NDCartesianRepresentation: [RepresentationMapping("xyz", "xyz")], + rep_nd.NDCartesianDifferential: [ + RepresentationMapping("d_xyz", "v_xyz"), + RegexRepresentationMapping("d_x([0-9])", "v_x{0}"), + ], + } + representation_mappings[r.UnitSphericalCosLatDifferential] = ( + representation_mappings[r.SphericalCosLatDifferential] + ) + representation_mappings[r.UnitSphericalDifferential] = representation_mappings[ + r.SphericalDifferential + ] + + def __init__(self, pos, vel=None, frame=None, copy=True): + """ + Represents phase-space positions, i.e. positions and conjugate momenta + (velocities). + + The class can be instantiated with Astropy representation objects (e.g., + :class:`~astropy.coordinates.CartesianRepresentation`), Astropy + :class:`~astropy.units.Quantity` objects, or plain Numpy arrays. + + If passing in representation objects, the default representation is + taken to be the class that is passed in. + + If passing in Quantity or Numpy array instances for both position and + velocity, they are assumed to be Cartesian. Array inputs are interpreted + as dimensionless quantities. The input position and velocity objects can + have an arbitrary number of (broadcastable) dimensions. For Quantity or + array inputs, the first axis (0) has special meaning: + + - `axis=0` is the coordinate dimension (e.g., x, y, z for Cartesian) + + So if the input position array, `pos`, has shape `pos.shape = (3, 100)`, + this would represent 100 3D positions (`pos[0]` is `x`, `pos[1]` is `y`, + etc.). The same is true for velocity. + + Parameters + ---------- + pos : representation, quantity_like, or array_like + Positions. If a numpy array (e.g., has no units), this will be + stored as a dimensionless :class:`~astropy.units.Quantity`. See + the note above about the assumed meaning of the axes of this object. + vel : differential, quantity_like, or array_like + Velocities. If a numpy array (e.g., has no units), this will be + stored as a dimensionless :class:`~astropy.units.Quantity`. See + the note above about the assumed meaning of the axes of this object. + frame : :class:`~gala.potential.FrameBase` (optional) + The reference frame of the input phase-space positions. + copy : bool (optional) + If `True`, the input position and velocity data is copied. If + `False`, the input data is referenced directly (if possible). + Default is `True`. + """ + + if isinstance(pos, coord.Galactocentric): + pos = pos.data + + if not isinstance(pos, coord.BaseRepresentation): + # assume Cartesian if not specified + if not hasattr(pos, "unit"): + pos = u.Quantity(pos, u.one, copy=copy) + + # 3D coordinates get special treatment + ndim = pos.shape[0] + if ndim == 3: + # TODO: HACK: until this stuff is in astropy core + if isinstance(pos, coord.BaseRepresentation): + kw = [(k, getattr(pos, k)) for k in pos.components] + pos = getattr(coord, pos.__class__.__name__)(**kw, copy=copy) + + else: + pos = coord.CartesianRepresentation(pos, copy=copy) + + else: + pos = rep_nd.NDCartesianRepresentation(pos, copy=copy) + + else: + ndim = 3 + + if vel is None: + if "s" not in pos.differentials: + msg = ( + "You must specify velocity data when creating " + f"a {self.__class__.__name__} object." + ) + raise TypeError(msg) + vel = pos.differentials.get("s", None) + + if not isinstance(vel, coord.BaseDifferential): + # assume representation is same as pos if not specified + if not hasattr(vel, "unit"): + vel = u.Quantity(vel, u.one, copy=copy) + + if ndim == 3: + name = _get_rep_name(pos) + Diff = coord.representation.DIFFERENTIAL_CLASSES[name] + vel = Diff(*vel, copy=copy) + else: + Diff = rep_nd.NDCartesianDifferential + vel = Diff(vel, copy=copy) + + # make sure shape is the same + if pos.shape != vel.shape: + raise ValueError( + "Position and velocity must have the same shape " + f"{pos.shape} vs. {vel.shape}" + ) + + from ..potential.frame import FrameBase + + if frame is not None and not isinstance(frame, FrameBase): + raise TypeError( + "Input reference frame must be a FrameBase subclass instance." + ) + + self.pos = pos + self.vel = vel + self.frame = frame + self.ndim = ndim + + def __getitem__(self, slyce): + return self.__class__( + pos=self.pos[slyce], vel=self.vel[slyce], frame=self.frame + ) + + def get_components(self, which): + """ + Get the component name dictionary for the desired object. + + The returned dictionary maps component names on this class to component + names on the desired object. + + Parameters + ---------- + which : str + Can either be ``'pos'`` or ``'vel'`` to get the components for the + position or velocity object. + """ + mappings = self.representation_mappings.get(getattr(self, which).__class__, []) + + old_to_new = {} + for name in getattr(self, which).components: + for m in mappings: + if isinstance(m, RegexRepresentationMapping): + pattr = re.match(m.repr_name, name) + old_to_new[name] = m.new_name.format(*pattr.groups()) + + elif m.repr_name == name: + old_to_new[name] = m.new_name + + mapping = {} + for name in getattr(self, which).components: + mapping[old_to_new.get(name, name)] = name + + return mapping + + @property + def pos_components(self): + return self.get_components("pos") + + @property + def vel_components(self): + return self.get_components("vel") + + def _get_extra_mappings(self, which): + mappings = self.representation_mappings.get(getattr(self, which).__class__, []) + + extra = {} + for m in mappings: + if m.new_name not in self.get_components(which) and not isinstance( + m, RegexRepresentationMapping + ): + extra[m.new_name] = m.repr_name + return extra + + def __dir__(self): + """ + Override the builtin `dir` behavior to include representation and + differential names. + """ + dir_values = set(self.pos_components.keys()) + dir_values |= set(self.vel_components.keys()) + dir_values |= set(self._get_extra_mappings("pos").keys()) + dir_values |= set(self._get_extra_mappings("vel").keys()) + dir_values |= set(r.REPRESENTATION_CLASSES.keys()) + dir_values |= set(super().__dir__()) + return sorted(dir_values) + + def __getattr__(self, attr): + """ + Allow access to attributes on the ``pos`` and ``vel`` representation and + differential objects. + """ + + # Prevent infinite recursion here. + if attr.startswith("_"): + return self.__getattribute__(attr) # Raise AttributeError. + + # TODO: with >3.5 support, can do: + # pos_comps = {**self.pos_components, + # **self._get_extra_mappings('pos')} + pos_comps = self.pos_components.copy() + pos_comps.update(self._get_extra_mappings("pos")) + if attr in pos_comps: + return getattr(self.pos, pos_comps[attr]) + + # TODO: with >3.5 support, can do: + # pos_comps = {**self.vel_components, + # **self._get_extra_mappings('vel')} + vel_comps = self.vel_components.copy() + vel_comps.update(self._get_extra_mappings("vel")) + if attr in vel_comps: + return getattr(self.vel, vel_comps[attr]) + + if attr in r.REPRESENTATION_CLASSES: + return self.represent_as(attr) + + return self.__getattribute__(attr) # Raise AttributeError. + + @property + def data(self): + return self.pos.with_differentials(self.vel) + + # ------------------------------------------------------------------------ + # Convert from Cartesian to other representations + # + def represent_as(self, new_pos, new_vel=None): + """ + Represent the position and velocity of the phase-space position in an + alternate coordinate system. Supports any of the Astropy coordinates + representation classes. + + Parameters + ---------- + new_pos : :class:`~astropy.coordinates.BaseRepresentation` or str + The type of representation to generate. Must be a class (not an + instance), or the string name of the representation class. + new_vel : :class:`~astropy.coordinates.BaseDifferential` or str, optional + Class in which any velocities should be represented. Must be a class + (not an instance), or the string name of the differential class. If + None, uses the default differential for the new position class. + + Returns + ------- + new_psp : :class:`~gala.dynamics.PhaseSpacePosition` + A new PhaseSpacePosition object with the specified representation. + """ + + if self.ndim != 3: + raise ValueError("Can only change representation for ndim=3 instances.") + + # get the name of the desired representation + pos_name = new_pos if isinstance(new_pos, str) else _get_rep_name(new_pos) + + if isinstance(new_vel, str): + vel_name = new_vel + elif new_vel is None: + vel_name = pos_name + else: + vel_name = _get_rep_name(new_vel) + + Representation = coord.representation.REPRESENTATION_CLASSES[pos_name] + Differential = coord.representation.DIFFERENTIAL_CLASSES[vel_name] + + new_pos = self.pos.represent_as(Representation) + new_vel = self.vel.represent_as(Differential, self.pos) + + return self.__class__(pos=new_pos, vel=new_vel, frame=self.frame) + + def to_frame(self, frame, current_frame=None, **kwargs): + """ + Transform to a new reference frame. + + Parameters + ---------- + frame : `~gala.potential.FrameBase` + The frame to transform to. + current_frame : `gala.potential.CFrameBase` + The current frame the phase-space position is in. + **kwargs + Any additional arguments are passed through to the individual frame + transformation functions (see: + `~gala.potential.frame.builtin.transformations`). + + Returns + ------- + psp : `gala.dynamics.PhaseSpacePosition` + The phase-space position in the new reference frame. + + """ + + from ..potential.frame.builtin import transformations as frame_trans + + if self.frame is None and current_frame is None: + raise ValueError( + f"If no frame was specified when this {self} was " + "initialized, you must pass the current frame in " + "via the current_frame argument to transform to a " + "new frame." + ) + + if self.frame is not None and current_frame is None: + current_frame = self.frame + + name1 = current_frame.__class__.__name__.rstrip("Frame").lower() + name2 = frame.__class__.__name__.rstrip("Frame").lower() + func_name = f"{name1}_to_{name2}" + + if not hasattr(frame_trans, func_name): + msg = f"Unsupported frame transformation: {current_frame} to {frame}" + raise ValueError(msg) + trans_func = getattr(frame_trans, func_name) + + pos, vel = trans_func(current_frame, frame, self, **kwargs) + return PhaseSpacePosition(pos=pos, vel=vel, frame=frame) + + def to_coord_frame(self, frame, galactocentric_frame=None, **kwargs): + """ + Transform the orbit from Galactocentric, cartesian coordinates to + Heliocentric coordinates in the specified Astropy coordinate frame. + + Parameters + ---------- + frame : :class:`~astropy.coordinates.BaseCoordinateFrame` + The frame instance specifying the desired output frame. + For example, :class:`~astropy.coordinates.ICRS`. + galactocentric_frame : :class:`~astropy.coordinates.Galactocentric` + This is the assumed frame that the position and velocity of this + object are in. The ``Galactocentric`` instand should have parameters + specifying the position and motion of the sun in the Galactocentric + frame, but no data. + + Returns + ------- + c : :class:`~astropy.coordinates.BaseCoordinateFrame` + An instantiated coordinate frame containing the positions and + velocities from this object transformed to the specified coordinate + frame. + + """ + + if self.ndim != 3: + raise ValueError("Can only change representation for ndim=3 instances.") + + if galactocentric_frame is None: + galactocentric_frame = coord.Galactocentric() + + pos_keys = list(self.pos_components.keys()) + vel_keys = list(self.vel_components.keys()) + if u.one in {getattr(self, pos_keys[0]).unit, getattr(self, vel_keys[0]).unit}: + raise u.UnitConversionError( + "Position and velocity must have " + "dimensioned units to convert to a " + "coordinate frame." + ) + + # first we need to turn the position into a Galactocentric instance + gc_c = galactocentric_frame.realize_frame(self.pos.with_differentials(self.vel)) + return gc_c.transform_to(frame) + + # Pseudo-backwards compatibility + def w(self, units=None): + """ + Return the full phase-space position as a single array. + + This returns a single array containing the phase-space positions, + with positions in the first half and velocities in the second half. + + Parameters + ---------- + units : :class:`~gala.units.UnitSystem`, optional + The unit system to represent the position and velocity in + before combining into the full array. If not provided, the + positions and velocities must be dimensionless. + + Returns + ------- + w : :class:`~numpy.ndarray` + A numpy array of all positions and velocities, without units. + Will have shape ``(2*ndim, ...)`` where the first ``ndim`` rows + contain positions and the last ``ndim`` rows contain velocities. + + Raises + ------ + ValueError + If no units are specified and the position/velocity have units. + """ + cart = self.cartesian if self.ndim == 3 else self + + xyz = cart.xyz + d_xyz = cart.v_xyz + + x_unit = xyz.unit + v_unit = d_xyz.unit + if (units is None or isinstance(units, DimensionlessUnitSystem)) and ( + x_unit == u.one and v_unit == u.one + ): + units = DimensionlessUnitSystem() + + elif units is None: + raise ValueError("A UnitSystem must be provided.") + + x = xyz.decompose(units).value + if x.ndim < 2: + x = atleast_2d(x, insert_axis=1) + + v = d_xyz.decompose(units).value + if v.ndim < 2: + v = atleast_2d(v, insert_axis=1) + + return np.vstack((x, v)) + + @classmethod + def from_w(cls, w, units=None, copy=True, **kwargs): + """Create a PhaseSpacePosition from a single array of positions and velocities. + + Parameters + ---------- + w : array_like + The array of phase-space positions. Should have shape ``(2*ndim, ...)`` + where the first ``ndim`` rows contain positions and the last ``ndim`` + rows contain velocities. + units : :class:`~gala.units.UnitSystem`, optional + The unit system that the input position+velocity array, ``w``, + is represented in. If not provided, the array is assumed to be + dimensionless. + copy : bool, optional + If `True`, the input array is copied. If `False`, the input data + is referenced directly (if possible). Default is `True`. + **kwargs + Additional keyword arguments passed to the class initializer. + + Returns + ------- + obj : :class:`~gala.dynamics.PhaseSpacePosition` + A new PhaseSpacePosition instance created from the input array. + + """ + + w = np.asarray(w) + + ndim = w.shape[0] // 2 + pos = w[:ndim] + vel = w[ndim:] + + # TODO: this is bad form - UnitSystem should know what to do with a + # Dimensionless + if units is not None and not isinstance(units, DimensionlessUnitSystem): + units = UnitSystem(units) + pos = u.Quantity(pos, units["length"], copy=copy) + vel = u.Quantity(vel, units["length"] / units["time"], copy=copy) + + return cls(pos=pos, vel=vel, copy=copy, **kwargs) + + # ------------------------------------------------------------------------ + # Input / output + # + def to_hdf5(self, f): + """ + Serialize this object to an HDF5 file. + + Requires ``h5py``. + + Parameters + ---------- + f : str, :class:`h5py.File` + Either the filename or an open HDF5 file. + """ + + if isinstance(f, str): + import h5py + + f = h5py.File(f, mode="r") + + if self.frame is not None: + frame_group = f.create_group("frame") + frame_group.attrs["module"] = self.frame.__module__ + frame_group.attrs["class"] = self.frame.__class__.__name__ + + units = [str(x).encode("utf8") for x in self.frame.units.to_dict().values()] + frame_group.create_dataset("units", data=units) + + d = frame_group.create_group("parameters") + for k, par in self.frame.parameters.items(): + quantity_to_hdf5(d, k, par) + + cart = self.represent_as("cartesian") + quantity_to_hdf5(f, "pos", cart.xyz) + quantity_to_hdf5(f, "vel", cart.v_xyz) + + return f + + @classmethod + def from_hdf5(cls, f): + """ + Load an object from an HDF5 file. + + Requires ``h5py``. + + Parameters + ---------- + f : str, :class:`h5py.File` + Either the filename or an open HDF5 file. + """ + if isinstance(f, str): + import h5py + + f = h5py.File(f, mode="r") + + pos = quantity_from_hdf5(f["pos"]) + vel = quantity_from_hdf5(f["vel"]) + + frame = None + if "frame" in f: + g = f["frame"] + + frame_mod = g.attrs["module"] + frame_cls = g.attrs["class"] + frame_units = [u.Unit(x.decode("utf-8")) for x in g["units"]] + + if u.dimensionless_unscaled in frame_units: + units = DimensionlessUnitSystem() + else: + units = UnitSystem(*frame_units) + + pars = {} + for k in g["parameters"]: + pars[k] = quantity_from_hdf5(g["parameters/" + k]) + + frame_cls = getattr(importlib.import_module(frame_mod), frame_cls) + frame = frame_cls(units=units, **pars) + + return cls(pos=pos, vel=vel, frame=frame) + + # ------------------------------------------------------------------------ + # Computed dynamical quantities + # + def kinetic_energy(self): + r""" + The kinetic energy *per unit mass*: + + .. math:: + + E_K = \frac{1}{2} \, |\boldsymbol{v}|^2 + + Returns + ------- + E : :class:`~astropy.units.Quantity` + The kinetic energy. + """ + return 0.5 * self.vel.norm() ** 2 + + def potential_energy(self, potential): + r""" + The potential energy *per unit mass*: + + .. math:: + + E_\Phi = \Phi(\boldsymbol{q}) + + Parameters + ---------- + potential : `gala.potential.PotentialBase` + The potential object to compute the energy from. + + Returns + ------- + E : :class:`~astropy.units.Quantity` + The potential energy. + """ + # TODO: check that potential ndim is consistent with here + return potential.energy(self) + + def energy(self, hamiltonian): + r""" + The total energy *per unit mass* (e.g., kinetic + potential): + + Parameters + ---------- + hamiltonian : `gala.potential.Hamiltonian`, `gala.potential.PotentialBase` instance + The Hamiltonian object to evaluate the energy. If a potential is + passed in, this assumes a static reference frame. + + Returns + ------- + E : :class:`~astropy.units.Quantity` + The total energy. + """ + from gala.potential import Hamiltonian + + hamiltonian = Hamiltonian(hamiltonian) + return hamiltonian(self) + + def angular_momentum(self): + r""" + Compute the angular momentum for the phase-space positions contained + in this object:: + + .. math:: + + \boldsymbol{{L}} = \boldsymbol{{q}} \times \boldsymbol{{p}} + + See :ref:`shape-conventions` for more information about the shapes of + input and output objects. + + Returns + ------- + L : :class:`~astropy.units.Quantity` + Array of angular momentum vectors. + + Examples + -------- + + >>> import numpy as np + >>> import astropy.units as u + >>> pos = np.array([1., 0, 0]) * u.au + >>> vel = np.array([0, 2*np.pi, 0]) * u.au/u.yr + >>> w = PhaseSpacePosition(pos, vel) + >>> w.angular_momentum() # doctest: +FLOAT_CMP + + """ + cart = self.represent_as(coord.CartesianRepresentation) + return cart.pos.cross(cart.vel).xyz + + def guiding_radius(self, potential, t=0.0, **root_kwargs): + """ + Compute the guiding-center radius + + Parameters + ---------- + potential : `gala.potential.PotentialBase` subclass instance + The potential to compute the guiding radius in. + t : quantity-like (optional) + Time. + **root_kwargs + Any additional keyword arguments are passed to `~scipy.optimize.root`. + + Returns + ------- + Rg : :class:`~astropy.units.Quantity` + Guiding-center radius. + """ + + R0s = np.atleast_1d( + np.sqrt(self.x**2 + self.y**2).decompose(potential.units).value + ) + Lzs = np.atleast_1d(self.angular_momentum()[2].decompose(potential.units).value) + Rgs = _guiding_radius_helper(R0s, Lzs, potential, t, **root_kwargs) + + return Rgs.reshape(self.shape) * potential.units["length"] + + # ------------------------------------------------------------------------ + # Misc. useful methods + # + def _plot_prepare(self, components, units): + """ + Prepare the ``PhaseSpacePosition`` or subclass for passing to a plotting + routine to plot all projections of the object. + """ + + # components to plot + if components is None: + components = self.pos.components + n_comps = len(components) + + # if units not specified, get units from the components + if units is not None: + if isinstance(units, u.UnitBase): + units = [units] * n_comps # global unit + + elif isinstance(units, UnitSystem): + list_units = [] + for name in components: + val = getattr(self, name) + list_units.append(units[val.unit.physical_type]) + units = list_units + + elif len(units) != n_comps: + raise ValueError( + "You must specify a unit for each axis, or a " + "single unit for all axes." + ) + + labels = [] + x = [] + for i, name in enumerate(components): + val = getattr(self, name) + + if units is not None: + val = val.to(units[i]) + unit = units[i] + else: + unit = val.unit + + if val.unit != u.one: + uu = unit.to_string(format="latex_inline") + unit_str = f" [{uu}]" + else: + unit_str = "" + + # Figure out how to fancy display the component name + if name.startswith("d_"): + dot = True + name = name[2:] + else: + dot = False + + if name in _greek_letters: + name = rf"\{name}" + + if dot: + name = rf"\dot{{{name}}}" + + labels.append(f"${name}$" + unit_str) + x.append(val.value) + + return x, labels + + def plot(self, components=None, units=None, auto_aspect=True, **kwargs): + """ + Plot the positions in all projections. This is a wrapper around + `~gala.dynamics.plot_projections` for fast access and quick + visualization. All extra keyword arguments are passed to that function + (the docstring for this function is included here for convenience). + + Parameters + ---------- + components : iterable (optional) + A list of component names (strings) to plot. By default, this is the + Cartesian positions ``['x', 'y', 'z']``. To plot Cartesian + velocities, pass in the velocity component names + ``['d_x', 'd_y', 'd_z']``. + units : `~astropy.units.UnitBase`, iterable, `gala.units.UnitSystem` (optional) + A single unit or list of units to display the components in. + auto_aspect : bool (optional) + Automatically enforce an equal aspect ratio. + relative_to : bool (optional) + Plot the values relative to this value or values. + autolim : bool (optional) + Automatically set the plot limits to be something sensible. + axes : array_like (optional) + Array of matplotlib Axes objects. + subplots_kwargs : dict (optional) + Dictionary of kwargs passed to :func:`~matplotlib.pyplot.subplots`. + labels : iterable (optional) + List or iterable of axis labels as strings. They should correspond to + the dimensions of the input orbit. + plot_function : callable (optional) + The ``matplotlib`` plot function to use. By default, this is + :func:`~matplotlib.pyplot.scatter`, but can also be, e.g., + :func:`~matplotlib.pyplot.plot`. + **kwargs + All other keyword arguments are passed to the ``plot_function``. + You can pass in any of the usual style kwargs like ``color=...``, + ``marker=...``, etc. + + Returns + ------- + fig : `~matplotlib.Figure` + + """ + + from gala._optional_deps import HAS_MATPLOTLIB + + if not HAS_MATPLOTLIB: + raise ImportError("matplotlib is required for visualization.") + import matplotlib.pyplot as plt + + if components is None: + components = self.pos.components + + x, labels = self._plot_prepare(components=components, units=units) + + kwargs.setdefault("plot_function", plt.scatter) + if kwargs["plot_function"] in {plt.plot, plt.scatter}: + kwargs.setdefault("marker", ".") + kwargs.setdefault("labels", labels) + kwargs.setdefault("plot_function", plt.scatter) + kwargs.setdefault("autolim", False) + + fig = plot_projections(x, **kwargs) + + if ( + _get_rep_name(self.pos) == "cartesian" + and all(not c.startswith("d_") for c in components) + and auto_aspect + ): + for ax in fig.axes: + ax.set(aspect="equal", adjustable="datalim") + + return fig + + # ------------------------------------------------------------------------ + # Display + # + def __repr__(self): + rep_name = _get_rep_name(self.pos) + return ( + f"<{self.__class__.__name__} {rep_name}, dim={self.ndim}, " + f"shape={self.pos.shape}>" + ) + + def __str__(self): + return f"pos={self.pos}\nvel={self.vel}" + + # ------------------------------------------------------------------------ + # Shape and size + # + + @property + def shape(self): + """ + This is *not* the shape of the position or velocity arrays. That is + accessed by doing, e.g., ``obj.x.shape``. + """ + return self.pos.shape + + def reshape(self, new_shape): + """ + Reshape the underlying position and velocity arrays. + """ + return self.__class__( + pos=self.pos.reshape(new_shape), + vel=self.vel.reshape(new_shape), + frame=self.frame, + ) + + +def _guiding_radius_rootfunc(R, Lz, potential, t): + dPhi_dR = potential.c_instance.d_dr( + np.array([[R[0], 0.0, 0.0]]), potential.G, t=np.array([t]) + ) + vc = np.sqrt(R * np.abs(dPhi_dR)) + return Lz - R * vc + + +def _guiding_radius_helper(R0s, Lzs, potential, t, **root_kwargs): + from scipy.optimize import root + + root_kwargs.setdefault("options", {"xtol": 1e-5}) + root_kwargs.setdefault("method", "hybr") + + Rgs = np.zeros_like(R0s) + for i, (R0, Lz) in enumerate(zip(R0s, Lzs)): + res = root( + _guiding_radius_rootfunc, R0, args=(np.abs(Lz), potential, t), **root_kwargs + ) + if res.success: + Rgs[i] = res.x[0] + else: + Rgs[i] = np.nan + + return Rgs diff --git a/gala/source/src/gala/dynamics/lyapunov/__init__.py b/gala/source/src/gala/dynamics/lyapunov/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..119ac54181d6d363b2313c277cabea4e31cce346 --- /dev/null +++ b/gala/source/src/gala/dynamics/lyapunov/__init__.py @@ -0,0 +1 @@ +from .dop853_lyapunov import dop853_lyapunov_max, dop853_lyapunov_max_dont_save diff --git a/gala/source/src/gala/dynamics/lyapunov/dop853_lyapunov.pyx b/gala/source/src/gala/dynamics/lyapunov/dop853_lyapunov.pyx new file mode 100644 index 0000000000000000000000000000000000000000..c629a4e3e3b004a4e2e688a1973d35b4aea01487 --- /dev/null +++ b/gala/source/src/gala/dynamics/lyapunov/dop853_lyapunov.pyx @@ -0,0 +1,171 @@ +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + +""" DOP853 integration in Cython. """ + + +import numpy as np +cimport numpy as np +np.import_array() + +from libc.stdio cimport printf +from libc.math cimport log + +from ...integrate.cyintegrators.dop853 cimport dop853_step, Fwrapper, FcnEqDiff, six_norm +from ...potential.potential.cpotential cimport CPotentialWrapper, CPotential +from ...potential.frame.cframe cimport CFrameWrapper, CFrameType + +cpdef dop853_lyapunov_max(hamiltonian, double[::1] w0, + double dt, int n_steps, double t0, + double d0, int n_steps_per_pullback, int noffset_orbits, + double atol=1E-10, double rtol=1E-10, int nmax=0, + unsigned log_output=0): + cdef: + int i, j, k, jiter + int res + unsigned ndim = w0.size + unsigned norbits = noffset_orbits + 1 + unsigned niter = n_steps // n_steps_per_pullback + double[::1] w = np.empty(norbits*ndim) + + # define full array of times + double t_end = (n_steps) * dt + double[::1] t = np.linspace(t0, t_end, n_steps) # TODO: should be n_steps+1 + double dt0 = t[1] - t[0] + + double d1_mag, norm + double[:, ::1] d1 = np.empty((norbits, ndim)) + double[:, ::1] LEs = np.zeros((niter, noffset_orbits)) + double[:, :, ::1] all_w = np.zeros((n_steps, norbits, ndim)) + + # temp stuff + double[:, ::1] d0_vec = np.random.uniform(size=(noffset_orbits, ndim)) + + # whoa, so many dots + CPotential* cp = ((hamiltonian.potential.c_instance)).cpotential + CFrameType cf = ((hamiltonian.frame.c_instance)).cframe + + void *args + + # store initial conditions + for i in range(norbits): + if i == 0: # store initial conditions for parent orbit + for k in range(ndim): + all_w[0, i, k] = w0[k] + w[i*ndim + k] = all_w[0, i, k] + + else: # offset orbits + norm = np.linalg.norm(d0_vec[i-1]) + for k in range(ndim): + d0_vec[i-1, k] *= d0/norm # rescale offset vector + + all_w[0, i, k] = w0[k] + d0_vec[i-1, k] + w[i*ndim + k] = all_w[0, i, k] + + # dummy counter for storing Lyapunov stuff, which only happens every few steps + jiter = 0 + for j in range(1, n_steps, 1): + dop853_step(cp, &cf, Fwrapper, + &w[0], t[j-1], t[j], dt0, ndim, + norbits, 0, args, # 0 is for nbody, ignored here + atol, rtol, nmax, -1, # disable stiffness check + err_if_fail=1, log_output=log_output) + + # store position of main orbit + for i in range(norbits): + for k in range(ndim): + all_w[j, i, k] = w[i*ndim + k] + + if (j % n_steps_per_pullback) == 0: + # get magnitude of deviation vector + for i in range(1, norbits): + for k in range(ndim): + d1[i, k] = w[i*ndim + k] - w[k] + + d1_mag = six_norm(&d1[i, 0]) + LEs[jiter, i-1] = log(d1_mag / d0) + + # renormalize offset orbits + for k in range(ndim): + w[i*ndim + k] = w[k] + d0 * d1[i, k] / d1_mag + + jiter += 1 + + LEs = np.array([np.sum(LEs[:j],axis=0)/t[j*n_steps_per_pullback] + for j in range(1, niter)]) + return np.asarray(t), np.asarray(all_w), np.asarray(LEs) + +cpdef dop853_lyapunov_max_dont_save(hamiltonian, double[::1] w0, + double dt, int n_steps, double t0, + double d0, int n_steps_per_pullback, int noffset_orbits, + double atol=1E-10, double rtol=1E-10, int nmax=0, + unsigned log_output=0): + cdef: + int i, j, k, jiter + int res + unsigned ndim = w0.size + unsigned norbits = noffset_orbits + 1 + unsigned niter = n_steps // n_steps_per_pullback + double[::1] w = np.empty(norbits*ndim) + + # define full array of times + double t_end = (n_steps) * dt + double[::1] t = np.linspace(t0, t_end, n_steps) # TODO: should be n_steps+1 + double dt0 = t[1]-t[0] + + double d1_mag, norm + double[:, ::1] d1 = np.empty((norbits, ndim)) + double[:, ::1] LEs = np.zeros((niter, noffset_orbits)) + + # temp stuff + double[:, ::1] d0_vec = np.random.uniform(size=(noffset_orbits, ndim)) + + # whoa, so many dots + CPotential* cp = ((hamiltonian.potential.c_instance)).cpotential + CFrameType cf = ((hamiltonian.frame.c_instance)).cframe + + void *args + + # store initial conditions + for i in range(norbits): + if i == 0: # store initial conditions for parent orbit + for k in range(ndim): + w[i*ndim + k] = w0[k] + + else: # offset orbits + norm = np.linalg.norm(d0_vec[i-1]) + for k in range(ndim): + d0_vec[i-1, k] *= d0/norm # rescale offset vector + w[i*ndim + k] = w0[k] + d0_vec[i-1, k] + + # dummy counter for storing Lyapunov stuff, which only happens every few steps + jiter = 0 + for j in range(1, n_steps, 1): + dop853_step(cp, &cf, Fwrapper, + &w[0], t[j-1], t[j], dt0, ndim, + norbits, 0, args, # 0 is for nbody, ignored here + atol, rtol, nmax, -1, # disable stiffness check + err_if_fail=1, log_output=log_output) + + if (j % n_steps_per_pullback) == 0: + # get magnitude of deviation vector + for i in range(1, norbits): + for k in range(ndim): + d1[i, k] = w[i*ndim + k] - w[k] + + d1_mag = six_norm(&d1[i, 0]) + LEs[jiter, i-1] = log(d1_mag / d0) + + # renormalize offset orbits + for k in range(ndim): + w[i*ndim + k] = w[k] + d0 * d1[i, k] / d1_mag + + jiter += 1 + + LEs = np.array([np.sum(LEs[:j],axis=0)/t[j*n_steps_per_pullback] for j in range(1, niter)]) + return np.asarray(LEs) diff --git a/gala/source/src/gala/dynamics/mockstream/__init__.py b/gala/source/src/gala/dynamics/mockstream/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..60642f91202960477ecba29cd098e22db169c5c6 --- /dev/null +++ b/gala/source/src/gala/dynamics/mockstream/__init__.py @@ -0,0 +1,4 @@ +from ._mockstream import mockstream_dop853 +from .core import * +from .df import * +from .mockstream_generator import * diff --git a/gala/source/src/gala/dynamics/mockstream/_coord.pxd b/gala/source/src/gala/dynamics/mockstream/_coord.pxd new file mode 100644 index 0000000000000000000000000000000000000000..83ec8d153ef3df3303042c7f8b5d922aeb7f6cb3 --- /dev/null +++ b/gala/source/src/gala/dynamics/mockstream/_coord.pxd @@ -0,0 +1,18 @@ +# cython: language_level=3 +# cython: language=c++ + +# cdef void cross(double[::1] x, double[::1] y, double[::1] z) +cdef void cross(double *x, double *y, double *z) +cdef double norm(double *x, int n) +cdef void apply_3matrix(double[:, ::1] R, double *x, double *y, int transpose) + +cdef void sat_rotation_matrix(double *w, double *R) + +cdef void to_sat_coords(double *w, double *R, + double *w_prime) + +cdef void from_sat_coords(double *w_prime, double *R, + double *w) + +cdef void car_to_cyl(double *w, double *cyl) +cdef void cyl_to_car(double *cyl, double *w) diff --git a/gala/source/src/gala/dynamics/mockstream/_coord.pyx b/gala/source/src/gala/dynamics/mockstream/_coord.pyx new file mode 100644 index 0000000000000000000000000000000000000000..d4518a16f5fd4d6f88de1877a56c21d8a8625c38 --- /dev/null +++ b/gala/source/src/gala/dynamics/mockstream/_coord.pyx @@ -0,0 +1,294 @@ +# cython: boundscheck=False +# cython: debug=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + +""" Coordinate help for generating mock streams. """ + +from libc.math cimport M_PI + +cdef extern from "math.h": + double fabs(double x) nogil + double sqrt(double x) nogil + double cos(double x) nogil + double sin(double x) nogil + double atan2(double y, double x) nogil + double fmod(double y, double x) nogil + + +cdef void cross(double *x, double *y, double *z): + z[0] = x[1]*y[2] - x[2]*y[1] + z[1] = -x[0]*y[2] + x[2]*y[0] + z[2] = x[0]*y[1] - x[1]*y[0] + + +cdef double norm(double *x, int n): + cdef: + double val = 0. + int i + + for i in range(n): + val += x[i]**2 + + return sqrt(val) + + +cdef void apply_3matrix(double[:, ::1] R, double *x, double *y, + int transpose): + cdef int i + if transpose == 0: + for i in range(3): + y[i] = R[i, 0] * x[0] + R[i, 1] * x[1] + R[i, 2] * x[2] + else: + for i in range(3): + y[i] = R[0, i] * x[0] + R[1, i] * x[1] + R[2, i] * x[2] + + +cdef void sat_rotation_matrix(double *w, # in + double *R): # out + cdef: + double x1_norm, x2_norm, x3_norm = 0. + unsigned int i + double *x1 = [0., 0., 0.] + double *x2 = [0., 0., 0.] + double *x3 = [0., 0., 0.] + + x1[0] = w[0] + x1[1] = w[1] + x1[2] = w[2] + + x3[0] = x1[1]*w[2+3] - x1[2]*w[1+3] + x3[1] = x1[2]*w[0+3] - x1[0]*w[2+3] + x3[2] = x1[0]*w[1+3] - x1[1]*w[0+3] + + x2[0] = -x1[1]*x3[2] + x1[2]*x3[1] + x2[1] = -x1[2]*x3[0] + x1[0]*x3[2] + x2[2] = -x1[0]*x3[1] + x1[1]*x3[0] + + x1_norm = sqrt(x1[0]*x1[0] + x1[1]*x1[1] + x1[2]*x1[2]) + x2_norm = sqrt(x2[0]*x2[0] + x2[1]*x2[1] + x2[2]*x2[2]) + x3_norm = sqrt(x3[0]*x3[0] + x3[1]*x3[1] + x3[2]*x3[2]) + + for i in range(3): + x1[i] /= x1_norm + x2[i] /= x2_norm + x3[i] /= x3_norm + + R[0] = x1[0] + R[1] = x1[1] + R[2] = x1[2] + R[3] = x2[0] + R[4] = x2[1] + R[5] = x2[2] + R[6] = x3[0] + R[7] = x3[1] + R[8] = x3[2] + +cdef void to_sat_coords(double *w, double *R, # in + double *w_prime): # out + # Translate to be centered on progenitor + cdef int i + + # Project into new basis + w_prime[0] = w[0]*R[0] + w[1]*R[1] + w[2]*R[2] + w_prime[1] = w[0]*R[3] + w[1]*R[4] + w[2]*R[5] + w_prime[2] = w[0]*R[6] + w[1]*R[7] + w[2]*R[8] + + w_prime[3] = w[3]*R[0] + w[4]*R[1] + w[5]*R[2] + w_prime[4] = w[3]*R[3] + w[4]*R[4] + w[5]*R[5] + w_prime[5] = w[3]*R[6] + w[4]*R[7] + w[5]*R[8] + +cdef void from_sat_coords(double *w_prime, double *R, # in + double *w): # out + cdef int i + + # Project back from sat plane + w[0] = w_prime[0]*R[0] + w_prime[1]*R[3] + w_prime[2]*R[6] + w[1] = w_prime[0]*R[1] + w_prime[1]*R[4] + w_prime[2]*R[7] + w[2] = w_prime[0]*R[2] + w_prime[1]*R[5] + w_prime[2]*R[8] + + w[3] = w_prime[3]*R[0] + w_prime[4]*R[3] + w_prime[5]*R[6] + w[4] = w_prime[3]*R[1] + w_prime[4]*R[4] + w_prime[5]*R[7] + w[5] = w_prime[3]*R[2] + w_prime[4]*R[5] + w_prime[5]*R[8] + +# --------------------------------------------------------------------- + +cdef void car_to_cyl(double *w, # in + double *cyl): # out + cdef: + double R = sqrt(w[0]*w[0] + w[1]*w[1]) + double phi = atan2(w[1], w[0]) + double vR = (w[0]*w[3] + w[1]*w[4]) / R + double vphi = (w[0]*w[4] - w[3]*w[1]) / R + + cyl[0] = R + if phi < 0: + phi = phi + 2*M_PI + cyl[1] = phi + cyl[2] = w[2] + + cyl[3] = vR + cyl[4] = vphi + cyl[5] = w[5] + +cdef void cyl_to_car(double *cyl, # in + double *w): # out + w[0] = cyl[0] * cos(cyl[1]) + w[1] = cyl[0] * sin(cyl[1]) + w[2] = cyl[2] + + w[3] = cyl[3] * cos(cyl[1]) - cyl[4] * sin(cyl[1]) + w[4] = cyl[3] * sin(cyl[1]) + cyl[4] * cos(cyl[1]) + w[5] = cyl[5] + +# --------------------------------------------------------------------- +# Tests +# + +cpdef _test_sat_rotation_matrix(): + import numpy as np + np.random.seed(42) + n = 1024 + + cdef: + double[::1] w = np.zeros(6) + double[::1] wrot = np.zeros(6) + double[::1] w2 = np.zeros(6) + double[:, ::1] R = np.zeros((3, 3)) + unsigned int i, j + + for i in range(n): + w = np.random.uniform(size=6) + sat_rotation_matrix(&w[0], &R[0, 0]) + + x = np.array(R).dot(np.array(w)[:3]) + assert x[0] > 0 + assert np.allclose(x[1], 0) + assert np.allclose(x[2], 0) + + v = np.array(R).dot(np.array(w)[3:]) + assert np.allclose(v[2], 0) + for j in range(3): + wrot[j] = x[j] + wrot[j+3] = v[j] + + x2 = np.array(R.T).dot(np.array(wrot)[:3]) + v2 = np.array(R.T).dot(np.array(wrot)[3:]) + for j in range(3): + w2[j] = x2[j] + w2[j+3] = v2[j] + + for j in range(6): + assert np.allclose(w[j], w2[j]) + +cpdef _test_to_sat_coords_roundtrip(): + import numpy as np + np.random.seed(42) + n = 1024 + + cdef: + double[:, ::1] w = np.random.uniform(size=(n, 6)) + double[:, ::1] w_sat = np.random.uniform(size=(n, 6)) + double[:, ::1] R = np.zeros((3, 3)) + + double[::1] w_prime = np.zeros(6) + double[::1] w2 = np.zeros(6) + + unsigned int i, j + + for i in range(n): + sat_rotation_matrix(&w_sat[i, 0], &R[0, 0]) + to_sat_coords(&w[i, 0], &R[0, 0], &w_prime[0]) + from_sat_coords(&w_prime[0], &R[0, 0], &w2[0]) + + for j in range(6): + assert np.allclose(w[i, j], w2[j]) + +cpdef _test_car_to_cyl_roundtrip(): + import numpy as np + np.random.seed(42) + n = 1024 + + cdef: + double[:, ::1] w = np.random.uniform(-10, 10, size=(n, 6)) + double[::1] cyl = np.zeros(6) + double[::1] w2 = np.zeros(6) + + unsigned int i, j + + for i in range(n): + car_to_cyl(&w[i, 0], &cyl[0]) + cyl_to_car(&cyl[0], &w2[0]) + for j in range(6): + assert np.allclose(w[i, j], w2[j]) + +cpdef _test_cyl_to_car_roundtrip(): + import numpy as np + # np.random.seed(42) + n = 1024 + + cdef: + double[:, ::1] cyl = np.random.uniform(0, 2*np.pi, size=(n, 6)) + double[::1] w = np.zeros(6) + double[::1] cyl2 = np.zeros(6) + + unsigned int i, j + + for i in range(n): + cyl_to_car(&cyl[i, 0], &w[0]) + car_to_cyl(&w[0], &cyl2[0]) + for j in range(6): + # assert np.allclose(cyl[i, j], cyl2[j]) + if not np.allclose(cyl[i, j], cyl2[j]): + print(i, j, cyl[i, j], cyl2[j]) + +# cdef void car_to_sph(double *xyz, double *sph): +# # TODO: note this isn't consistent with the velocity transform because of theta +# # get out spherical components +# cdef: +# double d = sqrt(xyz[0]*xyz[0]+xyz[1]*xyz[1]+xyz[2]*xyz[2]) +# double phi = atan2(xyz[1], xyz[0]) +# double theta = acos(xyz[2] / d) + +# sph[0] = d +# sph[1] = phi +# sph[2] = theta + +# cdef void sph_to_car(double *sph, double *xyz): +# # TODO: note this isn't consistent with the velocity transform because of theta +# # get out spherical components +# xyz[0] = sph[0] * cos(sph[1]) * sin(sph[2]) +# xyz[1] = sph[0] * sin(sph[1]) * sin(sph[2]) +# xyz[2] = sph[0] * cos(sph[2]) + +# cdef void v_car_to_sph(double *xyz, double *vxyz, double *vsph): +# # get out spherical components +# cdef: +# double d = sqrt(xyz[0]*xyz[0]+xyz[1]*xyz[1]+xyz[2]*xyz[2]) +# double dxy = sqrt(xyz[0]*xyz[0] + xyz[1]*xyz[1]) + +# double vr = (xyz[0]*vxyz[0]+xyz[1]*vxyz[1]+xyz[2]*vxyz[2]) / d + +# double mu_lon = (xyz[0]*vxyz[1] - vxyz[0]*xyz[1]) / (dxy*dxy) +# double vlon = mu_lon * dxy # cos(lat) + +# double mu_lat = (xyz[2]*(xyz[0]*vxyz[0] + xyz[1]*vxyz[1]) - dxy*dxy*vxyz[2]) / (d*d*dxy) +# double vlat = -mu_lat * d + +# vsph[0] = vr +# vsph[1] = vlon +# vsph[2] = vlat + +# cdef void v_sph_to_car(double *xyz, double *vsph, double *vxyz): +# # get out spherical components +# cdef: +# double d = sqrt(xyz[0]*xyz[0]+xyz[1]*xyz[1]+xyz[2]*xyz[2]) +# double dxy = sqrt(xyz[0]*xyz[0] + xyz[1]*xyz[1]) + +# vxyz[0] = vsph[0]*xyz[0]/dxy*dxy/d - xyz[1]/dxy*vsph[1] - xyz[0]/dxy*xyz[2]/d*vsph[2] +# vxyz[1] = vsph[0]*xyz[1]/dxy*dxy/d + xyz[0]/dxy*vsph[1] - xyz[1]/dxy*xyz[2]/d*vsph[2] +# vxyz[2] = vsph[0]*xyz[2]/d + dxy/d*vsph[2] diff --git a/gala/source/src/gala/dynamics/mockstream/core.py b/gala/source/src/gala/dynamics/mockstream/core.py new file mode 100644 index 0000000000000000000000000000000000000000..1a6a4f55893015354ec7913a9fdcbca9b8da35e7 --- /dev/null +++ b/gala/source/src/gala/dynamics/mockstream/core.py @@ -0,0 +1,156 @@ +import astropy.units as u +import numpy as np +from scipy.spatial.transform import Rotation + +from ...io import quantity_from_hdf5, quantity_to_hdf5 +from .. import PhaseSpacePosition + +__all__ = ["MockStream"] + + +class MockStream(PhaseSpacePosition): + @u.quantity_input(release_time=u.Myr) + def __init__( + self, pos, vel=None, frame=None, release_time=None, lead_trail=None, copy=True + ): + super().__init__(pos=pos, vel=vel, frame=frame, copy=copy) + + if release_time is not None: + release_time = u.Quantity(release_time) + if len(release_time) != self.pos.shape[0]: + msg = ( + "shape mismatch: input release time array " + "must have the same shape as the input " + "phase-space data, minus the component " + f"dimension. expected {self.pos.shape[0]}, got {len(release_time)}" + ) + raise ValueError(msg) + + self.release_time = release_time + + if lead_trail is not None: + lead_trail = np.array(lead_trail) + if len(lead_trail) != self.pos.shape[0]: + msg = ( + "shape mismatch: input leading/trailing array " + "must have the same shape as the input " + "phase-space data, minus the component " + f"dimension. expected {self.pos.shape[0]}, got {len(lead_trail)}" + ) + raise ValueError(msg) + + self.lead_trail = lead_trail + + def rotate_to_progenitor_plane(self, prog_w): + """Rotate the mock stream to align with the progenitor's orbital plane + + This method transforms the mock stream into a new coordinate system where the + progenitor's orbital plane is aligned with the xy-plane, the stream and + progenitor are centered at (0, 0), and the stream primarily extends in the x + direction (leading tail at positive x and trailing tail at negative x). This is + useful for visualizing streams in their natural orbital plane. + + Parameters + ---------- + prog_w : `~gala.dynamics.PhaseSpacePosition` + The phase-space position of the progenitor at the same time as the stream. + This defines the center and orientation of the rotated coordinate system. + + Returns + ------- + rotated_stream : `~gala.dynamics.MockStream` + A new MockStream instance with positions and velocities transformed to the + rotated coordinate system. The progenitor is at the origin with velocity + aligned along the positive x-axis. The release times and lead/trail flags + are preserved from the original stream. + """ + if prog_w.shape == (): + pass # scalar, good + elif prog_w.shape == (1,): + prog_w = prog_w[0] + else: + raise ValueError( + "prog_w must be a single phase-space position, not an array of " + "positions" + ) + + lon = prog_w.spherical.lon.to_value(u.rad) + lat = prog_w.spherical.lat.to_value(u.rad) + + R1 = Rotation.from_euler("z", -lon) + R2 = Rotation.from_euler("y", lat) + Rtmp = R2.as_matrix() @ R1.as_matrix() + + vtmp = Rtmp @ prog_w.v_xyz + R3 = Rotation.from_euler("x", -np.arctan2(vtmp[2], vtmp[1]).value) + R4 = Rotation.from_euler("z", -np.pi / 2) + R = R4.as_matrix() @ R3.as_matrix() @ Rtmp + + prog_rot = PhaseSpacePosition(prog_w.data.transform(R)) + R_final = Rotation.from_euler( + "z", -np.arctan2(prog_rot.v_y, prog_rot.v_x) + ).as_matrix() + + tmp = PhaseSpacePosition(self.data.transform(R)) + + return MockStream( + pos=R_final @ (tmp.xyz - prog_rot.xyz[:, None]), + vel=R_final @ tmp.v_xyz, + release_time=self.release_time, + lead_trail=self.lead_trail, + ) + + # ------------------------------------------------------------------------ + # Input / output + # + def to_hdf5(self, f): + """Serialize this object to an HDF5 file. + + Requires ``h5py``. + + Parameters + ---------- + f : str, :class:`h5py.File` + Either the filename or an open HDF5 file. + """ + + f = super().to_hdf5(f) + + # if self.potential is not None: + # import yaml + # from ..potential.potential.io import to_dict + # f['potential'] = yaml.dump(to_dict(self.potential)).encode('utf-8') + + if self.release_time: + quantity_to_hdf5(f, "release_time", self.release_time) + + if self.lead_trail is not None: + f["lead_trail"] = self.lead_trail.astype("S1") # TODO HACK + return f + + @classmethod + def from_hdf5(cls, f): + """Load an object from an HDF5 file. + + Requires ``h5py``. + + Parameters + ---------- + f : str, :class:`h5py.File` + Either the filename or an open HDF5 file. + """ + # TODO: this is duplicated code from PhaseSpacePosition + if isinstance(f, str): + import h5py + + f = h5py.File(f, mode="r") + + obj = PhaseSpacePosition.from_hdf5(f) + + t = quantity_from_hdf5(f["release_time"]) if "release_time" in f else None + + lt = f["lead_trail"][:] if "lead_trail" in f else None + + return cls( + pos=obj.pos, vel=obj.vel, release_time=t, lead_trail=lt, frame=obj.frame + ) diff --git a/gala/source/src/gala/dynamics/mockstream/df.pxd b/gala/source/src/gala/dynamics/mockstream/df.pxd new file mode 100644 index 0000000000000000000000000000000000000000..b35f7aa02e47b66adcd14fe516cc6e0da6888889 --- /dev/null +++ b/gala/source/src/gala/dynamics/mockstream/df.pxd @@ -0,0 +1,30 @@ +# cython: language_level=3 +# cython: language=c++ + +from ...potential.potential.cpotential cimport CPotential + +cdef class BaseStreamDF: + + cdef double _lead + cdef double _trail + cdef public object random_state + + # TODO: used only in the FardalStreamDF class + cdef int _gala_modified + + cdef void get_rj_vj_R(self, CPotential *cpotential, double G, + double *prog_x, double *prog_v, + double prog_m, double t, + double *rj, double *vj, double[:, ::1] R) + + cdef void transform_from_sat(self, double[:, ::1] R, + double *x, double *v, + double *prog_x, double *prog_v, + double *out_x, double *out_v) + + cpdef _sample(self, potential, + double[:, ::1] prog_x, double[:, ::1] prog_v, + double[::1] prog_t, double[::1] prog_m, int[::1] nparticles) + + cpdef sample(self, prog_orbit, prog_mass, hamiltonian=?, + release_every=?, n_particles=?) diff --git a/gala/source/src/gala/dynamics/mockstream/df.pyx b/gala/source/src/gala/dynamics/mockstream/df.pyx new file mode 100644 index 0000000000000000000000000000000000000000..b34759fb5838df33eda6e4af1ad66eb04b994464 --- /dev/null +++ b/gala/source/src/gala/dynamics/mockstream/df.pyx @@ -0,0 +1,702 @@ +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + + +import cython +import astropy.units as u +import numpy as np +cimport numpy as np +from libc.math cimport sqrt, sin, cos, M_PI + +from ..util import combine +from ..orbit import Orbit +from ..nbody import DirectNBody +from ...potential import Hamiltonian, PotentialBase, StaticFrame +from ...potential.potential.cpotential cimport CPotentialWrapper, CPotential, c_d2_dr2 +from ...potential.hamiltonian.chamiltonian import Hamiltonian + +from ._coord cimport cross, norm, apply_3matrix +from .core import MockStream + +__all__ = ['BaseStreamDF', 'FardalStreamDF', 'StreaklineStreamDF', + 'LagrangeCloudStreamDF', 'ChenStreamDF'] + + +@cython.embedsignature(True) +cdef class BaseStreamDF: + """A base class for representing distribution functions for generating + stellar streams. + + This class specifies how massless star particles should be sampled in + order to generate a mock stellar stream. + + Parameters + ---------- + lead : bool (optional) + Generate a leading tail. Default: True. + trail : bool (optional) + Generate a trailing tail. Default: True. + random_state : `~numpy.random.RandomState` (optional) + To control random number generation. + + """ + def __init__(self, lead=True, trail=True, random_state=None): + + self._lead = int(lead) + self._trail = int(trail) + + if random_state is None: + random_state = np.random.RandomState() + self.random_state = random_state + + if not self.lead and not self.trail: + raise ValueError("You must generate either leading or trailing " + "tails (or both!)") + + cdef void get_rj_vj_R(self, CPotential *cpotential, double G, + double *prog_x, double *prog_v, + double prog_m, double t, + double *rj, double *vj, double[:, ::1] R): # outputs + # NOTE: assuming ndim=3 throughout here + cdef: + int i + double dist = norm(prog_x, 3) + double L[3] + double Lmag, Om, d2r + + # angular momentum vector, L, and |L| + cross(prog_x, prog_v, &L[0]) + Lnorm = norm(&L[0], 3) + + # NOTE: R goes from non-rotating frame to rotating frame!!! + for i in range(3): + R[0, i] = prog_x[i] / dist + R[2, i] = L[i] / Lnorm + + # Now compute jacobi radius and relative velocity at jacobi radius + # Note: we re-use the L array as the "epsilon" array needed by d2_dr2 + Om = Lnorm / dist**2 + d2r = c_d2_dr2(cpotential, t, prog_x, + &L[0]) + rj[0] = (G * prog_m / (Om*Om - d2r)) ** (1/3.) + vj[0] = Om * rj[0] + + # re-use the epsilon array to compute cross-product + cross(&R[0, 0], &R[2, 0], &R[1, 0]) + for i in range(3): + R[1, i] = -R[1, i] + + cdef void transform_from_sat(self, double[:, ::1] R, + double *x, double *v, + double *prog_x, double *prog_v, + double *out_x, double *out_v): + # from satellite coordinates to global coordinates note: the 1 is + # because above in get_rj_vj_R(), we compute the transpose of the + # rotation matrix we actually need + apply_3matrix(R, x, out_x, 1) + apply_3matrix(R, v, out_v, 1) + + for n in range(3): + out_x[n] += prog_x[n] + out_v[n] += prog_v[n] + + + cpdef _sample(self, potential, + double[:, ::1] prog_x, double[:, ::1] prog_v, + double[::1] prog_t, double[::1] prog_m, int[::1] nparticles): + pass + + # ------------------------------------------------------------------------ + # Python-only: + + @property + def lead(self): + return self._lead + + @property + def trail(self): + return self._trail + + cpdef sample(self, prog_orbit, prog_mass, hamiltonian=None, + release_every=1, n_particles=1): + """sample(prog_orbit, prog_mass, hamiltonian=None, release_every=1, n_particles=1) + + Generate stream particle initial conditions and initial times. + + This method is primarily meant to be used within the + ``MockStreamGenerator``. + + Parameters + ---------- + prog_orbit : `~gala.dynamics.Orbit` + The orbit of the progenitor system. + prog_mass : `~astropy.units.Quantity` [mass] + The mass of the progenitor system, either a scalar quantity, or as + an array with the same shape as the number of timesteps in the orbit + to account for mass evolution. + hamiltonian : `~gala.potential.Hamiltonian` + The external potential and reference frame to numerically integrate + orbits in. + release_every : int (optional) + Controls how often to release stream particles from each tail. + Default: 1, meaning release particles at each timestep. + n_particles : int, array_like (optional) + If an integer, this controls the number of particles to release in + each tail at each release timestep. Alternatively, you can pass in + an array with the same shape as the number of timesteps to release + bursts of particles at certain times (e.g., pericenter). + + Returns + ------- + xyz : `~astropy.units.Quantity` [length] + The initial positions for stream star particles. + v_xyz : `~astropy.units.Quantity` [speed] + The initial velocities for stream star particles. + t1 : `~astropy.units.Quantity` [time] + The initial times (i.e. times to start integrating from) for stream + star particles. + """ + + if prog_orbit.hamiltonian is not None: + H = prog_orbit.hamiltonian + elif hamiltonian is not None: + H = Hamiltonian(hamiltonian) + else: + raise ValueError('TODO') + + # TODO: if an orbit with non-static frame passed in, convert to static frame before generating + static_frame = StaticFrame(H.units) + frame = H.frame + + # TODO: we could catch this possible error and make it more specific + prog_orbit_static = prog_orbit.to_frame(static_frame) + + # Coerce the input orbit into C-contiguous numpy arrays in the units of + # the hamiltonian + _units = H.units + prog_x = np.ascontiguousarray( + prog_orbit_static.xyz.decompose(_units).value.T) + prog_v = np.ascontiguousarray( + prog_orbit_static.v_xyz.decompose(_units).value.T) + prog_t = prog_orbit_static.t.decompose(_units).value + try: + prog_m = np.squeeze(prog_mass.decompose(_units).value) + except: + raise TypeError("Input progenitor mass must be a Quantity object " + "with a decompose() method, e.g, an astropy " + "quantity.") + + if prog_m.shape == (): + prog_m = np.full_like(prog_t, prog_m) + + if np.iterable(n_particles): + n_particles = np.array(n_particles).astype('i4') + if not len(n_particles) == len(prog_t): + raise ValueError('If passing in an array n_particles, its ' + 'shape must match the number of timesteps in ' + 'the progenitor orbit.') + + else: + N = int(n_particles) + n_particles = np.zeros_like(prog_t, dtype='i4') + n_particles[::release_every] = N + + x, v, t1 = self._sample(H.potential, prog_x, prog_v, + prog_t, prog_m, + n_particles) + + # First out what particles are leading vs. trailing: + lt = np.empty(len(t1), dtype='U1') + i = 0 + for n in n_particles: + if self._trail: + lt[i:i+n] = 't' + i += n + + if self._lead: + lt[i:i+n] = 'l' + i += n + + out = Orbit(pos=np.array(x).T * _units['length'], + vel=np.array(v).T * _units['length']/_units['time'], + t=np.array(t1) * _units['time'], + frame=static_frame, + copy=False, + ) + + # Transform back to the input frame + out = out.to_frame(frame) + + w0 = MockStream(pos=out.pos, vel=out.vel, frame=out.frame, + release_time=out.t, lead_trail=lt, copy=False) + + return w0 + + +@cython.embedsignature(True) +cdef class StreaklineStreamDF(BaseStreamDF): + """A class for representing the "streakline" distribution function for + generating stellar streams based on Kuepper et al. 2012 + https://ui.adsabs.harvard.edu/abs/2012MNRAS.420.2700K/abstract + + Parameters + ---------- + lead : bool (optional) + Generate a leading tail. Default: True. + trail : bool (optional) + Generate a trailing tail. Default: True. + random_state : `~numpy.random.RandomState` (optional) + To control random number generation. + """ + + cpdef _sample(self, potential, + double[:, ::1] prog_x, double[:, ::1] prog_v, + double[::1] prog_t, double[::1] prog_m, int[::1] nparticles): + cdef: + int i, j, k, n + int ntimes = len(prog_t) + int total_nparticles = (self._lead + self._trail) * np.sum(nparticles) + + double[:, ::1] particle_x = np.zeros((total_nparticles, 3)) + double[:, ::1] particle_v = np.zeros((total_nparticles, 3)) + double[::1] particle_t1 = np.zeros((total_nparticles, )) + + double[::1] tmp_x = np.zeros(3) + double[::1] tmp_v = np.zeros(3) + + double rj # jacobi radius + double vj # relative velocity at jacobi radius + double[:, ::1] R = np.zeros((3, 3)) # rotation to satellite coordinates + + CPotential* cpotential = ((potential.c_instance)).cpotential + double G = potential.G + + j = 0 + for i in range(ntimes): + if prog_m[i] == 0: + continue + + self.get_rj_vj_R(cpotential, G, + &prog_x[i, 0], &prog_v[i, 0], prog_m[i], prog_t[i], + &rj, &vj, R) # outputs + + # Trailing tail + if self._trail == 1: + for k in range(nparticles[i]): + tmp_x[0] = rj + tmp_v[1] = vj + particle_t1[j+k] = prog_t[i] + + self.transform_from_sat(R, + &tmp_x[0], &tmp_v[0], + &prog_x[i, 0], &prog_v[i, 0], + &particle_x[j+k, 0], + &particle_v[j+k, 0]) + + j += nparticles[i] + + # Leading tail + if self._lead == 1: + for k in range(nparticles[i]): + tmp_x[0] = -rj + tmp_v[1] = -vj + particle_t1[j+k] = prog_t[i] + + self.transform_from_sat(R, + &tmp_x[0], &tmp_v[0], + &prog_x[i, 0], &prog_v[i, 0], + &particle_x[j+k, 0], + &particle_v[j+k, 0]) + + j += nparticles[i] + + return particle_x, particle_v, particle_t1 + + +@cython.embedsignature(True) +cdef class FardalStreamDF(BaseStreamDF): + """A class for representing the Fardal+2015 distribution function for + generating stellar streams based on Fardal et al. 2015 + https://ui.adsabs.harvard.edu/abs/2015MNRAS.452..301F/abstract + + Parameters + ---------- + gala_modified : bool (optional) + If True, use the modified version of the Fardal method parameters used in Gala. If you would like to use the exact parameters from Fardal+2015, set this to False. Default: True. + lead : bool (optional) + Generate a leading tail. Default: True. + trail : bool (optional) + Generate a trailing tail. Default: True. + random_state : `~numpy.random.RandomState` (optional) + To control random number generation. + """ + def __init__( + self, gala_modified=None, lead=True, trail=True, random_state=None + ): + super().__init__(lead=lead, trail=trail, random_state=random_state) + + if gala_modified is None: + from gala.util import GalaFutureWarning + import warnings + msg = ( + "The parameter values of the FardalStreamDF have been updated (fixed) " + "to match the parameter values in the final published version of " + "Fardal+2015. For now, this class uses the Gala modified parameter " + "values that have been adopted over the last several years in Gala. " + "In the future, the default behavior of this class will use the " + "Fardal+2015 parameter values instead, breaking backwards " + "compatibility for mock stream simulations. To use the Fardal+2015 " + "parameters now, set gala_modified=False. To continue to use the Gala " + "modified parameter values, set gala_modified=True." + ) + warnings.warn(msg, GalaFutureWarning) + gala_modified = True + + self._gala_modified = int(gala_modified) + + + cpdef _sample(self, potential, + double[:, ::1] prog_x, double[:, ::1] prog_v, + double[::1] prog_t, double[::1] prog_m, int[::1] nparticles): + cdef: + int i, j, k, n + int ntimes = len(prog_t) + int total_nparticles = (self._lead + self._trail) * np.sum(nparticles) + + double[:, ::1] particle_x = np.zeros((total_nparticles, 3)) + double[:, ::1] particle_v = np.zeros((total_nparticles, 3)) + double[::1] particle_t1 = np.zeros((total_nparticles, )) + + double[::1] tmp_x = np.zeros(3) + double[::1] tmp_v = np.zeros(3) + + double rj # jacobi radius + double vj # relative velocity at jacobi radius + double[:, ::1] R = np.zeros((3, 3)) # rotation to satellite coordinates + + # for Fardal method: + double kx + double[::1] k_mean = np.zeros(6) + double[::1] k_disp = np.zeros(6) + + CPotential* cpotential = ((potential.c_instance)).cpotential + double G = potential.G + + # TODO: support computing this, which requires knowing the peri/apo and values + # of Om**2 - d2Phi/dr2 at those points... + # kvt_fardal = min(0.15 * self.f_t**2 * Racc**(2/3), 0.4) + kvt_fardal = 0.4 + + k_mean[0] = 2. # R + k_disp[0] = 0.5 if self._gala_modified else 0.4 + + k_mean[2] = 0. # z + k_disp[2] = 0.5 + + k_mean[4] = 0.3 # vt + k_disp[4] = 0.5 if self._gala_modified else kvt_fardal + + k_mean[5] = 0. # vz + k_disp[5] = 0.5 + + j = 0 + for i in range(ntimes): + if prog_m[i] == 0: + continue + + self.get_rj_vj_R(cpotential, G, + &prog_x[i, 0], &prog_v[i, 0], prog_m[i], prog_t[i], + &rj, &vj, R) # outputs + + # Trailing tail + if self._trail == 1: + for k in range(nparticles[i]): + kx = self.random_state.normal(k_mean[0], k_disp[0]) + tmp_x[0] = kx * rj + tmp_x[2] = self.random_state.normal(k_mean[2], k_disp[2]) * rj + tmp_v[1] = self.random_state.normal(k_mean[4], k_disp[4]) * vj + if self._gala_modified: # for backwards compatibility + tmp_v[1] *= kx + tmp_v[2] = self.random_state.normal(k_mean[5], k_disp[5]) * vj + particle_t1[j+k] = prog_t[i] + + self.transform_from_sat(R, + &tmp_x[0], &tmp_v[0], + &prog_x[i, 0], &prog_v[i, 0], + &particle_x[j+k, 0], + &particle_v[j+k, 0]) + + j += nparticles[i] + + # Leading tail + if self._lead == 1: + for k in range(nparticles[i]): + kx = self.random_state.normal(k_mean[0], k_disp[0]) + tmp_x[0] = kx * -rj + tmp_x[2] = self.random_state.normal(k_mean[2], k_disp[2]) * -rj + tmp_v[1] = self.random_state.normal(k_mean[4], k_disp[4]) * -vj + if self._gala_modified: # for backwards compatibility + tmp_v[1] *= kx + tmp_v[2] = self.random_state.normal(k_mean[5], k_disp[5]) * -vj + particle_t1[j+k] = prog_t[i] + + self.transform_from_sat(R, + &tmp_x[0], &tmp_v[0], + &prog_x[i, 0], &prog_v[i, 0], + &particle_x[j+k, 0], + &particle_v[j+k, 0]) + + j += nparticles[i] + + return particle_x, particle_v, particle_t1 + + +@cython.embedsignature(True) +cdef class LagrangeCloudStreamDF(BaseStreamDF): + """A class for representing the Lagrange Cloud Stripping distribution + function for generating stellar streams. This df is based on Gibbons et al. + 2014 https://ui.adsabs.harvard.edu/abs/2014MNRAS.445.3788G/abstract + but has since been modified by, e.g., Erkal et al. 2019 + https://ui.adsabs.harvard.edu/abs/2019MNRAS.487.2685E/abstract . + + Parameters + ---------- + v_disp : `~astropy.units.Quantity` [speed] + The velocity dispersion of the released particles. + lead : bool (optional) + Generate a leading tail. Default: True. + trail : bool (optional) + Generate a trailing tail. Default: True. + random_state : `~numpy.random.RandomState` (optional) + To control random number generation. + """ + + cdef public object v_disp + + @u.quantity_input(v_disp=u.km/u.s) + def __init__(self, v_disp, lead=True, trail=True, random_state=None): + super().__init__(lead=lead, trail=trail, random_state=random_state) + + self.v_disp = v_disp + + cpdef _sample(self, potential, + double[:, ::1] prog_x, double[:, ::1] prog_v, + double[::1] prog_t, double[::1] prog_m, int[::1] nparticles): + cdef: + int i, j, k, n + int ntimes = len(prog_t) + int total_nparticles = (self._lead + self._trail) * np.sum(nparticles) + + double[:, ::1] particle_x = np.zeros((total_nparticles, 3)) + double[:, ::1] particle_v = np.zeros((total_nparticles, 3)) + double[::1] particle_t1 = np.zeros((total_nparticles, )) + + double[::1] tmp_x = np.zeros(3) + double[::1] tmp_v = np.zeros(3) + + double rj # jacobi radius + double vj # relative velocity at jacobi radius + double[:, ::1] R = np.zeros((3, 3)) # rotation to satellite coordinates + + CPotential* cpotential = ((potential.c_instance)).cpotential + double G = potential.G + double _v_disp = self.v_disp.decompose(potential.units).value + + j = 0 + for i in range(ntimes): + if prog_m[i] == 0: + continue + + self.get_rj_vj_R(cpotential, G, + &prog_x[i, 0], &prog_v[i, 0], prog_m[i], prog_t[i], + &rj, &vj, R) # outputs + + # Trailing tail + if self._trail == 1: + for k in range(nparticles[i]): + tmp_x[0] = rj + tmp_v[0] = self.random_state.normal(0, _v_disp) + tmp_v[1] = self.random_state.normal(0, _v_disp) + tmp_v[2] = self.random_state.normal(0, _v_disp) + particle_t1[j + k] = prog_t[i] + + self.transform_from_sat(R, + &tmp_x[0], &tmp_v[0], + &prog_x[i, 0], &prog_v[i, 0], + &particle_x[j+k, 0], + &particle_v[j+k, 0]) + + j += nparticles[i] + + # Leading tail + if self._lead == 1: + for k in range(nparticles[i]): + tmp_x[0] = -rj + tmp_v[0] = self.random_state.normal(0, _v_disp) + tmp_v[1] = self.random_state.normal(0, _v_disp) + tmp_v[2] = self.random_state.normal(0, _v_disp) + particle_t1[j + k] = prog_t[i] + + self.transform_from_sat(R, + &tmp_x[0], &tmp_v[0], + &prog_x[i, 0], &prog_v[i, 0], + &particle_x[j+k, 0], + &particle_v[j+k, 0]) + + j += nparticles[i] + + return particle_x, particle_v, particle_t1 + + +@cython.embedsignature(True) +cdef class ChenStreamDF(BaseStreamDF): + """A class for representing the Chen+2024 distribution function for + generating stellar streams based on Chen et al. 2024 + https://ui.adsabs.harvard.edu/abs/2024arXiv240801496C/abstract + + Parameters + ---------- + lead : bool (optional) + Generate a leading tail. Default: True. + trail : bool (optional) + Generate a trailing tail. Default: True. + random_state : `~numpy.random.RandomState` (optional) + To control random number generation. + """ + def __init__( + self, lead=True, trail=True, random_state=None + ): + super().__init__(lead=lead, trail=trail, random_state=random_state) + + + cpdef _sample(self, potential, + double[:, ::1] prog_x, double[:, ::1] prog_v, + double[::1] prog_t, double[::1] prog_m, int[::1] nparticles): + cdef: + int i, j, k, n + int ntimes = len(prog_t) + int total_nparticles = (self._lead + self._trail) * np.sum(nparticles) + + double[:, ::1] particle_x = np.zeros((total_nparticles, 3)) + double[:, ::1] particle_v = np.zeros((total_nparticles, 3)) + double[::1] particle_t1 = np.zeros((total_nparticles, )) + + double[::1] tmp_x = np.zeros(3) + double[::1] tmp_v = np.zeros(3) + + double rj # jacobi radius + double vj # relative velocity at jacobi radius + double[:, ::1] R = np.zeros((3, 3)) # rotation to satellite coordinates + + # for Chen method: + double Dr + double Dv + double[::1] posvel = np.zeros(6) + double[::1] mean = np.zeros(6) + double[:, ::1] cov = np.zeros((6, 6)) + + CPotential* cpotential = ((potential.c_instance)).cpotential + double G = potential.G + + mean[0] = 1.6 # r + cov[0, 0] = 0.1225 + + mean[1] = -30. # phi + cov[1, 1] = 529. + + mean[2] = 0. # theta + cov[2, 2] = 144. + + mean[3] = 1. # v + cov[3, 3] = 0. + + mean[4] = 20. # alpha + cov[4, 4] = 400. + + mean[5] = 0. # beta + cov[5, 5] = 484. + + cov[0, 4] = -4.9 # covariance between r and alpha + cov[4, 0] = -4.9 + + j = 0 + for i in range(ntimes): + if prog_m[i] == 0: + continue + + self.get_rj_vj_R(cpotential, G, + &prog_x[i, 0], &prog_v[i, 0], prog_m[i], prog_t[i], + &rj, &vj, R) # outputs + + # trailing tail + if self._trail == 1: + for k in range(nparticles[i]): + # calculate the ejection position and velocity + posvel = self.random_state.multivariate_normal(mean, cov) + + Dr = posvel[0] * rj + Dv = posvel[3] * sqrt(2*G*prog_m[i]/Dr) # escape velocity + + # convert degrees to radians + posvel[1] = posvel[1] * (M_PI/180) + posvel[2] = posvel[2] * (M_PI/180) + posvel[4] = posvel[4] * (M_PI/180) + posvel[5] = posvel[5] * (M_PI/180) + + tmp_x[0] = Dr*cos(posvel[2])*cos(posvel[1]) + tmp_x[1] = Dr*cos(posvel[2])*sin(posvel[1]) + tmp_x[2] = Dr*sin(posvel[2]) + + tmp_v[0] = Dv*cos(posvel[5])*cos(posvel[4]) + tmp_v[1] = Dv*cos(posvel[5])*sin(posvel[4]) + tmp_v[2] = Dv*sin(posvel[5]) + + particle_t1[j+k] = prog_t[i] + + self.transform_from_sat(R, + &tmp_x[0], &tmp_v[0], + &prog_x[i, 0], &prog_v[i, 0], + &particle_x[j+k, 0], + &particle_v[j+k, 0]) + + j += nparticles[i] + + # Leading tail + if self._lead == 1: + for k in range(nparticles[i]): + # calculate the ejection position and velocity + posvel = self.random_state.multivariate_normal(mean, cov) + + Dr = posvel[0] * rj + Dv = posvel[3] * sqrt(2*G*prog_m[i]/Dr) # escape velocity + + # convert degrees to radians + posvel[1] = posvel[1] * (M_PI/180) + M_PI + posvel[2] = posvel[2] * (M_PI/180) + posvel[4] = posvel[4] * (M_PI/180) + M_PI + posvel[5] = posvel[5] * (M_PI/180) + + tmp_x[0] = Dr*cos(posvel[2])*cos(posvel[1]) + tmp_x[1] = Dr*cos(posvel[2])*sin(posvel[1]) + tmp_x[2] = Dr*sin(posvel[2]) + + tmp_v[0] = Dv*cos(posvel[5])*cos(posvel[4]) + tmp_v[1] = Dv*cos(posvel[5])*sin(posvel[4]) + tmp_v[2] = Dv*sin(posvel[5]) + + particle_t1[j+k] = prog_t[i] + + self.transform_from_sat(R, + &tmp_x[0], &tmp_v[0], + &prog_x[i, 0], &prog_v[i, 0], + &particle_x[j+k, 0], + &particle_v[j+k, 0]) + + j += nparticles[i] + + return particle_x, particle_v, particle_t1 diff --git a/gala/source/src/gala/dynamics/mockstream/mockstream.pyx b/gala/source/src/gala/dynamics/mockstream/mockstream.pyx new file mode 100644 index 0000000000000000000000000000000000000000..869020976e6566b8958d7895d9657cb3295ced48 --- /dev/null +++ b/gala/source/src/gala/dynamics/mockstream/mockstream.pyx @@ -0,0 +1,620 @@ +# cython: boundscheck=False +# cython: debug=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + +""" Generate mock streams. """ + + +import warnings +from os import path +import sys + + +import astropy.units as u +import numpy as np +cimport numpy as np +np.import_array() +from yaml import dump + +from libc.math cimport sqrt +from libc.stdlib cimport malloc, free +from cpython.exc cimport PyErr_CheckSignals + +from ...integrate.cyintegrators.dop853 cimport dop853_step, dop853_helper, Fwrapper_direct_nbody, FcnEqDiff +from ...integrate.cyintegrators.leapfrog cimport c_init_velocity_nbody, c_leapfrog_step_nbody +from ...integrate.cyintegrators.leapfrog import leapfrog_integrate_nbody +from ...potential.potential.cpotential cimport CPotentialWrapper, CPotential, c_gradient, c_nbody_gradient_symplectic +from ...potential.frame.cframe cimport CFrameWrapper, CFrameType +from ...potential.potential.builtin.cybuiltin import NullWrapper + +from ...potential import Hamiltonian +from ...potential.frame import StaticFrame +from ...io import quantity_to_hdf5 +from ...potential.potential.io import to_dict + +from .df cimport BaseStreamDF + +__all__ = ['mockstream_dop853', 'mockstream_dop853_animate', + 'mockstream_leapfrog', 'mockstream_leapfrog_animate'] + + +# ============================================================================== +# Helper functions for mockstream implementations + +cdef inline CPotential** _setup_particle_potentials( + nbody, int nbodies, int total_bodies, CPotential* null_p +) except NULL: + """ + Allocate and initialize particle potentials array. + + Returns pointer to array of CPotential pointers. The first nbodies entries + point to the massive body potentials, and the remaining entries point to + the null potential for test particles. + """ + cdef: + int i + CPotential **c_particle_potentials = NULL + + c_particle_potentials = malloc(total_bodies * sizeof(CPotential*)) + if c_particle_potentials == NULL: + raise MemoryError("Failed to allocate memory for particle potentials") + + # Set potentials for massive bodies + for i in range(nbodies): + c_particle_potentials[i] = ( + (nbody.particle_potentials[i].c_instance) + ).cpotential + + # Set null potentials for test particles + for i in range(nbodies, total_bodies): + c_particle_potentials[i] = null_p + + return c_particle_potentials + + +cdef inline void _report_progress(int i, int ntimes, int prog_out) noexcept nogil: + """Report integration progress to stdout.""" + if i % prog_out == 0: + with gil: + sys.stdout.write('\r') + sys.stdout.write( + f"Integrating orbits: {100 * i / ntimes: 3.0f}%") + sys.stdout.flush() + + +cdef inline void _finish_progress() noexcept: + """Finish progress reporting with 100%.""" + sys.stdout.write('\r') + sys.stdout.write(f"Integrating orbits: {100: 3.0f}%") + sys.stdout.flush() + + +cdef inline void _validate_time_arrays( + int ntimes, int stream_t1_size, int nstream_size +) except *: + """Validate that time-related arrays have consistent sizes.""" + if stream_t1_size != ntimes: + raise ValueError("stream_t1 must have the same length as time") + if nstream_size != ntimes: + raise ValueError("nstream must have the same length as time") + + +cdef _init_hdf5_file( + str output_filename, int overwrite, int check_filesize, + int noutput_times, int total_nstream, int nbodies, nbody +): + """ + Initialize HDF5 file for animation output. + + Returns h5py File object with stream and nbody groups and datasets. + """ + import h5py + + # Check file size estimate + est_filesize = total_nstream * noutput_times * 8 * u.byte + if est_filesize >= 8 * u.gigabyte and check_filesize: + warnings.warn( + "Estimated mockstream output file is expected to be " + ">8 GB in size! If you're sure, turn this warning " + "off with `check_filesize=False`" + ) + + # Check if file exists + if path.exists(output_filename) and overwrite == 0: + raise IOError( + f"Mockstream output file {output_filename} already exists! " + "Use overwrite=True to overwrite the file." + ) + + # Create file and groups + h5f = h5py.File(str(output_filename), 'w') + stream_g = h5f.create_group('stream') + nbody_g = h5f.create_group('nbody') + + # Create datasets for stream particles + d = stream_g.create_dataset( + 'pos', dtype='f8', + shape=(3, noutput_times, total_nstream), + fillvalue=np.nan, compression='gzip', + compression_opts=9 + ) + d.attrs['unit'] = str(nbody.units['length']) + + d = stream_g.create_dataset( + 'vel', dtype='f8', + shape=(3, noutput_times, total_nstream), + fillvalue=np.nan, compression='gzip', + compression_opts=9 + ) + d.attrs['unit'] = str(nbody.units['length'] / nbody.units['time']) + + # Create datasets for N-body particles + d = nbody_g.create_dataset( + 'pos', dtype='f8', + shape=(3, noutput_times, nbodies), + fillvalue=np.nan, compression='gzip', + compression_opts=9 + ) + d.attrs['unit'] = str(nbody.units['length']) + + d = nbody_g.create_dataset( + 'vel', dtype='f8', + shape=(3, noutput_times, nbodies), + fillvalue=np.nan, compression='gzip', + compression_opts=9 + ) + d.attrs['unit'] = str(nbody.units['length'] / nbody.units['time']) + + return h5f, stream_g, nbody_g + + +cpdef mockstream_dop853( + nbody, double[::1] time, + double[:, ::1] stream_w0, double[::1] stream_t1, + double tfinal, int[::1] nstream, + double atol=1E-10, double rtol=1E-10, int nmax=0, double dt_max=0.0, + int nstiff = -1, + int progress=0, + int err_if_fail=1, int log_output=0 +): + """ + Parameters + ---------- + nbody : `~gala.dynamics.nbody.DirectNBody` + time : numpy.ndarray (ntimes, ) + stream_w0 : numpy.ndarray (nstreamparticles, 6) + stream_t1 : numpy.ndarray (ntimes, ) + nstream : numpy.ndarray (ntimes, ) + The number of stream particles to be integrated from this timestep. + + Notes + ----- + In code, ``nbodies`` are the massive bodies included from the ``nbody`` + instance passed in. ``nstreamparticles`` are the stream test particles. + ``nstream`` is the array containing the number of stream particles released + at each timestep. + + """ + + cdef: + int i, j, k, n # indexing + unsigned ndim = 6 # TODO: hard-coded, but really must be 6D + void *args + CPotential **c_particle_potentials = NULL + + # Time-stepping parameters: + int ntimes = time.shape[0] + double dt0 = time[1] - time[0] + + CPotential* cp = ((nbody.H.potential.c_instance)).cpotential + CFrameType cf = ((nbody.H.frame.c_instance)).cframe + + # For test particles + CPotentialWrapper null_wrapper = NullWrapper(1., [], np.zeros(3), np.eye(3)) + CPotential* null_p = null_wrapper.cpotential + + int nbodies = nbody._c_w0.shape[0] + double [:, ::1] nbody_w0 = nbody._c_w0 + + int total_nstream = np.sum(nstream) + int total_bodies = nbodies + total_nstream + double[:, ::1] w_tmp = np.empty((total_bodies, ndim)) + double[:, ::1] w_final = np.empty((total_bodies, ndim)) + double[:, :, ::1] nbody_w = np.empty((ntimes, nbodies, ndim)) + + int prog_out = max(len(time) // 100, 1) + + # Validate input arrays + _validate_time_arrays(ntimes, stream_t1.shape[0], nstream.shape[0]) + + # Setup particle potentials + c_particle_potentials = _setup_particle_potentials( + nbody, nbodies, total_bodies, null_p + ) + args = (c_particle_potentials) + + # TODO: reconfigure this to use dense output? + + try: + + # First have to integrate the nbody orbits so we have their positions at + # each timestep + nbody_w = dop853_helper( + cp, &cf, + Fwrapper_direct_nbody, + nbody_w0, time, + ndim, nbodies, nbodies, args, ntimes, + atol, rtol, nmax, dt_max, + nstiff=nstiff, + err_if_fail=err_if_fail, log_output=log_output, save_all=1, + ) + + n = 0 + for i in range(ntimes): + if nstream[i] == 0: + continue + + # set initial conditions for progenitor and N-bodies + for j in range(nbodies): + for k in range(ndim): + w_tmp[j, k] = nbody_w[i, j, k] + + for j in range(nstream[i]): + for k in range(ndim): + w_tmp[nbodies+j, k] = stream_w0[n+j, k] + + dop853_step(cp, &cf, Fwrapper_direct_nbody, + &w_tmp[0, 0], stream_t1[i], tfinal, dt0, + ndim, nbodies+nstream[i], nbodies, args, + atol, rtol, nmax, nstiff=nstiff, + err_if_fail=err_if_fail, log_output=log_output) + + PyErr_CheckSignals() + + for j in range(nstream[i]): + for k in range(ndim): + w_final[nbodies+n+j, k] = w_tmp[nbodies+j, k] + + n += nstream[i] + + if progress == 1: + _report_progress(i, ntimes, prog_out) + + if progress == 1: + _finish_progress() + + for j in range(nbodies): + for k in range(ndim): + w_final[j, k] = w_tmp[j, k] + + return_nbody_w = np.array(w_final)[:nbodies] + return_stream_w = np.array(w_final)[nbodies:] + + return return_nbody_w, return_stream_w + + finally: + # Clean up allocated memory + if c_particle_potentials != NULL: + free(c_particle_potentials) + + +cpdef mockstream_dop853_animate(nbody, double[::1] t, + double[:, ::1] stream_w0, int[::1] nstream, + output_every=1, output_filename='', + overwrite=False, check_filesize=True, + double atol=1E-10, double rtol=1E-10, int nmax=0, + double dt_max=0.0, + int nstiff = -1, + int progress=0, + int err_if_fail=1, + int log_output=0): + """ + Parameters + ---------- + nbody : `~gala.dynamics.nbody.DirectNBody` + t : numpy.ndarray (ntimes, ) + stream_w0 : numpy.ndarray (nstreamparticles, 6) + nstream : numpy.ndarray (ntimes, ) + The number of stream particles to be integrated from this timestep. + There should be no zero values. + + Notes + ----- + In code, ``nbodies`` are the massive bodies included from the ``nbody`` + instance passed in. ``nstreamparticles`` are the stream test particles. + ``nstream`` is the array containing the number of stream particles released + at each timestep. + + """ + + cdef: + int i, j, k, n # indexing + unsigned ndim = 6 # TODO: hard-coded, but really must be 6D + void *args + CPotential **c_particle_potentials = NULL + + # Time-stepping parameters: + int ntimes = t.shape[0] + double dt0 = t[1] - t[0] + + CPotential* cp = ((nbody.H.potential.c_instance)).cpotential + CFrameType cf = ((nbody.H.frame.c_instance)).cframe + + int nbodies = nbody._c_w0.shape[0] + double [:, ::1] nbody_w0 = nbody._c_w0 + + int total_nstream = np.sum(nstream) + double[:, ::1] w = np.empty((nbodies + total_nstream, ndim)) + + # Snapshotting: + int noutput_times = (ntimes-1) // output_every + 1 + double[::1] output_times + + int prog_out = max(len(t) // 100, 1) + + if (ntimes-1) % output_every != 0: + noutput_times += 1 # +1 for final conditions + + output_times = np.zeros(noutput_times) + + # Initialize HDF5 output file + h5f, stream_g, nbody_g = _init_hdf5_file( + output_filename, overwrite, check_filesize, + noutput_times, total_nstream, nbodies, nbody + ) + + # Setup particle potentials (only need nbodies, not total_bodies for animate) + c_particle_potentials = malloc(nbodies * sizeof(CPotential*)) + if c_particle_potentials == NULL: + raise MemoryError("Failed to allocate memory for particle potentials") + + try: + # Set potentials for massive bodies + for i in range(nbodies): + c_particle_potentials[i] = ( + (nbody.particle_potentials[i].c_instance) + ).cpotential + args = (c_particle_potentials) + + # set initial conditions for progenitor and N-bodies + for j in range(nbodies): + for k in range(ndim): + w[j, k] = nbody_w0[j, k] + + for j in range(total_nstream): + for k in range(ndim): + w[nbodies+j, k] = stream_w0[j, k] + + n = nstream[0] + stream_g['pos'][:, 0, :n] = np.array(w[nbodies:nbodies+n, :]).T[:3] + stream_g['vel'][:, 0, :n] = np.array(w[nbodies:nbodies+n, :]).T[3:] + nbody_g['pos'][:, 0, :nbodies] = np.array(w[:nbodies, :]).T[:3] + nbody_g['vel'][:, 0, :nbodies] = np.array(w[:nbodies, :]).T[3:] + output_times[0] = t[0] + + j = 1 # output time index + for i in range(1, ntimes): + dop853_step(cp, &cf, Fwrapper_direct_nbody, + &w[0, 0], t[i-1], t[i], dt0, + ndim, nbodies+n, nbodies, args, + atol, rtol, nmax, nstiff=nstiff, + err_if_fail=err_if_fail, log_output=log_output) + + PyErr_CheckSignals() + + n += nstream[i] + + if (i % output_every) == 0 or i == ntimes-1: + output_times[j] = t[i] + stream_g['pos'][:, j, :n] = np.array(w[nbodies:nbodies+n, :]).T[:3] + stream_g['vel'][:, j, :n] = np.array(w[nbodies:nbodies+n, :]).T[3:] + nbody_g['pos'][:, j, :nbodies] = np.array(w[:nbodies, :]).T[:3] + nbody_g['vel'][:, j, :nbodies] = np.array(w[:nbodies, :]).T[3:] + j += 1 + + if progress == 1: + _report_progress(i, ntimes, prog_out) + + if progress == 1: + _finish_progress() + + for g in [stream_g, nbody_g]: + d = g.create_dataset('time', data=np.array(output_times)) + d.attrs['unit'] = str(nbody.units['time']) + + h5f.close() + + return_nbody_w = np.array(w)[:nbodies] + return_stream_w = np.array(w)[nbodies:] + + return return_nbody_w, return_stream_w + + finally: + # Clean up allocated memory + if c_particle_potentials != NULL: + free(c_particle_potentials) + +cpdef mockstream_leapfrog( + nbody, + double[::1] full_time, + double[::1] spawn_time, + double[:, ::1] stream_w0, double[::1] stream_t1, + double tfinal, int[::1] nstream, + int progress=0, + int err_if_fail=1 +): + """ + Leapfrog integration version of mockstream generation. + + Strategy: For each batch of stream particles released at time stream_t1[i], + integrate both the N-body particles AND the stream particles from stream_t1[i] + to tfinal. This ensures N-body particles are at the correct positions. + + Parameters + ---------- + nbody : `~gala.dynamics.nbody.DirectNBody` + full_time : numpy.ndarray (nfull, ) + Full time array for N-body integration (can have uniform or non-uniform spacing). + spawn_time : numpy.ndarray (ntimes, ) + Times at which stream particles are released (spawn times, subset of full_time). + stream_w0 : numpy.ndarray (nstreamparticles, 6) + Initial phase-space coordinates of stream particles at their spawn times. + stream_t1 : numpy.ndarray (ntimes, ) + Spawn times for each batch (should match spawn_time array). + tfinal : float + Final time for integration. + nstream : numpy.ndarray (ntimes, ) + The number of stream particles spawned at each time. + progress : int, optional + Show progress bar (default: 0). + err_if_fail : int, optional + Raise error if integration fails (default: 1). + + Notes + ----- + This follows the same pattern as mockstream_dop853: for each spawn time, + we integrate the N-body + new stream particles from spawn time to tfinal. + The N-body initial conditions come from using leapfrog_integrate_nbody to + pre-integrate them to all times in full_time, then we extract positions at spawn times. + + """ + + cdef: + int i, j, k, n, m # indexing + unsigned ndim = 6 # TODO: hard-coded, but really must be 6D + int half_ndim = ndim // 2 + CPotential **c_particle_potentials = NULL + + # Time-stepping parameters: + int ntimes = spawn_time.shape[0] + int nfull = full_time.shape[0] + double dt = full_time[1] - full_time[0] + + CPotential* cp = ((nbody.H.potential.c_instance)).cpotential + + # For test particles + CPotentialWrapper null_wrapper = NullWrapper(1., [], np.zeros(3), np.eye(3)) + CPotential* null_p = null_wrapper.cpotential + + int nbodies = nbody._c_w0.shape[0] + double [:, ::1] nbody_w0 = nbody._c_w0 + + int total_nstream = np.sum(nstream) + int total_bodies = nbodies + total_nstream + + # Working arrays for integration + double[:, ::1] w_tmp = np.empty((total_bodies, ndim)) + double[:, ::1] w_final = np.empty((total_bodies, ndim)) + double[:, :, ::1] nbody_w = np.empty((ntimes, nbodies, ndim)) + + # Leapfrog-specific arrays (half-step velocities) + double[:, ::1] v_jm1_2 = np.zeros((total_bodies, half_ndim)) + double[::1] grad = np.zeros(half_ndim) + + int n_steps + int prog_out = max(ntimes // 100, 1) + int last_i = -1 # Track last loop iteration that ran + + # Validate input arrays + _validate_time_arrays(ntimes, stream_t1.shape[0], nstream.shape[0]) + + # Setup particle potentials + c_particle_potentials = _setup_particle_potentials( + nbody, nbodies, total_bodies, null_p + ) + + # Use leapfrog_integrate_nbody to get N-body positions at all times in full_time + # Then extract positions at spawn times only + cdef: + int idx + + nbody_traj = leapfrog_integrate_nbody( + nbody.H, nbody_w0, full_time, nbody.particle_potentials, save_all=1 + ) + # nbody_traj returns (time, w) where w has shape (n_full_steps, nbodies, ndim) + full_nbody_traj = np.ascontiguousarray(nbody_traj[1]) + + # Extract N-body positions at spawn times by finding closest times in full_time + for i in range(ntimes): + # Find index in full_time closest to stream_t1[i] + # full_time starts at full_time[0], not necessarily at stream_t1[0] + idx = ((stream_t1[i] - full_time[0]) / dt + 0.5) + for j in range(nbodies): + for k in range(ndim): + nbody_w[i, j, k] = full_nbody_traj[idx, j, k] + + try: + + # For each spawn time, integrate N-body + stream particles to tfinal + n = 0 # Counter for total stream particles processed + for i in range(ntimes): + if nstream[i] == 0: + continue + + last_i = i # Update last iteration + + # Set initial conditions: N-bodies at release time + new stream particles + for j in range(nbodies): + for k in range(ndim): + w_tmp[j, k] = nbody_w[i, j, k] + + for j in range(nstream[i]): + for k in range(ndim): + w_tmp[nbodies+j, k] = stream_w0[n+j, k] + + # Initialize half-step velocities for this integration + with nogil: + for j in range(nbodies + nstream[i]): + for k in range(half_ndim): + grad[k] = 0. + c_init_velocity_nbody(cp, half_ndim, stream_t1[i], dt, + c_particle_potentials, &w_tmp[0, 0], nbodies, j, + &w_tmp[j, 0], &w_tmp[j, half_ndim], + &v_jm1_2[j, 0], &grad[0]) + + # Integrate from stream_t1[i] to tfinal + n_steps = ((tfinal - stream_t1[i]) / dt + 0.5) + with nogil: + for j in range(n_steps): + for k in range(nbodies + nstream[i]): + for m in range(half_ndim): + grad[m] = 0. + c_leapfrog_step_nbody(cp, half_ndim, stream_t1[i] + (j+1)*dt, dt, + c_particle_potentials, &w_tmp[0, 0], nbodies, k, + &w_tmp[k, 0], &w_tmp[k, half_ndim], + &v_jm1_2[k, 0], &grad[0]) + + PyErr_CheckSignals() + + # Save final stream particle state + for j in range(nstream[i]): + for k in range(ndim): + w_final[nbodies+n+j, k] = w_tmp[nbodies+j, k] + + n += nstream[i] + + if progress == 1: + _report_progress(i, ntimes, prog_out) + + if progress == 1: + _finish_progress() + + # The last loop iteration integrated to tfinal, so w_tmp contains N-body at tfinal + for j in range(nbodies): + for k in range(ndim): + w_final[j, k] = w_tmp[j, k] + + return_nbody_w = np.array(w_final)[:nbodies] + return_stream_w = np.array(w_final)[nbodies:] + + return return_nbody_w, return_stream_w + + finally: + # Clean up allocated memory + if c_particle_potentials != NULL: + free(c_particle_potentials) diff --git a/gala/source/src/gala/dynamics/mockstream/mockstream_generator.py b/gala/source/src/gala/dynamics/mockstream/mockstream_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..71fe86c90fc83cd07b388716e9a1c1f7cd7e9de2 --- /dev/null +++ b/gala/source/src/gala/dynamics/mockstream/mockstream_generator.py @@ -0,0 +1,372 @@ +import numpy as np + +from ...integrate.timespec import parse_time_specification +from ...potential import Hamiltonian, PotentialBase +from ..core import PhaseSpacePosition +from ..nbody import DirectNBody +from ..util import combine +from ._mockstream import ( + mockstream_dop853, + mockstream_dop853_animate, + mockstream_leapfrog, + # mockstream_leapfrog_animate, +) +from .core import MockStream + +__all__ = ["MockStreamGenerator"] + + +class MockStreamGenerator: + def __init__(self, df, hamiltonian, progenitor_potential=None): + """Generate a mock stellar stream in the specified external potential. + + By default, you must pass in a specification of the stream distribution + function (``df``), and the external gravitational potential and + reference frame (via a `~gala.potential.Hamiltonian` object passed in + through the ``hamiltonian`` argument). + + Also by default, the stream generation does not include the self-gravity + of the progenitor system: star particles are generated using the ``df`` + object, and released into the external potential specified by the + ``hamiltonian``. If you would like the star particles to feel the + gravitational field of the progenitor system, you may pass in a + potential object to represent the progenitor via the + ``progenitor_potential`` argument. This can be any valid gala potential + instance. + + Parameters + ---------- + df : `~gala.dynamics.BaseStreamDF` subclass instance + The stream distribution function (DF) object that specifies how to + generate stream star particle initial conditions. + hamiltonian : `~gala.potential.Hamiltonian` + The external potential and reference frame to numerically integrate + orbits in. + progenitor_potential : `~gala.potential.PotentialBase` (optional) + If specified, the self-gravity of the progenitor system is included + in the force calculation and orbit integration. If not specified, + self-gravity is not accounted for. Default: ``None`` + """ + from .df import BaseStreamDF + + if not isinstance(df, BaseStreamDF): + msg = ( + "The input distribution function (DF) instance " + "must be an instance of a subclass of " + f"BaseStreamDF, not {type(df)}." + ) + raise TypeError(msg) + self.df = df + + # Validate the inpute hamiltonian + self.hamiltonian = Hamiltonian(hamiltonian) + + if progenitor_potential is not None: + # validate the potential class + if not isinstance(progenitor_potential, PotentialBase): + raise TypeError( + "If specified, the progenitor_potential must be a gala.potential " + "class instance." + ) + + self.self_gravity = True + + else: + self.self_gravity = False + + self.progenitor_potential = progenitor_potential + + def _get_nbody(self, prog_w0, nbody): + """ + Internal function that adds the progenitor to the list of nbody objects to + integrate along with the test particles in the stream. + """ + + kwargs = {} + if nbody is not None: + if nbody.external_potential != self.hamiltonian.potential: + raise ValueError( + "The external potential of the input nbody instance must match the " + "potential of the mock stream input hamiltonian! " + f"{nbody.external_potential} vs. {self.hamiltonian.potential}" + ) + + if nbody.frame != self.hamiltonian.frame: + raise ValueError( + "The reference frame of the input nbody instance must match the " + "frame of the mock stream input hamiltonian! " + f"{nbody.frame} vs. {self.hamiltonian.frame}" + ) + + kwargs["w0"] = combine((prog_w0, nbody.w0)) + kwargs["particle_potentials"] = [ + self.progenitor_potential, + *nbody.particle_potentials, + ] + kwargs["external_potential"] = self.hamiltonian.potential + kwargs["frame"] = self.hamiltonian.frame + kwargs["units"] = self.hamiltonian.units + + else: + kwargs["w0"] = prog_w0 + kwargs["particle_potentials"] = [self.progenitor_potential] + kwargs["external_potential"] = self.hamiltonian.potential + kwargs["frame"] = self.hamiltonian.frame + kwargs["units"] = self.hamiltonian.units + + return DirectNBody(**kwargs) + + def run( + self, + prog_w0, + prog_mass, + nbody=None, + release_every=1, + n_particles=1, + output_every=None, + output_filename=None, + check_filesize=True, + overwrite=False, + progress=False, + Integrator=None, + Integrator_kwargs=None, + **time_spec, + ): + """ + Run the mock stream generator with the specified progenitor initial conditions. + + This method generates the mock stellar stream for the specified progenitor + system properties. The progenitor orbit is specified by passing in the initial + or final conditions ``prog_w0`` and by specifying time-stepping information via + the ``**time_spec`` keyword arguments. If the time-stepping specification + proceeds forward in time, ``prog_w0`` is interpreted as initial conditions and + the mock stream is generated forwards from this position. If the time-stepping + proceeds backwards in time, the progenitor orbit is first numerically integrated + backwards given the time-stepping information, then the stream is generated + forward from the past such that ``prog_w0`` becomes the final position of the + progenitor. + + Note that the stream generation also supports including other massive perturbers + that can gravitationally influence the stream stars. These other massive bodies + must be passed in as a `~gala.dynamics.DirectNBody` instance through the + ``nbody`` argument. The phase-space coordinates of the bodies, ``nbody.w0``, are + interpreted as initial or final conditions with the same logic as above. + + Parameters + ---------- + prog_w0 : `~gala.dynamics.PhaseSpacePosition` + The initial or final phase-space position of the progenitor system (see note + above). + prog_mass : `~astropy.units.Quantity` [mass] + The mass of the progenitor system, passed in to the stream distribution + function (df) ``.sample()`` method. This quantity sets the scale mass of the + particle release df, but not the mass of the progenitor potential used to + compute the self-gravity on the stream particles. + nbody : `~gala.dynamics.DirectNBody` (optional) + This allows specifying other massive perturbers (N-bodies) that can + gravitationally influence the stream star orbits. + release_every : int (optional) + Controls how often to release stream particles from each tail. Default: 1, + meaning release particles at each timestep. + n_particles : int, array_like (optional) + If an integer, this controls the number of particles to release in each tail + at each release timestep. Alternatively, you can pass in an array with the + same shape as the number of timesteps to release bursts of particles at + certain times (e.g., pericenter). + output_every : int (optional) + Controls whether to output snapshots of the stream particle orbits. This is + relative to the global time array. + output_filename : str (optional) + The path to the HDF5 file to be generated by the snapshotting. + check_filesize : bool (optional) + If True (the default value), this controls whether to check the estimated + size of the output file, and emits a warning if the file is >8GB in size. + overwrite : bool (optional) + Overwrite the output file if it exists. + progress : bool (optional) + Print a very basic progress bar while computing the stream. + Integrator : `~gala.integrate.Integrator`, str (optional) + Integrator class to use, or a string name like 'leapfrog', 'dopri853'. + Currently, only the `~gala.integrate.DOPRI853Integrator` and + `~gala.integrate.LeapfrogIntegrator` are supported. + Integrator_kwargs : dict (optional) + Any extra keyword arguments to pass to the integrator class + when initializing. For example, you can pass in the + ``atol`` and ``rtol`` keyword arguments to set the absolute and + relative tolerances for the DOPRI853 integrator. + **time_spec + Specification of how long to integrate. Most commonly, this is a timestep + ``dt`` and number of steps ``n_steps``, or a timestep ``dt``, initial time + ``t1``, and final time ``t2``. You may also pass in a time array with ``t``. + See documentation for `~gala.integrate.parse_time_specification` for more + information. + + Returns + ------- + stream_w : `~gala.dynamics.PhaseSpacePosition` + nbody_w : `~gala.dynamics.PhaseSpacePosition` + + """ + from gala.integrate import ( + DOPRI853Integrator, + LeapfrogIntegrator, + get_integrator, + # Ruth4Integrator, + ) + + if Integrator_kwargs is None: + Integrator_kwargs = {} + + if Integrator is None: + Integrator = DOPRI853Integrator + + # Validates and retrieves the integrator class from string name if needed + Integrator = get_integrator(Integrator) + + units = self.hamiltonian.units + t = parse_time_specification(units, **time_spec) + + prog_nbody = self._get_nbody(prog_w0, nbody) + nbody_orbits = prog_nbody.integrate_orbit( + t=t, Integrator=Integrator, Integrator_kwargs=Integrator_kwargs + ) + + # If the time stepping passed in is negative, assume this means that all + # of the initial conditions are at *end time*, and we first need to + # integrate them backwards before treating them as initial conditions + if t[1] < t[0]: + nbody_orbits = nbody_orbits[::-1] + + # TODO: this could be cleaned up... + nbody0 = DirectNBody( + nbody_orbits[0], + prog_nbody.particle_potentials, + external_potential=self.hamiltonian.potential, + frame=self.hamiltonian.frame, + units=units, + ) + + else: + nbody0 = prog_nbody + + # Note: assumes that this is an orbit not a psp, i.e. that save_all is True + prog_orbit = nbody_orbits[:, 0] # Note: Progenitor must be idx 0! + orbit_t = prog_orbit.t.decompose(units).value + + # Generate initial conditions from the DF + stream_w0 = self.df.sample( + prog_orbit, + prog_mass, + hamiltonian=self.hamiltonian, + release_every=release_every, + n_particles=n_particles, + ) + w0 = np.vstack( + ( + stream_w0.xyz.decompose(units).value, + stream_w0.v_xyz.decompose(units).value, + ) + ).T + w0 = np.ascontiguousarray(w0) + + unq_t1s, nstream = np.unique( + stream_w0.release_time.decompose(units).value, return_counts=True + ) + + all_nstream = np.zeros(prog_orbit.ntimes, dtype=int) + for t1, n in zip(unq_t1s, nstream): + all_nstream[np.isclose(orbit_t, t1)] = n + + nstream_idx = np.where(all_nstream != 0)[0] + if 0 not in nstream_idx: + nstream_idx = np.insert(nstream_idx, 0, 0) + unq_t1s = np.insert(unq_t1s, 0, orbit_t[0]) + + if Integrator == DOPRI853Integrator: + if output_every is None: + raw_nbody, raw_stream = mockstream_dop853( + nbody0, + orbit_t[nstream_idx], + w0, + unq_t1s, + orbit_t[-1], + all_nstream[nstream_idx].astype("i4"), + progress=int(progress), + **Integrator_kwargs, + ) + else: # store snapshots + if output_filename is None: + raise ValueError( + "If output_every is specified, you must also pass in a " + "filename to store the snapshots in" + ) + + raw_nbody, raw_stream = mockstream_dop853_animate( + nbody0, + orbit_t, + w0, + all_nstream.astype("i4"), + output_every=output_every, + output_filename=output_filename, + check_filesize=check_filesize, + overwrite=overwrite, + progress=int(progress), + **Integrator_kwargs, + ) + + elif Integrator == LeapfrogIntegrator: + if output_every is None: + raw_nbody, raw_stream = mockstream_leapfrog( + nbody0, + orbit_t, # Pass full time array for N-body integration + orbit_t[nstream_idx], # Spawn times only + w0, + unq_t1s, + orbit_t[-1], + all_nstream[nstream_idx].astype("i4"), + progress=int(progress), + ) + else: # store snapshots + if output_filename is None: + raise ValueError( + "If output_every is specified, you must also pass in a filename to " + "store the snapshots in" + ) + raise NotImplementedError( + "Animation output for LeapfrogIntegrator is not implemented" + ) + # raw_nbody, raw_stream = mockstream_leapfrog_animate( + # nbody0, + # orbit_t, + # w0, + # all_nstream.astype("i4"), + # output_every=output_every, + # output_filename=output_filename, + # check_filesize=check_filesize, + # overwrite=overwrite, + # progress=int(progress), + # ) + else: + raise ValueError( + "Currently, only the DOPRI853Integrator and LeapfrogIntegrator " + "are supported for mock stream generation." + ) + + x_unit = units["length"] + v_unit = units["length"] / units["time"] + stream_w = MockStream( + pos=raw_stream[:, :3].T * x_unit, + vel=raw_stream[:, 3:].T * v_unit, + release_time=stream_w0.release_time, + lead_trail=stream_w0.lead_trail, + frame=self.hamiltonian.frame, + copy=False, + ) + nbody_w = PhaseSpacePosition( + pos=raw_nbody[:, :3].T * x_unit, + vel=raw_nbody[:, 3:].T * v_unit, + frame=self.hamiltonian.frame, + copy=False, + ) + + return stream_w, nbody_w diff --git a/gala/source/src/gala/dynamics/nbody/__init__.py b/gala/source/src/gala/dynamics/nbody/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..479aacc3aeb515c6cc463cb17be1084676fe647c --- /dev/null +++ b/gala/source/src/gala/dynamics/nbody/__init__.py @@ -0,0 +1 @@ +from .core import DirectNBody diff --git a/gala/source/src/gala/dynamics/nbody/core.py b/gala/source/src/gala/dynamics/nbody/core.py new file mode 100644 index 0000000000000000000000000000000000000000..e8ddc3e07f391f47db768cbb7e7aa6de730e1ad1 --- /dev/null +++ b/gala/source/src/gala/dynamics/nbody/core.py @@ -0,0 +1,282 @@ +# cython: boundscheck=False +# cython: debug=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False + + +import numpy as np + +from ...integrate.cyintegrators.leapfrog import leapfrog_integrate_nbody +from ...integrate.cyintegrators.ruth4 import ruth4_integrate_nbody +from ...integrate.timespec import parse_time_specification +from ...potential import Hamiltonian, NullPotential, StaticFrame +from ...units import UnitSystem +from ...util import atleast_2d +from ..core import PhaseSpacePosition +from ..orbit import Orbit +from .nbody import direct_nbody_dop853, nbody_acceleration + +__all__ = ["DirectNBody"] + + +class DirectNBody: + def __init__( + self, + w0, + particle_potentials, + external_potential=None, + frame=None, + units=None, + save_all=True, + ): + """Perform orbit integration using direct N-body forces between + particles, optionally in an external background potential. + + TODO: could add another option, like in other contexts, for + "extra_force" to support, e.g., dynamical friction + + Parameters + ---------- + w0 : `~gala.dynamics.PhaseSpacePosition` + The particle initial conditions. + partcle_potentials : list + List of potential objects to add mass or mass distributions to the + particles. Use ``None`` to treat particles as test particles. + external_potential : `~gala.potential.PotentialBase` subclass instance (optional) + The background or external potential to integrate the particle + orbits in. + frame : :class:`~gala.potential.frame.FrameBase` subclass (optional) + The reference frame to perform integratiosn in. + units : `~gala.units.UnitSystem` (optional) + Set of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + save_all : bool (optional) + Save the full orbits of each particle. If ``False``, only returns + the final phase-space positions of each particle. + + """ + if not isinstance(w0, PhaseSpacePosition): + msg = ( + "Initial conditions `w0` must be a " + "gala.dynamics.PhaseSpacePosition object, " + f"not '{w0.__class__.__name__}'" + ) + raise TypeError(msg) + + if len(w0.shape) > 0 and w0.shape[0] != len(particle_potentials): + raise ValueError( + "The number of initial conditions in `w0` must" + " match the number of particle potentials " + "passed in with `particle_potentials`." + ) + + # First, figure out how to get units - first place to check is the arg + if units is None: + # Next, check the particle potentials + for pp in particle_potentials: + if pp is not None: + units = pp.units + break + + # If units is still none, and external_potential is defined, use that: + if units is None and external_potential is not None: + units = external_potential.units + + # Now, if units are still None, raise an error! + if units is None: + raise ValueError( + "Could not determine units from input! You must " + "either (1) pass in the unit system with `units`," + "(2) set the units on one of the " + "particle_potentials, OR (3) pass in an " + "`external_potential` with valid units." + ) + if not isinstance(units, UnitSystem): + units = UnitSystem(units) + + # Now that we have the unit system, enforce that all potentials are in + # that system: + particle_potentials_ = [] + for pp in particle_potentials: + pp = NullPotential(units=units) if pp is None else pp.replace_units(units) + particle_potentials_.append(pp) + + if external_potential is None: + external_potential = NullPotential(units=units) + else: + external_potential = external_potential.replace_units(units) + + if frame is None: + frame = StaticFrame(units) + + self.units = units + self.external_potential = external_potential + self.frame = frame + self.particle_potentials = particle_potentials_ + self.save_all = save_all + + self.H = Hamiltonian(self.external_potential, frame=self.frame) + if not self.H.c_enabled: + raise ValueError( + "Input potential must be C-enabled: one or more " + "components in the input external potential are " + "Python-only." + ) + + self.w0 = w0 + + @property + def w0(self): + return self._w0 + + @w0.setter + def w0(self, value): + self._w0 = value + self._cache_w0() + + def _cache_w0(self): + # cache the position and velocity / prepare the initial conditions + self._pos = atleast_2d(self.w0.xyz.decompose(self.units).value, insert_axis=1) + self._vel = atleast_2d(self.w0.v_xyz.decompose(self.units).value, insert_axis=1) + self._c_w0 = np.ascontiguousarray(np.vstack((self._pos, self._vel)).T) + + def __repr__(self): + if self.w0.shape: + return f"<{self.__class__.__name__} bodies={self.w0.shape[0]}>" + return f"<{self.__class__.__name__} bodies=1>" + + def _nbody_acceleration(self, t=0.0): + """ + Compute the N-body acceleration at the location of each body + """ + nbody_acc = nbody_acceleration(self._c_w0, t, self.particle_potentials) + return nbody_acc.T + + def acceleration(self, t=0.0): + """ + Compute the acceleration at the location of each N body, including the + external potential. + """ + nbody_acc = self._nbody_acceleration(t=t) * self.units["acceleration"] + ext_acc = self.external_potential.acceleration(self.w0, t=t) + return nbody_acc + ext_acc + + def integrate_orbit(self, Integrator=None, Integrator_kwargs=None, **time_spec): + """ + Integrate the initial conditions in the combined external potential + plus N-body forces. + + This integration uses the `~gala.integrate.DOPRI853Integrator`. + + Parameters + ---------- + Integrator : `~gala.integrate.Integrator`, str (optional) + Integrator class to use, or a string name like 'leapfrog', 'dopri853', + 'ruth4'. + Integrator_kwargs : dict (optional) + Any extra keyword arguments to pass to the integrator class + when initializing. For example, you can pass in the + ``atol`` and ``rtol`` keyword arguments to set the absolute and + relative tolerances for the DOPRI853 integrator. + **time_spec + Specification of how long to integrate. See documentation + for `~gala.integrate.parse_time_specification`. + + Returns + ------- + orbit : `~gala.dynamics.Orbit` + The orbits of the particles. + + """ + from gala.integrate import ( + DOPRI853Integrator, + LeapfrogIntegrator, + Ruth4Integrator, + get_integrator, + ) + + if Integrator_kwargs is None: + Integrator_kwargs = {} + if Integrator is None: + Integrator = DOPRI853Integrator + + # Validates and retrieves the integrator class from string name if needed + Integrator = get_integrator(Integrator) + + # Prepare the time-stepping array + t = parse_time_specification(self.units, **time_spec) + + # Reorganize orbits so that massive bodies are first: + front_idx = [] + front_pp = [] + end_idx = [] + end_pp = [] + for i, pp in enumerate(self.particle_potentials): + if not isinstance(pp, NullPotential): + front_idx.append(i) + front_pp.append(pp) + else: + end_idx.append(i) + end_pp.append(pp) + idx = np.array(front_idx + end_idx) + pps = front_pp + end_pp + + reorg_w0 = np.ascontiguousarray(self._c_w0[idx]) + + if Integrator == LeapfrogIntegrator: + _, ws = leapfrog_integrate_nbody( + self.H, + reorg_w0, + t, + pps, + save_all=int(self.save_all), + **Integrator_kwargs, + ) + elif Integrator == Ruth4Integrator: + _, ws = ruth4_integrate_nbody( + self.H, + reorg_w0, + t, + pps, + save_all=int(self.save_all), + **Integrator_kwargs, + ) + elif Integrator == DOPRI853Integrator: + ws = direct_nbody_dop853( + reorg_w0, t, self.H, pps, save_all=self.save_all, **Integrator_kwargs + ) + else: + raise NotImplementedError( + f"N-body integration is currently not supported with the {Integrator} " + "integrator class" + ) + + if self.save_all: + pos = np.rollaxis(np.array(ws[..., :3]), axis=2) # should this be axis=-1? + vel = np.rollaxis(np.array(ws[..., 3:]), axis=2) + + orbits = Orbit( + pos=pos * self.units["length"], + vel=vel * self.units["length"] / self.units["time"], + t=t * self.units["time"], + hamiltonian=self.H, + copy=False, + ) + + else: + pos = np.array(ws[..., :3]).T + vel = np.array(ws[..., 3:]).T + + orbits = PhaseSpacePosition( + pos=pos * self.units["length"], + vel=vel * self.units["length"] / self.units["time"], + frame=self.frame, + copy=False, + ) + + # Reorder orbits to original order: + undo_idx = np.argsort(idx) + + return orbits[..., undo_idx] diff --git a/gala/source/src/gala/dynamics/nbody/nbody.pxd b/gala/source/src/gala/dynamics/nbody/nbody.pxd new file mode 100644 index 0000000000000000000000000000000000000000..aed13ec3624a170b6d402b4f7bc6073ea8ae2e6e --- /dev/null +++ b/gala/source/src/gala/dynamics/nbody/nbody.pxd @@ -0,0 +1,2 @@ +# cython: language_level=3 +# cython: language=c++ diff --git a/gala/source/src/gala/dynamics/nbody/nbody.pyx b/gala/source/src/gala/dynamics/nbody/nbody.pyx new file mode 100644 index 0000000000000000000000000000000000000000..94a93b99e48289aa9158c30e81f6f5db9426985c --- /dev/null +++ b/gala/source/src/gala/dynamics/nbody/nbody.pyx @@ -0,0 +1,159 @@ +# cython: boundscheck=False +# cython: debug=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + + +import warnings + + +from astropy.constants import G +import astropy.units as u + +import numpy as np +cimport numpy as np +np.import_array() + +from libc.math cimport sqrt +from libc.stdlib cimport malloc, free +from cpython.exc cimport PyErr_CheckSignals + +from ...potential import Hamiltonian, NullPotential +from ...potential.potential.cpotential cimport CPotentialWrapper, CPotential, c_nbody_acceleration +from ...potential.frame.cframe cimport CFrameWrapper, CFrameType +from ...integrate.cyintegrators.dop853 cimport dop853_helper, FcnEqDiff, Fwrapper_direct_nbody + +cpdef direct_nbody_dop853( + double [:, ::1] w0, double[::1] t, + hamiltonian, list particle_potentials, + save_all=True, + double atol=1E-10, double rtol=1E-10, int nmax=0, double dt_max=0.0, + int err_if_fail=1, int log_output=0 +): + """Integrate orbits from initial conditions ``w0`` over the time grid ``t`` + using direct N-body force calculation in the external potential provided via + the ``hamiltonian`` argument. + + The potential objects for each set of initial conditions must be C-enabled + (i.e., must be ``CPotentialBase`` subclasses), and the total number of + potential objects must equal the number of initial conditions. + + By default, this integration procedure stores the full time series of all + orbits, but this may use a lot of memory. If you just want to store the + final state of the orbits, pass ``save_all=False``. + + NOTE: This assumes that all massive bodies are organized at the start of w0 and + particle_potentials, and all test particles are *after* the massive bodies. + """ + cdef: + unsigned nparticles = w0.shape[0] + unsigned nbody = 0 + unsigned ndim = w0.shape[1] + unsigned ntimes = len(t) + + int i + void *args + CPotential **c_particle_potentials = NULL + CPotential* cp = ((hamiltonian.potential.c_instance)).cpotential + CFrameType cf = ((hamiltonian.frame.c_instance)).cframe + + double[:, :, ::1] all_w + double[:, ::1] final_w + + # Some input validation: + if not isinstance(hamiltonian, Hamiltonian): + raise TypeError( + f"Input must be a Hamiltonian object, not {type(hamiltonian)}") + + if not hamiltonian.c_enabled: + raise TypeError( + "Input Hamiltonian object does not support C-level access.") + + if len(particle_potentials) != nparticles: + raise ValueError( + "The number of particle initial conditions must match the number " + f"of particle potentials passed in ({nparticles} vs. " + f"{len(particle_potentials)}).") + + for pot in particle_potentials: + if not isinstance(pot, NullPotential): + nbody += 1 + + # Dynamically allocate memory for particle potentials + c_particle_potentials = malloc(nparticles * sizeof(CPotential*)) + if c_particle_potentials == NULL: + raise MemoryError("Failed to allocate memory for particle potentials") + + try: + # Extract the CPotential objects from the particle potentials. + for i in range(nparticles): + c_particle_potentials[i] = ((particle_potentials[i].c_instance)).cpotential + + # We need a void pointer for any other arguments + args = (c_particle_potentials) + + w = dop853_helper( + cp, &cf, + Fwrapper_direct_nbody, + w0, t, + ndim, nparticles, nbody, args, + ntimes, + atol, rtol, nmax, dt_max, + nstiff=-1, # disable stiffness check - TODO: note somewhere + err_if_fail=err_if_fail, log_output=log_output, + save_all=save_all, + ) + return w + + finally: + # Clean up allocated memory + if c_particle_potentials != NULL: + free(c_particle_potentials) + + +cpdef nbody_acceleration(double [:, ::1] w0, double t, + list particle_potentials): + """ + Computes the N-body acceleration on a set of bodies at phase-space + positions w0. + """ + cdef: + unsigned nparticles = w0.shape[0] + unsigned ps_ndim = w0.shape[1] + unsigned ndim = ps_ndim // 2 + + int i + CPotential **c_particle_potentials = NULL + double[:, ::1] acc = np.zeros((nparticles, ps_ndim)) + + # Some input validation: + if len(particle_potentials) != nparticles: + raise ValueError( + "The number of particle initial conditions must match the number " + f"of particle potentials passed in ({nparticles} vs. " + f"{len(particle_potentials)}).") + + # Dynamically allocate memory for particle potentials + c_particle_potentials = malloc(nparticles * sizeof(CPotential*)) + if c_particle_potentials == NULL: + raise MemoryError("Failed to allocate memory for particle potentials") + + try: + # Extract the CPotential objects from the particle potentials. + for i in range(nparticles): + c_particle_potentials[i] = ((particle_potentials[i].c_instance)).cpotential + + c_nbody_acceleration(c_particle_potentials, t, &w0[0, 0], + nparticles, nparticles, ndim, &acc[0, 0]) + + # NOTES: Just the acceleration, does not handle frames + return np.asarray(acc)[:, ndim:] + + finally: + # Clean up allocated memory + if c_particle_potentials != NULL: + free(c_particle_potentials) diff --git a/gala/source/src/gala/dynamics/nonlinear.py b/gala/source/src/gala/dynamics/nonlinear.py new file mode 100644 index 0000000000000000000000000000000000000000..721dff91837c25b9933a464fbe4e641505172a14 --- /dev/null +++ b/gala/source/src/gala/dynamics/nonlinear.py @@ -0,0 +1,340 @@ +import astropy.units as u +import numpy as np +from scipy.signal import argrelmin + +from .core import PhaseSpacePosition +from .orbit import Orbit + +__all__ = ["fast_lyapunov_max", "lyapunov_max", "surface_of_section"] + + +def fast_lyapunov_max( + w0, + hamiltonian, + dt, + n_steps, + d0=1e-5, + n_steps_per_pullback=10, + noffset_orbits=2, + t1=0.0, + atol=1e-10, + rtol=1e-10, + nmax=0, + return_orbit=True, +): + """ + Compute the maximum Lyapunov exponent using a fast C-implemented method. + + This function estimates the maximum Lyapunov exponent by integrating + the orbit along with several nearby offset orbits and tracking their + separation over time. It uses the DOPRI853 integrator for high accuracy. + + Parameters + ---------- + w0 : :class:`~gala.dynamics.PhaseSpacePosition` or array_like + Initial conditions for the primary orbit. + hamiltonian : :class:`~gala.potential.Hamiltonian` + The Hamiltonian system to integrate in. Must contain a C-implemented + potential and frame. + dt : float + Integration timestep. + n_steps : int + Number of integration steps to run. + d0 : float, optional + Initial separation between the primary orbit and offset orbits. + Default is 1e-5. + n_steps_per_pullback : int, optional + Number of integration steps between each renormalization of the + offset vectors. Default is 10. + noffset_orbits : int, optional + Number of offset orbits to use. Default is 2. + t1 : float, optional + Initial time. Default is 0.0. + atol : float, optional + Absolute tolerance for the integrator. Default is 1e-10. + rtol : float, optional + Relative tolerance for the integrator. Default is 1e-10. + nmax : int, optional + Maximum number of function evaluations. Default is 0 (no limit). + return_orbit : bool, optional + Whether to return the integrated orbit along with the Lyapunov + exponent. Default is True. + + Returns + ------- + LEs : :class:`~astropy.units.Quantity` + The Lyapunov exponents calculated from each offset orbit. + orbit : :class:`~gala.dynamics.Orbit`, optional + The integrated primary orbit (returned only if ``return_orbit=True``). + + Raises + ------ + TypeError + If the Hamiltonian does not contain C-implemented components. + ValueError + If trying to compute Lyapunov exponents for multiple orbits + simultaneously. + + Notes + ----- + The Lyapunov exponent quantifies the rate of exponential divergence + of nearby trajectories in phase space. Positive values indicate + chaotic motion, while zero or negative values suggest regular motion. + + This implementation is optimized for speed and uses C code with the + DOPRI853 adaptive Runge-Kutta integrator. + """ + from gala.potential import PotentialBase + + from .lyapunov import dop853_lyapunov_max, dop853_lyapunov_max_dont_save + + # TODO: remove in v1.0 + if isinstance(hamiltonian, PotentialBase): + from ..potential import Hamiltonian + + hamiltonian = Hamiltonian(hamiltonian) + + if not hamiltonian.c_enabled: + raise TypeError( + "Input Hamiltonian must contain a C-implemented potential and frame." + ) + + if not isinstance(w0, PhaseSpacePosition): + w0 = np.asarray(w0) + ndim = w0.shape[0] // 2 + w0 = PhaseSpacePosition(pos=w0[:ndim], vel=w0[ndim:], copy=False) + + w0_ = np.squeeze(w0.w(hamiltonian.units)) + if w0_.ndim > 1: + raise ValueError("Can only compute fast Lyapunov exponent for a single orbit.") + + if return_orbit: + t, w, l = dop853_lyapunov_max( + hamiltonian, + w0_, + dt, + n_steps + 1, + t1, + d0, + n_steps_per_pullback, + noffset_orbits, + atol, + rtol, + nmax, + ) + w = np.rollaxis(w, -1) + + try: + tunit = hamiltonian.units["time"] + except (TypeError, AttributeError): + tunit = u.dimensionless_unscaled + + orbit = Orbit.from_w( + w=w, + units=hamiltonian.units, + t=t * tunit, + hamiltonian=hamiltonian, + copy=False, + ) + return l / tunit, orbit + + l = dop853_lyapunov_max_dont_save( + hamiltonian, + w0_, + dt, + n_steps + 1, + t1, + d0, + n_steps_per_pullback, + noffset_orbits, + atol, + rtol, + nmax, + ) + + try: + tunit = hamiltonian.units["time"] + except (TypeError, AttributeError): + tunit = u.dimensionless_unscaled + + return l / tunit + + +def lyapunov_max( + w0, + integrator, + dt, + n_steps, + d0=1e-5, + n_steps_per_pullback=10, + noffset_orbits=8, + t1=0.0, + units=None, + rng=None, +): + """ + + Compute the maximum Lyapunov exponent of an orbit by integrating many + nearby orbits (``noffset``) separated with isotropically distributed + directions but the same initial deviation length, ``d0``. This algorithm + re-normalizes the offset orbits every ``n_steps_per_pullback`` steps. + + Parameters + ---------- + w0 : `~gala.dynamics.PhaseSpacePosition`, array_like + Initial conditions. + integrator : `~gala.integrate.Integrator` + An instantiated `~gala.integrate.Integrator` object. Must have a run() method. + dt : numeric + Timestep. + n_steps : int + Number of steps to run for. + d0 : numeric (optional) + The initial separation. + n_steps_per_pullback : int (optional) + Number of steps to run before re-normalizing the offset vectors. + noffset_orbits : int (optional) + Number of offset orbits to run. + t1 : numeric (optional) + Time of initial conditions. Assumed to be t=0. + units : `~gala.units.UnitSystem` (optional) + If passing in an array (not a `~gala.dynamics.PhaseSpacePosition`), + you must specify a unit system. + rng : `numpy.random.Generator` (optional) + If provided, will use this random number generator to generate the + initial offset vectors. If not provided, will use the default + ``numpy.random.default_rng()``. + + Returns + ------- + LEs : :class:`~astropy.units.Quantity` + Lyapunov exponents calculated from each offset / deviation orbit. + orbit : `~gala.dynamics.Orbit` + """ + + if units is not None: + pos_unit = units["length"] + vel_unit = units["length"] / units["time"] + else: + pos_unit = u.dimensionless_unscaled + vel_unit = u.dimensionless_unscaled + + if not isinstance(w0, PhaseSpacePosition): + w0 = np.asarray(w0) + ndim = w0.shape[0] // 2 + w0 = PhaseSpacePosition( + pos=w0[:ndim] * pos_unit, vel=w0[ndim:] * vel_unit, copy=False + ) + + w0_ = w0.w(units) + ndim = 2 * w0.ndim + + # number of iterations + niter = n_steps // n_steps_per_pullback + + # define offset vectors to start the offset orbits on + rng = rng or np.random.default_rng() + d0_vec = rng.uniform(size=(ndim, noffset_orbits)) + d0_vec /= np.linalg.norm(d0_vec, axis=0)[np.newaxis] + d0_vec *= d0 + + w_offset = w0_ + d0_vec + all_w0 = np.hstack((w0_, w_offset)) + + # array to store the full, main orbit + full_w = np.zeros((ndim, n_steps + 1, noffset_orbits + 1)) + full_w[:, 0] = all_w0 + full_ts = np.zeros((n_steps + 1,)) + full_ts[0] = t1 + + # arrays to store the Lyapunov exponents and times + LEs = np.zeros((niter, noffset_orbits)) + ts = np.zeros_like(LEs) + time = t1 + total_steps_taken = 0 + for i in range(1, niter + 1): + ii = i * n_steps_per_pullback + + orbit = integrator(all_w0, dt=dt, n_steps=n_steps_per_pullback, t1=time) + tt = orbit.t.value + ww = orbit.w(units) + time += dt * n_steps_per_pullback + + main_w = ww[:, -1, 0:1] + d1 = ww[:, -1, 1:] - main_w + d1_mag = np.linalg.norm(d1, axis=0) + + LEs[i - 1] = np.log(d1_mag / d0) + ts[i - 1] = time + + w_offset = ww[:, -1, 0:1] + d0 * d1 / d1_mag[np.newaxis] + all_w0 = np.hstack((ww[:, -1, 0:1], w_offset)) + + full_w[:, (i - 1) * n_steps_per_pullback + 1 : ii + 1] = ww[:, 1:] + full_ts[(i - 1) * n_steps_per_pullback + 1 : ii + 1] = tt[1:] + + total_steps_taken += n_steps_per_pullback + + LEs = np.array([LEs[:ii].sum(axis=0) / ts[ii - 1] for ii in range(1, niter)]) + + try: + t_unit = units["time"] + except (TypeError, AttributeError): + t_unit = u.dimensionless_unscaled + + orbit = Orbit.from_w( + w=full_w[:, :total_steps_taken], + units=units, + t=full_ts[:total_steps_taken] * t_unit, + ) + return LEs / t_unit, orbit + + +def surface_of_section(orbit, constant_idx, constant_val=0.0): + """ + Generate and return a surface of section from the given orbit. + + Parameters + ---------- + orbit : `~gala.dynamics.Orbit` + The input orbit to generate a surface of section for. + constant_idx : int + Integer that represents the coordinate to record crossings in. For + example, for a 2D Hamiltonian where you want to make a SoS in + :math:`y-p_y`, you would specify ``constant_idx=0`` (crossing the + :math:`x` axis), and this will only record crossings for which + :math:`p_x>0`. + + Returns + ------- + sos : numpy ndarray + + TODO: + - Implement interpolation to get the other phase-space coordinates truly + at the plane, instead of just at the orbital position closest to the + plane. + + """ + + if orbit.norbits > 1: + raise NotImplementedError("Not yet implemented, sorry!") + + w = [getattr(orbit, x) for x in orbit.pos_components] + [ + getattr(orbit, v) for v in orbit.vel_components + ] + + ndim = orbit.ndim + + p_ix = constant_idx + ndim + + # record position on specified plane when orbit crosses + cross_idx = argrelmin((w[constant_idx] - constant_val) ** 2)[0] + cross_idx = cross_idx[w[p_ix][cross_idx] > 0.0] + + sos_pos = [w[i][cross_idx] for i in range(ndim)] + sos_pos = orbit.pos.__class__(*sos_pos) + + sos_vel = [w[i][cross_idx] for i in range(ndim, 2 * ndim)] + sos_vel = orbit.vel.__class__(*sos_vel) + + return Orbit(sos_pos, sos_vel, copy=False) diff --git a/gala/source/src/gala/dynamics/orbit.py b/gala/source/src/gala/dynamics/orbit.py new file mode 100644 index 0000000000000000000000000000000000000000..1f9b0a2e76ef0574020e4b3a18eb811743a97e9f --- /dev/null +++ b/gala/source/src/gala/dynamics/orbit.py @@ -0,0 +1,1384 @@ +import importlib +import warnings + +import astropy.coordinates as coord +import astropy.table as at +import astropy.units as u +import numpy as np +from scipy.signal import argrelmax + +from gala.logging import logger + +from ..io import quantity_from_hdf5, quantity_to_hdf5 +from ..units import DimensionlessUnitSystem, UnitSystem, dimensionless +from ..util import atleast_2d +from .core import PhaseSpacePosition, _get_rep_name +from .plot import plot_projections +from .util import peak_to_peak_period + +__all__ = ["Orbit"] + + +class Orbit(PhaseSpacePosition): + """ + Represents an orbit: positions and velocities (conjugate momenta) as a + function of time. + + The class can be instantiated with Astropy representation objects (e.g., + :class:`~astropy.coordinates.CartesianRepresentation`), Astropy + :class:`~astropy.units.Quantity` objects, or plain Numpy arrays. + + If passing in Quantity or Numpy array instances for both position and + velocity, they are assumed to be Cartesian. Array inputs are interpreted as + dimensionless quantities. The input position and velocity objects can have + an arbitrary number of (broadcastable) dimensions. For Quantity or array + inputs, the first axes have special meaning: + + - ``axis=0`` is the coordinate dimension (e.g., x, y, z) + - ``axis=1`` is the time dimension + + So if the input position array, ``pos``, has shape ``pos.shape = (3, 100)``, + this would be a 3D orbit at 100 times (``pos[0]`` is ``x``, ``pos[1]``` is + ``y``, etc.). For representing multiple orbits, the position array could + have 3 axes, e.g., it might have shape `pos.shape = (3, 100, 8)`, where this + is interpreted as a 3D position at 100 times for 8 different orbits. The + same is true for velocity. The position and velocity arrays must have the + same shape. + + If a time argument is specified, the position and velocity arrays must have + the same number of timesteps as the length of the time object:: + + len(t) == pos.shape[1] + + Parameters + ---------- + pos : representation, quantity_like, or array_like + Positions. If a numpy array (e.g., has no units), this will be + stored as a dimensionless :class:`~astropy.units.Quantity`. See + the note above about the assumed meaning of the axes of this object. + vel : differential, quantity_like, or array_like + Velocities. If a numpy array (e.g., has no units), this will be + stored as a dimensionless :class:`~astropy.units.Quantity`. See + the note above about the assumed meaning of the axes of this object. + t : array_like, :class:`~astropy.units.Quantity` (optional) + Array of times. If a numpy array (e.g., has no units), this will be + stored as a dimensionless :class:`~astropy.units.Quantity`. + hamiltonian : `~gala.potential.Hamiltonian` (optional) + The Hamiltonian that the orbit was integrated in. + copy : bool, optional + If `True`, the input arrays are copied. If `False`, the input data + is referenced directly (if possible). Default is `True`. + + """ + + def __init__( + self, pos, vel, t=None, hamiltonian=None, potential=None, frame=None, copy=True + ): + super().__init__(pos=pos, vel=vel, copy=copy) + + if self.pos.ndim < 1: + self.pos = self.pos.reshape(1) + self.vel = self.vel.reshape(1) + + # TODO: check that Hamiltonian ndim is consistent with here + + if t is not None: + t = np.atleast_1d(t) + if self.pos.shape[0] != len(t): + msg = ( + "Position and velocity must have the same " + "length along axis=1 as the length of the " + f"time array {len(t)} vs {self.pos.shape[0]}" + ) + raise ValueError(msg) + + if not hasattr(t, "unit"): + t *= u.one + + self.t = t + + if hamiltonian is not None: + self.potential = hamiltonian.potential + self.frame = hamiltonian.frame + + else: + self.potential = potential + self.frame = frame + + def __getitem__(self, slice_): + if isinstance(slice_, np.ndarray | list): + slice_ = (slice_,) + + try: + slice_ = tuple(slice_) + except TypeError: + slice_ = (slice_,) + + kw = {} + if self.t is not None: + kw["t"] = self.t[slice_[0]] + + pos = self.pos[slice_] + vel = self.vel[slice_] + + # if one time is sliced out, return a phasespaceposition + try: + int_tslice = int(slice_[0]) + except TypeError: + int_tslice = None + + if int_tslice is not None: + return PhaseSpacePosition(pos=pos, vel=vel, frame=self.frame) + + return self.__class__( + pos=pos, vel=vel, potential=self.potential, frame=self.frame, **kw + ) + + @property + def hamiltonian(self): + if self.potential is None or self.frame is None: + return None + + try: + return self._hamiltonian + except AttributeError: + from gala.potential import Hamiltonian + + self._hamiltonian = Hamiltonian(potential=self.potential, frame=self.frame) + + return self._hamiltonian + + def w(self, units=None): + """ + This returns a single array containing the phase-space positions. + + Parameters + ---------- + units : `~gala.units.UnitSystem` (optional) + The unit system to represent the position and velocity in + before combining into the full array. + + Returns + ------- + w : `~numpy.ndarray` + A numpy array of all positions and velocities, without units. + Will have shape ``(2*ndim, ...)``. + + """ + + if units is None: + if self.hamiltonian is None: + units = dimensionless + else: + units = self.hamiltonian.units + + return super().w(units=units) + + # ------------------------------------------------------------------------ + # Convert from Cartesian to other representations + # + def represent_as(self, new_pos, new_vel=None): + """ + Represent the position and velocity of the orbit in an alternate + coordinate system. Supports any of the Astropy coordinates + representation classes. + + Parameters + ---------- + new_pos : :class:`~astropy.coordinates.BaseRepresentation` + The type of representation to generate. Must be a class (not an + instance), or the string name of the representation class. + new_vel : :class:`~astropy.coordinates.BaseDifferential` (optional) + Class in which any velocities should be represented. Must be a class + (not an instance), or the string name of the differential class. If + None, uses the default differential for the new position class. + + Returns + ------- + new_orbit : `gala.dynamics.Orbit` + """ + kw = {} + if self.t is not None: + kw["t"] = self.t + o = super().represent_as(new_pos=new_pos, new_vel=new_vel) + return self.__class__(pos=o.pos, vel=o.vel, hamiltonian=self.hamiltonian, **kw) + + # ------------------------------------------------------------------------ + # Shape and size + # ------------------------------------------------------------------------ + @property + def ntimes(self): + return self.shape[0] + + @property + def norbits(self): + if len(self.shape) < 2: + return 1 + return self.shape[1] + + def reshape(self, new_shape): + """ + Reshape the underlying position and velocity arrays. + """ + kw = {} + if self.t is not None: + kw["t"] = self.t + return self.__class__( + pos=self.pos.reshape(new_shape), + vel=self.vel.reshape(new_shape), + hamiltonian=self.hamiltonian, + **kw, + ) + + # ------------------------------------------------------------------------ + # Input / output + # + def to_hdf5(self, f): + """ + Serialize this object to an HDF5 file. + + Requires ``h5py``. + + Parameters + ---------- + f : str, :class:`h5py.File` + Either the filename or an open HDF5 file. + """ + + f = super().to_hdf5(f) + + if self.potential is not None: + import yaml + + from ..potential.potential.io import to_dict + + f["potential"] = yaml.dump(to_dict(self.potential)).encode("utf-8") + + if self.t is not None: + quantity_to_hdf5(f, "time", self.t) + + return f + + @classmethod + def from_hdf5(cls, f): + """ + Load an object from an HDF5 file. + + Requires ``h5py``. + + Parameters + ---------- + f : str, :class:`h5py.File` + Either the filename or an open HDF5 file. + """ + # TODO: this is duplicated code from PhaseSpacePosition + if isinstance(f, str): + import h5py + + f = h5py.File(f, mode="r") + close = True + else: + close = False + + pos = quantity_from_hdf5(f["pos"]) + vel = quantity_from_hdf5(f["vel"]) + + time = None + if "time" in f: + time = quantity_from_hdf5(f["time"]) + + frame = None + if "frame" in f: + g = f["frame"] + + frame_mod = g.attrs["module"] + frame_cls = g.attrs["class"] + frame_units = [u.Unit(x.decode("utf-8")) for x in g["units"]] + + if u.dimensionless_unscaled in frame_units: + units = DimensionlessUnitSystem() + else: + units = UnitSystem(*frame_units) + + pars = {} + for k in g["parameters"]: + pars[k] = quantity_from_hdf5(g["parameters/" + k]) + + frame_cls = getattr(importlib.import_module(frame_mod), frame_cls) + frame = frame_cls(units=units, **pars) + + potential = None + if "potential" in f: + import yaml + + from ..potential.potential.io import from_dict + + dict_ = yaml.load(f["potential"][()].decode("utf-8"), Loader=yaml.Loader) + potential = from_dict(dict_) + + if close: + f.close() + + return cls(pos=pos, vel=vel, t=time, frame=frame, potential=potential) + + def orbit_gen(self): + """ + Generator for iterating over each orbit. + """ + if self.norbits == 1: + yield self + + else: + for i in range(self.norbits): + yield self[:, i] + + # ------------------------------------------------------------------------ + # Computed dynamical quantities + # + + def potential_energy(self, potential=None): + r""" + The potential energy *per unit mass*: + + .. math:: + + E_\Phi = \Phi(\boldsymbol{q}) + + Returns + ------- + E : :class:`~astropy.units.Quantity` + The potential energy. + """ + if self.hamiltonian is None and potential is None: + raise ValueError( + "To compute the potential energy, a potential object must be provided!" + ) + if potential is None: + potential = self.hamiltonian.potential + + return super().potential_energy(potential) + + def energy(self, hamiltonian=None): + r""" + The total energy *per unit mass*: + + Parameters + ---------- + hamiltonian : `gala.potential.Hamiltonian`, `gala.potential.PotentialBase` instance + The Hamiltonian object to evaluate the energy. If a potential is + passed in, this assumes a static reference frame. + + Returns + ------- + E : :class:`~astropy.units.Quantity` + The total energy. + """ + + if self.hamiltonian is None and hamiltonian is None: + raise ValueError( + "To compute the total energy, a hamiltonian object must be provided!" + ) + + if hamiltonian is None: + hamiltonian = self.hamiltonian + else: + from gala.potential import Hamiltonian + + hamiltonian = Hamiltonian(hamiltonian) + + return hamiltonian(self) + + def _max_helper(self, arr, approximate=False): + """ + Helper function for computing extrema (apocenter, pericenter, z_height) + and times of extrema. + + Parameters + ---------- + arr : `numpy.ndarray` + """ + assert self.norbits == 1 + assert self.t[-1] > self.t[0] # time must increase + + ix = argrelmax(arr.value, mode="wrap")[0] + ix = ix[(ix != 0) & (ix != (len(arr) - 1))] # remove edges + t = self.t.value + + approx_arr = arr[ix] + approx_t = t[ix] + + if approximate: + return approx_arr, approx_t * self.t.unit + + better_times = np.zeros(ix.shape, dtype=float) + better_arr = np.zeros(ix.shape, dtype=float) + for i, j in enumerate(ix): + tvals = t[j - 1 : j + 2] + rvals = arr[j - 1 : j + 2].value + coeffs = np.polynomial.polynomial.polyfit(tvals, rvals, 2) + better_times[i] = (-coeffs[1]) / (2 * coeffs[2]) + better_arr[i] = ( + (coeffs[2] * better_times[i] ** 2) + + (coeffs[1] * better_times[i]) + + coeffs[0] + ) + + return better_arr * arr.unit, better_times * self.t.unit + + def _max_return_helper(self, vals, times, return_times, reduce): + if return_times: + if len(vals) == 1: + return vals[0], times[0] + return vals, times + + if reduce: + return u.Quantity(vals).reshape(self.shape[1:]) + + return u.Quantity(vals) + + def pericenter(self, return_times=False, func=np.mean, approximate=False): + """ + Estimate the pericenter(s) of the orbit by identifying local minima in + the spherical radius, fitting a parabola around these local minima and + then solving this parabola to find the pericenter(s). + + By default, this returns the mean of all local minima (pericenters). To + get, e.g., the minimum pericenter, pass in ``func=np.min``. To get + all pericenters, pass in ``func=None``. + + Parameters + ---------- + func : func (optional) + A function to evaluate on all of the identified pericenter times. + return_times : bool (optional) + Also return the pericenter times. + approximate : bool (optional) + Compute an approximate pericenter by skipping interpolation. + + Returns + ------- + peri : float, :class:`~numpy.ndarray` + Either a single number or an array of pericenters. + times : :class:`~numpy.ndarray` (optional, see ``return_times``) + If ``return_times=True``, also returns an array of the pericenter + times. + + """ + + if return_times and func is not None: + raise ValueError( + "Cannot return times if reducing pericenters " + "using an input function. Pass `func=None` if " + "you want to return all individual pericenters " + "and times." + ) + + if func is None: + reduce = False + func = lambda x: x + else: + reduce = True + + # time must increase + obj = self[::-1] if self.t[-1] < self.t[0] else self + + vals = [] + times = [] + for orbit in obj.orbit_gen(): + v, t = orbit._max_helper( + -orbit.physicsspherical.r, + approximate=approximate, # pericenter + ) + vals.append(func(-v)) # negative for pericenter + times.append(t) + + return obj._max_return_helper(vals, times, return_times, reduce) + + def apocenter(self, return_times=False, func=np.mean, approximate=False): + """ + Estimate the apocenter(s) of the orbit by identifying local maxima in + the spherical radius, fitting a parabola around these local maxima and + then solving this parabola to find the apocenter(s). + + By default, this returns the mean of all local maxima (apocenters). To + get, e.g., the largest apocenter, pass in ``func=np.max``. To get + all apocenters, pass in ``func=None``. + + Parameters + ---------- + func : func (optional) + A function to evaluate on all of the identified apocenter times. + return_times : bool (optional) + Also return the apocenter times. + approximate : bool (optional) + Compute an approximate apocenter by skipping interpolation. + + Returns + ------- + apo : float, :class:`~numpy.ndarray` + Either a single number or an array of apocenters. + times : :class:`~numpy.ndarray` (optional, see ``return_times``) + If ``return_times=True``, also returns an array of the apocenter + times. + + """ + + if return_times and func is not None: + raise ValueError( + "Cannot return times if reducing apocenters " + "using an input function. Pass `func=None` if " + "you want to return all individual apocenters " + "and times." + ) + + if func is None: + reduce = False + func = lambda x: x + else: + reduce = True + + # time must increase + obj = self[::-1] if self.t[-1] < self.t[0] else self + + vals = [] + times = [] + for orbit in obj.orbit_gen(): + v, t = orbit._max_helper( + orbit.physicsspherical.r, + approximate=approximate, # apocenter + ) + vals.append(func(v)) + times.append(t) + + return obj._max_return_helper(vals, times, return_times, reduce) + + def guiding_radius(self, potential=None, t=0.0, **root_kwargs): + """ + Compute the guiding-center radius + + Parameters + ---------- + potential : `gala.potential.PotentialBase` subclass instance (optional) + The potential to compute the guiding radius in. + t : quantity-like (optional) + Time. + **root_kwargs + Any additional keyword arguments are passed to `~scipy.optimize.root`. + + Returns + ------- + Rg : :class:`~astropy.units.Quantity` + Guiding-center radius. + """ + from .core import _guiding_radius_helper + + if potential is None: + potential = self.potential + if potential is None: + raise ValueError( + "You must specify a potential if it is not already defined on the orbit" + ) + + R0s = np.atleast_1d( + np.mean(self.cylindrical.rho).decompose(potential.units).value + ) + Lzs = self.angular_momentum()[2].decompose(potential.units).value + mean_Lzs = np.atleast_1d(np.mean(Lzs, axis=0)) + check = np.abs(np.std(Lzs, axis=0) / mean_Lzs) > 1e-8 + if np.any(check): + warnings.warn( + f"{check.sum()} orbits do not have constant Lz (see orbits at indices: " + f"{list(np.where(check)[0][:10])}, ...). Are you sure you are using an" + " axisymmetric potential?", + RuntimeWarning, + ) + + Rgs = _guiding_radius_helper(R0s, mean_Lzs, potential, t=t, **root_kwargs) + + return Rgs.reshape(self.shape[1:]) * potential.units["length"] + + def zmax(self, return_times=False, func=np.mean, approximate=False): + """ + Estimate the maximum ``z`` height of the orbit by identifying local + maxima in the absolute value of the ``z`` position, fitting a parabola + around these local maxima and then solving this parabola to find the + maximum ``z`` height. + + By default, this returns the mean of all local maxima. To get, e.g., the + largest ``z`` excursion, pass in ``func=np.max``. To get all ``z`` + maxima, pass in ``func=None``. + + Parameters + ---------- + func : func (optional) + A function to evaluate on all of the identified z maximum times. + return_times : bool (optional) + Also return the times of maximum. + approximate : bool (optional) + Compute approximate values by skipping interpolation. + + Returns + ------- + zs : float, :class:`~numpy.ndarray` + Either a single number or an array of maximum z heights. + times : :class:`~numpy.ndarray` (optional, see ``return_times``) + If ``return_times=True``, also returns an array of the apocenter + times. + + """ + + if return_times and func is not None: + raise ValueError( + "Cannot return times if reducing " + "using an input function. Pass `func=None` if " + "you want to return all individual values " + "and times." + ) + + if func is None: + reduce = False + func = lambda x: x + else: + reduce = True + + # time must increase + obj = self[::-1] if self.t[-1] < self.t[0] else self + + vals = [] + times = [] + for orbit in obj.orbit_gen(): + v, t = orbit._max_helper( + np.abs(orbit.cylindrical.z), approximate=approximate + ) + vals.append(func(v)) + times.append(t) + + return obj._max_return_helper(vals, times, return_times, reduce) + + def eccentricity(self, **kw): + r""" + Returns the eccentricity computed from the mean apocenter and + mean pericenter. + + .. math:: + + e = \frac{r_{\rm apo} - r_{\rm per}}{r_{\rm apo} + r_{\rm per}} + + Parameters + ---------- + **kw + Any keyword arguments passed to ``apocenter()`` and + ``pericenter()``. For example, ``approximate=True``. + + Returns + ------- + ecc : float + The orbital eccentricity. + + """ + ra = self.apocenter(**kw) + rp = self.pericenter(**kw) + return (ra - rp) / (ra + rp) + + def estimate_period(self): + """Estimate the period of the orbit in each dimension. + + Returns + ------- + periods : `~astropy.table.QTable` + The estimated orbital periods for each phase-space component, for + each orbit. + + Examples + -------- + >>> from gala.potential import MilkyWayPotential2022 + >>> pot = MilkyWayPotential2022() + + Compute an orbit and estimate the orbital period in each Cartesian component: + + >>> orbit = pot.integrate_orbit([8., 0, 0, 0, 0.18, 0], dt=1., n_steps=4000) + >>> P_xyz = orbit.estimate_period() + >>> P_xyz + + x y z + Myr Myr Myr + float64 float64 float64 + ------------------ ------------------ ----------------- + 176.02380952380952 176.07034632034632 56.43902691511387 + + Or, to estimate the period in cylindrical radius: + + >>> orbit.cylindrical.estimate_period()["rho"] + + """ + + if self.t is None: + raise ValueError( + "To compute the period, a time array is needed. " + "Specify a time array when creating this object." + ) + + periods = {} + for k in self.pos_components: + q = getattr(self, k).value + if self.norbits == 1: + T = u.Quantity(peak_to_peak_period(self.t, q)) + else: + T = u.Quantity( + [peak_to_peak_period(self.t, q[:, n]) for n in range(q.shape[1])] + ) + periods[k] = np.atleast_1d(T) + return at.QTable(periods) + + # ------------------------------------------------------------------------ + # Misc. useful methods + # ------------------------------------------------------------------------ + def circulation(self): + """ + Determine which axes the orbit circulates around. + + This method checks whether there is a change of sign of the angular + momentum about each axis to determine circulation patterns. For example, + a tube orbit will circulate around one axis, while a box orbit will + not circulate around any axis. + + Returns + ------- + circulation : :class:`~numpy.ndarray` + An integer array indicating circulation about each axis. For each + axis: 1 indicates circulation, 0 indicates no circulation. + For a single orbit, returns a 1D array of shape ``(ndim,)``. + For multiple orbits, returns a 2D array of shape ``(ndim, norbits)``. + + Examples + -------- + For a single 3D orbit: + + - Box and boxlet orbits: ``[0, 0, 0]`` + - z-axis (short-axis) tube orbit: ``[0, 0, 1]`` + - x-axis (long-axis) tube orbit: ``[1, 0, 0]`` + + Notes + ----- + This method works by checking whether the angular momentum about each + axis changes sign or becomes very small during the orbit integration. + """ + L = self.angular_momentum() + + # if only 2D, add another empty axis + if L.ndim == 2: + single_orbit = True + L = L[..., None] + else: + single_orbit = False + + ndim, _ntimes, norbits = L.shape + + # initial angular momentum + L0 = L[:, 0] + + # see if at any timestep the sign has changed + circ = np.ones((ndim, norbits)) + for ii in range(ndim): + cnd = (np.sign(L0[ii]) != np.sign(L[ii, 1:])) | ( + np.abs(L[ii, 1:]).value < 1e-13 + ) + ix = np.atleast_1d(np.any(cnd, axis=0)) + circ[ii, ix] = 0 + + circ = circ.astype(int) + if single_orbit: + return circ.reshape((ndim,)) + return circ + + def align_circulation_with_z(self, circulation=None): + """ + Align the circulation axis with the z-axis for tube orbits. + + If the input orbit is a tube orbit (circulates around one axis), this + method rotates the coordinate system so that circulation occurs around + the z-axis. This is useful for standardizing orbit orientations when + computing actions or comparing orbits. + + Parameters + ---------- + circulation : :class:`~numpy.ndarray`, optional + The circulation pattern of the orbit. If not specified, this is + computed using the :meth:`~gala.dynamics.Orbit.circulation` method. + + Returns + ------- + orb : :class:`~gala.dynamics.Orbit` + A copy of the original orbit object with circulation aligned with + the z axis. + """ + + if circulation is None: + circulation = self.circulation() + circulation = atleast_2d(circulation, insert_axis=1) + + cart = self.cartesian + pos = cart.xyz + vel = ( + np.vstack( + (cart.v_x.value[None], cart.v_y.value[None], cart.v_z.value[None]) + ) + * cart.v_x.unit + ) + + if pos.ndim < 3: + pos = pos[..., np.newaxis] + vel = vel[..., np.newaxis] + + if circulation.shape[0] != self.ndim or circulation.shape[1] != pos.shape[2]: + raise ValueError( + "Shape of 'circulation' array should match the " + "shape of the position/velocity (minus the time " + "axis)." + ) + + new_pos = pos.copy() + new_vel = vel.copy() + for n in range(pos.shape[2]): + if circulation[2, n] == 1 or np.all(circulation[:, n] == 0): + # already circulating about z or box orbit + continue + + if sum(circulation[:, n]) > 1: + logger.warning( + "Circulation about multiple axes - are you sure " + "the orbit has been integrated for long enough?" + ) + + if circulation[0, n] == 1: + circ = 0 + elif circulation[1, n] == 1: + circ = 1 + else: + raise RuntimeError("Should never get here...") + + new_pos[circ, :, n] = pos[2, :, n] + new_pos[2, :, n] = pos[circ, :, n] + + new_vel[circ, :, n] = vel[2, :, n] + new_vel[2, :, n] = vel[circ, :, n] + + return self.__class__( + pos=new_pos.reshape(cart.xyz.shape), + vel=new_vel.reshape(cart.xyz.shape), + t=self.t, + hamiltonian=self.hamiltonian, + ) + + def plot(self, components=None, units=None, auto_aspect=True, **kwargs): + """ + Plot the positions in all projections. This is a wrapper around + `~gala.dynamics.plot_projections` for fast access and quick + visualization. All extra keyword arguments are passed to that function + (the docstring for this function is included here for convenience). + + Parameters + ---------- + components : iterable (optional) + A list of component names (strings) to plot. By default, this is the + Cartesian positions ``['x', 'y', 'z']``. To plot Cartesian + velocities, pass in the velocity component names + ``['v_x', 'v_y', 'v_z']``. If the representation is different, the + component names will be different. For example, for a Cylindrical + representation, the components are ``['rho', 'phi', 'z']`` and + ``['v_rho', 'pm_phi', 'v_z']``. + units : `~astropy.units.UnitBase`, iterable, `gala.units.UnitSystem` (optional) + A single unit or list of units to display the components in. + auto_aspect : bool (optional) + Automatically enforce an equal aspect ratio. + relative_to : bool (optional) + Plot the values relative to this value or values. + autolim : bool (optional) + Automatically set the plot limits to be something sensible. + axes : array_like (optional) + Array of matplotlib Axes objects. + subplots_kwargs : dict (optional) + Dictionary of kwargs passed to :func:`~matplotlib.pyplot.subplots`. + labels : iterable (optional) + List or iterable of axis labels as strings. They should correspond to + the dimensions of the input orbit. + plot_function : callable (optional) + The ``matplotlib`` plot function to use. By default, this is + :func:`~matplotlib.pyplot.scatter`, but can also be, e.g., + :func:`~matplotlib.pyplot.plot`. + **kwargs + All other keyword arguments are passed to the ``plot_function``. + You can pass in any of the usual style kwargs like ``color=...``, + ``marker=...``, etc. + + Returns + ------- + fig : `~matplotlib.Figure` + + """ + from gala._optional_deps import HAS_MATPLOTLIB + + if not HAS_MATPLOTLIB: + raise ImportError("matplotlib is required for visualization.") + import matplotlib.pyplot as plt + + if components is None: + if self.ndim == 1: # only a 1D orbit, so just plot time series + components = ["t", self.pos.components[0]] + else: + components = self.pos.components + + x, labels = self._plot_prepare(components=components, units=units) + + kwargs.setdefault("plot_function", plt.plot) + if kwargs["plot_function"] in {plt.plot, plt.scatter}: + kwargs.setdefault("marker", "") + kwargs.setdefault("labels", labels) + + if kwargs["plot_function"] == plt.plot: + kwargs.setdefault("linestyle", "-") + + fig = plot_projections(x, **kwargs) + + if ( + _get_rep_name(self.pos) == "cartesian" + and all(not c.startswith("d_") for c in components) + and "t" not in components + and auto_aspect + ): + # Use adjustable="box" to avoid conflicts with automatic limits + for ax in fig.axes: + ax.set_aspect("equal", adjustable="box") + + return fig + + def plot_3d( + self, + components=None, + units=None, + auto_aspect=True, + subplots_kwargs=None, + **kwargs, + ): + """ + Plot the specified 3D components. + + Parameters + ---------- + components : iterable (optional) + A list of component names (strings) to plot. By default, this is the + Cartesian positions ``['x', 'y', 'z']``. To plot Cartesian + velocities, pass in the velocity component names + ``['v_x', 'v_y', 'v_z']``. If the representation is different, the + component names will be different. For example, for a Cylindrical + representation, the components are ``['rho', 'phi', 'z']`` and + ``['v_rho', 'pm_phi', 'v_z']``. + units : `~astropy.units.UnitBase`, iterable, `gala.units.UnitSystem` (optional) + A single unit or list of units to display the components in. + auto_aspect : bool (optional) + Automatically enforce an equal aspect ratio. + ax : `matplotlib.axes.Axes` + The matplotlib Axes object to draw on. + subplots_kwargs : dict (optional) + Dictionary of kwargs passed to :func:`~matplotlib.pyplot.subplots`. + labels : iterable (optional) + List or iterable of axis labels as strings. They should correspond + to the dimensions of the input orbit. + plot_function : str (optional) + The ``matplotlib`` plot function to use. By default, this is 'plot' + but can also be, e.g., 'scatter'. + **kwargs + All other keyword arguments are passed to the ``plot_function``. + You can pass in any of the usual style kwargs like ``color=...``, + ``marker=...``, etc. + + Returns + ------- + fig : `~matplotlib.Figure` + + """ + from gala._optional_deps import HAS_MATPLOTLIB + + if not HAS_MATPLOTLIB: + raise ImportError("matplotlib is required for visualization.") + import matplotlib.pyplot as plt + from mpl_toolkits import mplot3d # noqa: F401 + + if components is None: + components = self.pos.components + + if subplots_kwargs is None: + subplots_kwargs = {} + + if len(components) != 3: + raise ValueError(f"The number of components ({len(components)}) must be 3") + + x, labels = self._plot_prepare(components=components, units=units) + + kwargs.setdefault("marker", "") + kwargs.setdefault("linestyle", kwargs.pop("ls", "-")) + plot_function_name = kwargs.pop("plot_function", "plot") + + ax = kwargs.pop("ax", None) + subplots_kwargs.setdefault("constrained_layout", True) + if ax is None: + fig, ax = plt.subplots( + figsize=(6, 6), subplot_kw={"projection": "3d"}, **subplots_kwargs + ) + else: + fig = ax.figure + + plot_function = getattr(ax, plot_function_name) + if x[0].ndim > 1: + for n in range(x[0].shape[1]): + plot_function(*[xx[:, n] for xx in x], **kwargs) + else: + plot_function(*x, **kwargs) + ax.set_xlabel(labels[0]) + ax.set_ylabel(labels[1]) + ax.set_zlabel(labels[2]) + + if ( + _get_rep_name(self.pos) == "cartesian" + and all(not c.startswith("d_") for c in components) + and "t" not in components + and auto_aspect + ): + for ax in fig.axes: + ax.set(aspect="auto", adjustable="datalim") + + return fig, ax + + def animate( + self, + components=None, + units=None, + stride=1, + segment_nsteps=10, + underplot_full_orbit=True, + show_time=True, + marker_style=None, + segment_style=None, + FuncAnimation_kwargs=None, + orbit_plot_kwargs=None, + axes=None, + ): + """ + Animate an orbit or collection of orbits. + + Parameters + ---------- + components : iterable (optional) + A list of component names (strings) to plot. By default, this is the + Cartesian positions ``['x', 'y', 'z']``. To plot Cartesian + velocities, pass in the velocity component names + ``['v_x', 'v_y', 'v_z']``. If the representation is different, the + component names will be different. For example, for a Cylindrical + representation, the components are ``['rho', 'phi', 'z']`` and + ``['v_rho', 'pm_phi', 'v_z']``. + units : `~astropy.units.UnitBase`, iterable, `gala.units.UnitSystem` (optional) + A single unit or list of units to display the components in. + stride : int (optional) + How often to draw a new frame, in terms of orbit timesteps. + segment_nsteps : int (optional) + How many timesteps to draw in an orbit segment trailing + the timestep marker. Set this to 0 or None to disable. + underplot_full_orbit : bool (optional) + Controls whether to under-plot the full orbit as a thin line. + show_time : bool (optional) + Controls whether to show a label of the current timestep + marker_style : dict or list of dict (optional) + Matplotlib style arguments passed to `matplotlib.pyplot.plot` + that control the plot style of the timestep marker. If a single + dict is passed then the marker_style is applied to all orbits. + If a list of dicts is passed then each dict will be applied to + each orbit. + segment_style : dict (optional) + Matplotlib style arguments passed to `matplotlib.pyplot.plot` + that control the plot style of the orbit segment. If a single + dict is passed then the segment_style is applied to all orbits. + If a list of dicts is passed then each dict will be applied to + each orbit. + FuncAnimation_kwargs : dict (optional) + Keyword arguments passed through to + `matplotlib.animation.FuncAnimation`. + orbit_plot_kwargs : dict (optional) + Keyword arguments passed through to `gala.dynamics.Orbit.plot`. + axes : `matplotlib.axes.Axes` (optional) + Where to draw the orbit. + + Returns + ------- + fig : `matplotlib.figure.Figure` + anim : `matplotlib.animation.FuncAnimation` + + """ + from gala._optional_deps import HAS_MATPLOTLIB + + if not HAS_MATPLOTLIB: + raise ImportError("matplotlib is required for visualization.") + from matplotlib.animation import FuncAnimation + + if components is None: + if self.ndim == 1: # only a 1D orbit, so just plot time series + components = ["t", self.pos.components[0]] + else: + components = self.pos.components + + # Extract the relevant components, in the given unit system + xs, _ = self._plot_prepare(components=components, units=units) + xs = [atleast_2d(xx, insert_axis=1) for xx in xs] + + # Figure out which components to plot on which axes + data_paired = [] + for i in range(len(xs)): + for j in range(len(xs)): + if i >= j: + continue # skip diagonal, upper triangle + data_paired.append((xs[i], xs[j])) + + if FuncAnimation_kwargs is None: + FuncAnimation_kwargs = {} + + if orbit_plot_kwargs is None: + orbit_plot_kwargs = {} + orbit_plot_kwargs.setdefault("zorder", 1) + orbit_plot_kwargs.setdefault("color", "#aaaaaa") + orbit_plot_kwargs.setdefault("linewidth", "1") + orbit_plot_kwargs.setdefault("axes", axes) + + if marker_style is None: + marker_style = [{} for _ in range(self.norbits)] + + # if a single dict is passed then copy it into a list + if isinstance(marker_style, dict): + marker_style = [marker_style for _ in range(self.norbits)] + # otherwise ensure the list is the right length + elif len(marker_style) != self.norbits: + raise ValueError( + "Length of `marker_style` list must be equal to the number of orbits" + ) + + for n in range(self.norbits): + marker_style[n].setdefault("marker", "o") + marker_style[n].setdefault("linestyle", marker_style[n].pop("ls", "None")) + marker_style[n].setdefault("markersize", marker_style[n].pop("ms", 4.0)) + marker_style[n].setdefault("color", marker_style[n].pop("c", "tab:red")) + marker_style[n].setdefault("zorder", 100) + + if segment_style is None: + segment_style = [{} for _ in range(self.norbits)] + + # if a single dict is passed then copy it into a list + if isinstance(segment_style, dict): + segment_style = [segment_style for _ in range(self.norbits)] + # otherwise ensure the list is the right length + elif len(segment_style) != self.norbits: + raise ValueError( + "Length of `segment_style` list must be equal to the number of orbits" + ) + + for n in range(self.norbits): + segment_style[n].setdefault("marker", "None") + segment_style[n].setdefault("linestyle", segment_style[n].pop("ls", "-")) + segment_style[n].setdefault("linewidth", segment_style[n].pop("lw", 2.0)) + segment_style[n].setdefault("color", segment_style[n].pop("c", "tab:blue")) + segment_style[n].setdefault("zorder", 10) + if segment_nsteps is None or segment_nsteps == 0: # HACK + segment_style[n]["alpha"] = 0 + + # Use this to get a figure with axes with the right limits + # Note: Labels are added by .plot() + if not underplot_full_orbit: + orbit_plot_kwargs["alpha"] = 0 + fig = self.plot(components=components, units=units, **orbit_plot_kwargs) + + # Set up all of the (data-less) markers and line segments + markers = [] + segments = [] + for n in range(self.norbits): + m = [] + s = [] + for i in range(len(data_paired)): + m.append(fig.axes[i].plot([], [], **marker_style[n])[0]) + s.append(fig.axes[i].plot([], [], **segment_style[n])[0]) + markers.append(m) + segments.append(s) + + # record the time unit and set up data-less annotates if user wants timestep label + if show_time: + time_unit = self.t.unit + times = [ + fig.axes[i].annotate( + "", xy=(0.98, 0.98), xycoords="axes fraction", ha="right", va="top" + ) + for i in range(len(data_paired)) + ] + + def anim_func(n): + i = max(0, n - segment_nsteps) + + for k in range(self.norbits): + for j in range(len(data_paired)): + markers[k][j].set_data( + data_paired[j][0][n : n + 1, k], data_paired[j][1][n : n + 1, k] + ) + segments[k][j].set_data( + data_paired[j][0][i : n + 1, k], data_paired[j][1][i : n + 1, k] + ) + + if show_time: + time_value = self.t[n : n + 1].value[0] + for time in times: + time.set_text(f"Time={time_value:1.1f} {time_unit}") + + artists = ( + *[m for m in markers for x in m], + *[s for s in segments for x in s], + *times, + ) + else: + artists = ( + *[m for m in markers for x in m], + *[s for s in segments for x in s], + ) + return artists + + anim = FuncAnimation( + fig, + anim_func, + frames=np.arange(0, self.ntimes, stride), + **FuncAnimation_kwargs, + ) + + return fig, anim + + def to_frame(self, frame, current_frame=None, **kwargs): + """ + Transform to a different reference frame. + + Parameters + ---------- + frame : `gala.potential.CFrameBase` + The frame to transform to. + current_frame : `gala.potential.CFrameBase` (optional) + If the Orbit has no associated Hamiltonian, this specifies the + current frame of the orbit. + + Returns + ------- + orbit : `gala.dynamics.Orbit` + The orbit in the new reference frame. + + """ + + kw = kwargs.copy() + + # TODO: this short-circuit sux + if current_frame is None: + current_frame = self.frame + if frame == current_frame and not kwargs: + return self + + # TODO: need a better way to do this! + from ..potential.frame.builtin import ConstantRotatingFrame + + for fr in [frame, current_frame, self.frame]: + if isinstance(fr, ConstantRotatingFrame) and "t" not in kw: + kw["t"] = self.t + + # TODO: this needs a re-write... + psp = super().to_frame(frame, current_frame, **kw) + + return Orbit( + pos=psp.pos, vel=psp.vel, t=self.t, frame=frame, potential=self.potential + ) + + # ------------------------------------------------------------------------ + # Compatibility with other packages + # + + def to_galpy_orbit(self, ro=None, vo=None): + """Convert this object to a ``galpy.Orbit`` instance. + + Parameters + ---------- + ro : `astropy.units.Quantity` or `astropy.units.UnitBase` + "Natural" length unit. + vo : `astropy.units.Quantity` or `astropy.units.UnitBase` + "Natural" velocity unit. + + Returns + ------- + galpy_orbit : `galpy.orbit.Orbit` + + """ + from galpy.orbit import Orbit + from galpy.util.config import __config__ as galpy_config + + if self.frame is not None: + from ..potential import StaticFrame + + w = self.to_frame(StaticFrame(self.frame.units)) + else: + w = self + + if ro is None: + ro = galpy_config.getfloat("normalization", "ro") + ro *= u.kpc + + if vo is None: + vo = galpy_config.getfloat("normalization", "vo") + vo = vo * u.km / u.s + + # PhaseSpacePosition or Orbit: + cyl = w.cylindrical + + R = cyl.rho.to_value(ro).T + phi = cyl.phi.to_value(u.rad).T + z = cyl.z.to_value(ro).T + + vR = cyl.v_rho.to_value(vo).T + vT = (cyl.rho * cyl.pm_phi).to_value(vo, u.dimensionless_angles()).T + vz = cyl.v_z.to_value(vo).T + + o = Orbit(np.array([R, vR, vT, z, vz, phi]).T, ro=ro, vo=vo) + if w.t is not None: + o.t = w.t.to_value(ro / vo) + + return o + + @classmethod + def from_galpy_orbit(self, galpy_orbit): + """Create a Gala ``PhaseSpacePosition`` or ``Orbit`` instance from a + ``galpy.Orbit`` instance. + + Parameters + ---------- + galpy_orbit : :class:`galpy.orbit.Orbit` + + Returns + ------- + orbit : :class:`~gala.dynamics.Orbit` + + """ + ro = galpy_orbit._ro * u.kpc + vo = galpy_orbit._vo * u.km / u.s + ts = galpy_orbit.t + + rep = coord.CylindricalRepresentation( + rho=galpy_orbit.R(ts) * ro, + phi=galpy_orbit.phi(ts) * u.rad, + z=galpy_orbit.z(ts) * ro, + copy=False, + ) + with u.set_enabled_equivalencies(u.dimensionless_angles()): + dif = coord.CylindricalDifferential( + d_rho=galpy_orbit.vR(ts) * vo, + d_phi=galpy_orbit.vT(ts) * vo / rep.rho, + d_z=galpy_orbit.vz(ts) * vo, + copy=False, + ) + + t = galpy_orbit.t * ro / vo + return Orbit(rep, dif, t=t, copy=False) diff --git a/gala/source/src/gala/dynamics/plot.py b/gala/source/src/gala/dynamics/plot.py new file mode 100644 index 0000000000000000000000000000000000000000..90c111bdf8a0bfd7ef003a205f44b0d833fe9323 --- /dev/null +++ b/gala/source/src/gala/dynamics/plot.py @@ -0,0 +1,148 @@ +import numpy as np + +__all__ = ["plot_projections"] + + +def _get_axes(dim, subplots_kwargs=None): + """ + Parameters + ---------- + dim : int + Dimensionality of the orbit. + subplots_kwargs : dict (optional) + Dictionary of kwargs passed to :func:`~matplotlib.pyplot.subplots`. + """ + from gala._optional_deps import HAS_MATPLOTLIB + + if not HAS_MATPLOTLIB: + raise ImportError("matplotlib is required for visualization.") + import matplotlib.pyplot as plt + + if subplots_kwargs is None: + subplots_kwargs = {} + + n_panels = int(dim * (dim - 1) / 2) if dim > 1 else 1 + + subplots_kwargs.setdefault("figsize", (4 * n_panels, 4)) + subplots_kwargs.setdefault("constrained_layout", True) + + _fig, axes = plt.subplots(1, n_panels, **subplots_kwargs) + + return [axes] if n_panels == 1 else axes.flat + + +def plot_projections( + x, + relative_to=None, + autolim=True, + axes=None, + subplots_kwargs=None, + labels=None, + plot_function=None, + **kwargs, +): + """ + Create 2D projections of multi-dimensional data. + + Given an N-dimensional array, this function creates a figure containing + 2D projections of all combinations of coordinate pairs. This is commonly + used for visualizing orbits or phase-space positions. + + Parameters + ---------- + x : array_like + Array of values with shape ``(ndim, npoints)`` where ``ndim`` is the + number of dimensions and ``npoints`` is the number of data points. + See :ref:`shape-conventions` for more information. + relative_to : array_like, optional + Values to subtract from ``x`` before plotting. Useful for plotting + relative to a reference position. + autolim : bool, optional + Automatically set sensible plot limits. Default is True. + axes : array_like, optional + Array of matplotlib Axes objects to plot on. If not provided, + new axes will be created. + subplots_kwargs : dict, optional + Dictionary of keyword arguments passed to + :func:`~matplotlib.pyplot.subplots` when creating new axes. + labels : list, optional + List of axis labels as strings corresponding to each dimension + of the input data. + plot_function : callable, optional + The matplotlib plotting function to use. Default is + :func:`~matplotlib.pyplot.plot`. Other options include + :func:`~matplotlib.pyplot.scatter`. + **kwargs + Additional keyword arguments passed to the plotting function. + Examples include ``color``, ``marker``, ``linewidth``, etc. + + Returns + ------- + fig : :class:`~matplotlib.figure.Figure` + The matplotlib figure containing the projection plots. + + Notes + ----- + This function creates an ``(ndim*(ndim-1)/2)`` subplot grid showing + all unique pairs of coordinate projections. For example, 3D data + creates 3 subplots: (x,y), (x,z), and (y,z). + """ + + # don't propagate changes back... + x = np.array(x, copy=True) + ndim = x.shape[0] + + # get axes object from arguments + if axes is None: + axes = _get_axes(dim=ndim, subplots_kwargs=subplots_kwargs) + + import matplotlib.pyplot as plt # mpl import already checked above + + if isinstance(axes, plt.Axes): + axes = [axes] + + # if the quantities are relative + if relative_to is not None: + x -= relative_to + + # name of the plotting function + plot_fn_name = plot_function.__name__ + + # automatically determine limits + if autolim: + lims = [] + for i in range(ndim): + max_, min_ = np.max(x[i]), np.min(x[i]) + delta = max_ - min_ + + if delta == 0.0: + delta = 1.0 + + lims.append([min_ - delta * 0.02, max_ + delta * 0.02]) + + k = 0 + for i in range(ndim): + for j in range(ndim): + if i >= j: + continue # skip diagonal, upper triangle + + plot_func = getattr(axes[k], plot_fn_name) + plot_func(x[i], x[j], **kwargs) + + if labels is not None: + axes[k].set_xlabel(labels[i]) + axes[k].set_ylabel(labels[j]) + + if autolim: + # ensure new limits only ever expand current axis limits + xlims = axes[k].get_xlim() + ylims = axes[k].get_ylim() + lims[i] = (min(lims[i][0], xlims[0]), max(lims[i][1], xlims[1])) + lims[j] = (min(lims[j][0], ylims[0]), max(lims[j][1], ylims[1])) + + axes[k].set_xlim(lims[i]) + axes[k].set_ylim(lims[j]) + + k += 1 + + return axes[0].figure diff --git a/gala/source/src/gala/dynamics/representation_nd.py b/gala/source/src/gala/dynamics/representation_nd.py new file mode 100644 index 0000000000000000000000000000000000000000..5b695f99fa770c898fc57dba595753ab66c67cd1 --- /dev/null +++ b/gala/source/src/gala/dynamics/representation_nd.py @@ -0,0 +1,249 @@ +import operator + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np + +from gala._compat_utils import COPY_IF_NEEDED + +__all__ = ["NDCartesianDifferential", "NDCartesianRepresentation"] + + +def _make_getter(component): + """Make an attribute getter for use in a property. + + Removed from Astropy in v6.3 but still used here. + + Parameters + ---------- + component : str + The name of the component that should be accessed. This assumes the + actual value is stored in an attribute of that name prefixed by '_'. + """ + # This has to be done in a function to ensure the reference to component + # is not lost/redirected. + component = "_" + component + + def get_component(self): + return getattr(self, component) + + return get_component + + +class NDMixin: + def _apply(self, method, *args, **kwargs): + """Create a new representation with ``method`` applied to the arrays. + + In typical usage, the method is any of the shape-changing methods for + `~numpy.ndarray` (``reshape``, ``swapaxes``, etc.), as well as those + picking particular elements (``__getitem__``, ``take``, etc.), which + are all defined in `~astropy.utils.misc.ShapedLikeNDArray`. It will be + applied to the underlying arrays (e.g., ``x``, ``y``, and ``z`` for + `~astropy.coordinates.CartesianRepresentation`), with the results used + to create a new instance. + + Internally, it is also used to apply functions to the components + (in particular, `~numpy.broadcast_to`). + + Parameters + ---------- + method : str or callable + If str, it is the name of a method that is applied to the internal + ``components``. If callable, the function is applied. + args : tuple + Any positional arguments for ``method``. + kwargs : dict + Any keyword arguments for ``method``. + """ + if callable(method): + apply_method = lambda array: method(array, *args, **kwargs) + else: + apply_method = operator.methodcaller(method, *args, **kwargs) + return self.__class__( + [apply_method(getattr(self, component)) for component in self.components], + copy=COPY_IF_NEEDED, + ) + + +class NDCartesianRepresentation(NDMixin, coord.CartesianRepresentation): + """ + Representation of points in ND cartesian coordinates. + + Parameters + ---------- + x : `~astropy.units.Quantity` or array + The Cartesian coordinates of the point(s). If not quantity, + ``unit`` should be set. + differentials : dict, `NDCartesianDifferential` (optional) + Any differential classes that should be associated with this + representation. + unit : `~astropy.units.Unit` or str + If given, the coordinates will be converted to this unit (or taken to + be in this unit if not given. + copy : bool, optional + If `True` (default), arrays will be copied rather than referenced. + """ + + attr_classes = {} + + def __init__(self, x, differentials=None, unit=None, copy=True): + if unit is None: + unit = u.one if not hasattr(x[0], "unit") else x[0].unit + + x = u.Quantity(x, unit, copy=copy, subok=True) + copy = False + + self.attr_classes = {"x" + str(i): u.Quantity for i in range(1, len(x) + 1)} + + super(coord.CartesianRepresentation, self).__init__( + *x, differentials=differentials, copy=copy + ) + + ptype = None + for name, _ in self.attr_classes.items(): + if ptype is None: + ptype = getattr(self, "_" + name).unit.physical_type + + elif getattr(self, "_" + name).unit.physical_type != ptype: + raise u.UnitsError("All components should have matching physical types") + + cls = self.__class__ + if not hasattr(cls, name): + setattr( + cls, + name, + property( + _make_getter(name), + doc=(f"The '{name}' component of the points(s)."), + ), + ) + + @property + def masked(self): + """NOTE: This overrides the support for masks""" + return False + + def get_xyz(self, xyz_axis=0): + """Return a vector array of the x, y, and z coordinates. + + Parameters + ---------- + xyz_axis : int, optional + The axis in the final array along which the x, y, z components + should be stored (default: 0). + + Returns + ------- + xs : `~astropy.units.Quantity` + With dimension 3 along ``xyz_axis``. + """ + # Add new axis in x, y, z so one can concatenate them around it. + # NOTE: just use np.stack once our minimum numpy version is 1.10. + result_ndim = self.ndim + 1 + if not -result_ndim <= xyz_axis < result_ndim: + msg = f"xyz_axis {xyz_axis} out of bounds [-{result_ndim}, {result_ndim})" + raise IndexError(msg) + if xyz_axis < 0: + xyz_axis += result_ndim + + # Get components to the same units (very fast for identical units) + # since np.concatenate cannot deal with quantity. + unit = self._x1.unit + + sh = self.shape + sh = (*sh[:xyz_axis], 1, *sh[xyz_axis:]) + components = [ + getattr(self, "_" + name).reshape(sh).to(unit).value + for name in self.attr_classes + ] + xs_value = np.concatenate(components, axis=xyz_axis) + return u.Quantity(xs_value, unit=unit, copy=COPY_IF_NEEDED) + + xyz = property(get_xyz) + + +class NDCartesianDifferential(NDMixin, coord.CartesianDifferential): + """Differentials in of points in ND cartesian coordinates. + + Parameters + ---------- + *d_x : `~astropy.units.Quantity` or array + The Cartesian coordinates of the differentials. If not quantity, + ``unit`` should be set. + unit : `~astropy.units.Unit` or str + If given, the differentials will be converted to this unit (or taken to + be in this unit if not given. + copy : bool, optional + If `True` (default), arrays will be copied rather than referenced. + """ + + base_representation = NDCartesianRepresentation + attr_classes = {} + + def __init__(self, d_x, unit=None, copy=True): + if unit is None: + unit = u.one if not hasattr(d_x[0], "unit") else d_x[0].unit + + d_x = u.Quantity(d_x, unit, copy=copy, subok=True) + copy = False + + self.attr_classes = {"d_x" + str(i): u.Quantity for i in range(1, len(d_x) + 1)} + + super(coord.CartesianDifferential, self).__init__(*d_x, copy=copy) + + ptype = None + for name, _ in self.attr_classes.items(): + if ptype is None: + ptype = getattr(self, "_" + name).unit.physical_type + + elif getattr(self, "_" + name).unit.physical_type != ptype: + raise u.UnitsError("All components should have matching physical types") + + cls = self.__class__ + if not hasattr(cls, name): + setattr( + cls, + name, + property( + _make_getter(name), + doc=(f"The '{name}' component of the points(s)."), + ), + ) + + def get_d_xyz(self, xyz_axis=0): + """Return a vector array of the x, y, and z coordinates. + + Parameters + ---------- + xyz_axis : int, optional + The axis in the final array along which the x, y, z components + should be stored (default: 0). + + Returns + ------- + d_xs : `~astropy.units.Quantity` + With dimension 3 along ``xyz_axis``. + """ + # Add new axis in x, y, z so one can concatenate them around it. + # NOTE: just use np.stack once our minimum numpy version is 1.10. + result_ndim = self.ndim + 1 + if not -result_ndim <= xyz_axis < result_ndim: + msg = f"xyz_axis {xyz_axis} out of bounds [-{result_ndim}, {result_ndim})" + raise IndexError(msg) + if xyz_axis < 0: + xyz_axis += result_ndim + + # Get components to the same units (very fast for identical units) + # since np.concatenate cannot deal with quantity. + unit = self._d_x1.unit + + sh = self.shape + sh = (*sh[:xyz_axis], 1, *sh[xyz_axis:]) + components = [ + getattr(self, "_" + name).reshape(sh).to(unit).value + for name in self.components + ] + xs_value = np.concatenate(components, axis=xyz_axis) + return u.Quantity(xs_value, unit=unit, copy=COPY_IF_NEEDED) + + d_xyz = property(get_d_xyz) diff --git a/gala/source/src/gala/dynamics/util.py b/gala/source/src/gala/dynamics/util.py new file mode 100644 index 0000000000000000000000000000000000000000..2a7fbb646399283f798a831ee0a1888a62a44900 --- /dev/null +++ b/gala/source/src/gala/dynamics/util.py @@ -0,0 +1,359 @@ +"""General dynamics utilities.""" + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +from scipy.signal import argrelmax, argrelmin + +from ..util import atleast_2d +from .core import PhaseSpacePosition, _get_rep_name + +__all__ = ["combine", "estimate_dt_n_steps", "peak_to_peak_period"] + + +def peak_to_peak_period(t, f, amplitude_threshold=1e-2): + """ + Estimate the period of a time series using peak-to-peak analysis. + + This function estimates the period of an oscillating time series by + identifying peaks and troughs and computing the mean time between them. + + Parameters + ---------- + t : array_like + Time grid aligned with the input time series. + f : array_like + A periodic time series to analyze. + amplitude_threshold : float, optional + A tolerance parameter for the minimum relative amplitude. The analysis + fails if the mean amplitude of oscillations isn't larger than this + threshold. Default is 1e-2. + + Returns + ------- + period : float or :class:`~astropy.units.Quantity` + The estimated period. Returns the same type as the input ``t``. + Returns NaN if the amplitude threshold is not met. + + Notes + ----- + This method works best for approximately sinusoidal time series with + well-defined peaks. For irregular or noisy data, consider smoothing + the input first. + """ + if hasattr(t, "unit"): + t_unit = t.unit + t = t.value + else: + t_unit = u.dimensionless_unscaled + + # find peaks + max_ix = argrelmax(f, mode="wrap")[0] + max_ix = max_ix[(max_ix != 0) & (max_ix != (len(f) - 1))] + + # find troughs + min_ix = argrelmin(f, mode="wrap")[0] + min_ix = min_ix[(min_ix != 0) & (min_ix != (len(f) - 1))] + + # neglect minor oscillations + if abs(np.mean(f[max_ix]) - np.mean(f[min_ix])) < amplitude_threshold: + return np.nan * t_unit + + # compute mean peak-to-peak + T_max = np.mean(t[max_ix[1:]] - t[max_ix[:-1]]) if len(max_ix) > 0 else np.nan + + # now compute mean trough-to-trough + T_min = np.mean(t[min_ix[1:]] - t[min_ix[:-1]]) if len(min_ix) > 0 else np.nan + + # then take the mean of these two + return np.mean([T_max, T_min]) * t_unit + + +def _autodetermine_initial_dt(w0, H, dE_threshold=1e-9, **integrate_kwargs): + if w0.shape and w0.shape[0] > 1: + raise ValueError( + "Only one set of initial conditions may be passed in at a time." + ) + + if dE_threshold is None: + return 1.0 + + dts = np.logspace(-3, 1, 8)[::-1] + base_n_steps = 1000 + + for dt in dts: + n_steps = round(base_n_steps / dt) + orbit = H.integrate_orbit(w0, dt=dt, n_steps=n_steps, **integrate_kwargs) + E = orbit.energy() + dE = np.abs((E[-1] - E[0]) / E[0]).value + + if dE < dE_threshold: + break + + return dt + + +def estimate_dt_n_steps( + w0, + hamiltonian, + n_periods, + n_steps_per_period, + dE_threshold=1e-9, + func=np.nanmax, + **integrate_kwargs, +): + """ + Estimate the timestep and number of steps for orbit integration. + + This function estimates appropriate integration parameters based on the + orbital period and desired sampling. It first integrates a short orbit + to determine the period, then calculates the timestep and number of + steps needed for the requested integration time. + + Parameters + ---------- + w0 : :class:`~gala.dynamics.PhaseSpacePosition` or array_like + Initial conditions for the orbit. + hamiltonian : :class:`~gala.potential.Hamiltonian` or :class:`~gala.potential.PotentialBase` + The Hamiltonian or potential to integrate the orbit in. + n_periods : int + Number of (maximum) orbital periods to integrate for. + n_steps_per_period : int + Number of integration steps to take per (maximum) orbital period. + dE_threshold : float, optional + Maximum fractional energy difference used to determine initial + timestep for the test integration. Set to ``None`` to ignore this + constraint. Default is 1e-9. + func : callable, optional + Function that determines which period to use when multiple periods + are found (e.g., for 3D orbits). Default is :func:`~numpy.nanmax`, + which uses the maximum period. Other options include + :func:`~numpy.nanmin`, :func:`~numpy.nanmean`, :func:`~numpy.nanmedian`. + **integrate_kwargs + Additional keyword arguments passed to the orbit integration method. + + Returns + ------- + dt : float + The recommended timestep for integration. + n_steps : int + The recommended number of integration steps. + + Raises + ------ + RuntimeError + If no period can be determined from the test orbit. + ValueError + If the computed timestep is zero or very small. + + Notes + ----- + This function works by: + 1. Integrating a test orbit to determine the characteristic periods + 2. Using the specified function to select the period to use + 3. Computing dt and n_steps based on the desired sampling + """ + if not isinstance(w0, PhaseSpacePosition): + w0 = np.asarray(w0) + w0 = PhaseSpacePosition.from_w(w0, units=hamiltonian.units, copy=False) + + from ..potential import Hamiltonian + + hamiltonian = Hamiltonian(hamiltonian) + + # integrate orbit + dt = _autodetermine_initial_dt( + w0, hamiltonian, dE_threshold=dE_threshold, **integrate_kwargs + ) + n_steps = round(10000 / dt) + orbit = hamiltonian.integrate_orbit(w0, dt=dt, n_steps=n_steps, **integrate_kwargs) + + # if loop, align circulation with Z and take R period + circ = orbit.circulation() + if np.any(circ): + orbit = orbit.align_circulation_with_z(circulation=circ) + cyl = orbit.represent_as(coord.CylindricalRepresentation) + + # convert to cylindrical coordinates + R = cyl.rho.value + phi = cyl.phi.value + z = cyl.z.value + + T = ( + np.array([peak_to_peak_period(orbit.t, f).value for f in [R, phi, z]]) + * orbit.t.unit + ) + + else: + T = ( + np.array([peak_to_peak_period(orbit.t, f).value for f in orbit.pos]) + * orbit.t.unit + ) + + # timestep from number of steps per period + T = func(T) + + if np.isnan(T): + raise RuntimeError("Failed to find period.") + + T = T.decompose(hamiltonian.units).value + dt = T / float(n_steps_per_period) + n_steps = round(n_periods * T / dt) + + if dt == 0.0 or dt < 1e-13: + raise ValueError("Timestep is zero or very small!") + + return dt, n_steps + + +def combine(objs): + """ + Combine multiple PhaseSpacePosition or Orbit objects into a single object. + + This function concatenates multiple objects of the same type into a single + object. All input objects must have the same type, dimensionality, reference + frame, and (for Orbit objects) the same time array and potential. + + Parameters + ---------- + objs : iterable + A sequence of :class:`~gala.dynamics.PhaseSpacePosition` or + :class:`~gala.dynamics.Orbit` objects to combine. All objects must + be of the same type. + + Returns + ------- + combined : :class:`~gala.dynamics.PhaseSpacePosition` or :class:`~gala.dynamics.Orbit` + A single object containing the combined data from all input objects. + The output will have the same type as the input objects. + + Raises + ------ + ValueError + If the input is empty or contains only one object, or if the objects + have different reference frames, potentials, or time arrays. + TypeError + If the objects are not all of the same type, or if they are not + PhaseSpacePosition or Orbit instances. + NotImplementedError + If the objects do not have Cartesian representations. + + Examples + -------- + Combine multiple phase-space positions:: + + >>> import gala.dynamics as gd + >>> import astropy.units as u + >>> w1 = gd.PhaseSpacePosition(pos=[1,0,0]*u.kpc, vel=[0,1,0]*u.km/u.s) + >>> w2 = gd.PhaseSpacePosition(pos=[0,1,0]*u.kpc, vel=[1,0,0]*u.km/u.s) + >>> combined = gd.combine([w1, w2]) + >>> combined.shape + (2,) + + Notes + ----- + Currently, this function only works for objects with Cartesian coordinate + representations. The objects are combined by concatenating their position + and velocity arrays along the appropriate axis. + """ + from .orbit import Orbit + + # have to special-case this because they are iterable + if isinstance(objs, PhaseSpacePosition | Orbit) or ( + not np.iterable(objs) or len(objs) < 1 + ): + raise ValueError("You must pass a non-empty iterable to combine.") + + if len(objs) == 1: # short circuit + return objs[0] + + # We only support these two types to combine: + if objs[0].__class__ not in {PhaseSpacePosition, Orbit}: + raise TypeError("Objects must be either PhaseSpacePosition or Orbit instances.") + + # Validate objects: + # - check type + # - check dimensionality + # - check frame, potential + # - Right now, we only support Cartesian + for obj in objs: + # Check to see if they are all the same type of object: + if obj.__class__ != objs[0].__class__: + raise TypeError("All objects must have the same type.") + + # Make sure they have same dimensionality + if obj.ndim != objs[0].ndim: + raise ValueError("All objects must have the same ndim.") + + # Check that all objects have the same reference frame + if obj.frame != objs[0].frame: + raise ValueError("All objects must have the same frame.") + + # Check that (for orbits) they all have the same potential + if hasattr(obj, "potential") and obj.potential != objs[0].potential: + raise ValueError("All objects must have the same potential.") + + # For orbits, time arrays must be the same + if ( + hasattr(obj, "t") + and obj.t is not None + and objs[0].t is not None + and not u.allclose(obj.t, objs[0].t, atol=1e-13 * objs[0].t.unit) + ): + raise ValueError("All orbits must have the same time array.") + + if "cartesian" not in _get_rep_name(obj.pos): + raise NotImplementedError( + "Currently, combine only works for Cartesian-represented objects." + ) + + # Now we prepare the positions, velocities: + if objs[0].__class__ == PhaseSpacePosition: + pos = [] + vel = [] + + for i, obj in enumerate(objs): + if i == 0: + pos_unit = obj.pos.xyz.unit + vel_unit = obj.vel.d_xyz.unit + + pos.append(atleast_2d(obj.pos.xyz.to(pos_unit).value, insert_axis=1)) + vel.append(atleast_2d(obj.vel.d_xyz.to(vel_unit).value, insert_axis=1)) + + pos = np.concatenate(pos, axis=1) * pos_unit + vel = np.concatenate(vel, axis=1) * vel_unit + + return PhaseSpacePosition(pos=pos, vel=vel, frame=objs[0].frame, copy=False) + + if objs[0].__class__ == Orbit: + pos = [] + vel = [] + + for i, obj in enumerate(objs): + if i == 0: + pos_unit = obj.pos.xyz.unit + vel_unit = obj.vel.d_xyz.unit + + p = obj.pos.xyz.to(pos_unit).value + v = obj.vel.d_xyz.to(vel_unit).value + + if p.ndim < 3: + p = p.reshape((*p.shape, 1)) + v = v.reshape((*v.shape, 1)) + + pos.append(p) + vel.append(v) + + pos = np.concatenate(pos, axis=2) * pos_unit + vel = np.concatenate(vel, axis=2) * vel_unit + + return Orbit( + pos=pos, + vel=vel, + t=objs[0].t, + frame=objs[0].frame, + potential=objs[0].potential, + copy=False, + ) + + raise RuntimeError("should never get here...") diff --git a/gala/source/src/gala/integrate/__init__.py b/gala/source/src/gala/integrate/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ddb8b4678f4b5172a7640423eba7d7d914b71d6b --- /dev/null +++ b/gala/source/src/gala/integrate/__init__.py @@ -0,0 +1,3 @@ +from .lookup import * +from .pyintegrators import * +from .timespec import * diff --git a/gala/source/src/gala/integrate/core.py b/gala/source/src/gala/integrate/core.py new file mode 100644 index 0000000000000000000000000000000000000000..4a514381d85a3464a32bf12a8a815e977a718ad3 --- /dev/null +++ b/gala/source/src/gala/integrate/core.py @@ -0,0 +1,191 @@ +"""Base class for integrators.""" + +from abc import ABCMeta, abstractmethod + +import numpy as np +from astropy.utils.decorators import deprecated + +from gala.units import DimensionlessUnitSystem, UnitSystem + +__all__ = ["Integrator"] + + +class Integrator(metaclass=ABCMeta): + """ + Abstract base class for numerical integrators. + + This class provides a common interface for different numerical integration + schemes used to integrate orbits in gravitational potentials. All concrete + integrator classes should inherit from this base class. + + Parameters + ---------- + func : callable + A function that computes the time derivatives of the phase-space + coordinates. Should have the signature ``func(t, w, *func_args)`` + where ``t`` is the time, ``w`` is the current phase-space position, + and ``*func_args`` are additional arguments. + func_args : tuple, optional + Additional arguments to pass to the derivative function. Default is (). + func_units : :class:`~gala.units.UnitSystem`, optional + The unit system assumed by the integrand function. If not provided, + uses a dimensionless unit system. + progress : bool, optional + Whether to display a progress bar during integration. Requires the + ``tqdm`` package. Default is False. + save_all : bool, optional + Whether to save the orbit at all integration timesteps. If False, + only saves the final state. Default is True. + + Raises + ------ + ValueError + If ``func`` is not callable. + ImportError + If ``progress=True`` but the ``tqdm`` package is not installed. + """ + + def __init__( + self, + func, + func_args=(), + func_units=None, + progress=False, + save_all=True, + ): + if not callable(func): + raise ValueError("func must be a callable object, e.g., a function.") + + self.F = func + self._func_args = func_args + + if func_units is not None and not isinstance( + func_units, DimensionlessUnitSystem + ): + func_units = UnitSystem(func_units) + else: + func_units = DimensionlessUnitSystem() + self._func_units = func_units + + self.progress = bool(progress) + self.save_all = save_all + + def _get_range_func(self): + if self.progress: + try: + from tqdm import trange + + return trange + except ImportError as e: + msg = ( + "tqdm must be installed to use progress=True when running " + f"{self.__class__.__name__}" + ) + raise ImportError(msg) from e + + return range + + def _prepare_ws(self, w0, mmap, n_steps): + """ + Decide how to make the return array. If ``mmap`` is False, this returns a full + array of zeros, but with the correct shape as the output. If ``mmap`` is True, + return a pointer to a memory-mapped array. The latter is particularly useful for + integrating a large number of orbits or integrating a large number of time + steps. + """ + from ..dynamics import PhaseSpacePosition + + if not isinstance(w0, PhaseSpacePosition): + w0 = PhaseSpacePosition.from_w(w0, copy=False) + + arr_w0 = w0.w(self._func_units) + + self.ndim, self.norbits = arr_w0.shape + self.ndim //= 2 + + if self.save_all: + return_shape = (2 * self.ndim, n_steps + 1, self.norbits) + else: + return_shape = (2 * self.ndim, self.norbits) + + if mmap is None: + # create the return arrays + ws = np.zeros(return_shape, dtype=float) + + else: + if mmap.shape != return_shape: + raise ValueError( + "Shape of memory-mapped array doesn't match expected shape of " + f"return array ({mmap.shape} vs {return_shape})" + ) + + if not mmap.flags.writeable: + raise TypeError( + f"Memory-mapped array must be a writable mode, not '{mmap.mode}'" + ) + + ws = mmap + + return w0, arr_w0, ws + + def _handle_output(self, w0, t, w): + """ """ + if w.shape[-1] == 1: + w = w[..., 0] + + pos_unit = self._func_units["length"] + t_unit = self._func_units["time"] + vel_unit = pos_unit / t_unit + + from ..dynamics import Orbit + + return Orbit( + pos=w[: self.ndim] * pos_unit, + vel=w[self.ndim :] * vel_unit, + t=t * t_unit, + copy=False, + ) + + @deprecated("1.9", alternative="Integrator call method") + def run(self, w0, mmap=None, **time_spec): + """Run the integrator starting from the specified phase-space position. + + .. deprecated:: 1.9 + Use the ``__call__`` method instead. + """ + return self(w0, mmap=mmap, **time_spec) + + @abstractmethod + def __call__(self, w0, mmap=None, **time_spec): + """ + Run the integrator starting from the specified initial conditions. + + This method integrates the orbit forward in time from the given + initial phase-space position according to the time specification. + + Parameters + ---------- + w0 : :class:`~gala.dynamics.PhaseSpacePosition` + Initial conditions for the integration. + mmap : :class:`~numpy.ndarray`, optional + A pre-allocated memory-mapped array to store the results. + Must have the correct shape for the expected output. + **time_spec + Keyword arguments specifying the integration time. Accepted + combinations include: + + * ``dt, n_steps[, t1]`` : Fixed timestep and number of steps + * ``dt, t1, t2`` : Fixed timestep with start and end times + * ``t`` : Array of specific times to integrate to + + Returns + ------- + orbit : :class:`~gala.dynamics.Orbit` + The integrated orbit containing positions, velocities, and times. + + Notes + ----- + The time specification is parsed by + :func:`~gala.integrate.timespec.parse_time_specification`. See that + function's documentation for more details on the accepted formats. + """ diff --git a/gala/source/src/gala/integrate/cyintegrators/__init__.py b/gala/source/src/gala/integrate/cyintegrators/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3b3dcddae7e64a3e83a5efe64eba5a28f4a0df32 --- /dev/null +++ b/gala/source/src/gala/integrate/cyintegrators/__init__.py @@ -0,0 +1,3 @@ +from .dop853 import dop853_integrate_hamiltonian +from .leapfrog import leapfrog_integrate_hamiltonian +from .ruth4 import ruth4_integrate_hamiltonian diff --git a/gala/source/src/gala/integrate/cyintegrators/dop853.pxd b/gala/source/src/gala/integrate/cyintegrators/dop853.pxd new file mode 100644 index 0000000000000000000000000000000000000000..a767040cdbf773e52a3be6de8f1c105c96eff959 --- /dev/null +++ b/gala/source/src/gala/integrate/cyintegrators/dop853.pxd @@ -0,0 +1,76 @@ +# cython: language_level=3 +# cython: language=c++ + +from libc.stdio cimport FILE + +from ...potential.potential.cpotential cimport CPotential +from ...potential.frame.cframe cimport CFrameType + +cdef extern from "dopri/dop853.h": + ctypedef void (*FcnEqDiff)(unsigned n, double x, double *y, double *f, + CPotential *p, CFrameType *fr, unsigned norbits, + unsigned nbody, void *args) nogil + ctypedef void (*SolTrait)(long nr, double xold, double x, double* y, + unsigned n, int* irtrn) + ctypedef struct Dop853DenseState: + double *rcont1 + double *rcont2 + double *rcont3 + double *rcont4 + double *rcont5 + double *rcont6 + double *rcont7 + double *rcont8 + double xold + double hout + unsigned nrds + unsigned *indir + + void Fwrapper (unsigned ndim, double t, double *w, double *f, + CPotential *p, CFrameType *fr, unsigned norbits, unsigned nbody) except + + void Fwrapper_T (unsigned ndim, double t, double *w, double *f, + CPotential *p, CFrameType *fr, unsigned norbits, unsigned nbody) except + + void Fwrapper_direct_nbody(unsigned ndim, double t, double *w, double *f, + CPotential *p, CFrameType *fr, + unsigned norbits, unsigned nbody, void *args) except + nogil + + # See dop853.h for full description of all input parameters + int dop853 (unsigned n, FcnEqDiff fn, + CPotential *p, CFrameType *fr, unsigned n_orbits, unsigned nbody, + void *args, + double x, double* y, double xend, + double* rtoler, double* atoler, int itoler, SolTrait solout, + int iout, FILE* fileout, double uround, double safe, double fac1, + double fac2, double beta, double hmax, double h, long nmax, int meth, + long nstiff, unsigned nrdens, unsigned* icont, unsigned licont, + Dop853DenseState* dense_state, + double* tout, unsigned ntout, double* yout) except + + + Dop853DenseState* dop853_dense_state_alloc(unsigned nrdens, unsigned n) nogil + void dop853_dense_state_free(Dop853DenseState* state, unsigned n) nogil + + double contd8(unsigned ii, double x) + # Thread-safe dense output function + double contd8_threadsafe(Dop853DenseState *state, unsigned ii, double x) + + double six_norm (double *x) + +cdef void dop853_step(CPotential *cp, CFrameType *cf, FcnEqDiff F, + double *w, double t1, double t2, double dt0, + int ndim, int norbits, int nbody, void *args, + double atol, double rtol, int nmax, int nstiff, + unsigned err_if_fail, unsigned log_output,) + +cdef dop853_helper( + CPotential *cp, CFrameType *cf, FcnEqDiff F, + double[:,::1] w0, double[::1] t, + int ndim, int norbits, int nbody, void *args, int ntimes, + double atol, double rtol, int nmax, double dt_max, + int nstiff, + unsigned err_if_fail, + unsigned log_output, + unsigned save_all=? +) + +# cpdef dop853_integrate_hamiltonian(hamiltonian, double[:,::1] w0, double[::1] t, +# double atol=?, double rtol=?, int nmax=?) diff --git a/gala/source/src/gala/integrate/cyintegrators/dop853.pyx b/gala/source/src/gala/integrate/cyintegrators/dop853.pyx new file mode 100644 index 0000000000000000000000000000000000000000..4262f1fe31054ff8dded53500bb7bd3a03ec13b9 --- /dev/null +++ b/gala/source/src/gala/integrate/cyintegrators/dop853.pyx @@ -0,0 +1,250 @@ +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + +""" DOP853 integration in Cython. """ + +import sys +from libc.stdio cimport * +from libc.stdlib cimport malloc, free +from libc.string cimport strcpy + +import numpy as np +cimport numpy as np +np.import_array() + +from cpython.exc cimport PyErr_CheckSignals +from ...potential.potential.cpotential cimport CPotentialWrapper, CPotential +from ...potential.frame.cframe cimport CFrameWrapper, CFrameType +from .dop853 cimport dop853, Fwrapper_T, FcnEqDiff, six_norm, SolTrait, dop853_dense_state_alloc, dop853_dense_state_free, Dop853DenseState + + +# LEGACY FUNCTION: don't use this (used by lyapunov functionality and mockstream) +cdef void dop853_step( + CPotential *cp, CFrameType *cf, FcnEqDiff F, + double *w, double t1, double t2, double dt0, + int ndim, int norbits, int nbody, void *args, + double atol, double rtol, int nmax, + int nstiff, + unsigned err_if_fail, + unsigned log_output +): + + cdef: + int res + SolTrait solout = NULL + FILE* cfile + + if log_output: + cfile = stdout + else: + cfile = NULL + + res = dop853( + ndim*norbits, F, cp, cf, + norbits, nbody, args, + t1, w, t2, + &rtol, &atol, 0, # itoler = 0 for scalar tolerances + NULL, # solout: Callback function for output + 0, # iout: Controls solout call + cfile, # fileout: file pointer for logging + 0.0, # uround: Machine precision (0.0 = use default) + 0.0, # safe: Safety factor + 0.0, # fac1: Step size control parameter + 0.0, # fac2: Step size control parameter + 0.0, # beta: Stabilizatin for step size control + 0.0, # hmax: maximum allowed step size + dt0, # h: Initial step size + nmax, # nmax: maximum number of integration steps + 0, # meth + 1, # nstiff: frequency of stiffness detect + 0, # nrdens: number of components for dense output + NULL, # icont: indices for components + 0, # licont: length of the icont array + NULL, # dense_state + NULL, # array of output times + 0, # number of output times + NULL # output array for dense output + ) + + if res < 0 and err_if_fail == 1: + raise RuntimeError(f"Integration failed with code {res}") + + +cdef class DenseOutputState: + cdef Dop853DenseState* state + cdef unsigned n + def __cinit__(self, unsigned nrdens, unsigned n): + self.state = dop853_dense_state_alloc(nrdens, n) + self.n = n + if self.state is NULL: + raise MemoryError("Could not allocate Dop853DenseState") + def __dealloc__(self): + if self.state is not NULL: + dop853_dense_state_free(self.state, self.n) + +cdef dop853_helper( + CPotential *cp, + CFrameType *cf, + FcnEqDiff F, + double[:, ::1] w0, + double[::1] t, + int ndim, + int norbits, + int nbody, + void *args, + int ntimes, + double atol, + double rtol, + int nmax, + double dt_max, + int nstiff, + unsigned err_if_fail, + unsigned log_output, + unsigned save_all=1, +): + """ + w0: any shape (typically (ndim, n) or (n, ndim)) + returns: shape (ntimes, *w0.shape) if save_all else w0.shape + """ + cdef: + double[:, ::1] w + + int res + FILE* cfile + + # Used when save_all = 1 + unsigned size = ndim * norbits + unsigned nrdens + DenseOutputState dense_state = DenseOutputState(size, size) + int ntot = ntimes * norbits * ndim + double[:, ::1] output_w = np.empty((ntimes, size)) + Dop853DenseState* state + double* output_ptr + + # w0 may be (ndim, n) or (n, ndim); this routine is agnostic, + # returning shape (ntimes, n * ndim) if save_all else (n * ndim,) + input_shape = tuple(w0.shape)[:w0.ndim] + w = w0.copy() + + if save_all: + output_ptr = &output_w[0, 0] + state = dense_state.state + nrdens = size + else: + output_ptr = NULL + state = NULL + nrdens = 0 + + if ntimes < 1: + raise ValueError("ntimes must be greater than 1") + + if log_output: + cfile = stdout + else: + cfile = NULL + + if w.size != size: + raise ValueError(f"w0 must be of shape ({norbits}, {ndim}), got size {w.size}") + + # FUTURE: based on the function signature, it looks like dop853() + # cares about which dimension is norbits vs. ndim, but in reality + # it just uses norbits * ndim. So we could probably simplify this. + res = dop853( + norbits * ndim, F, cp, cf, + norbits, nbody, args, + t[0], &w[0, 0], t[ntimes-1], + &rtol, &atol, 0, # itoler = 0 for scalar tolerances, 1 for array + NULL, # solout: Callback function for output at each accepted step + 0, # iout: Controls solout call (0: never, 1: at each step, 2: dense output) + cfile, # fileout: file pointer for logging + np.finfo(float).eps, # uround: Machine precision + 0.0, # safe: Safety factor for step size control (0.0 = use default) + 0.0, # fac1: Step size control parameter (0.0 = use default) + 0.0, # fac2: Step size control parameter (0.0 = use default) + 0.0, # beta: Stabilization for step size control (0.0 = use default) + dt_max, # hmax: maximum allowed step size (0.0 = no limit) + t[1] - t[0], # h: Initial step size + nmax, # nmax: maximum number of integration steps (0 = 100_000) + 1, # meth: set to 1 and don't think about it + nstiff, # nstiff: frequency of stiffness detect (set to -1 to disable) + nrdens, # nrdens: number of components where dense output is needed + NULL, # icont: indices for which components get dense out (NULL = all) + 0, # licont: length of the icont array (ignored if icont=NULL) + state, + &t[0], # array of output times + ntimes, # number of output times + output_ptr # output array for dense output + ) + + if res < 0 and err_if_fail == 1: + raise RuntimeError(f"Integration failed with code {res}") + + if save_all: + out = np.array(output_w, copy=False) + out = out.reshape((ntimes,) + input_shape) + else: + out = np.array(w, copy=False) + out = out.reshape(input_shape) + return out + + +cpdef dop853_integrate_hamiltonian( + hamiltonian, double[:, ::1] w0, double[::1] t, + double atol=1E-10, double rtol=1E-10, int nmax=0, double dt_max = 0., + int nstiff=0, int save_all=1, int err_if_fail=1, int log_output=0, + int nbatch=100, +): + """ + w0: shape (ndim, n) + returns: shape (ndim, [ntimes,] n) + """ + + if not hamiltonian.c_enabled: + raise TypeError("Input Hamiltonian object does not support C-level access.") + + cdef: + int i, j, k + unsigned ndim = w0.shape[0] + unsigned norbits = w0.shape[1] + void *args + + # define full array of times + int ntimes = len(t) + + # whoa, so many dots + CPotential* cp = ((hamiltonian.potential.c_instance)).cpotential + CFrameType cf = ((hamiltonian.frame.c_instance)).cframe + + if save_all: + wres = np.empty((ndim, ntimes, norbits)) + else: + wres = np.empty((ndim, norbits)) + + for i in range(0, norbits, nbatch): + # do the integration in batches for performance + # FUTURE: this batching could probably be done in C directly + j = min(i + nbatch, norbits) + wbatch = w0[:, i:j] + # 0 below is for nbody - we ignore that in this test particle integration + wbatchout = dop853_helper( + cp, &cf, Fwrapper_T, + wbatch, t, + ndim, j - i, 0, NULL, ntimes, + atol, rtol, nmax, dt_max, + nstiff=nstiff, + save_all=save_all, err_if_fail=err_if_fail, log_output=log_output, + ) + if save_all: + wres[:, :, i:j] = wbatchout.transpose(1, 0, 2) + else: + wres[:, i:j] = wbatchout + + if save_all: + return np.asarray(t), wres + else: + return np.asarray(t[-1:]), wres diff --git a/gala/source/src/gala/integrate/cyintegrators/dopri/__init__.py b/gala/source/src/gala/integrate/cyintegrators/dopri/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/gala/source/src/gala/integrate/cyintegrators/dopri/dop853.cpp b/gala/source/src/gala/integrate/cyintegrators/dopri/dop853.cpp new file mode 100644 index 0000000000000000000000000000000000000000..bee61b7d0bab1693e75c5049c6161dd7eb21deee --- /dev/null +++ b/gala/source/src/gala/integrate/cyintegrators/dopri/dop853.cpp @@ -0,0 +1,1015 @@ +#include +#include +// #include +#include "dop853.h" +#include +#include +#include + +static double sign(double a, double b) { + return (b < 0.0) ? -fabs(a) : fabs(a); + +} /* sign */ + +static double min_d(double a, double b) { return (a < b) ? a : b; } /* min_d */ + +static double max_d(double a, double b) { return (a > b) ? a : b; } /* max_d */ + +static double hinit(unsigned n, FcnEqDiff fcn, CPotential *p, CFrameType *fr, + unsigned norbits, unsigned nbody, void *args, double x, double *y, + double posneg, double *f0, double *f1, double *yy1, int iord, + double hmax, double *atoler, double *rtoler, int itoler) { + double dnf, dny, atoli, rtoli, sk, h, h1, der2, der12, sqr; + unsigned i; + + dnf = 0.0; + dny = 0.0; + atoli = atoler[0]; + rtoli = rtoler[0]; + + if (!itoler) + for (i = 0; i < n; i++) { + sk = atoli + rtoli * fabs(y[i]); + sqr = f0[i] / sk; + dnf += sqr * sqr; + sqr = y[i] / sk; + dny += sqr * sqr; + } + else + for (i = 0; i < n; i++) { + sk = atoler[i] + rtoler[i] * fabs(y[i]); + sqr = f0[i] / sk; + dnf += sqr * sqr; + sqr = y[i] / sk; + dny += sqr * sqr; + } + + if ((dnf <= 1.0E-10) || (dny <= 1.0E-10)) + h = 1.0E-6; + else + h = sqrt(dny / dnf) * 0.01; + + h = min_d(h, hmax); + h = sign(h, posneg); + + /* perform an explicit Euler step */ + for (i = 0; i < n; i++) + yy1[i] = y[i] + h * f0[i]; + fcn(n, x + h, yy1, f1, p, fr, norbits, nbody, args); + + /* estimate the second derivative of the solution */ + der2 = 0.0; + if (!itoler) + for (i = 0; i < n; i++) { + sk = atoli + rtoli * fabs(y[i]); + sqr = (f1[i] - f0[i]) / sk; + der2 += sqr * sqr; + } + else + for (i = 0; i < n; i++) { + sk = atoler[i] + rtoler[i] * fabs(y[i]); + sqr = (f1[i] - f0[i]) / sk; + der2 += sqr * sqr; + } + der2 = sqrt(der2) / h; + + /* step size is computed such that h**iord * max_d(norm(f0),norm(der2)) = 0.01 */ + der12 = max_d(fabs(der2), sqrt(dnf)); + if (der12 <= 1.0E-15) + h1 = max_d(1.0E-6, fabs(h) * 1.0E-3); + else + h1 = pow(0.01 / der12, 1.0 / (double)iord); + h = min_d(100.0 * fabs(h), min_d(h1, hmax)); + + return sign(h, posneg); + +} /* hinit */ + +/* core integrator */ +static int dopcor(unsigned n, FcnEqDiff fcn, CPotential *p, CFrameType *fr, + unsigned norbits, unsigned nbody, void *args, double x, double *y, + double xend, double hmax, double h, double *rtoler, double *atoler, + int itoler, FILE *fileout, SolTrait solout, int iout, long nmax, + double uround, int meth, long nstiff, double safe, double beta, + double fac1, double fac2, unsigned *icont, + Dop853DenseState *dense_state, double *yy1, double *k1, double *k2, + double *k3, double *k4, double *k5, double *k6, double *k7, + double *k8, double *k9, double *k10, double *output_times, + int n_output_times, double *output_y) { + double facold, expo1, fac, facc1, facc2, fac11, posneg, xph; + double atoli, rtoli, hlamb, err, sk, hnew, yd0, ydiff, bspl; + double stnum, stden, sqr, err2, erri, deno; + int iasti, iord, irtrn, reject, last, nonsti; + unsigned i, j; + double c2, c3, c4, c5, c6, c7, c8, c9, c10, c11, c14, c15, c16; + double b1, b6, b7, b8, b9, b10, b11, b12, bhh1, bhh2, bhh3; + double er1, er6, er7, er8, er9, er10, er11, er12; + double a21, a31, a32, a41, a43, a51, a53, a54, a61, a64, a65, a71, a74, a75, a76; + double a81, a84, a85, a86, a87, a91, a94, a95, a96, a97, a98; + double a101, a104, a105, a106, a107, a108, a109; + double a111, a114, a115, a116, a117, a118, a119, a1110; + double a121, a124, a125, a126, a127, a128, a129, a1210, a1211; + double a141, a147, a148, a149, a1410, a1411, a1412, a1413; + double a151, a156, a157, a158, a1511, a1512, a1513, a1514; + double a161, a166, a167, a168, a169, a1613, a1614, a1615; + double d41, d46, d47, d48, d49, d410, d411, d412, d413, d414, d415, d416; + double d51, d56, d57, d58, d59, d510, d511, d512, d513, d514, d515, d516; + double d61, d66, d67, d68, d69, d610, d611, d612, d613, d614, d615, d616; + double d71, d76, d77, d78, d79, d710, d711, d712, d713, d714, d715, d716; + int output_idx = 0; + long nfcn = 0; + long nstep = 0; + long naccpt = 0; + long nrejct = 0; + + /* initialisations */ + switch (meth) { + case 1: + + c2 = 0.526001519587677318785587544488E-01; + c3 = 0.789002279381515978178381316732E-01; + c4 = 0.118350341907227396726757197510E+00; + c5 = 0.281649658092772603273242802490E+00; + c6 = 0.333333333333333333333333333333E+00; + c7 = 0.25E+00; + c8 = 0.307692307692307692307692307692E+00; + c9 = 0.651282051282051282051282051282E+00; + c10 = 0.6E+00; + c11 = 0.857142857142857142857142857142E+00; + c14 = 0.1E+00; + c15 = 0.2E+00; + c16 = 0.777777777777777777777777777778E+00; + + b1 = 5.42937341165687622380535766363E-2; + b6 = 4.45031289275240888144113950566E0; + b7 = 1.89151789931450038304281599044E0; + b8 = -5.8012039600105847814672114227E0; + b9 = 3.1116436695781989440891606237E-1; + b10 = -1.52160949662516078556178806805E-1; + b11 = 2.01365400804030348374776537501E-1; + b12 = 4.47106157277725905176885569043E-2; + + bhh1 = 0.244094488188976377952755905512E+00; + bhh2 = 0.733846688281611857341361741547E+00; + bhh3 = 0.220588235294117647058823529412E-01; + + er1 = 0.1312004499419488073250102996E-01; + er6 = -0.1225156446376204440720569753E+01; + er7 = -0.4957589496572501915214079952E+00; + er8 = 0.1664377182454986536961530415E+01; + er9 = -0.3503288487499736816886487290E+00; + er10 = 0.3341791187130174790297318841E+00; + er11 = 0.8192320648511571246570742613E-01; + er12 = -0.2235530786388629525884427845E-01; + + a21 = 5.26001519587677318785587544488E-2; + a31 = 1.97250569845378994544595329183E-2; + a32 = 5.91751709536136983633785987549E-2; + a41 = 2.95875854768068491816892993775E-2; + a43 = 8.87627564304205475450678981324E-2; + a51 = 2.41365134159266685502369798665E-1; + a53 = -8.84549479328286085344864962717E-1; + a54 = 9.24834003261792003115737966543E-1; + a61 = 3.7037037037037037037037037037E-2; + a64 = 1.70828608729473871279604482173E-1; + a65 = 1.25467687566822425016691814123E-1; + a71 = 3.7109375E-2; + a74 = 1.70252211019544039314978060272E-1; + a75 = 6.02165389804559606850219397283E-2; + a76 = -1.7578125E-2; + + a81 = 3.70920001185047927108779319836E-2; + a84 = 1.70383925712239993810214054705E-1; + a85 = 1.07262030446373284651809199168E-1; + a86 = -1.53194377486244017527936158236E-2; + a87 = 8.27378916381402288758473766002E-3; + a91 = 6.24110958716075717114429577812E-1; + a94 = -3.36089262944694129406857109825E0; + a95 = -8.68219346841726006818189891453E-1; + a96 = 2.75920996994467083049415600797E1; + a97 = 2.01540675504778934086186788979E1; + a98 = -4.34898841810699588477366255144E1; + a101 = 4.77662536438264365890433908527E-1; + a104 = -2.48811461997166764192642586468E0; + a105 = -5.90290826836842996371446475743E-1; + a106 = 2.12300514481811942347288949897E1; + a107 = 1.52792336328824235832596922938E1; + a108 = -3.32882109689848629194453265587E1; + a109 = -2.03312017085086261358222928593E-2; + + a111 = -9.3714243008598732571704021658E-1; + a114 = 5.18637242884406370830023853209E0; + a115 = 1.09143734899672957818500254654E0; + a116 = -8.14978701074692612513997267357E0; + a117 = -1.85200656599969598641566180701E1; + a118 = 2.27394870993505042818970056734E1; + a119 = 2.49360555267965238987089396762E0; + a1110 = -3.0467644718982195003823669022E0; + a121 = 2.27331014751653820792359768449E0; + a124 = -1.05344954667372501984066689879E1; + a125 = -2.00087205822486249909675718444E0; + a126 = -1.79589318631187989172765950534E1; + a127 = 2.79488845294199600508499808837E1; + a128 = -2.85899827713502369474065508674E0; + a129 = -8.87285693353062954433549289258E0; + a1210 = 1.23605671757943030647266201528E1; + a1211 = 6.43392746015763530355970484046E-1; + + a141 = 5.61675022830479523392909219681E-2; + a147 = 2.53500210216624811088794765333E-1; + a148 = -2.46239037470802489917441475441E-1; + a149 = -1.24191423263816360469010140626E-1; + a1410 = 1.5329179827876569731206322685E-1; + a1411 = 8.20105229563468988491666602057E-3; + a1412 = 7.56789766054569976138603589584E-3; + a1413 = -8.298E-3; + + a151 = 3.18346481635021405060768473261E-2; + a156 = 2.83009096723667755288322961402E-2; + a157 = 5.35419883074385676223797384372E-2; + a158 = -5.49237485713909884646569340306E-2; + a1511 = -1.08347328697249322858509316994E-4; + a1512 = 3.82571090835658412954920192323E-4; + a1513 = -3.40465008687404560802977114492E-4; + a1514 = 1.41312443674632500278074618366E-1; + a161 = -4.28896301583791923408573538692E-1; + a166 = -4.69762141536116384314449447206E0; + a167 = 7.68342119606259904184240953878E0; + a168 = 4.06898981839711007970213554331E0; + a169 = 3.56727187455281109270669543021E-1; + a1613 = -1.39902416515901462129418009734E-3; + a1614 = 2.9475147891527723389556272149E0; + a1615 = -9.15095847217987001081870187138E0; + + d41 = -0.84289382761090128651353491142E+01; + d46 = 0.56671495351937776962531783590E+00; + d47 = -0.30689499459498916912797304727E+01; + d48 = 0.23846676565120698287728149680E+01; + d49 = 0.21170345824450282767155149946E+01; + d410 = -0.87139158377797299206789907490E+00; + d411 = 0.22404374302607882758541771650E+01; + d412 = 0.63157877876946881815570249290E+00; + d413 = -0.88990336451333310820698117400E-01; + d414 = 0.18148505520854727256656404962E+02; + d415 = -0.91946323924783554000451984436E+01; + d416 = -0.44360363875948939664310572000E+01; + + d51 = 0.10427508642579134603413151009E+02; + d56 = 0.24228349177525818288430175319E+03; + d57 = 0.16520045171727028198505394887E+03; + d58 = -0.37454675472269020279518312152E+03; + d59 = -0.22113666853125306036270938578E+02; + d510 = 0.77334326684722638389603898808E+01; + d511 = -0.30674084731089398182061213626E+02; + d512 = -0.93321305264302278729567221706E+01; + d513 = 0.15697238121770843886131091075E+02; + d514 = -0.31139403219565177677282850411E+02; + d515 = -0.93529243588444783865713862664E+01; + d516 = 0.35816841486394083752465898540E+02; + + d61 = 0.19985053242002433820987653617E+02; + d66 = -0.38703730874935176555105901742E+03; + d67 = -0.18917813819516756882830838328E+03; + d68 = 0.52780815920542364900561016686E+03; + d69 = -0.11573902539959630126141871134E+02; + d610 = 0.68812326946963000169666922661E+01; + d611 = -0.10006050966910838403183860980E+01; + d612 = 0.77771377980534432092869265740E+00; + d613 = -0.27782057523535084065932004339E+01; + d614 = -0.60196695231264120758267380846E+02; + d615 = 0.84320405506677161018159903784E+02; + d616 = 0.11992291136182789328035130030E+02; + + d71 = -0.25693933462703749003312586129E+02; + d76 = -0.15418974869023643374053993627E+03; + d77 = -0.23152937917604549567536039109E+03; + d78 = 0.35763911791061412378285349910E+03; + d79 = 0.93405324183624310003907691704E+02; + d710 = -0.37458323136451633156875139351E+02; + d711 = 0.10409964950896230045147246184E+03; + d712 = 0.29840293426660503123344363579E+02; + d713 = -0.43533456590011143754432175058E+02; + d714 = 0.96324553959188282948394950600E+02; + d715 = -0.39177261675615439165231486172E+02; + d716 = -0.14972683625798562581422125276E+03; + + break; + } + + facold = 1.0E-4; + expo1 = 1.0 / 8.0 - beta * 0.2; + facc1 = 1.0 / fac1; + facc2 = 1.0 / fac2; + posneg = sign(1.0, xend - x); + + /* initial preparations */ + atoli = atoler[0]; + rtoli = rtoler[0]; + last = 0; + hlamb = 0.0; + iasti = 0; + fcn(n, x, y, k1, p, fr, norbits, nbody, args); + hmax = fabs(hmax); + iord = 8; + + if (h == 0.0) + h = hinit(n, fcn, p, fr, norbits, nbody, args, x, y, posneg, k1, k2, k3, iord, hmax, + atoler, rtoler, itoler); + + nfcn += 2; + reject = 0; + if (dense_state) + dense_state->xold = x; + + if (iout) { + irtrn = 1; + if (dense_state) { + dense_state->hout = 1.0; + dense_state->xold = x; + } + solout(naccpt + 1, x, x, y, n, &irtrn); + if (irtrn < 0) { + if (fileout) + fprintf(fileout, "Exit of dop853 at x = %.16e\r\n", x); + return 2; + } + } + + /* basic integration step */ + while (1) { + if (nstep > nmax) { + if (fileout) + fprintf(fileout, + "Exit of dop853 at x = %.16e, more than nmax = %li are needed - nstep " + "= %li\n", + x, nmax, nstep); + return -2; + } + + if (0.1 * fabs(h) <= fabs(x) * uround) { + if (fileout) + fprintf(fileout, + "Exit of dop853 at x = %.16e, step size too small h = %.16e\r\n", x, h); + return -3; + } + + if ((x + 1.01 * h - xend) * posneg > 0.0) { + h = xend - x; + last = 1; + } + + if (dense_state) { + dense_state->xold = x; + dense_state->hout = h; + } + + nstep++; + + /* the twelve stages */ + for (i = 0; i < n; i++) + yy1[i] = y[i] + h * a21 * k1[i]; + fcn(n, x + c2 * h, yy1, k2, p, fr, norbits, nbody, args); + for (i = 0; i < n; i++) + yy1[i] = y[i] + h * (a31 * k1[i] + a32 * k2[i]); + fcn(n, x + c3 * h, yy1, k3, p, fr, norbits, nbody, args); + for (i = 0; i < n; i++) + yy1[i] = y[i] + h * (a41 * k1[i] + a43 * k3[i]); + fcn(n, x + c4 * h, yy1, k4, p, fr, norbits, nbody, args); + for (i = 0; i < n; i++) + yy1[i] = y[i] + h * (a51 * k1[i] + a53 * k3[i] + a54 * k4[i]); + fcn(n, x + c5 * h, yy1, k5, p, fr, norbits, nbody, args); + for (i = 0; i < n; i++) + yy1[i] = y[i] + h * (a61 * k1[i] + a64 * k4[i] + a65 * k5[i]); + fcn(n, x + c6 * h, yy1, k6, p, fr, norbits, nbody, args); + for (i = 0; i < n; i++) + yy1[i] = y[i] + h * (a71 * k1[i] + a74 * k4[i] + a75 * k5[i] + a76 * k6[i]); + fcn(n, x + c7 * h, yy1, k7, p, fr, norbits, nbody, args); + for (i = 0; i < n; i++) + yy1[i] = y[i] + h * (a81 * k1[i] + a84 * k4[i] + a85 * k5[i] + a86 * k6[i] + + a87 * k7[i]); + fcn(n, x + c8 * h, yy1, k8, p, fr, norbits, nbody, args); + for (i = 0; i < n; i++) + yy1[i] = y[i] + h * (a91 * k1[i] + a94 * k4[i] + a95 * k5[i] + a96 * k6[i] + + a97 * k7[i] + a98 * k8[i]); + fcn(n, x + c9 * h, yy1, k9, p, fr, norbits, nbody, args); + for (i = 0; i < n; i++) + yy1[i] = y[i] + h * (a101 * k1[i] + a104 * k4[i] + a105 * k5[i] + a106 * k6[i] + + a107 * k7[i] + a108 * k8[i] + a109 * k9[i]); + fcn(n, x + c10 * h, yy1, k10, p, fr, norbits, nbody, args); + for (i = 0; i < n; i++) + yy1[i] = y[i] + h * (a111 * k1[i] + a114 * k4[i] + a115 * k5[i] + a116 * k6[i] + + a117 * k7[i] + a118 * k8[i] + a119 * k9[i] + a1110 * k10[i]); + fcn(n, x + c11 * h, yy1, k2, p, fr, norbits, nbody, args); + xph = x + h; + for (i = 0; i < n; i++) + yy1[i] = y[i] + h * (a121 * k1[i] + a124 * k4[i] + a125 * k5[i] + a126 * k6[i] + + a127 * k7[i] + a128 * k8[i] + a129 * k9[i] + a1210 * k10[i] + + a1211 * k2[i]); + fcn(n, xph, yy1, k3, p, fr, norbits, nbody, args); + nfcn += 11; + for (i = 0; i < n; i++) { + k4[i] = b1 * k1[i] + b6 * k6[i] + b7 * k7[i] + b8 * k8[i] + b9 * k9[i] + + b10 * k10[i] + b11 * k2[i] + b12 * k3[i]; + k5[i] = y[i] + h * k4[i]; + } + + /* error estimation */ + err = 0.0; + err2 = 0.0; + if (!itoler) // Scalar tolerances + for (i = 0; i < n; i++) { + sk = atoli + rtoli * max_d(fabs(y[i]), fabs(k5[i])); + erri = k4[i] - bhh1 * k1[i] - bhh2 * k9[i] - bhh3 * k3[i]; + sqr = erri / sk; + err2 += sqr * sqr; + erri = er1 * k1[i] + er6 * k6[i] + er7 * k7[i] + er8 * k8[i] + er9 * k9[i] + + er10 * k10[i] + er11 * k2[i] + er12 * k3[i]; + sqr = erri / sk; + err += sqr * sqr; + } + else + for (i = 0; i < n; i++) { + sk = atoler[i] + rtoler[i] * max_d(fabs(y[i]), fabs(k5[i])); + erri = k4[i] - bhh1 * k1[i] - bhh2 * k9[i] - bhh3 * k3[i]; + sqr = erri / sk; + err2 += sqr * sqr; + erri = er1 * k1[i] + er6 * k6[i] + er7 * k7[i] + er8 * k8[i] + er9 * k9[i] + + er10 * k10[i] + er11 * k2[i] + er12 * k3[i]; + sqr = erri / sk; + err += sqr * sqr; + } + deno = err + 0.01 * err2; + if (deno <= 0.0) + deno = 1.0; + err = fabs(h) * err * sqrt(1.0 / (deno * (double)n)); + + /* computation of hnew */ + fac11 = pow(err, expo1); + /* Lund-stabilization */ + fac = fac11 / pow(facold, beta); + /* we require fac1 <= hnew/h <= fac2 */ + fac = max_d(facc2, min_d(facc1, fac / safe)); + hnew = h / fac; + + if (err <= 1.0) { + /* step accepted */ + + facold = max_d(err, 1.0E-4); + naccpt++; + fcn(n, xph, k5, k4, p, fr, norbits, nbody, args); + nfcn++; + + /* stiffness detection */ + if (!(naccpt % nstiff) || (iasti > 0)) { + stnum = 0.0; + stden = 0.0; + for (i = 0; i < n; i++) { + sqr = k4[i] - k3[i]; + stnum += sqr * sqr; + sqr = k5[i] - yy1[i]; + stden += sqr * sqr; + } + if (stden > 0.0) + hlamb = h * sqrt(stnum / stden); + if (hlamb > 6.1) { + nonsti = 0; + iasti++; + if (iasti == 15) + if (fileout) + fprintf(fileout, "The problem seems to become stiff at x = %.16e\r\n", x); + return -4; + } else { + nonsti++; + if (nonsti == 6) + iasti = 0; + } + } + + /* final preparation for dense output */ + // if (iout == 2) + // APW: modified because I don't think this logic is correct. We should enter + // below if we are doing dense output, which is not the same as iout == 2 + if (dense_state) { + /* save the first function evaluations */ + if (dense_state->nrds == n) + for (i = 0; i < n; i++) { + dense_state->rcont1[i] = y[i]; + ydiff = k5[i] - y[i]; + dense_state->rcont2[i] = ydiff; + bspl = h * k1[i] - ydiff; + dense_state->rcont3[i] = bspl; + dense_state->rcont4[i] = ydiff - h * k4[i] - bspl; + dense_state->rcont5[i] = d41 * k1[i] + d46 * k6[i] + d47 * k7[i] + + d48 * k8[i] + d49 * k9[i] + d410 * k10[i] + + d411 * k2[i] + d412 * k3[i]; + dense_state->rcont6[i] = d51 * k1[i] + d56 * k6[i] + d57 * k7[i] + + d58 * k8[i] + d59 * k9[i] + d510 * k10[i] + + d511 * k2[i] + d512 * k3[i]; + dense_state->rcont7[i] = d61 * k1[i] + d66 * k6[i] + d67 * k7[i] + + d68 * k8[i] + d69 * k9[i] + d610 * k10[i] + + d611 * k2[i] + d612 * k3[i]; + dense_state->rcont8[i] = d71 * k1[i] + d76 * k6[i] + d77 * k7[i] + + d78 * k8[i] + d79 * k9[i] + d710 * k10[i] + + d711 * k2[i] + d712 * k3[i]; + } + else { + fprintf(fileout, "Error: SHOULD NEVER GET HERE\n"); + for (j = 0; j < dense_state->nrds; j++) { + i = icont[j]; + dense_state->rcont1[j] = y[i]; + ydiff = k5[i] - y[i]; + dense_state->rcont2[j] = ydiff; + bspl = h * k1[i] - ydiff; + dense_state->rcont3[j] = bspl; + dense_state->rcont4[j] = ydiff - h * k4[i] - bspl; + dense_state->rcont5[j] = d41 * k1[i] + d46 * k6[i] + d47 * k7[i] + + d48 * k8[i] + d49 * k9[i] + d410 * k10[i] + + d411 * k2[i] + d412 * k3[i]; + dense_state->rcont6[j] = d51 * k1[i] + d56 * k6[i] + d57 * k7[i] + + d58 * k8[i] + d59 * k9[i] + d510 * k10[i] + + d511 * k2[i] + d512 * k3[i]; + dense_state->rcont7[j] = d61 * k1[i] + d66 * k6[i] + d67 * k7[i] + + d68 * k8[i] + d69 * k9[i] + d610 * k10[i] + + d611 * k2[i] + d612 * k3[i]; + dense_state->rcont8[j] = d71 * k1[i] + d76 * k6[i] + d77 * k7[i] + + d78 * k8[i] + d79 * k9[i] + d710 * k10[i] + + d711 * k2[i] + d712 * k3[i]; + } + } + + /* the next three function evaluations */ + for (i = 0; i < n; i++) + yy1[i] = y[i] + + h * (a141 * k1[i] + a147 * k7[i] + a148 * k8[i] + a149 * k9[i] + + a1410 * k10[i] + a1411 * k2[i] + a1412 * k3[i] + a1413 * k4[i]); + fcn(n, x + c14 * h, yy1, k10, p, fr, norbits, nbody, args); + for (i = 0; i < n; i++) + yy1[i] = y[i] + + h * (a151 * k1[i] + a156 * k6[i] + a157 * k7[i] + a158 * k8[i] + + a1511 * k2[i] + a1512 * k3[i] + a1513 * k4[i] + a1514 * k10[i]); + fcn(n, x + c15 * h, yy1, k2, p, fr, norbits, nbody, args); + for (i = 0; i < n; i++) + yy1[i] = y[i] + + h * (a161 * k1[i] + a166 * k6[i] + a167 * k7[i] + a168 * k8[i] + + a169 * k9[i] + a1613 * k4[i] + a1614 * k10[i] + a1615 * k2[i]); + fcn(n, x + c16 * h, yy1, k3, p, fr, norbits, nbody, args); + nfcn += 3; + + /* final preparation */ + if (dense_state->nrds == n) + for (i = 0; i < n; i++) { + dense_state->rcont5[i] = h * (dense_state->rcont5[i] + d413 * k4[i] + + d414 * k10[i] + d415 * k2[i] + d416 * k3[i]); + dense_state->rcont6[i] = h * (dense_state->rcont6[i] + d513 * k4[i] + + d514 * k10[i] + d515 * k2[i] + d516 * k3[i]); + dense_state->rcont7[i] = h * (dense_state->rcont7[i] + d613 * k4[i] + + d614 * k10[i] + d615 * k2[i] + d616 * k3[i]); + dense_state->rcont8[i] = h * (dense_state->rcont8[i] + d713 * k4[i] + + d714 * k10[i] + d715 * k2[i] + d716 * k3[i]); + } + else + for (j = 0; j < dense_state->nrds; j++) { + i = icont[j]; + dense_state->rcont5[j] = h * (dense_state->rcont5[j] + d413 * k4[i] + + d414 * k10[i] + d415 * k2[i] + d416 * k3[i]); + dense_state->rcont6[j] = h * (dense_state->rcont6[j] + d513 * k4[i] + + d514 * k10[i] + d515 * k2[i] + d516 * k3[i]); + dense_state->rcont7[j] = h * (dense_state->rcont7[j] + d613 * k4[i] + + d614 * k10[i] + d615 * k2[i] + d616 * k3[i]); + dense_state->rcont8[j] = h * (dense_state->rcont8[j] + d713 * k4[i] + + d714 * k10[i] + d715 * k2[i] + d716 * k3[i]); + } + } + + // After each accepted step, fill output_y for all output_times in this interval + unsigned idx; + if (dense_state && output_times && output_y && n_output_times > 0) { + double x0 = dense_state->xold; + double h = dense_state->hout; + double x1 = x0 + h; + + // For each output time in [x0, x1], fill output_y + + while (output_idx < n_output_times) { + double t_out = output_times[output_idx]; + if ((x0 <= t_out && t_out <= x1) || (x1 <= t_out && t_out <= x0)) { + for (unsigned i = 0; i < dense_state->nrds; i++) { + idx = output_idx * dense_state->nrds + i; + // ENABLE THIS TO CHECK FOR OUT OF BOUNDS + // if (idx >= (n_output_times * dense_state->nrds)) { + // fprintf(fileout, "ERROR: output index out of bounds: %d >= %d\n", idx, + // n_output_times * dense_state->nrds); + // return -5; + // } + output_y[idx] = contd8_threadsafe(dense_state, i, t_out); + + } + output_idx++; + } else { + break; + } + } + } + + memcpy(k1, k4, n * sizeof(double)); + memcpy(y, k5, n * sizeof(double)); + x = xph; + + if (iout) { + solout(naccpt + 1, x, x, y, n, &irtrn); + if (irtrn < 0) { + if (fileout) + fprintf(fileout, "Exit of dop853 at x = %.16e\r\n", x); + return 2; + } + } + + /* normal exit */ + if (last) { + return 1; + } + + if (fabs(hnew) > hmax) + hnew = posneg * hmax; + if (reject) + hnew = posneg * min_d(fabs(hnew), fabs(h)); + + reject = 0; + } else { + /* step rejected */ + hnew = h / min_d(facc1, fac11 / safe); + reject = 1; + if (naccpt >= 1) + nrejct = nrejct + 1; + last = 0; + } + + h = hnew; + } + +} /* dopcor */ + +/* front-end */ +int dop853(unsigned n, FcnEqDiff fcn, CPotential *p, CFrameType *fr, unsigned norbits, + unsigned nbody, void *args, double x, double *y, double xend, double *rtoler, + double *atoler, int itoler, SolTrait solout, int iout, FILE *fileout, + double uround, double safe, double fac1, double fac2, double beta, + double hmax, double h, long nmax, int meth, long nstiff, unsigned nrdens, + unsigned *icont, unsigned licont, Dop853DenseState *dense_state, + double *output_times, int n_output_times, double *output_y) { + int arret = 0; + int idid; + unsigned i; + double *yy1, *k1, *k2, *k3, *k4, *k5, *k6, *k7, *k8, *k9, *k10; + + /* n, the dimension of the system */ + if (n == UINT_MAX) { + if (fileout) + fprintf(fileout, "System too big, max. n = %u\r\n", UINT_MAX - 1); + arret = 1; + } + + /* nmax, the maximal number of steps */ + if (!nmax) + nmax = 1000000; // UPDATED from 100_000 + else if (nmax <= 0) { + if (fileout) + fprintf(fileout, "Wrong input, nmax = %li\r\n", nmax); + arret = 1; + } + + /* meth, coefficients of the method */ + if (!meth) + meth = 1; + else if ((meth <= 0) || (meth >= 2)) { + if (fileout) + fprintf(fileout, "Curious input, meth = %i\r\n", meth); + arret = 1; + } + + /* nstiff, parameter for stiffness detection */ + if (!nstiff) + nstiff = 1000; + else if (nstiff < 0) + nstiff = nmax + 10; + + /* iout, switch for calling solout */ + if ((iout < 0) || (iout > 2)) { + if (fileout) + fprintf(fileout, "Wrong input, iout = %i\r\n", iout); + arret = 1; + } + + /* nrdens, number of dense output components */ + if (nrdens > n) { + if (fileout) + fprintf(fileout, "Curious input, nrdens = %u\r\n", nrdens); + arret = 1; + } else if (nrdens) { + // ADDED BY APW: + if (nrdens != n) { + if (fileout) { + fprintf( + fileout, + "Warning: nrdens = %u, but not all components are dense (n = %u)\n", + nrdens, n + ); + arret = 1; + } + } + + if (!dense_state) { + if (fileout) + fprintf(fileout, "Dense state must be pre-allocated\r\n"); + arret = 1; + } + /* control of length of icont */ + if (nrdens == n) { + if (icont && fileout) + fprintf(fileout, "Warning : when nrdens = n there is no need allocating memory " + "for icont\r\n"); + dense_state->nrds = n; + } else if (licont < nrdens) { + if (fileout) + fprintf(fileout, "Insufficient storage for icont, min. licont = %u\r\n", + nrdens); + arret = 1; + } else { + if ((iout < 2) && fileout) + fprintf(fileout, "Warning : put iout = 2 for dense output\r\n"); + dense_state->nrds = nrdens; + for (i = 0; i < n; i++) + dense_state->indir[i] = UINT_MAX; + for (i = 0; i < nrdens; i++) + dense_state->indir[icont[i]] = i; + } + } else { + dense_state = NULL; // Defensive: ensure not used + } + + /* uround, smallest number satisfying 1.0+uround > 1.0 */ + if (uround == 0.0) + uround = 2.3E-16; + else if ((uround <= 1.0E-35) || (uround >= 1.0)) { + if (fileout) + fprintf(fileout, "Which machine do you have ? Your uround was : %.16e\r\n", + uround); + arret = 1; + } + + /* safety factor */ + if (safe == 0.0) + safe = 0.9; + else if ((safe >= 1.0) || (safe <= 1.0E-4)) { + if (fileout) + fprintf(fileout, "Curious input for safety factor, safe = %.16e\r\n", safe); + arret = 1; + } + + /* fac1, fac2, parameters for step size selection */ + if (fac1 == 0.0) + fac1 = 0.333; + if (fac2 == 0.0) + fac2 = 6.0; + + /* beta for step control stabilization */ + if (beta == 0.0) + beta = 0.0; + else if (beta < 0.0) + beta = 0.0; + else if (beta > 0.2) { + if (fileout) + fprintf(fileout, "Curious input for beta : beta = %.16e\r\n", beta); + arret = 1; + } + + /* maximal step size */ + if (hmax == 0.0) + hmax = xend - x; + + /* is there enough free memory for the method ? */ + yy1 = (double *)malloc(n * sizeof(double)); + k1 = (double *)malloc(n * sizeof(double)); + k2 = (double *)malloc(n * sizeof(double)); + k3 = (double *)malloc(n * sizeof(double)); + k4 = (double *)malloc(n * sizeof(double)); + k5 = (double *)malloc(n * sizeof(double)); + k6 = (double *)malloc(n * sizeof(double)); + k7 = (double *)malloc(n * sizeof(double)); + k8 = (double *)malloc(n * sizeof(double)); + k9 = (double *)malloc(n * sizeof(double)); + k10 = (double *)malloc(n * sizeof(double)); + + if (!yy1 || !k1 || !k2 || !k3 || !k4 || !k5 || !k6 || !k7 || !k8 || !k9 || !k10) { + if (fileout) + fprintf(fileout, "Not enough free memory for the method\r\n"); + arret = 1; + } + + /* when a failure has occured, we return -1 */ + if (arret) { + if (k10) + free(k10); + if (k9) + free(k9); + if (k8) + free(k8); + if (k7) + free(k7); + if (k6) + free(k6); + if (k5) + free(k5); + if (k4) + free(k4); + if (k3) + free(k3); + if (k2) + free(k2); + if (k1) + free(k1); + if (yy1) + free(yy1); + return -1; + } else { + idid = + dopcor(n, fcn, p, fr, norbits, nbody, args, x, y, xend, hmax, h, rtoler, atoler, + itoler, fileout, solout, iout, nmax, uround, meth, nstiff, safe, beta, + fac1, fac2, icont, nrdens > 0 ? dense_state : NULL, yy1, k1, k2, k3, k4, + k5, k6, k7, k8, k9, k10, output_times, n_output_times, output_y); + if (k10) + free(k10); + if (k9) + free(k9); + if (k8) + free(k8); + if (k7) + free(k7); + if (k6) + free(k6); + if (k5) + free(k5); + if (k4) + free(k4); + if (k3) + free(k3); + if (k2) + free(k2); + if (k1) + free(k1); + if (yy1) + free(yy1); + + return idid; + } + +} /* dop853 */ + +// Thread-safe dense output function +double contd8_threadsafe(Dop853DenseState *state, unsigned ii, double x) { + if (!state) { + fprintf(stderr, "contd8_threadsafe: state is NULL\n"); + return 0.0; + } + if (!state->rcont1 || !state->rcont2 || !state->rcont3 || !state->rcont4 || + !state->rcont5 || !state->rcont6 || !state->rcont7 || !state->rcont8) { + fprintf(stderr, "contd8_threadsafe: one or more rcont arrays are NULL\n"); + return 0.0; + } + if (ii >= state->nrds) { + fprintf(stderr, "contd8_threadsafe: ii=%u out of bounds (nrds=%u)\n", ii, + state->nrds); + return 0.0; + } + unsigned i; + if (!state->indir) { + i = ii; + } else { + i = state->indir[ii]; + } + if (i == UINT_MAX) { + fprintf(stderr, "contd8_threadsafe: No dense output available for %uth component\n", + ii); + return 0.0; + } + double s = (x - state->xold) / state->hout; + double s1 = 1.0 - s; + return state->rcont1[i] + + s * (state->rcont2[i] + + s1 * (state->rcont3[i] + + s * (state->rcont4[i] + + s1 * (state->rcont5[i] + + s * (state->rcont6[i] + + s1 * (state->rcont7[i] + s * state->rcont8[i])))))); +} + +// Allocate a Dop853DenseState and its arrays +Dop853DenseState *dop853_dense_state_alloc(unsigned nrdens, unsigned n) { + Dop853DenseState *state = (Dop853DenseState *)malloc(sizeof(Dop853DenseState)); + if (nrdens != n) { + printf("alloc: nrdens != n\n"); + } + if (!state) + return NULL; + state->rcont1 = (double *)malloc(nrdens * sizeof(double)); + state->rcont2 = (double *)malloc(nrdens * sizeof(double)); + state->rcont3 = (double *)malloc(nrdens * sizeof(double)); + state->rcont4 = (double *)malloc(nrdens * sizeof(double)); + state->rcont5 = (double *)malloc(nrdens * sizeof(double)); + state->rcont6 = (double *)malloc(nrdens * sizeof(double)); + state->rcont7 = (double *)malloc(nrdens * sizeof(double)); + state->rcont8 = (double *)malloc(nrdens * sizeof(double)); + state->nrds = nrdens; + state->xold = 0.0; + state->hout = 0.0; + if (nrdens < n) { + state->indir = (unsigned *)malloc(n * sizeof(unsigned)); + } else { + state->indir = NULL; + } + if (!state->rcont1 || !state->rcont2 || !state->rcont3 || !state->rcont4 || + !state->rcont5 || !state->rcont6 || !state->rcont7 || !state->rcont8 || + (nrdens < n && !state->indir)) { + printf("ERROR: freeing dense_state early\n"); + dop853_dense_state_free(state, n); + return NULL; + } + return state; +} + +// Free a Dop853DenseState and its arrays +void dop853_dense_state_free(Dop853DenseState *state, unsigned n) { + if (!state) + return; + if (state->rcont1) + free(state->rcont1); + if (state->rcont2) + free(state->rcont2); + if (state->rcont3) + free(state->rcont3); + if (state->rcont4) + free(state->rcont4); + if (state->rcont5) + free(state->rcont5); + if (state->rcont6) + free(state->rcont6); + if (state->rcont7) + free(state->rcont7); + if (state->rcont8) + free(state->rcont8); + if (state->indir) + free(state->indir); + free(state); +} + +/* ADDED BY APW */ +void Fwrapper(unsigned full_ndim, double t, double *w, double *f, CPotential *p, + CFrameType *fr, unsigned norbits, unsigned na, void *args) { + /* na can be ignored here - used in nbody wrapper below */ + + int i; + unsigned ndim = full_ndim / norbits; // phase-space dimensionality + + for (i = 0; i < norbits; i++) { + // call gradient function + hamiltonian_gradient(p, fr, t, &w[i * ndim], &f[i * ndim]); + } +} + +void Fwrapper_T(unsigned full_ndim, double t, double *w, double *f, CPotential *p, + CFrameType *fr, unsigned norbits, unsigned na, void *args) { + /* na can be ignored here - used in nbody wrapper below */ + + int i; + unsigned ndim = full_ndim / norbits; // phase-space dimensionality + + // call gradient function + hamiltonian_gradient_T(p, fr, norbits, t, w, f); +} + +void Fwrapper_direct_nbody(unsigned full_ndim, double t, double *w, double *f, + CPotential *p, CFrameType *fr, unsigned norbits, + unsigned nbody, void *args) { + /* Here, the extra args are actually the array of CPotential objects that + represent the potentials of the individual particles. + */ + CPotential **pots = (CPotential **)args; + + // Note: only really works with a static frame! This should be enforced + unsigned ps_ndim = 2 * p->n_dim; // phase-space dimensionality + + for (int i = 0; i < norbits; i++) + hamiltonian_gradient(p, fr, t, &w[i * ps_ndim], &f[i * ps_ndim]); + + if (nbody > 0) + c_nbody_acceleration(pots, t, w, norbits, nbody, p->n_dim, f); +} + +/* Needed for Lyapunov */ +double six_norm(double *x) { + double norm = 0; + for (int i = 0; i < 6; i++) { + norm = norm + x[i] * x[i]; + } + return sqrt(norm); +} diff --git a/gala/source/src/gala/integrate/cyintegrators/dopri/dop853.h b/gala/source/src/gala/integrate/cyintegrators/dopri/dop853.h new file mode 100644 index 0000000000000000000000000000000000000000..4da216ad44fa08337ae53ee8fc917bab34dad7d1 --- /dev/null +++ b/gala/source/src/gala/integrate/cyintegrators/dopri/dop853.h @@ -0,0 +1,268 @@ +/* DOP853 + ------ + +******************************************** + WARNING +******************************************** +This code has been modified! This is *not* +the original! I have added some extra +functionality to play nice with Python code. + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This code computes the numerical solution of a system of first order ordinary +differential equations y'=f(x,y). It uses an explicit Runge-Kutta method of +order 8(5,3) due to Dormand & Prince with step size control and dense output. + +Authors : E. Hairer & G. Wanner + Universite de Geneve, dept. de Mathematiques + CH-1211 GENEVE 4, SWITZERLAND + E-mail : HAIRER@DIVSUN.UNIGE.CH, WANNER@DIVSUN.UNIGE.CH + +The code is described in : E. Hairer, S.P. Norsett and G. Wanner, Solving +ordinary differential equations I, nonstiff problems, 2nd edition, +Springer Series in Computational Mathematics, Springer-Verlag (1993). + +Version of Mai 2, 1994. + +Remarks about the C version : this version allocates memory by itself, the +iwork array (among the initial FORTRAN parameters) has been splitted into +independant initial parameters, the statistical variables and last step size +and x have been encapsulated in the module and are now accessible through +dedicated functions; the variable names have been kept to maintain a kind +of reading compatibility between the C and FORTRAN codes; adaptation made by +J.Colinge (COLINGE@DIVSUN.UNIGE.CH). + + + +INPUT PARAMETERS +---------------- + +n Dimension of the system (n < UINT_MAX). + +fcn A pointer the the function definig the differential equation, this + function must have the following prototype + + void fcn (unsigned n, double x, double *y, double *f) + + where the array f will be filled with the function result. + +x Initial x value. + +*y Initial y values (double y[n]). + +xend Final x value (xend-x may be positive or negative). + +*rtoler Relative and absolute error tolerances. They can be both scalars or +*atoler vectors of length n (in the scalar case pass the addresses of + variables where you have placed the tolerance values). + +itoler Switch for atoler and rtoler : + itoler=0 : both atoler and rtoler are scalars, the code keeps + roughly the local error of y[i] below + rtoler*abs(y[i])+atoler. + itoler=1 : both rtoler and atoler are vectors, the code keeps + the local error of y[i] below + rtoler[i]*abs(y[i])+atoler[i]. + +solout A pointer to the output function called during integration. + If iout >= 1, it is called after every successful step. If iout = 0, + pass a pointer equal to NULL. solout must must have the following + prototype + + solout (long nr, double xold, double x, double* y, unsigned n, int* irtrn) + + where y is the solution the at nr-th grid point x, xold is the + previous grid point and irtrn serves to interrupt the integration + (if set to a negative value). + + Continuous output : during the calls to solout, a continuous solution + for the interval (xold,x) is available through the function + + contd8(i,s) + + which provides an approximation to the i-th component of the solution + at the point s (s must lie in the interval (xold,x)). + +iout Switch for calling solout : + iout=0 : no call, + iout=1 : solout only used for output, + iout=2 : dense output is performed in solout (in this case nrdens + must be greater than 0). + +fileout A pointer to the stream used for messages, if you do not want any + message, just pass NULL. + +icont An array containing the indexes of components for which dense + output is required. If no dense output is required, pass NULL. + +licont The number of cells in icont. + + +Sophisticated setting of parameters +----------------------------------- + + Several parameters have a default value (if set to 0) but, to better + adapt the code to your problem, you can specify particular initial + values. + +uround The rounding unit, default 2.3E-16 (this default value can be + replaced in the code by DBL_EPSILON providing float.h defines it + in your system). + +safe Safety factor in the step size prediction, default 0.9. + +fac1 Parameters for step size selection; the new step size is chosen +fac2 subject to the restriction fac1 <= hnew/hold <= fac2. + Default values are fac1=0.333 and fac2=6.0. + +beta The "beta" for stabilized step size control (see section IV.2 of our + book). Larger values for beta ( <= 0.1 ) make the step size control + more stable. Negative initial value provoke beta=0; default beta=0. + +hmax Maximal step size, default xend-x. + +h Initial step size, default is a guess computed by the function hinit. + +nmax Maximal number of allowed steps, default 100000. + +meth Switch for the choice of the method coefficients; at the moment the + only possibility and default value are 1. + +nstiff Test for stiffness is activated when the current step number is a + multiple of nstiff. A negative value means no test and the default + is 1000. + +nrdens Number of components for which dense outpout is required, default 0. + For 0 < nrdens < n, the components have to be specified in icont[0], + icont[1], ... icont[nrdens-1]. Note that if nrdens=0 or nrdens=n, no + icont is needed, pass NULL. + + +Memory requirements +------------------- + + The function dop853 allocates dynamically 11*n doubles for the method + stages, 8*nrdens doubles for the interpolation if dense output is + performed and n unsigned if 0 < nrdens < n. + + +OUTPUT PARAMETERS +----------------- + +y numerical solution at x=xRead() (see below). + +dopri5 returns the following values + + 1 : computation successful, + 2 : computation successful interrupted by solout, + -1 : input is not consistent, + -2 : larger nmax is needed, + -3 : step size becomes too small, + -4 : the problem is probably stff (interrupted). + + +Several functions provide access to different values : + +xRead x value for which the solution has been computed (x=xend after + successful return). + +hRead Predicted step size of the last accepted step (useful for a subsequent + call to dop853). + +nstepRead Number of used steps. +naccptRead Number of accepted steps. +nrejctRead Number of rejected steps. +nfcnRead Number of function calls. + + +*/ + + +#include +#include +#include "potential/src/cpotential.h" +#include "frame/src/cframe.h" +#include "hamiltonian/src/chamiltonian.h" + +// Thread-safe struct for dense output state +typedef struct { + double *rcont1, *rcont2, *rcont3, *rcont4; + double *rcont5, *rcont6, *rcont7, *rcont8; + double xold, hout; + unsigned nrds; + unsigned *indir; +} Dop853DenseState; + +// Thread-safe dense output function +double contd8_threadsafe(Dop853DenseState *state, unsigned ii, double x); + +Dop853DenseState* dop853_dense_state_alloc(unsigned nrdens, unsigned n); +void dop853_dense_state_free(Dop853DenseState* state, unsigned n); + +typedef void (*FcnEqDiff)(unsigned n, double x, double *y, double *f, + CPotential *p, CFrameType *fr, unsigned norbits, + unsigned nbody, void *args); + +typedef void (*SolTrait)(long nr, double xold, double x, double* y, unsigned n, int* irtrn); + +extern int dop853 + (unsigned n, /* dimension of the system <= UINT_MAX-1*/ + FcnEqDiff fcn, /* function computing the value of f(x,y) */ + CPotential *p, /* ADDED BY ADRN: parameters for gradient function */ + CFrameType *fr, /* ADDED BY ADRN: reference frame */ + unsigned n_orbits, /* ADDED BY ADRN: total number of orbits, i.e. bodies */ + unsigned n_body, /* ADDED BY ADRN: number of nbody particles */ + void *args, /* ADDED BY ADRN: a container for other stuff */ + double x, /* initial x-value */ + double* y, /* initial values for y */ + double xend, /* final x-value (xend-x may be positive or negative) */ + double* rtoler, /* relative error tolerance */ + double* atoler, /* absolute error tolerance */ + int itoler, /* switch for rtoler and atoler */ + SolTrait solout, /* function providing the numerical solution during integration */ + int iout, /* switch for calling solout */ + FILE* fileout, /* messages stream */ + double uround, /* rounding unit */ + double safe, /* safety factor */ + double fac1, /* parameters for step size selection */ + double fac2, + double beta, /* for stabilized step size control */ + double hmax, /* maximal step size */ + double h, /* initial step size */ + long nmax, /* maximal number of allowed steps */ + int meth, /* switch for the choice of the coefficients */ + long nstiff, /* test for stiffness */ + unsigned nrdens, /* number of components for which dense outpout is required */ + unsigned* icont, /* indexes of components for which dense output is required, >= nrdens */ + unsigned licont, /* declared length of icon */ + Dop853DenseState* dense_state, + double* output_times, /* array of times to sample */ + int n_output_times, /* number of output times */ + double* output_y /* output array to fill (size: n_output_times * n) */ + ); + +// extern double contd8 +// (unsigned ii, /* index of desired component */ +// double x /* approximation at x */ +// ); + +extern long nfcnRead (void); /* encapsulation of statistical data */ +extern long nstepRead (void); +extern long naccptRead (void); +extern long nrejctRead (void); + +/* ADDED BY APW */ +extern void Fwrapper (unsigned ndim, double t, double *w, double *f, + CPotential *p, CFrameType *fr, + unsigned norbits, unsigned nbody, void *args); +extern void Fwrapper_T (unsigned ndim, double t, double *w, double *f, + CPotential *p, CFrameType *fr, + unsigned norbits, unsigned nbody, void *args); +extern void Fwrapper_direct_nbody(unsigned ndim, double t, double *w, double *f, + CPotential *p, CFrameType *fr, + unsigned norbits, unsigned nbody, + void *args); // here args becomes the particle potentials +extern double six_norm (double *x); /* Needed for Lyapunov */ diff --git a/gala/source/src/gala/integrate/cyintegrators/dopri/licence.txt b/gala/source/src/gala/integrate/cyintegrators/dopri/licence.txt new file mode 100644 index 0000000000000000000000000000000000000000..08ed97885035d8a11f27298c37162af43c70e5f4 --- /dev/null +++ b/gala/source/src/gala/integrate/cyintegrators/dopri/licence.txt @@ -0,0 +1,25 @@ + +Copyright (c) 2004, Ernst Hairer + +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. + +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 REGENTS 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. diff --git a/gala/source/src/gala/integrate/cyintegrators/leapfrog.pxd b/gala/source/src/gala/integrate/cyintegrators/leapfrog.pxd new file mode 100644 index 0000000000000000000000000000000000000000..83f24787abb32ef5c788fb57432969f13f4b1a1e --- /dev/null +++ b/gala/source/src/gala/integrate/cyintegrators/leapfrog.pxd @@ -0,0 +1,22 @@ +# cython: language_level=3 +# cython: language=c++ + +from ...potential.potential.cpotential cimport CPotential + +cdef void c_init_velocity(CPotential *p, size_t n, int half_ndim, double t, double dt, + double *x_jm1, double *v_jm1, double *v_jm1_2, double *grad) nogil + +cdef void c_leapfrog_step(CPotential *p, size_t n, int half_ndim, double t, double dt, + double *x_jm1, double *v_jm1, double *v_jm1_2, double *grad) nogil + +cdef void c_init_velocity_nbody( + CPotential *p, int half_ndim, double t, double dt, + CPotential **pots, double *x_nbody_jm1, int nbody, int nbody_i, + double *x_jm1, double *v_jm1, double *v_jm1_2, double *grad +) nogil + +cdef void c_leapfrog_step_nbody( + CPotential *p, int half_ndim, double t, double dt, + CPotential **pots, double *x_nbody_jm1, int nbody, int nbody_i, + double *x_jm1, double *v_jm1, double *v_jm1_2, double *grad +) nogil diff --git a/gala/source/src/gala/integrate/cyintegrators/leapfrog.pyx b/gala/source/src/gala/integrate/cyintegrators/leapfrog.pyx new file mode 100644 index 0000000000000000000000000000000000000000..26723e229c90006d10c6f9d7a743d299c79a6e61 --- /dev/null +++ b/gala/source/src/gala/integrate/cyintegrators/leapfrog.pyx @@ -0,0 +1,257 @@ +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + +""" Leapfrog integration in Cython. """ + + +import numpy as np +cimport numpy as np +np.import_array() + + +from ...potential.potential.cpotential cimport CPotentialWrapper, CPotential, c_gradient, c_nbody_gradient_symplectic +from ...potential.frame import StaticFrame +from ...potential import NullPotential + +from libc.stdlib cimport malloc, free + + +cdef void c_init_velocity(CPotential *p, size_t n, int half_ndim, double t, double dt, + double *x_jm1, double *v_jm1, double *v_jm1_2, double *grad) nogil: + cdef int i, k + + c_gradient(p, n, t, x_jm1, grad) + + for k in range(half_ndim): + for i in range(n): + v_jm1_2[i + k * n] = v_jm1[i + k * n] - grad[i + k * n] * dt/2. # acceleration is minus gradient + + +cdef void c_leapfrog_step(CPotential *p, size_t n, int half_ndim, double t, double dt, + double *x_jm1, double *v_jm1, double *v_jm1_2, double *grad) nogil: + cdef int i, k + + # full step the positions + for k in range(half_ndim): + for i in range(n): + x_jm1[i + k * n] = x_jm1[i + k * n] + v_jm1_2[i + k * n] * dt + + c_gradient(p, n, t, x_jm1, grad) # compute gradient at new position + + # step velocity forward by half step, aligned w/ position, then + # finish the full step to leapfrog over position + for k in range(half_ndim): + for i in range(n): + v_jm1[i + k * n] = v_jm1_2[i + k * n] - grad[i + k * n] * dt/2. + v_jm1_2[i + k * n] = v_jm1_2[i + k * n] - grad[i + k * n] * dt + + +cpdef leapfrog_integrate_hamiltonian(hamiltonian, double [:, ::1] w0, double[::1] t, + int save_all=1): + """ + w0: shape (ndim, n) + returns: shape (ndim, [ntimes,] n) + """ + + if not hamiltonian.c_enabled: + raise TypeError("Input Hamiltonian object does not support C-level access.") + + if not isinstance(hamiltonian.frame, StaticFrame): + raise TypeError( + "Leapfrog integration is currently only supported for StaticFrame, " + f"not {hamiltonian.frame.__class__.__name__}" + ) + + cdef: + # temporary scalars + int i, j, k + int ndim = w0.shape[0] + int n = w0.shape[1] + int half_ndim = ndim // 2 + + int ntimes = len(t) + double dt = t[1]-t[0] + + # temporary array containers + double[:, ::1] grad_v = np.zeros((half_ndim, n)) + double[:, ::1] v_jm1_2 = np.zeros((half_ndim, n)) + + # return arrays + double[:, :, ::1] all_w + double[:, ::1] tmp_w + + # whoa, so many dots + CPotential* cp = ((hamiltonian.potential.c_instance)).cpotential + + if save_all: + all_w = np.empty((ndim, ntimes, n)) + + # save initial conditions + all_w[:, 0, :] = w0 + + tmp_w = w0.copy() + + with nogil: + # first initialize the velocities so they are evolved by a + # half step relative to the positions + c_init_velocity(cp, n, half_ndim, t[0], dt, + &tmp_w[0, 0], &tmp_w[half_ndim, 0], + &v_jm1_2[0, 0], &grad_v[0, 0]) + + for j in range(1, ntimes, 1): + grad_v[:] = 0. + c_leapfrog_step(cp, n, half_ndim, t[j], dt, + &tmp_w[0, 0], &tmp_w[half_ndim, 0], + &v_jm1_2[0, 0], + &grad_v[0, 0]) + + if save_all: + for k in range(ndim): + for i in range(n): + all_w[k, j, i] = tmp_w[k, i] + + if save_all: + return np.asarray(t), np.asarray(all_w) + else: + return np.asarray(t[-1:]), np.asarray(tmp_w) + +# ------------------------------------------------------------------------------------- +# N-body stuff - TODO: to be moved, because this is a HACK! + +cdef void c_init_velocity_nbody( + CPotential *p, int half_ndim, double t, double dt, + CPotential **pots, double *x_nbody_jm1, int nbody, int nbody_i, + double *x_jm1, double *v_jm1, double *v_jm1_2, double *grad +) nogil: + cdef int k + + c_gradient(p, 1, t, x_jm1, grad) + c_nbody_gradient_symplectic(pots, t, x_jm1, x_nbody_jm1, nbody, nbody_i, half_ndim, grad) + + for k in range(half_ndim): + v_jm1_2[k] = v_jm1[k] - grad[k] * dt/2. # acceleration is minus gradient + + +cdef void c_leapfrog_step_nbody( + CPotential *p, int half_ndim, double t, double dt, + CPotential **pots, double *x_nbody_jm1, int nbody, int nbody_i, + double *x_jm1, double *v_jm1, double *v_jm1_2, double *grad +) nogil: + cdef int k + + # full step the positions + for k in range(half_ndim): + x_jm1[k] = x_jm1[k] + v_jm1_2[k] * dt + + c_gradient(p, 1, t, x_jm1, grad) # compute gradient at new position + c_nbody_gradient_symplectic(pots, t, x_jm1, x_nbody_jm1, nbody, nbody_i, half_ndim, grad) + + # step velocity forward by half step, aligned w/ position, then + # finish the full step to leapfrog over position + for k in range(half_ndim): + v_jm1[k] = v_jm1_2[k] - grad[k] * dt/2. + v_jm1_2[k] = v_jm1_2[k] - grad[k] * dt + + +cpdef leapfrog_integrate_nbody(hamiltonian, double [:, ::1] w0, double[::1] t, + list particle_potentials, int save_all=1): + """ + CAUTION: Interpretation of axes is different here! We need the + arrays to be C ordered and easy to iterate over, so here the + axes are (norbits, ndim). + """ + + if not hamiltonian.c_enabled: + raise TypeError("Input Hamiltonian object does not support C-level access.") + + if not isinstance(hamiltonian.frame, StaticFrame): + raise TypeError( + "Leapfrog integration is currently only supported for StaticFrame, " + f"not {hamiltonian.frame.__class__.__name__}" + ) + + cdef: + # temporary scalars + int i, j, k + int n = w0.shape[0] + int ndim = w0.shape[1] + int half_ndim = ndim // 2 + + int ntimes = len(t) + double dt = t[1]-t[0] + + # temporary array containers + double[::1] grad = np.zeros(half_ndim) + double[:, ::1] v_jm1_2 = np.zeros((n, half_ndim)) + + # return arrays + double[:, :, ::1] all_w + double[:, ::1] tmp_w = np.zeros((n, ndim)) + + # whoa, so many dots + CPotential* cp = ((hamiltonian.potential.c_instance)).cpotential + CPotential **c_particle_potentials = NULL + unsigned nbody = 0 + + if save_all: + all_w = np.zeros((ntimes, n, ndim)) + + # save initial conditions + all_w[0, :, :] = w0.copy() + + for pot in particle_potentials: + if not isinstance(pot, NullPotential): + nbody += 1 + + # Dynamically allocate memory for particle potentials + c_particle_potentials = malloc(n * sizeof(CPotential*)) + if c_particle_potentials == NULL: + raise MemoryError("Failed to allocate memory for particle potentials") + + try: + # Extract the CPotential objects from the particle potentials. + for i in range(n): + c_particle_potentials[i] = ((particle_potentials[i].c_instance)).cpotential + + tmp_w = w0.copy() + + with nogil: + # first initialize the velocities so they are evolved by a + # half step relative to the positions + for i in range(n): + c_init_velocity_nbody(cp, half_ndim, t[0], dt, + c_particle_potentials, &tmp_w[0, 0], nbody, i, + &tmp_w[i, 0], &tmp_w[i, half_ndim], + &v_jm1_2[i, 0], &grad[0]) + + for j in range(1, ntimes, 1): + for i in range(n): + for k in range(half_ndim): + grad[k] = 0. + + c_leapfrog_step_nbody(cp, half_ndim, t[j], dt, + c_particle_potentials, &tmp_w[0, 0], nbody, i, + &tmp_w[i, 0], &tmp_w[i, half_ndim], + &v_jm1_2[i, 0], + &grad[0]) + + if save_all: + for k in range(ndim): + all_w[j, i, k] = tmp_w[i, k] + + if save_all: + return_val = (np.asarray(t), np.asarray(all_w)) + else: + return_val = (np.asarray(t[-1:]), np.asarray(tmp_w)) + + return return_val + + finally: + # Clean up allocated memory + if c_particle_potentials != NULL: + free(c_particle_potentials) diff --git a/gala/source/src/gala/integrate/cyintegrators/ruth4.pxd b/gala/source/src/gala/integrate/cyintegrators/ruth4.pxd new file mode 100644 index 0000000000000000000000000000000000000000..04a724034b1c6ed61414c6d0b288519f7c89c647 --- /dev/null +++ b/gala/source/src/gala/integrate/cyintegrators/ruth4.pxd @@ -0,0 +1,8 @@ +# cython: language_level=3 +# cython: language=c++ + +from ...potential.potential.cpotential cimport CPotential + +cdef void c_ruth4_step(CPotential *p, size_t n, int ndim, double t, double dt, + double *cs, double *ds, + double *w, double *grad) nogil diff --git a/gala/source/src/gala/integrate/cyintegrators/ruth4.pyx b/gala/source/src/gala/integrate/cyintegrators/ruth4.pyx new file mode 100644 index 0000000000000000000000000000000000000000..79643cfcb59eb6377dc3e72c759ee24b5668c35d --- /dev/null +++ b/gala/source/src/gala/integrate/cyintegrators/ruth4.pyx @@ -0,0 +1,241 @@ +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + +""" Leapfrog integration in Cython. """ + + +import numpy as np +cimport numpy as np +np.import_array() + + +from ...potential.potential.cpotential cimport CPotentialWrapper, CPotential, c_gradient, c_nbody_gradient_symplectic +from ...potential.frame import StaticFrame +from ...potential import NullPotential + +from libc.stdlib cimport malloc, free + + +cdef void c_ruth4_step(CPotential *p, size_t n, int half_ndim, double t, double dt, + double *cs, double *ds, + double *w, double *grad) nogil: + cdef: + int j, k, i + + for j in range(4): + c_gradient(p, n, t, w, grad) + for k in range(half_ndim): + for i in range(n): + w[(half_ndim + k) * n + i] = w[(half_ndim + k) * n + i] - ds[j] * grad[k * n + i] * dt + w[k * n + i] = w[k * n + i] + cs[j] * w[(half_ndim + k) * n + i] * dt + +cpdef ruth4_integrate_hamiltonian(hamiltonian, + double[:, ::1] w0, + double[::1] t, + int save_all=1): + """ + w0: shape (ndim, n) + returns: shape (ndim, [ntimes,] n) + """ + + if not hamiltonian.c_enabled: + raise TypeError("Input Hamiltonian object does not support C-level access.") + + if not isinstance(hamiltonian.frame, StaticFrame): + raise TypeError("Leapfrog integration is currently only supported " + "for StaticFrame, not {}." + .format(hamiltonian.frame.__class__.__name__)) + + cdef: + # temporary scalars + int i, j, k + int ndim = w0.shape[0] + int n = w0.shape[1] + int half_ndim = ndim // 2 + + int ntimes = len(t) + double dt = t[1] - t[0] + + # Integrator coefficients + double two_13 = 2 ** (1./3.) + double[::1] cs = np.array([ + 1. / (2. * (2. - two_13)), + (1. - two_13) / (2.*(2. - two_13)), + (1. - two_13) / (2.*(2. - two_13)), + 1. / (2.*(2. - two_13)) + ], dtype='f8') + + double[::1] ds = np.array([ + 0., + 1. / (2. - two_13), + -two_13 / (2. - two_13), + 1. / (2. - two_13) + ], dtype='f8') + + # temporary array containers + double[:, ::1] grad = np.zeros((half_ndim, n)) + + # return arrays + double[:, :, ::1] all_w + double[:, ::1] tmp_w + + # whoa, so many dots + CPotential* cp = ((hamiltonian.potential.c_instance)).cpotential + + if save_all: + all_w = np.empty((ndim, ntimes, n)) + + # save initial conditions + all_w[:, 0, :] = w0 + + tmp_w = w0.copy() + + with nogil: + for j in range(1, ntimes, 1): + grad[:] = 0. + c_ruth4_step(cp, n, half_ndim, t[j], dt, + &cs[0], &ds[0], + &tmp_w[0, 0], &grad[0, 0]) + + if save_all: + for k in range(ndim): + for i in range(n): + all_w[k, j, i] = tmp_w[k, i] + + if save_all: + return np.asarray(t), np.asarray(all_w) + else: + return np.asarray(t[-1:]), np.asarray(tmp_w) + + +# ------------------------------------------------------------------------------------- +# N-body stuff - TODO: to be moved, because this is a HACK! + +cdef void c_ruth4_step_nbody( + CPotential *p, int half_ndim, double t, double dt, + CPotential **pots, double *w_nbody, int nbody, int nbody_i, + double *cs, double *ds, + double *w, double *grad +) nogil: + cdef: + int j, k + + for j in range(4): + for k in range(half_ndim): + grad[k] = 0. + + c_gradient(p, 1, t, w, grad) + c_nbody_gradient_symplectic(pots, t, w, w_nbody, nbody, nbody_i, half_ndim, grad) + + for k in range(half_ndim): + w[half_ndim + k] = w[half_ndim + k] - ds[j] * grad[k] * dt + w[k] = w[k] + cs[j] * w[half_ndim + k] * dt + +cpdef ruth4_integrate_nbody(hamiltonian, double [:, ::1] w0, double[::1] t, + list particle_potentials, int save_all=1): + """ + CAUTION: Interpretation of axes is different here! We need the + arrays to be C ordered and easy to iterate over, so here the + axes are (norbits, ndim). + """ + + if not hamiltonian.c_enabled: + raise TypeError("Input Hamiltonian object does not support C-level access.") + + if not isinstance(hamiltonian.frame, StaticFrame): + raise TypeError( + "Leapfrog integration is currently only supported for StaticFrame, " + f"not {hamiltonian.frame.__class__.__name__}" + ) + + cdef: + # temporary scalars + int i, j, k + int n = w0.shape[0] + int ndim = w0.shape[1] + int half_ndim = ndim // 2 + + int ntimes = len(t) + double dt = t[1]-t[0] + + # Integrator coefficients + double two_13 = 2 ** (1./3.) + double[::1] cs = np.array([ + 1. / (2. * (2. - two_13)), + (1. - two_13) / (2.*(2. - two_13)), + (1. - two_13) / (2.*(2. - two_13)), + 1. / (2.*(2. - two_13)) + ], dtype='f8') + + double[::1] ds = np.array([ + 0., + 1. / (2. - two_13), + -two_13 / (2. - two_13), + 1. / (2. - two_13) + ], dtype='f8') + + # temporary array containers + double[::1] grad = np.zeros(half_ndim) + double[:, ::1] v_jm1_2 = np.zeros((n, half_ndim)) + + # return arrays + double[:, :, ::1] all_w + double[:, ::1] tmp_w = np.zeros((n, ndim)) + + # whoa, so many dots + CPotential* cp = ((hamiltonian.potential.c_instance)).cpotential + CPotential **c_particle_potentials = NULL + unsigned nbody = 0 + + if save_all: + all_w = np.zeros((ntimes, n, ndim)) + + # save initial conditions + all_w[0, :, :] = w0.copy() + + for pot in particle_potentials: + if not isinstance(pot, NullPotential): + nbody += 1 + + # Dynamically allocate memory for particle potentials + c_particle_potentials = malloc(n * sizeof(CPotential*)) + if c_particle_potentials == NULL: + raise MemoryError("Failed to allocate memory for particle potentials") + + try: + # Extract the CPotential objects from the particle potentials. + for i in range(n): + c_particle_potentials[i] = ((particle_potentials[i].c_instance)).cpotential + + tmp_w = w0.copy() + + with nogil: + for j in range(1, ntimes, 1): + for i in range(n): + c_ruth4_step_nbody( + cp, half_ndim, t[j], dt, + c_particle_potentials, &tmp_w[0, 0], nbody, i, + &cs[0], &ds[0], + &tmp_w[i, 0], &grad[0] + ) + + if save_all: + for k in range(ndim): + all_w[j, i, k] = tmp_w[i, k] + + if save_all: + return_val = (np.asarray(t), np.asarray(all_w)) + else: + return_val = (np.asarray(t[-1:]), np.asarray(tmp_w)) + + return return_val + + finally: + # Clean up allocated memory + if c_particle_potentials != NULL: + free(c_particle_potentials) diff --git a/gala/source/src/gala/integrate/lookup.py b/gala/source/src/gala/integrate/lookup.py new file mode 100644 index 0000000000000000000000000000000000000000..1eea161cfcb7255bd1aa1d8a694bb19c0e4c84ea --- /dev/null +++ b/gala/source/src/gala/integrate/lookup.py @@ -0,0 +1,84 @@ +"""Lookup utilities for integrator classes.""" + +from .pyintegrators import ( + DOPRI853Integrator, + LeapfrogIntegrator, + RK5Integrator, + Ruth4Integrator, +) + +__all__ = ["get_integrator"] + + +# Mapping from lowercase integrator names to integrator classes +_integrator_name_mapping = { + "leapfrog": LeapfrogIntegrator, + "dopri853": DOPRI853Integrator, + "dop853": DOPRI853Integrator, + "rk5": RK5Integrator, + "ruth4": Ruth4Integrator, +} + + +def get_integrator(name): + """Get an integrator class from a string name. + + This function allows you to specify an integrator by its string name instead + of directly importing the class. + + Parameters + ---------- + name : str or Integrator class + The name of the integrator (case-insensitive) or an integrator class. + If an integrator class is passed in, it is returned unchanged. Valid + integrator names are: 'leapfrog', 'dopri853', 'dop853', 'rk5', 'ruth4'. + + Returns + ------- + integrator_cls : Integrator class + The integrator class corresponding to the input name. + + Examples + -------- + Get an integrator class by name:: + + >>> from gala.integrate import get_integrator + >>> LeapfrogIntegrator = get_integrator('leapfrog') + >>> LeapfrogIntegrator + + + Use with potential integration:: + + >>> import gala.potential as gp + >>> pot = gp.HernquistPotential(m=1e11, c=10, units='galactic') + >>> w0 = gd.PhaseSpacePosition(pos=[10,0,0], vel=[0,175,0]) + >>> orbit = gp.Hamiltonian(pot).integrate_orbit( + ... w0, dt=1., n_steps=1000, Integrator='leapfrog' + ... ) + + If you pass in an integrator class, it is returned unchanged:: + + >>> from gala.integrate import LeapfrogIntegrator + >>> get_integrator(LeapfrogIntegrator) is LeapfrogIntegrator + True + + """ + # If it's already an integrator class, return it + if not isinstance(name, str): + if name not in list(_integrator_name_mapping.values()): + raise ValueError( + f"Integrator class '{name}' is not recognized. Valid classes are: " + f"{list(_integrator_name_mapping.values())}" + ) + return name + + # Convert to lowercase for case-insensitive lookup + name_lower = name.lower() + + try: + return _integrator_name_mapping[name_lower] + except KeyError as e: + raise ValueError( + f"Integrator name '{name}' is not recognized. Valid names are: " + f"{list(_integrator_name_mapping.keys())}" + ) from e diff --git a/gala/source/src/gala/integrate/pyintegrators/__init__.py b/gala/source/src/gala/integrate/pyintegrators/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3594c1abdc646d79dbe4672320796bf5da58bd78 --- /dev/null +++ b/gala/source/src/gala/integrate/pyintegrators/__init__.py @@ -0,0 +1,4 @@ +from .dopri853 import DOPRI853Integrator +from .leapfrog import LeapfrogIntegrator +from .rk5 import RK5Integrator +from .ruth4 import Ruth4Integrator diff --git a/gala/source/src/gala/integrate/pyintegrators/dopri853.py b/gala/source/src/gala/integrate/pyintegrators/dopri853.py new file mode 100644 index 0000000000000000000000000000000000000000..cf4cfd2dc43b523d82af601e9c80312d565a5be5 --- /dev/null +++ b/gala/source/src/gala/integrate/pyintegrators/dopri853.py @@ -0,0 +1,150 @@ +"""Wrapper around SciPy DOPRI853 integrator.""" + +from scipy.integrate import ode + +from ..core import Integrator +from ..timespec import parse_time_specification + +__all__ = ["DOPRI853Integrator"] + + +class DOPRI853Integrator(Integrator): + r""" + Dormand-Prince 85(3) adaptive step-size integrator. + + This integrator implements the Dormand-Prince method, which is an explicit + Runge-Kutta method with adaptive step-size control. It uses a 5th-order + accurate formula for advancing the solution and an embedded 3rd-order + formula for error estimation. + + For Python integration, this class wraps SciPy's implementation of the + integrator. The default tolerances (``atol=1.49e-8``, ``rtol=1.49e-8``) + may be too loose for many astronomical applications. Consider using + tighter tolerances like ``atol=1e-10`` and ``rtol=1e-10`` for better + accuracy:: + + >>> integrator = DOPRI853Integrator(func, atol=1e-10, rtol=1e-10) + + For Cython integration, this class wraps a C implementation based on the + original Hairer and Wanner code. The C version includes dense output + functionality (new in v1.10) that allows efficient evaluation at arbitrary + times through internal interpolation. + + Parameters + ---------- + func : callable + A function that computes the phase-space coordinate derivatives. + Must have signature ``func(t, w, *func_args)`` where ``t`` is time, + ``w`` is the phase-space position array, and ``*func_args`` are + additional arguments. + func_args : tuple, optional + Additional arguments to pass to the derivative function. + func_units : :class:`~gala.units.UnitSystem`, optional + Unit system assumed by the integrand function. + progress : bool, optional + Display a progress bar during integration. Default is False. + save_all : bool, optional + Save the orbit at all timesteps. If False, only save the final state. + Default is True. + **kwargs + Additional keyword arguments for the integrator: + + For Python (SciPy) integration: + * ``atol`` (float) : Absolute tolerance (default: 1.49e-8) + * ``rtol`` (float) : Relative tolerance (default: 1.49e-8) + * ``nsteps`` (int) : Maximum number of steps (default: 500) + * ``max_step`` (float) : Maximum step size (default: 0.0, no limit) + * ``first_step`` (float) : Initial step size (default: 0.0, automatic) + + For Cython integration: + * ``atol`` (float) : Absolute error tolerance per step + * ``rtol`` (float) : Relative error tolerance per step + * ``nmax`` (int) : Maximum number of integration steps + * ``dt_max`` (float) : Maximum internal timestep + * ``nstiff`` (int) : Steps before checking for stiffness + * ``err_if_fail`` (bool) : Raise error if integration fails + * ``log_output`` (bool) : Log debug messages from C integrator + + Notes + ----- + The DOPRI853 method is well-suited for smooth problems where high accuracy + is required. It automatically adjusts the step size to maintain the + specified error tolerances, making it efficient for problems with varying + time scales. + + References + ---------- + * Dormand, J. R. & Prince, P. J. (1980). A family of embedded Runge-Kutta + formulae. Journal of Computational and Applied Mathematics, 6(1), 19-26. + * Hairer, E., NÞrsett, S. P. & Wanner, G. (1993). Solving Ordinary + Differential Equations I. Springer-Verlag. + + Examples + -------- + Create an integrator with tight tolerances:: + + >>> def derivs(t, w): + ... # Simple harmonic oscillator + ... return np.array([w[1], -w[0]]) + >>> integrator = DOPRI853Integrator(derivs, atol=1e-12, rtol=1e-12) + """ + + def __init__( + self, + func, + func_args=(), + func_units=None, + progress=False, + save_all=True, + **kwargs, + ): + super().__init__( + func, func_args, func_units, progress=progress, save_all=save_all + ) + self._ode_kwargs = kwargs + + def __call__(self, w0, mmap=None, **time_spec): + # generate the array of times + times = parse_time_specification(self._func_units, **time_spec) + n_steps = len(times) - 1 + + w0, arr_w0, ws = self._prepare_ws(w0, mmap, n_steps) + size_1d = 2 * self.ndim * self.norbits + + # need this to do resizing, and to handle func_args because there is some + # issue with the args stuff in scipy... + def func_wrapper(t, x): + x_ = x.reshape((2 * self.ndim, self.norbits)) + val = self.F(t, x_, *self._func_args) + return val.reshape((size_1d,)) + + self._ode = ode(func_wrapper, jac=None) + self._ode = self._ode.set_integrator("dop853", **self._ode_kwargs) + + # create the return arrays + if self.save_all: + ws[:, 0] = arr_w0 + + # make 1D + arr_w0 = arr_w0.reshape((size_1d,)) + + # set the initial conditions + self._ode.set_initial_value(arr_w0, times[0]) + + # Integrate the ODE(s) across each delta_t timestep + range_ = self._get_range_func() + for k in range_(1, n_steps + 1): + self._ode.integrate(times[k]) + outy = self._ode.y + + if self.save_all: + ws[:, k] = outy.reshape(2 * self.ndim, self.norbits) + + if not self._ode.successful(): + raise RuntimeError("ODE integration failed!") + + if not self.save_all: + ws = outy.reshape(2 * self.ndim, 1, self.norbits) + times = times[-1:] + + return self._handle_output(w0, times, ws) diff --git a/gala/source/src/gala/integrate/pyintegrators/leapfrog.py b/gala/source/src/gala/integrate/pyintegrators/leapfrog.py new file mode 100644 index 0000000000000000000000000000000000000000..bba44851e392c4c13c5fa25f51cc435379cb66d5 --- /dev/null +++ b/gala/source/src/gala/integrate/pyintegrators/leapfrog.py @@ -0,0 +1,188 @@ +"""Leapfrog integration.""" + +import numpy as np + +from ..core import Integrator +from ..timespec import parse_time_specification + +__all__ = ["LeapfrogIntegrator"] + + +class LeapfrogIntegrator(Integrator): + r""" + Symplectic leapfrog integrator for Hamiltonian systems. + + The leapfrog integrator is a second-order symplectic method that is + particularly well-suited for integrating Hamiltonian systems over long + time periods. It conserves energy exactly for linear systems and + approximately for nonlinear systems, with bounded energy errors. + + The method alternately updates positions and momenta (velocities) in a + "leapfrog" pattern, where velocities are evaluated at half-integer + timesteps relative to positions. This staggered evaluation is what + gives the method its symplectic properties. + + Parameters + ---------- + func : callable + A function that computes the phase-space coordinate derivatives. + Must have signature ``func(t, w, *func_args)`` where ``t`` is time, + ``w`` is the phase-space position array with shape ``(2*ndim, ...)``, + and ``*func_args`` are additional arguments. + func_args : tuple, optional + Additional arguments to pass to the derivative function. + func_units : :class:`~gala.units.UnitSystem`, optional + Unit system assumed by the integrand function. + progress : bool, optional + Display a progress bar during integration. Default is False. + save_all : bool, optional + Save the orbit at all timesteps. If False, only save the final state. + Default is True. + + Notes + ----- + The leapfrog method uses the following update scheme: + + .. math:: + + v_{i+1/2} &= v_{i-1/2} + a_i \Delta t \\ + x_{i+1} &= x_i + v_{i+1/2} \Delta t + + where :math:`a_i = F(t_i, x_i, v_{i-1/2})` is the acceleration at + position :math:`x_i`. + + The integrator automatically handles the initial half-step offset for + velocities by computing :math:`v_{1/2} = v_0 + a_0 \Delta t / 2` from + the initial conditions. + + Advantages: + * Symplectic (preserves phase-space structure) + * Time-reversible + * Excellent long-term energy conservation + * Computationally efficient (one force evaluation per step) + + Disadvantages: + * Only second-order accurate + * Requires fixed timesteps + * Less accurate than higher-order methods for smooth problems + + References + ---------- + * Verlet, L. (1967). Computer "experiments" on classical fluids. Physical + Review, 159(1), 98-103. + * Leimkuhler, B. & Reich, S. (2004). Simulating Hamiltonian Dynamics. + Cambridge University Press. + + Examples + -------- + Simple harmonic oscillator with Hamiltonian :math:`H = \\frac{1}{2}(p^2 + q^2)`: + + .. code-block:: python + + def derivs(t, w): + q, p = w[0], w[1] # position, momentum + return np.array([p, -q]) # [dq/dt, dp/dt] + + + integrator = LeapfrogIntegrator(derivs) + orbit = integrator(w0=[1.0, 0.0], dt=0.1, n_steps=1000) + + The derivative function must return an array where the first half + contains position derivatives (velocities) and the second half contains + momentum derivatives (accelerations). + """ + + def step(self, t, x_im1, v_im1_2, dt): + """ + Advance the integration by one timestep using the leapfrog scheme. + + This method performs a single leapfrog step, updating positions + and velocities according to the symplectic leapfrog algorithm. + + Parameters + ---------- + t : float + Current time. + x_im1 : :class:`~numpy.ndarray` + Position at the previous timestep, shape ``(ndim, norbits)``. + v_im1_2 : :class:`~numpy.ndarray` + Velocity at the previous half-timestep, shape ``(ndim, norbits)``. + dt : float + Integration timestep. + + Returns + ------- + x_i : :class:`~numpy.ndarray` + Updated position at the current timestep. + v_i : :class:`~numpy.ndarray` + Velocity at the current timestep (synchronized with position). + v_ip1_2 : :class:`~numpy.ndarray` + Velocity at the next half-timestep, ready for the next integration step. + + Notes + ----- + The leapfrog step consists of: + 1. Update position: :math:`x_i = x_{i-1} + v_{i-1/2} \\Delta t` + 2. Compute force: :math:`F_i = F(t, x_i, v_{i-1/2})` + 3. Update velocity: :math:`v_{i+1/2} = v_{i-1/2} + a_i \\Delta t` + 4. Compute synchronized velocity: :math:`v_i = (v_{i-1/2} + v_{i+1/2})/2` + """ + + x_i = x_im1 + v_im1_2 * dt + F_i = self.F(t, np.vstack((x_i, v_im1_2)), *self._func_args) + a_i = F_i[self.ndim :] + + v_i = v_im1_2 + a_i * dt / 2 + v_ip1_2 = v_i + a_i * dt / 2 + + return x_i, v_i, v_ip1_2 + + def _init_v(self, t, w0, dt): + """ + Leapfrog updates the velocities offset a half-step from the + position updates. If we're given initial conditions aligned in + time, e.g. the positions and velocities at the same 0th step, + then we have to initially scoot the velocities forward by a half + step to prime the integrator. + + Parameters + ---------- + dt : numeric + The first timestep. + """ + + # here is where we scoot the velocity at t=t1 to v(t+1/2) + F0 = self.F(t.copy(), w0.copy(), *self._func_args) + a0 = F0[self.ndim :] + return w0[self.ndim :] + a0 * dt / 2.0 + + def __call__(self, w0, mmap=None, **time_spec): + # generate the array of times + times = parse_time_specification(self._func_units, **time_spec) + n_steps = len(times) - 1 + dt = times[1] - times[0] + + w0_obj, w0, ws = self._prepare_ws(w0, mmap, n_steps) + x0 = w0[: self.ndim] + + # prime the integrator so velocity is offset from coordinate by a + # half timestep + v_im1_2 = self._init_v(times[0], w0, dt) + x_im1 = x0 + + if self.save_all: + ws[:, 0] = w0 + + range_ = self._get_range_func() + for ii in range_(1, n_steps + 1): + x_i, v_i, v_ip1_2 = self.step(times[ii], x_im1, v_im1_2, dt) + + slc = (ii, slice(None)) if self.save_all else (slice(None),) + ws[(slice(None, self.ndim), *slc)] = x_i + ws[(slice(self.ndim, None), *slc)] = v_i + x_im1, v_im1_2 = x_i, v_ip1_2 + + if not self.save_all: + times = times[-1:] + + return self._handle_output(w0_obj, times, ws) diff --git a/gala/source/src/gala/integrate/pyintegrators/rk5.py b/gala/source/src/gala/integrate/pyintegrators/rk5.py new file mode 100644 index 0000000000000000000000000000000000000000..6eceacb9489dcb106ae075592168f7eda5515ead --- /dev/null +++ b/gala/source/src/gala/integrate/pyintegrators/rk5.py @@ -0,0 +1,197 @@ +"""5th order Runge-Kutta integration.""" + +import numpy as np + +from ..core import Integrator +from ..timespec import parse_time_specification + +__all__ = ["RK5Integrator"] + +# These are the Dormand-Prince parameters for embedded Runge-Kutta methods +A = np.array([0.0, 0.2, 0.3, 0.6, 1.0, 0.875]) +B = np.array( + [ + [0.0, 0.0, 0.0, 0.0, 0.0], + [1.0 / 5.0, 0.0, 0.0, 0.0, 0.0], + [3.0 / 40.0, 9.0 / 40.0, 0.0, 0.0, 0.0], + [3.0 / 10.0, -9.0 / 10.0, 6.0 / 5.0, 0.0, 0.0], + [-11.0 / 54.0, 5.0 / 2.0, -70.0 / 27.0, 35.0 / 27.0, 0.0], + [ + 1631.0 / 55296.0, + 175.0 / 512.0, + 575.0 / 13824.0, + 44275.0 / 110592.0, + 253.0 / 4096.0, + ], + ] +) +C = np.array([37.0 / 378.0, 0.0, 250.0 / 621.0, 125.0 / 594.0, 0.0, 512.0 / 1771.0]) +D = np.array( + [ + 2825.0 / 27648.0, + 0.0, + 18575.0 / 48384.0, + 13525.0 / 55296.0, + 277.0 / 14336.0, + 1.0 / 4.0, + ] +) + + +class RK5Integrator(Integrator): + r""" + Fifth-order Runge-Kutta integrator with fixed timesteps. + + This integrator implements the classical fifth-order Runge-Kutta method + (RK5) using the Dormand-Prince coefficients. It provides fifth-order + accuracy for smooth problems with a fixed timestep, making it suitable + for problems where high accuracy is needed and the solution varies + smoothly in time. + + Unlike adaptive methods, this integrator uses a fixed timestep throughout + the integration, which can be more predictable but may be less efficient + for problems with varying time scales. + + Parameters + ---------- + func : callable + A function that computes the phase-space coordinate derivatives. + Must have signature ``func(t, w, *func_args)`` where ``t`` is time, + ``w`` is the phase-space position array, and ``*func_args`` are + additional arguments. + func_args : tuple, optional + Additional arguments to pass to the derivative function. + func_units : :class:`~gala.units.UnitSystem`, optional + Unit system assumed by the integrand function. + progress : bool, optional + Display a progress bar during integration. Default is False. + save_all : bool, optional + Save the orbit at all timesteps. If False, only save the final state. + Default is True. + + Notes + ----- + The RK5 method uses six function evaluations per timestep to achieve + fifth-order accuracy. The update formula is: + + .. math:: + + w_{n+1} = w_n + \\sum_{i=1}^{6} c_i k_i + + where the :math:`k_i` are intermediate slope estimates computed using + the Dormand-Prince coefficients. + + Advantages: + * Fifth-order accuracy for smooth problems + * Stable and robust for most ODE systems + * Predictable computational cost (6 function evaluations per step) + + Disadvantages: + * Not symplectic (may not conserve energy for Hamiltonian systems) + * Fixed timestep can be inefficient + * More expensive per step than lower-order methods + + References + ---------- + * Dormand, J. R. & Prince, P. J. (1980). A family of embedded Runge-Kutta + formulae. Journal of Computational and Applied Mathematics, 6(1), 19-26. + * Hairer, E., NÞrsett, S. P. & Wanner, G. (1993). Solving Ordinary + Differential Equations I. Springer-Verlag. + + Examples + -------- + Integrate a simple harmonic oscillator: + + .. code-block:: python + + def derivs(t, w): + return np.array([w[1], -w[0]]) # [dx/dt, dv/dt] + + + integrator = RK5Integrator(derivs) + orbit = integrator(w0=[1.0, 0.0], dt=0.01, n_steps=1000) + """ + + def step(self, t, w, dt): + """ + Advance the integration by one timestep using the RK5 method. + + This method performs a single Runge-Kutta step using the classical + fifth-order formula with Dormand-Prince coefficients. + + Parameters + ---------- + t : float + Current time. + w : :class:`~numpy.ndarray` + Current state vector with shape ``(2*ndim, norbits)``. + dt : float + Integration timestep. + + Returns + ------- + w_new : :class:`~numpy.ndarray` + Updated state vector at time ``t + dt``. + + Notes + ----- + The method computes six intermediate slopes :math:`k_1, ..., k_6` + and combines them with the Dormand-Prince weights to achieve + fifth-order accuracy. + """ + + # Runge-Kutta Fehlberg formulas (see: Numerical Recipes) + F = lambda t, w: self.F(t, w, *self._func_args) + + K = np.zeros((6, *w.shape)) + K[0] = dt * F(t, w) + K[1] = dt * F(t + A[1] * dt, w + B[1][0] * K[0]) + K[2] = dt * F(t + A[2] * dt, w + B[2][0] * K[0] + B[2][1] * K[1]) + K[3] = dt * F( + t + A[3] * dt, w + B[3][0] * K[0] + B[3][1] * K[1] + B[3][2] * K[2] + ) + K[4] = dt * F( + t + A[4] * dt, + w + B[4][0] * K[0] + B[4][1] * K[1] + B[4][2] * K[2] + B[4][3] * K[3], + ) + K[5] = dt * F( + t + A[5] * dt, + w + + B[5][0] * K[0] + + B[5][1] * K[1] + + B[5][2] * K[2] + + B[5][3] * K[3] + + B[5][4] * K[4], + ) + + # shift + dw = np.zeros_like(w) + for i in range(6): + dw += C[i] * K[i] + + return w + dw + + def __call__(self, w0, mmap=None, **time_spec): + # generate the array of times + times = parse_time_specification(self._func_units, **time_spec) + n_steps = len(times) - 1 + dt = times[1] - times[0] + + w0_obj, w0, ws = self._prepare_ws(w0, mmap, n_steps=n_steps) + + if self.save_all: + # Set first step to the initial conditions + ws[:, 0] = w0 + w = w0.copy() + range_ = self._get_range_func() + for ii in range_(1, n_steps + 1): + w = self.step(times[ii], w, dt) + + if self.save_all: + ws[:, ii] = w + + if not self.save_all: + ws = w + times = times[-1:] + + return self._handle_output(w0_obj, times, ws) diff --git a/gala/source/src/gala/integrate/pyintegrators/ruth4.py b/gala/source/src/gala/integrate/pyintegrators/ruth4.py new file mode 100644 index 0000000000000000000000000000000000000000..9e1fe02410783343a1a97de17c8a5391b14d746a --- /dev/null +++ b/gala/source/src/gala/integrate/pyintegrators/ruth4.py @@ -0,0 +1,149 @@ +"""Leapfrog integration.""" + +from ..core import Integrator +from ..timespec import parse_time_specification + +__all__ = ["Ruth4Integrator"] + + +class Ruth4Integrator(Integrator): + r""" + Fourth-order symplectic integrator using Ruth's method. + + This integrator implements a fourth-order symplectic integration scheme + developed by Ruth (1983). It provides higher accuracy than the standard + leapfrog method while preserving the symplectic structure of Hamiltonian + systems, making it excellent for long-term orbital integrations. + + The method uses a composition of multiple leapfrog-like steps with + carefully chosen coefficients to achieve fourth-order accuracy while + maintaining symplecticity and time-reversibility. + + Parameters + ---------- + func : callable + A function that computes the phase-space coordinate derivatives. + Must have signature ``func(t, w, *func_args)`` where ``t`` is time, + ``w`` is the phase-space position array with shape ``(2*ndim, ...)``, + and ``*func_args`` are additional arguments. + func_args : tuple, optional + Additional arguments to pass to the derivative function. + func_units : :class:`~gala.units.UnitSystem`, optional + Unit system assumed by the integrand function. + progress : bool, optional + Display a progress bar during integration. Default is False. + save_all : bool, optional + Save the orbit at all timesteps. If False, only save the final state. + Default is True. + + Notes + ----- + The Ruth4 method uses the following composition coefficients: + + .. math:: + + c_1 = c_4 &= \\frac{1}{2(2-2^{1/3})} \\\\ + c_2 = c_3 &= \\frac{1-2^{1/3}}{2(2-2^{1/3})} \\\\ + d_1 &= 0 \\\\ + d_2 &= \\frac{1}{2-2^{1/3}} \\\\ + d_3 &= \\frac{-2^{1/3}}{2-2^{1/3}} \\\\ + d_4 &= \\frac{1}{2-2^{1/3}} + + Each timestep consists of four substeps that collectively achieve + fourth-order accuracy. + + Advantages: + * Fourth-order accuracy (vs second-order for leapfrog) + * Symplectic (preserves phase-space structure) + * Time-reversible + * Excellent long-term stability for Hamiltonian systems + + Disadvantages: + * More expensive than leapfrog (4 force evaluations per step) + * Requires fixed timesteps + * Can be less stable than leapfrog for some stiff problems + + References + ---------- + * Ruth, R. D. (1983). A canonical integration technique. IEEE Transactions + on Nuclear Science, 30(4), 2669-2671. + * Forest, E. & Ruth, R. D. (1990). Fourth-order symplectic integration. + Physica D, 43(1), 105-117. + + Examples + -------- + Simple harmonic oscillator with Hamiltonian :math:`H = \\frac{1}{2}(p^2 + q^2)`: + + .. code-block:: python + + def derivs(t, w): + q, p = w[0], w[1] # position, momentum + return np.array([p, -q]) # [dq/dt, dp/dt] + + + integrator = Ruth4Integrator(derivs) + orbit = integrator(w0=[1.0, 0.0], dt=0.1, n_steps=1000) + + The derivative function must return an array where the first half + contains position derivatives (velocities) and the second half contains + momentum derivatives (accelerations). + """ + + # From: https://en.wikipedia.org/wiki/Symplectic_integrator + _cs = [ + 1 / (2 * (2 - 2 ** (1 / 3))), + (1 - 2 ** (1 / 3)) / (2 * (2 - 2 ** (1 / 3))), + (1 - 2 ** (1 / 3)) / (2 * (2 - 2 ** (1 / 3))), + 1 / (2 * (2 - 2 ** (1 / 3))), + ] + _ds = [ + 0, + 1 / (2 - 2 ** (1 / 3)), + -(2 ** (1 / 3)) / (2 - 2 ** (1 / 3)), + 1 / (2 - 2 ** (1 / 3)), + ] + + def step(self, t, w, dt): + """ + Step forward the positions and velocities by the given timestep. + + Parameters + ---------- + dt : numeric + The timestep to move forward. + """ + + w_i = w.copy() + for cj, dj in zip(self._cs, self._ds): + F_i = self.F(t, w_i, *self._func_args) + a_i = F_i[self.ndim :] + + w_i[self.ndim :] += dj * a_i * dt + w_i[: self.ndim] += cj * w_i[self.ndim :] * dt + + return w_i + + def __call__(self, w0, mmap=None, **time_spec): + # generate the array of times + times = parse_time_specification(self._func_units, **time_spec) + n_steps = len(times) - 1 + dt = times[1] - times[0] + + w0_obj, w0, ws = self._prepare_ws(w0, mmap, n_steps=n_steps) + + # Set first step to the initial conditions + if self.save_all: + ws[:, 0] = w0 + w = w0.copy() + range_ = self._get_range_func() + for ii in range_(1, n_steps + 1): + w = self.step(times[ii], w, dt) + + if self.save_all: + ws[:, ii] = w + + if not self.save_all: + ws = w + times = times[-1:] + + return self._handle_output(w0_obj, times, ws) diff --git a/gala/source/src/gala/integrate/timespec.py b/gala/source/src/gala/integrate/timespec.py new file mode 100644 index 0000000000000000000000000000000000000000..52107be7ed7e1bab7c44e128d94f29ebe307cb4d --- /dev/null +++ b/gala/source/src/gala/integrate/timespec.py @@ -0,0 +1,147 @@ +"""Helper function for turning different ways of specifying the integration +times into an array of times. +""" + +import numpy as np + +__all__ = ["parse_time_specification"] + + +def parse_time_specification(units, dt=None, n_steps=None, t1=None, t2=None, t=None): + """ + Parse different ways of specifying integration times into an array of times. + + This function accepts several different combinations of parameters to + specify the times at which to evaluate the integrated orbit. The supported + combinations allow for flexible time specification in orbit integration. + + Parameters + ---------- + units : :class:`~gala.units.UnitSystem` + The unit system to use for dimensionful time quantities. + dt : float or array_like, optional + Timestep(s) for integration. Can be a scalar for fixed timesteps + or an array of timesteps for variable spacing. + n_steps : int, optional + Number of integration steps to take. + t1 : float, optional + Initial time for the integration. + t2 : float, optional + Final time for the integration. + t : array_like, optional + Explicit array of times at which to evaluate the orbit. + + Returns + ------- + times : :class:`~numpy.ndarray` + Array of times at which the orbit will be evaluated. + + Raises + ------ + ValueError + If the time specification is invalid or incomplete, or if the + signs of ``dt`` and ``(t2-t1)`` are inconsistent. + + Examples + -------- + Fixed timestep with number of steps:: + + >>> times = parse_time_specification(units, dt=0.1, n_steps=100) + + Fixed timestep with start and end times:: + + >>> times = parse_time_specification(units, dt=0.1, t1=0, t2=10) + + Explicit array of times:: + + >>> import numpy as np + >>> t_array = np.linspace(0, 10, 101) + >>> times = parse_time_specification(units, t=t_array) + + Notes + ----- + The following parameter combinations are supported: + + * ``dt, n_steps[, t1]`` : Fixed timestep and number of steps + * ``dt, t1, t2`` : Fixed timestep with start and end times + * ``dt, t1`` : Array of timesteps with initial time (dt must be array) + * ``n_steps, t1, t2`` : Number of steps between start and end times + * ``t`` : Explicit array of times + """ + if n_steps is not None: # parse and validate n_steps + n_steps = int(n_steps) + + if hasattr(dt, "unit"): + dt = dt.decompose(units).value + + if hasattr(t1, "unit"): + t1 = t1.decompose(units).value + + if hasattr(t2, "unit"): + t2 = t2.decompose(units).value + + if hasattr(t, "unit"): + t = t.decompose(units).value + + # t : array_like + if t is not None: + times = t + return times.astype(np.float64) + + if dt is None and (t1 is None or t2 is None or n_steps is None): + raise ValueError( + "Invalid specification of integration time. See docstring for more " + "information." + ) + + # dt, n_steps[, t1] : (numeric, int[, numeric]) + if dt is not None and n_steps is not None: + if t1 is None: + t1 = 0.0 + + times = parse_time_specification(units, dt=np.ones(n_steps + 1) * dt, t1=t1) + + # dt, t1, t2 : (numeric, numeric, numeric) + elif dt is not None and t1 is not None and t2 is not None: + if t2 < t1 and dt < 0: + t_i = t1 + times = [] + ii = 0 + while (t_i > t2) and (ii < 1e6): + times.append(t_i) + t_i += dt + + if times[-1] != t2: + times.append(t2) + + return np.array(times, dtype=np.float64) + + if t2 > t1 and dt > 0: + t_i = t1 + times = [] + ii = 0 + while (t_i < t2) and (ii < 1e6): + times.append(t_i) + t_i += dt + + return np.array(times, dtype=np.float64) + + if dt == 0: + raise ValueError("dt must be non-zero.") + raise ValueError( + "If t2 < t1, dt must be negative. If t1 < t2, dt must be positive." + ) + + # dt, t1 : (array_like, numeric) + elif isinstance(dt, np.ndarray) and t1 is not None: + times = np.cumsum(np.append([0.0], dt)) + t1 + times = times[:-1] + + # n_steps, t1, t2 : (int, numeric, numeric) + elif dt is None and not (t1 is None or t2 is None or n_steps is None): + times = np.linspace(t1, t2, n_steps, endpoint=True) + + else: + raise ValueError("Invalid options. See docstring.") + + return times.astype(np.float64) diff --git a/gala/source/src/gala/io.py b/gala/source/src/gala/io.py new file mode 100644 index 0000000000000000000000000000000000000000..428930312d887426e1483291057bfaa7565bc9b7 --- /dev/null +++ b/gala/source/src/gala/io.py @@ -0,0 +1,49 @@ +import astropy.units as u + + +def quantity_from_hdf5(dset): + """ + Return an Astropy Quantity object from a key in an HDF5 file, + group, or dataset. This checks to see if the input file/group/dataset + contains a ``'unit'`` attribute (e.g., in `f.attrs`). + + Parameters + ---------- + dset : :class:`h5py.DataSet` + + Returns + ------- + q : `astropy.units.Quantity`, `numpy.ndarray` + If a unit attribute exists, this returns a Quantity. Otherwise, it + returns a numpy array. + """ + if "unit" in dset.attrs and dset.attrs["unit"] is not None: + unit = u.Unit(dset.attrs["unit"]) + else: + unit = 1.0 + + return dset[:] * unit + + +def quantity_to_hdf5(f, key, q): + """ + Turn an Astropy Quantity object into something we can write out to + an HDF5 file. + + Parameters + ---------- + f : :class:`h5py.File`, :class:`h5py.Group`, :class:`h5py.DataSet` + key : str + The name. + q : float, `astropy.units.Quantity` + The quantity. + + """ + + if hasattr(q, "unit"): + f[key] = q.value + f[key].attrs["unit"] = str(q.unit) + + else: + f[key] = q + f[key].attrs["unit"] = "" diff --git a/gala/source/src/gala/logging.py b/gala/source/src/gala/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..b74d887ff6f8dc2131d89e81665ad38f19eb5cb5 --- /dev/null +++ b/gala/source/src/gala/logging.py @@ -0,0 +1,40 @@ +import logging +import sys + + +class CustomStreamHandler(logging.StreamHandler): + def emit(self, record): + record.origin = "gala" + + stream = sys.stdout if record.levelno <= logging.INFO else sys.stderr + + self.setStream(stream) + super().emit(record) + + +class Logger(logging.getLoggerClass()): + def _set_defaults(self): + """Reset logger to its initial state""" + + # Remove all previous handlers + for handler in self.handlers: + self.removeHandler(handler) + + # Set default level + self.setLevel(logging.INFO) + + # Set up the custom handler + sh = CustomStreamHandler() + + # create formatter + formatter = logging.Formatter("[%(origin)s] %(levelname)s: %(message)s") + + # add formatter to ch + sh.setFormatter(formatter) + + self.addHandler(sh) + + +logging.setLoggerClass(Logger) +logger = logging.getLogger("gala") +logger._set_defaults() diff --git a/gala/source/src/gala/potential/__init__.py b/gala/source/src/gala/potential/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5a6d13b1136f9d1c8c26866909595333e81f3a1c --- /dev/null +++ b/gala/source/src/gala/potential/__init__.py @@ -0,0 +1,5 @@ +from .common import PotentialParameter +from .frame import * +from .hamiltonian import * +from .potential import * +from .scf import SCFPotential diff --git a/gala/source/src/gala/potential/common.py b/gala/source/src/gala/potential/common.py new file mode 100644 index 0000000000000000000000000000000000000000..af77c16ded1b2578538e13b1da83161f99423dcb --- /dev/null +++ b/gala/source/src/gala/potential/common.py @@ -0,0 +1,331 @@ +import inspect + +import numpy as np + +from gala.units import DimensionlessUnitSystem, UnitSystem +from gala.util import atleast_2d + + +class PotentialParameter: + """A class for defining parameters needed by the potential classes + + Parameters + ---------- + name : str + The name of the parameter. For example, "m" for mass. + physical_type : str (optional) + The physical type (as defined by `astropy.units`) of the expected + physical units that this parameter is in. For example, "mass" for a mass + parameter. Pass `None` if the parameter is not meant to be a Quantity (e.g., + string or integer values). + default : numeric, str, array (optional) + The default value of the parameter. + equivalencies : `astropy.units.equivalencies.Equivalency` (optional) + Any equivalencies required for the parameter. + python_only : bool (optional) + Controls whether to pass this parameter value to the C/Cython layer. True means + a parameter is a Python-only value and will not be passed to the C/Cython layer. + Default is False, meaning by default parameters will be passed to the C/Cython + layer. + """ + + def __init__( + self, + name, + physical_type="dimensionless", + default=None, + equivalencies=None, + python_only=False, + ndim=0, + convert=np.asanyarray, + ): + # TODO: could add a "shape" argument? + # TODO: need better sanitization and validation here + + self.name = str(name) + self.physical_type = str(physical_type) if physical_type is not None else None + self.default = default + self.equivalencies = equivalencies + self.python_only = bool(python_only) + self.ndim = int(ndim) + self.convert = convert + + def __repr__(self): + if self.physical_type is None: + return f"" + return f"" + + +class CommonBase: + def __init_subclass__(cls, GSL_only=False, EXP_only=False, **kwargs): + # Read the default call signature for the init + sig = inspect.signature(cls.__init__) + + # Collect all potential parameters defined on the class: + cls._parameters = {} + sig_parameters = [] + + # Also allow passing parameters in to subclassing: + subcls_params = kwargs.pop("parameters", {}) + subcls_params.update(cls.__dict__) + + for k, v in subcls_params.items(): + if not isinstance(v, PotentialParameter): + continue + + cls._parameters[k] = v + + default = inspect.Parameter.empty if v.default is None else v.default + + sig_parameters.append( + inspect.Parameter( + k, inspect.Parameter.POSITIONAL_OR_KEYWORD, default=default + ) + ) + + for k, param in sig.parameters.items(): + if k == "self" or param.kind == param.VAR_POSITIONAL: + continue + sig_parameters.append(param) + sig_parameters = sorted(sig_parameters, key=lambda x: int(x.kind)) + + # Define a new init signature based on the potential parameters: + newsig = sig.replace(parameters=tuple(sig_parameters)) + cls.__signature__ = newsig + + super().__init_subclass__(**kwargs) + + cls._GSL_only = GSL_only + cls._EXP_only = EXP_only + + if not hasattr(cls, "_extra_serialize_args"): + cls._extra_serialize_args = [] + + @classmethod + def _validate_units(cls, units): + # make sure the units specified are a UnitSystem instance + if units is None: + units = DimensionlessUnitSystem() + + elif isinstance(units, str): + units = UnitSystem.from_string(units) + + elif not isinstance(units, UnitSystem): + units = UnitSystem(*units) + + return units + + def _parse_parameter_values(self, *args, strict=True, **kwargs): + expected_parameter_keys = list(self._parameters.keys()) + + if len(args) > len(expected_parameter_keys): + raise ValueError( + "Too many positional arguments passed in to " + f"{self.__class__.__name__}: Potential and Frame classes only " + "accept parameters as positional arguments, all other " + "arguments (e.g., units) must now be passed in as keyword " + "argument." + ) + + parameter_values = {} + parameter_is_default = set() + + # Get any parameters passed as positional arguments + i = 0 + + if args: + for i in range(len(args)): + parameter_values[expected_parameter_keys[i]] = args[i] + i += 1 + + # Get parameters passed in as keyword arguments: + for k in expected_parameter_keys[i:]: + if k in kwargs: + val = kwargs.pop(k) + else: + val = self._parameters[k].default + parameter_is_default.add(k) + parameter_values[k] = val + + for k, val in parameter_values.items(): + if self._parameters[k].convert is not None: + parameter_values[k] = self._parameters[k].convert(val) + else: + parameter_values[k] = val + + if kwargs and strict: + raise ValueError( + f"{self.__class__} received unexpected keyword " + f"argument(s): {list(kwargs.keys())}" + ) + + for k, pval in parameter_values.items(): + pp = self._parameters[k] + if pp.physical_type is not None and pval.ndim != pp.ndim: + raise ValueError( + f"Parameter {k} should have ndim={pp.ndim} " + f"dimensions, but has ndim={pval.ndim}" + ) + + return parameter_values, parameter_is_default + + def _prepare_parameters(self, parameters, units): + pars = {} + for k, v in parameters.items(): + expected_ptype = self._parameters[k].physical_type + expected_unit = ( + units[expected_ptype] if expected_ptype is not None else None + ) + equiv = self._parameters[k].equivalencies + + if hasattr(v, "unit"): + if not isinstance( + units, DimensionlessUnitSystem + ) and not v.unit.is_equivalent(expected_unit, equiv): + msg = ( + f"Parameter {k} has physical type " + f"'{v.unit.physical_type}', but we expected a " + f"physical type '{expected_ptype}'" + ) + if equiv is not None: + msg = ( + msg + f" or something equivalent via the {equiv} " + "equivalency." + ) + + raise ValueError(msg) + + # NOTE: this can lead to some comparison issues in __eq__, which + # tests for strong equality between parameter values. Here, the + # .to() could cause small rounding issues in comparisons + if v.unit.physical_type != expected_ptype: + v = v.to(expected_unit, equiv) + + v = v.decompose(units) + + elif expected_ptype is not None: + # this is false for empty ptype: treat empty string as u.one + # (i.e. this goes to the else clause) + + # TODO: remove when fix potentials that ask for scale velocity! + if expected_ptype == "speed": + v = v * units["length"] / units["time"] + else: + v = v * units[expected_ptype] + + v = v.decompose(units) + + pars[k] = v + + return pars + + def _remove_units_prepare_shape(self, x): + from gala.dynamics import PhaseSpacePosition + + if hasattr(x, "unit"): + x = x.decompose(self.units).value + + elif isinstance(x, PhaseSpacePosition): + x = x.w(self.units) + + return atleast_2d(x, insert_axis=1).astype(np.float64) + + def _get_c_valid_arr(self, x, transpose=True): + """ + Prepare an array for passing to C: make sure it's 2D and contiguous. + + Parameters + ---------- + x : array-like + The input array. + transpose : bool (optional) + If True, transpose the array so that shape is (N, ndim). Default is True. + + Returns + ------- + orig_shape : tuple + The original shape of the input array. + x : ndarray + The reshaped, contiguous array. + """ + orig_shape = x.shape + x = x.reshape(orig_shape[0], -1) # 2D + if transpose: + x = x.T + x = np.ascontiguousarray(x) + return orig_shape, x + + def _validate_prepare_time(self, t, N_pos): + """ + Make sure that t is a 1D array and compatible with the C position array. + """ + if hasattr(t, "unit"): + t = t.decompose(self.units).value + + if not np.iterable(t): + t = np.atleast_1d(t) + + t = np.ascontiguousarray(t.ravel()) + + if len(t) > 1 and len(t) != N_pos: + raise ValueError( + "If passing in an array of times, it must have a shape " + "compatible with the input position(s)." + ) + + return t + + # For comparison operations + def __eq__(self, other): + if other is None or not hasattr(other, "parameters"): + return False + + # the funkiness in the below is in case there are array parameters: + par_bool = [ + (k1 == k2) and np.all(self.parameters[k1] == other.parameters[k2]) + for k1, k2 in zip(self.parameters.keys(), other.parameters.keys()) + ] + return ( + np.all(par_bool) + and (str(self) == str(other)) + and (self.units == other.units) + ) + + # String representations: + def __repr__(self): + pars = [] + + keys = self.parameters.keys() + for k in keys: + v = self.parameters[k] + post = "" + + if hasattr(v, "unit"): + post = f" {v.unit}" + v = v.value + + if isinstance(v, float): + if v == 0: + par = f"{v:.0f}" + elif np.log10(np.abs(v)) < -2 or np.log10(np.abs(v)) > 5: + par = f"{v:.2e}" + else: + par = f"{v:.2f}" + + elif isinstance(v, int) and np.log10(np.abs(v)) > 5: + par = f"{v:.2e}" + + else: + par = str(v) + + pars.append(f"{k}={par}{post}") + + par_str = ", ".join(pars) + + if isinstance(self.units, DimensionlessUnitSystem): + return f"<{self.__class__.__name__}: {par_str} (dimensionless)>" + core_units_str = ",".join(map(str, self.units._core_units)) + return f"<{self.__class__.__name__}: {par_str} ({core_units_str})>" + + def __str__(self): + return self.__class__.__name__ diff --git a/gala/source/src/gala/potential/frame/__init__.py b/gala/source/src/gala/potential/frame/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6222dafaf3f1406379f3174697c7267a382eb7c9 --- /dev/null +++ b/gala/source/src/gala/potential/frame/__init__.py @@ -0,0 +1,3 @@ +from .builtin import * +from .cframe import CFrameBase +from .core import FrameBase diff --git a/gala/source/src/gala/potential/frame/builtin/__init__.py b/gala/source/src/gala/potential/frame/builtin/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..261f9f7039ce365b4b72635de0fc38dbaa2523c4 --- /dev/null +++ b/gala/source/src/gala/potential/frame/builtin/__init__.py @@ -0,0 +1 @@ +from .frames import ConstantRotatingFrame, StaticFrame diff --git a/gala/source/src/gala/potential/frame/builtin/builtin_frames.cpp b/gala/source/src/gala/potential/frame/builtin/builtin_frames.cpp new file mode 100644 index 0000000000000000000000000000000000000000..c294fc27cdf3f087c4007d15658da8ca921212d6 --- /dev/null +++ b/gala/source/src/gala/potential/frame/builtin/builtin_frames.cpp @@ -0,0 +1,120 @@ +#include +#include +#include "src/vectorization.h" + +/* + Static, inertial frame +*/ +double static_frame_hamiltonian(double t, double *pars, double *qp, int n_dim) { + int i; + double E = 0.; + + for (i=0; i + +extern double static_frame_hamiltonian(double t, double *pars, double *qp, int n_dim); +extern void static_frame_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void static_frame_hessian(double t, double *pars, double *qp, int n_dim, double *hess); + +extern double constant_rotating_frame_2d_hamiltonian(double t, double *pars, double *qp, int n_dim); +extern void constant_rotating_frame_2d_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void constant_rotating_frame_2d_hessian(double t, double *pars, double *qp, int n_dim, double *hess); + +extern double constant_rotating_frame_3d_hamiltonian(double t, double *pars, double *qp, int n_dim); +extern void constant_rotating_frame_3d_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void constant_rotating_frame_3d_hessian(double t, double *pars, double *qp, int n_dim, double *hess); diff --git a/gala/source/src/gala/potential/frame/builtin/frames.pyx b/gala/source/src/gala/potential/frame/builtin/frames.pyx new file mode 100644 index 0000000000000000000000000000000000000000..d3c5518487a5fc8410813dc7fa34e2fedb798692 --- /dev/null +++ b/gala/source/src/gala/potential/frame/builtin/frames.pyx @@ -0,0 +1,150 @@ +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + +import astropy.units as u +import numpy as np +cimport numpy as np +np.import_array() + + +from ..cframe import CFrameBase +from ..cframe cimport CFrameWrapper, CFrameType +from ...common import PotentialParameter +from ....units import dimensionless, DimensionlessUnitSystem +from ...potential.cpotential cimport energyfunc, gradientfunc, hessianfunc + + +cdef extern from "frame/builtin/builtin_frames.h": + double static_frame_hamiltonian(double t, double *pars, double *qp, int n_dim) nogil + void static_frame_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + void static_frame_hessian(double t, double *pars, double *qp, int n_dim, double *hess) nogil + + double constant_rotating_frame_2d_hamiltonian(double t, double *pars, double *qp, int n_dim) nogil + void constant_rotating_frame_2d_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + void constant_rotating_frame_2d_hessian(double t, double *pars, double *qp, int n_dim, double *hess) nogil + + double constant_rotating_frame_3d_hamiltonian(double t, double *pars, double *qp, int n_dim) nogil + void constant_rotating_frame_3d_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + void constant_rotating_frame_3d_hessian(double t, double *pars, double *qp, int n_dim, double *hess) nogil + +__all__ = ['StaticFrame', 'ConstantRotatingFrame'] + +cdef class StaticFrameWrapper(CFrameWrapper): + + def __init__(self, list params): + cdef CFrameType cf + + self.init(params) + + cf.energy = (static_frame_hamiltonian) + cf.gradient = (static_frame_gradient) + cf.hessian = (static_frame_hessian) + cf.n_params = 0 + cf.parameters = NULL + + self.cframe = cf + +class StaticFrame(CFrameBase): + """ + Represents a static intertial reference frame. + + Parameters + ---------- + units : `~gala.units.UnitSystem` (optional) + Set of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + + """ + Wrapper = StaticFrameWrapper + ndim = None + + # NOTE: this is a workaround to allow units as a positional arg for this + # class, because a lot of existing code assumes that... + def __init__(self, units=None): + super().__init__(units=units) + +# --- + +cdef class ConstantRotatingFrameWrapper2D(CFrameWrapper): + + def __init__(self, list params): + cdef: + CFrameType cf + + assert len(params) == 1 + self._params = np.array([params[0]], dtype=np.float64) + + cf.energy = (constant_rotating_frame_2d_hamiltonian) + cf.gradient = (constant_rotating_frame_2d_gradient) + cf.hessian = (constant_rotating_frame_2d_hessian) + cf.n_params = 1 + cf.parameters = &(self._params[0]) + + self.cframe = cf + +cdef class ConstantRotatingFrameWrapper3D(CFrameWrapper): + + def __init__(self, list params): + cdef: + CFrameType cf + + assert len(params) == 3 + self._params = np.array([params[0], params[1], params[2]], + dtype=np.float64) + + cf.energy = (constant_rotating_frame_3d_hamiltonian) + cf.gradient = (constant_rotating_frame_3d_gradient) + cf.hessian = (constant_rotating_frame_3d_hessian) + cf.n_params = 3 + cf.parameters = &(self._params[0]) + + self.cframe = cf + +class ConstantRotatingFrame(CFrameBase): + """ + Represents a constantly rotating reference frame. + + The reference frame rotates with constant angular velocity set by the + magnitude of the vector parameter ``Omega`` around the axis defined by + the unit vector computed from the input frequency vector. + + Parameters + ---------- + Omega : :class:`~astropy.units.Quantity` + The frequency vector, which specifies the axis of rotation and the + angular velocity of the frame. + units : `~gala.units.UnitSystem` (optional) + Set of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + + """ + Omega = PotentialParameter( + 'Omega', + physical_type='frequency', + equivalencies=u.dimensionless_angles(), + ndim=1, + convert=np.atleast_1d + ) + + def _setup_frame(self, parameters, parameter_is_default, units=None): + super()._setup_frame(parameters, parameter_is_default, units=units) + + Omega = np.atleast_1d(self.parameters['Omega']) + + if Omega.shape == (1,): + # assumes ndim=2, must be associated with a 2D potential + self.ndim = 2 + self.Wrapper = ConstantRotatingFrameWrapper2D + + elif Omega.shape == (3,): + # assumes ndim=3, must be associated with a 3D potential + self.ndim = 3 + self.Wrapper = ConstantRotatingFrameWrapper3D + + else: + raise ValueError("Invalid input for rotation vector Omega.") diff --git a/gala/source/src/gala/potential/frame/builtin/transformations.py b/gala/source/src/gala/potential/frame/builtin/transformations.py new file mode 100644 index 0000000000000000000000000000000000000000..9b9d354c2d51c66cd0fe445225fe0077e8771ac8 --- /dev/null +++ b/gala/source/src/gala/potential/frame/builtin/transformations.py @@ -0,0 +1,234 @@ +import numpy as np + +# Gala +from gala.dynamics import Orbit +from gala.units import DimensionlessUnitSystem + +__all__ = ["constantrotating_to_static", "static_to_constantrotating"] + + +def rodrigues_axis_angle_rotate(x, vec, theta): + """ + Rotate vector(s) around an axis using Rodrigues' rotation formula. + + This function rotates the input vector or array of vectors ``x`` around + the axis defined by ``vec`` by the angle ``theta``. The rotation is + performed using Rodrigues' axis-angle rotation formula. + + Parameters + ---------- + x : array_like + The vector or array of vectors to rotate. Should have shape + ``(n_dim,)`` for a single vector or ``(n_dim, n_vectors)`` for + multiple vectors, where ``n_dim`` is the spatial dimension. + vec : array_like + The unit vector defining the rotation axis. Should have the same + spatial dimension as ``x``. + theta : array_like + The rotation angle(s) in radians. Can be a scalar for uniform + rotation or an array matching the number of vectors in ``x``. + + Returns + ------- + rotated : `~numpy.ndarray` + The rotated vector(s) with the same shape as the input ``x``. + + Notes + ----- + This implements Rodrigues' rotation formula: + + .. math:: + \\vec{x}_{\\rm rot} = \\vec{x} \\cos\\theta + (\\vec{k} \\times \\vec{x}) \\sin\\theta + \\vec{k} (\\vec{k} \\cdot \\vec{x}) (1 - \\cos\\theta) + + where :math:`\\vec{k}` is the unit rotation axis vector. + """ + x = np.array(x).T + vec = np.array(vec).T + theta = np.array(theta).T[..., None] + + out = ( + np.cos(theta) * x + + np.sin(theta) * np.cross(vec, x) + + (1 - np.cos(theta)) * (vec * x).sum(axis=-1)[..., None] * vec + ) + + return out.T + + +def z_angle_rotate(xy, theta): + """ + Rotate 2D vector(s) around the z-axis by the specified angle. + + This function performs a 2D rotation of the input vector(s) in the + xy-plane by the angle ``theta`` around the z-axis (origin). + + Parameters + ---------- + xy : array_like + The 2D vector or array of vectors to rotate. Should have shape + ``(2,)`` for a single vector or ``(2, n_vectors)`` for multiple + vectors in the xy-plane. + theta : array_like + The rotation angle(s) in radians. Can be a scalar for uniform + rotation or an array matching the number of vectors in ``xy``. + + Returns + ------- + rotated : `~numpy.ndarray` + The rotated vector(s) with the same shape as the input ``xy``. + + Notes + ----- + This performs a standard 2D rotation using the rotation matrix: + + .. math:: + \\begin{pmatrix} x' \\\\ y' \\end{pmatrix} = + \\begin{pmatrix} \\cos\\theta & -\\sin\\theta \\\\ \\sin\\theta & \\cos\\theta \\end{pmatrix} + \\begin{pmatrix} x \\\\ y \\end{pmatrix} + """ + xy = np.array(xy).T + theta = np.array(theta).T + + out = np.zeros_like(xy) + out[..., 0] = np.cos(theta) * xy[..., 0] - np.sin(theta) * xy[..., 1] + out[..., 1] = np.sin(theta) * xy[..., 0] + np.cos(theta) * xy[..., 1] + + return out.T + + +def _constantrotating_static_helper(frame_r, frame_i, w, t=None, sign=1.0): + # TODO: use representation arithmetic instead + Omega = -frame_r.parameters["Omega"].decompose(frame_i.units).value + + if not isinstance(w, Orbit) and t is None: + raise ValueError( + "Time array must be provided if not passing an Orbit subclass." + ) + + if t is None: + t = w.t + + elif not hasattr(t, "unit"): + t *= frame_i.units["time"] + + if t is None: + raise ValueError( + "Time must be supplied either through the input " + "Orbit class instance or through the t argument." + ) + t = t.decompose(frame_i.units).value + + # HACK: this is a little bit crazy...this makes it so that !=3D + # representations will work here + if hasattr(w.pos, "xyz"): + pos = w.pos + vel = w.vel + else: + cart = w.cartesian + pos = cart.pos + vel = cart.vel + + pos = pos.xyz.decompose(frame_i.units).value + vel = vel.d_xyz.decompose(frame_i.units).value + + # get rotation angle, axis vs. time + if Omega.shape == (3,): # 3D + vec = Omega / np.linalg.norm(Omega) + theta = np.linalg.norm(Omega) * t + + x_i2r = rodrigues_axis_angle_rotate(pos, vec, sign * theta) + v_i2r = rodrigues_axis_angle_rotate(vel, vec, sign * theta) + + elif Omega.shape == (1,): # 2D + vec = Omega[0] * np.array([0, 0, 1.0]) + theta = sign * Omega[0] * t + + x_i2r = z_angle_rotate(pos, theta) + v_i2r = z_angle_rotate(vel, theta) + + else: + raise ValueError("Omega must be either 2D or 3D.") + + return ( + x_i2r * frame_i.units["length"], + v_i2r * frame_i.units["length"] / frame_i.units["time"], + ) + + +def static_to_constantrotating(frame_i, frame_r, w, t=None): + """ + Transform from an inertial static frame to a rotating frame. + + Parameters + ---------- + frame_i : `~gala.potential.StaticFrame` + frame_r : `~gala.potential.ConstantRotatingFrame` + w : `~gala.dynamics.PhaseSpacePosition`, `~gala.dynamics.Orbit` + t : quantity_like (optional) + Required if input coordinates are just a phase-space position. + + Returns + ------- + pos : `~astropy.units.Quantity` + Position in rotating frame. + vel : `~astropy.units.Quantity` + Velocity in rotating frame. + """ + return _constantrotating_static_helper( + frame_r=frame_r, frame_i=frame_i, w=w, t=t, sign=1.0 + ) + + +def constantrotating_to_static(frame_r, frame_i, w, t=None): + """ + Transform from a constantly rotating frame to a static, inertial frame. + + Parameters + ---------- + frame_i : `~gala.potential.StaticFrame` + frame_r : `~gala.potential.ConstantRotatingFrame` + w : `~gala.dynamics.PhaseSpacePosition`, `~gala.dynamics.Orbit` + t : quantity_like (optional) + Required if input coordinates are just a phase-space position. + + Returns + ------- + pos : `~astropy.units.Quantity` + Position in static, inertial frame. + vel : `~astropy.units.Quantity` + Velocity in static, inertial frame. + """ + return _constantrotating_static_helper( + frame_r=frame_r, frame_i=frame_i, w=w, t=t, sign=-1.0 + ) + + +def static_to_static(frame_r, frame_i, w, t=None): + """ + No-op transform + + Parameters + ---------- + frame_i : `~gala.potential.StaticFrame` + frame_r : `~gala.potential.ConstantRotatingFrame` + w : `~gala.dynamics.PhaseSpacePosition`, `~gala.dynamics.Orbit` + t : quantity_like (optional) + Required if input coordinates are just a phase-space position. + + Returns + ------- + pos : `~astropy.units.Quantity` + Position in static, inertial frame. + vel : `~astropy.units.Quantity` + Velocity in static, inertial frame. + """ + tmp = [ + isinstance(frame_r.units, DimensionlessUnitSystem), + isinstance(frame_i.units, DimensionlessUnitSystem), + ] + if not all(tmp) and any(tmp): + raise ValueError( + "StaticFrame to StaticFrame transformations are only allowed if " + "both unit systems are physical, or both are dimensionless." + ) + return w.pos.xyz, w.vel.d_xyz diff --git a/gala/source/src/gala/potential/frame/cframe.pxd b/gala/source/src/gala/potential/frame/cframe.pxd new file mode 100644 index 0000000000000000000000000000000000000000..212bd9cf3f6ee820ac76a837f9bfacf783cd42bd --- /dev/null +++ b/gala/source/src/gala/potential/frame/cframe.pxd @@ -0,0 +1,25 @@ +# cython: language_level=3 +# cython: language=c++ + +from ..potential.cpotential cimport energyfunc, gradientfunc, hessianfunc + +cdef extern from "frame/src/cframe.h": + ctypedef struct CFrameType: + energyfunc energy + gradientfunc gradient + hessianfunc hessian + + int n_params + double *parameters + + double frame_hamiltonian(CFrameType *fr, double t, double *qp, int n_dim) except + nogil + void frame_gradient(CFrameType *fr, double t, double *qp, int n_dim, size_t N, double *dH) except + nogil + void frame_hessian(CFrameType *fr, double t, double *qp, int n_dim, double *d2H) except + nogil + +cdef class CFrameWrapper: + cdef CFrameType cframe + cdef double[::1] _params + cpdef init(self, list parameters) + cpdef energy(self, double[:,::1] w, double[::1] t) + cpdef gradient(self, double[:,::1] w, double[::1] t) + cpdef hessian(self, double[:,::1] w, double[::1] t) diff --git a/gala/source/src/gala/potential/frame/cframe.pyx b/gala/source/src/gala/potential/frame/cframe.pyx new file mode 100644 index 0000000000000000000000000000000000000000..a5eed9ebcee16116c0f6963d60188a872b8d7087 --- /dev/null +++ b/gala/source/src/gala/potential/frame/cframe.pyx @@ -0,0 +1,127 @@ +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + +__all__ = ['CFrameBase'] + + + +import numpy as np +cimport numpy as np +np.import_array() + +from .core import FrameBase +from ..potential.cpotential import _validate_pos_arr +from ..potential.cpotential cimport energyfunc, gradientfunc, hessianfunc + + +cdef class CFrameWrapper: + """ Wrapper class for C implementation of reference frames. """ + + cpdef init(self, list parameters): + # save the array of parameters so it doesn't get garbage-collected + self._params = np.array(parameters, dtype=np.float64) + + cpdef energy(self, double[:, ::1] w, double[::1] t): + """ + w should have shape (n, ndim). + """ + cdef: + int n, ndim, i + CFrameType cf = self.cframe + n, ndim = _validate_pos_arr(w) + + cdef double [::1] pot = np.zeros(n) + if len(t) == 1: + for i in range(n): + pot[i] = frame_hamiltonian(&cf, t[0], &w[i, 0], ndim//2) + else: + for i in range(n): + pot[i] = frame_hamiltonian(&cf, t[i], &w[i, 0], ndim//2) + + + return np.array(pot) + + cpdef gradient(self, double[:, ::1] w, double[::1] t): + """ + w should have shape (ndim, n). + """ + cdef: + int n, ndim, i + CFrameType cf = self.cframe + ndim, n = _validate_pos_arr(w) + + cdef double[:, ::1] dH = np.zeros((ndim, n)) + if len(t) == 1: + frame_gradient(&cf, t[0], &w[0, 0], ndim//2, n, &dH[0, 0]) + else: + for i in range(n): + frame_gradient(&cf, t[i], &w[0, i], ndim//2, 1, &dH[0, i]) + + + return np.array(dH) + + cpdef hessian(self, double[:, ::1] w, double[::1] t): + """ + w should have shape (n, ndim). + """ + cdef: + int n, ndim, i + CFrameType cf = self.cframe + n, ndim = _validate_pos_arr(w) + + cdef double[:, :, ::1] d2H = np.zeros((n, ndim, ndim)) + if len(t) == 1: + for i in range(n): + frame_hessian(&cf, t[0], &w[i, 0], ndim//2, &d2H[i, 0, 0]) + else: + for i in range(n): + frame_hessian(&cf, t[i], &w[i, 0], ndim//2, &d2H[i, 0, 0]) + + return np.array(d2H) + + def __reduce__(self): + return (self.__class__, (list(self._params), )) + + +class CFrameBase(FrameBase): + Wrapper = None + + def __init__(self, *args, units=None, **kwargs): + super().__init__(*args, units=units, **kwargs) + self._setup_wrapper() + + def _setup_wrapper(self): + if self.Wrapper is None: + raise ValueError("C potential wrapper class not defined for " + f"potential class {self.__class__}") + + # to support array parameters, but they get unraveled + arrs = [np.atleast_1d(v.value).ravel() + for v in self.parameters.values()] + + if len(arrs) > 0: + self.c_parameters = np.concatenate(arrs) + else: + self.c_parameters = np.array([]) + + self.c_instance = self.Wrapper(list(self.c_parameters)) + + def __str__(self): + return self.__class__.__name__ + + def _energy(self, q, t): + return self.c_instance.energy(q, t=t) + + def _gradient(self, q, t): + return self.c_instance.gradient(q, t=t) + + def _density(self, q, t): + return self.c_instance.density(q, t=t) + + def _hessian(self, q, t): + return self.c_instance.hessian(q, t=t) diff --git a/gala/source/src/gala/potential/frame/core.py b/gala/source/src/gala/potential/frame/core.py new file mode 100644 index 0000000000000000000000000000000000000000..a5953bec450f2e3f1b27aa160f653521734a7f22 --- /dev/null +++ b/gala/source/src/gala/potential/frame/core.py @@ -0,0 +1,23 @@ +__all__ = ["FrameBase"] + + +from ..common import CommonBase + + +class FrameBase(CommonBase): + ndim = 3 + + def __init__(self, *args, units=None, **kwargs): + parameter_values, parameter_is_default = self._parse_parameter_values( + *args, **kwargs + ) + self._setup_frame( + parameters=parameter_values, + parameter_is_default=parameter_is_default, + units=units, + ) + + def _setup_frame(self, parameters, parameter_is_default, units=None): + self.units = self._validate_units(units) + self.parameters = self._prepare_parameters(parameters, self.units) + self.parameter_is_default = set(parameter_is_default) diff --git a/gala/source/src/gala/potential/frame/src/cframe.cpp b/gala/source/src/gala/potential/frame/src/cframe.cpp new file mode 100644 index 0000000000000000000000000000000000000000..6960441914fab5fc223f5d01ac7b766474a6a304 --- /dev/null +++ b/gala/source/src/gala/potential/frame/src/cframe.cpp @@ -0,0 +1,18 @@ +#include + +#include "frame/src/cframe.h" + +double frame_hamiltonian(CFrameType *fr, double t, double *qp, int n_dim) { + double v = (fr->energy)(t, (fr->parameters), qp, n_dim, NULL); + return v; +} + +void frame_gradient(CFrameType *fr, double t, double *qp, int n_dim, size_t N, double *dH) { + (fr->gradient)(t, (fr->parameters), qp, n_dim, N, dH, NULL); +} + +void frame_hessian(CFrameType *fr, double t, double *qp, int n_dim, double *d2H) { + // TODO: not implemented!! + // TODO: can I just add in the terms from the frame here? + // (fr->hessian)(t, (fr->parameters), qp, n_dim, d2H); +} diff --git a/gala/source/src/gala/potential/frame/src/cframe.h b/gala/source/src/gala/potential/frame/src/cframe.h new file mode 100644 index 0000000000000000000000000000000000000000..95bdd8b94d58a2f727e1f7374ceda938eafb4041 --- /dev/null +++ b/gala/source/src/gala/potential/frame/src/cframe.h @@ -0,0 +1,22 @@ +#include "src/funcdefs.h" + +#ifndef _CFRAME_H +#define _CFRAME_H + // typedef struct CFrameType CFrame; + + typedef struct { + // arrays of pointers to each of the function types above + energyfunc energy; + gradientfunc gradient; + hessianfunc hessian; + + int n_params; + + // pointer to the parameter array + double *parameters; + } CFrameType; +#endif + +extern double frame_hamiltonian(CFrameType *fr, double t, double *qp, int n_dim); +extern void frame_gradient(CFrameType *fr, double t, double *qp, int n_dim, size_t N, double *dH); +extern void frame_hessian(CFrameType *fr, double t, double *qp, int n_dim, double *d2H); diff --git a/gala/source/src/gala/potential/hamiltonian/__init__.py b/gala/source/src/gala/potential/hamiltonian/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9ac3adba7f0b7987e19cb294fc83c84d812c234a --- /dev/null +++ b/gala/source/src/gala/potential/hamiltonian/__init__.py @@ -0,0 +1 @@ +from .chamiltonian import * diff --git a/gala/source/src/gala/potential/hamiltonian/chamiltonian.pyx b/gala/source/src/gala/potential/hamiltonian/chamiltonian.pyx new file mode 100644 index 0000000000000000000000000000000000000000..497c0b47acac7380ece06c6c1bc6f918e5401e22 --- /dev/null +++ b/gala/source/src/gala/potential/hamiltonian/chamiltonian.pyx @@ -0,0 +1,380 @@ +# cython: language_level=3 +# cython: language=c++ + +# Standard-library +import warnings + + +import numpy as np +from astropy.utils.decorators import deprecated_renamed_argument +import astropy.units as u + + +from ..common import CommonBase +from ..potential import PotentialBase, CPotentialBase +from ..frame import FrameBase, CFrameBase, StaticFrame +from ...integrate import LeapfrogIntegrator, DOPRI853Integrator, Ruth4Integrator + +__all__ = ["Hamiltonian"] + + +class Hamiltonian(CommonBase): + """ + Represents a composition of a gravitational potential and a reference frame. + + This class is used to integrate orbits and compute quantities when working + in non-inertial reference frames. The input potential and frame objects + must have the same dimensionality and the same unit system. If both the + potential and the frame are implemented in C, numerical orbit integration + will use the C-implemented integrators and will be fast (to check if your + object is C-enabled, check the ``.c_enabled`` attribute). + + Parameters + ---------- + potential : :class:`~gala.potential.potential.PotentialBase` subclass + The gravitational potential. + frame : :class:`~gala.potential.frame.FrameBase` subclass (optional) + The reference frame. + + """ + def __init__(self, potential, frame=None): + if isinstance(potential, Hamiltonian): + frame = potential.frame + potential = potential.potential + + if frame is None: + frame = StaticFrame(units=potential.units) + + elif not isinstance(frame, FrameBase): + raise ValueError("Invalid input for reference frame. Must be a " + "FrameBase subclass.") + + if not isinstance(potential, PotentialBase): + raise ValueError("Invalid input for potential. Must be a " + "PotentialBase subclass.") + + self.potential = potential + self.frame = frame + self._pot_ndim = self.potential.ndim + self.ndim = 2 * self._pot_ndim + + if frame is not None: + if frame.units != potential.units: + raise ValueError( + "Potential and Frame must have compatible unit systems " + f"({potential.units} vs {frame.units})") + + if frame.ndim is not None and frame.ndim != potential.ndim: + raise ValueError( + "Potential and Frame must have compatible phase-space " + f"dimensionality ({potential.ndim} vs {frame.ndim})") + + # TODO: document this attribute + if isinstance(self.potential, CPotentialBase) and isinstance(self.frame, CFrameBase): + self.c_enabled = True + + else: + self.c_enabled = False + + @property + def units(self): + return self.potential.units + + def _energy(self, w, t): + pot_E = self.potential._energy(np.ascontiguousarray(w[:, :self._pot_ndim]), t=t) + other_E = self.frame._energy(w, t=t) + return pot_E + other_E + + def _gradient(self, w, t): + q = np.ascontiguousarray(w[:self._pot_ndim]) + + dH = np.zeros_like(w) + + # extra terms from the frame + dH += self.frame._gradient(w, t=t) + dH[self._pot_ndim:] += self.potential._gradient(q, t=t) + for i in range(self._pot_ndim): + dH[self._pot_ndim+i] = -dH[self._pot_ndim+i] + + return dH + + def _hessian(self, w, t): + raise NotImplementedError() + + # ======================================================================== + # Core methods that use the above implemented functions + # + def energy(self, w, t=0.): + """ + Compute the energy (the value of the Hamiltonian) at the given phase-space position(s). + + Parameters + ---------- + w : `~gala.dynamics.PhaseSpacePosition`, array_like + The phase-space position to compute the value of the Hamiltonian. + If the input object has no units (i.e. is an `~numpy.ndarray`), it + is assumed to be in the same unit system as the potential class. + + Returns + ------- + H : `~astropy.units.Quantity` + Energy per unit mass or value of the Hamiltonian. If the input + phase-space position has shape ``w.shape``, the output energy + will have shape ``w.shape[1:]``. + """ + w = self._remove_units_prepare_shape(w) + orig_shape, w = self._get_c_valid_arr(w) + t = self._validate_prepare_time(t, len(w)) + return self._energy(w, t=t).T.reshape(orig_shape[1:]) * self.units['energy'] / self.units['mass'] + + def gradient(self, w, t=0.): + """ + Compute the gradient of the Hamiltonian at the given phase-space position(s). + + Parameters + ---------- + w : `~gala.dynamics.PhaseSpacePosition`, array_like + The phase-space position to compute the value of the Hamiltonian. + If the input object has no units (i.e. is an `~numpy.ndarray`), it + is assumed to be in the same unit system as the potential class. + + Returns + ------- + TODO: this can't return a quantity, because units are different dH/dq vs. dH/dp + grad : `~astropy.units.Quantity` + The gradient of the potential. Will have the same shape as + the input phase-space position, ``w``. + """ + w = self._remove_units_prepare_shape(w) + + # transpose=False because the gradient functions expect (ndim, N) arrays + orig_shape, w = self._get_c_valid_arr(w, transpose=False) + + t = self._validate_prepare_time(t, w.shape[1]) + + # TODO: wat do about units here? + # ret_unit = self.units['length'] / self.units['time']**2 + return self._gradient(w, t=t).reshape(orig_shape) + + def hessian(self, w, t=0.): + """ + Compute the Hessian of the Hamiltonian at the given phase-space position(s). + + Parameters + ---------- + w : `~gala.dynamics.PhaseSpacePosition`, array_like + The phase-space position to compute the value of the Hamiltonian. + If the input object has no units (i.e. is an `~numpy.ndarray`), it + is assumed to be in the same unit system as the potential class. + + Returns + ------- + # TODO: see TODO about units about + hess : `~astropy.units.Quantity` + The Hessian matrix of second derivatives of the potential. If the input + position has shape ``w.shape``, the output energy will have shape + ``(w.shape[0],w.shape[0]) + w.shape[1:]``. That is, an ``n_dim`` by + ``n_dim`` array (matrix) for each position, where the dimensionality of + phase-space is ``n_dim``. + """ + raise NotImplementedError() + + # def jacobi_energy(self, w, t=0.): + # """ + # TODO: docstring + # TODO: if not rotating frame, raise error + # """ + + # if not isinstance(self.frame, gp.ConstantRotatingFrame): + # raise TypeError("The frame must be a ConstantRotatingFrame " + # "to compute the Jacobi energy.") + + # w = self._remove_units_prepare_shape(w) + # orig_shape, w = self._get_c_valid_arr(w) + # t = self._validate_prepare_time(t, len(w)) + + # E = self._energy(w, t=t).T.reshape(orig_shape[1:]) + # L = np.cross(w[:, :3], w[:, 3:]) + + # Omega = self.frame.parameters['Omega'] + # C = E - np.einsum('i, ...i->...', Omega, L).reshape(E.shape) + # return C * self.units['energy'] / self.units['mass'] + + # ======================================================================== + # Python special methods + # + def __call__(self, w): + return self.energy(w) + + def __repr__(self): + return ( + f"<{self.__class__.__name__}: " + f"potential={self.potential!r}, frame={self.frame!r}>" + ) + + def __str__(self): + return self.__class__.__name__ + + def __eq__(self, other): + return (self.potential == other.potential) and (self.frame == other.frame) + + def __ne__(self, other): + return not self.__eq__(other) + + @deprecated_renamed_argument("store_all", "save_all", since="1.10") + def integrate_orbit(self, + w0, + Integrator=None, + Integrator_kwargs=dict(), + cython_if_possible=True, + save_all=True, + **time_spec + ): + """ + Integrate an orbit in the current potential using the integrator class + provided. Uses same time specification as `Integrator.run()` -- see + the documentation for `gala.integrate` for more information. + + Parameters + ---------- + w0 : `~gala.dynamics.PhaseSpacePosition`, array_like + Initial conditions. + Integrator : `~gala.integrate.Integrator`, str (optional) + Integrator class to use, or a string name like 'leapfrog', 'dopri853', + 'ruth4'. By default, uses `~gala.integrate.LeapfrogIntegrator` if the + frame is static and `~gala.integrate.DOPRI853Integrator` else. + Integrator_kwargs : dict (optional) + Any extra keyword arguments to pass to the integrator class + when initializing. For example, you can pass in the + ``atol`` and ``rtol`` keyword arguments to set the absolute and + relative tolerances for the DOPRI853 integrator. + cython_if_possible : bool (optional) + If there is a Cython version of the integrator implemented, + and the potential object has a C instance, using Cython + will be *much* faster. + save_all : bool (optional) + Controls whether to store the phase-space position at all intermediate + timesteps. Set to False to store only the final values (i.e. the + phase-space position(s) at the final timestep). Default is True. + **time_spec + Specification of how long to integrate. Most commonly, this is a + timestep ``dt`` and number of steps ``n_steps``, or a timestep + ``dt``, initial time ``t1``, and final time ``t2``. You may also + pass in a time array with ``t``. See documentation for + `~gala.integrate.parse_time_specification` for more information. + + Returns + ------- + orbit : `~gala.dynamics.Orbit` + + """ + from gala.dynamics import PhaseSpacePosition, Orbit + from gala.integrate import get_integrator + + if Integrator is None and isinstance(self.frame, StaticFrame): + Integrator = LeapfrogIntegrator + elif Integrator is None: + Integrator = DOPRI853Integrator + + # Validates and retrieves the integrator class from string name if needed + Integrator = get_integrator(Integrator) + + symplectic_integrators = [LeapfrogIntegrator, Ruth4Integrator] + if (Integrator in symplectic_integrators and + not isinstance(self.frame, StaticFrame)): + warnings.warn( + "Using a symplectic integrator with a non-static frame can " + "lead to wildly incorrect orbits. It is recommended that you " + "use DOPRI853Integrator instead.", RuntimeWarning) + + if isinstance(w0, PhaseSpacePosition): + ndim = w0.ndim + arr_w0 = w0.w(self.units) + arr_w0 = self._remove_units_prepare_shape(arr_w0) + + msg = ( + f"Invalid initial conditions shape {w0.shape}. Expected shape " + f"(ndim={self.ndim}, ...) for both pos and vel, but got ndim={ndim}." + ) + + else: + arr_w0 = np.asarray(w0) + ndim = arr_w0.shape[0] // 2 + + msg = ( + f"Invalid initial conditions shape {arr_w0.shape}. Expected shape " + f"({self.ndim}, ...) but got shape {arr_w0.shape}." + ) + + if 2 * ndim != self.ndim: + raise ValueError(msg) + + # transpose=False because the gradient functions expect (ndim, N) arrays + orig_shape, arr_w0 = self._get_c_valid_arr(arr_w0, transpose=False) + + if self.c_enabled and cython_if_possible: + # array of times + from ...integrate.timespec import parse_time_specification + + t = np.ascontiguousarray(parse_time_specification(self.units, **time_spec)) + + # TODO: these replacements should be defined in gala.integrate... + # TODO: default kwargs should also be defined in gala.integrate, not here + if Integrator == LeapfrogIntegrator: + from ...integrate.cyintegrators import leapfrog_integrate_hamiltonian + + t, w = leapfrog_integrate_hamiltonian( + self, arr_w0, t, save_all=save_all + ) + + elif Integrator == Ruth4Integrator: + from ...integrate.cyintegrators import ruth4_integrate_hamiltonian + + t, w = ruth4_integrate_hamiltonian(self, arr_w0, t, save_all=save_all) + + elif Integrator == DOPRI853Integrator: + from ...integrate.cyintegrators import dop853_integrate_hamiltonian + + t, w = dop853_integrate_hamiltonian( + self, + arr_w0, + t, + Integrator_kwargs.get("atol", 1e-10), + Integrator_kwargs.get("rtol", 1e-10), + Integrator_kwargs.get("nmax", 0), + save_all=save_all, + err_if_fail=int(Integrator_kwargs.get("err_if_fail", 1)), + log_output=int(Integrator_kwargs.get("log_output", 0)), + nbatch=Integrator_kwargs.get("nbatch", 100), + ) + else: + raise ValueError( + f"Cython integration not supported for '{Integrator!r}'" + ) + + if w.shape[-1] == 1: + w = w[..., 0] + + else: + + def F(t, w): + w = np.ascontiguousarray(w) + return self._gradient(w, t=np.array([t])) + + integrator = Integrator(F, func_units=self.units, **Integrator_kwargs) + orbit = integrator(arr_w0, **time_spec) + orbit.potential = self.potential + orbit.frame = self.frame + return orbit + + if not save_all: + w = w[:, None] + + try: + tunit = self.units["time"] + except (TypeError, AttributeError): + tunit = u.dimensionless_unscaled + + t = u.Quantity(t, tunit, copy=False) + + return Orbit.from_w(w=w, units=self.units, t=t, hamiltonian=self, copy=False) diff --git a/gala/source/src/gala/potential/hamiltonian/src/chamiltonian.cpp b/gala/source/src/gala/potential/hamiltonian/src/chamiltonian.cpp new file mode 100644 index 0000000000000000000000000000000000000000..629a7a895478a820681db1076574e59b9b69604e --- /dev/null +++ b/gala/source/src/gala/potential/hamiltonian/src/chamiltonian.cpp @@ -0,0 +1,69 @@ +#include +#include +#include "chamiltonian.h" +#include "potential/src/cpotential.h" +#include "frame/src/cframe.h" + +double hamiltonian_value(CPotential *p, CFrameType *fr, double t, double *qp) { + double v = 0; + int i; + + v = v + (fr->energy)(t, (fr->parameters), qp, p->n_dim, NULL); + + for (i=0; i < p->n_components; i++) { + // TODO: change potential 'value' -> 'energy' + v = v + (p->value)[i](t, (p->parameters)[i], qp, p->n_dim, (p->state)[i]); + } + + return v; +} + +void hamiltonian_gradient(CPotential *p, CFrameType *fr, double t, double *qp, double *dH) { + int i; + + for (i=0; i < 2*(p->n_dim); i++) { + dH[i] = 0.; + } + + // potential gradient has to be first + c_gradient(p, 1, t, qp, &(dH[p->n_dim])); + + (fr->gradient)(t, (fr->parameters), qp, p->n_dim, 1, dH, NULL); + + for (i=p->n_dim; i < 2*(p->n_dim); i++) { + dH[i] = -dH[i]; // pdot = -dH/dq + } +} + +void hamiltonian_gradient_T(CPotential *p, CFrameType *fr, size_t n, double t, double *qp_T, double *dH_T) { + // qp_T: shape (n_dim, n) + // dH_T: shape (n_dim, n) + + int ndim = p->n_dim; + + // Initialize dH_T to zeros + for (int i = 0; i < 2 * ndim * n; i++) { + dH_T[i] = 0.0; + } + + // Call gradient functions directly with transposed data + c_gradient(p, n, t, qp_T, dH_T + ndim * n); // Write to momentum part + (fr->gradient)(t, (fr->parameters), qp_T, ndim, n, dH_T, NULL); // Write to position part + + // Negate the momentum derivatives + for (int i = 0; i < n * ndim; i++) { + dH_T[ndim * n + i] *= -1; // pdot = -dH/dq + } +} + +void hamiltonian_hessian(CPotential *p, CFrameType *fr, double t, double *qp, double *d2H) { + int i; + + for (i=0; i < p->n_components; i++) { + (p->hessian)[i](t, (p->parameters)[i], qp, p->n_dim, d2H, (p->state)[i]); + } + + // TODO: not implemented!! + // TODO: can I just add in the terms from the frame here? + // (fr->hessian)(t, (fr->parameters), qp, p->n_dim, d2H); +} diff --git a/gala/source/src/gala/potential/hamiltonian/src/chamiltonian.h b/gala/source/src/gala/potential/hamiltonian/src/chamiltonian.h new file mode 100644 index 0000000000000000000000000000000000000000..88e63e75c01e354fb34365e5ac141862637f75e7 --- /dev/null +++ b/gala/source/src/gala/potential/hamiltonian/src/chamiltonian.h @@ -0,0 +1,7 @@ +#include "potential/src/cpotential.h" +#include "frame/src/cframe.h" + +extern double hamiltonian_value(CPotential *p, CFrameType *fr, double t, double *q); +extern void hamiltonian_gradient(CPotential *p, CFrameType *fr, double t, double *q, double *grad); +extern void hamiltonian_gradient_T(CPotential *p, CFrameType *fr, size_t n, double t, double *q, double *grad); +extern void hamiltonian_hessian(CPotential *p, CFrameType *fr, double t, double *q, double *hess); diff --git a/gala/source/src/gala/potential/potential/__init__.py b/gala/source/src/gala/potential/potential/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8678aea180e1103c144144a5605f24b33a9c3e8e --- /dev/null +++ b/gala/source/src/gala/potential/potential/__init__.py @@ -0,0 +1,25 @@ +from .builtin import * +from .ccompositepotential import * +from .core import * +from .cpotential import * +from .io import * +from .symmetry import * +from .util import * + + +def __getattr__(name): + # Needed for MultipolePotential save/load + from . import builtin + + if name in globals(): + return globals()[name] + + if name.startswith("MultipolePotentialLmax"): + return getattr(builtin.core, name) + + if name.startswith("SCF"): + from .. import scf + + return getattr(scf, name) + + raise AttributeError("huh") diff --git a/gala/source/src/gala/potential/potential/builtin/__init__.py b/gala/source/src/gala/potential/potential/builtin/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9e9d246bbe2f995e098d2e96e94ca77deecbc5d6 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/__init__.py @@ -0,0 +1,3 @@ +from .core import * +from .pybuiltin import * +from .special import * diff --git a/gala/source/src/gala/potential/potential/builtin/builtin_potentials.cpp b/gala/source/src/gala/potential/potential/builtin/builtin_potentials.cpp new file mode 100644 index 0000000000000000000000000000000000000000..6c3d71abeb38c13c5fd2d2f3903c124578293aa6 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/builtin_potentials.cpp @@ -0,0 +1,2306 @@ +#include +#include +#include +#include "extra_compile_macros.h" +#include "src/vectorization.h" +#include "potential_helpers.h" + +#if USE_GSL == 1 +#include +#include +#endif + +double nan_density(double t, double *pars, double *q, int n_dim, void *state) { return NAN; } +double nan_value(double t, double *pars, double *q, int n_dim, void *state) { return NAN; } +void nan_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) {} +void nan_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) {} + +double null_density(double t, double *pars, double *q, int n_dim, void *state) { return 0; } +double null_value(double t, double *pars, double *q, int n_dim, void *state) { return 0; } +void null_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state){} +void null_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) {} + +/* Note: many Hessians generated with sympy in + gala-notebooks/Make-all-Hessians.ipynb +*/ + +/* --------------------------------------------------------------------------- + Henon-Heiles potential +*/ +double henon_heiles_value(double t, double *pars, double *q, int n_dim, void *state) { + /* no parameters... */ + return 0.5 * (q[0]*q[0] + q[1]*q[1] + 2*q[0]*q[0]*q[1] - 2/3.*q[1]*q[1]*q[1]); +} + +void henon_heiles_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* no parameters... */ + grad[0] = grad[0] + q[0] + 2*q[0]*q[1]; + grad[1] = grad[1] + q[1] + q[0]*q[0] - q[1]*q[1]; +} + +void henon_heiles_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + /* no parameters... */ + double x = q[0]; + double y = q[1]; + + double tmp_0 = 2.0 * y; + double tmp_1 = 2.0 * x; + + hess[0] = hess[0] + tmp_0 + 1.0; + hess[1] = hess[1] + tmp_1; + hess[2] = hess[2] + tmp_1; + hess[3] = hess[3] + 1.0 - tmp_0; +} + +/* --------------------------------------------------------------------------- + Kepler potential +*/ +double kepler_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + */ + return -pars[0] * pars[1] / norm3(q); +} + +void kepler_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + */ + const double fac = pars[0] * pars[1] / pow(norm3(q), 3); + + grad[0] = grad[0] + fac*q[0]; + grad[1] = grad[1] + fac*q[1]; + grad[2] = grad[2] + fac*q[2]; +} + +double kepler_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + */ + if (norm3_sq(q) == 0.) { + return INFINITY; + } else { + return 0.; + } +} + +void kepler_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + */ + double G = pars[0]; + double m = pars[1]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(x, 2); + double tmp_1 = pow(y, 2); + double tmp_2 = pow(z, 2); + double tmp_3 = tmp_0 + tmp_1 + tmp_2; + double tmp_4 = G*m; + double tmp_5 = tmp_4/pow(tmp_3, 3.0/2.0); + double tmp_6 = 3*tmp_4/pow(tmp_3, 5.0/2.0); + double tmp_7 = tmp_6*x; + double tmp_8 = -tmp_7*y; + double tmp_9 = -tmp_7*z; + double tmp_10 = -tmp_6*y*z; + + hess[0] = hess[0] + -tmp_0*tmp_6 + tmp_5; + hess[1] = hess[1] + tmp_8; + hess[2] = hess[2] + tmp_9; + hess[3] = hess[3] + tmp_8; + hess[4] = hess[4] + -tmp_1*tmp_6 + tmp_5; + hess[5] = hess[5] + tmp_10; + hess[6] = hess[6] + tmp_9; + hess[7] = hess[7] + tmp_10; + hess[8] = hess[8] + -tmp_2*tmp_6 + tmp_5; +} + +/* --------------------------------------------------------------------------- + Isochrone potential +*/ +double isochrone_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - b (core scale) + */ + const double r2 = norm3_sq(q); + return -pars[0] * pars[1] / (sqrt(r2 + pars[2]*pars[2]) + pars[2]); +} + +void isochrone_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - b (core scale) + */ + const double sqrt_r2_b2 = sqrt(norm3_sq(q) + pars[2]*pars[2]); + const double denom = sqrt_r2_b2 * (sqrt_r2_b2 + pars[2])*(sqrt_r2_b2 + pars[2]); + const double fac = pars[0] * pars[1] / denom; + + grad[0] = grad[0] + fac*q[0]; + grad[1] = grad[1] + fac*q[1]; + grad[2] = grad[2] + fac*q[2]; +} + +double isochrone_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - b (core scale) + */ + const double b = pars[2]; + const double r2 = norm3_sq(q); + const double a = sqrt(b*b + r2); + + return pars[1] * (3*(b+a)*a*a - r2*(b+3*a)) / (4*M_PI*pow(b+a,3)*a*a*a); +} + +void isochrone_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - b (length scale) + */ + double G = pars[0]; + double m = pars[1]; + double b = pars[2]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(x, 2); + double tmp_1 = pow(y, 2); + double tmp_2 = pow(z, 2); + double tmp_3 = pow(b, 2) + tmp_0 + tmp_1 + tmp_2; + double tmp_4 = sqrt(tmp_3); + double tmp_5 = b + tmp_4; + double tmp_6 = G*m; + double tmp_7 = tmp_6/pow(tmp_5, 2); + double tmp_8 = tmp_7/tmp_4; + double tmp_9 = 2*tmp_6/(tmp_3*pow(tmp_5, 3)); + double tmp_10 = tmp_7/pow(tmp_3, 3.0/2.0); + double tmp_11 = tmp_9*x; + double tmp_12 = tmp_10*x; + double tmp_13 = -tmp_11*y - tmp_12*y; + double tmp_14 = -tmp_11*z - tmp_12*z; + double tmp_15 = y*z; + double tmp_16 = -tmp_10*tmp_15 - tmp_15*tmp_9; + + hess[0] = hess[0] + -tmp_0*tmp_10 - tmp_0*tmp_9 + tmp_8; + hess[1] = hess[1] + tmp_13; + hess[2] = hess[2] + tmp_14; + hess[3] = hess[3] + tmp_13; + hess[4] = hess[4] + -tmp_1*tmp_10 - tmp_1*tmp_9 + tmp_8; + hess[5] = hess[5] + tmp_16; + hess[6] = hess[6] + tmp_14; + hess[7] = hess[7] + tmp_16; + hess[8] = hess[8] + -tmp_10*tmp_2 - tmp_2*tmp_9 + tmp_8; + +} + +/* --------------------------------------------------------------------------- + Hernquist sphere +*/ +double hernquist_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - c (length scale) + */ + const double r = norm3(q); + return -pars[0] * pars[1] / (r + pars[2]); +} + +void hernquist_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - c (length scale) + */ + const double r = norm3(q); + const double fac = pars[0] * pars[1] / ((r + pars[2]) * (r + pars[2]) * r); + + grad[0] = grad[0] + fac*q[0]; + grad[1] = grad[1] + fac*q[1]; + grad[2] = grad[2] + fac*q[2]; +} + +double hernquist_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - c (length scale) + */ + const double r = norm3(q); + const double rho0 = pars[1]/(2*M_PI*pars[2]*pars[2]*pars[2]); + return rho0 / ((r/pars[2]) * pow(1+r/pars[2],3)); +} + +void hernquist_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - c (length scale) + */ + double G = pars[0]; + double m = pars[1]; + double c = pars[2]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(x, 2); + double tmp_1 = pow(y, 2); + double tmp_2 = pow(z, 2); + double tmp_3 = tmp_0 + tmp_1 + tmp_2; + double tmp_4 = sqrt(tmp_3); + double tmp_5 = c + tmp_4; + double tmp_6 = G*m; + double tmp_7 = tmp_6/pow(tmp_5, 2); + double tmp_8 = tmp_7/tmp_4; + double tmp_9 = 2*tmp_6/(tmp_3*pow(tmp_5, 3)); + double tmp_10 = tmp_7/pow(tmp_3, 3.0/2.0); + double tmp_11 = tmp_9*x; + double tmp_12 = tmp_10*x; + double tmp_13 = -tmp_11*y - tmp_12*y; + double tmp_14 = -tmp_11*z - tmp_12*z; + double tmp_15 = y*z; + double tmp_16 = -tmp_10*tmp_15 - tmp_15*tmp_9; + + hess[0] = hess[0] + -tmp_0*tmp_10 - tmp_0*tmp_9 + tmp_8; + hess[1] = hess[1] + tmp_13; + hess[2] = hess[2] + tmp_14; + hess[3] = hess[3] + tmp_13; + hess[4] = hess[4] + -tmp_1*tmp_10 - tmp_1*tmp_9 + tmp_8; + hess[5] = hess[5] + tmp_16; + hess[6] = hess[6] + tmp_14; + hess[7] = hess[7] + tmp_16; + hess[8] = hess[8] + -tmp_10*tmp_2 - tmp_2*tmp_9 + tmp_8; +} + + +/* --------------------------------------------------------------------------- + Plummer sphere +*/ +double plummer_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - b (length scale) + */ + const double r2 = norm3_sq(q); + return -pars[0]*pars[1] / sqrt(r2 + pars[2]*pars[2]); +} + +void plummer_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - b (length scale) + */ + const double R2b = norm3_sq(q) + pars[2]*pars[2]; + const double fac = pars[0] * pars[1] / sqrt(R2b) / R2b; + + grad[0] = grad[0] + fac*q[0]; + grad[1] = grad[1] + fac*q[1]; + grad[2] = grad[2] + fac*q[2]; +} + +double plummer_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - b (length scale) + */ + const double r2 = norm3_sq(q); + return 3*pars[1] / (4*M_PI*pars[2]*pars[2]*pars[2]) * pow(1 + r2/(pars[2]*pars[2]), -2.5); +} + +void plummer_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - b (length scale) + */ + double G = pars[0]; + double m = pars[1]; + double b = pars[2]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(x, 2); + double tmp_1 = pow(y, 2); + double tmp_2 = pow(z, 2); + double tmp_3 = pow(b, 2) + tmp_0 + tmp_1 + tmp_2; + double tmp_4 = G*m; + double tmp_5 = tmp_4/pow(tmp_3, 3.0/2.0); + double tmp_6 = 3*tmp_4/pow(tmp_3, 5.0/2.0); + double tmp_7 = tmp_6*x; + double tmp_8 = -tmp_7*y; + double tmp_9 = -tmp_7*z; + double tmp_10 = -tmp_6*y*z; + + hess[0] = hess[0] + -tmp_0*tmp_6 + tmp_5; + hess[1] = hess[1] + tmp_8; + hess[2] = hess[2] + tmp_9; + hess[3] = hess[3] + tmp_8; + hess[4] = hess[4] + -tmp_1*tmp_6 + tmp_5; + hess[5] = hess[5] + tmp_10; + hess[6] = hess[6] + tmp_9; + hess[7] = hess[7] + tmp_10; + hess[8] = hess[8] + -tmp_2*tmp_6 + tmp_5; +} + +/* --------------------------------------------------------------------------- + Jaffe sphere +*/ +double jaffe_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - c (length scale) + */ + const double r = norm3(q); + return -pars[0] * pars[1] / pars[2] * log(1 + pars[2] / r); +} + +void jaffe_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state){ + /* pars: + - G (Gravitational constant) + - m (mass scale) + - c (length scale) + */ + const double r = norm3(q); + const double fac = pars[0] * pars[1] / pars[2] * (pars[2] / (r * (pars[2] + r))) / r; + + grad[0] = grad[0] + fac * q[0]; + grad[1] = grad[1] + fac * q[1]; + grad[2] = grad[2] + fac * q[2]; +} + +double jaffe_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - c (length scale) + */ + const double r = norm3(q); + const double rho0 = pars[1] / (4*M_PI*pars[2]*pars[2]*pars[2]); + return rho0 / (pow(r/pars[2],2) * pow(1+r/pars[2],2)); +} + +void jaffe_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - c (length scale) + */ + double G = pars[0]; + double m = pars[1]; + double c = pars[2]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(x, 2); + double tmp_1 = pow(y, 2); + double tmp_2 = pow(z, 2); + double tmp_3 = tmp_0 + tmp_1 + tmp_2; + double tmp_4 = 1.0/tmp_3; + double tmp_5 = sqrt(tmp_3); + double tmp_6 = c + tmp_5; + double tmp_7 = pow(tmp_6, -2); + double tmp_8 = tmp_7*x; + double tmp_9 = 1.0/tmp_5; + double tmp_10 = 1.0/tmp_6; + double tmp_11 = tmp_10*tmp_9; + double tmp_12 = G*m/c; + double tmp_13 = tmp_12*(tmp_11*x - tmp_8); + double tmp_14 = tmp_13*tmp_4; + double tmp_15 = pow(tmp_3, -3.0/2.0); + double tmp_16 = tmp_13*tmp_15*tmp_6; + double tmp_17 = tmp_10*tmp_15; + double tmp_18 = tmp_4*tmp_7; + double tmp_19 = 2*tmp_9/pow(tmp_6, 3); + double tmp_20 = tmp_11 - tmp_7; + double tmp_21 = tmp_12*tmp_6; + double tmp_22 = tmp_21*tmp_9; + double tmp_23 = tmp_19*x; + double tmp_24 = tmp_4*tmp_8; + double tmp_25 = tmp_17*x; + double tmp_26 = tmp_14*y - tmp_16*y + tmp_22*(tmp_23*y - tmp_24*y - tmp_25*y); + double tmp_27 = tmp_4*z; + double tmp_28 = tmp_13*tmp_27 - tmp_16*z + tmp_22*(tmp_23*z - tmp_24*z - tmp_25*z); + double tmp_29 = tmp_7*y; + double tmp_30 = tmp_11*y - tmp_29; + double tmp_31 = tmp_12*tmp_30; + double tmp_32 = tmp_15*tmp_21; + double tmp_33 = tmp_30*tmp_32; + double tmp_34 = y*z; + double tmp_35 = tmp_22*(-tmp_17*tmp_34 + tmp_19*tmp_34 - tmp_27*tmp_29) + tmp_27*tmp_31 - tmp_33*z; + double tmp_36 = tmp_11*z - tmp_7*z; + + hess[0] = hess[0] + tmp_14*x - tmp_16*x + tmp_22*(-tmp_0*tmp_17 - tmp_0*tmp_18 + tmp_0*tmp_19 + tmp_20); + hess[1] = hess[1] + tmp_26; + hess[2] = hess[2] + tmp_28; + hess[3] = hess[3] + tmp_26; + hess[4] = hess[4] + tmp_22*(-tmp_1*tmp_17 - tmp_1*tmp_18 + tmp_1*tmp_19 + tmp_20) + tmp_31*tmp_4*y - tmp_33*y; + hess[5] = hess[5] + tmp_35; + hess[6] = hess[6] + tmp_28; + hess[7] = hess[7] + tmp_35; + hess[8] = hess[8] + tmp_12*tmp_27*tmp_36 + tmp_22*(-tmp_17*tmp_2 - tmp_18*tmp_2 + tmp_19*tmp_2 + tmp_20) - tmp_32*tmp_36*z; +} + +/* --------------------------------------------------------------------------- + Power-law potential with exponential cutoff +*/ +#if USE_GSL == 1 + +double safe_gamma_inc(double a, double x) { + int N, m, n; + double A = 1.; + double B = 0.; + double tmp; + + if (a > 0) { + return gsl_sf_gamma_inc_P(a, x) * gsl_sf_gamma(a);; + } else { + N = (int) ceil(-a); + + for (n=0; n < N; n++) { + A = A * (a + n); + + tmp = 1.; + for (m=N-1; m > n; m--) { + tmp = tmp * (a + m); + } + B = B + pow(x, a+n) * exp(-x) * tmp; + } + return (B + gsl_sf_gamma_inc_P(a + N, x) * gsl_sf_gamma(a + N)) / A; + } +} + +double powerlawcutoff_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + 0 - G (Gravitational constant) + 1 - m (total mass) + 2 - a (power-law index) + 3 - c (cutoff radius) + */ + const double G = pars[0]; + const double m = pars[1]; + const double alpha = pars[2]; + const double r_c = pars[3]; + const double r = norm3(q); + + if (r == 0.) { + return -INFINITY; + } else { + const double tmp_0 = alpha / 2.0; + const double tmp_1 = -tmp_0; + const double tmp_2 = tmp_1 + 1.5; + const double tmp_3 = r * r; + const double tmp_4 = tmp_3 / pow(r_c, 2); + const double tmp_5 = G*m; + const double tmp_6 = tmp_5*safe_gamma_inc(tmp_2, tmp_4)/(sqrt(tmp_3)*tgamma(tmp_1 + 2.5)); + + // Original potential + double phi_r = tmp_0*tmp_6 - 3.0/2.0*tmp_6 + tmp_5*safe_gamma_inc(tmp_1 + 1, tmp_4)/(r_c*tgamma(tmp_2)); + + // Subtract asymptotic value to enforce Ί(∞) = 0 + double phi_infinity = 0.0; + if (tmp_2 > 0) { // alpha < 3 + phi_infinity = tmp_5 * tgamma(tmp_1 + 1) / (r_c * tgamma(tmp_2)); + } + + return phi_r - phi_infinity; + } +} + +double powerlawcutoff_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + 0 - G (Gravitational constant) + 1 - m (total mass) + 2 - a (power-law index) + 3 - c (cutoff radius) + */ + const double r = norm3(q); + const double A = pars[1] / (2*M_PI) * pow(pars[3], pars[2] - 3) / gsl_sf_gamma(0.5 * (3 - pars[2])); + return A * pow(r, -pars[2]) * exp(-r*r / (pars[3]*pars[3])); +} + +void powerlawcutoff_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + 0 - G (Gravitational constant) + 1 - m (total mass) + 2 - a (power-law index) + 3 - c (cutoff radius) + */ + const double r = norm3(q); + const double dPhi_dr = (pars[0] * pars[1] / (r*r * r) * + gsl_sf_gamma_inc_P(0.5 * (3-pars[2]), r*r/(pars[3]*pars[3]))); // / gsl_sf_gamma(0.5 * (3-pars[2]))); + + grad[0] = grad[0] + dPhi_dr * q[0]; + grad[1] = grad[1] + dPhi_dr * q[1]; + grad[2] = grad[2] + dPhi_dr * q[2]; +} + +void powerlawcutoff_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - alpha (exponent) + - r_c (cutoff radius) + */ + double G = pars[0]; + double m = pars[1]; + double alpha = pars[2]; + double r_c = pars[3]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(x, 2); + double tmp_1 = pow(y, 2); + double tmp_2 = pow(z, 2); + double tmp_3 = tmp_0 + tmp_1 + tmp_2; + double tmp_4 = (1.0/2.0)*alpha; + double tmp_5 = -tmp_4; + double tmp_6 = tmp_5 + 1.5; + double tmp_7 = pow(r_c, -2); + double tmp_8 = tmp_3*tmp_7; + double tmp_9 = G*m; + double tmp_10 = tmp_9/tgamma(tmp_5 + 2.5); + double tmp_11 = tmp_10*safe_gamma_inc(tmp_6, tmp_8); + double tmp_12 = tmp_11/pow(tmp_3, 5.0/2.0); + double tmp_13 = (9.0/2.0)*tmp_12; + double tmp_14 = exp(-tmp_8); + double tmp_15 = tmp_0*tmp_14; + double tmp_16 = pow(tmp_8, -tmp_4)*tmp_9/tgamma(tmp_6); + double tmp_17 = 4*tmp_16/pow(r_c, 5); + double tmp_18 = alpha*tmp_0; + double tmp_19 = (3.0/2.0)*tmp_12; + double tmp_20 = 6*tmp_15; + double tmp_21 = pow(r_c, -4); + double tmp_22 = tmp_5 + 0.5; + double tmp_23 = pow(tmp_8, tmp_22); + double tmp_24 = tmp_10*tmp_23/sqrt(tmp_3); + double tmp_25 = tmp_21*tmp_24; + double tmp_26 = pow(tmp_3, -3.0/2.0); + double tmp_27 = tmp_10*tmp_23*tmp_26*tmp_7; + double tmp_28 = tmp_20*tmp_27; + double tmp_29 = 2*tmp_14; + double tmp_30 = tmp_18*tmp_29; + double tmp_31 = tmp_16*tmp_29/pow(r_c, 3); + double tmp_32 = tmp_31/tmp_3; + double tmp_33 = tmp_27*tmp_30; + double tmp_34 = tmp_11*tmp_26; + double tmp_35 = tmp_14*tmp_24; + double tmp_36 = tmp_35*tmp_7; + double tmp_37 = alpha*tmp_36 + tmp_31 - tmp_34*tmp_4 + (3.0/2.0)*tmp_34 - 3*tmp_36; + double tmp_38 = tmp_13*x; + double tmp_39 = alpha*tmp_19; + double tmp_40 = x*y; + double tmp_41 = tmp_14*tmp_17; + double tmp_42 = alpha*tmp_40; + double tmp_43 = tmp_21*tmp_35; + double tmp_44 = 6*tmp_40; + double tmp_45 = tmp_14*tmp_27; + double tmp_46 = tmp_44*tmp_45; + double tmp_47 = tmp_29*tmp_42; + double tmp_48 = tmp_27*tmp_47; + double tmp_49 = -tmp_22*tmp_46 + tmp_22*tmp_48 - tmp_25*tmp_47 - tmp_32*tmp_42 - tmp_38*y + tmp_39*tmp_40 - tmp_40*tmp_41 + tmp_43*tmp_44 + tmp_46 - tmp_48; + double tmp_50 = x*z; + double tmp_51 = alpha*tmp_50; + double tmp_52 = 6*tmp_50; + double tmp_53 = tmp_45*tmp_52; + double tmp_54 = tmp_29*tmp_51; + double tmp_55 = tmp_27*tmp_54; + double tmp_56 = -tmp_22*tmp_53 + tmp_22*tmp_55 - tmp_25*tmp_54 - tmp_32*tmp_51 - tmp_38*z + tmp_39*tmp_50 - tmp_41*tmp_50 + tmp_43*tmp_52 + tmp_53 - tmp_55; + double tmp_57 = 6*tmp_1; + double tmp_58 = tmp_45*tmp_57; + double tmp_59 = alpha*tmp_1; + double tmp_60 = tmp_29*tmp_59; + double tmp_61 = tmp_27*tmp_60; + double tmp_62 = y*z; + double tmp_63 = alpha*tmp_62; + double tmp_64 = 6*tmp_62; + double tmp_65 = tmp_45*tmp_64; + double tmp_66 = tmp_29*tmp_63; + double tmp_67 = tmp_27*tmp_66; + double tmp_68 = -tmp_13*tmp_62 - tmp_22*tmp_65 + tmp_22*tmp_67 - tmp_25*tmp_66 - tmp_32*tmp_63 + tmp_39*tmp_62 - tmp_41*tmp_62 + tmp_43*tmp_64 + tmp_65 - tmp_67; + double tmp_69 = 6*tmp_2; + double tmp_70 = tmp_45*tmp_69; + double tmp_71 = alpha*tmp_2; + double tmp_72 = tmp_29*tmp_71; + double tmp_73 = tmp_27*tmp_72; + + hess[0] = hess[0] + -tmp_0*tmp_13 - tmp_15*tmp_17 + tmp_18*tmp_19 - tmp_18*tmp_32 + tmp_20*tmp_25 - tmp_22*tmp_28 + tmp_22*tmp_33 - tmp_25*tmp_30 + tmp_28 - tmp_33 + tmp_37; + hess[1] = hess[1] + tmp_49; + hess[2] = hess[2] + tmp_56; + hess[3] = hess[3] + tmp_49; + hess[4] = hess[4] + -tmp_1*tmp_13 + tmp_1*tmp_39 - tmp_1*tmp_41 - tmp_22*tmp_58 + tmp_22*tmp_61 - tmp_25*tmp_60 - tmp_32*tmp_59 + tmp_37 + tmp_43*tmp_57 + tmp_58 - tmp_61; + hess[5] = hess[5] + tmp_68; + hess[6] = hess[6] + tmp_56; + hess[7] = hess[7] + tmp_68; + hess[8] = hess[8] + -tmp_13*tmp_2 + tmp_2*tmp_39 - tmp_2*tmp_41 - tmp_22*tmp_70 + tmp_22*tmp_73 - tmp_25*tmp_72 - tmp_32*tmp_71 + tmp_37 + tmp_43*tmp_69 + tmp_70 - tmp_73; +} + +#endif + +/* --------------------------------------------------------------------------- + Stone-Ostriker potential from Stone & Ostriker (2015) +*/ +double stone_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - M (total mass) + - r_c (core radius) + - r_h (halo radius) + */ + const double r = norm3(q); + const double u_c = r / pars[2]; + const double u_h = r / pars[3]; + + const double fac = 2*pars[0]*pars[1] / M_PI / (pars[3] - pars[2]); + + if (r == 0) { + return -fac * 0.5 * log(pars[3]*pars[3] / (pars[2] * pars[2])); + } else { + return -fac * ( + atan(u_h)/u_h - atan(u_c)/u_c + + 0.5*log((r*r + pars[3]*pars[3])/(r*r + pars[2]*pars[2])) + ); + } + +} + +void stone_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - M (total mass) + - r_c (core radius) + - r_h (halo radius) + */ + + const double r = norm3(q); + const double u_c = r / pars[2]; + const double u_h = r / pars[3]; + + const double fac = 2*pars[0]*pars[1] / (M_PI*r*r * r) / (pars[2] - pars[3]); // order flipped from value + const double dphi_dr = fac * (pars[2]*atan(u_c) - pars[3]*atan(u_h)); + + grad[0] = grad[0] + dphi_dr*q[0]; + grad[1] = grad[1] + dphi_dr*q[1]; + grad[2] = grad[2] + dphi_dr*q[2]; +} + +double stone_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - M (total mass) + - r_c (core radius) + - r_h (halo radius) + */ + const double r = norm3(q); + const double rho = pars[1] * (pars[2] + pars[3]) / (2*M_PI*M_PI*pars[2]*pars[2]*pars[3]*pars[3]); + const double u_c = r / pars[2]; + const double u_t = r / pars[3]; + + return rho / ((1 + u_c*u_c)*(1 + u_t*u_t)); +} + +void stone_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - r_c (core radius) + - r_h (halo radius) + */ + double G = pars[0]; + double m = pars[1]; + double r_c = pars[2]; + double r_h = pars[3]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(r_h, 2); + double tmp_1 = 1.0/tmp_0; + double tmp_2 = pow(x, 2); + double tmp_3 = pow(y, 2); + double tmp_4 = pow(z, 2); + double tmp_5 = tmp_2 + tmp_3 + tmp_4; + double tmp_6 = tmp_1*tmp_5 + 1; + double tmp_7 = 1.0/tmp_6; + double tmp_8 = 3/pow(tmp_5, 2); + double tmp_9 = tmp_2*tmp_8; + double tmp_10 = pow(r_c, 2); + double tmp_11 = 1.0/tmp_10; + double tmp_12 = tmp_11*tmp_5 + 1; + double tmp_13 = 1.0/tmp_12; + double tmp_14 = tmp_10 + tmp_5; + double tmp_15 = pow(tmp_14, -2); + double tmp_16 = 8*tmp_15; + double tmp_17 = tmp_0 + tmp_5; + double tmp_18 = 8*tmp_17/pow(tmp_14, 3); + double tmp_19 = 2/tmp_14; + double tmp_20 = 2*tmp_15*tmp_17; + double tmp_21 = tmp_19 - tmp_20; + double tmp_22 = 1.0/tmp_17; + double tmp_23 = 0.5*tmp_14*tmp_22; + double tmp_24 = tmp_19*x - tmp_20*x; + double tmp_25 = 1.0*tmp_22; + double tmp_26 = tmp_24*tmp_25; + double tmp_27 = sqrt(tmp_5); + double tmp_28 = r_c*atan(tmp_27/r_c); + double tmp_29 = 3/pow(tmp_5, 5.0/2.0); + double tmp_30 = tmp_2*tmp_29; + double tmp_31 = 1.0/tmp_5; + double tmp_32 = 2*tmp_31; + double tmp_33 = tmp_2*tmp_32; + double tmp_34 = tmp_1/pow(tmp_6, 2); + double tmp_35 = tmp_11/pow(tmp_12, 2); + double tmp_36 = r_h*atan(tmp_27/r_h); + double tmp_37 = 1.0*tmp_14/pow(tmp_17, 2); + double tmp_38 = tmp_24*tmp_37; + double tmp_39 = pow(tmp_5, -3.0/2.0); + double tmp_40 = -tmp_13*tmp_31 + tmp_28*tmp_39 + tmp_31*tmp_7 - tmp_36*tmp_39; + double tmp_41 = 2*G*m/(-3.1415926535897931*r_c + 3.1415926535897931*r_h); + double tmp_42 = x*y; + double tmp_43 = tmp_42*tmp_8; + double tmp_44 = tmp_29*tmp_42; + double tmp_45 = tmp_32*tmp_42; + double tmp_46 = tmp_16*x; + double tmp_47 = -tmp_41*(tmp_13*tmp_43 + tmp_23*(tmp_18*tmp_42 - tmp_46*y) + tmp_26*y - tmp_28*tmp_44 - tmp_34*tmp_45 + tmp_35*tmp_45 + tmp_36*tmp_44 - tmp_38*y - tmp_43*tmp_7); + double tmp_48 = x*z; + double tmp_49 = tmp_48*tmp_8; + double tmp_50 = tmp_29*tmp_48; + double tmp_51 = tmp_32*tmp_48; + double tmp_52 = -tmp_41*(tmp_13*tmp_49 + tmp_23*(tmp_18*tmp_48 - tmp_46*z) + tmp_26*z - tmp_28*tmp_50 - tmp_34*tmp_51 + tmp_35*tmp_51 + tmp_36*tmp_50 - tmp_38*z - tmp_49*tmp_7); + double tmp_53 = tmp_3*tmp_8; + double tmp_54 = tmp_19*y - tmp_20*y; + double tmp_55 = tmp_25*tmp_54; + double tmp_56 = tmp_29*tmp_3; + double tmp_57 = tmp_3*tmp_32; + double tmp_58 = tmp_37*tmp_54; + double tmp_59 = y*z; + double tmp_60 = tmp_59*tmp_8; + double tmp_61 = tmp_29*tmp_59; + double tmp_62 = tmp_32*tmp_59; + double tmp_63 = -tmp_41*(tmp_13*tmp_60 + tmp_23*(-tmp_16*tmp_59 + tmp_18*tmp_59) - tmp_28*tmp_61 - tmp_34*tmp_62 + tmp_35*tmp_62 + tmp_36*tmp_61 + tmp_55*z - tmp_58*z - tmp_60*tmp_7); + double tmp_64 = tmp_4*tmp_8; + double tmp_65 = z*(tmp_19*z - tmp_20*z); + double tmp_66 = tmp_29*tmp_4; + double tmp_67 = tmp_32*tmp_4; + + hess[0] = hess[0] + -tmp_41*(tmp_13*tmp_9 + tmp_23*(-tmp_16*tmp_2 + tmp_18*tmp_2 + tmp_21) + tmp_26*x - tmp_28*tmp_30 + tmp_30*tmp_36 - tmp_33*tmp_34 + tmp_33*tmp_35 - tmp_38*x + tmp_40 - tmp_7*tmp_9); + hess[1] = hess[1] + tmp_47; + hess[2] = hess[2] + tmp_52; + hess[3] = hess[3] + tmp_47; + hess[4] = hess[4] + -tmp_41*(tmp_13*tmp_53 + tmp_23*(-tmp_16*tmp_3 + tmp_18*tmp_3 + tmp_21) - tmp_28*tmp_56 - tmp_34*tmp_57 + tmp_35*tmp_57 + tmp_36*tmp_56 + tmp_40 - tmp_53*tmp_7 + tmp_55*y - tmp_58*y); + hess[5] = hess[5] + tmp_63; + hess[6] = hess[6] + tmp_52; + hess[7] = hess[7] + tmp_63; + hess[8] = hess[8] + -tmp_41*(tmp_13*tmp_64 + tmp_23*(-tmp_16*tmp_4 + tmp_18*tmp_4 + tmp_21) + tmp_25*tmp_65 - tmp_28*tmp_66 - tmp_34*tmp_67 + tmp_35*tmp_67 + tmp_36*tmp_66 - tmp_37*tmp_65 + tmp_40 - tmp_64*tmp_7); +} + +/* --------------------------------------------------------------------------- + Spherical NFW +*/ +double sphericalnfw_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - r_s (scale radius) + */ + // v_h2 = pars[1]*pars[1] / (log(2.) - 0.5); + const double v_h2 = -pars[0] * pars[1] / pars[2]; + const double u = norm3(q) / pars[2]; + if (u == 0) { + return v_h2; + } else { + return v_h2 * log(1 + u) / u; + } +} + +void sphericalnfw_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - r_s (scale radius) + */ + // v_h2 = pars[1]*pars[1] / (log(2.) - 0.5); + const double v_h2 = pars[0] * pars[1] / pars[2]; + + const double u = norm3(q) / pars[2]; + const double fac = v_h2 / (u*u*u) / (pars[2]*pars[2]) * (log(1+u) - u/(1+u)); + + grad[0] = grad[0] + fac*q[0]; + grad[1] = grad[1] + fac*q[1]; + grad[2] = grad[2] + fac*q[2]; +} + +double sphericalnfw_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - r_s (scale radius) + */ + // double v_h2 = pars[1]*pars[1] / (log(2.) - 0.5); + const double v_h2 = pars[0] * pars[1] / pars[2]; + const double r = norm3(q); + + const double rho0 = v_h2 / (4*M_PI*pars[0]*pars[2]*pars[2]); + return rho0 / ((r/pars[2]) * pow(1+r/pars[2],2)); +} + +void sphericalnfw_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - r_s (scale radius) + */ + double G = pars[0]; + double m = pars[1]; + double r_s = pars[2]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(x, 2); + double tmp_1 = pow(y, 2); + double tmp_2 = pow(z, 2); + double tmp_3 = tmp_0 + tmp_1 + tmp_2; + double tmp_4 = pow(tmp_3, 7); + double tmp_5 = 3*tmp_0; + double tmp_6 = sqrt(tmp_3); + double tmp_7 = r_s + tmp_6; + double tmp_8 = pow(tmp_3, 13.0/2.0)*tmp_7; + double tmp_9 = pow(tmp_7, 2); + double tmp_10 = 1.0/r_s; + double tmp_11 = tmp_9*log(tmp_10*tmp_7); + double tmp_12 = tmp_11*pow(tmp_3, 6); + double tmp_13 = tmp_11*tmp_4 - pow(tmp_3, 15.0/2.0)*tmp_7; + double tmp_14 = G*m; + double tmp_15 = tmp_14/tmp_9; + double tmp_16 = tmp_15/pow(tmp_3, 17.0/2.0); + double tmp_17 = x*y; + double tmp_18 = 4*tmp_15/pow(tmp_3, 3.0/2.0); + double tmp_19 = 3*tmp_17; + double tmp_20 = r_s*tmp_15/pow(tmp_3, 2); + double tmp_21 = tmp_14*log(tmp_10*tmp_6 + 1)/pow(tmp_3, 5.0/2.0); + double tmp_22 = tmp_17*tmp_18 + tmp_19*tmp_20 - tmp_19*tmp_21; + double tmp_23 = x*z; + double tmp_24 = 3*tmp_20; + double tmp_25 = 3*tmp_21; + double tmp_26 = tmp_18*tmp_23 + tmp_23*tmp_24 - tmp_23*tmp_25; + double tmp_27 = 3*tmp_8; + double tmp_28 = 3*tmp_12; + double tmp_29 = y*z; + double tmp_30 = tmp_18*tmp_29 + tmp_24*tmp_29 - tmp_25*tmp_29; + + hess[0] = hess[0] + tmp_16*(tmp_0*tmp_4 - tmp_12*tmp_5 + tmp_13 + tmp_5*tmp_8); + hess[1] = hess[1] + tmp_22; + hess[2] = hess[2] + tmp_26; + hess[3] = hess[3] + tmp_22; + hess[4] = hess[4] + tmp_16*(tmp_1*tmp_27 - tmp_1*tmp_28 + tmp_1*tmp_4 + tmp_13); + hess[5] = hess[5] + tmp_30; + hess[6] = hess[6] + tmp_26; + hess[7] = hess[7] + tmp_30; + hess[8] = hess[8] + tmp_16*(tmp_13 + tmp_2*tmp_27 - tmp_2*tmp_28 + tmp_2*tmp_4); +} + +/* --------------------------------------------------------------------------- + Flattened NFW +*/ +double flattenednfw_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (scale mass) + - r_s (scale radius) + - a (ignore) + - b (ignore) + - c (z flattening) + */ + // v_h2 = pars[1]*pars[1] / (log(2.) - 0.5); + const double v_h2 = -pars[0] * pars[1] / pars[2]; + const double u = norm3_flat_z(q, pars[5]) / pars[2]; + if (u == 0) { + return v_h2; + } else { + return v_h2 * log(1 + u) / u; + } +} + +void flattenednfw_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - m (scale mass) + - r_s (scale radius) + - a (ignore) + - b (ignore) + - c (z flattening) + */ + // v_h2 = pars[1]*pars[1] / (log(2.) - 0.5); + const double v_h2 = pars[0] * pars[1] / pars[2]; + const double u = norm3_flat_z(q, pars[5]) / pars[2]; + + const double fac = v_h2 / (u*u*u) / (pars[2]*pars[2]) * (log(1+u) - u/(1+u)); + + grad[0] = grad[0] + fac*q[0]; + grad[1] = grad[1] + fac*q[1]; + grad[2] = grad[2] + fac*q[2]/(pars[5]*pars[5]); +} + +void flattenednfw_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - r_s (scale radius) + - a (ignore) + - b (ignore) + - c (z flattening) + */ + double G = pars[0]; + double m = pars[1]; + double r_s = pars[2]; + double c = pars[5]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(x, 2); + double tmp_1 = pow(z, 2); + double tmp_2 = pow(c, 2); + double tmp_3 = pow(y, 2); + double tmp_4 = tmp_1 + tmp_2*(tmp_0 + tmp_3); + double tmp_5 = pow(tmp_4, 4); + double tmp_6 = 3*tmp_0; + double tmp_7 = tmp_4/tmp_2; + double tmp_8 = sqrt(tmp_7); + double tmp_9 = r_s + tmp_8; + double tmp_10 = pow(c, 8); + double tmp_11 = tmp_10*tmp_9; + double tmp_12 = tmp_11*pow(tmp_7, 7.0/2.0); + double tmp_13 = pow(tmp_4, 3); + double tmp_14 = pow(tmp_9, 2); + double tmp_15 = tmp_14*log(tmp_9/r_s); + double tmp_16 = tmp_15*tmp_2; + double tmp_17 = -tmp_11*pow(tmp_7, 9.0/2.0) + tmp_15*tmp_5; + double tmp_18 = G*m/tmp_14; + double tmp_19 = tmp_18/pow(tmp_7, 11.0/2.0); + double tmp_20 = tmp_19/tmp_10; + double tmp_21 = pow(c, 4); + double tmp_22 = pow(tmp_4, 2); + double tmp_23 = 3*tmp_9; + double tmp_24 = pow(tmp_7, 3.0/2.0); + double tmp_25 = tmp_18*x; + double tmp_26 = tmp_21*tmp_25*y*(-3*tmp_15*tmp_21*tmp_24 + tmp_21*pow(tmp_7, 5.0/2.0) + tmp_22*tmp_23)/tmp_5; + double tmp_27 = 3*tmp_16; + double tmp_28 = tmp_2*z*(tmp_2*tmp_24 + tmp_23*tmp_4 - tmp_27*tmp_8)/tmp_13; + double tmp_29 = tmp_25*tmp_28; + double tmp_30 = tmp_18*tmp_28*y; + + hess[0] = hess[0] + tmp_20*(tmp_0*tmp_5 + tmp_12*tmp_6 - tmp_13*tmp_16*tmp_6 + tmp_17); + hess[1] = hess[1] + tmp_26; + hess[2] = hess[2] + tmp_29; + hess[3] = hess[3] + tmp_26; + hess[4] = hess[4] + tmp_20*(3*tmp_12*tmp_3 - tmp_13*tmp_27*tmp_3 + tmp_17 + tmp_3*tmp_5); + hess[5] = hess[5] + tmp_30; + hess[6] = hess[6] + tmp_29; + hess[7] = hess[7] + tmp_30; + hess[8] = hess[8] + tmp_13*tmp_19*(tmp_1*tmp_2*tmp_23*tmp_8 - tmp_1*tmp_27 + tmp_1*tmp_4 + tmp_16*tmp_4 - tmp_22*tmp_9/tmp_8)/pow(c, 12); + +} + +/* --------------------------------------------------------------------------- + Triaxial NFW - triaxiality in potential! +*/ +double triaxialnfw_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (scale mass) + - r_s (scale radius) + - a (major axis) + - b (intermediate axis) + - c (minor axis) + */ + double u, v_h2; + // v_h2 = pars[1]*pars[1] / (log(2.) - 0.5); + v_h2 = -pars[0] * pars[1] / pars[2]; + u = sqrt(q[0]*q[0]/(pars[3]*pars[3]) + + q[1]*q[1]/(pars[4]*pars[4]) + + q[2]*q[2]/(pars[5]*pars[5])) / pars[2]; + + if (u == 0) { + return v_h2; + } else { + return v_h2 * log(1 + u) / u; + } +} + +void triaxialnfw_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - v_c (circular velocity at the scale radius) + - r_s (scale radius) + - a (major axis) + - b (intermediate axis) + - c (minor axis) + */ + double fac, u, v_h2; + // v_h2 = pars[1]*pars[1] / (log(2.) - 0.5); + v_h2 = pars[0] * pars[1] / pars[2]; + u = sqrt(q[0]*q[0]/(pars[3]*pars[3]) + + q[1]*q[1]/(pars[4]*pars[4]) + + q[2]*q[2]/(pars[5]*pars[5])) / pars[2]; + + fac = v_h2 / (u*u*u) / (pars[2]*pars[2]) * (log(1+u) - u/(1+u)); + + grad[0] = grad[0] + fac*q[0]/(pars[3]*pars[3]); + grad[1] = grad[1] + fac*q[1]/(pars[4]*pars[4]); + grad[2] = grad[2] + fac*q[2]/(pars[5]*pars[5]); +} + +void triaxialnfw_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - r_s (scale radius) + - a (major axis) + - b (intermediate axis) + - c (minor axis) + */ + double G = pars[0]; + double m = pars[1]; + double r_s = pars[2]; + double a = pars[3]; + double b = pars[4]; + double c = pars[5]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(a, -2); + double tmp_1 = G*m; + double tmp_2 = tmp_0*tmp_1; + double tmp_3 = pow(x, 2); + double tmp_4 = pow(b, -2); + double tmp_5 = pow(y, 2); + double tmp_6 = pow(c, -2); + double tmp_7 = pow(z, 2); + double tmp_8 = tmp_0*tmp_3 + tmp_4*tmp_5 + tmp_6*tmp_7; + double tmp_9 = pow(tmp_8, -3.0/2.0); + double tmp_10 = 1.0/r_s; + double tmp_11 = tmp_10*sqrt(tmp_8) + 1; + double tmp_12 = log(tmp_11); + double tmp_13 = tmp_12*tmp_9; + double tmp_14 = tmp_3/pow(a, 4); + double tmp_15 = 3*tmp_1; + double tmp_16 = tmp_12/pow(tmp_8, 5.0/2.0); + double tmp_17 = tmp_15*tmp_16; + double tmp_18 = tmp_10/tmp_11; + double tmp_19 = tmp_18/tmp_8; + double tmp_20 = tmp_9/(pow(r_s, 2)*pow(tmp_11, 2)); + double tmp_21 = tmp_1*tmp_20; + double tmp_22 = tmp_18/pow(tmp_8, 2); + double tmp_23 = tmp_15*tmp_22; + double tmp_24 = tmp_4*y; + double tmp_25 = tmp_2*x; + double tmp_26 = 3*tmp_25; + double tmp_27 = tmp_16*tmp_26; + double tmp_28 = tmp_20*tmp_25; + double tmp_29 = tmp_22*tmp_26; + double tmp_30 = -tmp_24*tmp_27 + tmp_24*tmp_28 + tmp_24*tmp_29; + double tmp_31 = tmp_6*z; + double tmp_32 = -tmp_27*tmp_31 + tmp_28*tmp_31 + tmp_29*tmp_31; + double tmp_33 = tmp_1*tmp_13; + double tmp_34 = tmp_5/pow(b, 4); + double tmp_35 = tmp_1*tmp_19; + double tmp_36 = tmp_24*tmp_31; + double tmp_37 = -tmp_17*tmp_36 + tmp_21*tmp_36 + tmp_23*tmp_36; + double tmp_38 = tmp_7/pow(c, 4); + + hess[0] = hess[0] + tmp_13*tmp_2 - tmp_14*tmp_17 + tmp_14*tmp_21 + tmp_14*tmp_23 - tmp_19*tmp_2; + hess[1] = hess[1] + tmp_30; + hess[2] = hess[2] + tmp_32; + hess[3] = hess[3] + tmp_30; + hess[4] = hess[4] + -tmp_17*tmp_34 + tmp_21*tmp_34 + tmp_23*tmp_34 + tmp_33*tmp_4 - tmp_35*tmp_4; + hess[5] = hess[5] + tmp_37; + hess[6] = hess[6] + tmp_32; + hess[7] = hess[7] + tmp_37; + hess[8] = hess[8] + -tmp_17*tmp_38 + tmp_21*tmp_38 + tmp_23*tmp_38 + tmp_33*tmp_6 - tmp_35*tmp_6; +} + +/* --------------------------------------------------------------------------- + Satoh potential +*/ +double satoh_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - a (length scale 1) TODO + - b (length scale 2) TODO + */ + const double S2 = norm3_sq(q) + pars[2]*(pars[2] + 2*sqrt(q[2]*q[2] + pars[3]*pars[3])); + return -pars[0] * pars[1] / sqrt(S2); +} + +void satoh_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - a (length scale 1) TODO + - b (length scale 2) TODO + */ + + const double S2 = norm3_sq(q) + pars[2]*(pars[2] + 2*sqrt(q[2]*q[2] + pars[3]*pars[3])); + const double dPhi_dS = pars[0] * pars[1] / S2; + + grad[0] = grad[0] + dPhi_dS*q[0]/sqrt(S2); + grad[1] = grad[1] + dPhi_dS*q[1]/sqrt(S2); + grad[2] = grad[2] + dPhi_dS/sqrt(S2) * q[2]*(1 + pars[2] / sqrt(q[2]*q[2] + pars[3]*pars[3])); +} + +double satoh_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - a (length scale 1) TODO + - b (length scale 2) TODO + */ + double z2b2 = q[2]*q[2] + pars[3]*pars[3]; + double xyz2 = q[0]*q[0] + q[1]*q[1] + q[2]*q[2]; + double S2 = xyz2 + pars[2]*(pars[2] + 2*sqrt(z2b2)); + double A = pars[1] * pars[2] * pars[3]*pars[3] / (4*M_PI*S2*sqrt(S2)*z2b2); + return A * (1/sqrt(z2b2) + 3/pars[2]*(1 - xyz2/S2)); +} + +void satoh_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - a () + - b () + */ + double G = pars[0]; + double m = pars[1]; + double a = pars[2]; + double b = pars[3]; + + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(x, 2); + double tmp_1 = pow(y, 2); + double tmp_2 = pow(z, 2); + double tmp_3 = pow(b, 2) + tmp_2; + double tmp_4 = sqrt(tmp_3); + double tmp_5 = a*(a + 2*tmp_4) + tmp_0 + tmp_1 + tmp_2; + double tmp_6 = G*m; + double tmp_7 = tmp_6/pow(tmp_5, 3.0/2.0); + double tmp_8 = tmp_6/pow(tmp_5, 5.0/2.0); + double tmp_9 = 3*tmp_8; + double tmp_10 = -tmp_9*x*y; + double tmp_11 = 3*z; + double tmp_12 = a/tmp_4; + double tmp_13 = tmp_8*(-tmp_11*tmp_12 - tmp_11); + double tmp_14 = tmp_13*x; + double tmp_15 = tmp_13*y; + + hess[0] = hess[0] + -tmp_0*tmp_9 + tmp_7; + hess[1] = hess[1] + tmp_10; + hess[2] = hess[2] + tmp_14; + hess[3] = hess[3] + tmp_10; + hess[4] = hess[4] + -tmp_1*tmp_9 + tmp_7; + hess[5] = hess[5] + tmp_15; + hess[6] = hess[6] + tmp_14; + hess[7] = hess[7] + tmp_15; + hess[8] = hess[8] + -tmp_13*(-tmp_12*z - z) - tmp_7*(a*tmp_2/pow(tmp_3, 3.0/2.0) - tmp_12 - 1); +} + +/* --------------------------------------------------------------------------- + Kuzmin potential +*/ +double kuzmin_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - a (length scale 1) TODO + */ + double S2 = q[0]*q[0] + q[1]*q[1] + pow(pars[2] + fabs(q[2]), 2); + return -pars[0] * pars[1] / sqrt(S2); +} + +void kuzmin_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - a (length scale 1) TODO + */ + + double S2 = q[0]*q[0] + q[1]*q[1] + pow(pars[2] + fabs(q[2]), 2); + double fac = pars[0] * pars[1] * pow(S2, -1.5); + double zsign; + + if (q[2] > 0) { + zsign = 1.; + } else if (q[2] < 0) { + zsign = -1.; + } else { + zsign = 0.; + } + + grad[0] = grad[0] + fac * q[0]; + grad[1] = grad[1] + fac * q[1]; + grad[2] = grad[2] + fac * zsign * (pars[2] + fabs(q[2])); +} + +double kuzmin_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - a (length scale 1) TODO + */ + if (q[2] != 0.) { + return 0.; + } else { + return pars[1] * pars[2] / (2 * M_PI) * + pow(q[0]*q[0] + q[1]*q[1] + pars[2]*pars[2], -1.5); + } + +} + +/* --------------------------------------------------------------------------- + Miyamoto-Nagai flattened potential +*/ +double miyamotonagai_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - a (length scale 1) TODO + - b (length scale 2) TODO + */ + const double zd = (pars[2] + sqrt(q[2]*q[2] + pars[3]*pars[3])); + return -pars[0] * pars[1] / sqrt(q[0]*q[0] + q[1]*q[1] + zd*zd); +} + +void miyamotonagai_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - a (length scale 1) TODO + - b (length scale 2) TODO + */ + const double sqrtz = sqrt(q[2]*q[2] + pars[3]*pars[3]); + const double zd = pars[2] + sqrtz; + const double fac = pars[0]*pars[1] * pow(q[0]*q[0] + q[1]*q[1] + zd*zd, -1.5); + + grad[0] = grad[0] + fac*q[0]; + grad[1] = grad[1] + fac*q[1]; + grad[2] = grad[2] + fac*q[2] * (1. + pars[2] / sqrtz); +} + +double miyamotonagai_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - a (length scale 1) TODO + - b (length scale 2) TODO + */ + + const double M = pars[1]; + const double a = pars[2]; + const double b = pars[3]; + + const double R2 = q[0]*q[0] + q[1]*q[1]; + const double sqrt_zb = sqrt(q[2]*q[2] + b*b); + const double numer = (b*b*M / (4*M_PI)) * (a*R2 + (a + 3*sqrt_zb)*(a + sqrt_zb)*(a + sqrt_zb)); + const double denom = pow(R2 + (a + sqrt_zb)*(a + sqrt_zb), 2.5) * sqrt_zb*sqrt_zb*sqrt_zb; + + return numer / denom; +} + +void miyamotonagai_hessian(double t, double *pars, double *q, int n_dim, + double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - m (mass scale) + - a (length scale 1) TODO + - b (length scale 2) TODO + */ + double G = pars[0]; + double m = pars[1]; + double a = pars[2]; + double b = pars[3]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(x, 2); + double tmp_1 = pow(y, 2); + double tmp_2 = pow(z, 2); + double tmp_3 = pow(b, 2) + tmp_2; + double tmp_4 = sqrt(tmp_3); + double tmp_5 = a + tmp_4; + double tmp_6 = pow(tmp_5, 2); + double tmp_7 = tmp_0 + tmp_1 + tmp_6; + double tmp_8 = G*m; + double tmp_9 = tmp_8/pow(tmp_7, 3.0/2.0); + double tmp_10 = 3*tmp_8/pow(tmp_7, 5.0/2.0); + double tmp_11 = tmp_10*x; + double tmp_12 = -tmp_11*y; + double tmp_13 = tmp_5/tmp_4; + double tmp_14 = tmp_13*z; + double tmp_15 = -tmp_11*tmp_14; + double tmp_16 = -tmp_10*tmp_14*y; + double tmp_17 = 1.0/tmp_3; + double tmp_18 = tmp_2*tmp_9; + + hess[0] = hess[0] + -tmp_0*tmp_10 + tmp_9; + hess[1] = hess[1] + tmp_12; + hess[2] = hess[2] + tmp_15; + hess[3] = hess[3] + tmp_12; + hess[4] = hess[4] + -tmp_1*tmp_10 + tmp_9; + hess[5] = hess[5] + tmp_16; + hess[6] = hess[6] + tmp_15; + hess[7] = hess[7] + tmp_16; + hess[8] = hess[8] + -tmp_10*tmp_17*tmp_2*tmp_6 + tmp_13*tmp_9 + tmp_17*tmp_18 - tmp_18*tmp_5/pow(tmp_3, 3.0/2.0); +} + +/* --------------------------------------------------------------------------- + MN3 exponential disk approximation + + pars: + - G (Gravitational constant) + - m1, a1, b1 + - m2, a2, b2 + - m3, a3, b3 +*/ +double mn3_value(double t, double *pars, double *q, int n_dim, void *state) { + double tmp_pars[4] = {0., 0., 0., 0.}; + tmp_pars[0] = pars[0]; + + double val = 0.; + for (int i=0; i < 3; i++) { + tmp_pars[1] = pars[1+3*i]; + tmp_pars[2] = pars[1+3*i+1]; + tmp_pars[3] = pars[1+3*i+2]; + val += miyamotonagai_value(t, &tmp_pars[0], q, n_dim, state); + } + return val; +} + +void mn3_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + double tmp_pars[4] = {0., 0., 0., 0.}; + tmp_pars[0] = pars[0]; + + for (int i=0; i < 3; i++) { + tmp_pars[1] = pars[1+3*i]; + tmp_pars[2] = pars[1+3*i+1]; + tmp_pars[3] = pars[1+3*i+2]; + miyamotonagai_gradient_single(t, &tmp_pars[0], q, n_dim, grad, state); + } +} + +double mn3_density(double t, double *pars, double *q, int n_dim, void *state) { + double tmp_pars[4] = {0., 0., 0., 0.}; + tmp_pars[0] = pars[0]; + + double val = 0.; + for (int i=0; i < 3; i++) { + tmp_pars[1] = pars[1+3*i]; + tmp_pars[2] = pars[1+3*i+1]; + tmp_pars[3] = pars[1+3*i+2]; + val += miyamotonagai_density(t, &tmp_pars[0], q, n_dim, state); + } + return val; +} + +void mn3_hessian(double t, double *pars, double *q, int n_dim, + double *hess, void *state) { + double tmp_pars[4] = {0., 0., 0., 0.}; + tmp_pars[0] = pars[0]; + + for (int i=0; i < 3; i++) { + tmp_pars[1] = pars[1+3*i]; + tmp_pars[2] = pars[1+3*i+1]; + tmp_pars[3] = pars[1+3*i+2]; + miyamotonagai_hessian(t, &tmp_pars[0], q, n_dim, hess, state); + } +} + +/* --------------------------------------------------------------------------- + Lee-Suto triaxial NFW from Lee & Suto (2003) +*/ +double leesuto_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: (alpha = 1) + 0 - G + 1 - v_c + 2 - r_s + 3 - a + 4 - b + 5 - c + */ + double x, y, z, _r, u, phi0; + double e_b2 = 1-pow(pars[4]/pars[3],2); + double e_c2 = 1-pow(pars[5]/pars[3],2); + double F1,F2,F3,costh2,sinth2,sinph2; + + phi0 = pars[1]*pars[1] / (log(2.) - 0.5 + (log(2.)-0.75)*e_b2 + (log(2.)-0.75)*e_c2); + + x = q[0]; + y = q[1]; + z = q[2]; + + _r = sqrt(x*x + y*y + z*z); + u = _r / pars[2]; + + F1 = -log(1+u)/u; + F2 = -1/3. + (2*u*u - 3*u + 6)/(6*u*u) + (1/u - pow(u,-3.))*log(1+u); + F3 = (u*u - 3*u - 6)/(2*u*u*(1+u)) + 3*pow(u,-3)*log(1+u); + costh2 = z*z / (_r*_r); + sinth2 = 1 - costh2; + sinph2 = y*y / (x*x + y*y); + //return phi0 * ((e_b2/2 + e_c2/2)*((1/u - 1/(u*u*u))*log(u + 1) - 1 + (2*u*u - 3*u + 6)/(6*u*u)) + (e_b2*y*y/(2*_r*_r) + e_c2*z*z/(2*_r*_r))*((u*u - 3*u - 6)/(2*u*u*(u + 1)) + 3*log(u + 1)/(u*u*u)) - log(u + 1)/u); + if (u == 0) { + return phi0; + } else { + return phi0 * (F1 + (e_b2+e_c2)/2.*F2 + (e_b2*sinth2*sinph2 + e_c2*costh2)/2. * F3); + } +} + +void leesuto_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: (alpha = 1) + 0 - G + 1 - v_c + 2 - r_s + 3 - a + 4 - b + 5 - c + */ + double x, y, z, _r, _r2, _r4, ax, ay, az; + double v_h2, x0, x2, x22; + double x20, x21, x7, x1; + double x10, x13, x15, x16, x17; + double e_b2 = 1-pow(pars[4]/pars[3],2); + double e_c2 = 1-pow(pars[5]/pars[3],2); + + v_h2 = pars[1]*pars[1] / (log(2.) - 0.5 + (log(2.)-0.75)*e_b2 + (log(2.)-0.75)*e_c2); + + x = q[0]; + y = q[1]; + z = q[2]; + + _r2 = x*x + y*y + z*z; + _r = sqrt(_r2); + _r4 = _r2*_r2; + + x0 = _r + pars[2]; + x1 = x0*x0; + x2 = v_h2/(12.*_r4*_r2*_r*x1); + x10 = log(x0/pars[2]); + + x13 = _r*3.*pars[2]; + x15 = x13 - _r2; + x16 = x15 + 6.*(pars[2]*pars[2]); + x17 = 6.*pars[2]*x0*(_r*x16 - x0*x10*6.*(pars[2]*pars[2])); + x20 = x0*_r2; + x21 = 2.*_r*x0; + x7 = e_b2*y*y + e_c2*z*z; + x22 = -12.*_r4*_r*pars[2]*x0 + 12.*_r4*pars[2]*x1*x10 + 3.*pars[2]*x7*(x16*_r2 - 18.*x1*x10*(pars[2]*pars[2]) + x20*(2.*_r - 3.*pars[2]) + x21*(x15 + 9.*(pars[2]*pars[2]))) - x20*(e_b2 + e_c2)*(-6.*_r*pars[2]*(_r2 - (pars[2]*pars[2])) + 6.*pars[2]*x0*x10*(_r2 - 3.*(pars[2]*pars[2])) + x20*(-4.*_r + 3.*pars[2]) + x21*(-x13 + 2.*_r2 + 6.*(pars[2]*pars[2]))); + + ax = x2*x*(x17*x7 + x22); + ay = x2*y*(x17*(x7 - _r2*e_b2) + x22); + az = x2*z*(x17*(x7 - _r2*e_c2) + x22); + + grad[0] = grad[0] + ax; + grad[1] = grad[1] + ay; + grad[2] = grad[2] + az; +} + +double leesuto_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: (alpha = 1) + 0 - G + 1 - v_c + 2 - r_s + 3 - a + 4 - b + 5 - c + */ + double x, y, z, u, v_h2; + double b_a2, c_a2; + b_a2 = pars[4]*pars[4] / (pars[3]*pars[3]); + c_a2 = pars[5]*pars[5] / (pars[3]*pars[3]); + double e_b2 = 1-b_a2; + double e_c2 = 1-c_a2; + v_h2 = pars[1]*pars[1] / (log(2.) - 0.5 + (log(2.)-0.75)*e_b2 + (log(2.)-0.75)*e_c2); + + x = q[0]; + y = q[1]; + z = q[2]; + + u = sqrt(x*x + y*y/b_a2 + z*z/c_a2) / pars[2]; + return v_h2 / (u * (1+u)*(1+u)) / (4.*M_PI*pars[2]*pars[2]*pars[0]); +} + +/* --------------------------------------------------------------------------- + Logarithmic (triaxial) +*/ +double logarithmic_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - v_c (velocity scale) + - r_h (length scale) + - q1 + - q2 + - q3 + */ + const double x = q[0]*cos(pars[6]) + q[1]*sin(pars[6]); + const double y = -q[0]*sin(pars[6]) + q[1]*cos(pars[6]); + const double z = q[2]; + + return 0.5*pars[1]*pars[1] * log(pars[2]*pars[2] + // scale radius + x*x/(pars[3]*pars[3]) + + y*y/(pars[4]*pars[4]) + + z*z/(pars[5]*pars[5])); +} + +double logarithmic_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - v_c (velocity scale) + - r_h (length scale) + - q1 + - q2 + - q3 + */ + double tmp_0 = pow(pars[3], 2); + double tmp_1 = pow(pars[4], 2); + double tmp_2 = tmp_0*tmp_1; + double tmp_3 = tmp_2*pow(q[2], 2); + double tmp_4 = pow(pars[5], 2); + double tmp_5 = tmp_0*tmp_4; + double tmp_6 = tmp_5*pow(q[1], 2); + double tmp_7 = tmp_1*tmp_4; + double tmp_8 = tmp_7*pow(q[0], 2); + double tmp_9 = pow(pars[2], 2)*tmp_2*tmp_4; + double tmp_10 = tmp_6 + tmp_8 + tmp_9; + double tmp_11 = tmp_3 + tmp_9; + return pow(pars[1], 2)*(tmp_2*(tmp_10 - tmp_3) + tmp_5*(tmp_11 - tmp_6 + tmp_8) + tmp_7*(tmp_11 + tmp_6 - tmp_8))/pow(tmp_10 + tmp_3, 2) / (4*M_PI*pars[0]); +} + +void logarithmic_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - v_c (velocity scale) + - r_h (length scale) + - q1 + - q2 + - q3 + */ + + const double x = q[0]*cos(pars[6]) + q[1]*sin(pars[6]); + const double y = -q[0]*sin(pars[6]) + q[1]*cos(pars[6]); + const double z = q[2]; + + const double fac = pars[1]*pars[1] / (pars[2]*pars[2] + x*x/(pars[3]*pars[3]) + y*y/(pars[4]*pars[4]) + z*z/(pars[5]*pars[5])); + const double ax = fac*x/(pars[3]*pars[3]); + const double ay = fac*y/(pars[4]*pars[4]); + const double az = fac*z/(pars[5]*pars[5]); + + grad[0] = grad[0] + (ax*cos(pars[6]) - ay*sin(pars[6])); + grad[1] = grad[1] + (ax*sin(pars[6]) + ay*cos(pars[6])); + grad[2] = grad[2] + az; +} + +void logarithmic_hessian(double t, double *pars, double *q, int n_dim, + double *hess, void *state) { + /* pars: + - G (Gravitational constant) + - v_c (velocity scale) + - r_h (length scale) + - q1 + - q2 + - q3 + */ + double v_c = pars[1]; + double r_h = pars[2]; + double q1 = pars[3]; + double q2 = pars[4]; + double q3 = pars[5]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = pow(q1, -2); + double tmp_1 = pow(v_c, 2); + double tmp_2 = tmp_0*tmp_1; + double tmp_3 = pow(x, 2); + double tmp_4 = pow(q2, -2); + double tmp_5 = pow(y, 2); + double tmp_6 = pow(q3, -2); + double tmp_7 = pow(z, 2); + double tmp_8 = pow(r_h, 2) + tmp_0*tmp_3 + tmp_4*tmp_5 + tmp_6*tmp_7; + double tmp_9 = 1.0/tmp_8; + double tmp_10 = 2.0/pow(tmp_8, 2); + double tmp_11 = tmp_1*tmp_10; + double tmp_12 = tmp_4*y; + double tmp_13 = tmp_10*tmp_2*x; + double tmp_14 = tmp_12*tmp_13; + double tmp_15 = tmp_6*z; + double tmp_16 = tmp_13*tmp_15; + double tmp_17 = tmp_1*tmp_9; + double tmp_18 = tmp_11*tmp_12*tmp_15; + + // minus signs because I initially borked the sympy definition + hess[0] = hess[0] - (-tmp_2*tmp_9 + tmp_11*tmp_3/pow(q1, 4)); + hess[1] = hess[1] - (tmp_14); + hess[2] = hess[2] - (tmp_16); + hess[3] = hess[3] - (tmp_14); + hess[4] = hess[4] - (-tmp_17*tmp_4 + tmp_11*tmp_5/pow(q2, 4)); + hess[5] = hess[5] - (tmp_18); + hess[6] = hess[6] - (tmp_16); + hess[7] = hess[7] - (tmp_18); + hess[8] = hess[8] - (-tmp_17*tmp_6 + tmp_11*tmp_7/pow(q3, 4)); +} + +/* --------------------------------------------------------------------------- + Logarithmic (triaxial) +*/ +double longmuralibar_value(double t, double *pars, double *q, int n_dim, void *state) { + /* http://adsabs.harvard.edu/abs/1992ApJ...397...44L + + pars: + - G (Gravitational constant) + - m (mass scale) + - a + - b + - c + - alpha + */ + const double x = q[0]*cos(pars[5]) + q[1]*sin(pars[5]); + const double y = -q[0]*sin(pars[5]) + q[1]*cos(pars[5]); + const double z = q[2]; + + const double a = pars[2]; + const double b = pars[3]; + const double c = pars[4]; + + const double Tm = sqrt((a-x)*(a-x) + y*y + pow(b + sqrt(c*c + z*z),2)); + const double Tp = sqrt((a+x)*(a+x) + y*y + pow(b + sqrt(c*c + z*z),2)); + + return pars[0]*pars[1]/(2*a) * log((x - a + Tm) / (x + a + Tp)); +} + +void longmuralibar_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* http://adsabs.harvard.edu/abs/1992ApJ...397...44L + + pars: + - G (Gravitational constant) + - m (mass scale) + - a + - b + - c + - alpha + */ + const double x = q[0]*cos(pars[5]) + q[1]*sin(pars[5]); + const double y = -q[0]*sin(pars[5]) + q[1]*cos(pars[5]); + const double z = q[2]; + + const double a = pars[2]; + const double b = pars[3]; + const double c = pars[4]; + + const double bcz = b + sqrt(c*c + z*z); + const double Tm = sqrt((a-x)*(a-x) + y*y + bcz*bcz); + const double Tp = sqrt((a+x)*(a+x) + y*y + bcz*bcz); + + const double fac1 = pars[0]*pars[1] / (2*Tm*Tp); + const double fac2 = 1 / (y*y + bcz*bcz); + const double fac3 = Tp + Tm - (4*x*x)/(Tp+Tm); + + const double gx = 4 * fac1 * x / (Tp + Tm); + const double gy = fac1 * y * fac2 * fac3; + const double gz = fac1 * z * fac2 * fac3 * bcz / sqrt(z*z + c*c); + + grad[0] = grad[0] + (gx*cos(pars[5]) - gy*sin(pars[5])); + grad[1] = grad[1] + (gx*sin(pars[5]) + gy*cos(pars[5])); + grad[2] = grad[2] + gz; +} + +double longmuralibar_density(double t, double *pars, double *q, int n_dim, void *state) { + /* + Generated by sympy... + + pars: + - G (Gravitational constant) + - m (mass scale) + - a + - b + - c + - alpha + */ + double a = pars[2]; + double b = pars[3]; + double c = pars[4]; + + double x = q[0]*cos(pars[5]) + q[1]*sin(pars[5]); + double y = -q[0]*sin(pars[5]) + q[1]*cos(pars[5]); + double z = q[2]; + + double tmp0 = a - x; + double tmp1 = pow(tmp0, 2); + double tmp2 = pow(y, 2); + double tmp3 = pow(z, 2); + double tmp4 = pow(c, 2) + tmp3; + double tmp5 = sqrt(tmp4); + double tmp6 = b + tmp5; + double tmp7 = pow(tmp6, 2); + double tmp8 = tmp2 + tmp7; + double tmp9 = tmp1 + tmp8; + double tmp10 = sqrt(tmp9); + double tmp11 = -a + tmp10 + x; + double tmp12 = 1.0/tmp11; + double tmp13 = 1.0/tmp10; + double tmp14 = pow(tmp9, -1.5); + double tmp15 = 1.0/tmp4; + double tmp16 = tmp13*tmp3; + double tmp17 = tmp6/tmp5; + double tmp18 = pow(tmp4, -1.5); + double tmp19 = tmp15*tmp3*tmp7; + double tmp20 = 2*tmp2; + double tmp21 = a + x; + double tmp22 = pow(tmp21, 2); + double tmp23 = tmp22 + tmp8; + double tmp24 = sqrt(tmp23); + double tmp25 = 1.0/tmp24; + double tmp26 = tmp21 + tmp24; + double tmp27 = 1.0/tmp26; + double tmp28 = tmp25*tmp27; + double tmp29 = tmp11*tmp28; + double tmp30 = tmp11*tmp27/pow(tmp23, 1.5); + double tmp31 = 1.0/tmp23; + double tmp32 = pow(tmp26, -2); + double tmp33 = tmp11*tmp31*tmp32; + double tmp34 = tmp21*tmp25 + 1; + double tmp35 = tmp27*tmp34; + double tmp36 = tmp13*tmp15*tmp3*tmp7; + double tmp37 = -tmp13 + tmp29; + double tmp38 = tmp2*tmp37; + double tmp39 = tmp0*tmp13; + double tmp40 = tmp11*tmp27*tmp34 + tmp39 - 1; + return pars[1]/8.*tmp12*(2*tmp11*tmp32*pow(tmp34, 2) + + tmp12*tmp13*tmp38 + tmp12*tmp36*tmp37 + tmp12*tmp40*(-tmp39 + 1) + + tmp13*tmp17 - tmp13*tmp20*tmp25*tmp27 + tmp13*(-tmp1/tmp9 + 1) + tmp13 - + tmp14*tmp19 - tmp14*tmp2 + tmp15*tmp16 - tmp15*tmp28*tmp3*tmp37*tmp7 - + tmp15*tmp29*tmp3 + 2*tmp15*tmp3*tmp33*tmp7 - tmp16*tmp18*tmp6 - + tmp17*tmp29 + tmp18*tmp29*tmp3*tmp6 + tmp19*tmp30 + tmp2*tmp30 + + tmp20*tmp33 - 2*tmp25*tmp27*tmp36 - tmp28*tmp38 - tmp29*(-tmp22*tmp31 + + 1) - tmp29 - tmp35*tmp40 - tmp35*(-2*tmp0*tmp13 + 2))/(M_PI*a); +} + +void longmuralibar_hessian(double t, double *pars, double *q, int n_dim, + double *hess, void *state) { + /* Generated by sympy... + + pars: + - G (Gravitational constant) + - m (mass scale) + - a + - b + - c + - alpha + */ + double G = pars[0]; + double m = pars[1]; + double a = pars[2]; + double b = pars[3]; + double c = pars[4]; + double alpha = pars[5]; + double x = q[0]; + double y = q[1]; + double z = q[2]; + + double tmp_0 = cos(alpha); + double tmp_1 = tmp_0*x; + double tmp_2 = sin(alpha); + double tmp_3 = tmp_2*y; + double tmp_4 = tmp_1 + tmp_3; + double tmp_5 = a + tmp_4; + double tmp_6 = tmp_0*tmp_5; + double tmp_7 = tmp_0*y - tmp_2*x; + double tmp_8 = tmp_2*tmp_7; + double tmp_9 = -tmp_8; + double tmp_10 = tmp_6 + tmp_9; + double tmp_11 = pow(z, 2); + double tmp_12 = pow(c, 2) + tmp_11; + double tmp_13 = sqrt(tmp_12); + double tmp_14 = b + tmp_13; + double tmp_15 = pow(tmp_14, 2); + double tmp_16 = tmp_15 + pow(tmp_7, 2); + double tmp_17 = tmp_16 + pow(tmp_5, 2); + double tmp_18 = sqrt(tmp_17); + double tmp_19 = 1.0/tmp_18; + double tmp_20 = tmp_10*tmp_19; + double tmp_21 = tmp_18 + tmp_5; + double tmp_22 = 1.0/tmp_21; + double tmp_23 = a - tmp_1 - tmp_3; + double tmp_24 = tmp_0*tmp_23; + double tmp_25 = -tmp_24 + tmp_9; + double tmp_26 = tmp_16 + pow(tmp_23, 2); + double tmp_27 = sqrt(tmp_26); + double tmp_28 = 1.0/tmp_27; + double tmp_29 = tmp_25*tmp_28; + double tmp_30 = tmp_0 + tmp_29; + double tmp_31 = -tmp_0; + double tmp_32 = -tmp_20 + tmp_31; + double tmp_33 = pow(tmp_21, -2); + double tmp_34 = -a + tmp_27 + tmp_4; + double tmp_35 = tmp_33*tmp_34; + double tmp_36 = tmp_22*tmp_30 + tmp_32*tmp_35; + double tmp_37 = (1.0/2.0)*G*m/a; + double tmp_38 = tmp_37/tmp_34; + double tmp_39 = tmp_36*tmp_38; + double tmp_40 = tmp_21*tmp_37/pow(tmp_34, 2); + double tmp_41 = tmp_36*tmp_40; + double tmp_42 = tmp_32*tmp_33; + double tmp_43 = pow(tmp_0, 2) + pow(tmp_2, 2); + double tmp_44 = tmp_28*tmp_43; + double tmp_45 = pow(tmp_26, -3.0/2.0); + double tmp_46 = tmp_25*tmp_45; + double tmp_47 = -tmp_19*tmp_43; + double tmp_48 = pow(tmp_17, -3.0/2.0); + double tmp_49 = tmp_10*tmp_48; + double tmp_50 = tmp_34/pow(tmp_21, 3); + double tmp_51 = tmp_32*tmp_50; + double tmp_52 = tmp_21*tmp_38; + double tmp_53 = tmp_0*tmp_7; + double tmp_54 = tmp_2*tmp_5; + double tmp_55 = tmp_53 + tmp_54; + double tmp_56 = tmp_19*tmp_55; + double tmp_57 = tmp_2 + tmp_56; + double tmp_58 = -tmp_2; + double tmp_59 = tmp_2*tmp_23; + double tmp_60 = tmp_53 - tmp_59; + double tmp_61 = tmp_28*tmp_60; + double tmp_62 = tmp_58 - tmp_61; + double tmp_63 = -tmp_53; + double tmp_64 = tmp_59 + tmp_63; + double tmp_65 = tmp_22*tmp_46; + double tmp_66 = -tmp_56 + tmp_58; + double tmp_67 = tmp_33*tmp_66; + double tmp_68 = tmp_2 + tmp_61; + double tmp_69 = -tmp_54 + tmp_63; + double tmp_70 = tmp_35*tmp_49; + double tmp_71 = -2*tmp_2 - 2*tmp_56; + double tmp_72 = tmp_39*tmp_57 + tmp_41*tmp_62 + tmp_52*(tmp_30*tmp_67 + tmp_42*tmp_68 + tmp_51*tmp_71 + tmp_64*tmp_65 - tmp_69*tmp_70); + double tmp_73 = 1.0/tmp_13; + double tmp_74 = tmp_14*tmp_73; + double tmp_75 = tmp_74*z; + double tmp_76 = tmp_19*tmp_75; + double tmp_77 = tmp_28*tmp_75; + double tmp_78 = tmp_19*tmp_33; + double tmp_79 = tmp_75*tmp_78; + double tmp_80 = 2*tmp_76; + double tmp_81 = tmp_39*tmp_76 - tmp_41*tmp_77 + tmp_52*(-tmp_30*tmp_79 + tmp_42*tmp_77 - tmp_51*tmp_80 - tmp_65*tmp_75 + tmp_70*tmp_75); + double tmp_82 = tmp_22*tmp_68 + tmp_35*tmp_66; + double tmp_83 = tmp_38*tmp_82; + double tmp_84 = tmp_40*tmp_82; + double tmp_85 = tmp_45*tmp_60; + double tmp_86 = tmp_50*tmp_66; + double tmp_87 = tmp_48*tmp_55; + double tmp_88 = tmp_52*(-tmp_22*tmp_75*tmp_85 + tmp_35*tmp_75*tmp_87 + tmp_67*tmp_77 - tmp_68*tmp_79 - tmp_80*tmp_86) + tmp_76*tmp_83 - tmp_77*tmp_84; + double tmp_89 = tmp_22*tmp_28; + double tmp_90 = tmp_14*tmp_89; + double tmp_91 = tmp_73*tmp_90; + double tmp_92 = tmp_19*tmp_35; + double tmp_93 = tmp_74*tmp_92; + double tmp_94 = tmp_91*z - tmp_93*z; + double tmp_95 = tmp_11/tmp_12; + double tmp_96 = tmp_11/pow(tmp_12, 3.0/2.0); + double tmp_97 = tmp_15*tmp_95; + double tmp_98 = 2*tmp_97; + + hess[0] = hess[0] + tmp_39*(tmp_0 + tmp_20) + tmp_41*(-tmp_29 + tmp_31) + tmp_52*(tmp_22*(tmp_44 + tmp_46*(tmp_24 + tmp_8)) + 2*tmp_30*tmp_42 + tmp_35*(tmp_47 - tmp_49*(-tmp_6 + tmp_8)) + tmp_51*(-2*tmp_0 - 2*tmp_20)); + hess[1] = hess[1] + tmp_72; + hess[2] = hess[2] + tmp_81; + hess[3] = hess[3] + tmp_72; + hess[4] = hess[4] + tmp_52*(tmp_22*(tmp_44 + tmp_64*tmp_85) + tmp_35*(tmp_47 - tmp_69*tmp_87) + 2*tmp_67*tmp_68 + tmp_71*tmp_86) + tmp_57*tmp_83 + tmp_62*tmp_84; + hess[5] = hess[5] + tmp_88; + hess[6] = hess[6] + tmp_81; + hess[7] = hess[7] + tmp_88; + hess[8] = hess[8] + tmp_38*tmp_76*tmp_94 - tmp_40*tmp_77*tmp_94 + tmp_52*(tmp_14*tmp_92*tmp_96 - tmp_22*tmp_45*tmp_97 - tmp_28*tmp_78*tmp_98 + tmp_35*tmp_48*tmp_97 + tmp_89*tmp_95 - tmp_90*tmp_96 + tmp_91 - tmp_92*tmp_95 - tmp_93 + tmp_50*tmp_98/tmp_17); +} + + +/* --------------------------------------------------------------------------- + Spherical spline interpolated potentials (Density model) +*/ +#if USE_GSL == 1 + +#include +#include + +// Structure to hold cached GSL interpolation objects +typedef struct { + gsl_spline *spline; // Main spline for density, mass, or potential + gsl_interp_accel *acc; // Accelerator for main spline + gsl_spline *rho_r_spline; // Spline for ρ(r) * r (used in density potential calc) + gsl_spline *rho_r2_spline; // Spline for ρ(r) * r² (used in density gradient calc) + gsl_interp_accel *rho_r_acc; // Accelerator for ρ(r) * r spline + gsl_interp_accel *rho_r2_acc; // Accelerator for ρ(r) * r² spline + int n_knots; + int method; + double *r_knots; + double *values; +} spherical_spline_state; + +double spherical_spline_density_value(double t, double *pars, double *q, int n_dim, void *state) { + /* Spline model where the input is density as a function of radius + + pars: + 0 - G (Gravitational constant) + 1 to 1+n_knots-1 - r_knots (radial knot locations) + n_knots to 2*n_knots-1 - density_values (density at each knot) + */ + const double r = norm3(q); + spherical_spline_state *spl_state = (spherical_spline_state *)state; + + // Check bounds + if (r < spl_state->r_knots[0] || r > spl_state->r_knots[spl_state->n_knots-1]) { + return 0.0; // Outside interpolation range + } + + // Calculate enclosed mass M(r) = 4π ∫[0 to r] ρ(r') r'² dr' + const double r_min = spl_state->r_knots[0]; + const double integral_mass = gsl_spline_eval_integ(spl_state->rho_r2_spline, r_min, r, spl_state->rho_r2_acc); + const double M_r = 4.0 * M_PI * integral_mass; + + // Calculate potential from density + // For spherical symmetry: Ί(r) = -G M(r) / r - 4πG ∫[r to ∞] ρ(r') r' dr' + const double r_max = spl_state->r_knots[spl_state->n_knots-1]; + const double integral_outer = gsl_spline_eval_integ(spl_state->rho_r_spline, r, r_max, spl_state->rho_r_acc); + return -pars[0] * M_r / r - 4.0 * M_PI * pars[0] * integral_outer; +} + +void spherical_spline_density_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + 0 - G (Gravitational constant) + 1 to 1+n_knots-1 - r_knots (radial knot locations) + n_knots to 2*n_knots-1 - density_values (density at each knot) + */ + const double r = norm3(q); + if (r == 0.0) return; + + spherical_spline_state *spl_state = (spherical_spline_state *)state; + + // Check bounds + if (r < spl_state->r_knots[0] || r > spl_state->r_knots[spl_state->n_knots-1]) { + return; // Outside interpolation range + } + + // Calculate enclosed mass M(r) = 4π ∫[0 to r] ρ(r') r'² dr' + // Use the pre-computed ρ(r) * r² spline + const double r_min = spl_state->r_knots[0]; + const double integral = gsl_spline_eval_integ(spl_state->rho_r2_spline, r_min, r, spl_state->rho_r2_acc); + const double M_r = 4.0 * M_PI * integral; + + // Gradient: dΊ/dr = GM(r)/r² + double dPhi_dr = pars[0] * M_r / (r * r); + + // Convert to Cartesian gradients + grad[0] += dPhi_dr * q[0] / r; + grad[1] += dPhi_dr * q[1] / r; + grad[2] += dPhi_dr * q[2] / r; +} + +double spherical_spline_density_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + 0 - G (Gravitational constant) + 1 to 1+n_knots-1 - r_knots (radial knot locations) + n_knots to 2*n_knots-1 - density_values (density at each knot) + */ + const double r = norm3(q); + spherical_spline_state *spl_state = (spherical_spline_state *)state; + + // Check bounds + if (r < spl_state->r_knots[0] || r > spl_state->r_knots[spl_state->n_knots-1]) { + return 0.0; // Outside interpolation range + } + + // Evaluate density at position + return gsl_spline_eval(spl_state->spline, r, spl_state->acc); +} + +/* --------------------------------------------------------------------------- + Spherical spline interpolated potentials - mass +*/ +double spherical_spline_mass_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + 0 - G (Gravitational constant) + 1 to 1+n_knots-1 - r_knots (radial knot locations) + n_knots to 2*n_knots-1 - mass_values (mass enclosed at each knot) + */ + const double r = norm3(q); + spherical_spline_state *spl_state = (spherical_spline_state *)state; + + double M_r; + + // Check bounds + if (r < spl_state->r_knots[0]) { + // For r < r_min, use Keplerian potential with M(r_min) + M_r = spl_state->values[0]; + return -pars[0] * M_r / r; + } + if (r > spl_state->r_knots[spl_state->n_knots-1]) { + // For r > r_max, use Keplerian potential with M(r_max) + M_r = spl_state->values[spl_state->n_knots-1]; + return -pars[0] * M_r / r; + } + + // Calculate potential: Ί(r) = -G ∫[r to ∞] M(r')/r'² dr' + // For finite extent with maximum radius r_max, we assume M(r') = M(r_max) for r' > r_max + // So: Ί(r) = -G ∫[r to r_max] M(r')/r'² dr' - G M(r_max) / r_max + const double r_max = spl_state->r_knots[spl_state->n_knots-1]; + const double M_max = spl_state->values[spl_state->n_knots-1]; + + // Use numerical integration from r to r_max + // TODO: allow number of integration points to be a parameter + int n_integration_points = 1000; + const double dr = (r_max - r) / n_integration_points; + double potential = 0.0; + + for (int i = 0; i < n_integration_points; i++) { + double r_i = r + (i + 0.5) * dr; // Use midpoint for better accuracy + double M_i = gsl_spline_eval(spl_state->spline, r_i, spl_state->acc); + potential -= pars[0] * M_i * dr / (r_i * r_i); + } + + // Add contribution from r_max to infinity (assuming constant M = M_max) + potential -= pars[0] * M_max / r_max; + + return potential; +} + +void spherical_spline_mass_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + 0 - G (Gravitational constant) + 1 to 1+n_knots-1 - r_knots (radial knot locations) + n_knots to 2*n_knots-1 - mass_values (mass enclosed at each knot) + */ + const double r = norm3(q); + if (r == 0.0) return; + + spherical_spline_state *spl_state = (spherical_spline_state *)state; + double M_r; + + // Check bounds + if (r < spl_state->r_knots[0]) { + M_r = spl_state->values[0]; + } else if (r > spl_state->r_knots[spl_state->n_knots-1]) { + M_r = spl_state->values[spl_state->n_knots-1]; + } else { + M_r = gsl_spline_eval(spl_state->spline, r, spl_state->acc); + } + + // Gradient: dΊ/dr = GM(r)/r² + const double dPhi_dr = pars[0] * M_r / (r * r * r); + + // Convert to Cartesian gradients + grad[0] += dPhi_dr * q[0]; + grad[1] += dPhi_dr * q[1]; + grad[2] += dPhi_dr * q[2]; +} + +double spherical_spline_mass_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + 0 - G (Gravitational constant) + 1 to 1+n_knots-1 - r_knots (radial knot locations) + n_knots to 2*n_knots-1 - mass_values (mass enclosed at each knot) + */ + const double r = norm3(q); + if (r == 0.0) return 0.0; + + spherical_spline_state *spl_state = (spherical_spline_state *)state; + + // Check bounds + if (r < spl_state->r_knots[0] || r > spl_state->r_knots[spl_state->n_knots-1]) { + return 0.0; // Outside interpolation range + } + + // Calculate density using: ρ(r) = (1/4πr²) dM/dr + const double dM_dr = gsl_spline_eval_deriv(spl_state->spline, r, spl_state->acc); + return dM_dr / (4.0 * M_PI * r * r); +} + +/* --------------------------------------------------------------------------- + Spherical spline interpolated potentials (Potential model) +*/ +double spherical_spline_potential_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + 0 - G (Gravitational constant) + 1 to 1+n_knots-1 - r_knots (radial knot locations) + n_knots to 2*n_knots-1 - potential_values (potential at each knot) + */ + const double r = norm3(q); + spherical_spline_state *spl_state = (spherical_spline_state *)state; + + // Check bounds - extrapolate beyond grid + if (r < spl_state->r_knots[0]) { + // Linear extrapolation to smaller radii + const double slope = (spl_state->values[1] - spl_state->values[0]) / (spl_state->r_knots[1] - spl_state->r_knots[0]); + return spl_state->values[0] + slope * (r - spl_state->r_knots[0]); + } + if (r > spl_state->r_knots[spl_state->n_knots-1]) { + // Assume potential goes to zero at infinity - extrapolate with 1/r behavior + return spl_state->values[spl_state->n_knots-1] * spl_state->r_knots[spl_state->n_knots-1] / r; + } + + // Evaluate potential at position + return gsl_spline_eval(spl_state->spline, r, spl_state->acc); +} + +void spherical_spline_potential_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + 0 - G (Gravitational constant) + 1 to 1+n_knots-1 - r_knots (radial knot locations) + n_knots to 2*n_knots-1 - potential_values (potential at each knot) + */ + const double r = norm3(q); + if (r == 0.0) return; + + spherical_spline_state *spl_state = (spherical_spline_state *)state; + double dPhi_dr; + + // Check bounds - extrapolate beyond grid + if (r < spl_state->r_knots[0]) { + // Linear extrapolation to smaller radii + // TODO: add an option to instead use the spline derivative at the first knot + // dPhi_dr = gsl_spline_eval_deriv(spl_state->spline, spl_state->r_knots[0], spl_state->acc); + dPhi_dr = (spl_state->values[1] - spl_state->values[0]) / (spl_state->r_knots[1] - spl_state->r_knots[0]); + } else if (r > spl_state->r_knots[spl_state->n_knots-1]) { + // Assume potential goes to zero at infinity - extrapolate with 1/r behavior + // TODO: add an option to instead use the spline derivative at the final knot + // dPhi_dr = gsl_spline_eval_deriv(spl_state->spline, spl_state->r_knots[n_knots-1], spl_state->acc); + dPhi_dr = -spl_state->values[spl_state->n_knots-1] * spl_state->r_knots[spl_state->n_knots-1] / (r * r); + } else { + // Calculate gradient: dΊ/dr + dPhi_dr = gsl_spline_eval_deriv(spl_state->spline, r, spl_state->acc); + } + + // Convert to Cartesian gradients + grad[0] += dPhi_dr * q[0] / r; + grad[1] += dPhi_dr * q[1] / r; + grad[2] += dPhi_dr * q[2] / r; +} + +double spherical_spline_potential_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + 0 - G (Gravitational constant) + 1 to 1+n_knots-1 - r_knots (radial knot locations) + n_knots to 2*n_knots-1 - potential_values (potential at each knot) + */ + const double r = norm3(q); + if (r == 0.0) return 0.0; + + spherical_spline_state *spl_state = (spherical_spline_state *)state; + + // Check bounds + if (r < spl_state->r_knots[0] || r > spl_state->r_knots[spl_state->n_knots-1]) { + return 0.0; // Outside interpolation range + } + + // Calculate density using Poisson equation: ∇²Ί = 4πGρ + // For spherical symmetry: ρ = (1/4πG) [d²Ί/dr² + (2/r) dΊ/dr] + const double dPhi_dr = gsl_spline_eval_deriv(spl_state->spline, r, spl_state->acc); + const double d2Phi_dr2 = gsl_spline_eval_deriv2(spl_state->spline, r, spl_state->acc); + + return (d2Phi_dr2 + 2.0 * dPhi_dr / r) / (4.0 * M_PI * pars[0]); +} + +#endif + +/* --------------------------------------------------------------------------- + Burkert potential + (from Mori and Burkert 2000: https://iopscience.iop.org/article/10.1086/309140/fulltext/50172.text.html) +*/ +double burkert_value(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - rho (mass scale) + - r0 + */ + const double r = norm3(q); + const double x = r / pars[2]; + + // pi G rho r0^2 (pi - 2(1 - r0/r)arctan(r/r0) + 2(1 - r0/r)log(1 + r/r0) - (1 - r0/r)log(1 + (r/r0)^2)) + return -M_PI * pars[0] * pars[1] * pars[2] * pars[2] * (M_PI - 2 * (1 + 1 / x) * atan(x) + 2 * (1 + 1/x) * log(1 + x) - (1 - 1/x) * log(1 + x * x) ); +} + + +void burkert_gradient_single(double t, double *__restrict__ pars, double6ptr q, int n_dim, double6ptr grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - rho (mass scale) + - r0 + */ + const double r = norm3(q); + const double x = r / pars[2]; + + const double dphi_dr = -M_PI * pars[0] * pars[1] * pars[2] / (x * x) * (2 * atan(x) - 2 * log(1 + x) - log(1 + x * x)); + + grad[0] = grad[0] + dphi_dr*q[0]/r; + grad[1] = grad[1] + dphi_dr*q[1]/r; + grad[2] = grad[2] + dphi_dr*q[2]/r; +} + + +double burkert_density(double t, double *pars, double *q, int n_dim, void *state) { + /* pars: + - G (Gravitational constant) + - rho (mass scale) + - r0 + */ + const double r = norm3(q); + const double x = r / pars[2]; + return pars[1] / ((1 + x) * (1 + x * x)); +} + +DEFINE_VECTORIZED_GRADIENT(burkert) +DEFINE_VECTORIZED_GRADIENT(flattenednfw) +DEFINE_VECTORIZED_GRADIENT(henon_heiles) +DEFINE_VECTORIZED_GRADIENT(hernquist) +DEFINE_VECTORIZED_GRADIENT(isochrone) +DEFINE_VECTORIZED_GRADIENT(jaffe) +DEFINE_VECTORIZED_GRADIENT(kepler) +DEFINE_VECTORIZED_GRADIENT(kuzmin) +DEFINE_VECTORIZED_GRADIENT(leesuto) +DEFINE_VECTORIZED_GRADIENT(logarithmic) +DEFINE_VECTORIZED_GRADIENT(longmuralibar) +DEFINE_VECTORIZED_GRADIENT(miyamotonagai) +DEFINE_VECTORIZED_GRADIENT(mn3) +DEFINE_VECTORIZED_GRADIENT(nan) +DEFINE_VECTORIZED_GRADIENT(null) +DEFINE_VECTORIZED_GRADIENT(plummer) +DEFINE_VECTORIZED_GRADIENT(satoh) +DEFINE_VECTORIZED_GRADIENT(sphericalnfw) +DEFINE_VECTORIZED_GRADIENT(stone) +DEFINE_VECTORIZED_GRADIENT(triaxialnfw) + +#if USE_GSL == 1 +DEFINE_VECTORIZED_GRADIENT(powerlawcutoff) +DEFINE_VECTORIZED_GRADIENT(spherical_spline_density) +DEFINE_VECTORIZED_GRADIENT(spherical_spline_mass) +DEFINE_VECTORIZED_GRADIENT(spherical_spline_potential) +#endif diff --git a/gala/source/src/gala/potential/potential/builtin/builtin_potentials.h b/gala/source/src/gala/potential/potential/builtin/builtin_potentials.h new file mode 100644 index 0000000000000000000000000000000000000000..4d4a72342c5a5f428d247405e08340edc9c9e092 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/builtin_potentials.h @@ -0,0 +1,159 @@ +#include +#include "extra_compile_macros.h" + +#if USE_GSL == 1 +#include +#include +#else +// When GSL is not available, provide dummy types +typedef struct { int dummy; } gsl_spline; +typedef struct { int dummy; } gsl_interp_accel; +typedef struct { int dummy; } gsl_interp_type; + +// Provide dummy GSL interpolation type constants +static const gsl_interp_type *gsl_interp_linear = NULL; +static const gsl_interp_type *gsl_interp_polynomial = NULL; +static const gsl_interp_type *gsl_interp_cspline = NULL; +static const gsl_interp_type *gsl_interp_cspline_periodic = NULL; +static const gsl_interp_type *gsl_interp_akima = NULL; +static const gsl_interp_type *gsl_interp_akima_periodic = NULL; +static const gsl_interp_type *gsl_interp_steffen = NULL; + +// Provide dummy function declarations for GSL functions +static inline gsl_interp_accel* gsl_interp_accel_alloc(void) { return NULL; } +static inline void gsl_interp_accel_free(gsl_interp_accel *acc) {} +static inline gsl_spline* gsl_spline_alloc(const gsl_interp_type *T, size_t size) { return NULL; } +static inline int gsl_spline_init(gsl_spline *spline, const double *xa, const double *ya, size_t size) { return 0; } +static inline void gsl_spline_free(gsl_spline *spline) {} +static inline double gsl_spline_eval(const gsl_spline *spline, double x, gsl_interp_accel *acc) { return 0.0; } +static inline double gsl_spline_eval_deriv(const gsl_spline *spline, double x, gsl_interp_accel *acc) { return 0.0; } +static inline double gsl_spline_eval_deriv2(const gsl_spline *spline, double x, gsl_interp_accel *acc) { return 0.0; } +static inline double gsl_spline_eval_integ(const gsl_spline *spline, double a, double b, gsl_interp_accel *acc) { return 0.0; } +#endif + +// Spherical spline interpolation state structure +// Note: We always define the full struct to keep Cython happy, even when GSL is not available +typedef struct { + gsl_spline *spline; // Main spline for density, mass, or potential + gsl_interp_accel *acc; // Accelerator for main spline + gsl_spline *rho_r_spline; // Spline for ρ(r) * r (used in density potential calc) + gsl_spline *rho_r2_spline; // Spline for ρ(r) * r² (used in density gradient calc) + gsl_interp_accel *rho_r_acc; // Accelerator for ρ(r) * r spline + gsl_interp_accel *rho_r2_acc; // Accelerator for ρ(r) * r² spline + int n_knots; + int method; + double *r_knots; + double *values; +} spherical_spline_state; + +extern double nan_density(double t, double *pars, double *q, int n_dim, void *state); +extern double nan_value(double t, double *pars, double *q, int n_dim, void *state); +extern void nan_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void nan_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double null_density(double t, double *pars, double *q, int n_dim, void *state); +extern double null_value(double t, double *pars, double *q, int n_dim, void *state); +extern void null_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void null_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double henon_heiles_value(double t, double *pars, double *q, int n_dim, void *state); +extern void henon_heiles_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void henon_heiles_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double kepler_value(double t, double *pars, double *q, int n_dim, void *state); +extern double kepler_density(double t, double *pars, double *q, int n_dim, void *state); +extern void kepler_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void kepler_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double isochrone_value(double t, double *pars, double *q, int n_dim, void *state); +extern void isochrone_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double isochrone_density(double t, double *pars, double *q, int n_dim, void *state); +extern void isochrone_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double hernquist_value(double t, double *pars, double *q, int n_dim, void *state); +extern void hernquist_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double hernquist_density(double t, double *pars, double *q, int n_dim, void *state); +extern void hernquist_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double plummer_value(double t, double *pars, double *q, int n_dim, void *state); +extern void plummer_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double plummer_density(double t, double *pars, double *q, int n_dim, void *state); +extern void plummer_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double jaffe_value(double t, double *pars, double *q, int n_dim, void *state); +extern void jaffe_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double jaffe_density(double t, double *pars, double *q, int n_dim, void *state); +extern void jaffe_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double powerlawcutoff_value(double t, double *pars, double *q, int n_dim, void *state); +extern void powerlawcutoff_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double powerlawcutoff_density(double t, double *pars, double *q, int n_dim, void *state); +extern void powerlawcutoff_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double stone_value(double t, double *pars, double *q, int n_dim, void *state); +extern void stone_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void stone_density(double t, double *pars, double *q, int n_dim, void *state); +extern void stone_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double sphericalnfw_value(double t, double *pars, double *q, int n_dim, void *state); +extern void sphericalnfw_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double sphericalnfw_density(double t, double *pars, double *q, int n_dim, void *state); +extern void sphericalnfw_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double flattenednfw_value(double t, double *pars, double *q, int n_dim, void *state); +extern void flattenednfw_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void flattenednfw_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double triaxialnfw_value(double t, double *pars, double *q, int n_dim, void *state); +extern void triaxialnfw_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void triaxialnfw_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double satoh_value(double t, double *pars, double *q, int n_dim, void *state); +extern void satoh_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double satoh_density(double t, double *pars, double *q, int n_dim, void *state); +extern void satoh_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double kuzmin_value(double t, double *pars, double *q, int n_dim, void *state); +extern void kuzmin_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double kuzmin_density(double t, double *pars, double *q, int n_dim, void *state); + +extern double miyamotonagai_value(double t, double *pars, double *q, int n_dim, void *state); +extern void miyamotonagai_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void miyamotonagai_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); +extern double miyamotonagai_density(double t, double *pars, double *q, int n_dim, void *state); + +extern double mn3_value(double t, double *pars, double *q, int n_dim, void *state); +extern void mn3_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void mn3_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); +extern double mn3_density(double t, double *pars, double *q, int n_dim, void *state); + +extern double leesuto_value(double t, double *pars, double *q, int n_dim, void *state); +extern void leesuto_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double leesuto_density(double t, double *pars, double *q, int n_dim, void *state); + +extern double logarithmic_value(double t, double *pars, double *q, int n_dim, void *state); +extern void logarithmic_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern void logarithmic_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); +extern double logarithmic_density(double t, double *pars, double *q, int n_dim, void *state); + +extern double longmuralibar_value(double t, double *pars, double *q, int n_dim, void *state); +extern void longmuralibar_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double longmuralibar_density(double t, double *pars, double *q, int n_dim, void *state); +extern void longmuralibar_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +extern double burkert_value(double t, double *pars, double *q, int n_dim, void *state); +extern void burkert_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double burkert_density(double t, double *pars, double *q, int n_dim, void *state); + +// Spherical spline interpolated potentials +extern double spherical_spline_density_value(double t, double *pars, double *q, int n_dim, void *state); +extern void spherical_spline_density_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double spherical_spline_density_density(double t, double *pars, double *q, int n_dim, void *state); + +extern double spherical_spline_mass_value(double t, double *pars, double *q, int n_dim, void *state); +extern void spherical_spline_mass_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double spherical_spline_mass_density(double t, double *pars, double *q, int n_dim, void *state); + +extern double spherical_spline_potential_value(double t, double *pars, double *q, int n_dim, void *state); +extern void spherical_spline_potential_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double spherical_spline_potential_density(double t, double *pars, double *q, int n_dim, void *state); diff --git a/gala/source/src/gala/potential/potential/builtin/core.py b/gala/source/src/gala/potential/potential/builtin/core.py new file mode 100644 index 0000000000000000000000000000000000000000..bf42972a0f8796b202edf520956e8711725f5305 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/core.py @@ -0,0 +1,1854 @@ +"""Built-in potentials implemented in Cython""" + +# HACK: This hack brought to you by a bug in cython, and a solution from here: +# https://stackoverflow.com/questions/57138496/class-level-classmethod-can-only-be-called-on-a-method-descriptor-or-instance +try: + myclassmethod = __builtins__.classmethod +except AttributeError: + myclassmethod = __builtins__["classmethod"] + + +import astropy.units as u +import numpy as np +from astropy.constants import G +from astropy.cosmology import default_cosmology +from scipy.optimize import root_scalar + +from gala._cconfig import EXP_ENABLED +from gala.potential.common import PotentialParameter +from gala.potential.potential.builtin.cybuiltin import ( + BurkertWrapper, + CylSplineWrapper, + FlattenedNFWWrapper, + HenonHeilesWrapper, + HernquistWrapper, + IsochroneWrapper, + JaffeWrapper, + KeplerWrapper, + KuzminWrapper, + LeeSutoTriaxialNFWWrapper, + LogarithmicWrapper, + LongMuraliBarWrapper, + MiyamotoNagaiWrapper, + MN3ExponentialDiskWrapper, + MultipoleWrapper, + NullWrapper, + PlummerWrapper, + PowerLawCutoffWrapper, + SatohWrapper, + SphericalNFWWrapper, + SphericalSplineWrapper, + StoneWrapper, + TriaxialNFWWrapper, +) + +if EXP_ENABLED: + from gala.potential.potential.builtin.cyexp import EXPWrapper, PyEXPWrapper + +from ..core import PotentialBase, _potential_docstring +from ..cpotential import CPotentialBase +from ..symmetry import CylindricalSymmetry, SphericalSymmetry +from ..util import format_doc, sympy_wrap +from .time_interpolated import TimeInterpolatedPotential + +__all__ = [ + "BurkertPotential", + "CylSplinePotential", + "EXPPotential", + "HenonHeilesPotential", + "HernquistPotential", + "IsochronePotential", + "JaffePotential", + "KeplerPotential", + "KuzminPotential", + "LeeSutoTriaxialNFWPotential", + "LogarithmicPotential", + "LongMuraliBarPotential", + "MN3ExponentialDiskPotential", + "MiyamotoNagaiPotential", + "MultipolePotential", + "NFWPotential", + "NullPotential", + "PlummerPotential", + "PowerLawCutoffPotential", + "PyEXPPotential", + "SatohPotential", + "SphericalSplinePotential", + "StonePotential", + "TimeInterpolatedPotential", +] + + +def __getattr__(name): + if name in __all__ and name in globals(): + return globals()[name] + + if not (name.startswith("MultipolePotentialLmax")): + raise AttributeError(f"Module {__name__!r} has no attribute {name!r}.") + + if name in mp_cache: + return mp_cache[name] + + try: + lmax = int(name.split("Lmax")[1]) + except Exception as e: + raise ImportError("Invalid") from e # shouldn't ever get here! + + return make_multipole_cls(lmax, timedep="TimeDependent" in name) + + +@format_doc(common_doc=_potential_docstring) +class HenonHeilesPotential(CPotentialBase): + r""" + The Hénon-Heiles potential. + + Parameters + ---------- + {common_doc} + """ + + ndim = 2 + Wrapper = HenonHeilesWrapper + + @myclassmethod + @sympy_wrap(var="x y") + def to_sympy(cls, v, p): + expr = ( + 1.0 + / 2 + * ( + v["x"] ** 2 + + v["y"] ** 2 + + 2 * v["x"] ** 2 * v["y"] + - 2.0 / 3 * v["y"] ** 3 + ) + ) + return expr, v, p + + +# ============================================================================ +# Spherical models +# + + +@format_doc(common_doc=_potential_docstring) +class KeplerPotential(CPotentialBase): + r""" + The Kepler potential for a point mass. + + Parameters + ---------- + m : :class:`~astropy.units.Quantity`, numeric [mass] + Point mass value. + {common_doc} + """ + + m = PotentialParameter("m", physical_type="mass") + Wrapper = KeplerWrapper + _symmetry = SphericalSymmetry() + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) + expr = -p["G"] * p["m"] / r + return expr, v, p + + +@format_doc(common_doc=_potential_docstring) +class IsochronePotential(CPotentialBase): + r""" + The Isochrone potential. + + Parameters + ---------- + m : :class:`~astropy.units.Quantity`, numeric [mass] + Mass. + b : :class:`~astropy.units.Quantity`, numeric [length] + Core concentration. + {common_doc} + """ + + m = PotentialParameter("m", physical_type="mass") + b = PotentialParameter("b", physical_type="length") + + Wrapper = IsochroneWrapper + _symmetry = SphericalSymmetry() + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) + expr = -p["G"] * p["m"] / (sy.sqrt(r**2 + p["b"] ** 2) + p["b"]) + return expr, v, p + + def action_angle(self, w): + """ + Transform the input cartesian position and velocity to action-angle + coordinates the Isochrone potential. See Section 3.5.2 in + Binney & Tremaine (2008), and be aware of the errata entry for + Eq. 3.225. + + This transformation is analytic and can be used as a "toy potential" + in the Sanders & Binney 2014 formalism for computing action-angle + coordinates in _any_ potential. + + Adapted from Jason Sanders' code + `here `_. + + Parameters + ---------- + w : :class:`gala.dynamics.PhaseSpacePosition`, :class:`gala.dynamics.Orbit` + The positions or orbit to compute the actions, angles, and + frequencies at. + """ + from gala.dynamics.actionangle import isochrone_xv_to_aa + + return isochrone_xv_to_aa(w, self) + + +@format_doc(common_doc=_potential_docstring) +class HernquistPotential(CPotentialBase): + r""" + Hernquist potential for a spheroid. + See: http://adsabs.harvard.edu/abs/1990ApJ...356..359H + + Parameters + ---------- + m : :class:`~astropy.units.Quantity`, numeric [mass] + Mass. + c : :class:`~astropy.units.Quantity`, numeric [length] + Core concentration. + {common_doc} + """ + + m = PotentialParameter("m", physical_type="mass") + c = PotentialParameter("c", physical_type="length") + + Wrapper = HernquistWrapper + _symmetry = SphericalSymmetry() + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) + expr = -p["G"] * p["m"] / (r + p["c"]) + return expr, v, p + + +@format_doc(common_doc=_potential_docstring) +class PlummerPotential(CPotentialBase): + r""" + Plummer potential for a spheroid. + + Parameters + ---------- + m : :class:`~astropy.units.Quantity`, numeric [mass] + Mass. + b : :class:`~astropy.units.Quantity`, numeric [length] + Core concentration. + {common_doc} + """ + + m = PotentialParameter("m", physical_type="mass") + b = PotentialParameter("b", physical_type="length") + + Wrapper = PlummerWrapper + _symmetry = SphericalSymmetry() + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) + expr = -p["G"] * p["m"] / sy.sqrt(r**2 + p["b"] ** 2) + return expr, v, p + + +@format_doc(common_doc=_potential_docstring) +class JaffePotential(CPotentialBase): + r""" + Jaffe potential for a spheroid. + + Parameters + ---------- + m : :class:`~astropy.units.Quantity`, numeric [mass] + Mass. + c : :class:`~astropy.units.Quantity`, numeric [length] + Core concentration. + {common_doc} + """ + + m = PotentialParameter("m", physical_type="mass") + c = PotentialParameter("c", physical_type="length") + + Wrapper = JaffeWrapper + _symmetry = SphericalSymmetry() + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) + expr = p["G"] * p["m"] / p["c"] * sy.log(r / (r + p["c"])) + return expr, v, p + + +@format_doc(common_doc=_potential_docstring) +class StonePotential(CPotentialBase): + r""" + StonePotential(m, r_c, r_h, units=None, origin=None, R=None) + + Stone potential from `Stone & Ostriker (2015) + `_. + + Parameters + ---------- + m_tot : :class:`~astropy.units.Quantity`, numeric [mass] + Total mass. + r_c : :class:`~astropy.units.Quantity`, numeric [length] + Core radius. + r_h : :class:`~astropy.units.Quantity`, numeric [length] + Halo radius. + {common_doc} + """ + + m = PotentialParameter("m", physical_type="mass") + r_c = PotentialParameter("r_c", physical_type="length") + r_h = PotentialParameter("r_h", physical_type="length") + + Wrapper = StoneWrapper + _symmetry = SphericalSymmetry() + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) + A = -2 * p["G"] * p["m"] / (np.pi * (p["r_h"] - p["r_c"])) + expr = A * ( + p["r_h"] / r * sy.atan(r / p["r_h"]) + - p["r_c"] / r * sy.atan(r / p["r_c"]) + + 1.0 / 2 * sy.log((r**2 + p["r_h"] ** 2) / (r**2 + p["r_c"] ** 2)) + ) + return expr, v, p + + +@format_doc(common_doc=_potential_docstring) +class PowerLawCutoffPotential(CPotentialBase, GSL_only=True): + r""" + A spherical power-law density profile with an exponential cutoff. + + The power law index must be ``0 <= alpha < 3``. + + .. note:: + + This potential requires GSL to be installed, and Gala must have been + built and installed with GSL support enabled (the default behavior). + See http://gala.adrian.pw/en/latest/install.html for more information. + + Parameters + ---------- + m : :class:`~astropy.units.Quantity`, numeric [mass] + Total mass. + alpha : numeric + Power law index. Must satisfy: ``alpha < 3`` + r_c : :class:`~astropy.units.Quantity`, numeric [length] + Cutoff radius. + {common_doc} + """ + + m = PotentialParameter("m", physical_type="mass") + alpha = PotentialParameter("alpha", physical_type="dimensionless") + r_c = PotentialParameter("r_c", physical_type="length") + + Wrapper = PowerLawCutoffWrapper + _symmetry = SphericalSymmetry() + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + G = p["G"] + m = p["m"] + alpha = p["alpha"] + r_c = p["r_c"] + r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) + x = r**2 / r_c**2 + + a1 = 3.0 / 2 - alpha / 2 + a2 = 1 - alpha / 2 + + term1 = ( + G + * alpha + * m + * sy.lowergamma(a1, x) + / (2 * r * sy.gamma(5.0 / 2 - alpha / 2)) + ) + term2 = G * m * sy.lowergamma(a2, x) / (r_c * sy.gamma(3.0 / 2 - alpha / 2)) + term3 = ( + 3 * G * m * sy.lowergamma(a1, x) / (2 * r * sy.gamma(5.0 / 2 - alpha / 2)) + ) + + # Full unnormalized expression + expr = term1 + term2 - term3 + + # Subtract asymptotic value + phi_inf = G * m * sy.gamma(a2) / (r_c * sy.gamma(3.0 / 2 - alpha / 2)) + expr -= phi_inf + + return expr, v, p + + +@format_doc(common_doc=_potential_docstring) +class BurkertPotential(CPotentialBase): + r""" + The Burkert potential that well-matches the rotation curve of dwarf galaxies. + See https://iopscience.iop.org/article/10.1086/309140/fulltext/50172.text.html + + Parameters + ---------- + rho : :class:`~astropy.units.Quantity`, numeric [mass density] + Central mass density. + r0 : :class:`~astropy.units.Quantity`, numeric [length] + The core radius. + {common_doc} + """ + + rho = PotentialParameter("rho", physical_type="mass density") + r0 = PotentialParameter("r0", physical_type="length") + + Wrapper = BurkertWrapper + _symmetry = SphericalSymmetry() + + @classmethod + def from_r0(cls, r0, units=None): + r""" + from_r0(r0, units=None) + + Initialize a Burkert potential from the core radius, ``r0``. + See Equations 4 and 5 of Mori & Burkert. + + Parameters + ---------- + r0 : :class:`~astropy.units.Quantity`, numeric [length] + The core radius of the Burkert potential. + """ + a = 0.021572405792749372 * u.Msun / u.pc**3 # converted: 1.46e-24 g/cm**3 + rho_d0 = a * (r0 / (3.07 * u.kpc)) ** (-2 / 3) + return cls(rho=rho_d0, r0=r0, units=units) + + +# ============================================================================ +# Flattened, axisymmetric models +# + + +@format_doc(common_doc=_potential_docstring) +class SatohPotential(CPotentialBase): + r""" + SatohPotential(m, a, b, units=None, origin=None, R=None) + + Satoh potential for a flattened mass distribution. + + Parameters + ---------- + m : :class:`~astropy.units.Quantity`, numeric [mass] + Mass. + a : :class:`~astropy.units.Quantity`, numeric [length] + Scale length. + b : :class:`~astropy.units.Quantity`, numeric [length] + Scale height. + {common_doc} + """ + + m = PotentialParameter("m", physical_type="mass") + a = PotentialParameter("a", physical_type="length") + b = PotentialParameter("b", physical_type="length") + + Wrapper = SatohWrapper + _symmetry = CylindricalSymmetry() + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + R = sy.sqrt(v["x"] ** 2 + v["y"] ** 2) + z = v["z"] + term = R**2 + z**2 + p["a"] * (p["a"] + 2 * sy.sqrt(z**2 + p["b"] ** 2)) + expr = -p["G"] * p["m"] / sy.sqrt(term) + return expr, v, p + + +@format_doc(common_doc=_potential_docstring) +class KuzminPotential(CPotentialBase): + r""" + KuzminPotential(m, a, units=None, origin=None, R=None) + + Kuzmin potential for a flattened mass distribution. + + Parameters + ---------- + m : :class:`~astropy.units.Quantity`, numeric [mass] + Mass. + a : :class:`~astropy.units.Quantity`, numeric [length] + Flattening parameter. + {common_doc} + """ + + m = PotentialParameter("m", physical_type="mass") + a = PotentialParameter("a", physical_type="length") + + Wrapper = KuzminWrapper + _symmetry = CylindricalSymmetry() + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + denom = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + (p["a"] + sy.Abs(v["z"])) ** 2) + expr = -p["G"] * p["m"] / denom + return expr, v, p + + +@format_doc(common_doc=_potential_docstring) +class MiyamotoNagaiPotential(CPotentialBase): + r""" + MiyamotoNagaiPotential(m, a, b, units=None, origin=None, R=None) + + Miyamoto-Nagai potential for a flattened mass distribution. + + See: http://adsabs.harvard.edu/abs/1975PASJ...27..533M + + Parameters + ---------- + m : :class:`~astropy.units.Quantity`, numeric [mass] + Mass. + a : :class:`~astropy.units.Quantity`, numeric [length] + Scale length. + b : :class:`~astropy.units.Quantity`, numeric [length] + Scale height. + {common_doc} + """ + + m = PotentialParameter("m", physical_type="mass") + a = PotentialParameter("a", physical_type="length") + b = PotentialParameter("b", physical_type="length") + + Wrapper = MiyamotoNagaiWrapper + _symmetry = CylindricalSymmetry() + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + R = sy.sqrt(v["x"] ** 2 + v["y"] ** 2) + z = v["z"] + term = R**2 + (p["a"] + sy.sqrt(z**2 + p["b"] ** 2)) ** 2 + expr = -p["G"] * p["m"] / sy.sqrt(term) + return expr, v, p + + +@format_doc(common_doc=_potential_docstring) +class MN3ExponentialDiskPotential(CPotentialBase): + """ + MN3ExponentialDiskPotential(m, h_R, h_z, positive_density=True, sech2_z=True, + units=None, origin=None, R=None) + + A sum of three Miyamoto-Nagai disk potentials that approximate the potential + generated by a double exponential disk. + + This model is taken from `Smith et al. (2015) + `_ - if you + use this potential class, please also cite that work. + + As described in the above reference, this approximation has two options: (1) + with the ``positive_density=True`` argument set, this density will be + positive everywhere, but is only a good approximation of the exponential + density within about 5 disk scale lengths, and (2) with + ``positive_density=False``, this density will be negative in some regions, + but is a better approximation out to about 7 or 8 disk scale lengths. + + Parameters + ---------- + m : :class:`~astropy.units.Quantity`, numeric [mass] + Mass. + h_R : :class:`~astropy.units.Quantity`, numeric [length] + Radial (exponential) scale length. + h_z : :class:`~astropy.units.Quantity`, numeric [length] + If ``sech2_z=True``, this is the scale height in a sech^2 vertical + profile. If ``sech2_z=False``, this is an exponential scale height. + {common_doc} + + """ + + m = PotentialParameter("m", physical_type="mass") + h_R = PotentialParameter("h_R", physical_type="length") + h_z = PotentialParameter("h_z", physical_type="length") + Wrapper = MN3ExponentialDiskWrapper + _symmetry = CylindricalSymmetry() + + _K_pos_dens = np.array( + [ + [0.0036, -0.0330, 0.1117, -0.1335, 0.1749], + [-0.0131, 0.1090, -0.3035, 0.2921, -5.7976], + [-0.0048, 0.0454, -0.1425, 0.1012, 6.7120], + [-0.0158, 0.0993, -0.2070, -0.7089, 0.6445], + [-0.0319, 0.1514, -0.1279, -0.9325, 2.6836], + [-0.0326, 0.1816, -0.2943, -0.6329, 2.3193], + ] + ) + _K_neg_dens = np.array( + [ + [-0.0090, 0.0640, -0.1653, 0.1164, 1.9487], + [0.0173, -0.0903, 0.0877, 0.2029, -1.3077], + [-0.0051, 0.0287, -0.0361, -0.0544, 0.2242], + [-0.0358, 0.2610, -0.6987, -0.1193, 2.0074], + [-0.0830, 0.4992, -0.7967, -1.2966, 4.4441], + [-0.0247, 0.1718, -0.4124, -0.5944, 0.7333], + ] + ) + + def __init__( + self, + *args, + units=None, + origin=None, + R=None, + positive_density=True, + sech2_z=True, + **kwargs, + ): + PotentialBase.__init__(self, *args, units=units, origin=origin, R=R, **kwargs) + hzR = (self.parameters["h_z"] / self.parameters["h_R"]).decompose() + + K = self._K_pos_dens if positive_density else self._K_neg_dens + + # get b / h_R + if sech2_z: + b_hR = -0.033 * hzR**3 + 0.262 * hzR**2 + 0.659 * hzR + else: + b_hR = -0.269 * hzR**3 + 1.08 * hzR**2 + 1.092 * hzR + + self.positive_density = positive_density + self.sech2_z = sech2_z + + x = np.vander([b_hR], N=5)[0] + + param_vec = K @ x + + self._ms = param_vec[:3] * self.parameters["m"].value + self._as = param_vec[3:] * self.parameters["h_R"].value + self._b = b_hR * self.parameters["h_R"] + + c_only = {} + for i in range(3): + c_only[f"m{i + 1}"] = self._ms[i] + c_only[f"a{i + 1}"] = self._as[i] + c_only[f"b{i + 1}"] = self._b.value + + self._setup_wrapper(c_only) + + def get_three_potentials(self): + """ + Return three MiyamotoNagaiPotential instances that represent the three internal + components of this MN3 potential model + """ + pots = {} + for i in range(3): + name = f"disk{i + 1}" + pots[name] = MiyamotoNagaiPotential( + m=self._ms[i], a=self._as[i], b=self._b, units=self.units + ) + return pots + + +# ============================================================================ +# Triaxial models +# + + +@format_doc(common_doc=_potential_docstring) +class NFWPotential(CPotentialBase): + r""" + NFWPotential(m, r_s, a=1, b=1, c=1, units=None, origin=None, R=None) + + General Navarro-Frenk-White potential. Supports spherical, flattened, and + triaxiality but the flattening is introduced into the potential, not the + density, and can therefore lead to unphysical mass distributions. For a + triaxial NFW potential that supports flattening in the density, see + :class:`gala.potential.LeeSutoTriaxialNFWPotential`. + + See also the alternate initializers: `NFWPotential.from_circular_velocity` + and `NFWPotential.from_M200_c` + + Parameters + ---------- + m : :class:`~astropy.units.Quantity`, numeric [mass] + Scale mass. + r_s : :class:`~astropy.units.Quantity`, numeric [length] + Scale radius. + a : numeric + Major axis scale. + b : numeric + Intermediate axis scale. + c : numeric + Minor axis scale. + {common_doc} + """ + + m = PotentialParameter("m", physical_type="mass") + r_s = PotentialParameter("r_s", physical_type="length") + a = PotentialParameter("a", physical_type="dimensionless", default=1.0) + b = PotentialParameter("b", physical_type="dimensionless", default=1.0) + c = PotentialParameter("c", physical_type="dimensionless", default=1.0) + + def _setup_potential( + self, parameters, parameter_is_default, origin=None, R=None, units=None + ): + super()._setup_potential( + parameters, parameter_is_default, origin=origin, R=R, units=units + ) + a = self.parameters["a"] + b = self.parameters["b"] + c = self.parameters["c"] + + if np.allclose([a, b, c], 1.0): + self.Wrapper = SphericalNFWWrapper + self._symmetry = SphericalSymmetry() + + elif np.allclose([a, b], 1.0): + self.Wrapper = FlattenedNFWWrapper + self._symmetry = CylindricalSymmetry() + + else: + self.Wrapper = TriaxialNFWWrapper + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + uu = ( + sy.sqrt( + (v["x"] / p["a"]) ** 2 + (v["y"] / p["b"]) ** 2 + (v["z"] / p["c"]) ** 2 + ) + / p["r_s"] + ) + v_h2 = p["G"] * p["m"] / p["r_s"] + expr = -v_h2 * sy.log(1 + uu) / uu + return expr, v, p + + @classmethod + def _get_rho_c(cls, cosmo=None): + """ + Return the critical density at z=0, rho_c. If cosmo is None, uses the default + astropy cosmology. + """ + if cosmo is None: + cosmo = default_cosmology.get() + return 3 * cosmo.H(0.0) ** 2 / (8 * np.pi * G) + + @classmethod + def from_M200_c(cls, M200, c, rho_c=None, units=None, origin=None, R=None): + r""" + from_M200_c(M200, c, rho_c=None, units=None, origin=None, R=None) + + Initialize an NFW potential from a virial mass, ``M200``, and a + concentration, ``c``. + + Parameters + ---------- + M200 : :class:`~astropy.units.Quantity`, numeric [mass] + Virial mass, or mass at 200 times the critical density, ``rho_c``. + c : numeric + NFW halo concentration. + rho_c : :class:`~astropy.units.Quantity`, numeric [density] + Critical density at z=0. If not specified, uses the default astropy + cosmology to obtain this, `~astropy.cosmology.default_cosmology`. + """ + if rho_c is None: + cosmo = default_cosmology.get() + rho_c = cls._get_rho_c(cosmo) + + R200 = np.cbrt(M200 / (200 * rho_c) / (4.0 / 3 * np.pi)).to(u.kpc) + r_s = R200 / c + + A_NFW = np.log(1 + c) - c / (1 + c) + m = M200 / A_NFW + + return NFWPotential( + m=m, r_s=r_s, a=1.0, b=1.0, c=1.0, units=units, origin=origin, R=R + ) + + @classmethod + def from_circular_velocity( + cls, + v_c, + r_s, + a=1.0, + b=1.0, + c=1.0, + r_ref=None, + units=None, + origin=None, + R=None, + ): + r""" + Initialize an NFW potential from a circular velocity, scale radius, and + reference radius for the circular velocity. + + For scale mass :math:`m_s`, scale radius :math:`r_s`, scaled + reference radius :math:`u_{\rm ref} = r_{\rm ref}/r_s`: + + .. math:: + + \frac{G\,m_s}{r_s} = \frac{v_c^2}{u_{\rm ref}} \, + \left[\frac{u_{\rm ref}}{1+u_{\rm ref}} - + \frac{\ln(1+u_{\rm ref})}{u_{\rm ref}^2} \right]^{-1} + + Parameters + ---------- + v_c : :class:`~astropy.units.Quantity`, numeric [velocity] + Circular velocity at the reference radius ``r_ref`` (see below). + r_s : :class:`~astropy.units.Quantity`, numeric [length] + Scale radius. + a : numeric + Major axis scale. + b : numeric + Intermediate axis scale. + c : numeric + Minor axis scale. + r_ref : :class:`~astropy.units.Quantity`, numeric [length] (optional) + Reference radius at which the circular velocity is given. By default, + this is assumed to be the scale radius, ``r_s``. + + """ + + units = cls._validate_units(units) + + if not hasattr(v_c, "unit"): + v_c = v_c * units["length"] / units["time"] + + if not hasattr(r_s, "unit"): + r_s *= units["length"] + + if r_ref is None: + r_ref = r_s + + m = NFWPotential._vc_rs_rref_to_m(v_c, r_s, r_ref) + m = m.to(units["mass"]) + + return NFWPotential( + m=m, r_s=r_s, a=a, b=b, c=c, units=units, origin=origin, R=R + ) + + @staticmethod + def _vc_rs_rref_to_m(v_c, r_s, r_ref): + uu = r_ref / r_s + vs2 = v_c**2 / uu / (np.log(1 + uu) / uu**2 - 1 / (uu * (1 + uu))) + return r_s * vs2 / G + + def c200(self, rho_c=None, root_solver=None): + """The concentration parameter c200.""" + + if rho_c is None: + cosmo = default_cosmology.get() + rho_c = (3 * cosmo.H(0.0) ** 2 / (8 * np.pi * G)).to(u.Msun / u.kpc**3) + + A = ( + (4 * np.pi / 3) + * 200 + * rho_c + * self.parameters["r_s"] ** 3 + / self.parameters["m"] + ) + A = A.decompose().value # dimensionless - strip units for numerical solver + + def func(c): + return (np.log(1 + c) - c / (1 + c)) / c**3 - A + + if root_solver is None: + sol = root_scalar(func, bracket=[1e-6, 100], method="brentq") + + if not sol.converged: + msg = "Root finding for concentration did not converge" + raise RuntimeError(msg) + + return sol.root + + def M200(self, **kwargs): + r""" + The virial mass M200. + + This is the mass within the virial radius R200, where the density is 200 times + the critical density. + + Returns + ------- + M200 : :class:`~astropy.units.Quantity` [mass] + The virial mass. + kwargs : dict + Additional keyword arguments passed to the `c200` method, such as + `rho_c` or `root_solver`. + """ + c = self.c200(**kwargs) + A_NFW = np.log(1 + c) - c / (1 + c) + M200 = self.parameters["m"] * A_NFW + + return M200.decompose(self.units) + + def R200(self, **kwargs): + r""" + The virial radius R200. + + This is the radius within which the mean density is 200 times the critical + density. + + Returns + ------- + R200 : :class:`~astropy.units.Quantity` [length] + The virial radius. + kwargs : dict + Additional keyword arguments passed to the `c200` method, such as + `rho_c` or `root_solver`. + """ + c = self.c200(**kwargs) + return self.parameters["r_s"] * c + + +@format_doc(common_doc=_potential_docstring) +class LogarithmicPotential(CPotentialBase): + r""" + LogarithmicPotential(v_c, r_h, q1, q2, q3, phi=0, theta=0, psi=0, units=None, + origin=None, R=None) + + Triaxial logarithmic potential. + + Parameters + ---------- + v_c : :class:`~astropy.units.Quantity`, numeric [velocity] + Circular velocity. + r_h : :class:`~astropy.units.Quantity`, numeric [length] + Scale radius. + q1 : numeric + Flattening in X. + q2 : numeric + Flattening in Y. + q3 : numeric + Flattening in Z. + phi : `~astropy.units.Quantity`, numeric + First euler angle in the z-x-z convention. + {common_doc} + """ + + v_c = PotentialParameter("v_c", physical_type="speed") + r_h = PotentialParameter("r_h", physical_type="length") + q1 = PotentialParameter("q1", physical_type="dimensionless", default=1.0) + q2 = PotentialParameter("q2", physical_type="dimensionless", default=1.0) + q3 = PotentialParameter("q3", physical_type="dimensionless", default=1.0) + phi = PotentialParameter("phi", physical_type="angle", default=0.0) + + Wrapper = LogarithmicWrapper + + # TODO: could add a post_init to apply symmetry if spherical or axisymmetric + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + r2 = (v["x"] / p["q1"]) ** 2 + (v["y"] / p["q2"]) ** 2 + (v["z"] / p["q3"]) ** 2 + expr = 1.0 / 2 * p["v_c"] ** 2 * sy.log(p["r_h"] ** 2 + r2) + return expr, v, p + + +@format_doc(common_doc=_potential_docstring) +class LeeSutoTriaxialNFWPotential(CPotentialBase): + r""" + LeeSutoTriaxialNFWPotential(v_c, r_s, a, b, c, units=None, origin=None, R=None) + + Approximation of a Triaxial NFW Potential with the flattening in the density, + not the potential. + See `Lee & Suto (2003) `_ + for details. + + Parameters + ---------- + v_c : `~astropy.units.Quantity`, numeric [velocity] + Circular velocity at the scale radius. + r_h : `~astropy.units.Quantity`, numeric [length] + Scale radius. + a : numeric + Major axis. + b : numeric + Intermediate axis. + c : numeric + Minor axis. + {common_doc} + """ + + v_c = PotentialParameter("v_c", physical_type="speed") + r_s = PotentialParameter("r_s", physical_type="length") + a = PotentialParameter("a", physical_type="dimensionless", default=1.0) + b = PotentialParameter("b", physical_type="dimensionless", default=1.0) + c = PotentialParameter("c", physical_type="dimensionless", default=1.0) + + Wrapper = LeeSutoTriaxialNFWWrapper + + # TODO: implement to_sympy() + + +@format_doc(common_doc=_potential_docstring) +class LongMuraliBarPotential(CPotentialBase): + r""" + LongMuraliBarPotential(m, a, b, c, alpha=0, units=None, origin=None, R=None) + + A simple, triaxial model for a galaxy bar. This is a softened "needle" + density distribution with an analytic potential form. + See `Long & Murali (1992) `_ + for details. + + Parameters + ---------- + m : `~astropy.units.Quantity`, numeric [mass] + Mass scale. + a : `~astropy.units.Quantity`, numeric [length] + Bar half-length. + b : `~astropy.units.Quantity`, numeric [length] + Like the Miyamoto-Nagai ``b`` parameter. + c : `~astropy.units.Quantity`, numeric [length] + Like the Miyamoto-Nagai ``c`` parameter. + {common_doc} + """ + + m = PotentialParameter("m", physical_type="mass") + a = PotentialParameter("a", physical_type="length") + b = PotentialParameter("b", physical_type="length") + c = PotentialParameter("c", physical_type="length") + alpha = PotentialParameter("alpha", physical_type="angle", default=0) + + Wrapper = LongMuraliBarWrapper + + @myclassmethod + @sympy_wrap + def to_sympy(cls, v, p): + import sympy as sy + + x = v["x"] * sy.cos(p["alpha"]) + v["y"] * sy.sin(p["alpha"]) + y = -v["x"] * sy.sin(p["alpha"]) + v["y"] * sy.cos(p["alpha"]) + z = v["z"] + + Tm = sy.sqrt( + (p["a"] - x) ** 2 + y**2 + (p["b"] + sy.sqrt(p["c"] ** 2 + z**2)) ** 2 + ) + Tp = sy.sqrt( + (p["a"] + x) ** 2 + y**2 + (p["b"] + sy.sqrt(p["c"] ** 2 + z**2)) ** 2 + ) + + expr = ( + p["G"] + * p["m"] + / (2 * p["a"]) + * sy.log((x - p["a"] + Tm) / (x + p["a"] + Tp)) + ) + + return expr, v, p + + +# ============================================================================== +# Special +# + + +@format_doc(common_doc=_potential_docstring) +class NullPotential(CPotentialBase): + r""" + NullPotential(units=None, origin=None, R=None) + + A null potential with 0 mass. Does nothing. + + Parameters + ---------- + {common_doc} + """ + + Wrapper = NullWrapper + + +# ============================================================================== +# Multipole and flexible potential models +# +mp_cache = {} + + +def make_multipole_cls(lmax, timedep=False): + """Create a MultipolePotential or MultipoleTimeDependentPotential class + (not an instance!) with the specified value of lmax. + + Parameters: + ----------- + lmax : int + timedep : bool + + """ + if timedep: + raise NotImplementedError("Time dependent potential coming soon!") + # cls = MultipoleTimeDependentPotential + # param_default = [0.] + cls = MultipolePotential + param_default = 0.0 + cls_name = f"{cls.__name__}Lmax{lmax}" + + if cls_name in mp_cache: + return mp_cache[cls_name] + + parameters = { + "_lmax": lmax, + "inner": PotentialParameter("inner", default=False), + "m": PotentialParameter("m", physical_type="mass", default=1.0), + "r_s": PotentialParameter("r_s", physical_type="length", default=1.0), + } + doc_lines = [] + ab_callsig = [] + for l in range(lmax + 1): + for m in range(l + 1): + if timedep: + a = f"alpha{l}{m}" + b = f"beta{l}{m}" + dtype = "array-like" + else: + a = f"S{l}{m}" + b = f"T{l}{m}" + dtype = "float" + + parameters[a] = PotentialParameter( + a, physical_type="dimensionless", default=param_default + ) + parameters[b] = PotentialParameter( + b, physical_type="dimensionless", default=param_default + ) + + doc_lines.append(f"{a} : {dtype}\n{b} : {dtype}") + ab_callsig.append(f"{a}, {b}") + + ab_callsig = ", ".join(ab_callsig) + call_signature = f"{cls.__name__}(m, r_s, {ab_callsig})" + parameters["__doc__"] = call_signature + cls.__doc__ + "\n".join(doc_lines) + + # https://stackoverflow.com/a/58716798/623453 + parameters["__module__"] = __name__ + + # Create a new SkyOffsetFrame subclass for this frame class. + potential_cls = type(cls_name, (cls,), parameters) + mp_cache[cls_name] = potential_cls + return mp_cache[cls_name] + + +class MultipolePotential(CPotentialBase, GSL_only=True): + r""" + + A perturbing potential represented by a multipole expansion. + + Inner: + + .. math:: + + \Phi^l_\mathrm{max}(r,\theta,\phi) = \sum_{l=1}^{l=l_\mathrm{max}}\sum_{m=0}^{m=l} + r^l \, (S_{lm} \, \cos{m\,\phi} + T_{lm} \, \sin{m\,\phi}) + \, P_l^m(\cos\theta) + + Outer: + + .. math:: + + \Phi^l_\mathrm{max}(r,\theta,\phi) = \sum_{l=1}^{l=l_\mathrm{max}}\sum_{m=0}^{m=l} + r^{-(l+1)} \, (S_{lm} \, \cos{m\,\phi} + T_{lm} \, \sin{m\,\phi}) + \, P_l^m(\cos\theta) + + + The allowed coefficient parameter names will depend on how you set ``lmax``, and the + default value for all coefficient parameter values is 0. + + Parameters + ---------- + m : numeric + Scale mass. + r_s : numeric + Scale length. + lmax : int + The maximum ``l`` order. + inner : bool (optional) + Controls whether to use the inner expansion, or the outer expansion (see above). + Default value = ``False``. + S00 : float (optional) + S10 : float (optional) + S11 : float (optional) + T11 : float (optional) + etc. + + Examples + -------- + To create a potential object with only a dipole: + + >>> pot = MultipolePotential(lmax=1, S10=5.) + """ + + Wrapper = MultipoleWrapper + + def __init__(self, *args, units=None, origin=None, R=None, **kwargs): + kwargs.pop("lmax", None) + + PotentialBase.__init__(self, *args, units=units, origin=origin, R=R, **kwargs) + + self._setup_wrapper( + {"lmax": self._lmax, "n_coeffs": sum(range(self._lmax + 2))} + ) + + def __new__(cls, *args, **kwargs): + if not (issubclass(cls, MultipolePotential) and cls is not MultipolePotential): + try: + lmax = kwargs["lmax"] + except KeyError as e: + msg = ( + "Can't initialize a MultipolePotential without specifying " + "the `lmax` keyword argument." + ) + raise TypeError(msg) from e + newcls = make_multipole_cls(lmax) + return newcls.__new__(newcls, *args, **kwargs) + + if super().__new__ is object.__new__: + return super().__new__(cls) + return super().__new__(cls, *args, **kwargs) + + +@format_doc(common_doc=_potential_docstring) +class CylSplinePotential(CPotentialBase): + r""" + A flexible potential model that uses spline interpolation over a 2D grid in + cylindrical R-z coordinates. + + Parameters + ---------- + grid_R : `~astropy.units.Quantity`, numeric [length] + A 1D grid of cylindrical radius R values. This should start at 0. + grid_z : `~astropy.units.Quantity`, numeric [length] + A 1D grid of cylindrical z values. This should start at 0. + grid_Phi : `~astropy.units.Quantity`, numeric [specific energy] + A 2D grid of potential values, evaluated at all R,z locations. + {common_doc} + """ + + grid_R = PotentialParameter("grid_R", physical_type="length", ndim=1) + grid_z = PotentialParameter("grid_z", physical_type="length", ndim=1) + grid_Phi = PotentialParameter("grid_Phi", physical_type="specific energy", ndim=2) + + Wrapper = CylSplineWrapper + _symmetry = CylindricalSymmetry() + + @classmethod + def from_file(cls, filename, **kwargs): + """Load a potential instance from an Agama export file. + + Parameters + ---------- + filename : path-like + The path to the Agama expoirt file, either as a string or ``pathlib.Path`` object. + **kwargs + Other keyword arguments are passed to the initializer. + """ + with open(filename, encoding="utf-8") as f: + raw_lines = f.readlines() + + start = r"#R(row)\z(col)" + Phi_lines = [] + for i, line in enumerate(raw_lines): # noqa: B007 + if line.startswith(start): + Phi_lines.append( + [np.nan] + + [float(y) for y in line[len(start) :].strip().split("\t")] + ) + break + + Phi_lines.extend( + [[float(y) for y in x.strip().split("\t")] for x in raw_lines[i + 1 :]] + ) + Phi_lines = np.array(Phi_lines) + + gridR = Phi_lines[1:, 0] * u.kpc + gridz = Phi_lines[0, 1:] * u.kpc + gridPhi = Phi_lines[1:, 1:] * (u.km / u.s) ** 2 + + return cls(gridR, gridz, gridPhi, **kwargs) + + def __init__(self, *args, units=None, origin=None, R=None, **kwargs): + PotentialBase.__init__(self, *args, units=units, origin=origin, R=R, **kwargs) + + grid_R = self.parameters["grid_R"] + grid_z = self.parameters["grid_z"] + grid_Phi = self.parameters["grid_Phi"] + Phi0 = grid_Phi[0, 0] # potential at R=0,z=0 + + self._multipole_pot = self._fit_asympt(grid_R, grid_z, grid_Phi) + Phi_Rmax = self._multipole_pot.energy([1.0, 0, 0] * grid_R.max()) + Mtot = -Phi_Rmax[0] * grid_R.max() + + if Phi0 < 0 and Mtot > 0: + # assign Rscale so that it approximately equals -Mtotal/Phi(r=0), + # i.e. would equal the scale radius of a Plummer potential + Rscale = (-Mtot / Phi0).to(self.units["length"]) + else: + Rscale = grid_R[len(grid_R) // 2] # "rather arbitrary" + + # APW: assumed / enforced mmax=0 - different from Agama + + sizeR = len(grid_R) + + # grid in z assumed to only cover half-space z>=0; the density is assumed + # to be z-reflection symmetric: + sizez_orig = len(grid_z) + grid_z = np.concatenate((-grid_z[::-1], grid_z[1:])) + sizez = len(grid_z) + + # transform the grid to log-scaled coordinates + grid_R_asinh = np.arcsinh((grid_R / Rscale).decompose().value) + grid_z_asinh = np.arcsinh((grid_z / Rscale).decompose().value) + + logScaling = np.all(grid_Phi < 0) + + # temporary containers of scaled potential and derivatives used to + # construct 2d splines + + if grid_Phi.shape[0] != sizeR or grid_Phi.shape[1] != sizez_orig: + raise ValueError("CylSpline: incorrect coefs array size") + + grid_Phi_full = np.zeros((sizeR, sizez)) + grid_Phi_full[:, : sizez_orig - 1] = grid_Phi[:, :0:-1] + grid_Phi_full[:, sizez_orig - 1 :] = grid_Phi + grid_Phi_full = np.log(-grid_Phi_full) if logScaling else grid_Phi_full + + from scipy.interpolate import RectBivariateSpline + + self.spl = RectBivariateSpline(grid_R_asinh, grid_z_asinh, grid_Phi_full) + + # Note: if MultipolePotential parameter order changes, this needs to be updated! + multipole_pars = np.concatenate( + [ + [ + self.G, + self._multipole_pot._lmax, + sum(range(self._multipole_pot._lmax + 2)), + ], + [x.value for x in self._multipole_pot.parameters.values()], + ] + ) + + self._c_only = { + "log_scaling": logScaling, + "Rscale": Rscale.value, + "sizeR": sizeR, + "sizez": sizez, + "grid_R_trans": grid_R_asinh, + "grid_z_trans": grid_z_asinh, + "grid_Phi_trans": grid_Phi_full.T, + "multipole_pars": multipole_pars, + } + self._setup_wrapper(self._c_only) + + def _fit_asympt(self, grid_R, grid_z, grid_Phi, lmax_fit=8): + """ + Assumes z reflection symmetry + + lmax_fit : int + Number of meridional harmonics to fit - don't set too large + + """ + from scipy.special import sph_harm + + sizeR = len(grid_R) + sizez = len(grid_z) + + # assemble the boundary points and their indices + assert grid_Phi.shape == (sizeR, sizez) + maxz = np.max(grid_z.value) + + # first run along R at the max-z and min-z edges + points = np.concatenate( + ([[R, maxz] for R in grid_R.value], [[R, -maxz] for R in grid_R.value]) + ) + Phis = np.concatenate( + (grid_Phi[:, np.argmax(grid_z)].value, grid_Phi[:, np.argmax(grid_z)].value) + ) + + maxR = np.max(grid_R.value) + points = np.concatenate( + ( + points, + [[maxR, z] for z in grid_z.value], + [[maxR, -z] for z in grid_z.value], + ) + ) + Phis = np.concatenate( + ( + Phis, + grid_Phi[np.argmax(grid_R), :].value, + grid_Phi[np.argmax(grid_R), :].value, + ) + ) + + npoints = len(points) + # ncoefs = lmax_fit + 1 + + r0 = min(np.max(grid_R), np.max(grid_z)) + + i, j = len(grid_R) // 2, len(grid_z) // 2 + rr = np.sqrt(grid_R[i] ** 2 + grid_z[j] ** 2) + m = np.abs(grid_Phi[i, j] * rr / G).to(self.units["mass"]) + scale = (G * m / r0).decompose(self.units).value + + # find values of spherical harmonic coefficients + # that best match the potential at the array of boundary points + + # for m-th harmonic, we may have lmax-m+1 different l-terms + matr = np.zeros((npoints, lmax_fit + 1)) + + # The linear system to solve in the least-square sense is M_{p,l} * S_l = R_p, + # where R_p = Phi at p-th boundary point (0<=p bool: + """ + Whether the potential is in static, i.e. fixed-time, mode. + """ + return self.c_instance.static + + @property + def tmin_exp(self) -> u.Quantity: + """ + The actual, loaded minimum time for which the potential is defined. + """ + return self.c_instance.tmin * self.parameters["snapshot_time_unit"] + + @property + def tmax_exp(self) -> u.Quantity: + """ + The actual, loaded maximum time for which the potential is defined. + """ + return self.c_instance.tmax * self.parameters["snapshot_time_unit"] + + +@format_doc(common_doc=_potential_docstring) +class PyEXPPotential(CPotentialBase, EXP_only=True): + r""" + Calls the EXP code for the potential, using the pyEXP objects that the + user provides. + + This potential will usually be constructed with + :class:`~gala.units.SimulationUnitSystem` units. See the tutorial for more + information. + + .. note:: + + This potential requires EXP and pyEXP to be installed, and Gala must have been + built and installed with EXP support enabled. + See https://gala.adrian.pw/en/latest/tutorials/exp.html for more information. + + Parameters + ---------- + basis : `pyEXP.basis.BiorthBasis` + A pyEXP BiorthBasis object + coefs : `pyEXP.coefs.Coefs` + A pyEXP Coefs object + {common_doc} + + Attributes + ---------- + static : bool + Whether the potential is in static, i.e. fixed-time, mode. + tmin_exp, tmax_exp : `~astropy.units.Quantity` + The actual, loaded minimum and maximum time for which the potential is defined. + """ + + basis = PotentialParameter( + "basis", physical_type=None, python_only=True, convert=None + ) + coefs = PotentialParameter( + "coefs", physical_type=None, python_only=True, convert=None + ) + snapshot_time_unit = PotentialParameter( + "snapshot_time_unit", + physical_type=None, + default=None, + python_only=True, + convert=None, + ) + + def __init__(self, *args, **kwargs): + if "units" not in kwargs: + raise ValueError( + "Must specify a `units` keyword argument to initialize a PyEXPPotential " + "(most likely a SimulationUnitSystem with G=1)." + ) + + PotentialBase.__init__(self, *args, **kwargs) + + if self.parameters["snapshot_time_unit"] is None: + self.parameters["snapshot_time_unit"] = self.units["time"] + + # This hackery handles the situation where the snapshot time unit is different + # from the EXP (G=1) unit system that the coefficients/basis are in: + factor = 1 / ( + u.Quantity(1.0, self.parameters["snapshot_time_unit"]) + .decompose(self.units) + .value + ) + + try: + basis_capsule = self.parameters["basis"].get_shared_ptr_capsule() + except AttributeError as e: + raise ValueError( + "The `basis` parameter must be a pyEXP BiorthBasis object from pyEXP >= 7.9.1" + ) from e + + try: + coefs_capsule = self.parameters["coefs"].get_shared_ptr_capsule() + except AttributeError as e: + raise ValueError( + "The `coefs` parameter must be a pyEXP Coefs object from pyEXP >= 7.9.1" + ) from e + + self._setup_wrapper( + basis_capsule=basis_capsule, + coefs_capsule=coefs_capsule, + snapshot_time_factor=factor, + ) + + if EXP_ENABLED: + Wrapper = PyEXPWrapper + + def hessian(self, *args, **kwargs): + """ + Not implemented yet. + """ + raise NotImplementedError( + "Computing Hessian matrices for EXP potentials is not supported." + ) + + @property + def static(self) -> bool: + """ + Whether the potential is in static, i.e. fixed-time, mode. + """ + return self.c_instance.static + + @property + def tmin_exp(self) -> u.Quantity: + """ + The actual, loaded minimum time for which the potential is defined. + """ + return self.c_instance.tmin * self.parameters["snapshot_time_unit"] + + @property + def tmax_exp(self) -> u.Quantity: + """ + The actual, loaded maximum time for which the potential is defined. + """ + return self.c_instance.tmax * self.parameters["snapshot_time_unit"] diff --git a/gala/source/src/gala/potential/potential/builtin/cybuiltin.pxd b/gala/source/src/gala/potential/potential/builtin/cybuiltin.pxd new file mode 100644 index 0000000000000000000000000000000000000000..a6953fadecdad2b4f839e67350bfe5963220ae64 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/cybuiltin.pxd @@ -0,0 +1,102 @@ +# cython: language_level=3 +# cython: language=c++ + +cdef extern from "potential/potential/builtin/builtin_potentials.h": + double nan_density(double t, double *pars, double *q, int n_dim, void *state) nogil + double nan_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void nan_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + void nan_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double null_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void null_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double null_density(double t, double *pars, double *q, int n_dim, void *state) nogil + void null_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double henon_heiles_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void henon_heiles_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + void henon_heiles_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double kepler_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void kepler_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double kepler_density(double t, double *pars, double *q, int n_dim, void *state) nogil + void kepler_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double isochrone_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void isochrone_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double isochrone_density(double t, double *pars, double *q, int n_dim, void *state) nogil + void isochrone_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double hernquist_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void hernquist_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double hernquist_density(double t, double *pars, double *q, int n_dim, void *state) nogil + void hernquist_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double plummer_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void plummer_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double plummer_density(double t, double *pars, double *q, int n_dim, void *state) nogil + void plummer_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double jaffe_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void jaffe_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double jaffe_density(double t, double *pars, double *q, int n_dim, void *state) nogil + void jaffe_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double powerlawcutoff_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void powerlawcutoff_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double powerlawcutoff_density(double t, double *pars, double *q, int n_dim, void *state) nogil + void powerlawcutoff_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double stone_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void stone_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double stone_density(double t, double *pars, double *q, int n_dim, void *state) nogil + void stone_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double sphericalnfw_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void sphericalnfw_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double sphericalnfw_density(double t, double *pars, double *q, int n_dim, void *state) nogil + void sphericalnfw_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double flattenednfw_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void flattenednfw_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + void flattenednfw_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double triaxialnfw_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void triaxialnfw_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + void triaxialnfw_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double satoh_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void satoh_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double satoh_density(double t, double *pars, double *q, int n_dim, void *state) nogil + void satoh_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double kuzmin_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void kuzmin_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double kuzmin_density(double t, double *pars, double *q, int n_dim, void *state) nogil + + double miyamotonagai_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void miyamotonagai_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + void miyamotonagai_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + double miyamotonagai_density(double t, double *pars, double *q, int n_dim, void *state) nogil + + double mn3_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void mn3_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + void mn3_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + double mn3_density(double t, double *pars, double *q, int n_dim, void *state) nogil + + double leesuto_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void leesuto_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double leesuto_density(double t, double *pars, double *q, int n_dim, void *state) nogil + + double logarithmic_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void logarithmic_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + void logarithmic_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + double logarithmic_density(double t, double *pars, double *q, int n_dim, void *state) nogil + + double longmuralibar_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void longmuralibar_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double longmuralibar_density(double t, double *pars, double *q, int n_dim, void *state) nogil + void longmuralibar_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + + double burkert_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void burkert_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double burkert_density(double t, double *pars, double *q, int n_dim, void *state) nogil diff --git a/gala/source/src/gala/potential/potential/builtin/cybuiltin.pyx b/gala/source/src/gala/potential/potential/builtin/cybuiltin.pyx new file mode 100644 index 0000000000000000000000000000000000000000..64b4a7f9a11cb675de2ac975ce597251f05ee0d8 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/cybuiltin.pyx @@ -0,0 +1,598 @@ +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + +""" Built-in potential wrappers """ + + +import warnings +from libc.stdlib cimport malloc, free + +from astropy.constants import G +import astropy.units as u +import numpy as np +cimport numpy as np +np.import_array() + + +from ..core import CompositePotential, _potential_docstring, PotentialBase +from ..util import format_doc, sympy_wrap +from ..cpotential import CPotentialBase +from ..cpotential cimport CPotential, CPotentialWrapper +from ..cpotential cimport densityfunc, energyfunc, gradientfunc, hessianfunc +from ...common import PotentialParameter +from ...frame.cframe cimport CFrameWrapper +from ....units import dimensionless, DimensionlessUnitSystem +from ...._cconfig cimport USE_GSL + +# GSL includes for spline functionality +# The builtin_potentials.h header provides dummy definitions when GSL is not available +cdef extern from "potential/potential/builtin/builtin_potentials.h": + # GSL types - either real (if GSL available) or dummy structs (if not) + ctypedef struct gsl_spline: + pass + ctypedef struct gsl_interp_accel: + pass + ctypedef struct gsl_interp_type: + pass + + # GSL constants - NULL when GSL not available + const gsl_interp_type *gsl_interp_linear + const gsl_interp_type *gsl_interp_polynomial + const gsl_interp_type *gsl_interp_cspline + const gsl_interp_type *gsl_interp_cspline_periodic + const gsl_interp_type *gsl_interp_akima + const gsl_interp_type *gsl_interp_akima_periodic + const gsl_interp_type *gsl_interp_steffen + + # GSL functions - dummy implementations when GSL not available + gsl_interp_accel* gsl_interp_accel_alloc() + void gsl_interp_accel_free(gsl_interp_accel *acc) + gsl_spline* gsl_spline_alloc(const gsl_interp_type *T, size_t size) + int gsl_spline_init(gsl_spline *spline, const double *xa, const double *ya, size_t size) + void gsl_spline_free(gsl_spline *spline) + double gsl_spline_eval(const gsl_spline *spline, double x, gsl_interp_accel *acc) + double gsl_spline_eval_deriv(const gsl_spline *spline, double x, gsl_interp_accel *acc) + double gsl_spline_eval_deriv2(const gsl_spline *spline, double x, gsl_interp_accel *acc) + double gsl_spline_eval_integ(const gsl_spline *spline, double a, double b, gsl_interp_accel *acc) + +cdef extern from "potential/potential/builtin/builtin_potentials.h": + ctypedef struct spherical_spline_state: + gsl_spline *spline + gsl_interp_accel *acc + gsl_spline *rho_r_spline + gsl_spline *rho_r2_spline + gsl_interp_accel *rho_r_acc + gsl_interp_accel *rho_r2_acc + int n_knots + int method + double *r_knots + double *values + + # Spherical spline functions + double spherical_spline_density_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void spherical_spline_density_gradient(double t, double *pars, double *q, int n_dim, double *grad, void *state) nogil + double spherical_spline_density_density(double t, double *pars, double *q, int n_dim, void *state) nogil + + double spherical_spline_mass_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void spherical_spline_mass_gradient(double t, double *pars, double *q, int n_dim, double *grad, void *state) nogil + double spherical_spline_mass_density(double t, double *pars, double *q, int n_dim, void *state) nogil + + double spherical_spline_potential_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void spherical_spline_potential_gradient(double t, double *pars, double *q, int n_dim, double *grad, void *state) nogil + double spherical_spline_potential_density(double t, double *pars, double *q, int n_dim, void *state) nogil + +cdef extern from "potential/potential/builtin/multipole.h": + double mp_potential(double t, double *pars, double *q, int n_dim, void *state) nogil + void mp_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double mp_density(double t, double *pars, double *q, int n_dim, void *state) nogil + + double axisym_cylspline_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void axisym_cylspline_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double axisym_cylspline_density(double t, double *pars, double *q, int n_dim, void *state) nogil + + +__all__ = [ + 'HenonHeilesWrapper', + 'KeplerWrapper', + 'HernquistWrapper', + 'IsochroneWrapper', + 'PlummerWrapper', + 'JaffeWrapper', + 'StoneWrapper', + 'PowerLawCutoffWrapper', + 'SatohWrapper', + 'KuzminWrapper', + 'MiyamotoNagaiWrapper', + 'MN3ExponentialDiskWrapper', + 'SphericalNFWWrapper', + 'FlattenedNFWWrapper', + 'TriaxialNFWWrapper', + 'LeeSutoTriaxialNFWWrapper', + 'LogarithmicWrapper', + 'LongMuraliBarWrapper', + 'NullWrapper', + 'MultipoleWrapper', + 'CylSplineWrapper' + 'BurkertWrapper' +] + +# ============================================================================ + +cdef class HenonHeilesWrapper(CPotentialWrapper): + + def __init__(self, G, _, q0, R): + self.init([G], + np.ascontiguousarray(q0), + np.ascontiguousarray(R), + n_dim=2) + self.cpotential.value[0] = (henon_heiles_value) + self.cpotential.gradient[0] = (henon_heiles_gradient) + self.cpotential.hessian[0] = (henon_heiles_hessian) + + +# ============================================================================ +# Spherical models +# +cdef class KeplerWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (kepler_value) + self.cpotential.density[0] = (kepler_density) + self.cpotential.gradient[0] = (kepler_gradient) + self.cpotential.hessian[0] = (kepler_hessian) + + +cdef class IsochroneWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (isochrone_value) + self.cpotential.density[0] = (isochrone_density) + self.cpotential.gradient[0] = (isochrone_gradient) + self.cpotential.hessian[0] = (isochrone_hessian) + + +cdef class HernquistWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (hernquist_value) + self.cpotential.density[0] = (hernquist_density) + self.cpotential.gradient[0] = (hernquist_gradient) + self.cpotential.hessian[0] = (hernquist_hessian) + + +cdef class PlummerWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (plummer_value) + self.cpotential.density[0] = (plummer_density) + self.cpotential.gradient[0] = (plummer_gradient) + self.cpotential.hessian[0] = (plummer_hessian) + + +cdef class JaffeWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (jaffe_value) + self.cpotential.density[0] = (jaffe_density) + self.cpotential.gradient[0] = (jaffe_gradient) + self.cpotential.hessian[0] = (jaffe_hessian) + + +cdef class StoneWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (stone_value) + self.cpotential.density[0] = (stone_density) + self.cpotential.gradient[0] = (stone_gradient) + self.cpotential.hessian[0] = (stone_hessian) + + +cdef class PowerLawCutoffWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + + if USE_GSL == 1: + self.cpotential.value[0] = (powerlawcutoff_value) + self.cpotential.density[0] = (powerlawcutoff_density) + self.cpotential.gradient[0] = (powerlawcutoff_gradient) + self.cpotential.hessian[0] = (powerlawcutoff_hessian) + +cdef class BurkertWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (burkert_value) + self.cpotential.density[0] = (burkert_density) + self.cpotential.gradient[0] = (burkert_gradient) + + +# ============================================================================ +# Flattened, axisymmetric models +# +cdef class SatohWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (satoh_value) + self.cpotential.density[0] = (satoh_density) + self.cpotential.gradient[0] = (satoh_gradient) + self.cpotential.hessian[0] = (satoh_hessian) + + +cdef class KuzminWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (kuzmin_value) + self.cpotential.density[0] = (kuzmin_density) + self.cpotential.gradient[0] = (kuzmin_gradient) + self.cpotential.hessian[0] = (null_hessian) + + +cdef class MiyamotoNagaiWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (miyamotonagai_value) + self.cpotential.density[0] = (miyamotonagai_density) + self.cpotential.gradient[0] = (miyamotonagai_gradient) + self.cpotential.hessian[0] = (miyamotonagai_hessian) + + +cdef class MN3ExponentialDiskWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (mn3_value) + self.cpotential.density[0] = (mn3_density) + self.cpotential.gradient[0] = (mn3_gradient) + self.cpotential.hessian[0] = (mn3_hessian) + + +# ============================================================================ +# Triaxial models +# + +cdef class SphericalNFWWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (sphericalnfw_value) + self.cpotential.density[0] = (sphericalnfw_density) + self.cpotential.gradient[0] = (sphericalnfw_gradient) + self.cpotential.hessian[0] = (sphericalnfw_hessian) + +cdef class FlattenedNFWWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (flattenednfw_value) + self.cpotential.gradient[0] = (flattenednfw_gradient) + self.cpotential.hessian[0] = (flattenednfw_hessian) + +cdef class TriaxialNFWWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (triaxialnfw_value) + self.cpotential.gradient[0] = (triaxialnfw_gradient) + self.cpotential.hessian[0] = (triaxialnfw_hessian) + + +cdef class LogarithmicWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (logarithmic_value) + self.cpotential.gradient[0] = (logarithmic_gradient) + self.cpotential.hessian[0] = (logarithmic_hessian) + self.cpotential.density[0] = (logarithmic_density) + + +cdef class LeeSutoTriaxialNFWWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (leesuto_value) + self.cpotential.density[0] = (leesuto_density) + self.cpotential.gradient[0] = (leesuto_gradient) + + +cdef class LongMuraliBarWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (longmuralibar_value) + self.cpotential.gradient[0] = (longmuralibar_gradient) + self.cpotential.density[0] = (longmuralibar_density) + self.cpotential.hessian[0] = (longmuralibar_hessian) + + +# ============================================================================== +# Special +# +cdef class NullWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G], + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + self.cpotential.value[0] = (null_value) + self.cpotential.density[0] = (null_density) + self.cpotential.gradient[0] = (null_gradient) + self.cpotential.hessian[0] = (null_hessian) + self.cpotential.null = 1 + + +# ============================================================================== +# Multipole and flexible potential models +# +cdef class MultipoleWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + + if USE_GSL == 1: + self.cpotential.value[0] = (mp_potential) + self.cpotential.density[0] = (mp_density) + self.cpotential.gradient[0] = (mp_gradient) + + +cdef class CylSplineWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + + if USE_GSL == 1: + self.cpotential.value[0] = (axisym_cylspline_value) + self.cpotential.gradient[0] = (axisym_cylspline_gradient) + self.cpotential.density[0] = (axisym_cylspline_density) + #self.cpotential.hessian[0] = (axisym_cylspline_hessian) + + +# ============================================================================ +# Spherical spline interpolated potentials +# + +cdef class SphericalSplineWrapper(CPotentialWrapper): + """Wrapper for spherical spline interpolated potentials""" + + cdef spherical_spline_state spl_state + cdef double *r_knots_copy + cdef double *values_copy + cdef str spline_value_type + + def __init__( + self, G, parameters, q0, R, spline_value_type, interpolation_method, n_knots + ): + """ + Parameters + ---------- + spline_value_type : str + Type of values provided: "density", "mass", or "potential" + interpolation_method : str + Interpolation method to use. Names from GSL (e.g., cspline, linear, akima, etc.). + """ + self.spline_value_type = spline_value_type + + method_to_enum = { + "linear": 0, + "polynomial": 1, + "cspline": 2, + "cspline_periodic": 3, + "akima": 4, + "akima_periodic": 5, + "steffen": 6, + } + + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + + # Set the state pointer to our spline state + # This must be done BEFORE _setup_spline_state since that function initializes + # the GSL objects that are stored in spl_state + self.cpotential.state[0] = &self.spl_state + + self._setup_spline_state( + parameters, + method=method_to_enum[interpolation_method], + n_knots=n_knots + ) + + if USE_GSL == 1: + if self.spline_value_type == "density": + self.cpotential.value[0] = (spherical_spline_density_value) + self.cpotential.gradient[0] = (spherical_spline_density_gradient) + self.cpotential.density[0] = (spherical_spline_density_density) + elif self.spline_value_type == "mass": + self.cpotential.value[0] = (spherical_spline_mass_value) + self.cpotential.gradient[0] = (spherical_spline_mass_gradient) + self.cpotential.density[0] = (spherical_spline_mass_density) + elif self.spline_value_type == "potential": + self.cpotential.value[0] = (spherical_spline_potential_value) + self.cpotential.gradient[0] = (spherical_spline_potential_gradient) + self.cpotential.density[0] = (spherical_spline_potential_density) + else: + raise ValueError( + f"Unknown value_type: {self.spline_value_type}. Must be 'density', " + "'mass', or 'potential'" + ) + + cdef void _setup_spline_state(self, parameters, method, n_knots): + """Setup the cached GSL spline state""" + # Copy parameter arrays to ensure they stay alive + self.r_knots_copy = malloc(n_knots * sizeof(double)) + self.values_copy = malloc(n_knots * sizeof(double)) + + # Temporary arrays for density spline setup + cdef double *rho_r_values = malloc(n_knots * sizeof(double)) + cdef double *rho_r2_values = malloc(n_knots * sizeof(double)) + + cdef int i + for i in range(n_knots): + self.r_knots_copy[i] = parameters[i] + self.values_copy[i] = parameters[i + n_knots] + + # Set up state struct + self.spl_state.n_knots = n_knots + self.spl_state.method = method + self.spl_state.r_knots = self.r_knots_copy + self.spl_state.values = self.values_copy + + # Select GSL interpolation type + cdef const gsl_interp_type *interp_type + if method == 0: + interp_type = gsl_interp_linear + elif method == 1: + interp_type = gsl_interp_polynomial + elif method == 2: + interp_type = gsl_interp_cspline + elif method == 3: + interp_type = gsl_interp_cspline_periodic + elif method == 4: + interp_type = gsl_interp_akima + elif method == 5: + interp_type = gsl_interp_akima_periodic + elif method == 6: + interp_type = gsl_interp_steffen + else: + raise ValueError(f"Unknown interpolation method, index = {method}") + + # Create GSL objects + self.spl_state.acc = gsl_interp_accel_alloc() + if self.spl_state.acc == NULL: + raise RuntimeError("Failed to allocate GSL interpolation accelerator") + + self.spl_state.spline = gsl_spline_alloc(interp_type, n_knots) + if self.spl_state.spline == NULL: + raise RuntimeError(f"Failed to allocate GSL spline with method {method} and {n_knots} knots") + + cdef int init_status = gsl_spline_init( + self.spl_state.spline, self.r_knots_copy, self.values_copy, n_knots + ) + if init_status != 0: + raise RuntimeError(f"Failed to initialize GSL spline, error code: {init_status}") + + # For density interpolation, need additional splines for efficient integration + if self.spline_value_type == "density": + # Create rho(r) * r spline for potential calculations + for i in range(n_knots): + rho_r_values[i] = self.values_copy[i] * self.r_knots_copy[i] + + self.spl_state.rho_r_acc = gsl_interp_accel_alloc() + self.spl_state.rho_r_spline = gsl_spline_alloc(interp_type, n_knots) + gsl_spline_init( + self.spl_state.rho_r_spline, self.r_knots_copy, rho_r_values, n_knots + ) + + # Create rho(r) * r**2 spline for gradient calculations + for i in range(n_knots): + rho_r2_values[i] = self.values_copy[i] * self.r_knots_copy[i] * self.r_knots_copy[i] + + self.spl_state.rho_r2_acc = gsl_interp_accel_alloc() + self.spl_state.rho_r2_spline = gsl_spline_alloc(interp_type, n_knots) + gsl_spline_init( + self.spl_state.rho_r2_spline, self.r_knots_copy, rho_r2_values, n_knots + ) + + else: + # For non-density types, set auxiliary splines to NULL + self.spl_state.rho_r_spline = NULL + self.spl_state.rho_r2_spline = NULL + self.spl_state.rho_r_acc = NULL + self.spl_state.rho_r2_acc = NULL + + # Clean up temporary arrays + free(rho_r_values) + free(rho_r2_values) + + def __reduce__(self): + """Support for pickling/deepcopy""" + return ( + self.__class__, + ( + self._params[0], # G + list(self._params[1:]), # parameters + np.array(self._q0), # q0 + np.array(self._R).reshape(self.cpotential.n_dim, self.cpotential.n_dim), # R + self.spline_value_type, # spline_value_type + # Reconstruct interpolation_method from the stored enum + ["linear", "polynomial", "cspline", "cspline_periodic", + "akima", "akima_periodic", "steffen"][self.spl_state.method], + self.spl_state.n_knots # n_knots + ) + ) + + def __dealloc__(self): + """Clean up GSL objects and allocated memory""" + if USE_GSL == 1: + if self.spl_state.spline != NULL: + gsl_spline_free(self.spl_state.spline) + if self.spl_state.acc != NULL: + gsl_interp_accel_free(self.spl_state.acc) + if self.spl_state.rho_r_spline != NULL: + gsl_spline_free(self.spl_state.rho_r_spline) + if self.spl_state.rho_r_acc != NULL: + gsl_interp_accel_free(self.spl_state.rho_r_acc) + if self.spl_state.rho_r2_spline != NULL: + gsl_spline_free(self.spl_state.rho_r2_spline) + if self.spl_state.rho_r2_acc != NULL: + gsl_interp_accel_free(self.spl_state.rho_r2_acc) + + if self.r_knots_copy != NULL: + free(self.r_knots_copy) + if self.values_copy != NULL: + free(self.values_copy) diff --git a/gala/source/src/gala/potential/potential/builtin/cyexp.pyx b/gala/source/src/gala/potential/potential/builtin/cyexp.pyx new file mode 100644 index 0000000000000000000000000000000000000000..26af7d01aed7ba40522e93f9d60983bcb45701b8 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/cyexp.pyx @@ -0,0 +1,163 @@ +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ +# cython: c_string_type=unicode, c_string_encoding=utf8 +# cython: cpp_locals=True +# cython: initializedcheck=True + +import numpy as np +cimport numpy as np +np.import_array() + +from libcpp.string cimport string +from libcpp.memory cimport shared_ptr +from libcpp cimport bool as cbool + +from cpython.pycapsule cimport PyCapsule_GetPointer + +from ..cpotential cimport CPotentialWrapper +from ..cpotential cimport densityfunc, energyfunc, gradientfunc, hessianfunc +from ...._cconfig cimport USE_EXP + + +cdef extern from "EXP/Coefficients.H" namespace "CoefClasses": + cdef cppclass Coefs: + pass + ctypedef shared_ptr[Coefs] CoefsPtr + +cdef extern from "EXP/BiorthBasis.H" namespace "BasisClasses": + cdef cppclass Basis: + pass + ctypedef shared_ptr[Basis] BasisPtr + + +cdef extern from "potential/potential/builtin/exp_fields.h" namespace "gala_exp": + cdef cppclass State: + double tmin + double tmax + cbool is_static + + State exp_init( + const string &config, + const string &coeffile, + int stride, + double tmin, + double tmax, + int snapshot_index, + double snapshot_time_factor + ) except + nogil + + State pyexp_init( + BasisPtr *basis_ptr, + CoefsPtr *coefs_ptr, + double snapshot_time_factor + ) except + nogil + +cdef extern from "potential/potential/builtin/exp_fields.h": + # Note: the 'except +' annotations here don't actually do anything, since these functions + # are not (currently) called directly from Cython/Python. But they serve as a reminder that + # any cdef extern function that may throw a C++ exception must carry this annotation. + + double exp_value(double t, double *pars, double *q, int n_dim, void *state) except + nogil + void exp_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) except + nogil + double exp_density(double t, double *pars, double *q, int n_dim, void *state) except + nogil + +__all__ = [ + 'EXPWrapper', +] + +# ============================================================================== +# EXP potential +# + +cdef class EXPWrapper(CPotentialWrapper): + cdef State exp_state + + def __init__( + self, G, parameters, q0, R, + config_file, coef_file, stride, snapshot_index, snapshot_time_factor + ): + tmin = parameters[0] + tmax = parameters[1] + + self.init( + [G], + np.ascontiguousarray(q0), + np.ascontiguousarray(R) + ) + + if USE_EXP == 1: + self.exp_state = exp_init( + str(config_file), + str(coef_file), + stride, + tmin, + tmax, + snapshot_index, + snapshot_time_factor + ) + self.cpotential.state[0] = &self.exp_state + self.cpotential.value[0] = (exp_value) + self.cpotential.density[0] = (exp_density) + self.cpotential.gradient[0] = (exp_gradient) + + + @property + def static(self): + return self.exp_state.is_static + + @property + def tmin(self): + return self.exp_state.tmin + + @property + def tmax(self): + return self.exp_state.tmax + + +cdef class PyEXPWrapper(CPotentialWrapper): + cdef State exp_state + + def __init__( + self, G, parameters, q0, R, + basis_capsule, coefs_capsule, snapshot_time_factor + ): + cdef BasisPtr *basis_ptr + cdef CoefsPtr *coefs_ptr + + self.init( + [G], + np.ascontiguousarray(q0), + np.ascontiguousarray(R) + ) + + if USE_EXP == 1: + basis_ptr = PyCapsule_GetPointer(basis_capsule, "BiorthBasis_shared_ptr") + coefs_ptr = PyCapsule_GetPointer(coefs_capsule, "Coefs_shared_ptr") + + self.exp_state = pyexp_init( + basis_ptr, + coefs_ptr, + snapshot_time_factor + ) + self.cpotential.state[0] = &self.exp_state + self.cpotential.value[0] = (exp_value) + self.cpotential.density[0] = (exp_density) + self.cpotential.gradient[0] = (exp_gradient) + + + @property + def static(self): + return self.exp_state.is_static + + @property + def tmin(self): + return self.exp_state.tmin + + @property + def tmax(self): + return self.exp_state.tmax diff --git a/gala/source/src/gala/potential/potential/builtin/cytimeinterp.pyx b/gala/source/src/gala/potential/potential/builtin/cytimeinterp.pyx new file mode 100644 index 0000000000000000000000000000000000000000..9b6df6398ae3264d4500f45c801e7c2b6f8e89e2 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/cytimeinterp.pyx @@ -0,0 +1,330 @@ +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + +import numpy as np +cimport numpy as np +np.import_array() + +from libc.stdlib cimport malloc, free +from libc.stdint cimport uintptr_t + +from ..cpotential cimport CPotentialWrapper +from ..cpotential cimport densityfunc, energyfunc, gradientfunc, hessianfunc +from ...._cconfig cimport USE_GSL + +# Time interpolation state structure and GSL types (declared in time_interp.h) +cdef extern from "time_interp.h": + # Forward declaration of GSL type (defined in time_interp.h) + ctypedef struct gsl_interp_type: + pass + + # GSL interpolation type pointers - these will only link if USE_GSL==1 + gsl_interp_type * gsl_interp_linear + gsl_interp_type * gsl_interp_cspline + gsl_interp_type * gsl_interp_akima + gsl_interp_type * gsl_interp_steffen + +cdef extern from "time_interp.h": + ctypedef struct TimeInterpParam: + int is_constant + double constant_value + int n_knots + + ctypedef struct TimeInterpRotation: + int is_constant + double constant_matrix[9] + + ctypedef struct TimeInterpState: + TimeInterpParam *params + TimeInterpParam origin + TimeInterpRotation rotation + int n_params + int n_dim + const gsl_interp_type *interp_type + double t_min + double t_max + void *wrapped_potential + + TimeInterpState* time_interp_alloc(int n_params, int n_dim, const gsl_interp_type *interp_type) + void time_interp_free(TimeInterpState *state) + int time_interp_init_param(TimeInterpParam *param, double *time_knots, double *values, + int n_knots, int n_elements, const gsl_interp_type *interp_type) + int time_interp_init_constant_param(TimeInterpParam *param, double *constant_values, int n_elements) + int time_interp_init_rotation(TimeInterpRotation *rot, double *time_knots, double *matrices, + int n_knots, const gsl_interp_type *interp_type) + int time_interp_init_constant_rotation(TimeInterpRotation *rot, double *matrix) + +cdef extern from "time_interp_wrapper.h": + double time_interp_value(double t, double *pars, double *q, int n_dim, void *state) nogil + void time_interp_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + double time_interp_density(double t, double *pars, double *q, int n_dim, void *state) nogil + void time_interp_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) nogil + +__all__ = ['TimeInterpolatedWrapper'] + + +cdef class TimeInterpolatedWrapper(CPotentialWrapper): + """ + Cython wrapper for time-interpolated potentials. + """ + cdef TimeInterpState *interp_state + cdef CPotentialWrapper wrapped_potential + cdef np.ndarray time_knots + cdef np.ndarray c_only_params + cdef object params # dict of arrays (n_knots, ...) + cdef np.ndarray origins # array (n_knots, 3) + cdef np.ndarray rotation_matrices # array (n_knots, 3, 3) + cdef list interp_params + cdef dict param_element_counts + cdef str interpolation_method + + def __init__( + self, + double G, + CPotentialWrapper wrapped_potential, + double[::1] time_knots, + list interp_params, + dict params, + dict param_element_counts, + double[::1] c_only_params, + double[:, ::1] origins, + double[:, :, ::1] rotation_matrices, + str interpolation_method='cspline' + ): + """ + Initialize time-interpolated potential wrapper. + + Parameters + ---------- + wrapped_potential : CPotentialWrapper + The potential to wrap with time interpolation + time_knots : array_like + Time values for interpolation knots + params : dict + Dictionary mapping parameter names to arrays of values at each time knot + param_element_counts : dict + Dictionary mapping parameter names to number of elements per parameter + c_only_params : array_like + C-only parameters (e.g., nmax, lmax for SCF) that are prepended to regular params + origins : array_like + Array of origin vectors at each time knot, shape (n_knots, n_dim) + rotation_matrices : array_like + Array of rotation matrices at each time knot, shape (n_knots, n_dim, n_dim) + interpolation_method : str, optional + Interpolation type: 'linear', 'cubic', 'akima', or 'steffen'. Default is + linear interpolation + """ + if USE_GSL != 1: + raise RuntimeError( + "TimeInterpolatedPotential requires GSL support. Please install GSL " + "and rebuild gala with GSL support to use this potential." + ) + + self.wrapped_potential = wrapped_potential + self.interp_params = interp_params + self.interpolation_method = interpolation_method + + # We need to keep these references because they may not be stored on the parent + # potential instance + self.time_knots = np.array(time_knots, dtype=np.float64, order='C', copy=True) + self.params = { + k: np.array(v, dtype=np.float64, order='C', copy=True) + for k, v in params.items() + } + self.param_element_counts = param_element_counts + self.c_only_params = np.array( + c_only_params, dtype=np.float64, order='C', copy=True + ) + self.origins = np.array(origins, dtype=np.float64, order='C', copy=True) + self.rotation_matrices = np.array( + rotation_matrices, dtype=np.float64, order='C', copy=True + ) + + cdef: + int n_knots = len(time_knots) + int n_dim = 3 # required + int n_c_only = len(c_only_params) + # n_params is the number of TimeInterpParam objects, which might contain + # array-valued parameters TODO: check this is true and safe + int n_params = 1 + n_c_only + len(params) + + const gsl_interp_type *gsl_interp_type_ptr + double[::1] time_knots_view = self.time_knots + double[::1] c_only_params_view = self.c_only_params + double[::1] param_values_view + double[::1] origins_flat = np.ravel(self.origins) + double[::1] rotations_flat = np.ravel(self.rotation_matrices) + double rotation_matrix[9] # one instance of rotation matrix + double origin_val # temporary for passing address of scalar origin + int param_idx, i, j, result + int n_elements + + if interpolation_method == 'linear': + gsl_interp_type_ptr = gsl_interp_linear + elif interpolation_method == 'cspline': + gsl_interp_type_ptr = gsl_interp_cspline + elif interpolation_method == 'akima': + gsl_interp_type_ptr = gsl_interp_akima + elif interpolation_method == 'steffen': + gsl_interp_type_ptr = gsl_interp_steffen + else: + msg = f"Unknown interpolation method: {interpolation_method}" + raise ValueError(msg) + + if USE_GSL == 1: + # interpolation state maintains parameter, rotation matrix, and origin + # interpolators + self.interp_state = time_interp_alloc(n_params, n_dim, gsl_interp_type_ptr) + if self.interp_state == NULL: + msg = "Failed to allocate interpolation state" + raise MemoryError(msg) + + # Set time bounds - class enforces monotonic increasing knots, so just take + # first and last values + self.interp_state.t_min = time_knots[0] + self.interp_state.t_max = time_knots[n_knots - 1] + + # By convention in Gala, G is always at index 0, and c_only parameters + # (e.g., nmax, lmax for SCF) follow, then the potential parameters in + # order as defined on each class. Index starts at 1 because G is at index 0 + + # Initialize G (index 0) - always constant + result = time_interp_init_constant_param( + &self.interp_state.params[0], &G, 1 + ) + if result != 0: + raise RuntimeError("Failed to initialize G parameter") + + # Initialize c_only parameters (e.g., nmax, lmax for SCF) - always constant + param_idx = 1 + for i in range(n_c_only): + result = time_interp_init_constant_param( + &self.interp_state.params[param_idx], &c_only_params_view[i], 1 + ) + if result != 0: + raise RuntimeError( + f"Failed to initialize c_only parameter at index {i}" + ) + param_idx += 1 + + # Initialize regular parameters + for param_name, param_values in self.params.items(): + param_values_view = np.ravel(param_values) + n_elements = param_element_counts.get(param_name, 1) + + if param_name not in interp_params: # constant parameter + # For constant parameters, pass pointer to first element + result = time_interp_init_constant_param( + &self.interp_state.params[param_idx], ¶m_values_view[0], n_elements + ) + + else: # time-interpolated parameter + result = time_interp_init_param( + &self.interp_state.params[param_idx], + &time_knots_view[0], ¶m_values_view[0], n_knots, n_elements, gsl_interp_type_ptr + ) + + if result != 0: + raise RuntimeError(f"Failed to initialize parameter {param_name}") + + param_idx += 1 + + # Initialize origin as a single multi-element parameter (n_elements = n_dim) + # This always comes in as a 2D array. If it's constant, axis=0 has length 1. + + if self.origins.shape[0] == 1: # constant + result = time_interp_init_constant_param( + &self.interp_state.origin, &origins_flat[0], n_dim + ) + + elif self.origins.shape[0] == n_knots: # time-interpolated + result = time_interp_init_param( + &self.interp_state.origin, + &time_knots_view[0], &origins_flat[0], n_knots, n_dim, + gsl_interp_type_ptr + ) + else: + msg = ( + f"Origin array has wrong shape: expected " + f"({1 if origins.shape[0] == 1 else n_knots}, {n_dim})" + ) + raise ValueError(msg) + + if result != 0: + raise RuntimeError(f"Failed to initialize origin") + + # Initialize rotation matrix interpolators + # This always comes in as a 3D array. If it's constant, axis=0 has length 1. + result = 0 + + if self.rotation_matrices.shape[0] == 1: # constant + time_interp_init_constant_rotation( + &self.interp_state.rotation, &rotations_flat[0] + ) + + elif self.rotation_matrices.shape[0] == n_knots: # time-varying + time_interp_init_rotation( + &self.interp_state.rotation, + &time_knots_view[0], &rotations_flat[0], + n_knots, gsl_interp_type_ptr + ) + else: + raise ValueError( + "Rotation matrices array has wrong shape " + f"{rotation_matrices.shape}" + ) + + # Pointer to the temporary wrapped potential + self.interp_state.wrapped_potential = self.wrapped_potential.cpotential + + self.init( + [0.0], # value of G doesn't matter for this wrapper + np.zeros(n_dim, dtype=np.float64), # placeholder origin + np.eye(n_dim, dtype=np.float64), # placeholder rotation + n_dim=n_dim + ) + + # Set up function pointers (only if GSL is available) + if USE_GSL == 1: + self.cpotential.value[0] = time_interp_value + self.cpotential.gradient[0] = time_interp_gradient + self.cpotential.density[0] = time_interp_density + self.cpotential.hessian[0] = time_interp_hessian + + # Store interpolation state in the state pointer + self.cpotential.state[0] = self.interp_state + + def __dealloc__(self): + if self.interp_state != NULL: + time_interp_free(self.interp_state) + self.interp_state = NULL + + def __reduce__(self): + """Support for pickling/deepcopy""" + return ( + self.__class__, + ( + self._params[0], # G + self.wrapped_potential, + self.time_knots, + self.interp_params, + self.params, + self.param_element_counts, + self.c_only_params, + self.origins, + self.rotation_matrices, + self.interpolation_method + ) + ) + + @property + def time_bounds(self): + """Get the time bounds for interpolation.""" + if self.interp_state != NULL: + return (self.interp_state.t_min, self.interp_state.t_max) + return None diff --git a/gala/source/src/gala/potential/potential/builtin/exp_fields.cc b/gala/source/src/gala/potential/potential/builtin/exp_fields.cc new file mode 100644 index 0000000000000000000000000000000000000000..45ea0080e629b46bc4a9eb92b2e436339475c097 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/exp_fields.cc @@ -0,0 +1,345 @@ +#include "extra_compile_macros.h" + +#if USE_EXP == 1 + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +// EXP headers +#include +#include +#include +#include + +#include "exp_fields.h" +#include "src/vectorization.h" + +namespace gala_exp { + +State pyexp_init( + BasisClasses::BasisPtr *basis_ptr, + CoefClasses::CoefsPtr *coefs_ptr, + double snapshot_time_factor +) { + if (!basis_ptr) { + throw std::runtime_error("pyexp_init: basis pointer is null"); + } + + if (!coefs_ptr) { + throw std::runtime_error("pyexp_init: coefs pointer is null"); + } + + if (!*basis_ptr) { + throw std::runtime_error("pyexp_init: basis is null"); + } + + if (!*coefs_ptr) { + throw std::runtime_error("pyexp_init: coefs is null"); + } + + auto biorth_basis( + std::dynamic_pointer_cast( + *basis_ptr + ) + ); + if (!biorth_basis) { + throw std::runtime_error("pyEXP Basis must be a BiorthBasis."); + } + + return { biorth_basis, *coefs_ptr, snapshot_time_factor, -1 }; +} + +State exp_init( + const std::string &config_fn, const std::string &coeffile, + int stride, double tmin, double tmax, int snapshot_index, double snapshot_time_factor) +{ + YAML::Node yaml = YAML::LoadFile(std::string(config_fn)); + + auto load_basis = [](auto yaml, auto config_fn) -> auto + { + BasisClasses::BasisPtr base_basis; + { + // change the cwd to the directory of the config file + // so that relative paths in the config file work + // TODO: this is not thread-safe, threads share a cwd + ScopedChdir cd(fs::path(config_fn).parent_path()); + + base_basis = BasisClasses::Basis::factory(yaml); + } + + if (!base_basis) { + std::ostringstream error_msg; + error_msg << "Failed to load basis from config file: " << config_fn; + throw std::runtime_error(error_msg.str()); + } + return base_basis; + }; + + auto biorth_basis( + std::dynamic_pointer_cast( + load_basis(yaml, config_fn) + ) + ); + if (!biorth_basis) { + std::ostringstream error_msg; + error_msg << "Basis in config file " << config_fn << " must be a BiorthBasis."; + throw std::runtime_error(error_msg.str()); + } + + auto coefs = CoefClasses::Coefs::factory(coeffile, + stride, tmin, tmax); + + if(!coefs) { + std::ostringstream error_msg; + error_msg << "Failed to load coefficients from file: " << coeffile; + throw std::runtime_error(error_msg.str()); + } + + try { + // Turn the "pure virtual" error in a more informative message + // TODO: is there a better way to "validate" the Coefs object? + coefs->Times(); + } catch (const std::runtime_error& e) { + std::ostringstream error_msg; + error_msg << "Failed to load coefficients from file: " << coeffile + << ". Error: " << e.what(); + throw std::runtime_error(error_msg.str()); + } + + if(coefs->Times().empty()) { + std::ostringstream error_msg; + error_msg << "No times in coeffile=" << coeffile + << " within tmin=" << tmin + << " and tmax=" << tmax + << " (raw EXP snapshot time units)."; + throw std::runtime_error(error_msg.str()); + } + + return { biorth_basis, coefs, snapshot_time_factor, snapshot_index }; +} + +State::State( + BiorthBasisPtr basis_, + CoefClasses::CoefsPtr coefs_, + double snapshot_time_factor_, + int snapshot_index) + : basis(basis_), + coefs(coefs_), + snapshot_time_factor(snapshot_time_factor_) { + + try { + // Turn the "pure virtual" error in a more informative message + // TODO: is there a better way to "validate" the Coefs object? + coefs->Times(); + } catch (const std::runtime_error& e) { + std::ostringstream error_msg; + error_msg << "Failed to fetch Times from Coefs object. " + << "Is this a valid, non-empty Coefs instance? " + << "Error: " << e.what(); + throw std::runtime_error(error_msg.str()); + } + + if(coefs->Times().empty()) { + throw std::runtime_error("No times in coefficients."); + } + + if (coefs->Times().size() == 1 && snapshot_index < 0) { + // If there is only one loaded snapshot in the coefs, + // we treat it as static + snapshot_index = 0; + } + + bool is_static = false; + double tmin, tmax; + + if (snapshot_index >= 0) { + const auto& times = coefs->Times(); + if (snapshot_index >= times.size()) { + std::ostringstream error_msg; + error_msg << "Invalid snapshot_index: " << snapshot_index + << ". Valid indices are in [0," << (times.size() - 1) << "]" + << " (times [" << times.front() << ", " << times.back() << "])" + << " (raw EXP snapshot time units)."; + throw std::runtime_error(error_msg.str()); + } + tmin = times[snapshot_index]; + tmax = tmin; + + basis->set_coefs(coefs->getCoefStruct(tmin)); + is_static = true; + } else { + // Adjust tmin and tmax to the first and last times in the coefficients + + auto times = coefs->Times(); + tmin = times.front(); + tmax = times.back(); + + is_static = (tmax == tmin); + + if (is_static) { + basis->set_coefs(gala_exp::interpolator(tmin, coefs)); + } + } + + this->is_static = is_static; + this->tmin = tmin; + this->tmax = tmax; +} + +// Linear interpolator on coefficients. Higher order interpolation +// could be implemented similarly. This is the same implementation +// used in BiorthBasis and probably belongs in CoefClasses . . . +// +CoefClasses::CoefStrPtr interpolator(double t, CoefClasses::CoefsPtr coefs) +{ + // This routine requires at least two snapshots to interpolate + assert(coefs->Times().size() >= 2); + + // Interpolate coefficients + // + auto times = coefs->Times(); + + if (ttimes.back()) { + std::ostringstream sout; + sout << "FieldWrapper::interpolator: time t=" << t << " is out of bounds: [" + << times.front() << ", " << times.back() << "] (raw EXP snapshot time units)"; + throw std::runtime_error(sout.str()); + } + + auto it1 = std::lower_bound(times.begin(), times.end(), t); + auto it2 = it1 + 1; + + if (it2 == times.end()) { + it2--; + it1 = it2 - 1; + } + + // Handle degenerate case where it1 == it2 (single time entry) + if (it1 == it2 || *it1 == *it2) { + return coefs->getCoefStruct(*it1); + } + + double a = (*it2 - t)/(*it2 - *it1); + double b = (t - *it1)/(*it2 - *it1); + + auto coefsA = coefs->getCoefStruct(*it1); + auto coefsB = coefs->getCoefStruct(*it2); + + // Duplicate a coefficient instance. Shared pointer for proper + // garbage collection. + // + auto newcoef = coefsA->deepcopy(); + + // Now interpolate the matrix + // + newcoef->time = t; + + auto & cN = newcoef->store; + auto & cA = coefsA->store; + auto & cB = coefsB->store; + + for (int i=0; istore.size(); i++) + cN(i) = a * cA(i) + b * cB(i); + + // Interpolate the center data + // + if (coefsA->ctr.size() and coefsB->ctr.size()) { + newcoef->ctr.resize(3); + for (int k=0; k<3; k++) + newcoef->ctr[k] = a * coefsA->ctr[k] + b * coefsB->ctr[k]; + } + + return newcoef; +} + +} + +/* --------------------------------------------------------------------------- + EXP potential + + Calls the EXP code (https://github.com/exp-code/exp). + Only available if EXP available at build time. +*/ + +double exp_value(double t, double *pars, double *q, int n_dim, void* state) { + gala_exp::State *exp_state = static_cast(state); + + if (!exp_state->is_static) { + // TODO: how expensive is this, actually? + exp_state->basis->set_coefs( + gala_exp::interpolator(t * exp_state->snapshot_time_factor, exp_state->coefs) + ); + } + + // Get the field quantities + // TODO: ask Martin/Mike for a way to compute only the potential - we're wasting + // computation time here by computing all fields + auto field = exp_state->basis->getFields(q[0], q[1], q[2]); + + return field[5]; +} + +void exp_gradient(double t, double *__restrict__ pars, double *__restrict__ q_in, int n_dim, size_t N, double *__restrict__ grad_in, void *__restrict__ state){ + gala_exp::State *exp_state = static_cast(state); + + if (!exp_state->is_static) { + exp_state->basis->set_coefs( + gala_exp::interpolator(t * exp_state->snapshot_time_factor, exp_state->coefs) + ); + } + + double6ptr q = double6ptr{q_in, N}; + double6ptr grad = double6ptr{grad_in, N}; + + Eigen::Map eigen_x(q.x, N); + Eigen::Map eigen_y(q.y, N); + Eigen::Map eigen_z(q.z, N); + + auto& allaccel = exp_state->basis->getAccel(eigen_x, eigen_y, eigen_z); + + for(size_t i = 0; i < N; i++) { + grad.x[i] -= allaccel(i, 0); + grad.y[i] -= allaccel(i, 1); + grad.z[i] -= allaccel(i, 2); + } + +} + +double exp_density(double t, double *pars, double *q, int n_dim, void* state) { + gala_exp::State *exp_state = static_cast(state); + + if (!exp_state->is_static) { + exp_state->basis->set_coefs( + gala_exp::interpolator(t * exp_state->snapshot_time_factor, exp_state->coefs) + ); + } + + // TODO: ask Martin/Mike for a way to compute only the density - we're wasting + // computation time here by computing all fields + auto field = exp_state->basis->getFields(q[0], q[1], q[2]); + + return field[2]; +} + +// TODO: No hessian available in EXP yet +// void exp_hessian(double t, double *pars, double *q, int n_dim, double *hess, void* state) { +// gala_exp::State *exp_state = static_cast(state); + +// if (!exp_state->is_static) { +// exp_state->basis->set_coefs( +// gala_exp::interpolator(t * exp_state->snapshot_time_factor, exp_state->coefs) +// ); +// } + +// auto field = exp_state->basis->getFields(q[0], q[1], q[2]); + +// for(int i=0; i<9; i++) { +// hess[i] += NAN; // TODO: get hessian from EXP +// } +// } + +#endif // USE_EXP diff --git a/gala/source/src/gala/potential/potential/builtin/exp_fields.h b/gala/source/src/gala/potential/potential/builtin/exp_fields.h new file mode 100644 index 0000000000000000000000000000000000000000..de9e342fef63a722c4f8c1274b6f3eba99a47a9d --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/exp_fields.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include + +#include +#include + +namespace gala_exp { + +using BiorthBasisPtr = shared_ptr; + +class State { +public: + BiorthBasisPtr basis; + CoefClasses::CoefsPtr coefs; + double snapshot_time_factor; + double tmin; + double tmax; + bool is_static; + + State( + BiorthBasisPtr basis_, + CoefClasses::CoefsPtr coefs_, + double snapshot_time_factor_, + int snapshot_index); +}; + +State exp_init( + const std::string &config, + const std::string &coeffile, + int stride, + double tmin, + double tmax, + int snapshot_index, + double snapshot_time_factor +); + +State pyexp_init( + BasisClasses::BasisPtr *basis_ptr, + CoefClasses::CoefsPtr *coefs_ptr, + double snapshot_time_factor +); + +CoefClasses::CoefStrPtr interpolator(double t, CoefClasses::CoefsPtr coefs); + +} + +extern double exp_value(double t, double *pars, double *q, int n_dim, void* state); +extern void exp_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double exp_density(double t, double *pars, double *q, int n_dim, void* state); + +class ScopedChdir { +private: + std::filesystem::path original_path; + bool empty; + +public: + inline explicit ScopedChdir(const std::filesystem::path& new_path) { + empty = new_path.empty(); + + if(!empty){ + original_path = std::filesystem::current_path(); + std::filesystem::current_path(new_path); + } + } + + inline ~ScopedChdir() { + if (empty) return; + try { + std::filesystem::current_path(original_path); + } catch (...) { + // Can't throw in destructor + } + } + + ScopedChdir(const ScopedChdir&) = delete; + ScopedChdir& operator=(const ScopedChdir&) = delete; +}; diff --git a/gala/source/src/gala/potential/potential/builtin/multipole.cpp b/gala/source/src/gala/potential/potential/builtin/multipole.cpp new file mode 100644 index 0000000000000000000000000000000000000000..533f742efda45c9a8ea3adaa8e1910d095390d1f --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/multipole.cpp @@ -0,0 +1,830 @@ +/* +copied from https://github.com/adrn/gala/blob/main/gala/potential/scf/src/bfe.c +and https://github.com/adrn/gala/blob/main/gala/potential/scf/src/bfe_helper.c +*/ +#include +#include +#include "extra_compile_macros.h" +#include +#include +#include "src/vectorization.h" + +#define SQRT_FOURPI 3.544907701811031 + +#if USE_GSL == 1 + +#include "gsl/gsl_sf_legendre.h" +#include "gsl/gsl_sf_gegenbauer.h" +#include "gsl/gsl_sf_gamma.h" +#include +#include +#include + +/* -------------------------------------------------------------------------- + + Low-level helper functions + +*/ + +/* + Density +*/ +double mp_rho_l_outer(double r, int l) { + return l * (l+1) * pow(r, -(l+3)); +} + +double mp_rho_l_inner(double r, int l) { + return l * (l+1) * pow(r, l-2); +} + +double mp_rho_lm(double r, double phi, double X, int l, int m, int inner) { + if (inner > 0) { + return mp_rho_l_inner(r, l) * gsl_sf_legendre_sphPlm(l, m, X); + } else { + return mp_rho_l_outer(r, l) * gsl_sf_legendre_sphPlm(l, m, X); + } +} + +/* + Potential +*/ +double mp_phi_l_outer(double r, int l) { + return pow(r, -(l + 1)); +} + +double mp_phi_l_inner(double r, int l) { + return pow(r, l); +} + +double mp_phi_lm(double r, double phi, double X, int l, int m, int inner) { + if (inner > 0) { + return mp_phi_l_inner(r, l) * gsl_sf_legendre_sphPlm(l, m, X); + } else { + return mp_phi_l_outer(r, l) * gsl_sf_legendre_sphPlm(l, m, X); + } +} + +/* + Gradient +*/ +void mp_sph_grad_phi_lm(double r, double phi, double X, int l, int m, + int lmax, int inner, + double *__restrict__ sphgradx, double *__restrict__ sphgrady, double *__restrict__ sphgradz) { + double A, dYlm_dtheta; + double dPhil_dr, dPhi_dphi, dPhi_dtheta; + + // spherical coord stuff + double sintheta = sqrt(1 - X*X); + + double Phi_l, Ylm, Plm, Pl1m; + Ylm = gsl_sf_legendre_sphPlm(l, m, X); + + // Correct: associated Legendre polynomial -- not sphPlm! + if (m <= l) { + Plm = gsl_sf_legendre_Plm(l, m, X); + } else { + Plm = 0.; + } + + if (inner > 0) { + Phi_l = mp_phi_l_inner(r, l); + dPhil_dr = l*pow(r, l-1) * Ylm; + } else { + Phi_l = mp_phi_l_outer(r, l); + dPhil_dr = -(l+1) * pow(r, -l-2) * Ylm; + } + + if (l==0) { + dYlm_dtheta = 0.; + } else { + // Correct: associated Legendre polynomial -- not sphPlm! + if (m <= (l-1)) { + Pl1m = gsl_sf_legendre_Plm(l-1, m, X); + } else { + Pl1m = 0.; + } + + if (l == m) { + A = sqrt(2*l+1) / SQRT_FOURPI * sqrt(1. / gsl_sf_gamma(l+m+1.)); + } else { + A = sqrt(2*l+1) / SQRT_FOURPI * sqrt(gsl_sf_gamma(l-m+1.) + / gsl_sf_gamma(l+m+1.)); + } + + // fixed at sintheta = 0 + if (sintheta != 0) { + dYlm_dtheta = A / sintheta * (l*X*Plm - (l+m)*Pl1m); + } else { + dYlm_dtheta = 0; + } + } + dPhi_dtheta = dYlm_dtheta * Phi_l / r; + + if (m == 0) { + dPhi_dphi = 0.; + } else { + dPhi_dphi = m; + } + dPhi_dphi *= Ylm * Phi_l; + + if (r > 0) { + *sphgradx = dPhil_dr; + *sphgrady = dPhi_dtheta; + *sphgradz = dPhi_dphi; + } else { + *sphgradx = 0; + *sphgrady = 0; + *sphgradz = 0; + } +} + +/* + High-level functions and helpers +*/ + +void mp_density_helper(double *xyz, int K, + double M, double r_s, + double *Slm, double *Tlm, + int lmax, int inner, double *dens) { + + int i,j,k, l,m; + double s, r, X, phi; + double cosmphi[lmax+1], sinmphi[lmax+1]; + memset(cosmphi, 0, (lmax+1)*sizeof(double)); + memset(sinmphi, 0, (lmax+1)*sizeof(double)); + for (k=0; k= gridR[0]) && (Rasinh <= gridR[nR-1]) && + (zasinh >= gridz[0]) && (zasinh <= gridz[nz-1])) { // Use CylSpline + + /* initialize interpolation */ + // TODO: define this in wrapper, make all CPotential's have a void + // pointer array to store things like this, all these functions then + // need to accept one more parameter (or is there a way to do optional + // args in C?), ??, profit. + gsl_spline2d_init(spline, gridR, gridz, gridPhi, nR, nz); + Phi = gsl_spline2d_eval(spline, Rasinh, zasinh, xacc, yacc); + + if (logScaling) + Phi = -exp(Phi); + + } else { // Use external Multipole + Phi = mp_potential(t, &pars[5 + nR + nz + nR * nz], q, n_dim); + } + + gsl_spline2d_free(spline); + gsl_interp_accel_free(xacc); + gsl_interp_accel_free(yacc); + + return Phi; +} + +void axisym_cylspline_gradient(double t, double *__restrict__ pars, double *__restrict__ q_in, int n_dim, size_t N, double *__restrict__ grad_in, void *__restrict__ state) { + + int logScaling = (int)pars[1]; + double Rscale = pars[2]; + int nR = (int)pars[3]; + int nz = (int)pars[4]; + + double gridR[nR]; + double gridz[nz]; + double gridPhi[nz * nR]; + for (int i=0; i < nR; i++) + gridR[i] = pars[5 + i]; + for (int i=0; i < nz; i++) + gridz[i] = pars[5 + nR + i]; + for (int i=0; i < nR; i++) + for (int j=0; j < nz; j++) + gridPhi[i * nz + j] = pars[5 + nR + nz + i * nz + j]; + + const gsl_interp2d_type *T = gsl_interp2d_bicubic; + gsl_spline2d *spline = gsl_spline2d_alloc(T, nR, nz); + gsl_interp_accel *xacc = gsl_interp_accel_alloc(); + gsl_interp_accel *yacc = gsl_interp_accel_alloc(); + + /* initialize interpolation */ + gsl_spline2d_init(spline, gridR, gridz, gridPhi, nR, nz); + + double6ptr q = double6ptr{q_in, N}; + double6ptr grad = double6ptr{grad_in, N}; + + for(size_t i = 0; i < N; i++) { + double R = sqrt(q.x[i]*q.x[i] + q.y[i]*q.y[i]); + double Rasinh = asinh(R / Rscale); + double zasinh = asinh(q.z[i] / Rscale); + + if ((Rasinh >= gridR[0]) && (Rasinh <= gridR[nR-1]) && + (zasinh >= gridz[0]) && (zasinh <= gridz[nz-1])) { // Use CylSpline + + double dPhi_dR = gsl_spline2d_eval_deriv_x(spline, Rasinh, zasinh, xacc, yacc); + dPhi_dR = dPhi_dR / (Rscale * cosh(Rasinh)); + + double dPhi_dz = gsl_spline2d_eval_deriv_y(spline, Rasinh, zasinh, xacc, yacc); + dPhi_dz = dPhi_dz / (Rscale * cosh(zasinh)); + + if (logScaling) { + double Phi = gsl_spline2d_eval(spline, Rasinh, zasinh, xacc, yacc); + Phi = -exp(Phi); + dPhi_dR = dPhi_dR * Phi; + dPhi_dz = dPhi_dz * Phi; + } + + if (R > 0) { + grad.x[i] += dPhi_dR * q.x[i] / R; + grad.y[i] += dPhi_dR * q.y[i] / R; + grad.z[i] += dPhi_dz; + } else { + grad.z[i] += dPhi_dz; + } + + } else { // Use external Multipole + _mp_gradient_single(t, &pars[5 + nR + nz + nR * nz], double6ptr{q_in + i, N}, n_dim, double6ptr{grad_in + i, N}, state); + } + } + + gsl_spline2d_free(spline); + gsl_interp_accel_free(xacc); + gsl_interp_accel_free(yacc); +} + +double axisym_cylspline_density(double t, double *pars, double *q, int n_dim) { + double G = pars[0]; + int logScaling = (int)pars[1]; + double Rscale = pars[2]; + int nR = (int)pars[3]; + int nz = (int)pars[4]; + + return 0.0/0.0; + + /* TODO: bug in the below... */ + + double dens; + double Phi, dPhi_dR, dPhi_dz, d2Phi_dR2, d2Phi_dz2; + double R = sqrt(q[0]*q[0] + q[1]*q[1]); + double Rasinh = asinh(R / Rscale); + double zasinh = asinh(q[2] / Rscale); + + double gridR[nR]; + double gridz[nz]; + double gridPhi[nz * nR]; + for (int i=0; i < nR; i++) + gridR[i] = pars[5 + i]; + for (int i=0; i < nz; i++) + gridz[i] = pars[5 + nR + i]; + for (int i=0; i < nR; i++) + for (int j=0; j < nz; j++) + gridPhi[i * nz + j] = pars[5 + nR + nz + i * nz + j]; + + const gsl_interp2d_type *T = gsl_interp2d_bicubic; + gsl_spline2d *spline = gsl_spline2d_alloc(T, nR, nz); + gsl_interp_accel *xacc = gsl_interp_accel_alloc(); + gsl_interp_accel *yacc = gsl_interp_accel_alloc(); + + // TODO: interpolation is very slow I think because this setup is done every + // time the function is called... + + if ((Rasinh >= gridR[0]) && (Rasinh <= gridR[nR-1]) && + (zasinh >= gridz[0]) && (zasinh <= gridz[nz-1])) { // Use CylSpline + + /* initialize interpolation */ + // TODO: define this in wrapper, make all CPotential's have a void + // pointer array to store things like this, all these functions then + // need to accept one more parameter (or is there a way to do optional + // args in C?), ??, profit. + gsl_spline2d_init(spline, gridR, gridz, gridPhi, nR, nz); + + dPhi_dR = gsl_spline2d_eval_deriv_x(spline, Rasinh, zasinh, xacc, yacc); + dPhi_dR = dPhi_dR / (Rscale * cosh(Rasinh)); + + dPhi_dz = gsl_spline2d_eval_deriv_y(spline, Rasinh, zasinh, xacc, yacc); + dPhi_dz = dPhi_dz / (Rscale * cosh(zasinh)); + + d2Phi_dR2 = gsl_spline2d_eval_deriv_xx(spline, Rasinh, zasinh, xacc, yacc); + d2Phi_dR2 = d2Phi_dR2 / pow(Rscale * cosh(Rasinh), 2); + + d2Phi_dz2 = gsl_spline2d_eval_deriv_yy(spline, Rasinh, zasinh, xacc, yacc); + d2Phi_dz2 = d2Phi_dz2 / pow(Rscale * cosh(zasinh), 2); + + if (logScaling) { + Phi = gsl_spline2d_eval(spline, Rasinh, zasinh, xacc, yacc); + Phi = -exp(Phi); + dPhi_dR = dPhi_dR * Phi; + d2Phi_dR2 = (d2Phi_dR2 + pow(dPhi_dR / Phi, 2)) * Phi; + + dPhi_dz = dPhi_dz * Phi; + d2Phi_dz2 = (d2Phi_dz2 + pow(dPhi_dz / Phi, 2)) * Phi; + } + + dens = (dPhi_dR / R + d2Phi_dR2 + d2Phi_dz2) / (4 * M_PI * G); + + } else { // Use external Multipole + dens = mp_density(t, &pars[5 + nR + nz + nR * nz], q, n_dim); + } + gsl_spline2d_free(spline); + gsl_interp_accel_free(xacc); + gsl_interp_accel_free(yacc); + + return dens; +} + +#endif // USE_GSL diff --git a/gala/source/src/gala/potential/potential/builtin/multipole.h b/gala/source/src/gala/potential/potential/builtin/multipole.h new file mode 100644 index 0000000000000000000000000000000000000000..ee3f9731ad92c3c1c1744461a3da664f26198234 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/multipole.h @@ -0,0 +1,28 @@ +#include + +extern double mp_potential(double t, double *pars, double *q, int n_dim); +extern double mp_density(double t, double *pars, double *q, int n_dim); +extern void mp_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); + +extern double mpetd_potential(double t, double *pars, double *q, int n_dim); +extern double mpetd_density(double t, double *pars, double *q, int n_dim); +extern void mpetd_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); + +extern void mp_density_helper(double *xyz, int K, + double M, double r_s, + double *anlm, double *bnlm, + int lmax, int inner, double *dens); + +extern void mp_potential_helper(double *xyz, int K, + double G, double M, double r_s, + double *anlm, double *bnlm, + int lmax, int inner, double *val); + +extern double mp_rho_lm(double r, double phi, double X, int l, int m, int inner); +extern double mp_phi_lm(double r, double phi, double X, int l, int m, int inner); +extern void mp_sph_grad_phi_lm(double r, double phi, double X, int l, int m, int lmax, int inner, double *sphgrad); + + +extern double axisym_cylspline_value(double t, double *pars, double *q, int n_dim); +extern void axisym_cylspline_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double axisym_cylspline_density(double t, double *pars, double *q, int n_dim); diff --git a/gala/source/src/gala/potential/potential/builtin/potential_helpers.h b/gala/source/src/gala/potential/potential/builtin/potential_helpers.h new file mode 100644 index 0000000000000000000000000000000000000000..ac42f39236402890fb779df2565a0f0dbf9440a8 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/potential_helpers.h @@ -0,0 +1,35 @@ +#include +#include "src/vectorization.h" + +static inline double norm2_sq(const double *q) { + return q[0]*q[0] + q[1]*q[1]; +} + +static inline double norm2(const double *q) { + return sqrt(norm2_sq(q)); +} + +static inline double norm3_sq(const double *q) { + return q[0]*q[0] + q[1]*q[1] + q[2]*q[2]; +} + +static inline double norm3(const double *q) { + return sqrt(norm3_sq(q)); +} + +static inline double norm3_flat_z(const double *q, const double qz) { + return sqrt(q[0]*q[0] + q[1]*q[1] + q[2]*q[2]/(qz*qz)); +} + +// Helper functions for computing norms with double6ptr +static inline double norm3_sq(const double6ptr& q) { + return (*q.x) * (*q.x) + (*q.y) * (*q.y) + (*q.z) * (*q.z); +} + +static inline double norm3(const double6ptr& q) { + return sqrt(norm3_sq(q)); +} + +static inline double norm3_flat_z(const double6ptr& q, const double qz) { + return sqrt((*q.x) * (*q.x) + (*q.y) * (*q.y) + (*q.z) * (*q.z) / (qz * qz)); +} diff --git a/gala/source/src/gala/potential/potential/builtin/pybuiltin.py b/gala/source/src/gala/potential/potential/builtin/pybuiltin.py new file mode 100644 index 0000000000000000000000000000000000000000..c4a1fa5c4f02f928564fd37d1f0586e3ed4a1383 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/pybuiltin.py @@ -0,0 +1,98 @@ +import numpy as np + +from gala.potential.common import PotentialParameter +from gala.potential.potential.core import PotentialBase +from gala.potential.potential.util import sympy_wrap + +__all__ = ["HarmonicOscillatorPotential"] + + +class HarmonicOscillatorPotential(PotentialBase): + r""" + Represents an N-dimensional harmonic oscillator. + + .. math:: + + \Phi = \frac{1}{2}\omega^2 x^2 + + Parameters + ---------- + omega : numeric + Frequency. + units : iterable(optional) + Unique list of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + """ + + omega = PotentialParameter( + "omega", physical_type="frequency", ndim=1, convert=np.atleast_1d + ) + + def _setup_potential( + self, parameters, parameter_is_default, origin=None, R=None, units=None + ): + parameters["omega"] = np.atleast_1d(parameters["omega"]) + super()._setup_potential( + parameters, parameter_is_default, origin=origin, R=R, units=units + ) + self.ndim = len(self.parameters["omega"]) + + def _energy(self, q, t=0.0): + om = np.atleast_1d(self.parameters["omega"].value) + return np.sum(0.5 * om[None] ** 2 * q**2, axis=1) + + def _gradient(self, q, t=0.0): + om = self.parameters["omega"].value + om = np.atleast_2d(om).T + return om**2 * q + + def _hessian(self, q, t=0.0): + om = np.atleast_1d(self.parameters["omega"].value) + return np.tile(np.diag(om)[:, :, None], reps=(1, 1, q.shape[0])) + + @classmethod + @sympy_wrap(var="x") + def to_sympy(cls, v, p): + expr = 1 / 2 * p["omega"] ** 2 * v["x"] ** 2 + return expr, v, p + + def action_angle(self, w): + """ + Transform the input cartesian position and velocity to action-angle + coordinates the Harmonic Oscillator potential. This transformation + is analytic and can be used as a "toy potential" in the + Sanders & Binney 2014 formalism for computing action-angle coordinates + in _any_ potential. + + Adapted from Jason Sanders' code + `genfunc `_. + + Parameters + ---------- + w : :class:`gala.dynamics.PhaseSpacePosition`, :class:`gala.dynamics.Orbit` + The positions or orbit to compute the actions, angles, and frequencies at. + """ + from gala.dynamics.actionangle import harmonic_oscillator_xv_to_aa + + return harmonic_oscillator_xv_to_aa(w, self) + + # def phase_space(self, actions, angles): + # """ + # Transform the input action-angle coordinates to cartesian position and velocity + # assuming a Harmonic Oscillator potential. This transformation + # is analytic and can be used as a "toy potential" in the + # Sanders & Binney 2014 formalism for computing action-angle coordinates + # in _any_ potential. + + # Adapted from Jason Sanders' code + # `genfunc `_. + + # Parameters + # ---------- + # x : array_like + # Positions. + # v : array_like + # Velocities. + # """ + # from gala.dynamics.actionangle import harmonic_oscillator_aa_to_xv + # return harmonic_oscillator_aa_to_xv(actions, angles, self) diff --git a/gala/source/src/gala/potential/potential/builtin/special.py b/gala/source/src/gala/potential/potential/builtin/special.py new file mode 100644 index 0000000000000000000000000000000000000000..6bfed4bd4b0d33829ddeb8b23b4b3ffb54e7b16e --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/special.py @@ -0,0 +1,382 @@ +import warnings + +import astropy.units as u +import numpy as np + +from gala.potential.potential.builtin.core import ( + HernquistPotential, + LogarithmicPotential, + MiyamotoNagaiPotential, + MN3ExponentialDiskPotential, + NFWPotential, + PowerLawCutoffPotential, +) +from gala.potential.potential.ccompositepotential import CCompositePotential +from gala.units import galactic +from gala.util import GalaFutureWarning + +__all__ = [ + "BovyMWPotential2014", + "LM10Potential", + "MilkyWayPotential", + "MilkyWayPotential2022", +] + + +class LM10Potential(CCompositePotential): + """ + The Galactic potential used by Law and Majewski (2010) to represent + the Milky Way as a three-component sum of disk, bulge, and halo. + + The disk potential is an axisymmetric + :class:`~gala.potential.MiyamotoNagaiPotential`, the bulge potential + is a spherical :class:`~gala.potential.HernquistPotential`, and the + halo potential is a triaxial :class:`~gala.potential.LogarithmicPotential`. + + Default parameters are fixed to those found in LM10 by fitting N-body + simulations to the Sagittarius stream. + + Parameters + ---------- + units : `~gala.units.UnitSystem` (optional) + Set of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + disk : dict (optional) + Parameters to be passed to the :class:`~gala.potential.MiyamotoNagaiPotential`. + bulge : dict (optional) + Parameters to be passed to the :class:`~gala.potential.HernquistPotential`. + halo : dict (optional) + Parameters to be passed to the :class:`~gala.potential.LogarithmicPotential`. + + Note: in subclassing, order of arguments must match order of potential + components added at bottom of init. + """ + + def __init__(self, units=galactic, disk=None, bulge=None, halo=None): + if halo is None: + halo = {} + if bulge is None: + bulge = {} + if disk is None: + disk = {} + default_disk = {"m": 1e11 * u.Msun, "a": 6.5 * u.kpc, "b": 0.26 * u.kpc} + default_bulge = {"m": 3.4e10 * u.Msun, "c": 0.7 * u.kpc} + default_halo = { + "q1": 1.38, + "q2": 1.0, + "q3": 1.36, + "r_h": 12.0 * u.kpc, + "phi": 97 * u.degree, + "v_c": np.sqrt(2) * 121.858 * u.km / u.s, + } + + for k, v in default_disk.items(): + if k not in disk: + disk[k] = v + + for k, v in default_bulge.items(): + if k not in bulge: + bulge[k] = v + + for k, v in default_halo.items(): + if k not in halo: + halo[k] = v + + super().__init__() + + self["disk"] = MiyamotoNagaiPotential(units=units, **disk) + self["bulge"] = HernquistPotential(units=units, **bulge) + self["halo"] = LogarithmicPotential(units=units, **halo) + self.lock = True + + +# ============================================================================ +# Gala MilkyWayPotential +# + + +def _setup_mwp_v1(obj, units, **kwargs): + default_disk = {"m": 6.8e10 * u.Msun, "a": 3.0 * u.kpc, "b": 0.28 * u.kpc} + default_bulge = {"m": 5e9 * u.Msun, "c": 1.0 * u.kpc} + default_nucl = {"m": 1.71e9 * u.Msun, "c": 0.07 * u.kpc} + default_halo = {"m": 5.4e11 * u.Msun, "r_s": 15.62 * u.kpc} + + disk = kwargs.get("disk", {}) + bulge = kwargs.get("bulge", {}) + halo = kwargs.get("halo", {}) + nucleus = kwargs.get("nucleus", {}) + + for k, v in default_disk.items(): + disk.setdefault(k, v) + + for k, v in default_bulge.items(): + bulge.setdefault(k, v) + + for k, v in default_halo.items(): + halo.setdefault(k, v) + + for k, v in default_nucl.items(): + nucleus.setdefault(k, v) + + obj["disk"] = MiyamotoNagaiPotential(units=units, **disk) + obj["bulge"] = HernquistPotential(units=units, **bulge) + obj["nucleus"] = HernquistPotential(units=units, **nucleus) + obj["halo"] = NFWPotential(units=units, **halo) + + +def _setup_mwp_2022(obj, units, **kwargs): + default_disk = {"m": 4.7717e10 * u.Msun, "h_R": 2.6 * u.kpc, "h_z": 0.3 * u.kpc} + default_bulge = {"m": 5e9 * u.Msun, "c": 1.0 * u.kpc} + default_nucl = {"m": 1.8142e9 * u.Msun, "c": 0.0688867 * u.kpc} + default_halo = {"m": 5.5427e11 * u.Msun, "r_s": 15.626 * u.kpc} + + disk = kwargs.get("disk", {}) + halo = kwargs.get("halo", {}) + bulge = kwargs.get("bulge", {}) + nucleus = kwargs.get("nucleus", {}) + + for k, v in default_disk.items(): + disk.setdefault(k, v) + + for k, v in default_bulge.items(): + bulge.setdefault(k, v) + + for k, v in default_halo.items(): + halo.setdefault(k, v) + + for k, v in default_nucl.items(): + nucleus.setdefault(k, v) + + obj["disk"] = MN3ExponentialDiskPotential(units=units, **disk) + obj["bulge"] = HernquistPotential(units=units, **bulge) + obj["nucleus"] = HernquistPotential(units=units, **nucleus) + obj["halo"] = NFWPotential(units=units, **halo) + + +class MilkyWayPotential(CCompositePotential): + """ + A simple mass-model for the Milky Way consisting of a spherical nucleus and + bulge, a Miyamoto-Nagai disk, and a spherical NFW dark matter halo. + + The disk model is taken from `Bovy (2015) + `_ - if you + use this potential, please also cite that work. + + Default parameters are fixed by fitting to a compilation of recent mass + measurements of the Milky Way, from 10 pc to ~150 kpc. + + Parameters + ---------- + units : `~gala.units.UnitSystem` (optional) + Set of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + disk : dict (optional) + Parameters to be passed to the :class:`~gala.potential.MiyamotoNagaiPotential`. + bulge : dict (optional) + Parameters to be passed to the :class:`~gala.potential.HernquistPotential`. + halo : dict (optional) + Parameters to be passed to the :class:`~gala.potential.NFWPotential`. + nucleus : dict (optional) + Parameters to be passed to the :class:`~gala.potential.HernquistPotential`. + + Note: in subclassing, order of arguments must match order of potential + components added at bottom of init. + """ + + _extra_serialize_args = ["version"] + + def __init__(self, version=None, units=galactic, **kwargs): + super().__init__() + + # TODO: remove when MilkyWayPotential API changes + if version is None: + warnings.warn( + "In a future version of Gala, the current MilkyWayPotential and " + "MilkyWayPotential2022 classes will be combined into a single class, " + "MilkyWayPotential, with an optional 'version' argument to select " + "between the models. To use the old (version 1) MilkyWayPotential, " + 'specify version="v1" when creating an instance. To use the newer ' + '(version 2 = current MilkyWayPotential2022), specify version="v2".', + GalaFutureWarning, + ) + version = "v1" + + self.version = str(version).lower() + + if self.version in ("latest", "v2"): + _setup_mwp_2022(self, units, **kwargs) + + elif self.version == "v1": + _setup_mwp_v1(self, units, **kwargs) + + else: + raise ValueError( + f"Invalid MilkyWayPotential version: {version}. Supported values are: " + "(v1, v2, latest)" + ) + + self.lock = True + + +class MilkyWayPotential2022(CCompositePotential): + """ + A mass-model for the Milky Way consisting of a spherical nucleus and bulge, a + 3-component sum of Miyamoto-Nagai disks to represent an exponential disk, and a + spherical NFW dark matter halo. + + The disk model is fit to the Eilers et al. 2019 rotation curve for the radial + dependence, and the shape of the phase-space spiral in the solar neighborhood is + used to set the vertical structure in Darragh-Ford et al. 2023. + + Other parameters are fixed by fitting to a compilation of recent mass measurements + of the Milky Way, from 10 pc to ~150 kpc. + + Parameters + ---------- + units : `~gala.units.UnitSystem` (optional) + Set of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + disk : dict (optional) + Parameters to be passed to the + :class:`~gala.potential.MN3ExponentialDiskPotential`. + bulge : dict (optional) + Parameters to be passed to the :class:`~gala.potential.HernquistPotential`. + halo : dict (optional) + Parameters to be passed to the :class:`~gala.potential.NFWPotential`. + nucleus : dict (optional) + Parameters to be passed to the :class:`~gala.potential.HernquistPotential`. + + Note: in subclassing, order of arguments must match order of potential + components added at bottom of init. + """ + + def __init__(self, units=galactic, disk=None, halo=None, bulge=None, nucleus=None): + super().__init__() + + # TODO: remove when MilkyWayPotential API changes + warnings.warn( + "The MilkyWayPotential2022 class will be deprecated soon. Instead, use: " + 'MilkyWayPotential(version="v2") to get what is currently the ' + "MilkyWayPotential2022 class. Or, to always use the latest Milky Way model " + "in Gala, you can call the class with no arguments MilkyWayPotential() or " + 'specify MilkyWayPotential(version="latest")', + GalaFutureWarning, + ) + disk = {} if disk is None else disk + halo = {} if halo is None else halo + bulge = {} if bulge is None else bulge + nucleus = {} if nucleus is None else nucleus + _setup_mwp_2022(self, units, disk=disk, halo=halo, bulge=bulge, nucleus=nucleus) + + self.lock = True + + +class BovyMWPotential2014(CCompositePotential): + """ + An implementation of the ``MWPotential2014`` + `from galpy `_ + and described in `Bovy (2015) + `_. + + This potential consists of a spherical bulge and dark matter halo, and a + Miyamoto-Nagai disk component. + + .. note:: + + Because it internally uses the PowerLawCutoffPotential, + this potential requires GSL to be installed, and Gala must have been + built and installed with GSL support enaled (the default behavior). + See http://gala.adrian.pw/en/latest/install.html for more information. + + Parameters + ---------- + units : `~gala.units.UnitSystem` (optional) + Set of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + disk : dict (optional) + Parameters to be passed to the :class:`~gala.potential.MiyamotoNagaiPotential`. + bulge : dict (optional) + Parameters to be passed to the :class:`~gala.potential.PowerLawCutoffPotential`. + halo : dict (optional) + Parameters to be passed to the :class:`~gala.potential.NFWPotential`. + + Note: in subclassing, order of arguments must match order of potential + components added at bottom of init. + """ + + def __init__(self, units=galactic, disk=None, halo=None, bulge=None): + default_disk = { + "m": 68193902782.346756 * u.Msun, + "a": 3.0 * u.kpc, + "b": 280 * u.pc, + } + default_bulge = { + "m": 4501365375.06545 * u.Msun, + "alpha": 1.8, + "r_c": 1.9 * u.kpc, + } + default_halo = {"m": 4.3683325e11 * u.Msun, "r_s": 16 * u.kpc} + + if disk is None: + disk = {} + + if halo is None: + halo = {} + + if bulge is None: + bulge = {} + + for k, v in default_disk.items(): + if k not in disk: + disk[k] = v + + for k, v in default_bulge.items(): + if k not in bulge: + bulge[k] = v + + for k, v in default_halo.items(): + if k not in halo: + halo[k] = v + + super().__init__() + + self["disk"] = MiyamotoNagaiPotential(units=units, **disk) + self["bulge"] = PowerLawCutoffPotential(units=units, **bulge) + self["halo"] = NFWPotential(units=units, **halo) + self.lock = True + + +# -------------------------------------------------------------------- +# class TriaxialMWPotential(CCompositePotential): + +# def __init__(self, units=galactic, +# disk=dict(), bulge=dict(), halo=dict()): +# """ Axis ratio values taken from Jing & Suto (2002). Other +# parameters come from a by-eye fit to Bovy's MW2014Potential. +# Choice of v_c sets circular velocity at Sun to 220 km/s +# """ + +# default_disk = dict(m=7E10, a=3.5, b=0.14) +# default_bulge = dict(m=1E10, c=1.1) +# default_halo = dict(a=1., b=0.75, c=0.55, +# v_c=0.239225, r_s=30., +# phi=0., theta=0., psi=0.) + +# for k, v in default_disk.items(): +# if k not in disk: +# disk[k] = v + +# for k, v in default_bulge.items(): +# if k not in bulge: +# bulge[k] = v + +# for k, v in default_halo.items(): +# if k not in halo: +# halo[k] = v + +# kwargs = dict() +# kwargs["disk"] = MiyamotoNagaiPotential(units=units, **disk) +# kwargs["bulge"] = HernquistPotential(units=units, **bulge) +# kwargs["halo"] = LeeSutoTriaxialNFWPotential(units=units, **halo) +# super(TriaxialMWPotential, self).__init__(**kwargs) +# -------------------------------------------------------------------- diff --git a/gala/source/src/gala/potential/potential/builtin/time_interp.cpp b/gala/source/src/gala/potential/potential/builtin/time_interp.cpp new file mode 100644 index 0000000000000000000000000000000000000000..5cb8525063268906e7fc4754121e4288963bc657 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/time_interp.cpp @@ -0,0 +1,554 @@ +#include "extra_compile_macros.h" + +#if USE_GSL == 1 + +#include +#include +#include +#include +#include "time_interp.h" + +TimeInterpState* time_interp_alloc(int n_params, int n_dim, const gsl_interp_type *interp_type) { + /* + Allocate and initialize a TimeInterpState structure + */ + TimeInterpState *state = (TimeInterpState*)calloc(1, sizeof(TimeInterpState)); + if (!state) return NULL; + + state->n_params = n_params; + state->n_dim = n_dim; + state->interp_type = interp_type; + state->t_min = 0.0; + state->t_max = 0.0; + + // Allocate parameter interpolators + if (n_params > 0) { + state->params = (TimeInterpParam*)calloc(n_params, sizeof(TimeInterpParam)); + if (!state->params) { + free(state); + return NULL; + } + } + + // Initialize origin as a single multi-element parameter (will be set up later) + memset(&state->origin, 0, sizeof(TimeInterpParam)); + + // Initialize rotation to identity/constant + state->rotation.is_constant = 1; + memset(state->rotation.constant_matrix, 0, 9 * sizeof(double)); + state->rotation.constant_matrix[0] = 1.0; // Identity matrix + state->rotation.constant_matrix[4] = 1.0; + state->rotation.constant_matrix[8] = 1.0; + + return state; +} + +void time_interp_free(TimeInterpState *state) { + /** + Free all allocated memory in TimeInterpState + */ + if (!state) return; + + // Free parameter interpolators + if (state->params) { + for (int i = 0; i < state->n_params; i++) { + // Free arrays of splines and accelerators + if (state->params[i].splines) { + for (int j = 0; j < state->params[i].n_elements; j++) { + if (state->params[i].splines[j]) gsl_spline_free(state->params[i].splines[j]); + } + free(state->params[i].splines); + } + if (state->params[i].accels) { + for (int j = 0; j < state->params[i].n_elements; j++) { + if (state->params[i].accels[j]) gsl_interp_accel_free(state->params[i].accels[j]); + } + free(state->params[i].accels); + } + if (state->params[i].time_knots) free(state->params[i].time_knots); + if (state->params[i].param_values) { + for (int j = 0; j < state->params[i].n_elements; j++) { + if (state->params[i].param_values[j]) free(state->params[i].param_values[j]); + } + free(state->params[i].param_values); + } + if (state->params[i].constant_values) free(state->params[i].constant_values); + } + free(state->params); + } + + // Free origin interpolator (now a single multi-element parameter) + if (state->origin.splines) { + for (int i = 0; i < state->origin.n_elements; i++) { + if (state->origin.splines[i]) { + gsl_spline_free(state->origin.splines[i]); + } + if (state->origin.accels[i]) { + gsl_interp_accel_free(state->origin.accels[i]); + } + } + free(state->origin.splines); + } + if (state->origin.accels) free(state->origin.accels); + if (state->origin.time_knots) free(state->origin.time_knots); + if (state->origin.param_values) { + for (int i = 0; i < state->origin.n_elements; i++) { + if (state->origin.param_values[i]) { + free(state->origin.param_values[i]); + } + } + free(state->origin.param_values); + } + if (state->origin.constant_values) free(state->origin.constant_values); + + // Free rotation interpolators + if (!state->rotation.is_constant) { + if (state->rotation.axis_x.splines && state->rotation.axis_x.splines[0]) { + gsl_spline_free(state->rotation.axis_x.splines[0]); + } + if (state->rotation.axis_x.splines) free(state->rotation.axis_x.splines); + if (state->rotation.axis_x.accels && state->rotation.axis_x.accels[0]) { + gsl_interp_accel_free(state->rotation.axis_x.accels[0]); + } + if (state->rotation.axis_x.accels) free(state->rotation.axis_x.accels); + if (state->rotation.axis_x.time_knots) free(state->rotation.axis_x.time_knots); + if (state->rotation.axis_x.param_values && state->rotation.axis_x.param_values[0]) { + free(state->rotation.axis_x.param_values[0]); + } + if (state->rotation.axis_x.param_values) free(state->rotation.axis_x.param_values); + + if (state->rotation.axis_y.splines && state->rotation.axis_y.splines[0]) { + gsl_spline_free(state->rotation.axis_y.splines[0]); + } + if (state->rotation.axis_y.splines) free(state->rotation.axis_y.splines); + if (state->rotation.axis_y.accels && state->rotation.axis_y.accels[0]) { + gsl_interp_accel_free(state->rotation.axis_y.accels[0]); + } + if (state->rotation.axis_y.accels) free(state->rotation.axis_y.accels); + if (state->rotation.axis_y.time_knots) free(state->rotation.axis_y.time_knots); + if (state->rotation.axis_y.param_values && state->rotation.axis_y.param_values[0]) { + free(state->rotation.axis_y.param_values[0]); + } + if (state->rotation.axis_y.param_values) free(state->rotation.axis_y.param_values); + + if (state->rotation.axis_z.splines && state->rotation.axis_z.splines[0]) { + gsl_spline_free(state->rotation.axis_z.splines[0]); + } + if (state->rotation.axis_z.splines) free(state->rotation.axis_z.splines); + if (state->rotation.axis_z.accels && state->rotation.axis_z.accels[0]) { + gsl_interp_accel_free(state->rotation.axis_z.accels[0]); + } + if (state->rotation.axis_z.accels) free(state->rotation.axis_z.accels); + if (state->rotation.axis_z.time_knots) free(state->rotation.axis_z.time_knots); + if (state->rotation.axis_z.param_values && state->rotation.axis_z.param_values[0]) { + free(state->rotation.axis_z.param_values[0]); + } + if (state->rotation.axis_z.param_values) free(state->rotation.axis_z.param_values); + + if (state->rotation.angle.splines && state->rotation.angle.splines[0]) { + gsl_spline_free(state->rotation.angle.splines[0]); + } + if (state->rotation.angle.splines) free(state->rotation.angle.splines); + if (state->rotation.angle.accels && state->rotation.angle.accels[0]) { + gsl_interp_accel_free(state->rotation.angle.accels[0]); + } + if (state->rotation.angle.accels) free(state->rotation.angle.accels); + if (state->rotation.angle.time_knots) free(state->rotation.angle.time_knots); + if (state->rotation.angle.param_values && state->rotation.angle.param_values[0]) { + free(state->rotation.angle.param_values[0]); + } + if (state->rotation.angle.param_values) free(state->rotation.angle.param_values); + } + + free(state); +} + +int time_interp_init_param( + TimeInterpParam *param, double *time_knots, double *values, + int n_knots, int n_elements, const gsl_interp_type *interp_type +) { + /* + Initialize a time-varying parameter interpolator with support for multi-element parameters. + + Input values should be in row-major order: shape (n_knots, n_elements) flattened to 1D. + values[0] = element 0 at time 0 + values[1] = element 1 at time 0 + ... + values[n_elements] = element 0 at time 1 + etc. + */ + if (!param || !time_knots || !values || n_knots < 2 || n_elements < 1) return -1; + + // Check if all values are constant (all elements, all times) + int is_constant = 1; + for (int elem = 0; elem < n_elements; elem++) { + for (int t = 1; t < n_knots; t++) { + if (fabs(values[t * n_elements + elem] - values[elem]) > 1e-15) { + is_constant = 0; + break; + } + } + if (!is_constant) break; + } + + if (is_constant || n_knots == 1) { + // Extract first time step values for constant case + double *const_vals = (double*)malloc(n_elements * sizeof(double)); + if (!const_vals) return -1; + for (int i = 0; i < n_elements; i++) { + const_vals[i] = values[i]; // First row + } + int result = time_interp_init_constant_param(param, const_vals, n_elements); + free(const_vals); + return result; + } + + param->is_constant = 0; + param->n_knots = n_knots; + param->n_elements = n_elements; + + // Allocate arrays for multi-element support + param->splines = (gsl_spline**)calloc(n_elements, sizeof(gsl_spline*)); + param->accels = (gsl_interp_accel**)calloc(n_elements, sizeof(gsl_interp_accel*)); + param->param_values = (double**)calloc(n_elements, sizeof(double*)); + + if (!param->splines || !param->accels || !param->param_values) { + if (param->splines) free(param->splines); + if (param->accels) free(param->accels); + if (param->param_values) free(param->param_values); + return -1; + } + + // Allocate shared time knots (same for all elements) + param->time_knots = (double*)malloc(n_knots * sizeof(double)); + if (!param->time_knots) { + free(param->splines); + free(param->accels); + free(param->param_values); + return -1; + } + memcpy(param->time_knots, time_knots, n_knots * sizeof(double)); + + // Initialize interpolator for each element + for (int elem = 0; elem < n_elements; elem++) { + // Allocate values array for this element + param->param_values[elem] = (double*)malloc(n_knots * sizeof(double)); + if (!param->param_values[elem]) { + // Cleanup on failure + for (int j = 0; j < elem; j++) { + if (param->param_values[j]) free(param->param_values[j]); + if (param->splines[j]) gsl_spline_free(param->splines[j]); + if (param->accels[j]) gsl_interp_accel_free(param->accels[j]); + } + free(param->time_knots); + free(param->splines); + free(param->accels); + free(param->param_values); + return -1; + } + + // Extract values for this element from row-major layout + for (int t = 0; t < n_knots; t++) { + param->param_values[elem][t] = values[t * n_elements + elem]; + } + + // Initialize GSL spline for this element + param->splines[elem] = gsl_spline_alloc(interp_type, n_knots); + param->accels[elem] = gsl_interp_accel_alloc(); + + if (!param->splines[elem] || !param->accels[elem]) { + // Cleanup on failure + if (param->splines[elem]) gsl_spline_free(param->splines[elem]); + if (param->accels[elem]) gsl_interp_accel_free(param->accels[elem]); + for (int j = 0; j <= elem; j++) { + if (param->param_values[j]) free(param->param_values[j]); + if (j < elem && param->splines[j]) gsl_spline_free(param->splines[j]); + if (j < elem && param->accels[j]) gsl_interp_accel_free(param->accels[j]); + } + free(param->time_knots); + free(param->splines); + free(param->accels); + free(param->param_values); + return -1; + } + + int status = gsl_spline_init(param->splines[elem], param->time_knots, + param->param_values[elem], n_knots); + if (status != GSL_SUCCESS) { + // Cleanup on failure + gsl_spline_free(param->splines[elem]); + gsl_interp_accel_free(param->accels[elem]); + for (int j = 0; j <= elem; j++) { + if (param->param_values[j]) free(param->param_values[j]); + if (j < elem && param->splines[j]) gsl_spline_free(param->splines[j]); + if (j < elem && param->accels[j]) gsl_interp_accel_free(param->accels[j]); + } + free(param->time_knots); + free(param->splines); + free(param->accels); + free(param->param_values); + return -1; + } + } + + param->constant_values = NULL; // Not used for time-varying + return 0; +} + +int time_interp_init_constant_param(TimeInterpParam *param, double *constant_values, int n_elements) { + /* + Initialize a constant parameter with support for multi-element parameters + */ + if (!param || !constant_values || n_elements < 1) return -1; + + // Clear all fields first + memset(param, 0, sizeof(TimeInterpParam)); + + // Set the constant flag and store values + param->is_constant = 1; + param->n_elements = n_elements; + + param->constant_values = (double*)malloc(n_elements * sizeof(double)); + if (!param->constant_values) return -1; + + memcpy(param->constant_values, constant_values, n_elements * sizeof(double)); + + // Explicitly set interpolation pointers to NULL for safety + param->splines = NULL; + param->accels = NULL; + param->time_knots = NULL; + param->param_values = NULL; + param->n_knots = 0; + + return 0; +} + +int time_interp_init_rotation( + TimeInterpRotation *rot, double *time_knots, double *matrices, + int n_knots, const gsl_interp_type *interp_type +) { + /* + Initialize rotation interpolation using axis-angle representation + */ + if (!rot || !time_knots || !matrices || n_knots < 1) return -1; + + if (n_knots == 1) { + return time_interp_init_constant_rotation(rot, matrices); + } + + // Check if all rotation matrices are the same + int is_constant = 1; + for (int i = 1; i < n_knots; i++) { + for (int j = 0; j < 9; j++) { + if (fabs(matrices[i*9 + j] - matrices[j]) > 1e-15) { + is_constant = 0; + break; + } + } + if (!is_constant) break; + } + + if (is_constant) { + return time_interp_init_constant_rotation(rot, matrices); + } + + rot->is_constant = 0; + + // Convert rotation matrices to axis-angle representation + double *axis_x_vals = (double*)malloc(n_knots * sizeof(double)); + double *axis_y_vals = (double*)malloc(n_knots * sizeof(double)); + double *axis_z_vals = (double*)malloc(n_knots * sizeof(double)); + double *angle_vals = (double*)malloc(n_knots * sizeof(double)); + + if (!axis_x_vals || !axis_y_vals || !axis_z_vals || !angle_vals) { + free(axis_x_vals); + free(axis_y_vals); + free(axis_z_vals); + free(angle_vals); + return -1; + } + + for (int i = 0; i < n_knots; i++) { + double axis[3], angle; + rotation_matrix_to_axis_angle(&matrices[i*9], axis, &angle); + axis_x_vals[i] = axis[0]; + axis_y_vals[i] = axis[1]; + axis_z_vals[i] = axis[2]; + angle_vals[i] = angle; + } + + // Initialize interpolators for each component (each is scalar, n_elements=1) + int status = 0; + status |= time_interp_init_param(&rot->axis_x, time_knots, axis_x_vals, n_knots, 1, interp_type); + status |= time_interp_init_param(&rot->axis_y, time_knots, axis_y_vals, n_knots, 1, interp_type); + status |= time_interp_init_param(&rot->axis_z, time_knots, axis_z_vals, n_knots, 1, interp_type); + status |= time_interp_init_param(&rot->angle, time_knots, angle_vals, n_knots, 1, interp_type); + + free(axis_x_vals); + free(axis_y_vals); + free(axis_z_vals); + free(angle_vals); + + return status; +} + +int time_interp_init_constant_rotation(TimeInterpRotation *rot, double *matrix) { + if (!rot || !matrix) return -1; + + memset(rot, 0, sizeof(TimeInterpRotation)); + rot->is_constant = 1; + memcpy(rot->constant_matrix, matrix, 9 * sizeof(double)); + + return 0; +} + +void time_interp_eval_param(const TimeInterpParam *param, double t, double *output_values) { + /* + Evaluate a parameter at time t for all elements. + + For constant parameters: copies constant_values to output_values + For interpolated parameters: evaluates each element's spline and writes to output_values + + output_values must be pre-allocated with size n_elements + */ + if (!param || !output_values) { + return; + } + + if (param->is_constant) { + // Copy constant values to output + if (!param->constant_values) { + // Safety check: if constant_values is NULL, fill with NAN + for (int i = 0; i < param->n_elements; i++) { + output_values[i] = NAN; + } + return; + } + memcpy(output_values, param->constant_values, param->n_elements * sizeof(double)); + return; + } + + // Interpolate each element + for (int elem = 0; elem < param->n_elements; elem++) { + // Add safety check for NULL spline before calling GSL + if (!param->splines[elem] || !param->accels[elem]) { + output_values[elem] = NAN; + } else { + output_values[elem] = gsl_spline_eval(param->splines[elem], t, param->accels[elem]); + } + } +} + +void time_interp_eval_rotation(const TimeInterpRotation *rot, double t, double *matrix) { + /* + Evaluate rotation matrix at time t + */ + if (!rot || !matrix) return; + + if (rot->is_constant) { + memcpy(matrix, rot->constant_matrix, 9 * sizeof(double)); + return; + } + + double axis[3]; + double angle_val; + + // Each rotation component is scalar (n_elements=1) + time_interp_eval_param(&rot->axis_x, t, &axis[0]); + time_interp_eval_param(&rot->axis_y, t, &axis[1]); + time_interp_eval_param(&rot->axis_z, t, &axis[2]); + time_interp_eval_param(&rot->angle, t, &angle_val); + + axis_angle_to_rotation_matrix(axis, angle_val, matrix); +} + +/* +Utility functions for rotation matrix <-> axis-angle conversion +*/ + +void rotation_matrix_to_axis_angle(const double *matrix, double *axis, double *angle) { + /* + Convert rotation matrix to axis-angle representation + */ + double trace = matrix[0] + matrix[4] + matrix[8]; + *angle = acos((trace - 1.0) / 2.0); + + if (fabs(*angle) < 1e-15) { + // Identity rotation + axis[0] = 1.0; + axis[1] = 0.0; + axis[2] = 0.0; + *angle = 0.0; + } else if (fabs(*angle - M_PI) < 1e-15) { + // 180 degree rotation - special case + double xx = (matrix[0] + 1.0) / 2.0; + double yy = (matrix[4] + 1.0) / 2.0; + double zz = (matrix[8] + 1.0) / 2.0; + double xy = matrix[1] / 2.0; + double xz = matrix[2] / 2.0; + double yz = matrix[5] / 2.0; + + if (xx > yy && xx > zz) { + axis[0] = sqrt(xx); + axis[1] = xy / axis[0]; + axis[2] = xz / axis[0]; + } else if (yy > zz) { + axis[1] = sqrt(yy); + axis[0] = xy / axis[1]; + axis[2] = yz / axis[1]; + } else { + axis[2] = sqrt(zz); + axis[0] = xz / axis[2]; + axis[1] = yz / axis[2]; + } + } else { + // General case + double sin_angle = sin(*angle); + axis[0] = (matrix[7] - matrix[5]) / (2.0 * sin_angle); + axis[1] = (matrix[2] - matrix[6]) / (2.0 * sin_angle); + axis[2] = (matrix[3] - matrix[1]) / (2.0 * sin_angle); + + // Normalize axis + double norm = sqrt(axis[0]*axis[0] + axis[1]*axis[1] + axis[2]*axis[2]); + if (norm > 1e-15) { + axis[0] /= norm; + axis[1] /= norm; + axis[2] /= norm; + } + } +} + +void axis_angle_to_rotation_matrix(const double *axis, double angle, double *matrix) { + /* + Convert axis-angle representation to rotation matrix + */ + double c = cos(angle); + double s = sin(angle); + double C = 1.0 - c; + double x = axis[0], y = axis[1], z = axis[2]; + + matrix[0] = x*x*C + c; + matrix[1] = x*y*C - z*s; + matrix[2] = x*z*C + y*s; + matrix[3] = y*x*C + z*s; + matrix[4] = y*y*C + c; + matrix[5] = y*z*C - x*s; + matrix[6] = z*x*C - y*s; + matrix[7] = z*y*C + x*s; + matrix[8] = z*z*C + c; +} + +int time_interp_check_bounds(const TimeInterpState *state, double t) { + /* + Check if time t is within bounds defined by the interpolation state + */ + if (!state) return -1; + + if (t < state->t_min || t > state->t_max) { + return -1; // Out of bounds + } + + return 0; // Within bounds +} + +#endif // USE_GSL diff --git a/gala/source/src/gala/potential/potential/builtin/time_interp.h b/gala/source/src/gala/potential/potential/builtin/time_interp.h new file mode 100644 index 0000000000000000000000000000000000000000..9cc6b887e1eaf6534089f03d723e4d6c32afc3fe --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/time_interp.h @@ -0,0 +1,108 @@ +#pragma once + +#include "extra_compile_macros.h" + +#if USE_GSL == 1 +#include +#include +#include +#else +// Forward declarations of GSL types when GSL is not available +typedef struct gsl_spline gsl_spline; +typedef struct gsl_interp_accel gsl_interp_accel; +typedef struct gsl_interp_type gsl_interp_type; + +// Dummy extern declarations for GSL interpolation types +// These allow the code to compile but will never be used due to runtime checks +extern gsl_interp_type* gsl_interp_linear; +extern gsl_interp_type* gsl_interp_cspline; +extern gsl_interp_type* gsl_interp_akima; +extern gsl_interp_type* gsl_interp_steffen; +#endif + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Structure to hold interpolation state for a single parameter +// Supports both scalar (n_elements=1) and multi-element array parameters +typedef struct { + gsl_spline **splines; // Array of splines (one per element) + gsl_interp_accel **accels; // Array of accelerators (one per element) + double *time_knots; // Shared time knots for all elements + double **param_values; // param_values[element][time_knot] + int n_knots; + int n_elements; // Number of elements in this parameter + int is_constant; + double *constant_values; // Array of constant values (length n_elements) +} TimeInterpParam; + +// Structure to hold rotation interpolation state using axis-angle representation +typedef struct { + TimeInterpParam axis_x; // x-component of rotation axis + TimeInterpParam axis_y; // y-component of rotation axis + TimeInterpParam axis_z; // z-component of rotation axis + TimeInterpParam angle; // rotation angle + int is_constant; + double constant_matrix[9]; // flattened 3x3 rotation matrix for constant case +} TimeInterpRotation; + +// Global state structure for time interpolation of potential parameters +typedef struct { + TimeInterpParam *params; // Array of parameter interpolators + TimeInterpParam origin; // Origin interpolator (n_elements = n_dim) + TimeInterpRotation rotation; // Rotation interpolator + void *wrapped_potential; // Pointer to the wrapped CPotential + int n_params; + int n_dim; + const gsl_interp_type *interp_type; // interpolation type (linear, cubic, etc.) + double t_min; // minimum time for bounds checking + double t_max; // maximum time for bounds checking +} TimeInterpState; + +#if USE_GSL == 1 +// Function prototypes (GSL available) +TimeInterpState* time_interp_alloc(int n_params, int n_dim, const gsl_interp_type *interp_type); +void time_interp_free(TimeInterpState *state); + +int time_interp_init_param(TimeInterpParam *param, double *time_knots, double *values, + int n_knots, int n_elements, const gsl_interp_type *interp_type); +int time_interp_init_constant_param(TimeInterpParam *param, double *constant_values, int n_elements); + +int time_interp_init_rotation(TimeInterpRotation *rot, double *time_knots, double *matrices, + int n_knots, const gsl_interp_type *interp_type); +int time_interp_init_constant_rotation(TimeInterpRotation *rot, double *matrix); + +void time_interp_eval_param(const TimeInterpParam *param, double t, double *output_values); +void time_interp_eval_rotation(const TimeInterpRotation *rot, double t, double *matrix); + +// Utility functions for rotation matrix <-> axis-angle conversion +void rotation_matrix_to_axis_angle(const double *matrix, double *axis, double *angle); +void axis_angle_to_rotation_matrix(const double *axis, double angle, double *matrix); + +// Bounds checking +int time_interp_check_bounds(const TimeInterpState *state, double t); +#else +// Dummy implementations when GSL is not available +static inline TimeInterpState* time_interp_alloc(int n_params, int n_dim, const gsl_interp_type *interp_type) { return NULL; } +static inline void time_interp_free(TimeInterpState *state) {} +static inline int time_interp_init_param(TimeInterpParam *param, double *time_knots, double *values, + int n_knots, int n_elements, const gsl_interp_type *interp_type) { return -1; } +static inline int time_interp_init_constant_param(TimeInterpParam *param, double *constant_values, int n_elements) { return -1; } +static inline int time_interp_init_rotation(TimeInterpRotation *rot, double *time_knots, double *matrices, + int n_knots, const gsl_interp_type *interp_type) { return -1; } +static inline int time_interp_init_constant_rotation(TimeInterpRotation *rot, double *matrix) { return -1; } +static inline void time_interp_eval_param(const TimeInterpParam *param, double t, double *output_values) {} +static inline void time_interp_eval_rotation(const TimeInterpRotation *rot, double t, double *matrix) {} +static inline void rotation_matrix_to_axis_angle(const double *matrix, double *axis, double *angle) {} +static inline void axis_angle_to_rotation_matrix(const double *axis, double angle, double *matrix) {} +static inline int time_interp_check_bounds(const TimeInterpState *state, double t) { return -1; } +#endif // USE_GSL == 1 + +#ifdef __cplusplus +} +#endif diff --git a/gala/source/src/gala/potential/potential/builtin/time_interp_wrapper.cpp b/gala/source/src/gala/potential/potential/builtin/time_interp_wrapper.cpp new file mode 100644 index 0000000000000000000000000000000000000000..17635a56d3c9faac4afe3a9a0c350f9ea83e10e0 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/time_interp_wrapper.cpp @@ -0,0 +1,323 @@ +#include "extra_compile_macros.h" + +#if USE_GSL == 1 + +#include "time_interp.h" +#include "time_interp_wrapper.h" +#include "../src/cpotential.h" +#include "src/vectorization.h" +#include +#include +#include + +extern "C" { + +// Helper function to interpolate all parameters, origin, and rotation at time t +static int time_interp_eval_all( + TimeInterpState *interp_state, double t, int n_dim, + double **interp_params_out, double **interp_origin_out, double **interp_rotation_out +) { + /* + Interpolate all state (parameters, origin, rotation) at time t. + Returns 0 on success, -1 on failure. + Caller is responsible for freeing the output arrays. + */ + if (!interp_state) return -1; + + // Calculate total number of parameter elements + int total_param_elements = 0; + for (int i = 0; i < interp_state->n_params; i++) { + total_param_elements += interp_state->params[i].n_elements; + } + + // Allocate and interpolate parameters + double *interp_params = (double*)malloc(total_param_elements * sizeof(double)); + if (!interp_params) return -1; + + int param_offset = 0; + for (int i = 0; i < interp_state->n_params; i++) { + int n_elem = interp_state->params[i].n_elements; + time_interp_eval_param(&interp_state->params[i], t, &interp_params[param_offset]); + + // Check for NaN + for (int j = 0; j < n_elem; j++) { + if (isnan(interp_params[param_offset + j])) { + free(interp_params); + return -1; + } + } + param_offset += n_elem; + } + + // Allocate and interpolate origin + double *interp_origin = (double*)malloc(n_dim * sizeof(double)); + if (!interp_origin) { + free(interp_params); + return -1; + } + + // Origin is now a single multi-element parameter + time_interp_eval_param(&interp_state->origin, t, interp_origin); + + // Check for NaN in origin + for (int i = 0; i < n_dim; i++) { + if (isnan(interp_origin[i])) { + free(interp_params); + free(interp_origin); + return -1; + } + } + + // Allocate and interpolate rotation + double *interp_rotation = (double*)malloc(n_dim * n_dim * sizeof(double)); + if (!interp_rotation) { + free(interp_params); + free(interp_origin); + return -1; + } + + time_interp_eval_rotation(&interp_state->rotation, t, interp_rotation); + + // Check for NaN in rotation matrix + for (int i = 0; i < n_dim * n_dim; i++) { + if (isnan(interp_rotation[i])) { + free(interp_params); + free(interp_origin); + free(interp_rotation); + return -1; + } + } + + *interp_params_out = interp_params; + *interp_origin_out = interp_origin; + *interp_rotation_out = interp_rotation; + return 0; +} + +// Time-interpolated potential evaluation function +double time_interp_value(double t, double *pars, double *q, int n_dim, void *state) { + if (!state) return NAN; + + TimeInterpState *interp_state = (TimeInterpState*)state; + + // Check time bounds + if (time_interp_check_bounds(interp_state, t) != 0) { + // Extrapolation not allowed - return NAN + return NAN; + } + + // Get the wrapped potential from the state + CPotential *wrapped_pot = (CPotential*)interp_state->wrapped_potential; + + // Interpolate all state at time t + double *interp_params, *interp_origin, *interp_rotation; + if (time_interp_eval_all(interp_state, t, n_dim, + &interp_params, &interp_origin, &interp_rotation) != 0) { + return NAN; + } + + // Transform position using existing apply_shift_rotate function + double *q_transformed = (double*)calloc(n_dim, sizeof(double)); + if (!q_transformed) { + free(interp_params); + free(interp_origin); + free(interp_rotation); + return NAN; + } + apply_shift_rotate(q, interp_origin, interp_rotation, n_dim, 0, q_transformed); + + // Evaluate wrapped potential + double result = wrapped_pot->value[0]( + t, interp_params, q_transformed, n_dim, wrapped_pot->state[0] + ); + + free(interp_params); + free(interp_origin); + free(interp_rotation); + free(q_transformed); + + return result; +} + +// Time-interpolated potential gradient function +void time_interp_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) { + if (!state || !grad) return; + + TimeInterpState *interp_state = (TimeInterpState*)state; + + // Check time bounds + if (time_interp_check_bounds(interp_state, t) != 0) { + // Extrapolation not allowed - set gradient to NAN + for (size_t i = 0; i < N * n_dim; i++) { + grad[i] = NAN; + } + return; + } + + // Get the wrapped potential from the state + CPotential *wrapped_pot = (CPotential*)interp_state->wrapped_potential; + + // Interpolate all state at time t + double *interp_params, *interp_origin, *interp_rotation; + if (time_interp_eval_all(interp_state, t, n_dim, + &interp_params, &interp_origin, &interp_rotation) != 0) { + for (size_t i = 0; i < N * n_dim; i++) grad[i] = NAN; + return; + } + + // Allocate temporary arrays for transformed coordinates + double *q_transformed = (double*)calloc(N * n_dim, sizeof(double)); + double *grad_transformed = (double*)calloc(N * n_dim, sizeof(double)); + if (!q_transformed || !grad_transformed) { + free(interp_params); + free(interp_origin); + free(interp_rotation); + free(q_transformed); + free(grad_transformed); + for (size_t i = 0; i < N * n_dim; i++) grad[i] = NAN; + return; + } + + // Transform positions for all orbits using existing apply_shift_rotate_N function + apply_shift_rotate_N(q, interp_origin, interp_rotation, n_dim, N, 0, q_transformed); + + // Evaluate wrapped potential gradient in transformed coordinates + wrapped_pot->gradient[0](t, interp_params, q_transformed, n_dim, N, grad_transformed, wrapped_pot->state[0]); + + // Transform gradient back: For each orbit, apply R^T to the gradient + // grad_out = R^T @ grad_transformed + for (size_t orbit_idx = 0; orbit_idx < N; orbit_idx++) { + double temp_grad[3]; // Temporary for one orbit's gradient + for (int i = 0; i < n_dim; i++) { + temp_grad[i] = 0.0; + for (int j = 0; j < n_dim; j++) { + // R^T[i,j] = R[j,i], so we use interp_rotation[j*n_dim + i] + temp_grad[i] += interp_rotation[j*n_dim + i] * grad_transformed[orbit_idx*n_dim + j]; + } + } + // Copy back + for (int i = 0; i < n_dim; i++) { + grad[orbit_idx*n_dim + i] = temp_grad[i]; + } + } + + free(interp_params); + free(interp_origin); + free(interp_rotation); + free(q_transformed); + free(grad_transformed); +} + +// Time-interpolated potential density function +double time_interp_density(double t, double *pars, double *q, int n_dim, void *state) { + if (!state) return NAN; + + TimeInterpState *interp_state = (TimeInterpState*)state; + + // Check time bounds + if (time_interp_check_bounds(interp_state, t) != 0) { + return NAN; + } + + // Get the wrapped potential from the state + CPotential *wrapped_pot = (CPotential*)interp_state->wrapped_potential; + + // Interpolate all state at time t + double *interp_params, *interp_origin, *interp_rotation; + if (time_interp_eval_all(interp_state, t, n_dim, + &interp_params, &interp_origin, &interp_rotation) != 0) { + return NAN; + } + + // Transform position using existing apply_shift_rotate function + double *q_transformed = (double*)calloc(n_dim, sizeof(double)); + if (!q_transformed) { + free(interp_params); + free(interp_origin); + free(interp_rotation); + return NAN; + } + apply_shift_rotate(q, interp_origin, interp_rotation, n_dim, 0, q_transformed); + + // Evaluate wrapped potential density + double result = wrapped_pot->density[0](t, interp_params, q_transformed, n_dim, wrapped_pot->state[0]); + + free(interp_params); + free(interp_origin); + free(interp_rotation); + free(q_transformed); + + return result; +} + +// Time-interpolated potential Hessian function +void time_interp_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state) { + if (!state || !hess) return; + + TimeInterpState *interp_state = (TimeInterpState*)state; + + // Check time bounds + if (time_interp_check_bounds(interp_state, t) != 0) { + // Extrapolation not allowed - set Hessian to NAN + for (int i = 0; i < n_dim * n_dim; i++) { + hess[i] = NAN; + } + return; + } + + // Get the wrapped potential from the state + CPotential *wrapped_pot = (CPotential*)interp_state->wrapped_potential; + + // Interpolate all state at time t + double *interp_params, *interp_origin, *interp_rotation; + if (time_interp_eval_all(interp_state, t, n_dim, + &interp_params, &interp_origin, &interp_rotation) != 0) { + for (int i = 0; i < n_dim * n_dim; i++) hess[i] = NAN; + return; + } + + // Transform position using existing apply_shift_rotate function + double *q_transformed = (double*)calloc(n_dim, sizeof(double)); + if (!q_transformed) { + free(interp_params); + free(interp_origin); + free(interp_rotation); + for (int i = 0; i < n_dim * n_dim; i++) hess[i] = NAN; + return; + } + apply_shift_rotate(q, interp_origin, interp_rotation, n_dim, 0, q_transformed); + + // Evaluate wrapped potential Hessian in transformed coordinates + double *hess_transformed = (double*)calloc(n_dim * n_dim, sizeof(double)); + if (!hess_transformed) { + free(interp_params); + free(interp_origin); + free(interp_rotation); + free(q_transformed); + for (int i = 0; i < n_dim * n_dim; i++) hess[i] = NAN; + return; + } + wrapped_pot->hessian[0](t, interp_params, q_transformed, n_dim, hess_transformed, wrapped_pot->state[0]); + + // Transform Hessian back: hess = R^T @ hess_transformed @ R + for (int i = 0; i < n_dim; i++) { + for (int j = 0; j < n_dim; j++) { + hess[i*n_dim + j] = 0.0; + for (int k = 0; k < n_dim; k++) { + for (int l = 0; l < n_dim; l++) { + hess[i*n_dim + j] += interp_rotation[k*n_dim + i] * hess_transformed[k*n_dim + l] * interp_rotation[l*n_dim + j]; + } + } + } + } + + free(interp_params); + free(interp_origin); + free(interp_rotation); + free(q_transformed); + free(hess_transformed); +} + +} // extern "C" + +#endif // USE_GSL == 1 diff --git a/gala/source/src/gala/potential/potential/builtin/time_interp_wrapper.h b/gala/source/src/gala/potential/potential/builtin/time_interp_wrapper.h new file mode 100644 index 0000000000000000000000000000000000000000..d0d0ede3eeb7ae7dbc37386b7d1dfb8daa00f997 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/time_interp_wrapper.h @@ -0,0 +1,21 @@ +#ifndef TIME_INTERP_WRAPPER_H +#define TIME_INTERP_WRAPPER_H + +#include +#include "extra_compile_macros.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Function prototypes for time-interpolated potential evaluation +extern double time_interp_value(double t, double *pars, double *q, int n_dim, void *state); +extern void time_interp_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double time_interp_density(double t, double *pars, double *q, int n_dim, void *state); +extern void time_interp_hessian(double t, double *pars, double *q, int n_dim, double *hess, void *state); + +#ifdef __cplusplus +} +#endif + +#endif // TIME_INTERP_WRAPPER_H diff --git a/gala/source/src/gala/potential/potential/builtin/time_interpolated.py b/gala/source/src/gala/potential/potential/builtin/time_interpolated.py new file mode 100644 index 0000000000000000000000000000000000000000..fc965b5548836f848ee074064b2bab7893ff8c95 --- /dev/null +++ b/gala/source/src/gala/potential/potential/builtin/time_interpolated.py @@ -0,0 +1,417 @@ +""" +Time-interpolated potential wrapper for Gala. + +This module provides the TimeInterpolatedPotential class that allows interpolating +potential parameters, origin, and rotation over time using GSL splines. +""" + +import copy + +import numpy as np + +from ....integrate.timespec import parse_time_specification +from ...common import PotentialParameter +from ..cpotential import CPotentialBase +from .cytimeinterp import TimeInterpolatedWrapper + +__all__ = ["TimeInterpolatedPotential"] + +_unsupported_cls = [ + "EXPPotential", + "HenonHeilesPotential", + "NullPotential", + "MultipolePotential", # TODO? + "MN3ExponentialDiskPotential", # TODO: need to move parameter transforms to C + "SphericalSplinePotential", # TODO + "CylSplinePotential", # TODO +] + + +class TimeInterpolatedPotential(CPotentialBase, GSL_only=True): + """ + A time-interpolated wrapper for any potential class. + + This class allows any PotentialBase subclass to have time-varying parameters, + origin, and rotation by interpolating between values specified at discrete + time knots using GSL splines. + + Parameters + ---------- + potential_cls : PotentialBase subclass + The potential class to wrap. + time_knots : array_like + Array of time values for interpolation knots. Must be monotonically increasing. + interpolation_method : str, optional + Interpolation type. Any GSL interpolation type is supported: + https://www.gnu.org/software/gsl/doc/html/interp.html + Common options are: + - 'linear': Linear interpolation + - 'cspline': Cubic spline interpolation (default) + - 'akima': Akima spline interpolation. This avoids unphysical wiggles in + regions where the second derivative in the underlying curve is rapidly + changing, however it does not have a continuous second derivative. + - 'steffen': Steffen spline interpolation. This guarantees monotonicity of the + interpolating function between the given data points. Therefore, minima and + maxima can only occur exactly at the data points, and there can never be + spurious oscillations between data points. + units : UnitSystem, optional + Unit system for the potential + origin : array_like, optional + Either a constant origin vector, or an array of origin vectors with shape + (n_knots, n_dim). + R : array_like, optional + Either a constant rotation matrix, or an array of rotation matrices with + shape (n_knots, n_dim, n_dim). + **kwargs + Potential parameters. Each parameter can be either a constant value, or an array with shape (n_knots, *parameter_shape) for a time-varying parameter. + + Examples + -------- + Create a Kepler potential with time-varying mass: + + >>> import astropy.units as u + >>> from gala.potential import KeplerPotential + >>> from gala.units import galactic + >>> + >>> # Time knots in Myr + >>> times = np.linspace(0, 100, 11) * u.Myr + >>> # Mass growing linearly with time + >>> masses = np.linspace(1e10, 2e10, 11) * u.Msun + >>> + >>> pot = TimeInterpolatedPotential( + ... KeplerPotential, times, m=masses, units=galactic + ... ) + >>> pot.energy([1., 0, 0] * u.pc, t=0*u.Myr) + + >>> pot.energy([1., 0, 0] * u.pc, t=50*u.Myr) + + + Create a potential with a time-varying rotation: + + >>> # Rotation matrices for 90 degree rotation over 1 Gyr + >>> R_times = np.linspace(0, 1, 11) * u.Gyr + >>> angles = np.linspace(0, np.pi / 2, 11) + >>> Rs = np.array([R.from_rotvec([0, 0, angle]).as_matrix() for angle in angles]) + >>> pot = gp.TimeInterpolatedPotential( + ... gp.LongMuraliBarPotential, + ... R_times, + ... m=1e10 * u.Msun, + ... a=3 * u.kpc, + ... b=1 * u.kpc, + ... c=0.5 * u.kpc, + ... R=Rs, + ... units=galactic, + ... ) + >>> pot.gradient([5., 0, 0] * u.kpc, t=0.*u.Gyr)[0, 0] + + >>> pot.gradient([5., 0, 0] * u.kpc, t=0.5*u.Gyr)[0, 0] + + """ + + potential_cls = PotentialParameter( + "potential_cls", physical_type=None, python_only=True, convert=None + ) + time_knots = PotentialParameter( + "time_knots", ndim=1, physical_type="time", python_only=True + ) + interpolation_method = PotentialParameter( + "interpolation_method", + physical_type=None, + default="cspline", + python_only=True, + convert=str, + ) + + def __init__( + self, + *args, + units=None, + origin=None, + R=None, + **kwargs, + ): + tmp, _ = self._parse_parameter_values(*args, strict=False, **kwargs) + + if tmp["potential_cls"].__name__ in _unsupported_cls: + raise NotImplementedError( + f"TimeInterpolatedPotential does not currently support " + f"{tmp['potential_cls'].__name__}. Raise an issue on GitHub if " + f"you would like this to be implemented:" + "https://github.com/adrn/gala/issues" + ) + + # HACK: ._parameters exists on the class, not the instance, but this makes a + # *copy* exist on this instance... + self._parameters = copy.deepcopy(self._parameters) + + # Copy parameter definitions from the wrapped potential class so the base class + # knows what parameters to expect in kwargs + self._potential_param_names = [] + for attr_name in tmp["potential_cls"]._parameters: + attr = getattr(tmp["potential_cls"], attr_name) + if isinstance(attr, PotentialParameter): + setattr(self, attr_name, attr) + self._parameters[attr_name] = copy.copy(attr) + self._potential_param_names.append(attr_name) + + # Validate interpolation method vs number of knots + n_knots = len(tmp["time_knots"]) + interp_method = tmp["interpolation_method"] + min_knots_required = { + "linear": 2, + "cspline": 3, + "akima": 5, + "steffen": 3, + } + if interp_method not in min_knots_required: + raise ValueError( + f"Interpolation method '{interp_method}' is not recognized. " + f"Supported methods are: {list(min_knots_required.keys())}" + ) + min_required = min_knots_required.get(interp_method) + if n_knots < min_required: + raise ValueError( + f"Interpolation method '{interp_method}' requires at least " + f"{min_required} time knots, but only {n_knots} were provided. " + f"Either provide more time knots or use 'linear' interpolation." + ) + + # Determine dimensionality from potential class + self.ndim = ( + tmp["potential_cls"].ndim if hasattr(tmp["potential_cls"], "ndim") else 3 + ) + + # Determine which parameters have an extra ndim over expectation + self._interp_params = [] + for param_name in self._potential_param_names: + pp = self._parameters[param_name] + if param_name not in kwargs: + if pp.default is None: + raise ValueError( + f"You must specify a value for potential parameter {param_name}" + ) + continue + + tmp = np.asanyarray(kwargs[param_name]) + if tmp.ndim == (pp.ndim + 1): + # Validate that the first dimension matches the number of time knots + if tmp.shape[0] != n_knots: + raise ValueError( + f"Parameter '{param_name}' has shape {tmp.shape} but there are " + f"{n_knots} time knots. For time-interpolated parameters, the first " + f"dimension must match the number of time knots. If you intended this " + f"to be a constant parameter, pass a scalar value instead of a " + f"length-{tmp.shape[0]} array." + ) + + self._interp_params.append(param_name) + + # increase ndim for validation + self._parameters[param_name].ndim += 1 + + # # Validate rotation matrices are orthogonal + # for i, rot_matrix in enumerate(rotation_matrices): + # if not self._is_orthogonal(rot_matrix): + # raise ValueError(f"Rotation matrix at index {i} is not orthogonal") + + super().__init__( + *args, + units=units, + origin=origin, + R=R, + **kwargs, + ) + + # Additional validation of input: + if not np.all(np.diff(self.parameters["time_knots"]) > 0): + raise ValueError( + "time_knots must be monotonically increasing (and no duplicate times)" + ) + + def _setup_wrapper(self, **_): + """Set up the time interpolation wrapper.""" + + # This is needed because we need to pass a dummy c_instance just to get the C + # functions for that potential. + # TODO: there may be a better way to pass the C functions... + potential_cls = self.parameters["potential_cls"] + wrapped_potential = potential_cls( + units=self.units, + **{ + k: ( + self.parameters[k][0] + if k in self._interp_params + else self.parameters[k] + ) + for k in self._potential_param_names + }, + ) + + origin_arrays = ( + np.atleast_2d(self.origin) + if self.origin is not None + else np.zeros(self.ndim)[np.newaxis] + ) + assert origin_arrays.ndim == 2 + + if self.R is not None: + R_arrays = self.R if self.R.ndim == 3 else self.R[np.newaxis] + else: + R_arrays = np.eye(3)[np.newaxis] + assert R_arrays.ndim == 3 + + # Prepare parameter arrays for the C wrapper + # For multi-dimensional parameters that are time-interpolated, + # reshape them from (n_knots, d1, d2, ...) to (n_knots, d1*d2*...) + param_arrays = {} + param_element_counts = {} # Track how many elements each parameter has + + # Calculate how many c_only parameters exist (e.g., nmax, lmax for SCF) + # These are prepended to c_parameters but not in the regular parameters dict + # TODO: need to detect potential parameters that aren't array type, like + # SphericalSplinePotential's spline_value_type + total_regular_param_size = 0 + for k in self._potential_param_names: + param_val = np.atleast_1d(wrapped_potential.parameters[k].value) + total_regular_param_size += param_val.size + + n_c_only_params = len(wrapped_potential.c_parameters) - total_regular_param_size + + # Extract c_only parameters (they're constant, so just take from wrapped_potential) + if n_c_only_params > 0: + c_only_params = wrapped_potential.c_parameters[:n_c_only_params] + else: + c_only_params = np.array([]) + + for k in self._potential_param_names: + param_val = np.atleast_1d(self.parameters[k].value) + + # If this is a time-interpolated multi-dimensional parameter, + # flatten the extra dimensions + if k in self._interp_params and param_val.ndim > 1: + n_knots = len(self.parameters["time_knots"]) + # Reshape from (n_knots, d1, d2, ...) to (n_knots, d1*d2*...) + param_reshaped = param_val.reshape(n_knots, -1) + n_elements = param_reshaped.shape[1] + param_element_counts[k] = n_elements + param_arrays[k] = param_reshaped.ravel() # Flatten to 1D row-major + # For constant parameters, flatten if multi-dimensional + elif param_val.ndim > 1: + param_arrays[k] = param_val.ravel() + param_element_counts[k] = param_val.size + else: + param_arrays[k] = param_val + param_element_counts[k] = 1 + + self.c_instance = TimeInterpolatedWrapper( + self.G, + wrapped_potential.c_instance, + self.parameters["time_knots"].value, + self._interp_params, + param_arrays, + param_element_counts, + c_only_params, + origins=origin_arrays, + rotation_matrices=R_arrays, + interpolation_method=self.parameters["interpolation_method"], + ) + + @staticmethod + def _is_orthogonal(matrix, rtol=1e-5, atol=1e-8): + """Check if a matrix is orthogonal.""" + return np.allclose( + matrix @ matrix.T, np.eye(matrix.shape[0]), rtol=rtol, atol=atol + ) + + def replicate(self, **kwargs): + """Create a copy of this potential with possibly different parameters.""" + # TODO: update this + + # Extract current parameters + new_kwargs = {} + + # Copy time-varying parameters + for param_name, param_array in self._param_arrays.items(): + new_kwargs[param_name] = kwargs.pop(param_name, param_array) + + # Copy other parameters + new_kwargs.update(kwargs) + + return self.__class__( + self._potential_cls, + self._time_knots, + interpolation_method=self._interpolation_method, + units=self.units, + origin=self._origin_arrays, + R=self._rotation_matrices, + **new_kwargs, + ) + + def integrate_orbit( + self, + w0, + Integrator=None, + Integrator_kwargs=None, + cython_if_possible=True, + save_all=True, + **time_spec, + ): + """ + Integrate an orbit in the current potential using the integrator class + provided. Uses same time specification as `Integrator()` -- see + the documentation for `gala.integrate` for more information. + + Parameters + ---------- + w0 : `~gala.dynamics.PhaseSpacePosition`, array_like + Initial conditions. + Integrator : `~gala.integrate.Integrator` (optional) + Integrator class to use. + Integrator_kwargs : dict (optional) + Any extra keyword arguments to pass to the integrator class + when initializing. Only works in non-Cython mode. + cython_if_possible : bool (optional) + If there is a Cython version of the integrator implemented, + and the potential object has a C instance, using Cython + will be *much* faster. + save_all : bool (optional) + Controls whether to store the phase-space position at all intermediate + timesteps. Set to False to store only the final values (i.e. the + phase-space position(s) at the final timestep). Default is True. + **time_spec + Specification of how long to integrate. See documentation + for `~gala.integrate.parse_time_specification`. + + Returns + ------- + orbit : `~gala.dynamics.Orbit` + """ + if Integrator_kwargs is None: + Integrator_kwargs = {} + t = parse_time_specification(self.units, **time_spec) + + # ensure timesteps are within the range of time_knots + knot_times = self.parameters["time_knots"].decompose(self.units).value + t_min, t_max = knot_times.min(), knot_times.max() + if np.any(t < t_min) or np.any(t > t_max): + raise ValueError( + "Integration times must be within the range of the Potential's interpolation range " + f"that you defined: [{t_min}, {t_max}] {self.units['time']}, " + f"your orbit integration range is [{min(t)}, {max(t)}] {self.units['time']}" + ) + + return super().integrate_orbit( + w0, + Integrator=Integrator, + Integrator_kwargs=Integrator_kwargs, + cython_if_possible=cython_if_possible, + save_all=save_all, + t=t, + ) + + def __repr__(self): + return ( + f"<{self.__class__.__name__}: " + f"{self.parameters['potential_cls'].__name__} " + f"interpolation_method='{self.parameters['interpolation_method']}')>" + ) diff --git a/gala/source/src/gala/potential/potential/ccompositepotential.pyx b/gala/source/src/gala/potential/potential/ccompositepotential.pyx new file mode 100644 index 0000000000000000000000000000000000000000..2ee4278012d556e92bf5d5a921099c9a7c40e1cc --- /dev/null +++ b/gala/source/src/gala/potential/potential/ccompositepotential.pyx @@ -0,0 +1,105 @@ +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + + +import numpy as np +cimport numpy as np +np.import_array() +import cython +cimport cython + +from libc.stdio cimport printf + + +from .core import CompositePotential +from .cpotential import CPotentialBase +from .cpotential cimport ( + CPotentialWrapper, CPotential, allocate_cpotential, free_cpotential, resize_cpotential_arrays +) + +__all__ = ['CCompositePotential'] + +cdef class CCompositePotentialWrapper(CPotentialWrapper): + def __init__(self, list potentials): + cdef: + int i, n_components + CPotentialWrapper[::1] _cpotential_arr + + self._potentials = potentials + _cpotential_arr = np.array(potentials) + n_components = len(potentials) + + # First, check if we need more components + if n_components > 1: + # Reallocate arrays without freeing the struct itself + # (This requires implementing a resize function in the C code) + resize_cpotential_arrays(self.cpotential, n_components) + + # Store parameter counts + self._n_params = np.zeros(n_components, dtype=np.int32) + for i in range(n_components): + self._n_params[i] = _cpotential_arr[i]._n_params[0] + self.cpotential.n_params[i] = self._n_params[i] + self.cpotential.do_shift_rotate[i] = _cpotential_arr[i].cpotential.do_shift_rotate[0] + + self.cpotential.n_dim = 0 + + for i in range(n_components): + self.cpotential.parameters[i] = &(_cpotential_arr[i]._params[0]) + self.cpotential.q0[i] = &(_cpotential_arr[i]._q0[0]) + self.cpotential.R[i] = &(_cpotential_arr[i]._R[0]) + self.cpotential.state[i] = _cpotential_arr[i].cpotential.state[0] + self.cpotential.value[i] = _cpotential_arr[i].cpotential.value[0] + self.cpotential.density[i] = _cpotential_arr[i].cpotential.density[0] + self.cpotential.gradient[i] = _cpotential_arr[i].cpotential.gradient[0] + self.cpotential.hessian[i] = _cpotential_arr[i].cpotential.hessian[0] + + if self.cpotential.n_dim == 0: + self.cpotential.n_dim = _cpotential_arr[i].cpotential.n_dim + elif self.cpotential.n_dim != _cpotential_arr[i].cpotential.n_dim: + raise ValueError( + "Input potentials must have same number of coordinate dimensions" + ) + + def __reduce__(self): + return (self.__class__, (list(self._potentials),)) + + +class CCompositePotential(CompositePotential, CPotentialBase): + + def __init__(self, **potentials): + CompositePotential.__init__(self, **potentials) + + def _reset_c_instance(self): + """Rebuilds the C instance after the composite potential is modified.""" + self._potential_list = [] + for p in self.values(): + self._potential_list.append(p.c_instance) + + if len(self._potential_list) > 0: + self.G = p.G + self.c_instance = CCompositePotentialWrapper(self._potential_list) + + def __setitem__(self, *args, **kwargs): + CompositePotential.__setitem__(self, *args, **kwargs) + self._reset_c_instance() + + def __setstate__(self, state): + # when rebuilding from a pickle, temporarily release lock + self.lock = False + self._units = None + for name, potential in state: + self[name] = potential + self._reset_c_instance() + self.lock = True + + def __reduce__(self): + """ Properly package the object for pickling """ + extra_args = tuple(getattr(self, k) for k in self._extra_serialize_args) + state = list(self.items()) + return self.__class__, extra_args, state diff --git a/gala/source/src/gala/potential/potential/core.py b/gala/source/src/gala/potential/potential/core.py new file mode 100644 index 0000000000000000000000000000000000000000..1d3568ac3ccbf07e10ce750b820a27759cbf253c --- /dev/null +++ b/gala/source/src/gala/potential/potential/core.py @@ -0,0 +1,1520 @@ +import abc +import copy as pycopy +import uuid +from collections import OrderedDict +from types import MappingProxyType + +import astropy.units as u +import numpy as np +from astropy.constants import G + +try: + from scipy.spatial.transform import Rotation +except ImportError as exc: + raise ImportError( + "Gala requires scipy>=1.2: make sure you have updated your version of " + "scipy and try importing gala again." + ) from exc + + +from ...units import DimensionlessUnitSystem +from ...util import atleast_2d +from ..common import CommonBase + +__all__ = ["CompositePotential", "PotentialBase"] + + +class PotentialBase(CommonBase, metaclass=abc.ABCMeta): + """ + A base class for defining gravitational potentials in Gala. + + This abstract base class provides the foundation for all gravitational + potential models in Gala. It handles unit conversions, coordinate + transformations, and provides a consistent interface for computing + gravitational forces, energies, and related quantities. + + Subclasses must implement the abstract methods ``_energy(q, t)`` and + ``_gradient(q, t)`` that compute the potential energy and its gradient + (negative acceleration) at position ``q`` and time ``t``. Optionally, + subclasses may implement ``_density(q, t)`` and ``_hessian(q, t)`` to + provide mass density and second derivative information. + + Parameters + ---------- + units : `~gala.units.UnitSystem`, optional + Set of non-reducible units that specify (at minimum) the + length, mass, time, and angle units. If not specified, the default + unit system will be used. + origin : array_like, optional + The origin of the potential in Cartesian coordinates. Default is + the origin ``[0, 0, 0]``. + R : array_like, `~scipy.spatial.transform.Rotation`, optional + Rotation matrix or `~scipy.spatial.transform.Rotation` object to + rotate the reference frame of the potential. If specified, the + potential will be evaluated in the rotated coordinate system. + + Attributes + ---------- + ndim : int + Number of spatial dimensions (default: 3). + parameters : `MappingProxyType` + Dictionary of potential parameters with associated units. + units : `~gala.units.UnitSystem` + The unit system used by the potential. + G : float + Gravitational constant in the potential's unit system. + origin : array_like + The origin of the potential coordinate system. + R : array_like, optional + Rotation matrix for the potential coordinate system. + + Notes + ----- + The potential is evaluated in a coordinate system that may be shifted + (via ``origin``) and/or rotated (via ``R``) relative to the input + coordinates. The transformation is applied as: + ``q_transformed = R @ (q - origin)``. + """ + + ndim = 3 + _symmetry = ( + None # Subclasses can set to SphericalSymmetry(), CylindricalSymmetry(), etc. + ) + + def __init__(self, *args, units=None, origin=None, R=None, **kwargs): + if self._GSL_only: + from gala._cconfig import GSL_ENABLED + + if not GSL_ENABLED: + raise ValueError( + "Gala was compiled without GSL and so this potential -- " + f"{self.__class__!s} -- will not work. See the gala " + "documentation for more information about installing and " + "using GSL with gala: " + "http://gala.adrian.pw/en/latest/install.html" + ) + + if self._EXP_only: + from gala._cconfig import EXP_ENABLED + + if not EXP_ENABLED: + raise ValueError( + "Gala was compiled without EXP and so this potential -- " + f"{self.__class__!s} -- will not work. See the gala " + "documentation for more information about installing and " + "using EXP with gala: " + "http://gala.adrian.pw/en/latest/install.html" + ) + + parameter_values, parameter_is_default = self._parse_parameter_values( + *args, **kwargs + ) + self._setup_potential( + parameters=parameter_values, + parameter_is_default=parameter_is_default, + origin=origin, + R=R, + units=units, + ) + + def _setup_potential( + self, parameters, parameter_is_default, origin=None, R=None, units=None + ): + self._units = self._validate_units(units) + self.parameters = self._prepare_parameters(parameters, self.units) + self.parameter_is_default = set(parameter_is_default) + + try: + self.G = G.decompose(self.units).value + except u.UnitConversionError: + # TODO: this is a convention that and could lead to confusion! + self.G = 1.0 + + if origin is None: + origin = np.zeros(self.ndim) + self.origin = self._remove_units(origin) + + if R is not None and self.ndim not in {2, 3}: + raise NotImplementedError( + "Gala potentials currently only support " + "rotations when ndim=2 or ndim=3." + ) + + if R is not None: + if isinstance(R, Rotation): + R = R.as_matrix() + R = np.array(R) + + if R.shape[-2:] != (self.ndim, self.ndim): + msg = ( + f"Rotation matrix passed to potential {self.__class__.__name__} " + f"has an invalid shape: expected {(self.ndim, self.ndim)}, got " + f"{R.shape}" + ) + raise ValueError(msg) + self.R = R + + def replicate(self, **kwargs): + """ + Return a copy of the potential instance with some parameter values + changed. This always produces copies of any parameter arrays. + + Parameters + ---------- + **kwargs + All other keyword arguments are used to overwrite parameter values + when making the copy. + + Returns + ------- + replicant : `~gala.potential.PotentialBase` subclass instance + The replicated potential. + """ + for k, v in self.parameters.items(): + if k in self.parameter_is_default: + continue + kwargs.setdefault(k, pycopy.copy(v)) + + for k in ["units", "origin", "R"]: + v = getattr(self, k) + kwargs.setdefault(k, pycopy.copy(v)) + + return self.__class__(**kwargs) + + @classmethod + def to_sympy(cls): + """Return a representation of this potential class as a sympy expression + + Returns + ------- + expr : sympy expression + vars : dict + A dictionary of sympy symbols used in the expression. + """ + raise NotImplementedError(f"to_sympy() is not implemented for this class {cls}") + + @classmethod + def to_latex(cls): + """Return a string LaTeX representation of this potential + + Returns + ------- + latex_str : str + The latex expression as a Python string. + """ + try: + expr, *_ = cls.to_sympy() + except NotImplementedError as e: + raise NotImplementedError( + ".to_latex() requires having a .to_sympy() method implemented " + "on the requesting potential class" + ) from e + + # testing for this import happens in the sympy method + import sympy as sy + + return sy.latex(expr) + + ########################################################################### + # Abstract methods that must be implemented by subclasses + # + @abc.abstractmethod + def _energy(self, q, t=0.0): + pass + + @abc.abstractmethod + def _gradient(self, q, t=0.0): + pass + + def _density(self, q, t=0.0): + raise NotImplementedError("This Potential has no implemented density function.") + + def _hessian(self, q, t=0.0): + raise NotImplementedError("This Potential has no implemented Hessian.") + + ########################################################################### + # Utility methods + # + def _remove_units(self, x): + """ + Always returns an array. If a Quantity is passed in, it converts to the + units associated with this object and returns the value. + """ + return x.decompose(self.units).value if hasattr(x, "unit") else np.array(x) + + def _remove_units_prepare_shape(self, x): + """ + This is similar to that implemented by + `gala.potential.common.CommonBase`, but returns just the position if the + input is a `PhaseSpacePosition`. + """ + from gala.dynamics import PhaseSpacePosition + + if hasattr(x, "unit"): + x = x.decompose(self.units).value + + elif isinstance(x, PhaseSpacePosition): + x = x.cartesian.xyz.decompose(self.units).value + + x = atleast_2d(x, insert_axis=1).astype(np.float64) + + if x.shape[0] != self.ndim: + raise ValueError( + f"Input position has ndim={x.shape[0]}, but this potential " + f"expects an {self.ndim}-dimensional position." + ) + + return x + + def _reapply_units_and_shape(self, x, ptype, shape, conv_unit=None, transpose=True): + """ + This is the inverse of _remove_units_prepare_shape. It takes the output of one + of the C functions below and reapplies units and the original shape. + ptype is an Astropy PhysicalType object + """ + if transpose: + x = np.moveaxis(x, 0, -1) + if isinstance(ptype, u.PhysicalType): + uu = self.units[ptype] + elif isinstance(ptype, str): + uu = self.units[u.get_physical_type(ptype)] + elif isinstance(ptype, u.UnitBase): + uu = ptype + else: + raise ValueError( + f"ptype must be a PhysicalType, str, or UnitBase object. " + f"Got {ptype} instead." + ) + x = x.reshape(shape) * uu + if conv_unit is None: + return x + return x.to(conv_unit) + + def _process_position_argument(self, q, coord_kwargs): + """ + Process position input, handling both Cartesian and symmetry coordinates. + + Parameters + ---------- + q : array-like, PhaseSpacePosition, Quantity, or None + Cartesian position input, or None if using symmetry coordinates. + coord_kwargs : dict + Dictionary of symmetry coordinate keyword arguments. + + Returns + ------- + q : array + Processed Cartesian position array ready for internal use. + + Raises + ------ + ValueError + If both q and coord_kwargs are provided, or if neither is provided, + or if symmetry coordinates are used but the potential has no symmetry. + """ + # Check for conflicting inputs + if q is not None and coord_kwargs: + raise ValueError( + "Cannot provide both Cartesian position (q) and symmetry " + f"coordinates ({', '.join(coord_kwargs.keys())}). " + "Please provide only one." + ) + + # If using symmetry coordinates + if coord_kwargs: + if self._symmetry is None: + raise ValueError( + f"Potential {self.__class__.__name__} does not have a defined " + f"symmetry. Symmetry coordinates {coord_kwargs.keys()} cannot " + "be used. Please provide Cartesian coordinates instead." + ) + + # Validate the coordinate keywords + self._symmetry.validate_coords(**coord_kwargs) + + # Convert to Cartesian + q_cartesian = self._symmetry.to_cartesian(**coord_kwargs) + + # Now proceed with normal unit handling + q = self._remove_units_prepare_shape(q_cartesian) + + elif q is not None: + # Normal Cartesian input + q = self._remove_units_prepare_shape(q) + + else: + raise ValueError( + "Must provide either Cartesian position (q) or symmetry coordinates " + f"(e.g., {self._symmetry.coord_names if self._symmetry else 'r, R, z, etc.'})" + ) + + return q + + ########################################################################### + # Core methods that use the above implemented functions + # + def energy(self, q=None, t=0.0, **coord_kwargs): + """ + Compute the gravitational potential energy at the given position(s). + + The potential energy per unit mass is evaluated at the specified + position(s) and time. + + Parameters + ---------- + q : `~gala.dynamics.PhaseSpacePosition`, `~astropy.units.Quantity`, array_like, optional + Position(s) at which to evaluate the potential. If the input + has no units (i.e., is an `~numpy.ndarray`), it is assumed to + be in the same unit system as the potential. Shape should be + ``(n_dim,)`` for a single position or ``(n_dim, n_positions)`` + for multiple positions. If using symmetry coordinates, pass + those as keyword arguments instead and leave q as None. + t : numeric, `~astropy.units.Quantity`, optional + Time at which to evaluate the potential. Default is 0. + **coord_kwargs + For potentials with spherical or cylindrical symmetry, you can + optionally provide coordinates in the natural coordinate system. + For spherical potentials, use ``r=...``. For cylindrical potentials, + use ``R=...`` and optionally ``z=...`` (defaults to 0). + + Returns + ------- + E : `~astropy.units.Quantity` + The gravitational potential energy per unit mass. For input + shape ``(n_dim, n_positions)``, returns shape ``(n_positions,)``. + Units are specific energy (e.g., m²/s² in SI units). + + Notes + ----- + The potential energy is related to the gravitational acceleration + by :math:`\\vec{a} = -\\nabla \\phi`, where φ is the potential + energy per unit mass. + + Examples + -------- + Using Cartesian coordinates (works for all potentials): + + >>> import astropy.units as u + >>> import numpy as np + >>> pot = SomePotential(...) # doctest: +SKIP + >>> xyz = np.array([[1., 0., 0.]]).T * u.kpc # doctest: +SKIP + >>> pot.energy(xyz) # doctest: +SKIP + + For spherical potentials, you can use spherical radius: + + >>> pot = HernquistPotential(m=1e10*u.Msun, c=5*u.kpc) # doctest: +SKIP + >>> r = np.linspace(0.1, 10, 100) * u.kpc # doctest: +SKIP + >>> pot.energy(r=r) # doctest: +SKIP + + For cylindrical potentials, you can use R and z: + + >>> pot = MiyamotoNagaiPotential(m=1e11*u.Msun, a=3*u.kpc, b=0.3*u.kpc) # doctest: +SKIP + >>> R = np.linspace(1, 15, 100) * u.kpc # doctest: +SKIP + >>> pot.energy(R=R, z=0*u.kpc) # doctest: +SKIP + >>> pot.energy(R=R) # z defaults to 0 # doctest: +SKIP + """ + q = self._process_position_argument(q, coord_kwargs) + orig_shape, q = self._get_c_valid_arr(q) + t = self._validate_prepare_time(t, len(q)) + return self._reapply_units_and_shape( + self._energy(q, t=t), + ptype=u.get_physical_type("energy") / u.get_physical_type("mass"), + shape=orig_shape[1:], + ) + + def gradient(self, q=None, t=0.0, **coord_kwargs): + """ + Compute the gradient of the gravitational potential. + + Parameters + ---------- + q : `~gala.dynamics.PhaseSpacePosition`, `~astropy.units.Quantity`, array_like, optional + Position(s) at which to evaluate the potential gradient. If the + input has no units (i.e., is an `~numpy.ndarray`), it is assumed + to be in the same unit system as the potential. Shape should be + ``(n_dim,)`` for a single position or ``(n_dim, n_positions)`` + for multiple positions. If using symmetry coordinates, pass + those as keyword arguments instead and leave q as None. + t : numeric, `~astropy.units.Quantity`, optional + Time at which to evaluate the potential gradient. Default is 0. + **coord_kwargs + For potentials with spherical or cylindrical symmetry, you can + optionally provide coordinates in the natural coordinate system. + For spherical potentials, use ``r=...``. For cylindrical potentials, + use ``R=...`` and optionally ``z=...`` (defaults to 0). + + Returns + ------- + grad : `~astropy.units.Quantity` + The gradient of the gravitational potential. Has the same shape + as the input position array ``q``. Units are acceleration + (e.g., m/s² in SI units). To get gravitational acceleration, + use ``acceleration()`` or compute ``-gradient()``. + + See Also + -------- + acceleration : Compute gravitational acceleration (negative gradient). + + Notes + ----- + The relationship between potential φ, gradient, and acceleration is: + + .. math:: + \\vec{a} = -\\nabla \\phi = -\\frac{\\partial \\phi}{\\partial \\vec{q}} + + The gradient is always returned in Cartesian coordinates, even when + using symmetry coordinates as input. + """ + q = self._process_position_argument(q, coord_kwargs) + + # transpose=False because the gradient functions expect (ndim, N) arrays + orig_shape, q = self._get_c_valid_arr(q, transpose=False) + + t = self._validate_prepare_time(t, q.shape[1]) + return self._reapply_units_and_shape( + self._gradient(q, t=t), + u.get_physical_type("acceleration"), + orig_shape, + transpose=False, + ) + + def density(self, q=None, t=0.0, **coord_kwargs): + """ + Compute the mass density at the given position(s). + + For potentials that have an associated mass distribution, this method + computes the mass density rho(q, t) at the specified positions and time. + The density is related to the potential via Poisson's equation: + :math:`\\nabla^2 \\phi = 4\\pi G \\rho`. + + Parameters + ---------- + q : `~gala.dynamics.PhaseSpacePosition`, `~astropy.units.Quantity`, array_like, optional + Position(s) at which to evaluate the mass density. If the input + has no units (i.e., is an `~numpy.ndarray`), it is assumed to + be in the same unit system as the potential. Shape should be + ``(n_dim,)`` for a single position or ``(n_dim, n_positions)`` + for multiple positions. If using symmetry coordinates, pass + those as keyword arguments instead and leave q as None. + t : numeric, `~astropy.units.Quantity`, optional + Time at which to evaluate the mass density. Default is 0. + **coord_kwargs + For potentials with spherical or cylindrical symmetry, you can + optionally provide coordinates in the natural coordinate system. + For spherical potentials, use ``r=...``. For cylindrical potentials, + use ``R=...`` and optionally ``z=...`` (defaults to 0). + + Returns + ------- + dens : `~astropy.units.Quantity` + The mass density at the specified position(s). For input + shape ``(n_dim, n_positions)``, returns shape ``(n_positions,)``. + Units are mass density (e.g., kg/m³ in SI units). + + Notes + ----- + Not all potential models have an implemented density function. + For potentials without a density implementation, this method + will raise a ``NotImplementedError``. + + The density is computed using the relationship with the potential's + Laplacian (when available) or from the underlying mass model. + + Raises + ------ + NotImplementedError + If the potential does not have an implemented density function. + """ + q = self._process_position_argument(q, coord_kwargs) + orig_shape, q = self._get_c_valid_arr(q) + t = self._validate_prepare_time(t, len(q)) + return self._reapply_units_and_shape( + self._density(q, t=t), u.get_physical_type("mass density"), orig_shape[1:] + ) + + def hessian(self, q=None, t=0.0, **coord_kwargs): + """ + Compute the Hessian matrix of the gravitational potential. + + The Hessian matrix contains the second partial derivatives of the + potential: :math:`H_{ij} = \\frac{\\partial^2 \\phi}{\\partial q_i \\partial q_j}`. + This is useful for stability analysis, computing tidal tensors, and + orbital frequency analysis. + + Parameters + ---------- + q : `~gala.dynamics.PhaseSpacePosition`, `~astropy.units.Quantity`, array_like, optional + Position(s) at which to evaluate the Hessian matrix. If the input + has no units (i.e., is an `~numpy.ndarray`), it is assumed to + be in the same unit system as the potential. Shape should be + ``(n_dim,)`` for a single position or ``(n_dim, n_positions)`` + for multiple positions. If using symmetry coordinates, pass + those as keyword arguments instead and leave q as None. + t : numeric, `~astropy.units.Quantity`, optional + Time at which to evaluate the Hessian matrix. Default is 0. + **coord_kwargs + For potentials with spherical or cylindrical symmetry, you can + optionally provide coordinates in the natural coordinate system. + For spherical potentials, use ``r=...``. For cylindrical potentials, + use ``R=...`` and optionally ``z=...`` (defaults to 0). + + Returns + ------- + hess : `~astropy.units.Quantity` + The Hessian matrix of second derivatives. For input shape + ``(n_dim, n_positions)``, returns shape + ``(n_dim, n_dim, n_positions)``. Each ``n_dim x n_dim`` slice + corresponds to the Hessian matrix at one position. Units are + acceleration per length (e.g., s⁻² in SI units). + + Notes + ----- + Computing Hessian matrices for rotated potentials (when ``R`` is + not the identity matrix) is currently not supported and will raise + a ``NotImplementedError``. + + Not all potential models have an implemented Hessian function. + For potentials without a Hessian implementation, this method + will raise a ``NotImplementedError``. + + The Hessian matrix is symmetric for time-independent potentials. + + Raises + ------ + NotImplementedError + If the potential does not have an implemented Hessian function, + or if the potential is rotated (``R`` is not the identity). + """ + if self.R is not None and not np.allclose( + np.diag(self.R), 1.0, atol=1e-15, rtol=0 + ): + raise NotImplementedError( + "Computing Hessian matrices for rotated " + "potentials is currently not supported." + ) + q = self._process_position_argument(q, coord_kwargs) + orig_shape, q = self._get_c_valid_arr(q) + t = self._validate_prepare_time(t, len(q)) + return self._reapply_units_and_shape( + self._hessian(q, t=t), + u.get_physical_type("frequency drift"), + (orig_shape[0], orig_shape[0], *orig_shape[1:]), + ) + + ########################################################################### + # Convenience methods that make use the base methods + # + def acceleration(self, q=None, t=0.0, **coord_kwargs): + """ + Compute the gravitational acceleration at the given position(s). + + The gravitational acceleration is the negative gradient of the + potential: :math:`\\vec{a} = -\\nabla \\phi`. This is the + acceleration experienced by a test particle in the gravitational field. + + Parameters + ---------- + q : `~gala.dynamics.PhaseSpacePosition`, `~astropy.units.Quantity`, array_like, optional + Position(s) at which to compute the gravitational acceleration. + If the input has no units (i.e., is an `~numpy.ndarray`), it is + assumed to be in the same unit system as the potential. If using + symmetry coordinates, pass those as keyword arguments instead and + leave q as None. + t : numeric, `~astropy.units.Quantity`, optional + Time at which to evaluate the acceleration. Default is 0. + **coord_kwargs + For potentials with spherical or cylindrical symmetry, you can + optionally provide coordinates in the natural coordinate system. + For spherical potentials, use ``r=...``. For cylindrical potentials, + use ``R=...`` and optionally ``z=...`` (defaults to 0). + + Returns + ------- + acc : `~astropy.units.Quantity` + The gravitational acceleration vector(s). Has the same shape as + the input position array ``q``. Units are acceleration + (e.g., m/s² in SI units). + + See Also + -------- + gradient : Compute the potential gradient (negative acceleration). + + Notes + ----- + This method is equivalent to ``-self.gradient(q, t)`` and is provided + for convenience in orbital integration and dynamics calculations. + """ + return -self.gradient(q, t=t, **coord_kwargs) + + def mass_enclosed(self, q=None, t=0.0, **coord_kwargs): + """ + Estimate the mass enclosed within spherical radius at given position(s). + + This method estimates the enclosed mass by assuming spherical symmetry + and using the relation :math:`M_{\\rm enc}(r) = r^2 |dΊ/dr| / G`, where + the radial derivative is computed numerically using finite differences. + + Parameters + ---------- + q : `~gala.dynamics.PhaseSpacePosition`, `~astropy.units.Quantity`, array_like, optional + Position(s) at which to estimate the enclosed mass. The enclosed + mass is computed at the spherical radius corresponding to each + position. If the input has no units, it is assumed to be in the + same unit system as the potential. If using symmetry coordinates, + pass those as keyword arguments instead and leave q as None. + t : numeric, `~astropy.units.Quantity`, optional + Time at which to evaluate the enclosed mass. Default is 0. + **coord_kwargs + For potentials with spherical or cylindrical symmetry, you can + optionally provide coordinates in the natural coordinate system. + For spherical potentials, use ``r=...``. For cylindrical potentials, + use ``R=...`` and optionally ``z=...`` (defaults to 0). + + Returns + ------- + menc : `~astropy.units.Quantity` + Mass enclosed within the spherical radius at each position. + For input shape ``(n_dim, n_positions)``, returns shape + ``(n_positions,)``. Units are mass (e.g., kg in SI units). + + Notes + ----- + This method assumes the potential is approximately spherically + symmetric. The enclosed mass is estimated using a finite difference + approximation to the radial derivative of the potential. + + For potentials with negative mass parameters (e.g., some composite + models), the sign is handled appropriately. + + The calculation uses the relation derived from Gauss's law: + + .. math:: + M_{\\rm enc}(r) = \\frac{r^2}{G} \\left| \\frac{d\\Phi}{dr} \\right| + """ + q = self._process_position_argument(q, coord_kwargs) + orig_shape, q = self._get_c_valid_arr(q) + t = self._validate_prepare_time(t, len(q)) + + # small step-size in direction of q + h = 1e-3 # MAGIC NUMBER + + # Radius + r = np.sqrt(np.sum(q**2, axis=1)) + + epsilon = h * q / r[:, np.newaxis] + + dPhi_dr_plus = self._energy(q + epsilon, t=t) + dPhi_dr_minus = self._energy(q - epsilon, t=t) + diff = dPhi_dr_plus - dPhi_dr_minus + + if isinstance(self.units, DimensionlessUnitSystem): + Gee = 1.0 + else: + Gee = G.decompose(self.units).value + + Menc = np.abs(r * r * diff / Gee / (2.0 * h)) + + sgn = 1.0 + if "m" in self.parameters and self.parameters["m"] < 0: + sgn = -1.0 + + return self._reapply_units_and_shape( + sgn * Menc, u.get_physical_type("mass"), orig_shape[1:] + ) + + def circular_velocity(self, q=None, t=0.0, **coord_kwargs): + """ + Estimate the circular velocity at given position(s) assuming spherical symmetry. + + The circular velocity is the speed required for a circular orbit at + the given radius in a spherically symmetric potential. It is computed + using :math:`v_{\\rm circ}(r) = \\sqrt{r |dΊ/dr|}`, where the radial + derivative is evaluated from the potential gradient. + + Parameters + ---------- + q : `~gala.dynamics.PhaseSpacePosition`, `~astropy.units.Quantity`, array_like, optional + Position(s) at which to estimate the circular velocity. The + calculation uses the spherical radius from the origin. If the + input has no units, it is assumed to be in the same unit system + as the potential. If using symmetry coordinates, pass those as + keyword arguments instead and leave q as None. + t : numeric, `~astropy.units.Quantity`, optional + Time at which to evaluate the circular velocity. Default is 0. + **coord_kwargs + For potentials with spherical or cylindrical symmetry, you can + optionally provide coordinates in the natural coordinate system. + For spherical potentials, use ``r=...``. For cylindrical potentials, + use ``R=...`` and optionally ``z=...`` (defaults to 0). + + Returns + ------- + vcirc : `~astropy.units.Quantity` + Circular velocity at the spherical radius corresponding to each + position. For input shape ``(n_dim, n_positions)``, returns shape + ``(n_positions,)``. Units are velocity (e.g., m/s in SI units). + + Notes + ----- + This method assumes the potential is approximately spherically + symmetric. The circular velocity is computed using the relation: + + .. math:: + v_{\\rm circ}(r) = \\sqrt{r \\left| \\frac{d\\Phi}{dr} \\right|} + + where the radial derivative is computed from the Cartesian gradient + via :math:`dΊ/dr = \\vec{\\nabla}Ί \\cdot \\hat{r}`. + + For exactly spherical potentials, this gives the speed of circular + orbits. For non-spherical potentials, this provides an approximation + useful for initial orbit estimates. + """ + q = self._process_position_argument(q, coord_kwargs) + + # Radius + r = np.sqrt(np.sum(q**2, axis=0)) + dPhi_dxyz = self.gradient(q, t=t) + dPhi_dr = np.sum(dPhi_dxyz.value * q / r, axis=0) + + return self._reapply_units_and_shape( + np.sqrt(r * np.abs(dPhi_dr)), + self.units[u.get_physical_type("length")] + / self.units[u.get_physical_type("time")], + r.shape, + conv_unit=self.units[u.get_physical_type("velocity")], + ) + + ########################################################################### + # Python special methods + # + def __call__(self, q): + return self.energy(q) + + def __add__(self, other): + if not isinstance(other, PotentialBase): + raise TypeError( + f"Cannot add a {self.__class__.__name__} to a " + f"{other.__class__.__name__}" + ) + + new_pot = CompositePotential() + + if isinstance(self, CompositePotential): + for k, v in self.items(): + new_pot[k] = v + + else: + k = str(uuid.uuid4()) + new_pot[k] = self + + if isinstance(other, CompositePotential): + for k, v in self.items(): + if k in new_pot: + raise KeyError( + f'Potential component "{k}" already exists ' + "-- duplicate key provided in potential " + "addition" + ) + new_pot[k] = v + + else: + k = str(uuid.uuid4()) + new_pot[k] = other + + return new_pot + + ########################################################################### + # Convenience methods that do fancy things + # + def plot_contours( + self, + grid, + t=0.0, + filled=True, + ax=None, + labels=None, + subplots_kw=None, + **kwargs, + ): + """ + Plot equipotentials contours. Computes the potential energy on a grid + (specified by the array `grid`). + + .. warning:: Right now the grid input must be arrays and must already + be in the unit system of the potential. Quantity support is coming... + + Parameters + ---------- + grid : tuple + Coordinate grids or slice value for each dimension. Should be a + tuple of 1D arrays or numbers. + t : quantity-like (optional) + The time to evaluate at. + filled : bool (optional) + Use :func:`~matplotlib.pyplot.contourf` instead of + :func:`~matplotlib.pyplot.contour`. Default is ``True``. + ax : matplotlib.Axes (optional) + labels : iterable (optional) + List of axis labels. + subplots_kw : dict + kwargs passed to matplotlib's subplots() function if an axes object + is not specified. + kwargs : dict + kwargs passed to either contourf() or plot(). + + Returns + ------- + fig : `~matplotlib.Figure` + + """ + + import matplotlib.pyplot as plt + from matplotlib import cm + + # figure out which elements are iterable, which are numeric + if subplots_kw is None: + subplots_kw = {} + grids = [] + slices = [] + for ii, g in enumerate(grid): + if np.iterable(g): + grids.append((ii, g)) + else: + slices.append((ii, g)) + + # figure out the dimensionality + ndim = len(grids) + + # if ndim > 2, don't know how to handle this! + if ndim > 2: + raise ValueError( + "ndim > 2: you can only make contours on a 2D grid. For other " + "dimensions, you have to specify values to slice." + ) + + if ax is None: + # default figsize + fig, ax = plt.subplots(1, 1, **subplots_kw) + else: + fig = ax.figure + + if ndim == 1: + # 1D curve + x1 = grids[0][1] + r = np.zeros((len(grids) + len(slices), len(x1))) + r[grids[0][0]] = x1 + + for ii, slc in slices: + r[ii] = slc + + Z = self.energy(r * self.units["length"], t=t).value + ax.plot(x1, Z, **kwargs) + + if labels is not None: + ax.set_xlabel(labels[0]) + ax.set_ylabel("potential") + else: + # 2D contours + x1, x2 = np.meshgrid(grids[0][1], grids[1][1]) + shp = x1.shape + x1, x2 = x1.ravel(), x2.ravel() + + r = np.zeros((len(grids) + len(slices), len(x1))) + r[grids[0][0]] = x1 + r[grids[1][0]] = x2 + + for ii, slc in slices: + r[ii] = slc + + Z = self.energy(r * self.units["length"], t=t).value + + # make default colormap not suck + cmap = kwargs.pop("cmap", cm.Blues) + if filled: + ax.contourf( + x1.reshape(shp), + x2.reshape(shp), + Z.reshape(shp), + cmap=cmap, + **kwargs, + ) + else: + ax.contour( + x1.reshape(shp), + x2.reshape(shp), + Z.reshape(shp), + cmap=cmap, + **kwargs, + ) + + if labels is not None: + ax.set_xlabel(labels[0]) + ax.set_ylabel(labels[1]) + + return fig + + def plot_density_contours( + self, + grid, + t=0.0, + filled=True, + ax=None, + labels=None, + subplots_kw=None, + **kwargs, + ): + """ + Plot density contours. Computes the density on a grid + (specified by the array `grid`). + + .. warning:: + + For now, the grid input must be arrays and must already be in + the unit system of the potential. Quantity support is coming... + + Parameters + ---------- + grid : tuple + Coordinate grids or slice value for each dimension. Should be a + tuple of 1D arrays or numbers. + t : quantity-like (optional) + The time to evaluate at. + filled : bool (optional) + Use :func:`~matplotlib.pyplot.contourf` instead of + :func:`~matplotlib.pyplot.contour`. Default is ``True``. + ax : matplotlib.Axes (optional) + labels : iterable (optional) + List of axis labels. + subplots_kw : dict + kwargs passed to matplotlib's subplots() function if an axes object + is not specified. + kwargs : dict + kwargs passed to either contourf() or plot(). + + Returns + ------- + fig : `~matplotlib.Figure` + + """ + + import matplotlib.pyplot as plt + from matplotlib import cm + + # figure out which elements are iterable, which are numeric + if subplots_kw is None: + subplots_kw = {} + grids = [] + slices = [] + for ii, g in enumerate(grid): + if np.iterable(g): + grids.append((ii, g)) + else: + slices.append((ii, g)) + + # figure out the dimensionality + ndim = len(grids) + + # if ndim > 2, don't know how to handle this! + if ndim > 2: + raise ValueError( + "ndim > 2: you can only make contours on a 2D grid. For other " + "dimensions, you have to specify values to slice." + ) + + if ax is None: + # default figsize + fig, ax = plt.subplots(1, 1, **subplots_kw) + else: + fig = ax.figure + + if ndim == 1: + # 1D curve + x1 = grids[0][1] + r = np.zeros((len(grids) + len(slices), len(x1))) + r[grids[0][0]] = x1 + + for ii, slc in slices: + r[ii] = slc + + Z = self.density(r * self.units["length"], t=t).value + ax.plot(x1, Z, **kwargs) + + if labels is not None: + ax.set_xlabel(labels[0]) + ax.set_ylabel("potential") + else: + # 2D contours + x1, x2 = np.meshgrid(grids[0][1], grids[1][1]) + shp = x1.shape + x1, x2 = x1.ravel(), x2.ravel() + + r = np.zeros((len(grids) + len(slices), len(x1))) + r[grids[0][0]] = x1 + r[grids[1][0]] = x2 + + for ii, slc in slices: + r[ii] = slc + + Z = self.density(r * self.units["length"], t=t).value + + # make default colormap not suck + cmap = kwargs.pop("cmap", cm.Blues) + if filled: + ax.contourf( + x1.reshape(shp), + x2.reshape(shp), + Z.reshape(shp), + cmap=cmap, + **kwargs, + ) + else: + ax.contour( + x1.reshape(shp), + x2.reshape(shp), + Z.reshape(shp), + cmap=cmap, + **kwargs, + ) + + # cs.cmap.set_under('w') + # cs.cmap.set_over('k') + + if labels is not None: + ax.set_xlabel(labels[0]) + ax.set_ylabel(labels[1]) + + return fig + + def plot_rotation_curve(self, R_grid, t=0.0, ax=None, labels=None, **plot_kwargs): + """ + Plot the rotation curve or circular velocity curve for this potential on the + input grid of cylindrical radii. + + Parameters + ---------- + R_grid : array-like + A grid of radius values to compute the rotation curve at. This should be a + one-dimensional grid. + t : quantity-like (optional) + The time to evaluate at. + ax : matplotlib.Axes (optional) + labels : iterable (optional) + List of axis labels. Set to False to disable adding labels. + plot_kwargs : dict + kwargs passed to plot(). + + Returns + ------- + fig : `~matplotlib.Figure` + ax : `~matplotlib.Axes` + + """ + + if not hasattr(R_grid, "unit"): + R_grid *= self.units["length"] + + xyz = np.zeros((3, *R_grid.shape)) * self.units["length"] + xyz[0] = R_grid + + vcirc = self.circular_velocity(xyz, t=t) + + if labels is None: + labels = [ + f"$R$ [{self.units['length']:latex_inline}]", + r"$v_{\rm circ}$ " + f"[{self.units['speed']:latex_inline}]", + ] + + import matplotlib.pyplot as plt + + if ax is None: + fig, ax = plt.subplots() + else: + fig = ax.figure + + if labels is not False: + ax.set_xlabel(labels[0]) + ax.set_ylabel(labels[1]) + + plot_kwargs.setdefault("marker", "") + plot_kwargs.setdefault("linestyle", plot_kwargs.pop("ls", "-")) + plot_kwargs.setdefault("linewidth", plot_kwargs.pop("lw", 1)) + + ax.plot( + R_grid.to_value(self.units["length"]), + vcirc.to_value(self.units["speed"]), + **plot_kwargs, + ) + + return fig, ax + + def integrate_orbit(self, *args, **kwargs): + """ + Integrate an orbit in the current potential using the integrator class + provided. Uses same time specification as `Integrator()` -- see + the documentation for `gala.integrate` for more information. + + Parameters + ---------- + w0 : `~gala.dynamics.PhaseSpacePosition`, array_like + Initial conditions. + Integrator : `~gala.integrate.Integrator` (optional) + Integrator class to use. + Integrator_kwargs : dict (optional) + Any extra keyword argumets to pass to the integrator class + when initializing. Only works in non-Cython mode. + cython_if_possible : bool (optional) + If there is a Cython version of the integrator implemented, + and the potential object has a C instance, using Cython + will be *much* faster. + save_all : bool (optional) + Controls whether to store the phase-space position at all intermediate + timesteps. Set to False to store only the final values (i.e. the + phase-space position(s) at the final timestep). Default is True. + **time_spec + Specification of how long to integrate. See documentation + for `~gala.integrate.parse_time_specification`. + + Returns + ------- + orbit : `~gala.dynamics.Orbit` + + """ + from ..hamiltonian import Hamiltonian + + return Hamiltonian(self).integrate_orbit(*args, **kwargs) + + def save(self, f): + """ + Save the potential to a text file. See :func:`~gala.potential.save` + for more information. + + Parameters + ---------- + f : str, file_like + A filename or file-like object to write the input potential object to. + + """ + from .io import save + + save(self, f) + + @property + def units(self): + return self._units + + def replace_units(self, units, copy=True): + """Change the unit system of this potential. + + Parameters + ---------- + units : `~gala.units.UnitSystem`, str + Set of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + copy : bool (optional) + If True, returns a copy, if False, changes this object. + """ + pot = pycopy.deepcopy(self) if copy else self + + units = self._validate_units(units) + + # TODO: this is repeated code - see equivalent in cpotential.pyx + tmp = [ + isinstance(units, DimensionlessUnitSystem), + isinstance(self.units, DimensionlessUnitSystem), + ] + if not all(tmp) and any(tmp): + raise ValueError( + "Cannot replace a dimensionless unit system with " + "a unit system with physical units, or vice versa" + ) + + parameters = { + k: v + for k, v in self.parameters.items() + if k not in self.parameter_is_default + } + PotentialBase.__init__( + pot, origin=self.origin, R=self.R, units=units, **parameters + ) + + return pot + + ########################################################################### + # Interoperability with other packages + # + def as_interop(self, package, **kwargs): + """Interoperability with other Galactic dynamics packages + + Parameters + ---------- + package : str + The package to export the potential to. Currently supported packages are + ``"galpy"`` and ``"agama"``. + kwargs + Any additional keyword arguments are passed to the interop function. + """ + if package == "galpy": + from .interop import gala_to_galpy_potential + + kwargs.setdefault("ro", None) + kwargs.setdefault("vo", None) + return gala_to_galpy_potential(self, **kwargs) + if package == "agama": + import agama + + from .interop import gala_to_agama_potential + + agama_pot = gala_to_agama_potential(self, **kwargs) + if not isinstance(agama_pot, agama.Potential): + agama_pot = agama.Potential(*agama_pot) + return agama_pot + raise ValueError(f"Unsupported package: {package}") + + +class CompositePotential(PotentialBase, OrderedDict): + """ + A gravitational potential composed of multiple distinct components. + + This class allows combining multiple gravitational potential models + into a single potential. This is useful for modeling complex systems + like galaxies, where you might combine a disk, bulge, and dark matter + halo, each represented by different potential models. + + The `CompositePotential` behaves like a Python dictionary where each + key-value pair represents a named component and its potential model. + All components must have compatible unit systems and the same number + of spatial dimensions. + + Parameters + ---------- + **kwargs : dict + Keyword arguments where each key is a component name (string) and + each value is a `~gala.potential.PotentialBase` instance. + + Attributes + ---------- + lock : bool + If ``True``, prevents adding new components or modifying existing ones. + + Examples + -------- + Create a composite potential with named components:: + + >>> import astropy.units as u + >>> from gala.potential import HernquistPotential, NFWPotential + >>> from gala.units import galactic + >>> bulge = HernquistPotential(m=1E10*u.Msun, c=1*u.kpc, units=galactic) + >>> halo = NFWPotential(m=1E12*u.Msun, r_s=20*u.kpc, units=galactic) + >>> mw = CompositePotential(bulge=bulge, halo=halo) + + Or build it step by step to preserve component order:: + + >>> mw = CompositePotential() + >>> mw['bulge'] = bulge + >>> mw['halo'] = halo + + Access individual components:: + + >>> bulge_potential = mw['bulge'] + >>> total_energy = mw.energy(pos) # Sum of all components + + Notes + ----- + The potential energy, gradients, and other quantities are computed as + the sum over all components. Each component maintains its own parameters + and can be accessed or modified independently (unless ``lock=True``). + + All components must have the same unit system and spatial dimensionality. + The composite potential inherits these properties from its components. + """ + + def __init__(self, *args, **kwargs): + self._units = None + self.ndim = None + + if len(args) > 0 and isinstance(args[0], list): + for k, v in args[0]: + kwargs[k] = v + else: + for i, v in args: + kwargs[str(i)] = v + + self.lock = False + for v in kwargs.values(): + self._check_component(v) + + OrderedDict.__init__(self, **kwargs) + + self.R = None # TODO: this is a little messy + self._update_symmetry() + + def __setitem__(self, key, value): + self._check_component(value) + super().__setitem__(key, value) + self._update_symmetry() + + def _check_component(self, p): + if not isinstance(p, PotentialBase): + msg = f"Potential components may only be Potential objects, not {type(p)}." + raise TypeError(msg) + + if self.units is None: + self._units = p.units + self.ndim = p.ndim + + else: + if sorted([str(x) for x in self.units]) != sorted( + [str(x) for x in p.units] + ): + raise ValueError( + "Unit system of new potential component must " + "match unit systems of other potential " + "components." + ) + + if p.ndim != self.ndim: + msg = ( + "All potential components must have the same " + f"number of phase-space dimensions ({self.ndim} in this " + "case)" + ) + raise ValueError(msg) + + if self.lock: + raise ValueError( + "Potential object is locked - new components can " + "only be added to unlocked potentials." + ) + + def _update_symmetry(self): + """ + Update the composite potential's symmetry based on its components. + + Logic: + - If all components are spherical -> composite is spherical + - If mix of spherical and cylindrical -> composite is cylindrical + - If any component has no symmetry -> composite has no symmetry + - If empty -> no symmetry + """ + from .symmetry import CylindricalSymmetry, SphericalSymmetry + + if len(self) == 0: + self._symmetry = None + return + + # Get all component symmetries + symmetries = [p._symmetry for p in self.values()] + + # If any component has no symmetry, composite has no symmetry + if any(s is None for s in symmetries): + self._symmetry = None + return + + # Categorize symmetries + has_spherical = any(isinstance(s, SphericalSymmetry) for s in symmetries) + has_cylindrical = any(isinstance(s, CylindricalSymmetry) for s in symmetries) + + # Check for unknown symmetry types + known_types = (SphericalSymmetry, CylindricalSymmetry) + if not all(isinstance(s, known_types) for s in symmetries): + # Unknown symmetry type - can't determine composite symmetry + self._symmetry = None + return + + # Apply rules: + # - All spherical -> spherical + # - Mix of spherical and cylindrical -> cylindrical + # - All cylindrical -> cylindrical + if has_cylindrical: + self._symmetry = CylindricalSymmetry() + elif has_spherical: + self._symmetry = SphericalSymmetry() + else: + # Shouldn't reach here, but default to no symmetry + self._symmetry = None + + @property + def parameters(self): + params = {} + for k, v in self.items(): + params[k] = v.parameters + return MappingProxyType(params) + + def replace_units(self, units): + """Change the unit system of this potential. + + Parameters + ---------- + units : `~gala.units.UnitSystem` + Set of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + """ + lock = self.lock + extra_args = {k: getattr(self, k) for k in self._extra_serialize_args} + pots = self.__class__(**extra_args) + + pots._units = None + pots.lock = False + + for k, v in self.items(): + pots[k] = v.replace_units(units) + + pots.lock = lock + return pots + + def _energy(self, q, t=0.0): + return np.sum([p._energy(q, t) for p in self.values()], axis=0) + + def _gradient(self, q, t=0.0): + return np.sum([p._gradient(q, t) for p in self.values()], axis=0) + + def _hessian(self, w, t=0.0): + return np.sum([p._hessian(w, t) for p in self.values()], axis=0) + + def _density(self, q, t=0.0): + return np.sum([p._density(q, t) for p in self.values()], axis=0) + + def __repr__(self): + return "".format(",".join(self.keys())) + + def replicate(self, **kwargs): + """ + Return a copy of the potential instance with some parameter values + changed. This always produces copies of any parameter arrays. + + Parameters + ---------- + **kwargs + All other keyword arguments are used to overwrite parameter values + when making the copy. The keywords passed in should be the same as + the potential component names, so you can pass in dictionaries to set + parameters for different subcomponents of this composite potential. + + Returns + ------- + replicant : `~gala.potential.PotentialBase` subclass instance + The replicated potential. + """ + obj = pycopy.copy(self) + + # disable potential lock + lock = obj.lock + obj.lock = False + + for k, v in kwargs.items(): + obj[k] = self[k].replicate(**v) + + obj.lock = lock + return obj + + +_potential_docstring = """units : `~gala.units.UnitSystem` (optional) + Set of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + origin : `~astropy.units.Quantity` (optional) + The origin of the potential, the default being 0. + R : `~scipy.spatial.transform.Rotation`, array_like (optional) + A Scipy ``Rotation`` object or an array representing a rotation matrix + that specifies a rotation of the potential. This is applied *after* the + origin shift. Default is the identity matrix. +""" diff --git a/gala/source/src/gala/potential/potential/cpotential.pxd b/gala/source/src/gala/potential/potential/cpotential.pxd new file mode 100644 index 0000000000000000000000000000000000000000..72921b0d2901787fb4e248520b565ff620f74484 --- /dev/null +++ b/gala/source/src/gala/potential/potential/cpotential.pxd @@ -0,0 +1,69 @@ +# cython: language_level=3 +# cython: language=c++ + +cdef extern from "src/funcdefs.h": + ctypedef double (*densityfunc)(double t, double *pars, double *q, int n_dim, void *state) except + nogil + ctypedef double (*energyfunc)(double t, double *pars, double *q, int n_dim, void *state) except + nogil + ctypedef void (*gradientfunc)(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) except + nogil + ctypedef void (*hessianfunc)(double t, double *pars, double *q, int n_dim, double *hess, void *state) except + nogil + +cdef extern from "potential/src/cpotential.h": + ctypedef struct CPotential: + int n_components # number of potential components + int n_dim # coordinate system dimensionality + int null # shortcut flag to skip evaluation + int* do_shift_rotate # shortcut flag to skip pos/vel transformation + densityfunc* density + energyfunc* value + gradientfunc* gradient + hessianfunc* hessian + + int* n_params # parameter counts per component + double** parameters # pointers to parameter arrays per component + double** q0 # pointers to origin per component + double** R # pointers to rotation per component + void **state # pointers to additional state/metadata information + + CPotential* allocate_cpotential(int n_components) + void free_cpotential(CPotential* p) nogil + int resize_cpotential_arrays(CPotential* p, int n_components) nogil + + double c_potential(CPotential *p, double t, double *q) except + nogil + double c_density(CPotential *p, double t, double *q) except + nogil + void c_gradient(CPotential *p, size_t N, double t, double *q, double *grad) except + nogil + void c_hessian(CPotential *p, double t, double *q, double *hess) except + nogil + + double c_d_dr(CPotential *p, double t, double *q, double *epsilon) except + nogil + double c_d2_dr2(CPotential *p, double t, double *q, double *epsilon) except + nogil + double c_mass_enclosed(CPotential *p, double t, double *q, double G, double *epsilon) except + nogil + + void c_nbody_gradient_symplectic( + CPotential **pots, double t, double *q, + double *nbody_q, int nbody, int nbody_i, + int ndim, double *grad + ) except + nogil + + void c_nbody_acceleration(CPotential **pots, double t, double *qp, + int norbits, int nbody, int ndim, double *acc) except + nogil + +cpdef _validate_pos_arr(double[:,::1] arr) + +cdef class CPotentialWrapper: + cdef CPotential* cpotential + cdef double[::1] _params + cdef int[::1] _n_params + cdef list _potentials # HACK: for CCompositePotentialWrapper + cdef double[::1] _q0 + cdef double[::1] _R + + cpdef init(self, list parameters, double[::1] q0, double[:, ::1] R, + int n_dim=?) + + cpdef energy(self, double[:,::1] q, double[::1] t) + cpdef density(self, double[:,::1] q, double[::1] t) + cpdef gradient(self, double[:,::1] q, double[::1] t) + cpdef hessian(self, double[:,::1] q, double[::1] t) + + cpdef d_dr(self, double[:,::1] q, double G, double[::1] t) + cpdef d2_dr2(self, double[:,::1] q, double G, double[::1] t) + cpdef mass_enclosed(self, double[:,::1] q, double G, double[::1] t) diff --git a/gala/source/src/gala/potential/potential/cpotential.pyx b/gala/source/src/gala/potential/potential/cpotential.pyx new file mode 100644 index 0000000000000000000000000000000000000000..5405642680e65e5284e3cf6342748c4918e1f099 --- /dev/null +++ b/gala/source/src/gala/potential/potential/cpotential.pyx @@ -0,0 +1,433 @@ +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + + +import copy as pycopy +import sys +import warnings +import uuid + + +import numpy as np +cimport numpy as np +np.import_array() +import cython +cimport cython + +from libc.stdio cimport printf + + +from .builtin.cybuiltin cimport nan_density, nan_value, nan_gradient, nan_hessian +from .core import PotentialBase, CompositePotential +from ...util import atleast_2d +from ...units import DimensionlessUnitSystem + +cdef extern from "math.h": + double sqrt(double x) nogil + double fabs(double x) nogil + +__all__ = ['CPotentialBase'] + + +cpdef _validate_pos_arr(double[:, ::1] arr): + if arr.ndim != 2: + raise ValueError("Phase-space coordinate array must have 2 dimensions") + return arr.shape[0], arr.shape[1] + +cdef class CPotentialWrapper: + """ + Wrapper class for C implementation of potentials. At the C layer, potentials + are effectively struct's that maintain pointers to functions specific to a + given potential. This provides a Cython wrapper around this C implementation. + """ + + def __cinit__(self): + # Allocate a CPotential with one component by default + self.cpotential = allocate_cpotential(1) + + def __dealloc__(self): + if self.cpotential != NULL: + free_cpotential(self.cpotential) + + cpdef init(self, list parameters, double[::1] q0, double[:, ::1] R, + int n_dim=3): + + # save the array of parameters so it doesn't get garbage-collected + self._params = np.array(parameters, dtype=np.float64) + + # an array of number of parameter counts for composite potentials + self._n_params = np.array([len(self._params)], dtype=np.int32) + + # store pointers to the above arrays + self.cpotential.n_params[0] = len(self._params) + self.cpotential.parameters[0] = &(self._params[0]) + + # phase-space half-dimensionality of the potential + self.cpotential.n_dim = n_dim + + # by default, don't skip this potential! + self.cpotential.null = 0 + + # set the function pointers to nan defaults + self.cpotential.value[0] = (nan_value) + self.cpotential.density[0] = (nan_density) + self.cpotential.gradient[0] = (nan_gradient) + self.cpotential.hessian[0] = (nan_hessian) + + # set the origin of the potentials + _q0 = np.array(q0) + self._q0 = _q0 + assert len(self._q0) == n_dim + self.cpotential.q0[0] = &(self._q0[0]) + + # set the rotation matrix of the potentials + _R = np.array(R) + self._R = np.ascontiguousarray(_R.ravel()) + self.cpotential.R[0] = &(self._R[0]) + + # No state by default + # We do not store a self._state under the assumption that the subclass + # will hold a typed state object (e.g. exp_state) + self.cpotential.state[0] = NULL + + # set a short-circuit flag if no shift/rotate is necessary + if np.all(_q0 == 0.0) and np.all(_R == np.eye(n_dim)): + self.cpotential.do_shift_rotate[0] = 0 + else: + self.cpotential.do_shift_rotate[0] = 1 + + cpdef energy(self, double[:, ::1] q, double[::1] t): + """ + CAUTION: Interpretation of axes is different here! We need the + arrays to be C ordered and easy to iterate over, so here the + axes are (norbits, ndim). + """ + cdef int n, ndim, i + n, ndim = _validate_pos_arr(q) + + cdef double [::1] pot = np.zeros(n) + + if len(t) == 1: + for i in range(n): + pot[i] = c_potential(self.cpotential, t[0], &q[i, 0]) + else: + for i in range(n): + pot[i] = c_potential(self.cpotential, t[i], &q[i, 0]) + + return np.array(pot) + + cpdef density(self, double[:, ::1] q, double[::1] t): + """ + CAUTION: Interpretation of axes is different here! We need the + arrays to be C ordered and easy to iterate over, so here the + axes are (norbits, ndim). + """ + cdef int n, ndim, i + n, ndim = _validate_pos_arr(q) + + cdef double [::1] dens = np.zeros(n) + + if len(t) == 1: + for i in range(n): + dens[i] = c_density(self.cpotential, t[0], &q[i, 0]) + else: + for i in range(n): + dens[i] = c_density(self.cpotential, t[i], &q[i, 0]) + + return np.array(dens) + + cpdef gradient(self, double[:, ::1] q, double[::1] t): + """ + q: shape (ndim, n) + t: shape (n,) or (1,) + returns: shape (ndim, n) + """ + cdef int n, ndim, i + ndim, n = _validate_pos_arr(q) + + cdef double[:, ::1] grad = np.zeros((ndim, n)) + + if len(t) == 1: + c_gradient(self.cpotential, n, t[0], &q[0, 0], &grad[0, 0]) + else: + # TODO: optimize the multi-time case (probably not relevant for orbit integrations) + for i in range(n): + c_gradient(self.cpotential, 1, t[i], &q[0, i], &grad[0, i]) + + return np.array(grad, copy=False) + + cpdef hessian(self, double[:, ::1] q, double[::1] t): + """ + CAUTION: Interpretation of axes is different here! We need the + arrays to be C ordered and easy to iterate over, so here the + axes are (norbits, ndim). + """ + cdef int n, ndim, i + n, ndim = _validate_pos_arr(q) + + cdef double[:, :, ::1] hess = np.zeros((n, ndim, ndim)) + + if len(t) == 1: + for i in range(n): + c_hessian(self.cpotential, t[0], &q[i, 0], &hess[i, 0, 0]) + else: + for i in range(n): + c_hessian(self.cpotential, t[i], &q[i, 0], &hess[i, 0, 0]) + + return np.array(hess) + + # ------------------------------------------------------------------------ + # Other functionality + # + cpdef d_dr(self, double[:, ::1] q, double G, double[::1] t): + """ + CAUTION: Interpretation of axes is different here! We need the + arrays to be C ordered and easy to iterate over, so here the + axes are (norbits, ndim). + """ + cdef int n, ndim, i + n, ndim = _validate_pos_arr(q) + + cdef double [::1] dr = np.zeros(n, dtype=np.float64) + cdef double [::1] epsilon = np.zeros(ndim, dtype=np.float64) + + if len(t) == 1: + for i in range(n): + dr[i] = c_d_dr(self.cpotential, t[0], &q[i, 0], &epsilon[0]) + else: + for i in range(n): + dr[i] = c_d_dr(self.cpotential, t[i], &q[i, 0], &epsilon[0]) + + return np.array(dr) + + cpdef d2_dr2(self, double[:, ::1] q, double G, double[::1] t): + """ + CAUTION: Interpretation of axes is different here! We need the + arrays to be C ordered and easy to iterate over, so here the + axes are (norbits, ndim). + """ + cdef int n, ndim, i + n, ndim = _validate_pos_arr(q) + + cdef double [::1] dr2 = np.zeros(n, dtype=np.float64) + cdef double [::1] epsilon = np.zeros(ndim, dtype=np.float64) + + if len(t) == 1: + for i in range(n): + dr2[i] = c_d2_dr2(self.cpotential, t[0], &q[i, 0], &epsilon[0]) + else: + for i in range(n): + dr2[i] = c_d2_dr2(self.cpotential, t[i], &q[i, 0], &epsilon[0]) + + return np.array(dr2) + + cpdef mass_enclosed(self, double[:, ::1] q, double G, double[::1] t): + """ + CAUTION: Interpretation of axes is different here! We need the + arrays to be C ordered and easy to iterate over, so here the + axes are (norbits, ndim). + """ + cdef int n, ndim, i + n, ndim = _validate_pos_arr(q) + + cdef double [::1] mass = np.zeros(n, dtype=np.float64) + cdef double [::1] epsilon = np.zeros(ndim, dtype=np.float64) + + if len(t) == 1: + for i in range(n): + mass[i] = c_mass_enclosed(self.cpotential, t[0], &q[i, 0], G, &epsilon[0]) + else: + for i in range(n): + mass[i] = c_mass_enclosed(self.cpotential, t[i], &q[i, 0], G, &epsilon[0]) + + return np.array(mass) + + # For pickling in Python 2 + def __reduce__(self): + return (self.__class__, + (self._params[0], list(self._params[1:]), + np.array(self._q0), + np.array(self._R).reshape(self.cpotential.n_dim, + self.cpotential.n_dim))) + +# ---------------------------------------------------------------------------- + +# TODO: docstrings are now fucked for energy, gradient, etc. + +class CPotentialBase(PotentialBase): + """ + A baseclass for defining gravitational potentials implemented in C. + """ + Wrapper = None + + def __init__(self, *args, units=None, origin=None, R=None, + Wrapper_kwargs=None, **kwargs + ): + if Wrapper_kwargs is None: + Wrapper_kwargs = {} + + super().__init__(*args, + units=units, + origin=origin, + R=R, + **kwargs) + self._setup_wrapper(**Wrapper_kwargs) + + def _setup_wrapper(self, c_only_parameters=None, **kwargs): + if self.Wrapper is None: + raise ValueError("C potential wrapper class not defined for " + f"potential class {self.__class__}") + + if c_only_parameters is None: + c_only_parameters = {} + + arrs = [] + for k, v in c_only_parameters.items(): + arrs.append(np.atleast_1d(v).ravel()) + + # to support array parameters, but they get unraveled + kwargs = dict(kwargs) + for k, v in self.parameters.items(): + if self._parameters[k].python_only: + continue + + # TODO: this is sloppy - need a better way to identify array parameters + if hasattr(v, "unit"): + arrs.append(np.atleast_1d(v.value).ravel()) + else: + kwargs[k] = v + + if len(arrs) > 0: + self.c_parameters = np.concatenate(arrs) + else: + self.c_parameters = np.array([]) + + if self.R is None: + self._R = np.eye(self.ndim) + else: + self._R = self.R + self.c_instance = self.Wrapper(self.G, self.c_parameters, + q0=self.origin, R=self._R, + **kwargs) + + def _energy(self, q, t): + return self.c_instance.energy(q, t=t) + + def _gradient(self, q, t): + return self.c_instance.gradient(q, t=t) + + def _density(self, q, t): + return self.c_instance.density(q, t=t) + + def _hessian(self, q, t): + return self.c_instance.hessian(q, t=t) + + # ---------------------------------------------------------- + # Overwrite the Python potential method to use Cython method + def mass_enclosed(self, q=None, t=0., **coord_kwargs): + """ + mass_enclosed(q, t) + + Estimate the mass enclosed within the given position by assuming the potential + is spherical. This is not so good! + + Parameters + ---------- + q : array_like, numeric + Position to compute the mass enclosed. + """ + q = self._process_position_argument(q, coord_kwargs) + orig_shape, q = self._get_c_valid_arr(q) + t = self._validate_prepare_time(t, len(q)) + + sgn = 1. + if 'm' in self.parameters and self.parameters['m'] < 0: + sgn = -1. + + try: + menc = self.c_instance.mass_enclosed(q, self.G, t=t) + except AttributeError, TypeError: + raise ValueError("Potential C instance has no defined " + "mass_enclosed function") + + return sgn * menc.reshape(orig_shape[1:]) * self.units['mass'] + + def __add__(self, other): + """ + If all components are Cython, return a CCompositePotential. + Otherwise, return a standard CompositePotential. + """ + from .ccompositepotential import CCompositePotential + + if not isinstance(other, PotentialBase): + raise TypeError('Cannot add a {} to a {}' + .format(self.__class__.__name__, + other.__class__.__name__)) + + components = dict() + + if isinstance(self, CompositePotential): + for k, v in self.items(): + components[k] = v + + else: + k = str(uuid.uuid4()) + components[k] = self + + if isinstance(other, CompositePotential): + for k, v in self.items(): + if k in components: + raise KeyError('Potential component "{}" already exists --' + 'duplicate key provided in potential ' + 'addition') + components[k] = v + + else: + k = str(uuid.uuid4()) + components[k] = other + + cython_only = True + for k, pot in components.items(): + if not isinstance(pot, CPotentialBase): + cython_only = False + break + + if cython_only: + new_pot = CCompositePotential() + else: + new_pot = CompositePotential() + + for k, pot in components.items(): + new_pot[k] = pot + + return new_pot + + def replace_units(self, units): + """Change the unit system of this potential. + + Parameters + ---------- + units : `~gala.units.UnitSystem` + Set of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + """ + + # TODO: this is repeated code - see equivalent in core.py + tmp = [isinstance(units, DimensionlessUnitSystem), + isinstance(self.units, DimensionlessUnitSystem)] + if not all(tmp) and any(tmp): + raise ValueError("Cannot replace a dimensionless unit system with " + "a unit system with physical units, or vice versa") + + parameters = { + k: v for k, v in self.parameters.items() if k not in self.parameter_is_default + } + + return self.__class__(**parameters, units=units, + R=self.R, origin=self.origin, + ) diff --git a/gala/source/src/gala/potential/potential/interop.py b/gala/source/src/gala/potential/potential/interop.py new file mode 100644 index 0000000000000000000000000000000000000000..5f044b1a8f4101eabf5ab500fd5db2b6711b44d9 --- /dev/null +++ b/gala/source/src/gala/potential/potential/interop.py @@ -0,0 +1,435 @@ +"""Interoperability with other dynamics packages""" + +import inspect +import warnings + +import astropy.units as u +import numpy as np +from astropy.constants import G + +import gala.potential.potential.builtin as gp +from gala._optional_deps import HAS_AGAMA, HAS_GALPY +from gala.potential.potential.ccompositepotential import CCompositePotential +from gala.potential.potential.core import CompositePotential +from gala.units import galactic + +__all__ = [ + "gala_to_agama_potential", + "gala_to_galpy_potential", + "galpy_to_gala_potential", +] + +############################################################################### +# Galpy interoperability +# + +if HAS_GALPY: + import galpy.potential as galpy_gp + from scipy.special import gamma + + def _powerlaw_amp_to_galpy(pars, ro, vo): + # I don't really remember why this is like this, but it might be related + # to the difference between GSL gamma and scipy gamma?? + fac = ( + 1 + / (2 * np.pi) + * pars["r_c"].to_value(ro) ** (pars["alpha"] - 3) + / (gamma(3 / 2 - pars["alpha"] / 2)) + ) + return fac * (G * pars["m"]).to_value(vo**2 * ro) + + def _powerlaw_m_from_galpy(pars, ro, vo): + # See note above! + fac = ( + 1 + / (2 * np.pi) + * pars["rc"] ** (pars["alpha"] - 3) + / (gamma(3 / 2 - pars["alpha"] / 2)) + ) + amp = pars["amp"] * vo**2 * ro + return amp / G / fac + + def _mn3_amp_to_galpy(pars, ro, vo): + num = (G * pars["m"]).to_value(ro * vo**2) + den = 4 * np.pi * pars["h_R"].to_value(ro) ** 2 * pars["h_z"].to_value(ro) + return num / den + + # TODO: some potential conversions drop parameters. Might want to add an + # option for a custom validator function or something to raise warnings? + _gala_to_galpy = { + gp.HernquistPotential: ( + galpy_gp.HernquistPotential, + { + "a": "c", + "amp": lambda pars, ro, vo: (G * 2 * pars["m"]).to_value(ro * vo**2), + }, + ), + gp.IsochronePotential: (galpy_gp.IsochronePotential, {"b": "b"}), + gp.JaffePotential: (galpy_gp.JaffePotential, {"a": "c"}), + gp.KeplerPotential: (galpy_gp.KeplerPotential, {}), + gp.KuzminPotential: ( + galpy_gp.KuzminDiskPotential, + { + "a": "a", + }, + ), + gp.LogarithmicPotential: ( + galpy_gp.LogarithmicHaloPotential, + { + "amp": lambda pars, ro, vo: pars["v_c"].to_value(vo) ** 2, + "core": "r_h", + "q": "q3", + }, + ), + gp.LongMuraliBarPotential: ( + galpy_gp.SoftenedNeedleBarPotential, + {"a": "a", "b": "b", "c": "c", "pa": "alpha"}, + ), + gp.MiyamotoNagaiPotential: ( + galpy_gp.MiyamotoNagaiPotential, + {"a": "a", "b": "b"}, + ), + gp.MN3ExponentialDiskPotential: ( + galpy_gp.MN3ExponentialDiskPotential, + { + "amp": _mn3_amp_to_galpy, + "hr": "h_R", + "hz": "h_z", + "posdens": "positive_density", + "sech": "sech2_z", + }, + ), + gp.NFWPotential: ( + galpy_gp.TriaxialNFWPotential, + { + "a": "r_s", + "b": lambda pars, *_: pars["b"] / pars["a"], + "c": lambda pars, *_: pars["c"] / pars["a"], + }, + ), + gp.PlummerPotential: (galpy_gp.PlummerPotential, {"b": "b"}), + gp.PowerLawCutoffPotential: ( + galpy_gp.PowerSphericalPotentialwCutoff, + {"amp": _powerlaw_amp_to_galpy, "rc": "r_c", "alpha": "alpha"}, + ), + } + + _galpy_to_gala = {} + for gala_cls, (galpy_cls, pars) in _gala_to_galpy.items(): + galpy_pars = { + v: k + for k, v in pars.items() + if isinstance(v, str | int | float | np.ndarray) + } + _galpy_to_gala[galpy_cls] = (gala_cls, galpy_pars) + + # Special cases: + _galpy_to_gala[galpy_gp.HernquistPotential][1]["m"] = lambda pars, ro, vo: ( + pars["amp"] * ro * vo**2 / G / 2 + ) + + _galpy_to_gala[galpy_gp.LogarithmicHaloPotential][1]["v_c"] = ( + lambda pars, ro, vo: np.sqrt(pars["amp"] * vo**2) + ) + + _galpy_to_gala[galpy_gp.TriaxialNFWPotential][1]["m"] = lambda pars, ro, vo: ( + pars["amp"] * ro * vo**2 / G * 4 * np.pi * pars["a"] ** 3 + ) + _galpy_to_gala[galpy_gp.TriaxialNFWPotential][1]["a"] = 1.0 + _galpy_to_gala[galpy_gp.TriaxialNFWPotential][1]["b"] = "b" + _galpy_to_gala[galpy_gp.TriaxialNFWPotential][1]["c"] = "c" + + _galpy_to_gala[galpy_gp.PowerSphericalPotentialwCutoff][1]["m"] = ( + _powerlaw_m_from_galpy + ) + + _galpy_to_gala[galpy_gp.NFWPotential] = ( + gp.NFWPotential, + { + "r_s": "a", + }, + ) + +if HAS_AGAMA: + # TODO: some potential conversions drop parameters. Might want to add an + # option for a custom validator function or something to raise warnings? + _gala_to_agama = { + gp.HernquistPotential: { + "type": "dehnen", + "mass": "m", + "scaleradius": "c", + "gamma": 1.0, + }, + gp.IsochronePotential: {"type": "isochrone", "mass": "m", "scaleradius": "b"}, + gp.JaffePotential: { + "type": "dehnen", + "mass": "m", + "scaleradius": "c", + "gamma": 2.0, + }, + # gp.KeplerPotential: {}, + # gp.KuzminPotential: {}, + gp.LogarithmicPotential: { + "type": "logarithmic", + "v0": "v_c", + "scaleradius": "r_h", + "axisRatioY": "q2", + "axisRatioZ": "q3", + }, + # gp.LongMuraliBarPotential: {}, + gp.MiyamotoNagaiPotential: { + "type": "miyamotonagai", + "mass": "m", + "scaleradius": "a", + "scaleheight": "b", + }, + # gp.MN3ExponentialDiskPotential: {}, # Special cased below + gp.NFWPotential: {"type": "nfw", "mass": "m", "scaleradius": "r_s"}, + gp.PlummerPotential: {"type": "plummer", "mass": "m", "scaleradius": "b"}, + # gp.PowerLawCutoffPotential: {} + } + + +def _get_ro_vo(ro, vo): + # If not specified, get the default ro, vo from Galpy + if ro is None or vo is None: + from galpy.potential import Force + + f = Force() + + if ro is None: + ro = f._ro * u.kpc + if vo is None: + vo = f._vo * u.km / u.s + + return u.Quantity(ro), u.Quantity(vo) + + +def gala_to_galpy_potential(potential, ro=None, vo=None): + if not HAS_GALPY: + raise ImportError( + "Failed to import galpy.potential: Converting a potential to a galpy " + "potential requires galpy to be installed." + ) + + ro, vo = _get_ro_vo(ro, vo) + + if isinstance(potential, CompositePotential): + pot = [] + for k in potential: + pot.append(gala_to_galpy_potential(potential[k], ro, vo)) + + else: + if potential.__class__ not in _gala_to_galpy: + raise TypeError( + f"Converting potential class {potential.__class__.__name__} " + "to galpy is currently not supported" + ) + + galpy_cls, converters = _gala_to_galpy[potential.__class__] + gala_pars = potential.parameters.copy() + + galpy_pars = {} + if "amp" not in converters and "m" not in gala_pars: + raise ValueError( + "Gala potential has no mass parameter, so converting to a Galpy " + "potential is currently not supported." + ) + + if isinstance(potential, gp.MN3ExponentialDiskPotential): + gala_pars["positive_density"] = potential.positive_density + gala_pars["sech2_z"] = potential.sech2_z + + converters.setdefault( + "amp", lambda pars, ro, vo: (G * pars["m"]).to_value(ro * vo**2) + ) + + for galpy_par_name, conv in converters.items(): + if isinstance(conv, str): + galpy_pars[galpy_par_name] = gala_pars[conv] + elif callable(conv): + galpy_pars[galpy_par_name] = conv(gala_pars, ro, vo) + elif isinstance(conv, int | float | u.Quantity | np.ndarray): + galpy_pars[galpy_par_name] = conv + else: + # TODO: invalid parameter?? + pass + + par = galpy_pars[galpy_par_name] + if hasattr(par, "unit"): + if par.unit.physical_type == "length": + galpy_pars[galpy_par_name] = par.to_value(ro) + elif par.unit.physical_type == "speed": + galpy_pars[galpy_par_name] = par.to_value(vo) + elif par.unit.physical_type == "dimensionless": + galpy_pars[galpy_par_name] = par.value + elif par.unit.physical_type == "angle": + galpy_pars[galpy_par_name] = par.to_value(u.rad) + else: + warnings.warn( + f"Unknown unit physical type '{par.unit.physical_type}'" + " - this should have a custom unit converter. Please " + "make a GitHub issue!", + RuntimeWarning, + ) + galpy_pars[galpy_par_name] = par.value + + pot = galpy_cls(**galpy_pars, ro=ro, vo=vo) + + return pot + + +def galpy_to_gala_potential(potential, ro=None, vo=None, units=galactic): + if not HAS_GALPY: + raise ImportError( + "Failed to import galpy.potential: Converting a potential to a " + "gala potential requires galpy to be installed." + ) + + ro, vo = _get_ro_vo(ro, vo) + + if potential._roSet: + ro = potential._ro * u.kpc + if potential._voSet: + vo = potential._vo * u.km / u.s + + if isinstance(potential, list): + pot = CCompositePotential() + for i, sub_pot in enumerate(potential): + pot[str(i)] = galpy_to_gala_potential(sub_pot, ro, vo) + + else: + if potential.__class__ not in _galpy_to_gala: + raise TypeError( + f"Converting galpy potential {potential.__class__.__name__} " + "to gala is currently not supported" + ) + if isinstance(potential, galpy_gp.MN3ExponentialDiskPotential): + warnings.warn( + "For the MN3ExponentialDiskPotential, galpy does not store " + "information to fully reconstruct the potential, so the " + "default gala choices will be adopted for the " + "'positive_density' and 'sech2_z' potential arguments", + RuntimeWarning, + ) + + gala_cls, converters = _galpy_to_gala[potential.__class__] + + exclude = ["self", "normalize", "ro", "vo"] + spec = inspect.getfullargspec(potential.__class__) + par_names = [arg for arg in spec.args if arg not in exclude] + + # UGH! + galpy_pars = {} + for name in par_names: + galpy_pars[name] = getattr( + potential, "_" + name, getattr(potential, name, None) + ) + + if isinstance(potential, galpy_gp.LogarithmicHaloPotential): + galpy_pars["core"] = np.sqrt(potential._core2) + + elif isinstance(potential, galpy_gp.SoftenedNeedleBarPotential): + galpy_pars["c"] = np.sqrt(potential._c2) + + if "m" in inspect.getfullargspec(gala_cls).args: + converters.setdefault( + "m", lambda pars, ro, vo: pars["amp"] * ro * vo**2 / G + ) + + gala_pars = {} + for gala_par_name, conv in converters.items(): + if isinstance(conv, str): + gala_pars[gala_par_name] = galpy_pars[conv] + elif callable(conv): + gala_pars[gala_par_name] = conv(galpy_pars, ro, vo) + elif isinstance(conv, int | float | u.Quantity | np.ndarray): + gala_pars[gala_par_name] = conv + else: + # TODO: invalid parameter?? + pass + + if hasattr(gala_pars[gala_par_name], "unit"): + continue + + if gala_par_name not in gala_cls._parameters: + continue + + gala_par = gala_cls._parameters[gala_par_name] + if gala_par.physical_type == "mass": + gala_pars[gala_par_name] *= u.Msun + elif gala_par.physical_type == "length": + gala_pars[gala_par_name] *= ro + elif gala_par.physical_type == "speed": + gala_pars[gala_par_name] *= vo + elif gala_par.physical_type == "angle": + gala_pars[gala_par_name] *= u.radian + elif gala_par.physical_type == "dimensionless": + pass + + pot = gala_cls(**gala_pars, units=units) + + return pot + + +def gala_to_agama_potential(potential): + if not HAS_AGAMA: + raise ImportError( + "Failed to import agama: Converting a potential to an Agama potential " + "requires Agama to be installed." + ) + + import agama + + units = { + "length": (1 * potential.units["length"]).to(u.kpc).value, + "mass": (1 * potential.units["mass"]).to(u.Msun).value, + "time": (1 * potential.units["time"]).to(u.Myr).value, + } + agama.setUnits(**units) + + if isinstance(potential, CompositePotential): + pot = [] + for k in potential: + agama_pot = gala_to_agama_potential(potential[k]) + if isinstance(agama_pot, list): + pot.extend(agama_pot) + else: + pot.append(agama_pot) + + elif isinstance(potential, gp.MN3ExponentialDiskPotential): + pot = [] + for disk in potential.get_three_potentials().values(): + pot.append(gala_to_agama_potential(disk)) + + else: + if potential.__class__ not in _gala_to_agama: + raise TypeError( + f"Converting potential class {potential.__class__.__name__} " + "to agama is currently not supported" + ) + + agama_spec = _gala_to_agama[potential.__class__] + gala_pars = potential.parameters.copy() + + agama_pars = {"type": agama_spec["type"]} + for agama_par_name, conv in agama_spec.items(): + if agama_par_name == "type": + continue + if isinstance(conv, str): + agama_pars[agama_par_name] = gala_pars[conv] + # elif hasattr(conv, "__call__"): + # agama_pars[agama_par_name] = conv(gala_pars) + elif isinstance(conv, int | float | u.Quantity | np.ndarray): + agama_pars[agama_par_name] = conv + else: + # TODO: invalid parameter?? + pass + + for k, v in agama_pars.items(): + if hasattr(v, "unit"): + agama_pars[k] = v.decompose(potential.units).value + + pot = agama.Potential(**agama_pars) + + return pot diff --git a/gala/source/src/gala/potential/potential/io.py b/gala/source/src/gala/potential/potential/io.py new file mode 100644 index 0000000000000000000000000000000000000000..a160446ecf57ec4bdbc38643d542bf89bd8e7844 --- /dev/null +++ b/gala/source/src/gala/potential/potential/io.py @@ -0,0 +1,366 @@ +"""Read and write potentials to text (YAML) files.""" + +import os + +import astropy.units as u +import numpy as np +import yaml + +from gala.units import DimensionlessUnitSystem + +__all__ = ["load", "save"] + + +def _unpack_params(p): + params = p.copy() + for key, item in p.items(): + if "_unit" in key: + continue + + if np.iterable(item) and not isinstance(item, str): + params[key] = np.array(item).astype(float) + elif isinstance(item, str): + params[key] = item + else: + try: + params[key] = float(item) + except Exception: + params[key] = item + + if key + "_unit" in params: + params[key] *= u.Unit(params[key + "_unit"]) + del params[key + "_unit"] + + return params + + +def _parse_component(component, module): + # need this here for circular import + from .. import potential as gala_potential + + try: + class_name = component["class"] + except KeyError as e: + raise KeyError( + "Potential dictionary must contain a key 'class' for " + "specifying the name of the Potential class." + ) from e + + if "units" not in component: + unitsys = None + else: + try: + unitsys = [u.Unit(unit) for ptype, unit in component["units"].items()] + except KeyError as e: + raise KeyError( + "Potential dictionary must contain a key 'units' " + "with a list of strings specifying the unit system." + ) from e + + params = component.get("parameters", {}) + + # need to crawl the dictionary structure and unpack quantities + params = _unpack_params(params) + + potential = gala_potential if module is None else module + + try: + Potential = getattr(potential, class_name) + except AttributeError: # HACK: this might be bad to assume + Potential = getattr(gala_potential, class_name) + + # Add any extra potential arguments to the params kwargs + params = {**params, **component.get("extra_args", {})} + + return Potential(units=unitsys, **params) + + +def from_dict(d, module=None): + """ + Convert a dictionary potential specification into a potential object. + + This function parses a dictionary representation of a potential and + creates the corresponding :class:`~gala.potential.PotentialBase` + subclass instance. Supports both simple potentials and composite + potentials with multiple components. + + Parameters + ---------- + d : dict + Dictionary specification of a potential. Must contain at minimum + a 'class' key specifying the potential class name. For composite + potentials, should include 'type': 'composite' and a 'components' + list of component dictionaries. + module : module, optional + Python module namespace to search for potential classes. If not + provided, uses `gala.potential`. + + Returns + ------- + potential : `~gala.potential.PotentialBase` + The instantiated potential object. + + Examples + -------- + Create a simple Hernquist potential:: + + >>> pot_dict = {'class': 'HernquistPotential', 'm': 1e11, 'c': 2.0} + >>> pot = from_dict(pot_dict) + + Create a composite potential:: + + >>> comp_dict = { + ... 'type': 'composite', + ... 'class': 'CompositePotential', + ... 'components': [ + ... {'class': 'HernquistPotential', 'm': 1e10, 'c': 1.0}, + ... {'class': 'NFWPotential', 'm': 1e12, 'r_s': 20.0} + ... ] + ... } + >>> comp_pot = from_dict(comp_dict) + """ + + # need this here for circular import issues + import gala.potential as gala_potential + + potential = gala_potential if module is None else module + + if "type" in d and d["type"] == "composite": + p = getattr(potential, d["class"])() + for i, component in enumerate(d["components"]): + c = _parse_component(component, module) + name = component.get("name", str(i)) + p[name] = c + + elif "type" in d and d["type"] == "custom": + param_groups = {} + for component in d["components"]: + c = _parse_component(component, module) + + try: + name = component["name"] + except KeyError as e: + raise KeyError( + "For custom potentials, component specification " + "must include the component name (e.g., name: " + "'blah')" + ) from e + + params = component.get("parameters", {}) + params = _unpack_params(params) # unpack quantities + param_groups[name] = params + + # Append any extra arguments + param_groups = {**param_groups, **d.get("extra_args", {})} + + p = getattr(potential, d["class"])(**param_groups) + + else: + p = _parse_component(d, module) + + return p + + +# ---------------------------------------------------------------------------- + + +def _pack_params(p): + params = p.copy() + for key, item in p.items(): + if hasattr(item, "unit"): + params[key] = item.value + params[key + "_unit"] = str(item.unit) + + if hasattr(params[key], "tolist"): # convert array to list + params[key] = params[key].tolist() + + return params + + +def _to_dict_help(potential): + d = {} + + d["class"] = potential.__class__.__name__ + + if not isinstance(potential.units, DimensionlessUnitSystem): + d["units"] = {str(k): str(v) for k, v in potential.units.to_dict().items()} + + if len(potential.parameters) > 0: + params = _pack_params(potential.parameters) + d["parameters"] = params + + if potential._extra_serialize_args: + d["extra_args"] = {} + for arg in potential._extra_serialize_args: + d["extra_args"][arg] = getattr(potential, arg) + + return d + + +def to_dict(potential): + """ + Convert a potential object into a dictionary representation. + + This function serializes a :class:`~gala.potential.PotentialBase` + object into a dictionary that fully specifies the potential's state, + including all parameters, units, and structure. The resulting + dictionary can be used to recreate the potential using + :func:`~gala.potential.io.from_dict`. + + Parameters + ---------- + potential : :class:`~gala.potential.PotentialBase` + The instantiated potential object to convert to dictionary form. + + Returns + ------- + pot_dict : dict + Dictionary representation of the potential containing the class + name, parameters, units, and (for composite potentials) component + structure. + + Examples + -------- + Convert a simple potential to dictionary:: + + >>> pot = HernquistPotential(m=1e11*u.Msun, c=2*u.kpc) + >>> pot_dict = to_dict(pot) + + Convert a composite potential:: + + >>> comp_pot = CompositePotential(bulge=hernquist, halo=nfw) + >>> comp_dict = to_dict(comp_pot) + + See Also + -------- + from_dict : Create potential object from dictionary representation. + save : Save potential to file. + """ + from .. import potential as gp + + if isinstance(potential, gp.CompositePotential): + d = {} + d["class"] = potential.__class__.__name__ + if potential._extra_serialize_args: + d["extra_args"] = {} + for arg in potential._extra_serialize_args: + d["extra_args"][arg] = getattr(potential, arg) + + d["components"] = [] + for k, p in potential.items(): + comp_dict = _to_dict_help(p) + comp_dict["name"] = k + d["components"].append(comp_dict) + + if potential.__class__.__name__ in { + "CompositePotential", + "CCompositePotential", + }: + d["type"] = "composite" + else: + d["type"] = "custom" + + else: + d = _to_dict_help(potential) + + return d + + +# ---------------------------------------------------------------------------- + + +def load(f, module=None): + """ + Load a potential from a YAML specification file. + + This function reads a YAML file containing a potential specification + and creates the corresponding :class:`~gala.potential.PotentialBase` + object. The file format should match the dictionary structure expected + by :func:`~gala.potential.io.from_dict`. + + Parameters + ---------- + f : str, file-like + Path to a YAML file, a block of YAML text, or a file-like object + containing the potential specification to parse and load. + module : module, optional + Python module namespace to search for potential classes. If not + provided, uses `gala.potential`. + + Returns + ------- + potential : :class:`~gala.potential.PotentialBase` + The loaded potential object. + + Examples + -------- + Load a potential from a YAML file:: + + >>> pot = load('my_potential.yml') + + Load from a YAML string:: + + >>> yaml_spec = ''' + ... class: HernquistPotential + ... parameters: + ... m: 1.0e11 + ... c: 2.0 + ... ''' + >>> pot = load(yaml_spec) + + See Also + -------- + save : Save potential to YAML file. + from_dict : Create potential from dictionary specification. + """ + if hasattr(f, "read"): + p_dict = yaml.load(f.read(), Loader=yaml.Loader) + else: + with open(os.path.abspath(f), encoding="utf-8") as fil: + p_dict = yaml.load(fil.read(), Loader=yaml.Loader) + + return from_dict(p_dict, module=module) + + +def save(potential, f): + """ + Save a potential object to a YAML file. + + This function serializes a :class:`~gala.potential.PotentialBase` + object to YAML format and writes it to a file. The resulting file + can be loaded using :func:`~gala.potential.io.load`. + + Parameters + ---------- + potential : :class:`~gala.potential.PotentialBase` + The potential object to save. + f : str, file-like + Output filename or file-like object to write the potential + specification to. + + Examples + -------- + Save a potential to file:: + + >>> pot = HernquistPotential(m=1e11*u.Msun, c=2*u.kpc) + >>> save(pot, 'hernquist_potential.yml') + + Save to a string buffer:: + + >>> from io import StringIO + >>> buffer = StringIO() + >>> save(pot, buffer) + >>> yaml_content = buffer.getvalue() + + See Also + -------- + load : Load potential from YAML file. + to_dict : Convert potential to dictionary representation. + """ + d = to_dict(potential) + + if hasattr(f, "write"): + yaml.dump(d, f, default_flow_style=None) + else: + with open(f, "w", encoding="utf-8") as f2: + yaml.dump(d, f2, default_flow_style=None) diff --git a/gala/source/src/gala/potential/potential/src/cpotential.cpp b/gala/source/src/gala/potential/potential/src/cpotential.cpp new file mode 100644 index 0000000000000000000000000000000000000000..d942f5f9e5c8db176ef507aec4e712f467517a80 --- /dev/null +++ b/gala/source/src/gala/potential/potential/src/cpotential.cpp @@ -0,0 +1,442 @@ +#include +#include +#include "cpotential.h" +#include "src/vectorization.h" + +CPotential* allocate_cpotential(int n_components) { + CPotential* p = (CPotential*)malloc(sizeof(CPotential)); + + p->n_components = n_components; + p->n_dim = 0; + p->null = 0; + + // Allocate arrays + p->density = (densityfunc*)malloc(n_components * sizeof(densityfunc)); + p->value = (energyfunc*)malloc(n_components * sizeof(energyfunc)); + p->gradient = (gradientfunc*)malloc(n_components * sizeof(gradientfunc)); + p->hessian = (hessianfunc*)malloc(n_components * sizeof(hessianfunc)); + p->n_params = (int*)malloc(n_components * sizeof(int)); + p->parameters = (double**)malloc(n_components * sizeof(double*)); + p->q0 = (double**)malloc(n_components * sizeof(double*)); + p->R = (double**)malloc(n_components * sizeof(double*)); + p->state = (void**)malloc(n_components * sizeof(void*)); + p->do_shift_rotate = (int*)malloc(n_components * sizeof(int)); + + // Initialize with NULL pointers + for (int i = 0; i < n_components; i++) { + p->parameters[i] = NULL; + p->q0[i] = NULL; + p->R[i] = NULL; + p->state[i] = NULL; + } + + return p; + } + + void free_cpotential(CPotential* p) { + if (p == NULL) return; + + free(p->density); + free(p->value); + free(p->gradient); + free(p->hessian); + free(p->n_params); + free(p->parameters); // Note: doesn't free the actual parameter arrays + free(p->q0); // Note: doesn't free the actual q0 arrays + free(p->R); // Note: doesn't free the actual R arrays + free(p->state); // Note: doesn't free the actual state arrays + free(p->do_shift_rotate); + free(p); + } + + int resize_cpotential_arrays(CPotential* pot, int new_n_components) { + if (new_n_components <= pot->n_components) + return 1; // Nothing to do + + // Reallocate arrays to the new size + pot->n_components = new_n_components; + pot->density = (densityfunc*)realloc(pot->density, new_n_components * sizeof(densityfunc)); + pot->value = (energyfunc*)realloc(pot->value, new_n_components * sizeof(energyfunc)); + pot->gradient = (gradientfunc*)realloc(pot->gradient, new_n_components * sizeof(gradientfunc)); + pot->hessian = (hessianfunc*)realloc(pot->hessian, new_n_components * sizeof(hessianfunc)); + pot->n_params = (int*)realloc(pot->n_params, new_n_components * sizeof(int)); + pot->parameters = (double**)realloc(pot->parameters, new_n_components * sizeof(double*)); + pot->q0 = (double**)realloc(pot->q0, new_n_components * sizeof(double*)); + pot->R = (double**)realloc(pot->R, new_n_components * sizeof(double*)); + pot->state = (void**)realloc(pot->state, new_n_components * sizeof(void*)); + pot->do_shift_rotate = (int*)realloc(pot->do_shift_rotate, new_n_components * sizeof(int)); + + // Initialize new elements + for (int i = pot->n_components; i < new_n_components; i++) { + pot->parameters[i] = NULL; + pot->q0[i] = NULL; + pot->R[i] = NULL; + pot->state[i] = NULL; + } + + return 1; // Success + } + + void apply_rotate(double *q_in, double *R, int n_dim, int transpose, + double *q_out) { + // NOTE: elsewhere, we enforce that rotation matrix only works for + // ndim=2 or ndim=3, so here we can assume that! + if (n_dim == 3) { + if (transpose == 0) { + q_out[0] = q_out[0] + R[0] * q_in[0] + R[1] * q_in[1] + R[2] * q_in[2]; + q_out[1] = q_out[1] + R[3] * q_in[0] + R[4] * q_in[1] + R[5] * q_in[2]; + q_out[2] = q_out[2] + R[6] * q_in[0] + R[7] * q_in[1] + R[8] * q_in[2]; + } else { + q_out[0] = q_out[0] + R[0] * q_in[0] + R[3] * q_in[1] + R[6] * q_in[2]; + q_out[1] = q_out[1] + R[1] * q_in[0] + R[4] * q_in[1] + R[7] * q_in[2]; + q_out[2] = q_out[2] + R[2] * q_in[0] + R[5] * q_in[1] + R[8] * q_in[2]; + } + } else if (n_dim == 2) { + if (transpose == 0) { + q_out[0] = q_out[0] + R[0] * q_in[0] + R[1] * q_in[1]; + q_out[1] = q_out[1] + R[2] * q_in[0] + R[3] * q_in[1]; + } else { + q_out[0] = q_out[0] + R[0] * q_in[0] + R[2] * q_in[1]; + q_out[1] = q_out[1] + R[1] * q_in[0] + R[3] * q_in[1]; + } + } else { + for (int j=0; j < n_dim; j++) + q_out[j] = q_out[j] + q_in[j]; + } +} + +void apply_rotate_T(double6ptr q, const double *R, int n_dim, int transpose) { + // in-place rotation + + double x = q[0]; + if (n_dim == 3) { + double y = q[1]; + double z = q[2]; + if (transpose == 0) { + q[0] = R[0] * x + R[1] * y + R[2] * z; + q[1] = R[3] * x + R[4] * y + R[5] * z; + q[2] = R[6] * x + R[7] * y + R[8] * z; + } else { + q[0] = R[0] * x + R[3] * y + R[6] * z; + q[1] = R[1] * x + R[4] * y + R[7] * z; + q[2] = R[2] * x + R[5] * y + R[8] * z; + } + } else if (n_dim == 2) { + double y = q[1]; + if (transpose == 0) { + q[0] = R[0] * x + R[1] * y; + q[1] = R[2] * x + R[3] * y; + } else { + q[0] = R[0] * x + R[2] * y; + q[1] = R[1] * x + R[3] * y; + } + } +} + + +void apply_shift_rotate(double *q_in, double *q0, double *R, int n_dim, + int transpose, double *q_out) { + double tmp[n_dim]; + int j; + + // Shift to the specified origin + for (j=0; j < n_dim; j++) { + tmp[j] = q_in[j] - q0[j]; + } + + // Apply rotation matrix + apply_rotate(&tmp[0], R, n_dim, transpose, q_out); +} + +void apply_shift_rotate_N(const double *q_in, const double *q0, const double *R, int n_dim, size_t N, + int transpose, double *q_out) { + // q_in: shape [n_dim, N] + // q_out: shape [n_dim, N] + + for(size_t i = 0; i < N; i++) { + // Shift to the specified origin + for (int j=0; j < n_dim; j++) { + q_out[j * N + i] = q_in[j * N + i] - q0[j]; + } + + // Apply rotation matrix in place + apply_rotate_T( + double6ptr{q_out + i, N}, R, n_dim, transpose + ); + } +} + + +double c_potential(CPotential *p, double t, double *qp) { + double v = 0; + int i, j; + double qp_trans[p->n_dim]; + + for (i=0; i < p->n_components; i++) { + if (p->do_shift_rotate[i] == 0) { + v = v + (p->value)[i](t, (p->parameters)[i], &qp[0], p->n_dim, (p->state)[i]); + continue; + } else { + for (j=0; j < p->n_dim; j++) + qp_trans[j] = 0.; + apply_shift_rotate(qp, (p->q0)[i], (p->R)[i], p->n_dim, 0, + &qp_trans[0]); + v = v + (p->value)[i](t, (p->parameters)[i], &qp_trans[0], p->n_dim, (p->state)[i]); + } + } + + return v; +} + + +double c_density(CPotential *p, double t, double *qp) { + double v = 0; + int i, j; + double qp_trans[p->n_dim]; + + for (i=0; i < p->n_components; i++) { + if (p->do_shift_rotate[i] == 0) { + v = v + (p->density)[i](t, (p->parameters)[i], &qp[0], p->n_dim, (p->state)[i]); + continue; + } else { + for (j=0; j < p->n_dim; j++) + qp_trans[j] = 0.; + apply_shift_rotate(qp, (p->q0)[i], (p->R)[i], p->n_dim, 0, + &qp_trans[0]); + v = v + (p->density)[i](t, (p->parameters)[i], &qp_trans[0], p->n_dim, (p->state)[i]); + } + } + + return v; +} + + +void c_gradient(CPotential *p, size_t N, double t, double *qp, double *grad) { + // qp: shape [p->n_dim, N] + // grad: shape [p->n_dim, N] + + double *qp_trans = NULL; + double *tmp_grad = NULL; + bool need_transform = false; + + // Check if any components need transformation + for (size_t i = 0; i < p->n_components; i++) { + if (p->do_shift_rotate[i] != 0) { + need_transform = true; + break; + } + } + + // Allocate temporary arrays if transformation is needed + if (need_transform) { + qp_trans = (double*)malloc(p->n_dim * N * sizeof(double)); + tmp_grad = (double*)malloc(p->n_dim * N * sizeof(double)); + } + + // Initialize gradient array + // TODO: may need to remove this for n-body-style accumulations + for (size_t i = 0; i < p->n_dim * N; i++) { + grad[i] = 0.; + } + + for (size_t i = 0; i < p->n_components; i++) { + if (p->do_shift_rotate[i] == 0) { + (p->gradient)[i](t, (p->parameters)[i], qp, p->n_dim, N, grad, (p->state)[i]); + } else { + // Initialize temporary arrays + for (size_t j = 0; j < p->n_dim * N; j++) { + qp_trans[j] = 0.; + tmp_grad[j] = 0.; + } + + // Apply shift and rotation to all particles + apply_shift_rotate_N( + qp, + (p->q0)[i], + (p->R)[i], + p->n_dim, + N, + 0, + qp_trans + ); + + // Compute gradient for transformed coordinates + (p->gradient)[i](t, (p->parameters)[i], qp_trans, p->n_dim, N, tmp_grad, (p->state)[i]); + + // Apply inverse rotation to gradient and accumulate + for (size_t j = 0; j < N; j++) { + apply_rotate_T( + double6ptr{tmp_grad + j, N}, + (p->R)[i], + p->n_dim, + 1 + ); + } + + for (size_t j = 0; j < p->n_dim * N; j++) { + grad[j] += tmp_grad[j]; + } + } + } + + // Free temporary arrays + if (need_transform) { + free(qp_trans); + free(tmp_grad); + } +} + + +void c_hessian(CPotential *p, double t, double *qp, double *hess) { + int i; + double qp_trans[p->n_dim]; + + for (i=0; i < pow(p->n_dim,2); i++) { + hess[i] = 0.; + + if (i < p->n_dim) { + qp_trans[i] = 0.; + } + } + + for (i=0; i < p->n_components; i++) { + if (p->do_shift_rotate[i] == 0) { + (p->hessian)[i](t, (p->parameters)[i], &qp[0], p->n_dim, hess, (p->state)[i]); + continue; + } else { + apply_shift_rotate(qp, (p->q0)[i], (p->R)[i], p->n_dim, 0, &qp_trans[0]); + (p->hessian)[i](t, (p->parameters)[i], &qp_trans[0], p->n_dim, hess, (p->state)[i]); + // TODO: here - need to apply inverse rotation to the Hessian! + // - Hessian calculation for potentials with rotations are disabled + } + } + +} + + +double c_d_dr(CPotential *p, double t, double *qp, double *epsilon) { + double h, r, dPhi_dr; + int j; + double r2 = 0; + + for (j=0; jn_dim; j++) { + r2 = r2 + qp[j]*qp[j]; + } + + // TODO: allow user to specify fractional step-size + h = 1E-4; + + // Step-size for estimating radial gradient of the potential + r = sqrt(r2); + + for (j=0; j < (p->n_dim); j++) + epsilon[j] = qp[j] + h * qp[j]/r; + + dPhi_dr = c_potential(p, t, epsilon); + + for (j=0; j < (p->n_dim); j++) + epsilon[j] = qp[j] - h * qp[j]/r; + + dPhi_dr = dPhi_dr - c_potential(p, t, epsilon); + + return dPhi_dr / (2.*h); +} + + +double c_d2_dr2(CPotential *p, double t, double *qp, double *epsilon) { + double h, r, d2Phi_dr2; + int j; + double r2 = 0; + for (j=0; jn_dim; j++) { + r2 = r2 + qp[j]*qp[j]; + } + + // TODO: allow user to specify fractional step-size + h = 1E-2; + + // Step-size for estimating radial gradient of the potential + r = sqrt(r2); + + for (j=0; j < (p->n_dim); j++) + epsilon[j] = qp[j] + h * qp[j]/r; + d2Phi_dr2 = c_potential(p, t, epsilon); + + d2Phi_dr2 = d2Phi_dr2 - 2.*c_potential(p, t, qp); + + for (j=0; j < (p->n_dim); j++) + epsilon[j] = qp[j] - h * qp[j]/r; + d2Phi_dr2 = d2Phi_dr2 + c_potential(p, t, epsilon); + + return d2Phi_dr2 / (h*h); +} + + +double c_mass_enclosed(CPotential *p, double t, double *qp, double G, + double *epsilon) { + double r2, dPhi_dr; + int j; + + r2 = 0; + for (j=0; jn_dim; j++) { + r2 = r2 + qp[j]*qp[j]; + } + dPhi_dr = c_d_dr(p, t, qp, epsilon); + return fabs(r2 * dPhi_dr / G); +} + + +// TODO: This isn't really the right place for this... +void c_nbody_acceleration(CPotential **pots, double t, double *qp, + int norbits, int nbody, int ndim, double *acc) { + int i, j, k; + CPotential *body_pot; + int ps_ndim = 2 * ndim; // 6, for 3D position/velocity + double f2[ndim]; + + for (j=0; j < nbody; j++) { // the particles generating force + body_pot = pots[j]; + + if ((body_pot->null) == 1) + continue; + + for (i=0; i < body_pot->n_components; i++) { + (body_pot->do_shift_rotate)[i] = 1; + (body_pot->q0)[i] = &qp[j * ps_ndim]; + } + + for (i=0; i < norbits; i++) { + if (i != j) { + c_gradient(body_pot, 1, t, &qp[i * ps_ndim], &f2[0]); + for (k=0; k < ndim; k++) + acc[i*ps_ndim + ndim + k] += -f2[k]; + } + } + } +} + +// TODO: this is a hack to get nbody leapfrog working +void c_nbody_gradient_symplectic( + CPotential **pots, double t, double *w, + double *nbody_w, int nbody, int nbody_i, + int ndim, double *grad +) { + int i, j, k; + CPotential *body_pot; + double f2[ndim]; + + for (j=0; j < nbody; j++) { // the particles generating force + body_pot = pots[j]; + + if ((body_pot->null == 1) || (j == nbody_i)) + continue; + + for (i=0; i < body_pot->n_components; i++) { + (body_pot->do_shift_rotate)[i] = 1; + (body_pot->q0)[i] = &nbody_w[j * 2 * ndim]; // p-s ndim + } + + c_gradient(body_pot, 1, t, w, &f2[0]); + for (k=0; k < ndim; k++) + grad[k] += f2[k]; + } +} diff --git a/gala/source/src/gala/potential/potential/src/cpotential.h b/gala/source/src/gala/potential/potential/src/cpotential.h new file mode 100644 index 0000000000000000000000000000000000000000..ef26df044861f3572130e8bf188519de841b4268 --- /dev/null +++ b/gala/source/src/gala/potential/potential/src/cpotential.h @@ -0,0 +1,67 @@ +#include + +#include "src/funcdefs.h" +#include "../../src/vectorization.h" + +#ifndef _CPotential_H +#define _CPotential_H + typedef struct _CPotential CPotential; + + struct _CPotential { + int n_components; // number of potential components + int n_dim; // coordinate system dimensionality + int null; // short circuit: if null, can skip evaluation + int* do_shift_rotate; // short circuit: if 0, skip transforming pos/vel + + // arrays of pointers to each of the function types above + densityfunc* density; + energyfunc* value; + gradientfunc* gradient; + hessianfunc* hessian; + + // array containing the number of parameters in each component + int* n_params; + + // pointer to array of pointers to the parameter arrays + double** parameters; + + // pointer to array of pointers containing the origin coordinates + double** q0; + + // pointer to array of pointers containing rotation matrix elements + double** R; + + // pointer to array of pointers containing the state + void **state; + }; +#endif + +extern CPotential* allocate_cpotential(int n_components); +extern void free_cpotential(CPotential* p); +extern int resize_cpotential_arrays(CPotential* pot, int new_n_components); + +extern double c_potential(CPotential *p, double t, double *q); +extern double c_density(CPotential *p, double t, double *q); +extern void c_gradient(CPotential *p, size_t N, double t, double *q, double *grad); +extern void c_hessian(CPotential *p, double t, double *q, double *hess); + +// TODO: err, what about reference frames... +extern double c_d_dr(CPotential *p, double t, double *q, double *epsilon); +extern double c_d2_dr2(CPotential *p, double t, double *q, double *epsilon); +extern double c_mass_enclosed(CPotential *p, double t, double *q, double G, double *epsilon); + +// Coordinate transformation functions +extern void apply_rotate(double *q_in, double *R, int n_dim, int transpose, double *q_out); +extern void apply_shift_rotate(double *q_in, double *q0, double *R, int n_dim, int transpose, double *q_out); +extern void apply_shift_rotate_N(const double *q_in, const double *q0, const double *R, int n_dim, size_t N, + int transpose, double *q_out); +extern void apply_rotate_T(double6ptr q, const double *R, int n_dim, int transpose); + +// TODO: move this elsewhere? +void c_nbody_acceleration(CPotential **pots, double t, double *qp, + int norbits, int nbody, int ndim, double *acc); +void c_nbody_gradient_symplectic( + CPotential **pots, double t, double *q, + double *nbody_q, int nbody, int nbody_i, + int ndim, double *grad +); diff --git a/gala/source/src/gala/potential/potential/symmetry.py b/gala/source/src/gala/potential/potential/symmetry.py new file mode 100644 index 0000000000000000000000000000000000000000..ee64277a78a116e68be5f720153c1fcdd241cd01 --- /dev/null +++ b/gala/source/src/gala/potential/potential/symmetry.py @@ -0,0 +1,306 @@ +""" +Symmetry classes for gravitational potentials. + +These classes define coordinate transformations between symmetry-specific +coordinates (e.g., spherical radius r, cylindrical (R, z)) and the internal +Cartesian representation used by potential calculations. +""" + +from abc import ABC, abstractmethod + +import numpy as np + +__all__ = ["CylindricalSymmetry", "PotentialSymmetry", "SphericalSymmetry"] + + +class PotentialSymmetry(ABC): + """ + Base class for potential coordinate symmetries. + + This abstract base class defines the interface for converting between + symmetry-specific coordinates and the Cartesian coordinates used internally + by potential calculations. + """ + + @property + @abstractmethod + def coord_names(self): + """ + Tuple of coordinate names for this symmetry. + + Returns + ------- + coord_names : tuple of str + Names of the coordinates in this symmetry system. + """ + + @abstractmethod + def to_cartesian(self, **coords): + """ + Convert symmetry coordinates to Cartesian coordinates. + + Parameters + ---------- + **coords + Coordinate values in the symmetry system. Keys must match + the names in `coord_names`. + + Returns + ------- + xyz : `~astropy.units.Quantity` + Cartesian coordinates with shape (3, n_points). If inputs are + unitless, output will also be unitless. + """ + + def validate_coords(self, **coords): + """ + Validate that the provided coordinates are appropriate for this symmetry. + + Parameters + ---------- + **coords + Coordinate keyword arguments to validate. + + Raises + ------ + ValueError + If the coordinates are invalid or incomplete. + """ + # Check for unexpected coordinates first - this gives a better error message + extra = set(coords.keys()) - set(self.coord_names) + if extra: + raise ValueError( + f"Invalid coordinate(s) for {self.__class__.__name__}: {extra}. " + f"This symmetry only accepts: {self.coord_names}" + ) + + # Check that all required coordinates are provided + # For some symmetries (like cylindrical), certain coords may be optional + # So we only check required ones exist + required_coords = self.coord_names # Base class: all are required + if hasattr(self, "_optional_coords"): + required_coords = tuple( + c for c in self.coord_names if c not in self._optional_coords + ) + + missing_required = set(required_coords) - set(coords.keys()) + if missing_required: + raise ValueError( + f"Missing required coordinate(s) for {self.__class__.__name__}: " + f"{missing_required}. Required: {required_coords}" + ) + + +class SphericalSymmetry(PotentialSymmetry): + """ + Spherical symmetry for potentials with no angular dependence. + + This symmetry is appropriate for potentials that depend only on the + spherical radius r = sqrt(x² + y² + z²). + + Examples + -------- + >>> import astropy.units as u + >>> import numpy as np + >>> from gala.potential import HernquistPotential + >>> pot = HernquistPotential(m=1e10*u.Msun, c=1*u.kpc) + >>> r = np.linspace(0.1, 10, 100) * u.kpc + >>> energy = pot.energy(r=r) + """ + + coord_names = ("r",) + + def to_cartesian(self, r): + """ + Convert spherical radius to Cartesian coordinates. + + Parameters + ---------- + r : array-like, `~astropy.units.Quantity` + Spherical radius values. Can be scalar or array. + + Returns + ------- + xyz : `~astropy.units.Quantity` or `~numpy.ndarray` + Cartesian coordinates with shape (3, n_points). The x-component + is set to r, while y and z are set to zero. Units are preserved + if input has units. + """ + # Handle units + has_units = hasattr(r, "unit") + if has_units: + unit = r.unit + r = r.value + else: + unit = None + + # Handle scalar vs array + r = np.asarray(r, dtype=np.float64) + is_scalar = r.ndim == 0 + if is_scalar: + r = r.reshape(1) + + # Create Cartesian array: (x, y, z) = (r, 0, 0) + xyz = np.zeros((3, r.size), dtype=np.float64) + xyz[0] = r.ravel() + + # Reapply units if necessary + if has_units: + xyz = xyz * unit + + return xyz + + def validate_coords(self, **coords): + """ + Validate spherical radius coordinate. + + Parameters + ---------- + **coords + Coordinate keyword arguments. Must contain 'r'. + + Raises + ------ + ValueError + If radius values are negative or invalid coordinates are provided. + """ + # Call parent validation first + super().validate_coords(**coords) + + # Now validate the value + r = coords["r"] + r_val = r.value if hasattr(r, "value") else r + if np.any(r_val < 0): + raise ValueError("Spherical radius r must be non-negative") + + +class CylindricalSymmetry(PotentialSymmetry): + """ + Cylindrical (axisymmetric) symmetry for potentials with no azimuthal dependence. + + This symmetry is appropriate for potentials that depend only on the + cylindrical radius R = sqrt(x² + y²) and height z, but not on the + azimuthal angle phi. + + Examples + -------- + >>> import astropy.units as u + >>> import numpy as np + >>> from gala.potential import MiyamotoNagaiPotential + >>> pot = MiyamotoNagaiPotential(m=1e11*u.Msun, a=3*u.kpc, b=0.3*u.kpc) + >>> R = np.linspace(1, 15, 100) * u.kpc + >>> z = np.zeros_like(R) + >>> energy = pot.energy(R=R, z=z) + >>> + >>> # z can be omitted and defaults to zero + >>> energy = pot.energy(R=R) + """ + + coord_names = ("R", "z") + + def to_cartesian(self, R, z=None): + """ + Convert cylindrical coordinates to Cartesian coordinates. + + Parameters + ---------- + R : array-like, `~astropy.units.Quantity` + Cylindrical radius values. Can be scalar or array. + z : array-like, `~astropy.units.Quantity`, optional + Height above/below the midplane. If not provided, defaults to + zero with the same shape as R. Must have the same shape as R + if provided. + + Returns + ------- + xyz : `~astropy.units.Quantity` or `~numpy.ndarray` + Cartesian coordinates with shape (3, n_points). The x-component + is set to R, y to 0, and z to the provided z values. Units are + preserved if input has units. + + Raises + ------ + ValueError + If R and z have incompatible shapes. + """ + # Handle units for R + has_units = hasattr(R, "unit") + if has_units: + unit = R.unit + R = R.value + else: + unit = None + + # Ensure array and get shape + R = np.atleast_1d(np.asarray(R, dtype=np.float64)) + + # Handle z coordinate + if z is None: + # Default to zeros with same shape as R + z = np.zeros_like(R) + else: + # Extract units and values + if hasattr(z, "unit"): + if has_units and z.unit != unit: + # Convert z to same units as R + z = z.to(unit).value + elif has_units: + z = z.value + else: + # R has no units but z does - use z's units + unit = z.unit + z = z.value + else: + z = np.asarray(z, dtype=np.float64) + + z = np.atleast_1d(z) + + # Check shape compatibility + if z.shape != R.shape: + if z.size == 1: + # Broadcast scalar z to match R + z = np.full_like(R, z.item()) + elif R.size == 1: + # Broadcast scalar R to match z + R = np.full_like(z, R.item()) + else: + raise ValueError( + f"Incompatible shapes for R and z: R.shape={R.shape}, " + f"z.shape={z.shape}. Shapes must match or one must be scalar." + ) + + # Create Cartesian array: (x, y, z) = (R, 0, z) + xyz = np.zeros((3, R.size), dtype=np.float64) + xyz[0] = R.ravel() + xyz[2] = z.ravel() + + # Reapply units if necessary + if unit is not None: + xyz = xyz * unit + + return xyz + + def validate_coords(self, **coords): + """ + Validate cylindrical coordinates. + + Parameters + ---------- + **coords + Coordinate keyword arguments. Must contain 'R', may contain 'z'. + + Raises + ------ + ValueError + If R values are negative or invalid coordinates are provided. + """ + # Call parent validation first (handles checking for extra coords) + # Mark 'z' as optional for this symmetry + self._optional_coords = ("z",) + super().validate_coords(**coords) + + # Now validate the values + R = coords["R"] + R_val = R.value if hasattr(R, "value") else R + if np.any(R_val < 0): + raise ValueError("Cylindrical radius R must be non-negative") diff --git a/gala/source/src/gala/potential/potential/util.py b/gala/source/src/gala/potential/potential/util.py new file mode 100644 index 0000000000000000000000000000000000000000..b43ac0d0646f62f3b3a689e0ad0dfa4dd6280f42 --- /dev/null +++ b/gala/source/src/gala/potential/potential/util.py @@ -0,0 +1,236 @@ +"""Utilities for Potential classes""" + +from functools import wraps + +import numpy as np + +from ..common import PotentialParameter +from .core import PotentialBase + +__all__ = ["from_equation"] +__doctest_requires__ = {("from_equation",): ["sympy"]} + + +def from_equation(expr, vars, pars, name=None, hessian=False): + r""" + Create a potential class from an expression for the potential. + + .. note:: + + This utility requires having `Sympy `_ installed. + + .. warning:: + + These potentials are *not* pickle-able and cannot be written + out to YAML files (using `~gala.potential.PotentialBase.save()`) + + Parameters + ---------- + expr : :class:`sympy.core.expr.Expr`, str + Either a ``Sympy`` expression, or a string that can be converted to + a ``Sympy`` expression. + vars : iterable + An iterable of variable names in the expression. + pars : iterable + An iterable of parameter names in the expression. + name : str (optional) + The name of the potential class returned. + hessian : bool (optional) + Generate a function to compute the Hessian. + + Returns + ------- + CustomPotential : `~gala.potential.PotentialBase` + A potential class that represents the input equation. To instantiate the + potential, use just like a normal class with parameters. + + Examples + -------- + Here we'll create a potential class for the harmonic oscillator + potential, :math:`\Phi(x) = \frac{1}{2}\,k\,x^2`: + + >>> Potential = from_equation("1/2*k*x**2", vars="x", pars="k", + ... name='HarmonicOscillator') + >>> p1 = Potential(k=1.) + >>> p1 + + + The potential class (and object) is a fully-fledged subclass of + `~gala.potential.PotentialBase` and therefore has many useful methods. + For example, to integrate an orbit: + + >>> from gala.potential import Hamiltonian + >>> H = Hamiltonian(p1) + >>> orbit = H.integrate_orbit([1., 0], dt=0.01, n_steps=1000) + + """ + try: + import sympy + from sympy.utilities.lambdify import lambdify + except ImportError as e: + raise ImportError( + "sympy is required to use 'from_equation()' potential class creation." + ) from e + + # convert all input to Sympy objects + expr = sympy.sympify(expr) + vars = [sympy.sympify(v) for v in vars] + var_names = [v.name for v in vars] + pars = [sympy.sympify(p) for p in pars] + par_names = [p.name for p in pars] + ndim = len(vars) + + # Energy / value + energyfunc = lambdify(vars + pars, expr, dummify=False, modules=["numpy", "sympy"]) + + # Gradient + gradfuncs = [] + for var in vars: + gradfuncs.append( + lambdify( + vars + pars, + sympy.diff(expr, var), + dummify=False, + modules=["numpy", "sympy"], + ) + ) + + parameters = {} + for _name in par_names: + parameters[_name] = PotentialParameter(_name, physical_type="dimensionless") + + class CustomPotential(PotentialBase, parameters=parameters): + ndim = len(vars) + + def _energy(self, w, t=0.0): + kw = self.parameters.copy() + for k, v in kw.items(): + kw[k] = v.value + + for i, name in enumerate(var_names): + kw[name] = w[:, i] + + return np.array(energyfunc(**kw)) + + def _gradient(self, w, t=0.0): + kw = self.parameters.copy() + for k, v in kw.items(): + kw[k] = v.value + + for i, name in enumerate(var_names): + kw[name] = w[i, :] + + return np.vstack([f(**kw)[np.newaxis] for f in gradfuncs]) + + if name is not None: + # name = _classnamify(name) + if "potential" not in name.lower(): + name += "Potential" + CustomPotential.__name__ = str(name) + + # Hessian + if hessian: + hessfuncs = [] + for var1 in vars: + for var2 in vars: + hessfuncs.append( + lambdify( + vars + pars, + sympy.diff(expr, var1, var2), + dummify=False, + modules=["numpy", "sympy"], + ) + ) + + def _hessian(self, w, t): + kw = self.parameters.copy() + for k, v in kw.items(): + kw[k] = v.value + + for i, name in enumerate(var_names): + kw[name] = w[:, i] + + # expand = [np.newaxis] * w[i].ndim + + # This ain't pretty, bub + arrs = [] + for f in hessfuncs: + hess_arr = np.array(f(**kw)) + if hess_arr.shape != w[:, i].shape: + hess_arr = np.tile(hess_arr, reps=w[:, i].shape) + arrs.append(hess_arr) + hess = np.vstack(arrs) + + return hess.reshape((ndim, ndim, len(w[:, i]))) + + CustomPotential._hessian = _hessian + + CustomPotential.save = None + return CustomPotential + + +def format_doc(*args, **kwargs): + """ + Replaces the docstring of the decorated object and then formats it. + + Modeled after astropy.utils.decorators.format_doc + """ + + def set_docstring(obj): + # None means: use the objects __doc__ + doc = obj.__doc__ + # Delete documentation in this case so we don't end up with + # awkwardly self-inserted docs. + obj.__doc__ = None + + # If the original has a not-empty docstring append it to the format + # kwargs. + kwargs["__doc__"] = obj.__doc__ or "" + obj.__doc__ = doc.format(*args, **kwargs) + return obj + + return set_docstring + + +class SympyWrapper: + @classmethod + def as_decorator(cls, func=None, **kwargs): + self = cls(**kwargs) + if func is not None and not kwargs: + return self(func) + return self + + def __init__(self, func=None, var=None, include_G=True): + var_ = "x, y, z" if var is None else var + self.var = var_ + self.include_G = include_G + + def __call__(self, wrapped_function): + @wraps(wrapped_function) + def wrapper(cls, *func_args, **func_kwargs): + try: + import sympy as sy + except ImportError as e: + raise ImportError( + "Converting to a latex expression requires " + "the sympy package to be installed" + ) from e + + var = sy.symbols(self.var, seq=True, real=True) + var = {v.name: v for v in var} + + if cls._parameters: + par = sy.symbols(" ".join(cls._parameters.keys()), seq=True, real=True) + par = {v.name: v for v in par} + else: + par = {} + + if self.include_G: + par["G"] = sy.symbols("G") + + return wrapped_function(cls, var, par) + + return wrapper + + +sympy_wrap = SympyWrapper.as_decorator diff --git a/gala/source/src/gala/potential/scf/__init__.py b/gala/source/src/gala/potential/scf/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5f9172d0f3b8c4d34fbb00fa46ae27376460e8eb --- /dev/null +++ b/gala/source/src/gala/potential/scf/__init__.py @@ -0,0 +1,6 @@ +""" +Implementation of the Self-Consistent Field (SCF) expansion method. +""" + +from ._bfe_class import SCFInterpolatedPotential, SCFPotential +from .core import compute_coeffs, compute_coeffs_discrete diff --git a/gala/source/src/gala/potential/scf/bfe.pxd b/gala/source/src/gala/potential/scf/bfe.pxd new file mode 100644 index 0000000000000000000000000000000000000000..bd8883752dd4d78b2c6f7579878013ff4e369991 --- /dev/null +++ b/gala/source/src/gala/potential/scf/bfe.pxd @@ -0,0 +1,23 @@ +# cython: language_level=3 +# cython: language=c++ + +cdef extern from "scf/src/bfe.h": + void scf_density_helper(double *xyz, int K, double M, double r_s, + double *Snlm, double *Tnlm, + int nmax, int lmax, double *dens) nogil + void scf_potential_helper(double *xyz, int K, double G, double M, double r_s, + double *Snlm, double *Tnlm, + int nmax, int lmax, double *potv) nogil + void scf_gradient_helper(double *x, double *y, double *z, int K, + double G, double M, double r_s, + double *Snlm, double *Tnlm, + int nmax, int lmax, + double *gradx, double *grady, double *gradz) nogil + + double scf_value(double t, double *pars, double *q, int n_dim) nogil + double scf_density(double t, double *pars, double *q, int n_dim) nogil + void scf_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil + + double scf_interp_value(double t, double *pars, double *q, int n_dim) nogil + double scf_interp_density(double t, double *pars, double *q, int n_dim) nogil + void scf_interp_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state) nogil diff --git a/gala/source/src/gala/potential/scf/bfe.pyx b/gala/source/src/gala/potential/scf/bfe.pyx new file mode 100644 index 0000000000000000000000000000000000000000..2b092d9cf28b8a99fa93b179fcb7cddcde451722 --- /dev/null +++ b/gala/source/src/gala/potential/scf/bfe.pyx @@ -0,0 +1,189 @@ +# coding: utf-8 +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: language_level=3 +# cython: language=c++ + + +from libc.math cimport M_PI + +# Third party +from astropy.constants import G +import numpy as np +cimport numpy as np +np.import_array() +import cython +cimport cython + +from gala._cconfig cimport USE_GSL +from gala.potential.scf.bfe cimport scf_density_helper, \ + scf_potential_helper, scf_gradient_helper + + +cdef extern from "scf/src/bfe_helper.h": + double rho_nlm(double s, double phi, double X, int n, int l, int m) nogil + double phi_nlm(double s, double phi, double X, int n, int l, int m) nogil + double sph_grad_phi_nlm(double s, double phi, double X, int n, int l, int m, double *grad) nogil + +__all__ = ['density', 'potential', 'gradient'] + +cpdef density(double[:, ::1] xyz, + double[:, :, ::1] Snlm, double[:, :, ::1] Tnlm, + double M=1., double r_s=1.): + """ + density(xyz, Snlm, Tnlm, M=1, r_s=1) + + Compute the density of the basis function expansion + at a set of positions given the expansion coefficients. + + Parameters + ---------- + xyz : `~numpy.ndarray` + A 2D array of positions where ``axis=0`` are multiple positions + and ``axis=1`` are the coordinate dimensions (x, y, z). + Snlm : `~numpy.ndarray` + A 3D array of expansion coefficients for the cosine terms + of the expansion. This notation follows Lowing et al. (2011). + The array should have shape ``(nmax+1, lmax+1, lmax+1)`` and any + invalid terms (e.g., when m > l) will be ignored. + Tnlm : `~numpy.ndarray` + A 3D array of expansion coefficients for the sine terms + of the expansion. This notation follows Lowing et al. (2011). + The array should have shape ``(nmax+1, lmax+1, lmax+1)`` and any + invalid terms (e.g., when m > l) will be ignored. + M : numeric (optional) + Mass scale. Leave unset for dimensionless units. + r_s : numeric (optional) + Length scale. Leave unset for dimensionless units. + + Returns + ------- + dens : `~numpy.ndarray` + A 1D array of the density at each input position. + Will have the same length as the input position array, ``xyz``. + + """ + + cdef: + int ncoords = xyz.shape[0] + double[::1] dens = np.zeros(ncoords) + + int nmax = Snlm.shape[0]-1 + int lmax = Snlm.shape[1]-1 + + if USE_GSL == 1: + scf_density_helper(&xyz[0, 0], ncoords, M, r_s, + &Snlm[0, 0, 0], &Tnlm[0, 0, 0], + nmax, lmax, &dens[0]) + + return np.array(dens) + +cpdef potential(double[:, ::1] xyz, + double[:, :, ::1] Snlm, double[:, :, ::1] Tnlm, + double G=1., double M=1., double r_s=1.): + """ + potential(xyz, Snlm, Tnlm, G=1, M=1, r_s=1) + + Compute the gravitational potential of the basis function expansion + at a set of positions given the expansion coefficients. + + Parameters + ---------- + xyz : `~numpy.ndarray` + A 2D array of positions where ``axis=0`` are multiple positions + and ``axis=1`` are the coordinate dimensions (x, y, z). + Snlm : `~numpy.ndarray` + A 3D array of expansion coefficients for the cosine terms + of the expansion. This notation follows Lowing et al. (2011). + The array should have shape ``(nmax+1, lmax+1, lmax+1)`` and any + invalid terms (e.g., when m > l) will be ignored. + Tnlm : `~numpy.ndarray` + A 3D array of expansion coefficients for the sine terms + of the expansion. This notation follows Lowing et al. (2011). + The array should have shape ``(nmax+1, lmax+1, lmax+1)`` and any + invalid terms (e.g., when m > l) will be ignored. + G : numeric (optional) + Gravitational constant. Leave unset for dimensionless units. + M : numeric (optional) + Mass scale. Leave unset for dimensionless units. + r_s : numeric (optional) + Length scale. Leave unset for dimensionless units. + + Returns + ------- + pot : `~numpy.ndarray` + A 1D array of the value of the potential at each input position. + Will have the same length as the input position array, ``xyz``. + + """ + cdef: + int ncoords = xyz.shape[0] + double[::1] potv = np.zeros(ncoords) + + int nmax = Snlm.shape[0]-1 + int lmax = Snlm.shape[1]-1 + + if USE_GSL == 1: + scf_potential_helper(&xyz[0, 0], ncoords, G, M, r_s, + &Snlm[0, 0, 0], &Tnlm[0, 0, 0], + nmax, lmax, &potv[0]) + + return np.array(potv) + +cpdef gradient(double[:, ::1] xyz, + double[:, :, ::1] Snlm, double[:, :, ::1] Tnlm, + double G=1, double M=1, double r_s=1): + """ + gradient(xyz, Snlm, Tnlm, G=1, M=1, r_s=1) + + Compute the gradient of the gravitational potential of the + basis function expansion at a set of positions given the + expansion coefficients. + + Parameters + ---------- + xyz : `~numpy.ndarray` + A 2D array of positions where ``axis=0`` are multiple positions + and ``axis=1`` are the coordinate dimensions (x, y, z). + Snlm : `~numpy.ndarray` + A 3D array of expansion coefficients for the cosine terms + of the expansion. This notation follows Lowing et al. (2011). + The array should have shape ``(nmax+1, lmax+1, lmax+1)`` and any + invalid terms (e.g., when m > l) will be ignored. + Tnlm : `~numpy.ndarray` + A 3D array of expansion coefficients for the sine terms + of the expansion. This notation follows Lowing et al. (2011). + The array should have shape ``(nmax+1, lmax+1, lmax+1)`` and any + invalid terms (e.g., when m > l) will be ignored. + G : numeric (optional) + Gravitational constant. Leave unset for dimensionless units. + M : numeric (optional) + Mass scale. Leave unset for dimensionless units. + r_s : numeric (optional) + Length scale. Leave unset for dimensionless units. + + Returns + ------- + grad : `~numpy.ndarray` + A 2D array of the gradient of the potential at each input position. + Will have the same shape as the input position array, ``xyz``. + + """ + cdef: + int ncoords = xyz.shape[0] + double[:, ::1] xyz_T = np.ascontiguousarray(xyz.T) + double[:, ::1] grad = np.zeros((3, ncoords)) + + int nmax = Snlm.shape[0]-1 + int lmax = Snlm.shape[1]-1 + + if USE_GSL == 1: + scf_gradient_helper(&xyz_T[0, 0], &xyz_T[1, 0], &xyz_T[2, 0], + ncoords, G, M, r_s, + &Snlm[0, 0, 0], &Tnlm[0, 0, 0], + nmax, lmax, + &grad[0, 0], &grad[1, 0], &grad[2, 0]) + + return np.array(grad.T, copy=False) diff --git a/gala/source/src/gala/potential/scf/bfe_class.pyx b/gala/source/src/gala/potential/scf/bfe_class.pyx new file mode 100644 index 0000000000000000000000000000000000000000..c75e32b405bafdf90e2db547d26c70e1a9e780e9 --- /dev/null +++ b/gala/source/src/gala/potential/scf/bfe_class.pyx @@ -0,0 +1,200 @@ +# coding: utf-8 +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: language_level=3 +# cython: language=c++ + +from libc.math cimport M_PI + +from astropy.constants import G +import numpy as np +cimport numpy as np +np.import_array() +import cython +cimport cython + +from gala._cconfig cimport USE_GSL +from gala.util import GalaFutureWarning +from gala.units import galactic +from gala.potential.common import PotentialParameter +from gala.potential import PotentialBase +from gala.potential.potential.cpotential cimport CPotentialWrapper, CPotential, densityfunc, energyfunc, gradientfunc +from gala.potential.potential.cpotential import CPotentialBase +from gala.potential.scf.bfe cimport scf_value, scf_density, scf_gradient, \ + scf_interp_value, scf_interp_density, scf_interp_gradient + + +__all__ = ['SCFPotential', 'InterpolatedSCFPotential'] + + +gsl_err_msg = ("Gala was compiled without GSL and so the {classname} class " + "will not work. See the gala documentation for more " + "information about installing and using GSL with gala: " + "http://gala.adrian.pw/en/latest/install.html") + + +cdef class SCFWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + if USE_GSL == 1: + self.cpotential.value[0] = (scf_value) + self.cpotential.density[0] = (scf_density) + self.cpotential.gradient[0] = (scf_gradient) + +class SCFPotential(CPotentialBase, GSL_only=True): + r""" + SCFPotential(m, r_s, Snlm, Tnlm, units=None, origin=None, R=None) + + A gravitational potential represented as a basis function expansion. This + uses the self-consistent field (SCF) method of Hernquist & Ostriker (1992) + and Lowing et al. (2011), and represents all coefficients as real + quantities. + + Parameters + ---------- + m : numeric + Scale mass. + r_s : numeric + Scale length. + Snlm : array_like + Array of coefficients for the cos() terms of the expansion. + This should be a 3D array with shape `(nmax+1, lmax+1, lmax+1)`, + where `nmax` is the number of radial expansion terms and `lmax` + is the number of spherical harmonic `l` terms. + Tnlm : array_like + Array of coefficients for the sin() terms of the expansion. + This should be a 3D array with shape `(nmax+1, lmax+1, lmax+1)`, + where `nmax` is the number of radial expansion terms and `lmax` + is the number of spherical harmonic `l` terms. + units : iterable + Unique list of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + + """ + m = PotentialParameter('m', physical_type='mass') + r_s = PotentialParameter('r_s', physical_type='length') + Snlm = PotentialParameter('Snlm', physical_type='dimensionless', ndim=3) + Tnlm = PotentialParameter('Tnlm', physical_type='dimensionless', ndim=3) + + Wrapper = SCFWrapper + + def __init__(self, *args, units=None, origin=None, R=None, **kwargs): + PotentialBase.__init__( + self, + *args, + units=units, + origin=origin, + R=R, + **kwargs) + + shp1 = self.parameters['Snlm'].shape + shp2 = self.parameters['Tnlm'].shape + if shp1 != shp2: + raise ValueError( + "The input coefficient arrays Snlm and Tnlm must have the same " + f"shape! Received: {shp1} and {shp2}") + + # extra parameters + nmax = self.parameters['Snlm'].shape[0] - 1 + lmax = self.parameters['Snlm'].shape[1] - 1 + + self._setup_wrapper({'nmax': nmax, 'lmax': lmax}) + + +cdef class InterpolatedSCFWrapper(CPotentialWrapper): + + def __init__(self, G, parameters, q0, R): + self.init([G] + list(parameters), + np.ascontiguousarray(q0), + np.ascontiguousarray(R)) + if USE_GSL == 1: + self.cpotential.value[0] = (scf_interp_value) + self.cpotential.density[0] = (scf_interp_density) + self.cpotential.gradient[0] = (scf_interp_gradient) + + +class SCFInterpolatedPotential(CPotentialBase, GSL_only=True): + r""" + SCFInterpolatedPotential(m, r_s, Sjnlm, Tjnlm, tj, com_xj, com_vj, units=None, origin=None, R=None) + + A gravitational potential represented as a basis function expansion with the + Hernquist basis, but where the coefficients are interpolated with linear + interpolation to compute the density, potential, or acceleration at a given time. + This uses the self-consistent field (SCF) method of Hernquist & Ostriker (1992) and + Lowing et al. (2011), and represents all coefficients as real quantities. + + Parameters + ---------- + m : numeric + Scale mass. + r_s : numeric + Scale length. + Sjnlm : array_like + Array of coefficients for the cos() terms of the expansion. The 0th axis should + contain the coefficients at a given time (specified by the ``tj`` argument). + This should be a 4D array with shape `(len(tj), nmax+1, lmax+1, lmax+1)`, where + `tj` is the array of times that the input coefficients are stored at, `nmax` is + the number of radial expansion terms and `lmax` is the number of spherical + harmonic `l` terms. + Tjnlm : array_like + Same as `Sjnlm`, but for the sin() terms of the expansion. + tj : array_like + The array of times that the input coefficients are specified at. + com_xj : array_like + The position of the expansion center as a function of time, evaluated at the + same times as the input time array `tj`. + com_vj : array_like + The velocity of the expansion center as a function of time, evaluated at the + same times as the input time array `tj`. + units : iterable + Unique list of non-reducable units that specify (at minimum) the + length, mass, time, and angle units. + + """ + m = PotentialParameter('m', physical_type='mass') + r_s = PotentialParameter('r_s', physical_type='length') + Sjnlm = PotentialParameter('Sjnlm', physical_type='dimensionless', ndim=4) + Tjnlm = PotentialParameter('Tjnlm', physical_type='dimensionless', ndim=4) + tj = PotentialParameter('tj', physical_type='time', ndim=1) + com_xj = PotentialParameter('com_xj', physical_type='length', ndim=2) + com_vj = PotentialParameter('com_vj', physical_type='speed', ndim=2) + + Wrapper = InterpolatedSCFWrapper + + def __init__(self, *args, units=None, origin=None, R=None, **kwargs): + + import warnings + msg = ( + "This class is now deprecated and should not be used. Instead, use the " + "more general TimeDependentPotential class with SCFPotential as the base " + "potential. See the gala documentation for more details." + ) + warnings.warn(msg, GalaFutureWarning) + + PotentialBase.__init__( + self, + *args, + units=units, + origin=origin, + R=R, + **kwargs) + + shp1 = self.parameters['Sjnlm'].shape + shp2 = self.parameters['Tjnlm'].shape + if shp1 != shp2: + raise ValueError( + "The input coefficient arrays must have the same shape! Received: " + f"{shp1} and {shp2}" + ) + + # extra parameters + ntimes = shp1[0] + nmax = shp1[1] - 1 + lmax = shp1[2] - 1 + + self._setup_wrapper({'nmax': nmax, 'lmax': lmax, 'ntimes': ntimes}) diff --git a/gala/source/src/gala/potential/scf/computecoeff.pyx b/gala/source/src/gala/potential/scf/computecoeff.pyx new file mode 100644 index 0000000000000000000000000000000000000000..925bbd31a64ae1c5df04e9a90552bf4ba6d6f2a5 --- /dev/null +++ b/gala/source/src/gala/potential/scf/computecoeff.pyx @@ -0,0 +1,90 @@ +# coding: utf-8 +# cython: boundscheck=False +# cython: nonecheck=False +# cython: cdivision=True +# cython: wraparound=False +# cython: profile=False +# cython: language_level=3 +# cython: language=c++ + +""" THIS IS A THIN WRAPPER AROUND THE FUNCTIONS IN coeff_helper.c """ + +import numpy as np +cimport numpy as np +from libc.math cimport M_PI + +from ..._cconfig cimport USE_GSL + + +cdef extern from "math.h": + double sqrt(double x) nogil + double cos(double x) nogil + double sin(double x) nogil + +cdef extern from "scf/src/coeff_helper.h": + double c_Snlm_integrand(double phi, double X, double xsi, double density, int n, int l, int m) + double c_Tnlm_integrand(double phi, double X, double xsi, double density, int n, int l, int m) + void c_STnlm_discrete(double *s, double *phi, double *X, double *m_k, int K, int n, int l, int m, double *ST) + void c_STnlm_var_discrete(double *s, double *phi, double *X, double *m_k, int K, int n, int l, int m, double *ST_var) + +__all__ = ['Snlm_integrand', 'Tnlm_integrand'] + +cpdef Snlm_integrand(double phi, double X, double xsi, + density_func, + int n, int l, int m, + double M, double r_s, args): + cdef: + double s = (1 + xsi) / (1 - xsi) + double r = s * r_s + double x = r * cos(phi) * sqrt(1-X*X) + double y = r * sin(phi) * sqrt(1-X*X) + double z = r * X + double val = 0. + + if USE_GSL == 1: + val = c_Snlm_integrand(phi, X, xsi, + density_func(x, y, z, *args) / M * r_s*r_s*r_s, + n, l, m) + return val + +cpdef Tnlm_integrand(double phi, double X, double xsi, + density_func, + int n, int l, int m, + double M, double r_s, args): + cdef: + double s = (1 + xsi) / (1 - xsi) + double r = s * r_s + double x = r * cos(phi) * sqrt(1-X*X) + double y = r * sin(phi) * sqrt(1-X*X) + double z = r * X + double val = 0. + + if USE_GSL == 1: + val = c_Tnlm_integrand(phi, X, xsi, + density_func(x, y, z, *args) / M * r_s*r_s*r_s, + n, l, m) + return val + +cpdef STnlm_discrete(double[::1] s, double[::1] phi, double[::1] X, + double[::1] m_k, + int n, int l, int m): + cdef: + double[::1] ST = np.zeros(2) + int K = s.size + + if USE_GSL == 1: + c_STnlm_discrete(&s[0], &phi[0], &X[0], + &m_k[0], K, n, l, m, &ST[0]) + return ST + +cpdef STnlm_var_discrete(double[::1] s, double[::1] phi, double[::1] X, + double[::1] m_k, + int n, int l, int m): + cdef: + double[::1] ST_var = np.zeros(3) + int K = s.size + + if USE_GSL == 1: + c_STnlm_var_discrete(&s[0], &phi[0], &X[0], + &m_k[0], K, n, l, m, &ST_var[0]) + return ST_var diff --git a/gala/source/src/gala/potential/scf/core.py b/gala/source/src/gala/potential/scf/core.py new file mode 100644 index 0000000000000000000000000000000000000000..b92b346eb9bf67d2d1824f80a909a7eac871fabf --- /dev/null +++ b/gala/source/src/gala/potential/scf/core.py @@ -0,0 +1,287 @@ +import numpy as np +import scipy.integrate as si + +from ._computecoeff import ( + Snlm_integrand, + STnlm_discrete, + STnlm_var_discrete, + Tnlm_integrand, +) + +__all__ = ["compute_coeffs", "compute_coeffs_discrete"] + + +def compute_coeffs( + density_func, + nmax, + lmax, + M, + r_s, + args=(), + skip_odd=False, + skip_even=False, + skip_m=False, + S_only=False, + progress=False, + **nquad_opts, +): + """ + Compute the expansion coefficients for representing the input density + function using a basis function expansion. + + Computing the coefficients involves computing triple integrals which are + computationally expensive. + + .. warning:: + + GSL is required for this function, see the + `Installation instructions `_ for more details + + Parameters + ---------- + density_func : function, callable + A function or callable object that evaluates the density at a given + position. The call format must be of the form: ``density_func(x, y, z, + M, r_s, args)`` where ``x, y, z`` are cartesian coordinates, ``M`` is a + scale mass, ``r_s`` a scale radius, and ``args`` is an iterable + containing any other arguments needed by the density function. + nmax : int + Maximum value of ``n`` for the radial expansion. + lmax : int + Maximum value of ``l`` for the spherical harmonics. + M : numeric + Scale mass. + r_s : numeric + Scale radius. + args : iterable (optional) + A list or iterable of any other arguments needed by the density + function. + skip_odd : bool (optional) + Skip the odd terms in the angular portion of the expansion. For example, + only take :math:`l=0, 2, 4, ...` + skip_even : bool (optional) + Skip the even terms in the angular portion of the expansion. For + example, only take :math:`l=1, 3, 5, ...` + skip_m : bool (optional) + Ignore terms with :math:`m > 0`. + S_only : bool (optional) + Only compute the S coefficients. + progress : bool (optional) + If ``tqdm`` is installed, display a progress bar. + **nquad_opts + Any additional keyword arguments are passed through to + `~scipy.integrate.nquad` as options, `opts`. + + Returns + ------- + Snlm : float, `~numpy.ndarray` + The value of the cosine expansion coefficient. + Snlm_err : , `~numpy.ndarray` + An estimate of the uncertainty in the coefficient value (from `~scipy.integrate.nquad`). + Tnlm : , `~numpy.ndarray` + The value of the sine expansion coefficient. + Tnlm_err : , `~numpy.ndarray` + An estimate of the uncertainty in the coefficient value. (from `~scipy.integrate.nquad`). + + """ + from gala._cconfig import GSL_ENABLED + + if not GSL_ENABLED: + raise ValueError( + "Gala was compiled without GSL and so this function " + "will not work. See the gala documentation for more " + "information about installing and using GSL with " + "gala: http://gala.adrian.pw/en/latest/install.html" + ) + + lmin = 0 + lstride = 1 + + if skip_odd or skip_even: + lstride = 2 + + if skip_even: + lmin = 1 + + Snlm = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + Snlm_e = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + Tnlm = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + Tnlm_e = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + + nquad_opts.setdefault("limit", 256) + nquad_opts.setdefault("epsrel", 1e-10) + + limits = [ + [0, 2 * np.pi], # phi + [-1, 1.0], # X (cos(theta)) + [-1, 1.0], + ] # xsi + + nlms = [] + for n in range(nmax + 1): + for l in range(lmin, lmax + 1, lstride): + for m in range(l + 1): + if skip_m and m > 0: + continue + + nlms.append((n, l, m)) + + if progress: + try: + from tqdm import tqdm + except ImportError as e: + raise ImportError( + "tqdm is not installed - you can install it " + "with `pip install tqdm`.\n" + str(e) + ) from e + iterfunc = tqdm + else: + iterfunc = lambda x: x + + for n, l, m in iterfunc(nlms): + Snlm[n, l, m], Snlm_e[n, l, m] = si.nquad( + Snlm_integrand, + ranges=limits, + args=(density_func, n, l, m, M, r_s, args), + opts=nquad_opts, + ) + + if not S_only: + Tnlm[n, l, m], Tnlm_e[n, l, m] = si.nquad( + Tnlm_integrand, + ranges=limits, + args=(density_func, n, l, m, M, r_s, args), + opts=nquad_opts, + ) + + return (Snlm, Snlm_e), (Tnlm, Tnlm_e) + + +def _discrete_worker(task): + (n, l, m), compute_var, *args = task + # args = s, phi, X, mass + + S, T = STnlm_discrete(*args, n, l, m) + + if compute_var: + (S_var, T_var, co_var) = STnlm_var_discrete(*args, n, l, m) + cov = np.array([[S_var, co_var], [co_var, T_var]]) + else: + cov = None + + return (n, l, m), (S, T), cov + + +def compute_coeffs_discrete( + xyz, + mass, + nmax, + lmax, + r_s, + skip_odd=False, + skip_even=False, + skip_m=False, + compute_var=False, + pool=None, +): + """ + Compute the expansion coefficients for representing the density distribution + of input points as a basis function expansion. The points, ``xyz``, are + assumed to be samples from the density distribution. + + .. warning:: + + GSL is required for this function, see the + `Installation instructions `_ for more details + + Parameters + ---------- + xyz : array_like + Samples from the density distribution. Should have shape ``(n_samples, + 3)``. + mass : array_like + Mass of each sample. Should have shape ``(n_samples,)``. + nmax : int + Maximum value of ``n`` for the radial expansion. + lmax : int + Maximum value of ``l`` for the spherical harmonics. + r_s : numeric + Scale radius. + skip_odd : bool (optional) + Skip the odd terms in the angular portion of the expansion. For example, + only take :math:`l=0, 2, 4, ...` + skip_even : bool (optional) + Skip the even terms in the angular portion of the expansion. For + example, only take :math:`l=1, 3, 5, ...` + skip_m : bool (optional) + Ignore terms with :math:`m > 0`. + compute_var : bool (optional) + Also compute the variances (and covariances) of the coefficients. + pool : `~multiprocessing.Pool`, `schwimmbad.BasePool` (optional) + A multi-processing or other parallel processing pool to use to distribute the + tasks of computing the coefficients for each n,l,m term. The pool instance must + have a `.map()` method. + + Returns + ------- + Snlm : `~numpy.ndarray` + The value of the cosine expansion coefficient. + Tnlm : `~numpy.ndarray` + The value of the sine expansion coefficient. + STcovar : `~numpy.ndarray`, optional + If ``compute_var==True``, this also computes and returns the covariance + matrix of the coefficients. + + """ + from gala._cconfig import GSL_ENABLED + + if not GSL_ENABLED: + raise ValueError( + "Gala was compiled without GSL and so this function " + "will not work. See the gala documentation for more " + "information about installing and using GSL with " + "gala: http://gala.adrian.pw/en/latest/install.html" + ) + + map_ = map if pool is None else pool.map + + lmin = 0 + lstride = 1 + + if skip_odd or skip_even: + lstride = 2 + + if skip_even: + lmin = 1 + + Snlm = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + Tnlm = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + + # positions and masses of point masses + xyz = np.ascontiguousarray(np.atleast_2d(xyz)) + mass = np.ascontiguousarray(np.atleast_1d(mass)) + + r = np.sqrt(np.sum(xyz**2, axis=-1)) + s = r / r_s + phi = np.arctan2(xyz[:, 1], xyz[:, 0]) + X = xyz[:, 2] / r + + nlms = [] + for n in range(nmax + 1): + for l in range(lmin, lmax + 1, lstride): + for m in range(l + 1): + if skip_m and m > 0: + continue + + nlms.append((n, l, m)) + + tasks = [(nlm, compute_var, s, phi, X, mass) for nlm in nlms] + ST_cov = np.zeros((2, 2, *Snlm.shape)) + for (n, l, m), ST_nlm, ST_cov_nlm in map_(_discrete_worker, tasks): + Snlm[n, l, m], Tnlm[n, l, m] = ST_nlm + if compute_var: + ST_cov[:, :, n, l, m] = ST_cov_nlm + + if compute_var: + return Snlm, Tnlm, ST_cov + return Snlm, Tnlm diff --git a/gala/source/src/gala/potential/scf/src/bfe.cpp b/gala/source/src/gala/potential/scf/src/bfe.cpp new file mode 100644 index 0000000000000000000000000000000000000000..02a74b37ba5c073a94152e211ead4ffd83b50105 --- /dev/null +++ b/gala/source/src/gala/potential/scf/src/bfe.cpp @@ -0,0 +1,516 @@ +#include +#include +#include +#include +#include "bfe_helper.h" +#include "extra_compile_macros.h" +#include "src/vectorization.h" + +#if USE_GSL == 1 +#include "gsl/gsl_math.h" +#include "gsl/gsl_spline.h" +#endif + +#if USE_GSL == 1 +void scf_density_helper(double *xyz, int K, + double M, double r_s, + double *Snlm, double *Tnlm, + int nmax, int lmax, double *dens) { + + int i,j,k, n,l,m; + double r, s, X, phi; + double cosmphi[lmax+1], sinmphi[lmax+1]; + memset(cosmphi, 0, (lmax+1)*sizeof(double)); + memset(sinmphi, 0, (lmax+1)*sizeof(double)); + for (k=0; k l) { + // i++; + continue; + } + + i = m + (lmax+1) * (l + (lmax+1) * n); + if ((Snlm[i] == 0.) & (Tnlm[i] == 0.)) { + // i++; + continue; + } + dens[k] += rho_nlm(s, phi, X, n, l, m) * (Snlm[i]*cosmphi[m] + + Tnlm[i]*sinmphi[m]); + } + } + } + dens[k] *= M / (r_s*r_s*r_s); + } +} + +void scf_potential_helper(double *xyz, int K, + double G, double M, double r_s, + double *Snlm, double *Tnlm, + int nmax, int lmax, double *val) { + + int i,j,k, n,l,m; + double r, s, X, phi; + double cosmphi[lmax+1], sinmphi[lmax+1]; + memset(cosmphi, 0, (lmax+1)*sizeof(double)); + memset(sinmphi, 0, (lmax+1)*sizeof(double)); + for (k=0; k l) { + // i++; + continue; + } + + i = m + (lmax+1) * (l + (lmax+1) * n); + if ((Snlm[i] == 0.) & (Tnlm[i] == 0.)) { + // i++; + continue; + } + + val[k] += phi_nlm(s, phi, X, n, l, m) * (Snlm[i]*cosmphi[m] + + Tnlm[i]*sinmphi[m]); + // i++; + } + } + } + val[k] *= G*M/r_s; + } +} + +void scf_gradient_helper(double *__restrict__ x, double *__restrict__ y, double *__restrict__ z, int K, + double G, double M, double r_s, + double *__restrict__ Snlm, double *__restrict__ Tnlm, + int nmax, int lmax, + double *__restrict__ gradx, double *__restrict__ grady, double *__restrict__ gradz) { + + int i,k, n,l,m; + double r, s, X, phi; + double sintheta, cosphi, sinphi, tmp; + double tmp_grad[3], tmp_grad2[3]; + double cosmphi[lmax+1], sinmphi[lmax+1]; + memset(cosmphi, 0, (lmax+1)*sizeof(double)); + memset(sinmphi, 0, (lmax+1)*sizeof(double)); + + for (k=0; k l) { + // i++; + continue; + } + + i = m + (lmax+1) * (l + (lmax+1) * n); + tmp = (Snlm[i]*cosmphi[m] + Tnlm[i]*sinmphi[m]); + if ((Snlm[i] == 0.) & (Tnlm[i] == 0.)) { + // i++; + continue; + } + + sph_grad_phi_nlm(s, phi, X, n, l, m, lmax, &tmp_grad[0]); + tmp_grad2[0] += tmp_grad[0] * tmp; // r + tmp_grad2[1] += tmp_grad[1] * tmp; // theta + tmp_grad2[2] += tmp_grad[2] * (Tnlm[i]*cosmphi[m] - Snlm[i]*sinmphi[m]) / (s*sintheta); // phi + + // i++; + } + } + } + tmp_grad[0] = tmp_grad2[0]; + tmp_grad[1] = tmp_grad2[1]; + tmp_grad[2] = tmp_grad2[2]; + + // transform to cartesian + tmp_grad2[0] = sintheta*cosphi*tmp_grad[0] + X*cosphi*tmp_grad[1] - sinphi*tmp_grad[2]; + tmp_grad2[1] = sintheta*sinphi*tmp_grad[0] + X*sinphi*tmp_grad[1] + cosphi*tmp_grad[2]; + tmp_grad2[2] = X*tmp_grad[0] - sintheta*tmp_grad[1]; + + gradx[k] += tmp_grad2[0]*G*M/(r_s*r_s); + grady[k] += tmp_grad2[1]*G*M/(r_s*r_s); + gradz[k] += tmp_grad2[2]*G*M/(r_s*r_s); + } +} + +double scf_value(double t, double *pars, double *q, int n_dim) { + /* pars: + - G (Gravitational constant) + - nmax + - lmax + - m (mass scale) + - r_s (length scale) + [- sin_coeff, cos_coeff] + */ + + double G = pars[0]; + int nmax = (int)pars[1]; + int lmax = (int)pars[2]; + double M = pars[3]; + double r_s = pars[4]; + + double val[1] = {0.}; + double _val; + int n,l,m; + + int num_coeff = 0; + for (n=0; n<(nmax+1); n++) { + for (l=0; l<(lmax+1); l++) { + for (m=0; m<(lmax+1); m++) { + num_coeff++; + } + } + } + + scf_potential_helper(&q[0], 1, + G, M, r_s, + &pars[5], &pars[5+num_coeff], + nmax, lmax, &val[0]); + + _val = val[0]; + return _val; +} + +void scf_gradient(double t, double *__restrict__ pars, double *__restrict__ q, int n_dim, size_t N, double *__restrict__ grad, void *__restrict__ state) { + /* pars: + - G (Gravitational constant) + - nmax + - lmax + - m (mass scale) + - r_s (length scale) + [- sin_coeff, cos_coeff] + */ + double G = pars[0]; + int nmax = (int)pars[1]; + int lmax = (int)pars[2]; + double M = pars[3]; + double r_s = pars[4]; + + int n,l,m; + + int num_coeff = 0; + for (n=0; n<(nmax+1); n++) { + for (l=0; l<(lmax+1); l++) { + for (m=0; m<(lmax+1); m++) { + num_coeff++; + } + } + } + + scf_gradient_helper(q, q + N, q + 2 * N, N, + G, M, r_s, + &pars[5], &pars[5+num_coeff], + nmax, lmax, grad, grad + N, grad + 2 * N); +} + +double scf_density(double t, double *pars, double *q, int n_dim) { + /* pars: + - G (Gravitational constant) + - nmax + - lmax + - m (mass scale) + - r_s (length scale) + [- sin_coeff, cos_coeff] + */ + double G = pars[0]; + int nmax = (int)pars[1]; + int lmax = (int)pars[2]; + double M = pars[3]; + double r_s = pars[4]; + + double val[1] = {0.}; + double _val; + int n,l,m; + + int num_coeff = 0; + for (n=0; n<(nmax+1); n++) { + for (l=0; l<(lmax+1); l++) { + for (m=0; m<(lmax+1); m++) { + num_coeff++; + } + } + } + + scf_density_helper(&q[0], 1, + M, r_s, + &pars[5], &pars[5+num_coeff], + nmax, lmax, &val[0]); + + _val = val[0]; + return _val; +} + +/* Support for interpolation between SCF coefficient snapshots */ +void get_bound_idx(double val, double *arr, int narr, int *idx) { + double dist = fabs(arr[0] - val); + double newdist; + int min_i = 0; + for (int i=0; i val) { + idx[0] = min_i - 1; + idx[1] = min_i; + } else { + idx[0] = min_i; + idx[1] = min_i + 1; + } +} + +void interp_helper(double t, double *__restrict__ q, double *__restrict__ pars, int ntimes, int ncoeff, + double *__restrict__ interp_pars, double *__restrict__ newq) { + int i, n; + for (i=0; i<5; i++) { + interp_pars[i] = pars[i]; + } + + interp_pars[0] = pars[0]; + interp_pars[1] = pars[1]; + interp_pars[2] = pars[2]; + interp_pars[3] = pars[4]; // skip ntimes + interp_pars[4] = pars[5]; + + // Get the indices in the coefficient time array, tj, that bound the + // evaluation time, t. pars[6 + 2*ncoeff] is tj! + int idx[2]; + get_bound_idx(t, &pars[6 + 2*ncoeff*ntimes], ntimes, &idx[0]); + + // Time difference between bounding timesteps + double t0 = pars[6 + 2*ncoeff*ntimes + idx[0]]; + double dt = pars[6 + 2*ncoeff*ntimes + idx[1]] - t0; + double slope; + + if (idx[0] == idx[1]) { + // evaluation time exactly equals one of the coefficient times + for (n=0; n + +extern void scf_density_helper(double *xyz, int K, double M, double r_s, + double *Snlm, double *Tnlm, + int nmax, int lmax, double *dens); + +extern void scf_potential_helper(double *xyz, int K, + double G, double M, double r_s, + double *Snlm, double *Tnlm, + int nmax, int lmax, double *val); + +void scf_gradient_helper(double *x, double *y, double *z, int K, + double G, double M, double r_s, + double *Snlm, double *Tnlm, + int nmax, int lmax, + double *gradx, double *grady, double *gradz); + +extern double scf_value(double t, double *pars, double *q, int n_dim); +extern void scf_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double scf_density(double t, double *pars, double *q, int n_dim); + +extern double scf_interp_value(double t, double *pars, double *q, int n_dim); +extern void scf_interp_gradient(double t, double *pars, double *q, int n_dim, size_t N, double *grad, void *state); +extern double scf_interp_density(double t, double *pars, double *q, int n_dim); diff --git a/gala/source/src/gala/potential/scf/src/bfe_helper.cpp b/gala/source/src/gala/potential/scf/src/bfe_helper.cpp new file mode 100644 index 0000000000000000000000000000000000000000..3237ec67501e2df56cef3ef962e84ba5a66cc9a0 --- /dev/null +++ b/gala/source/src/gala/potential/scf/src/bfe_helper.cpp @@ -0,0 +1,91 @@ +#include +#include "extra_compile_macros.h" +#include +#include "bfe_helper.h" +#if USE_GSL == 1 +#include "gsl/gsl_sf_legendre.h" +#include "gsl/gsl_sf_gegenbauer.h" +#include "gsl/gsl_sf_gamma.h" +#endif + +#define SQRT_FOURPI 3.544907701811031 + +#if USE_GSL == 1 +double rho_nl(double s, int n, int l) { + double RR, Knl; + Knl = 0.5*n*(n+4*l+3) + (l+1)*(2*l+1); + RR = Knl/(2*M_PI) * pow(s,l) / (s*pow(1+s,2*l+3)) * gsl_sf_gegenpoly_n(n, 2*l + 1.5, (s-1)/(s+1)); + return SQRT_FOURPI*RR; +} +double rho_nlm(double s, double phi, double X, int n, int l, int m) { + return rho_nl(s, n, l) * gsl_sf_legendre_sphPlm(l, m, X);// / SQRT_FOURPI; +} + +double phi_nl(double s, int n, int l) { + return -SQRT_FOURPI*pow(s,l) * pow(1+s, -2*l-1) * gsl_sf_gegenpoly_n(n, 2*l+1.5, (s-1)/(s+1)); +} +double phi_nlm(double s, double phi, double X, int n, int l, int m) { + return phi_nl(s, n, l) * gsl_sf_legendre_sphPlm(l, m, X); // / SQRT_FOURPI; +} + +void sph_grad_phi_nlm(double s, double phi, double X, int n, int l, int m, + int lmax, double *sphgrad) { + double A, dYlm_dtheta; + double dPhinl_dr, dPhi_dphi, dPhi_dtheta; + + // spherical coord stuff + double sintheta = sqrt(1-X*X); + + double Phi_nl, Ylm, Plm, Pl1m; + Phi_nl = phi_nl(s, n, l); + + Ylm = gsl_sf_legendre_sphPlm(l, m, X); + + // Correct: associated Legendre polynomial -- not sphPlm! + if (m <= l) { + Plm = gsl_sf_legendre_Plm(l, m, X); + } else { + Plm = 0.; + } + + // copied out of Mathematica + if (n == 0) { + dPhinl_dr = SQRT_FOURPI*pow(s,-1 + l)*pow(1 + s,-3 - 2*l)*(1 + s)*(l*(-1 + s) + s); + } else { + dPhinl_dr = (SQRT_FOURPI*pow(s,-1 + l)*pow(1 + s,-3 - 2*l)* + (-2*(3 + 4*l)*s*gsl_sf_gegenpoly_n(-1 + n, 2.5 + 2*l, (-1 + s)/(1 + s)) + + (1 + s)*(l*(-1 + s) + s)*gsl_sf_gegenpoly_n(n, 1.5 + 2*l, (-1 + s)/(1 + s)))); + } + dPhinl_dr *= Ylm; + + if (l==0) { + dYlm_dtheta = 0.; + } else { + // Correct: associated Legendre polynomial -- not sphPlm! + if (m <= (l-1)) { + Pl1m = gsl_sf_legendre_Plm(l-1, m, X); + } else { + Pl1m = 0.; + } + + if (l == m) { + A = sqrt(2*l+1) / SQRT_FOURPI * sqrt(1. / gsl_sf_gamma(l+m+1.)); + } else { + A = sqrt(2*l+1) / SQRT_FOURPI * sqrt(gsl_sf_gamma(l-m+1.) / gsl_sf_gamma(l+m+1.)); + } + dYlm_dtheta = A / sintheta * (l*X*Plm - (l+m)*Pl1m); + } + dPhi_dtheta = dYlm_dtheta * Phi_nl / s; + + if (m == 0) { + dPhi_dphi = 0.; + } else { + dPhi_dphi = m; + } + dPhi_dphi *= Ylm * Phi_nl; + + sphgrad[0] = dPhinl_dr; + sphgrad[1] = dPhi_dtheta; + sphgrad[2] = dPhi_dphi; +} +#endif diff --git a/gala/source/src/gala/potential/scf/src/bfe_helper.h b/gala/source/src/gala/potential/scf/src/bfe_helper.h new file mode 100644 index 0000000000000000000000000000000000000000..c7d9a6817cdf30ffbe55291153ed0d87276fa377 --- /dev/null +++ b/gala/source/src/gala/potential/scf/src/bfe_helper.h @@ -0,0 +1,10 @@ +// #ifndef _BFE_HELPER_ +// #define _BFE_HELPER_ +extern double rho_nl(double s, int n, int l); +extern double rho_nlm(double s, double phi, double X, int n, int l, int m); + +extern double phi_nl(double s, int n, int l); +extern double phi_nlm(double s, double phi, double X, int n, int l, int m); + +extern void sph_grad_phi_nlm(double s, double phi, double X, int n, int l, int m, int lmax, double *sphgrad); +// #endif diff --git a/gala/source/src/gala/potential/scf/src/coeff_helper.cpp b/gala/source/src/gala/potential/scf/src/coeff_helper.cpp new file mode 100644 index 0000000000000000000000000000000000000000..6ac4835a299a89680411d1b7595a75e6d17aae40 --- /dev/null +++ b/gala/source/src/gala/potential/scf/src/coeff_helper.cpp @@ -0,0 +1,117 @@ +#include +#include "extra_compile_macros.h" +#include +#include "coeff_helper.h" +#include "bfe_helper.h" +#include +#if USE_GSL == 1 +#include "gsl/gsl_sf_legendre.h" +#include "gsl/gsl_sf_gegenbauer.h" +#include "gsl/gsl_sf_gamma.h" +#endif + +#define SQRT_FOURPI 3.544907701811031 + +#if USE_GSL == 1 +double STnlm_integrand_help(double phi, double X, double xsi, + double density, int n, int l, int m) { + /* + Computes the integrand used to compute the expansion + coefficients, Snlm, Tnlm. The integral is done over: + + * phi: azimuthal angle + * X: cos(theta), where theta is the colatitude + (e.g., from spherical coordinates typical to physicists) + * xsi: (s-1)/(s+1), a radial coordinate mapped to the interval + [-1,1] rather than [0,inf]. + */ + double s = (1 + xsi) / (1 - xsi); + double sinth = sqrt(1 - X*X); + + // temporary variables + double Knl, Anl_til, krond, numer, denom, ds; + + Knl = 0.5*n*(n + 4*l + 3) + (l + 1)*(2*l + 1); + if (m == 0) { + krond = 1.; + } else { + krond = 0.; + } + + numer = gsl_sf_fact(n) * (n + 2*l + 1.5) * pow(gsl_sf_gamma(2*l + 1.5),2); + denom = gsl_sf_gamma(n + 4*l + 3); + Anl_til = -(pow(2., 8*l+6) / (4*M_PI*Knl)) * numer / denom; + + ds = s*s*(s+1)*(s+1) / 2; // change of variables ds -> dxsi + return (2 - krond) * phi_nlm(s, phi, X, n, l, m) * Anl_til * density * ds; + +} + +double c_Snlm_integrand(double phi, double X, double xsi, + double density, int n, int l, int m) { + return STnlm_integrand_help(phi, X, xsi, density, n, l, m) * cos(m*phi); +} + +double c_Tnlm_integrand(double phi, double X, double xsi, + double density, int n, int l, int m) { + return STnlm_integrand_help(phi, X, xsi, density, n, l, m) * sin(m*phi); +} + +void c_STnlm_discrete(double *s, double *phi, double *X, double *m_k, int K, + int n, int l, int m, double *ST) { + // temporary variables + double Knl, Anl_til, krond, numer, denom, coeff, _tmp; + + Knl = 0.5*n*(n + 4*l + 3) + (l + 1)*(2*l + 1); + if (m == 0) { + krond = 1.; + } else { + krond = 0.; + } + + numer = gsl_sf_fact(n) * (n + 2*l + 1.5) * pow(gsl_sf_gamma(2*l + 1.5),2); + denom = gsl_sf_gamma(n + 4*l + 3); + Anl_til = -(pow(2., 8*l+6) / (4*M_PI*Knl)) * numer / denom; + coeff = (2 - krond) * Anl_til; + + // zero out coeff storage array + ST[0] = 0.; + ST[1] = 0.; + for (int k=0; k +#include + +class double6ptr { + // This class is a container for 6 pointers to doubles. + // It lets us naturally pass around arrays of shape (6,N). + // Note that not all the pointers may be valid; it is the + // responsibility of the downstream function to not dereference + // an invalid pointer! + +public: + double *__restrict__ x, *__restrict__ y, *__restrict__ z; + double *__restrict__ px, *__restrict__ py, *__restrict__ pz; + + explicit double6ptr(double *__restrict__ q, size_t N) { + x = q; + y = q + N; + z = q + 2 * N; + px = q + 3 * N; + py = q + 4 * N; + pz = q + 5 * N; + } + + // Access through the index operator will dereference the pointers + double & operator[](int i) { + switch (i) { + case 0: return *x; + case 1: return *y; + case 2: return *z; + case 3: return *px; + case 4: return *py; + case 5: return *pz; + default: throw std::out_of_range("Index out of range"); + } + } + + const double & operator[](int i) const { + switch (i) { + case 0: return *x; + case 1: return *y; + case 2: return *z; + case 3: return *px; + case 4: return *py; + case 5: return *pz; + default: throw std::out_of_range("Index out of range"); + } + } +}; + +// This is a wrapper to generate vectorized versions of the scalar gradient +// functions. It looks a little gnarly because we have to play some tricks to ensure +// that the compiler can vectorize through the call to the scalar function. + +#define DEFINE_VECTORIZED_GRADIENT(POTENTIAL_NAME) \ +struct POTENTIAL_NAME##_gradient_functor { \ + template \ + void operator()(Params&&... params) { \ + POTENTIAL_NAME##_gradient_single(std::forward(params)...); \ + } \ +}; \ +void POTENTIAL_NAME##_gradient(double t, double *__restrict__ pars, double *__restrict__ q, int n_dim, size_t N, double *__restrict__ grad, void *__restrict__ state) { \ + gradientv(t, pars, q, n_dim, N, grad, state); \ +} + +template +void gradientv(double t, double *__restrict__ pars, double *__restrict__ q, int n_dim, size_t N, double *__restrict__ grad, void *__restrict__ state) { + F f; + for (size_t i = 0; i < N; i++) { + f(t, pars, + double6ptr{q + i, N}, + n_dim, + double6ptr{grad + i, N}, + state); + } +} + +#endif diff --git a/gala/source/src/gala/units.py b/gala/source/src/gala/units.py new file mode 100644 index 0000000000000000000000000000000000000000..d87bc7e2c205e3f66e3d0f464b5302277c96ba66 --- /dev/null +++ b/gala/source/src/gala/units.py @@ -0,0 +1,387 @@ +__all__ = [ + "DimensionlessUnitSystem", + "SimulationUnitSystem", + "UnitSystem", + "dimensionless", + "galactic", + "solarsystem", +] + +import astropy.constants as const +import astropy.units as u +import numpy as np +from astropy.units.physical import _physical_unit_mapping + +_greek_letters = [ + "alpha", + "beta", + "gamma", + "delta", + "epsilon", + "zeta", + "eta", + "theta", + "iota", + "kappa", + "lambda", + "mu", + "nu", + "xi", + "pi", + "o", + "rho", + "sigma", + "tau", + "upsilon", + "phi", + "chi", + "psi", + "omega", +] + + +class UnitSystem: + _required_physical_types = [ + u.get_physical_type("length"), + u.get_physical_type("time"), + u.get_physical_type("mass"), + u.get_physical_type("angle"), + ] + + def __init__(self, units, *args): + """ + Represents a system of units. + + At minimum, this consists of a set of length, time, mass, and angle + units, but may also contain preferred representations for composite + units. For example, the base unit system could be ``{kpc, Myr, Msun, + radian}``, but you can also specify a preferred velocity unit, such as + ``km/s``. + + This class behaves like a dictionary with keys set by physical types. If + a unit for a particular physical type is not specified on creation, a + composite unit will be created with the base units. See the examples + below for some demonstrations. + + Parameters + ---------- + units : :class:`~astropy.units.Unit` or :class:`astropy.units.Quantity` + The first unit that defines the unit system (e.g., length). + *args : :class:`~astropy.units.Unit` or :class:`~astropy.units.Quantity` + Additional units that define the unit system (e.g., time, mass, angle, and + any preferred composite units). At minimum, you must specify length, time, + mass, and angle units, in any order. + + Examples + -------- + If only base units are specified, any physical type specified as a key + to this object will be composed out of the base units:: + + >>> usys = UnitSystem(u.m, u.s, u.kg, u.radian) + >>> usys['energy'] # doctest: +SKIP + Unit("kg m2 / s2") + + However, custom representations for composite units can also be + specified when initializing:: + + >>> usys = UnitSystem(u.m, u.s, u.kg, u.radian, u.erg) + >>> usys['energy'] + Unit("erg") + + This is useful for Galactic dynamics where lengths and times are usually + given in terms of ``kpc`` and ``Myr``, but velocities are given in + ``km/s``:: + + >>> usys = UnitSystem(u.kpc, u.Myr, u.Msun, u.radian, u.km/u.s) + >>> usys['velocity'] + Unit("km / s") + + """ + + self._core_units = [] + + if isinstance(units, UnitSystem): + self._registry = units._registry.copy() + self._core_units = units._core_units + return + + if len(args) > 0: + units = (units, *tuple(args)) + + self._registry = {} + for unit in units: + if not isinstance(unit, u.UnitBase): # hopefully a quantity + q = unit + new_unit = u.def_unit(f"{q!s}", q) + unit = new_unit + + typ = unit.decompose().physical_type + if typ in self._registry: + raise ValueError(f"Multiple units passed in with type '{typ}'") + self._registry[typ] = unit + + for phys_type in self._required_physical_types: + if phys_type not in self._registry: + raise ValueError( + f"You must specify a unit for the physical type'{phys_type}'" + ) + self._core_units.append(self._registry[phys_type]) + + @classmethod + def from_string(cls, name: str) -> "UnitSystem": + """ + Create a UnitSystem instance from a name. + + Parameters + ---------- + name : str + The name of the unit system. Examples of valid names are 'galactic', + 'solarsystem', and 'dimensionless'. + + Returns + ------- + usys : :class:`~gala.units.UnitSystem` + The corresponding unit system instance. + + Examples + -------- + Create the galactic unit system from its name:: + + >>> usys = UnitSystem.from_string('galactic') + >>> usys['length'] + Unit("kpc") + + """ + try: + return _usys_name_mapping[name.lower()] + except KeyError as e: + raise ValueError( + f"Unit system name '{name}' is not recognized. Valid names are: " + f"{list(_usys_name_mapping.keys())}" + ) from e + + def __getitem__(self, key): + key = u.get_physical_type(key) + + if key in self._registry: + return self._registry[key] + + unit = None + for k, v in _physical_unit_mapping.items(): + if v == key: + unit = u.Unit(" ".join([f"{x}**{y}" for x, y in k])) + break + + if unit is None: + raise ValueError(f"Physical type '{key}' doesn't exist in unit registry.") + + unit = unit.decompose(self._core_units) + unit._scale = 1.0 + return unit + + def __len__(self): + return len(self._core_units) + + def __iter__(self): + yield from self._core_units + + def __str__(self): + core_units = ", ".join([str(uu) for uu in self._core_units]) + return f"UnitSystem ({core_units})" + + def __repr__(self): + return f"<{self.__str__()}>" + + def __eq__(self, other): + for k in self._registry: + if not self[k] == other[k]: + return False + + return all(self[k] == other[k] for k in other._registry) + + def __ne__(self, other): + return not self.__eq__(other) + + def to_dict(self): + """ + Return a dictionary representation of the unit system with keys + set by the physical types and values set by the unit objects. + """ + return self._registry.copy() + + def decompose(self, q): + """ + A thin wrapper around :meth:`astropy.units.Quantity.decompose` that + knows how to handle Quantities with physical types with non-default + representations. + + Parameters + ---------- + q : :class:`~astropy.units.Quantity` + An instance of an astropy Quantity object. + + Returns + ------- + q : :class:`~astropy.units.Quantity` + A new quantity, decomposed to be represented in this unit system. + """ + try: + ptype = q.unit.physical_type + except AttributeError as e: + raise TypeError( + "Object must be an astropy.units.Quantity, not " + f"a '{q.__class__.__name__}'." + ) from e + + if ptype in self._registry: + return q.to(self._registry[ptype]) + return q.decompose(self) + + def get_constant(self, name): + """ + Retrieve a constant with specified name in this unit system. + + Parameters + ---------- + name : str + The name of the constant, e.g., G. + + Returns + ------- + const : float + The value of the constant represented in this unit system. + + Examples + -------- + We will get the value of the speed of light in a custom unit system: + + >>> usys = UnitSystem(u.kpc, u.Myr, u.Msun, u.radian) + >>> usys.get_constant('c') # doctest: +SKIP + 306.6013937855506 + + """ + try: + c = getattr(const, name) + except AttributeError as e: + raise ValueError( + f"Constant name '{name}' doesn't exist in astropy.constants" + ) from e + + return c.decompose(self._core_units).value + + +class DimensionlessUnitSystem(UnitSystem): + _required_physical_types = [] + + def __init__(self): + """ + Initialize a dimensionless unit system. All quantities are treated as + dimensionless. + """ + self._core_units = [u.one] + self._registry = {"dimensionless": u.one} + + def __getitem__(self, key): + return u.one + + def __str__(self): + return "UnitSystem (dimensionless)" + + def to_dict(self): + raise ValueError("Cannot represent dimensionless unit system as dict!") + + def get_constant(self, name): + raise ValueError("Cannot get constant in dimensionless units!") + + +l_pt = u.get_physical_type("length") +m_pt = u.get_physical_type("mass") +t_pt = u.get_physical_type("time") +v_pt = u.get_physical_type("velocity") +a_pt = u.get_physical_type("angle") + + +class SimulationUnitSystem(UnitSystem): + def __init__( + self, + length: u.Unit | u.Quantity[l_pt] = None, + mass: u.Unit | u.Quantity[m_pt] = None, + time: u.Unit | u.Quantity[t_pt] = None, + velocity: u.Unit | u.Quantity[v_pt] = None, + G: float | u.Quantity = 1.0, + angle: u.Unit | u.Quantity[a_pt] = u.radian, + ): + """ + Represents a system of units for a (dynamical) simulation. + + A common assumption is that G=1. If this is the case, then you only have to + specify two of the three fundamental unit types (length, mass, time), and the + rest will be derived from these. Alternatively, you may specify a velocity + instead of one of the three, and the remaining units will be derived. + + Parameters + ---------- + length : :class:`~astropy.units.Unit`, :class:`~astropy.units.Quantity`, optional + The length unit or quantity. + mass : :class:`~astropy.units.Unit`, :class:`~astropy.units.Quantity`, optional + The mass unit or quantity. + time : :class:`~astropy.units.Unit`, :class:`~astropy.units.Quantity`, optional + The time unit or quantity. + velocity : :class:`~astropy.units.Unit`, :class:`~astropy.units.Quantity`, optional + The velocity unit or quantity. + G : float, :class:`~astropy.units.Quantity`, optional + The value of the gravitational constant to use. Default is 1.0. + angle : :class:`~astropy.units.Unit`, :class:`~astropy.units.Quantity`, optional + The angle unit. Default is astropy.units.radian. + + Examples + -------- + To convert simulation positions and velocities to physical units, you can + use this unit system:: + + usys = SimulationUnitSystem(length=10 * u.kpc, time=50 * u.Myr) + (sim_pos * usys["length"]).to(u.kpc) + (sim_vel * usys["velocity"]).to(u.km/u.s) + + Or, to convert positions and velocities from physical units to simulation + units:: + + (100 * u.kpc).to(usys["length"]) + + """ + G = 1 / G * const.G + + if length is not None and mass is not None: + time = 1 / np.sqrt(G * mass / length**3) + elif length is not None and time is not None: + mass = 1 / G * length**3 / time**2 + elif length is not None and velocity is not None: + time = length / velocity + mass = velocity**2 / G * length + elif mass is not None and time is not None: + length = np.cbrt(G * mass * time**2) + elif mass is not None and velocity is not None: + length = G * mass / velocity**2 + time = length / velocity + elif time is not None and velocity is not None: + mass = 1 / G * velocity**3 * time + length = G * mass / velocity**2 + else: + msg = ( + "You must specify at least two of the three fundamental unit types " + "(length, mass, time) or a velocity unit." + ) + raise ValueError(msg) + + super().__init__(length, mass, time, angle) + + +galactic = UnitSystem(u.kpc, u.Myr, u.Msun, u.radian, u.km / u.s) +solarsystem = UnitSystem(u.au, u.M_sun, u.yr, u.radian) +dimensionless = DimensionlessUnitSystem() + +_usys_name_mapping = { + "galactic": galactic, + "solarsystem": solarsystem, + "dimensionless": dimensionless, +} diff --git a/gala/source/src/gala/util.py b/gala/source/src/gala/util.py new file mode 100644 index 0000000000000000000000000000000000000000..d9d79243df7920d81630dd50559350ea57948360 --- /dev/null +++ b/gala/source/src/gala/util.py @@ -0,0 +1,172 @@ +"""General utilities.""" + +import os +from contextlib import contextmanager + +import numpy as np + +__all__ = ["assert_angles_allclose", "atleast_2d", "rolling_window"] + + +def rolling_window(arr, window_size, stride=1, return_idx=False): + """ + There is an example of an iterator for pure-Python objects in: + http://stackoverflow.com/questions/6822725/rolling-or-sliding-window-iterator-in-python + This is a rolling-window iterator Numpy arrays, with window size and + stride control. See examples below for demos. + + Parameters + ---------- + arr : array_like + Input numpy array. + window_size : int + Width of the window. + stride : int (optional) + Number of indices to advance the window each iteration step. + return_idx : bool (optional) + Whether to return the slice indices alone with the array segment. + + Examples + -------- + >>> a = np.array([1, 2, 3, 4, 5, 6]) + >>> for x in rolling_window(a, 3): + ... print(x) + [1 2 3] + [2 3 4] + [3 4 5] + [4 5 6] + >>> for x in rolling_window(a, 2, stride=2): + ... print(x) + [1 2] + [3 4] + [5 6] + >>> for (i1, i2), x in rolling_window(a, 2, stride=2, return_idx=True): # doctest: +SKIP + ... print(i1, i2, x) + (0, 2, array([1, 2])) + (2, 4, array([3, 4])) + (4, 6, array([5, 6])) + + """ + + window_size = int(window_size) + stride = int(stride) + + if window_size < 0 or stride < 1: + raise ValueError + + arr_len = len(arr) + if arr_len < window_size: + if return_idx: + yield (0, arr_len), arr + else: + yield arr + + ix1 = 0 + while ix1 < arr_len: + ix2 = ix1 + window_size + result = arr[ix1:ix2] + if return_idx: + yield (ix1, ix2), result + else: + yield result + if len(result) < window_size or ix2 >= arr_len: + break + ix1 += stride + + +def atleast_2d(*arys, **kwargs): + """ + View inputs as arrays with at least two dimensions. + + Parameters + ---------- + arys1, arys2, ... : array_like + One or more array-like sequences. Non-array inputs are converted + to arrays. Arrays that already have two or more dimensions are + preserved. + insert_axis : int (optional) + Where to create a new axis if input array(s) have <2 dim. + + Returns + ------- + res, res2, ... : ndarray + An array, or tuple of arrays, each with ``a.ndim >= 2``. + Copies are avoided where possible, and views with two or more + dimensions are returned. + + Examples + -------- + >>> atleast_2d(3.0) # doctest: +FLOAT_CMP + array([[3.]]) + + >>> x = np.arange(3.0) + >>> atleast_2d(x) # doctest: +FLOAT_CMP + array([[0., 1., 2.]]) + >>> atleast_2d(x, insert_axis=-1) # doctest: +FLOAT_CMP + array([[0.], + [1.], + [2.]]) + >>> atleast_2d(x).base is x + True + + >>> atleast_2d(1, [1, 2], [[1, 2]]) + [array([[1]]), array([[1, 2]]), array([[1, 2]])] + + """ + insert_axis = kwargs.pop("insert_axis", 0) + slc = [slice(None)] * 2 + slc[insert_axis] = None + slc = tuple(slc) + + res = [] + for ary in arys: + ary = np.asanyarray(ary) + if len(ary.shape) == 0: + result = ary.reshape(1, 1) + elif len(ary.shape) == 1: + result = ary[slc] + else: + result = ary + res.append(result) + if len(res) == 1: + return res[0] + return res + + +def assert_angles_allclose(x, y, **kwargs): + """ + Like numpy's assert_allclose, but for angles (in radians). + """ + c2 = (np.sin(x) - np.sin(y)) ** 2 + (np.cos(x) - np.cos(y)) ** 2 + diff = np.arccos((2.0 - c2) / 2.0) # a = b = 1 + assert np.allclose(diff, 0.0, **kwargs) + + +class GalaDeprecationWarning(DeprecationWarning): + """ + A warning class to indicate a deprecated feature. + """ + + +class GalaFutureWarning(FutureWarning): + """ + A warning class to indicate a future change that will impact users. + """ + + +@contextmanager +def chdir(new_path): + """ + Context manager to change the current working directory. + + Parameters + ---------- + new_path : str + The path to change to. + """ + old_path = os.getcwd() + os.chdir(new_path) + try: + yield + finally: + os.chdir(old_path) diff --git a/gala/source/tests/benchmarks/test_integrate_benchmark.py b/gala/source/tests/benchmarks/test_integrate_benchmark.py new file mode 100644 index 0000000000000000000000000000000000000000..a8aaed410e1f4245f4d86dd9e8e4f58235f81368 --- /dev/null +++ b/gala/source/tests/benchmarks/test_integrate_benchmark.py @@ -0,0 +1,30 @@ +import astropy.units as u +import pytest + +import gala.dynamics as gd +import gala.potential as gp + + +@pytest.fixture +def potential(): + return gp.HernquistPotential(m=1e11, c=5, units="galactic") + + +@pytest.mark.benchmark +@pytest.mark.parametrize("Integrator", ["dop853", "leapfrog", "ruth4"]) +@pytest.mark.parametrize("n_steps", [1, 10, 1_000, 100_000]) +@pytest.mark.parametrize("n_orbits", [1, 10, 1_000, 100_000]) +def test_integrate_circular_orbit(potential, Integrator, n_steps, n_orbits): + w0 = gd.PhaseSpacePosition([10.0, 0, 0] * u.kpc, [0, 138.25768647, 0] * u.km / u.s) + potential.integrate_orbit(w0, dt=1 * u.Myr, n_steps=n_steps, Integrator=Integrator) + + +@pytest.mark.benchmark +@pytest.mark.parametrize("Integrator", ["dop853", "leapfrog", "ruth4"]) +@pytest.mark.parametrize("n_steps", [1, 10, 1_000, 100_000]) +@pytest.mark.parametrize("n_orbits", [1, 10, 1_000, 100_000]) +def test_integrate_eccentric_orbit(potential, Integrator, n_steps, n_orbits): + w0 = gd.PhaseSpacePosition( + [10.0, 0, 0] * u.kpc, [0, 0.25 * 138.25768647, 0] * u.km / u.s + ) + potential.integrate_orbit(w0, dt=1 * u.Myr, n_steps=n_steps, Integrator=Integrator) diff --git a/gala/source/tests/benchmarks/test_mockstream_benchmark.py b/gala/source/tests/benchmarks/test_mockstream_benchmark.py new file mode 100644 index 0000000000000000000000000000000000000000..dedb954f359b8ac9b19f6f432bfd5af3ab74885f --- /dev/null +++ b/gala/source/tests/benchmarks/test_mockstream_benchmark.py @@ -0,0 +1,163 @@ +import astropy.units as u +import numpy as np +import pytest + +import gala.dynamics as gd +import gala.potential as gp +from gala.dynamics import mockstream as ms + + +@pytest.fixture +def rng(): + """Random number generator with fixed seed for reproducibility.""" + return np.random.default_rng(42) + + +@pytest.fixture +def hamiltonian(): + """Standard NFW potential for benchmarks.""" + return gp.Hamiltonian( + gp.NFWPotential.from_circular_velocity( + v_c=220 * u.km / u.s, r_s=15 * u.kpc, units="galactic" + ) + ) + + +@pytest.fixture +def progenitor_w0(): + """Standard progenitor initial conditions.""" + return gd.PhaseSpacePosition( + pos=[10.0, 0, 0.0] * u.kpc, vel=[0, 170, 0.0] * u.km / u.s + ) + + +@pytest.fixture +def progenitor_mass(): + """Standard progenitor mass.""" + return 2.5e4 * u.Msun + + +@pytest.mark.benchmark +@pytest.mark.parametrize("StreamDF", ["FardalStreamDF", "ChenStreamDF"]) +@pytest.mark.parametrize("n_steps", [10, 100, 1_000]) +@pytest.mark.parametrize("n_particles", [1, 2, 10]) +def test_mockstream_generation( + hamiltonian, progenitor_w0, progenitor_mass, rng, StreamDF, n_steps, n_particles +): + """Benchmark basic stream generation with different DFs and parameters.""" + if StreamDF == "FardalStreamDF": + df = ms.FardalStreamDF(gala_modified=True, random_state=rng) + elif StreamDF == "ChenStreamDF": + df = ms.ChenStreamDF(random_state=rng) + + gen = ms.MockStreamGenerator(df=df, hamiltonian=hamiltonian) + stream, prog = gen.run( + progenitor_w0, + progenitor_mass, + dt=1 * u.Myr, + n_steps=n_steps, + n_particles=n_particles, + progress=False, + ) + + +@pytest.mark.benchmark +@pytest.mark.parametrize("Integrator", ["dop853", "leapfrog"]) +@pytest.mark.parametrize("n_steps", [10, 100, 1_000]) +def test_mockstream_integrators( + hamiltonian, progenitor_w0, progenitor_mass, rng, Integrator, n_steps +): + """Benchmark stream generation with different integrators.""" + df = ms.FardalStreamDF(gala_modified=True, random_state=rng) + gen = ms.MockStreamGenerator(df=df, hamiltonian=hamiltonian) + + stream, prog = gen.run( + progenitor_w0, + progenitor_mass, + dt=1 * u.Myr, + n_steps=n_steps, + Integrator=Integrator, + progress=False, + ) + + +@pytest.mark.benchmark +@pytest.mark.parametrize("release_every", [1, 5, 10]) +@pytest.mark.parametrize("n_steps", [100, 1_000]) +def test_mockstream_release_every( + hamiltonian, progenitor_w0, progenitor_mass, rng, release_every, n_steps +): + """Benchmark stream generation with different release frequencies.""" + df = ms.FardalStreamDF(gala_modified=True, random_state=rng) + gen = ms.MockStreamGenerator(df=df, hamiltonian=hamiltonian) + + stream, prog = gen.run( + progenitor_w0, + progenitor_mass, + dt=1 * u.Myr, + n_steps=n_steps, + release_every=release_every, + progress=False, + ) + + +@pytest.mark.benchmark +@pytest.mark.parametrize("n_steps", [100, 1_000]) +def test_mockstream_with_self_gravity( + hamiltonian, progenitor_w0, progenitor_mass, rng, n_steps +): + """Benchmark stream generation with progenitor self-gravity.""" + df = ms.FardalStreamDF(gala_modified=True, random_state=rng) + prog_pot = gp.PlummerPotential(m=progenitor_mass, b=4 * u.pc, units="galactic") + gen = ms.MockStreamGenerator( + df=df, hamiltonian=hamiltonian, progenitor_potential=prog_pot + ) + + stream, prog = gen.run( + progenitor_w0, + progenitor_mass, + dt=1 * u.Myr, + n_steps=n_steps, + progress=False, + ) + + +@pytest.mark.benchmark +@pytest.mark.parametrize("n_steps", [100, 1_000]) +@pytest.mark.parametrize("n_perturbers", [1, 3, 5]) +def test_mockstream_with_nbody( + hamiltonian, progenitor_w0, progenitor_mass, rng, n_steps, n_perturbers +): + """Benchmark stream generation with N-body perturbers.""" + from gala.dynamics.nbody import DirectNBody + + df = ms.FardalStreamDF(gala_modified=True, random_state=rng) + gen = ms.MockStreamGenerator(df=df, hamiltonian=hamiltonian) + + # Create perturbers with random positions and velocities + perturber_positions = rng.uniform(-20, 20, size=(3, n_perturbers)) * u.kpc + perturber_velocities = rng.uniform(-100, 100, size=(3, n_perturbers)) * u.km / u.s + perturber_w0 = gd.PhaseSpacePosition( + pos=perturber_positions, vel=perturber_velocities + ) + + # Create DirectNBody with perturbers + particle_potentials = [ + gp.PlummerPotential(m=1e9 * u.Msun, b=100 * u.pc, units="galactic") + for _ in range(n_perturbers) + ] + nbody = DirectNBody( + w0=perturber_w0, + particle_potentials=particle_potentials, + external_potential=hamiltonian.potential, + frame=hamiltonian.frame, + ) + + stream, prog = gen.run( + progenitor_w0, + progenitor_mass, + dt=1 * u.Myr, + n_steps=n_steps, + nbody=nbody, + progress=False, + ) diff --git a/gala/source/tests/benchmarks/test_potentials_benchmark.py b/gala/source/tests/benchmarks/test_potentials_benchmark.py new file mode 100644 index 0000000000000000000000000000000000000000..a7900657a7d5cb5277e1fb639258250eb76890df --- /dev/null +++ b/gala/source/tests/benchmarks/test_potentials_benchmark.py @@ -0,0 +1,119 @@ +import functools +import pathlib +import sys + +import astropy.units as u +import numpy as np +import pytest +from gala._cconfig import EXP_ENABLED, GSL_ENABLED + +import gala.potential as gp +from gala.units import SimulationUnitSystem + +this_path = pathlib.Path(__file__).parent +potentials_test_path = (this_path / "../potential/potential").resolve() + +# NOTE: this is a hack to allow importing from tests/potential/potential +sys.path.append(str(potentials_test_path)) + + +@pytest.mark.parametrize("n_points", [1, 10, 1_000, 100_000]) +class BenchmarkPotentialBase: + @pytest.fixture(scope="class") + def rng(self): + return np.random.default_rng(42) + + def sample_xyz(self, n_points, rng): + return rng.normal(0, 10, size=(3, n_points)) + + def sample_vxyz(self, n_points, rng): + return rng.normal(0, 100, size=(3, n_points)) + + @pytest.mark.benchmark(max_rounds=16) + def test_evaluate_potential(self, n_points, rng): + self.potential.energy(self.sample_xyz(n_points, rng)) + + @pytest.mark.benchmark(max_rounds=16) + def test_evaluate_gradient(self, n_points, rng): + self.potential.gradient(self.sample_xyz(n_points, rng)) + + @pytest.mark.benchmark(max_rounds=16) + def test_evaluate_density(self, n_points, rng): + self.potential.density(self.sample_xyz(n_points, rng)) + + +# ============================================================================ +# Spherical + + +class TestHernquistBenchmark(BenchmarkPotentialBase): + potential = gp.HernquistPotential(m=1e11, c=5, units="galactic") + + +# ============================================================================ +# Special + + +@pytest.mark.skipif(not GSL_ENABLED, reason="requires GSL") +class TestSphericalSplineBenchmark_density(BenchmarkPotentialBase): + @functools.cached_property + def potential(self): + from test_spherical_spline import _make_potential + + return _make_potential("density") + + +@pytest.mark.skipif(not GSL_ENABLED, reason="requires GSL") +class TestSphericalSplineBenchmark_potential(BenchmarkPotentialBase): + @functools.cached_property + def potential(self): + from test_spherical_spline import _make_potential + + return _make_potential("potential") + + +@pytest.mark.skipif(not GSL_ENABLED, reason="requires GSL") +class TestSphericalSplineBenchmark_mass(BenchmarkPotentialBase): + @functools.cached_property + def potential(self): + from test_spherical_spline import _make_potential + + return _make_potential("mass") + + +@pytest.mark.skipif(not EXP_ENABLED, reason="requires EXP") +class TestEXPStaticBenchmark(BenchmarkPotentialBase): + @functools.cached_property + def potential(self): + """Note: See tests/potential/potential/test_exp.py""" + exp_units = SimulationUnitSystem( + mass=1.25234e11 * u.Msun, length=3.845 * u.kpc, G=1 + ) + EXP_CONFIG_FILE = str(potentials_test_path / "EXP-Hernquist-basis.yml") + EXP_SINGLE_COEF_FILE = str( + potentials_test_path / "EXP-Hernquist-single-coefs.hdf5" + ) + return gp.EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_SINGLE_COEF_FILE, + units=exp_units, + ) + + +@pytest.mark.skipif(not EXP_ENABLED, reason="requires EXP") +class TestEXPTimeInterpBenchmark(BenchmarkPotentialBase): + @functools.cached_property + def potential(self): + """Note: See tests/potential/potential/test_exp.py""" + exp_units = SimulationUnitSystem( + mass=1.25234e11 * u.Msun, length=3.845 * u.kpc, G=1 + ) + EXP_CONFIG_FILE = str(potentials_test_path / "EXP-Hernquist-basis.yml") + EXP_SINGLE_COEF_FILE = str( + potentials_test_path / "EXP-Hernquist-multi-coefs.hdf5" + ) + return gp.EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_SINGLE_COEF_FILE, + units=exp_units, + ) diff --git a/gala/source/tests/coordinates/SgrCoord.cpp b/gala/source/tests/coordinates/SgrCoord.cpp new file mode 100644 index 0000000000000000000000000000000000000000..1d3e27961505428d882ad707c534b61f041ea417 --- /dev/null +++ b/gala/source/tests/coordinates/SgrCoord.cpp @@ -0,0 +1,34 @@ +#include "SgrCoord.h" +#include + +int main() +{ + using namespace std; + + double l, b, r; + double Xs, Ys, Zs, lambda, beta, lambda_gc, beta_gc, d; + double Xsun = 8.0; + + r = 1.0; + double ls[] = {111.413, 174.123, 18.34, 272.435, 14.341, 1.0, 71.45, 50.13, 200.14, 310.124}; + double bs[] = {13.51, 10.12, -19.1245, 68.46, 45.136, 81.512, -71.235, 21.535, 1.641, -11.51346}; + + ofstream output_file; + output_file.open ("SgrCoord_data"); + + output_file << "# l,b,lambda,beta\n"; + for (int i=0; i < 10; i++) { + char buffer [50]; + + l = ls[i]; + b = bs[i]; + LBRtoSgr(l,b,r,Xs,Ys,Zs,lambda,beta,Xsun); + + sprintf(buffer, "%f,%f,%f,%f\n", l, b, lambda, beta); + output_file << buffer; + } + + output_file.close(); + + return 0; +} diff --git a/gala/source/tests/coordinates/SgrCoord.h b/gala/source/tests/coordinates/SgrCoord.h new file mode 100644 index 0000000000000000000000000000000000000000..2ee29d78982e2948beb1603cb26327fd43be8a76 --- /dev/null +++ b/gala/source/tests/coordinates/SgrCoord.h @@ -0,0 +1,166 @@ +// SgrCoord.h +// C++ header file of transformation code to the Sgr longitudinal coordinate systems +// defined by Majewski et al. 2003 (ApJ, 599, 1082). +// Author: David R. Law (drlaw@virginia.edu), University of Virginia +// June 2003 +// http://www.astro.virginia.edu/~drl5n/Sgr/ +// +// This transformation code has been made publically available to promote the use +// of the Sgr longitudinal coordinate system, and may be used freely. However, +// please acknowledge this website when using this code, and leave all +// header information intact. +// +// Last modified Jan 2010. +// Modification revises Z_Sgr and Z_Sgr,GC to be positive in the direction of the +// orbital pole of Sgr (i.e., match the convention already used for beta) + +#include +#include +using namespace std; + +// Transform positions from standard left handed Galactocentric XYZ to +// the heliocentric Sgr system (lambda=0 at Sgr) +// Input must be in kpc of the form X Y Z +// Output is in kpc and degrees, of the form X_Sgr Y_Sgr Z_Sgr r lambda beta +void XYZtoSgr(double X,double Y,double Z,double &Xs,double &Ys,double &Zs,double &r,double &lambda,double &beta,double Xsun=7.0) + { + double radpdeg=3.141592653589793/180.; + // Define the Euler angles + double phi=(180+3.75)*radpdeg; + double theta=(90-13.46)*radpdeg; + double psi=(180+14.111534)*radpdeg; + + // Define the rotation matrix from the Euler angles + double rot11=cos(psi)*cos(phi)-cos(theta)*sin(phi)*sin(psi); + double rot12=cos(psi)*sin(phi)+cos(theta)*cos(phi)*sin(psi); + double rot13=sin(psi)*sin(theta); + double rot21=-sin(psi)*cos(phi)-cos(theta)*sin(phi)*cos(psi); + double rot22=-sin(psi)*sin(phi)+cos(theta)*cos(phi)*cos(psi); + double rot23=cos(psi)*sin(theta); + double rot31=sin(theta)*sin(phi); + double rot32=-sin(theta)*cos(phi); + double rot33=cos(theta); + + X=-X; // Make the input system right-handed + X=X+Xsun; // Transform the input system to heliocentic right handed coordinates + + // Calculate X,Y,Z,distance in the Sgr system + Xs=rot11*X+rot12*Y+rot13*Z; + Ys=rot21*X+rot22*Y+rot23*Z; + Zs=rot31*X+rot32*Y+rot33*Z; + r=sqrt(Xs*Xs+Ys*Ys+Zs*Zs); + + Zs=-Zs; + // Calculate the angular coordinates lambda,beta + lambda=atan2(Ys,Xs)/radpdeg; + if (lambda<0) lambda=lambda+360; + beta=asin(Zs/sqrt(Xs*Xs+Ys*Ys+Zs*Zs))/radpdeg; + } + + +// Transform positions from Galactic coordinates (l,b,r) to +// the heliocentric Sgr system (lambda=0 at Sgr) +// Input must be in degrees and kpc of the form l b r +// Output is in kpc and degrees, of the form X_Sgr Y_Sgr Z_Sgr r lambda beta +void LBRtoSgr(double l,double b,double r,double &Xs,double &Ys,double &Zs,double &lambda,double &beta,double Xsun=7.0) + { + double radpdeg=3.141592653589793/180.; + double X,Y,Z; + + // Transform l,b to radians + l=l*radpdeg; b=b*radpdeg; + + // Transform to heliocentric Cartesian coordinates + X=r*cos(b)*cos(l); + Y=r*cos(b)*sin(l); + Z=r*sin(b); + + // Transform to Galactocentric left handed frame + X=-X; + X=X+Xsun; + + // Transform from left handed Galactocentric to Sgr coordinates + XYZtoSgr(X,Y,Z,Xs,Ys,Zs,r,lambda,beta,Xsun); + } + + +// Transform positions from standard left handed Galactocentric XYZ to +// the Galactocentric Sgr system (lambda=0 at the Galactic plane) +// Input must be in kpc of the form X Y Z +// Output is in kpc and degrees, of the form X_Sgr,GC Y_Sgr,GC Z_Sgr,GC d_GC lambda_GC beta_GC +// Note that d is distance from Galactic Center +void XYZtoSgrGC(double X,double Y,double Z,double &Xs,double &Ys,double &Zs,double &d,double &lambda,double &beta,double Xsun=7.0) + { + double radpdeg=3.141592653589793/180.; + // Define the Euler angles + double phi=(180+3.75)*radpdeg; + double theta=(90-13.46)*radpdeg; + double psiGC=(180+21.604399)*radpdeg; + // Rotation angle of phiGC past 180degrees is a useful number + double ang=21.604399*radpdeg; + // Note that the plane does not actually include the G.C., although it is close + double xcenter=-8.5227; + double ycenter=-.3460; + double zcenter=-.0828; + double Temp,Temp2,Temp3; + + // Define the rotation matrix from the Euler angles + double GCrot11=cos(psiGC)*cos(phi)-cos(theta)*sin(phi)*sin(psiGC); + double GCrot12=cos(psiGC)*sin(phi)+cos(theta)*cos(phi)*sin(psiGC); + double GCrot13=sin(psiGC)*sin(theta); + double GCrot21=-sin(psiGC)*cos(phi)-cos(theta)*sin(phi)*cos(psiGC); + double GCrot22=-sin(psiGC)*sin(phi)+cos(theta)*cos(phi)*cos(psiGC); + double GCrot23=cos(psiGC)*sin(theta); + double GCrot31=sin(theta)*sin(phi); + double GCrot32=-sin(theta)*cos(phi); + double GCrot33=cos(theta); + + X=-X; // Make the input system right-handed + X=X+Xsun; // Transform the input system to heliocentric right handed coordinates + + // Calculate Z,distance in the SgrGC system + Temp=GCrot11*(X+xcenter)+GCrot12*(Y-ycenter)+GCrot13*(Z-zcenter); + Temp2=GCrot21*(X+xcenter)+GCrot22*(Y-ycenter)+GCrot23*(Z-zcenter); + Zs=GCrot31*(X+xcenter)+GCrot32*(Y-ycenter)+GCrot33*(Z-zcenter); + d=sqrt(Temp*Temp+Temp2*Temp2+Zs*Zs); + + Zs=-Zs; + // Calculate the angular coordinates lambdaGC,betaGC + Temp3=atan2(Temp2,Temp)/radpdeg; + if (Temp3<0) Temp3=Temp3+360; + Temp3=Temp3+ang/radpdeg; + if (Temp3>360) Temp3=Temp3-360; + lambda=Temp3; + beta=asin(Zs/sqrt(Temp*Temp+Temp2*Temp2+Zs*Zs))/radpdeg; + + // Calculate X,Y in the SgrGC system + Xs=Temp*cos(ang)-Temp2*sin(ang); + Ys=Temp*sin(ang)+Temp2*cos(ang); + } + + +// Transform positions from Galactic coordinates (l,b,r) to +// the Galactocentric Sgr system (lambda=0 at the Galactic plane) +// Input must be in degrees and kpc of the form l b r +// Output is in kpc and degrees, of the form X_Sgr,GC Y_Sgr,GC Z_Sgr,GC d_GC lambda_GC beta_GC +// Note that d is distance from Galactic Center +void LBRtoSgrGC(double l,double b,double r,double &Xs,double &Ys,double &Zs,double &d,double &lambda,double &beta,double Xsun=7.0) + { + double radpdeg=3.141592653589793/180.; + double X,Y,Z; + + // Transform l,b to radians + l=l*radpdeg; b=b*radpdeg; + + // Transform to heliocentric Cartesian coordinates + X=r*cos(b)*cos(l); + Y=r*cos(b)*sin(l); + Z=r*sin(b); + + // Transform to Galactocentric left handed frame + X=-X; + X=X+Xsun; + + // Transform from left handed Galactocentric to Sgr coordinates + XYZtoSgrGC(X,Y,Z,Xs,Ys,Zs,d,lambda,beta); + } diff --git a/gala/source/tests/coordinates/SgrCoord_data b/gala/source/tests/coordinates/SgrCoord_data new file mode 100644 index 0000000000000000000000000000000000000000..5246de23b05e6b4f6386e898347a91f45df6b390 --- /dev/null +++ b/gala/source/tests/coordinates/SgrCoord_data @@ -0,0 +1,11 @@ +# l,b,lambda,beta +111.413000,13.510000,168.128916,-72.827611 +174.123000,10.120000,173.665905,-11.595970 +18.340000,-19.124500,8.135988,-8.928467 +272.435000,68.460000,255.400865,8.074558 +14.341000,45.136000,302.340030,-16.922319 +1.000000,81.512000,264.587983,-12.904682 +71.450000,-71.235000,68.860235,-3.959954 +50.130000,21.535000,328.558258,-47.760479 +200.140000,1.641000,171.457470,15.524190 +310.124000,-11.513460,346.920841,54.463997 diff --git a/gala/source/tests/coordinates/Vasiliev2020-Sagittarius-subset.csv b/gala/source/tests/coordinates/Vasiliev2020-Sagittarius-subset.csv new file mode 100644 index 0000000000000000000000000000000000000000..7913b15ed23e4f72a23cd23d84dfcef9b1606e51 --- /dev/null +++ b/gala/source/tests/coordinates/Vasiliev2020-Sagittarius-subset.csv @@ -0,0 +1,1001 @@ +ra,dec,parallax,plxerr,pmra,pmraerr,pmdec,pmdecerr,g_mag,bp_rp,j_mag,h_mag,k_mag,ebv,dist,disterr,vlos,vloserr,FeH,FeHerr,src,Lambda,Beta +288.285,-33.9943,-0.012,0.042,-2.945,0.07,-1.327,0.062,15.39,1.76,13.19,12.39,12.25,0.08,26.16,0.71,nan,nan,nan,nan,0,-4.342,4.379 +289.8917,-34.6561,-0.023,0.06,-2.533,0.095,-1.263,0.078,15.81,1.45,13.95,13.3,13.18,0.11,25.61,0.91,nan,nan,nan,nan,0,-5.747,4.866 +283.6658,-30.9564,0.082,0.067,-2.689,0.082,-1.44,0.073,15.67,1.74,13.5,12.77,12.54,0.14,27.68,3.45,nan,nan,nan,nan,0,-0.017,1.979 +286.7396,-29.7587,-0.05,0.045,-2.612,0.083,-1.42,0.069,15.39,1.85,13.09,12.3,12.04,0.13,26.36,2.24,nan,nan,nan,nan,0,-2.443,0.371 +281.939,-28.3551,0.072,0.098,-2.606,0.145,-1.266,0.128,16.71,1.43,14.88,14.17,14.08,0.19,27.3,1.48,nan,nan,nan,nan,0,1.929,-0.309 +279.3032,-29.7067,0.018,0.056,-2.705,0.094,-1.355,0.084,16.02,1.61,13.92,13.22,13.07,0.17,26.56,0.82,nan,nan,nan,nan,0,3.919,1.473 +292.2863,-32.2551,0.081,0.047,-2.76,0.079,-1.629,0.071,15.67,1.71,13.55,12.74,12.58,0.08,28.0,1.0,nan,nan,nan,nan,0,-7.47,2.259 +207.6631,0.3263,-0.117,0.171,-0.814,0.271,-0.331,0.197,17.6,1.36,15.87,15.21,15.03,0.03,49.26,2.64,nan,nan,nan,nan,0,78.26,2.749 +276.4176,-28.8792,-0.019,0.059,-2.551,0.094,-1.204,0.085,15.58,2.32,12.77,11.81,11.57,0.44,26.89,1.19,nan,nan,nan,nan,0,6.557,1.221 +282.8943,-30.6772,0.02,0.036,-2.62,0.076,-1.324,0.068,14.67,2.22,11.99,11.11,10.88,0.14,26.96,0.87,147.76,0.02,-0.88,0.02,1,0.685,1.821 +285.9609,-33.7155,0.024,0.054,-2.674,0.089,-1.115,0.082,14.85,2.35,12.1,11.22,10.95,0.09,26.84,0.51,132.1,nan,nan,nan,4,-2.386,4.383 +297.9801,-31.1381,0.066,0.139,-2.403,0.223,-1.319,0.135,17.67,1.35,15.92,15.26,15.09,0.17,29.67,0.69,nan,nan,nan,nan,0,-12.219,0.782 +228.7228,-12.0126,-0.058,0.17,-0.972,0.282,-0.648,0.225,17.73,1.46,15.99,15.15,14.96,0.11,49.24,1.16,nan,nan,nan,nan,0,53.953,2.885 +280.4156,-31.3219,0.073,0.077,-2.416,0.115,-1.071,0.099,16.18,1.66,14.09,13.37,13.22,0.16,25.45,0.93,nan,nan,nan,nan,0,2.65,2.858 +289.1025,-31.9789,-0.233,0.189,-2.116,0.334,-1.562,0.342,18.17,1.26,16.57,15.74,15.54,0.12,27.56,1.88,nan,nan,nan,nan,0,-4.758,2.291 +284.7918,-29.9314,0.096,0.118,-3.151,0.158,-1.484,0.143,16.81,1.55,14.85,14.12,13.9,0.17,27.25,2.03,nan,nan,nan,nan,0,-0.801,0.805 +292.0548,-32.9769,0.063,0.077,-2.983,0.127,-1.753,0.106,16.33,1.49,14.46,13.81,13.64,0.1,28.26,1.61,nan,nan,nan,nan,0,-7.349,2.997 +39.9652,3.8957,-0.354,0.196,-0.051,0.196,-2.176,0.197,17.48,1.19,16.1,15.42,15.3,0.04,27.3,5.58,nan,nan,nan,nan,0,-114.484,-0.164 +280.4922,-29.9387,-0.325,0.149,-2.282,0.268,-1.684,0.247,17.93,1.33,16.43,15.72,15.63,0.18,29.06,3.23,nan,nan,nan,nan,0,2.862,1.489 +22.0418,-2.2126,0.039,0.123,-0.649,0.217,-3.021,0.13,17.13,1.24,15.56,14.94,14.89,0.05,24.41,2.84,-171.67,6.28,-0.6,0.22,2,-95.923,-3.938 +59.0499,13.372,-0.052,0.092,0.423,0.17,-1.22,0.113,16.73,1.78,14.53,13.74,13.55,0.32,35.05,10.29,nan,nan,nan,nan,0,-135.559,0.709 +291.1575,-33.3791,0.053,0.038,-2.444,0.063,-1.552,0.062,14.9,1.67,12.79,12.05,11.88,0.11,27.84,1.54,nan,nan,nan,nan,0,-6.644,3.476 +287.7446,-31.469,0.089,0.044,-2.692,0.084,-1.399,0.079,15.06,1.91,12.7,11.9,11.71,0.09,28.68,1.24,146.67,0.96,nan,nan,6,-3.544,1.94 +292.0384,-30.907,-0.228,0.16,-1.91,0.256,-1.569,0.238,17.92,1.22,16.43,15.58,15.48,0.11,26.14,0.9,nan,nan,nan,nan,0,-7.124,0.939 +305.6904,-28.3101,-0.051,0.035,-2.708,0.055,-2.134,0.038,14.95,1.75,12.82,12.03,11.83,0.06,23.64,2.83,nan,nan,nan,nan,0,-18.868,-2.152 +285.2872,-31.0438,0.024,0.095,-2.553,0.157,-1.201,0.136,16.96,1.55,15.02,14.34,14.27,0.12,26.94,0.53,nan,nan,nan,nan,0,-1.403,1.833 +283.1558,-31.1133,0.066,0.054,-2.982,0.121,-1.349,0.095,15.81,1.47,13.92,13.27,13.15,0.14,25.26,1.71,nan,nan,nan,nan,0,0.386,2.21 +281.6643,-28.8641,-0.061,0.054,-2.51,0.097,-1.348,0.089,15.84,1.82,13.55,12.77,12.56,0.18,25.51,0.95,nan,nan,nan,nan,0,2.069,0.237 +225.7243,-8.7182,-0.047,0.079,-1.055,0.137,-0.492,0.12,16.46,1.71,14.36,13.58,13.39,0.08,51.05,3.39,nan,nan,nan,nan,0,58.147,1.475 +295.6889,-34.0619,-0.049,0.076,-2.493,0.117,-1.548,0.08,16.63,1.6,14.66,13.89,13.8,0.15,24.51,1.58,nan,nan,nan,nan,0,-10.467,3.816 +296.9087,-32.1378,0.012,0.085,-2.969,0.121,-1.985,0.086,16.59,1.59,14.59,13.87,13.7,0.2,27.35,2.08,nan,nan,nan,nan,0,-11.362,1.83 +284.5718,-30.1586,-0.164,0.128,-2.5,0.161,-1.363,0.14,16.94,1.5,14.77,14.1,14.03,0.14,28.65,0.6,nan,nan,nan,nan,0,-0.651,1.06 +283.9158,-30.8513,-0.332,0.227,-2.552,0.267,-1.733,0.23,17.6,1.44,15.65,14.95,14.86,0.17,25.35,0.29,nan,nan,nan,nan,0,-0.21,1.838 +22.5106,-28.4554,0.073,0.044,-0.793,0.096,-4.027,0.033,15.48,1.28,13.82,13.23,13.15,0.01,20.68,0.79,nan,nan,nan,nan,0,-82.849,18.936 +208.9851,9.9753,-0.04,0.06,-1.221,0.114,-0.516,0.078,16.07,1.86,13.8,13.02,12.72,0.03,44.52,4.7,-19.1,3.32,-0.37,0.13,2,82.011,-6.24 +315.9989,-28.2,0.035,0.16,-2.903,0.194,-2.611,0.144,16.58,1.34,14.86,14.27,14.18,0.11,24.1,1.65,nan,nan,nan,nan,0,-27.939,-1.685 +283.1052,-29.6902,0.047,0.069,-2.557,0.149,-1.189,0.131,16.31,1.76,14.12,13.36,13.17,0.16,27.65,1.57,nan,nan,nan,nan,0,0.682,0.817 +287.0094,-31.6075,-0.06,0.111,-2.855,0.173,-1.257,0.169,17.16,1.4,15.37,14.69,14.58,0.09,27.95,1.22,nan,nan,nan,nan,0,-2.944,2.166 +279.0614,-28.8688,0.012,0.06,-2.557,0.127,-1.182,0.11,15.98,1.82,13.61,12.83,12.68,0.23,28.22,1.65,155.96,1.12,nan,nan,6,4.302,0.698 +289.2526,-30.4466,0.1,0.09,-2.91,0.155,-1.698,0.153,16.9,1.5,14.98,14.35,14.22,0.11,27.81,0.42,nan,nan,nan,nan,0,-4.691,0.755 +282.8806,-30.6491,0.016,0.045,-2.667,0.089,-1.277,0.077,15.74,1.85,13.45,12.72,12.47,0.14,26.96,0.87,nan,nan,nan,nan,0,0.702,1.796 +289.1085,-31.6836,0.082,0.074,-2.406,0.104,-1.526,0.091,16.21,1.65,14.16,13.41,13.24,0.11,27.74,1.88,nan,nan,nan,nan,0,-4.725,1.998 +51.6678,16.5213,0.025,0.058,0.18,0.117,-1.639,0.096,15.75,1.75,13.62,12.8,12.55,0.14,36.5,6.89,-197.47,0.89,-0.7,0.18,5,-130.749,-5.431 +314.0534,-27.8184,-0.018,0.133,-3.032,0.192,-2.831,0.163,17.12,1.3,15.53,14.91,14.83,0.1,24.06,2.19,nan,nan,nan,nan,0,-26.267,-2.236 +280.2169,-26.5637,0.018,0.058,-2.792,0.091,-1.347,0.086,15.76,1.92,13.33,12.45,12.26,0.36,26.79,2.76,nan,nan,nan,nan,0,3.777,-1.768 +286.5486,-30.9213,-0.046,0.164,-2.906,0.298,-1.069,0.266,17.87,1.31,16.14,15.5,15.38,0.11,28.99,1.45,nan,nan,nan,nan,0,-2.453,1.545 +294.4691,-31.2237,0.019,0.039,-2.346,0.068,-1.741,0.065,14.97,2.04,12.5,11.64,11.4,0.14,26.25,1.46,nan,nan,nan,nan,0,-9.227,1.064 +18.5681,-16.172,0.003,0.025,-0.702,0.051,-3.142,0.045,13.78,1.78,11.54,10.78,10.57,0.02,22.36,1.78,-100.79,1.29,nan,nan,3,-86.034,6.506 +284.0746,-30.7187,-0.056,0.055,-2.677,0.087,-1.509,0.078,15.48,1.95,13.07,12.22,12.02,0.15,29.31,1.04,nan,nan,nan,nan,0,-0.322,1.684 +283.325,-28.6021,-0.03,0.043,-2.503,0.077,-1.369,0.066,15.44,1.78,13.25,12.45,12.27,0.18,27.88,0.73,nan,nan,nan,nan,0,0.686,-0.287 +283.9535,-30.658,-0.063,0.121,-2.59,0.148,-1.161,0.129,16.62,1.61,14.57,13.83,13.63,0.16,27.26,0.9,141.15,0.09,nan,nan,1,-0.209,1.642 +34.2228,12.1206,-0.08,0.132,0.37,0.306,-2.095,0.215,17.87,1.33,16.18,15.44,15.27,0.16,24.0,2.28,nan,nan,nan,nan,0,-113.774,-10.135 +284.1152,-30.2606,0.027,0.068,-2.815,0.141,-1.384,0.131,16.49,1.53,14.54,13.91,13.82,0.15,27.86,2.59,nan,nan,nan,nan,0,-0.279,1.227 +302.2005,-32.404,0.042,0.117,-2.57,0.172,-1.797,0.12,17.36,1.48,15.56,14.76,14.57,0.22,27.18,0.09,nan,nan,nan,nan,0,-15.845,1.934 +283.4017,-30.282,0.067,0.059,-2.686,0.093,-1.293,0.085,15.64,1.58,13.62,12.9,12.76,0.16,26.88,0.91,nan,nan,nan,nan,0,0.325,1.354 +282.5651,-30.0019,-0.163,0.097,-2.545,0.133,-1.538,0.112,16.53,1.72,14.35,13.61,13.5,0.17,27.31,2.16,nan,nan,nan,nan,0,1.087,1.208 +281.4802,-29.8645,0.005,0.129,-2.852,0.237,-1.252,0.207,17.5,1.49,15.65,14.97,14.74,0.13,26.54,1.95,nan,nan,nan,nan,0,2.036,1.249 +294.9041,-30.1149,0.086,0.092,-2.634,0.163,-1.563,0.153,17.02,1.49,15.21,14.44,14.28,0.11,28.05,4.08,nan,nan,nan,nan,0,-9.512,-0.071 +215.6697,0.2857,-0.057,0.118,-0.882,0.161,-0.552,0.146,16.33,1.58,14.4,13.6,13.51,0.03,49.74,3.4,nan,nan,nan,nan,0,71.334,-1.272 +33.529,-1.4085,-0.163,0.215,0.069,0.292,-2.115,0.25,17.86,1.08,16.75,15.99,15.79,0.03,23.59,4.35,nan,nan,nan,nan,0,-106.251,1.154 +22.5752,-2.0999,-0.034,0.046,-0.464,0.079,-2.352,0.058,15.34,1.63,13.31,12.56,12.41,0.04,24.67,3.23,nan,nan,nan,nan,0,-96.443,-3.77 +282.895,-31.8546,0.045,0.044,-2.378,0.073,-1.157,0.071,15.13,1.91,12.83,11.91,11.72,0.14,27.59,0.42,nan,nan,nan,nan,0,0.472,2.979 +286.6746,-30.8677,-0.06,0.071,-2.813,0.161,-1.357,0.117,14.88,2.43,12.1,11.16,10.91,0.11,27.7,2.17,nan,nan,nan,nan,0,-2.552,1.476 +13.3384,-11.9761,-0.025,0.069,-0.318,0.153,-2.568,0.069,14.42,2.75,11.47,10.68,10.37,0.03,22.76,0.97,nan,nan,nan,nan,0,-83.623,0.38 +286.8975,-31.5046,-0.307,0.155,-2.418,0.231,-1.637,0.216,17.89,1.21,16.41,15.63,15.34,0.09,27.66,1.24,nan,nan,nan,nan,0,-2.834,2.078 +308.8963,-31.9615,0.032,0.037,-2.828,0.053,-1.942,0.035,14.48,1.79,12.29,11.48,11.29,0.06,21.99,2.36,nan,nan,nan,nan,0,-21.521,1.587 +262.0181,-20.8184,-0.062,0.166,-2.158,0.333,-0.457,0.263,17.81,2.58,14.62,13.72,13.45,0.98,30.87,4.47,nan,nan,nan,nan,0,21.369,-2.755 +39.0406,-0.6842,0.071,0.071,-0.198,0.134,-2.238,0.125,16.43,1.26,14.81,14.21,14.11,0.03,31.15,0.96,nan,nan,nan,nan,0,-111.373,3.323 +352.4234,-19.1503,-0.123,0.127,-1.347,0.21,-2.873,0.199,17.16,1.19,15.66,15.09,14.98,0.03,19.14,2.15,nan,nan,nan,nan,0,-62.376,-1.893 +281.5925,-28.2065,-0.032,0.064,-2.784,0.141,-1.211,0.127,15.95,1.72,13.74,12.99,12.84,0.23,26.56,2.04,nan,nan,nan,nan,0,2.256,-0.397 +282.857,-29.3536,-0.063,0.151,-2.11,0.278,-1.285,0.247,17.73,1.27,16.47,15.59,15.28,0.16,28.22,0.66,nan,nan,nan,nan,0,0.955,0.525 +288.4925,-31.8992,0.075,0.152,-2.211,0.254,-1.347,0.221,17.85,1.2,16.32,15.6,15.35,0.11,28.47,1.2,nan,nan,nan,nan,0,-4.234,2.28 +318.453,-32.1691,-0.085,0.051,-2.013,0.075,-1.806,0.065,15.35,1.7,13.26,12.49,12.35,0.1,23.52,0.68,nan,nan,nan,nan,0,-29.572,2.508 +354.7341,-21.2361,0.011,0.271,-1.456,0.328,-2.411,0.295,17.73,1.11,16.37,15.67,15.6,0.02,17.78,6.85,nan,nan,nan,nan,0,-63.565,0.871 +290.0635,-31.6543,-0.022,0.071,-2.809,0.131,-1.85,0.127,16.49,1.51,14.59,13.96,13.79,0.12,25.03,0.44,nan,nan,nan,nan,0,-5.529,1.868 +280.9427,-33.0445,0.026,0.066,-2.653,0.103,-0.971,0.096,16.21,1.5,14.31,13.63,13.55,0.09,27.02,0.8,nan,nan,nan,nan,0,1.869,4.458 +278.9766,-31.41,0.046,0.061,-2.59,0.092,-1.153,0.08,15.7,1.89,13.37,12.51,12.34,0.14,24.57,0.97,nan,nan,nan,nan,0,3.836,3.197 +285.207,-30.3102,-0.225,0.199,-2.463,0.279,-1.164,0.24,17.52,1.29,15.77,15.1,15.04,0.12,27.23,0.22,nan,nan,nan,nan,0,-1.217,1.12 +299.4309,-27.6769,-0.024,0.059,-2.589,0.096,-1.728,0.05,15.53,1.66,13.43,12.66,12.49,0.13,27.72,2.47,nan,nan,nan,nan,0,-13.33,-2.731 +290.0331,-32.5578,-0.026,0.042,-2.504,0.068,-1.294,0.064,15.37,1.75,13.18,12.4,12.18,0.1,26.62,1.13,nan,nan,nan,nan,0,-5.611,2.769 +327.3874,-38.5971,-0.014,0.034,-3.129,0.042,-2.928,0.05,14.22,1.81,12.02,11.18,10.98,0.03,18.86,2.44,nan,nan,nan,nan,0,-35.704,10.066 +290.5167,-31.9381,-0.061,0.089,-2.561,0.166,-1.587,0.155,16.7,1.53,14.68,13.98,13.88,0.14,26.1,0.91,nan,nan,nan,nan,0,-5.945,2.105 +262.6608,-22.7595,0.069,0.28,-2.427,0.398,-1.03,0.307,18.45,2.64,15.22,14.27,13.93,1.32,30.87,4.47,nan,nan,nan,nan,0,20.156,-1.126 +285.0317,-32.0164,0.065,0.059,-2.78,0.104,-1.7,0.099,16.18,1.52,14.22,13.52,13.38,0.13,28.26,2.39,139.69,0.05,-0.34,0.02,1,-1.344,2.828 +61.9433,12.7578,0.03,0.076,0.329,0.151,-1.034,0.106,16.27,2.15,13.63,12.66,12.5,0.47,36.28,7.85,nan,nan,nan,nan,0,-137.795,2.532 +347.4703,-29.6005,-0.125,0.084,-1.456,0.126,-2.501,0.151,16.42,1.34,14.72,14.15,14.11,0.03,23.78,2.15,nan,nan,nan,nan,0,-54.338,6.172 +216.635,-2.8545,-0.005,0.128,-0.924,0.23,-0.779,0.186,17.31,1.61,15.21,14.44,14.37,0.07,52.71,1.85,nan,nan,nan,nan,0,68.911,0.946 +288.0613,-31.8506,-0.064,0.102,-2.752,0.201,-1.393,0.151,16.45,1.54,14.56,13.93,13.84,0.09,26.35,3.01,nan,nan,nan,nan,0,-3.864,2.281 +219.175,-3.0004,-0.003,0.123,-1.159,0.194,-0.597,0.179,17.29,1.55,15.32,14.52,14.43,0.08,52.68,2.51,nan,nan,nan,nan,0,66.649,-0.211 +83.1959,22.6385,-0.894,0.391,-0.31,0.531,-0.923,0.448,18.56,2.07,16.06,15.12,14.97,0.56,49.25,1.44,nan,nan,nan,nan,0,-160.27,1.404 +287.0514,-31.2207,-0.01,0.044,-2.644,0.084,-1.176,0.065,14.88,1.84,12.64,11.8,11.58,0.09,25.98,2.19,nan,nan,nan,nan,0,-2.923,1.778 +286.2604,-31.1288,-0.199,0.152,-2.526,0.26,-1.161,0.235,17.86,1.27,16.25,15.7,15.63,0.1,27.58,0.36,nan,nan,nan,nan,0,-2.24,1.788 +160.271,22.5827,-0.056,0.18,-1.696,0.278,-1.496,0.301,17.65,1.18,16.24,15.54,15.45,0.03,25.0,1.41,nan,nan,nan,nan,0,129.471,2.574 +285.2495,-30.8853,0.014,0.066,-2.604,0.111,-1.555,0.097,16.16,1.62,14.08,13.4,13.27,0.14,26.94,0.53,nan,nan,nan,nan,0,-1.345,1.682 +286.8458,-31.7179,-0.216,0.17,-2.475,0.22,-1.655,0.204,17.28,1.27,15.71,15.13,15.05,0.09,25.9,0.24,nan,nan,nan,nan,0,-2.822,2.296 +276.4401,-30.6339,0.049,0.099,-2.967,0.149,-0.966,0.133,16.34,1.78,13.83,13.07,12.84,0.31,27.67,1.99,nan,nan,nan,nan,0,6.131,2.924 +285.6569,-30.6377,0.001,0.153,-2.578,0.213,-1.459,0.2,17.36,1.49,15.43,14.8,14.63,0.14,27.0,1.66,nan,nan,nan,nan,0,-1.652,1.382 +281.3468,-33.0079,-0.116,0.167,-2.386,0.27,-1.354,0.245,17.88,1.25,16.27,15.46,15.28,0.11,25.57,1.87,nan,nan,nan,nan,0,1.543,4.356 +281.154,-26.1242,-0.019,0.133,-2.73,0.213,-1.495,0.189,17.47,1.59,15.4,14.73,14.6,0.33,25.67,1.41,nan,nan,nan,nan,0,3.04,-2.365 +49.8985,4.5438,-0.05,0.064,0.274,0.1,-1.195,0.091,15.47,1.64,13.39,12.69,12.52,0.14,29.42,0.29,nan,nan,nan,nan,0,-123.409,4.199 +287.4731,-30.8265,0.035,0.058,-2.51,0.076,-1.259,0.067,15.36,1.64,13.36,12.57,12.46,0.09,26.55,0.05,nan,nan,nan,nan,0,-3.224,1.336 +280.697,-30.2506,-0.032,0.048,-2.811,0.08,-1.447,0.068,14.47,2.06,12.01,11.12,10.89,0.14,26.76,2.58,nan,nan,nan,nan,0,2.626,1.76 +284.2636,-30.5579,0.044,0.282,-2.609,0.467,-1.534,0.395,18.31,1.31,16.46,15.69,15.36,0.16,27.17,1.68,nan,nan,nan,nan,0,-0.455,1.498 +282.5333,-30.3623,-0.029,0.041,-2.431,0.087,-1.259,0.079,15.38,2.12,12.84,11.9,11.71,0.17,26.91,1.05,135.91,0.99,nan,nan,6,1.048,1.568 +290.9812,-32.0694,0.018,0.046,-2.744,0.08,-1.414,0.072,15.59,1.8,13.38,12.56,12.39,0.1,28.24,2.04,nan,nan,nan,nan,0,-6.351,2.191 +284.6215,-33.7174,-0.051,0.053,-2.453,0.1,-1.751,0.093,15.86,1.53,13.97,13.21,13.11,0.08,27.24,0.23,nan,nan,nan,nan,0,-1.283,4.563 +283.5774,-32.6579,0.047,0.063,-3.175,0.125,-1.189,0.105,15.62,1.6,13.59,12.9,12.73,0.11,26.96,0.77,nan,nan,nan,nan,0,-0.239,3.668 +277.4488,-28.1019,0.044,0.047,-2.87,0.081,-0.818,0.072,15.14,2.12,12.54,11.71,11.42,0.41,25.7,1.1,nan,nan,nan,nan,0,5.852,0.258 +279.9524,-28.2685,-0.114,0.056,-2.465,0.124,-1.105,0.104,15.89,1.74,13.73,12.98,12.82,0.2,27.57,2.07,130.86,1.17,nan,nan,6,3.661,-0.051 +281.8182,-28.6942,-0.003,0.057,-2.956,0.118,-1.168,0.095,15.44,1.93,13.05,12.23,12.03,0.19,27.69,1.54,nan,nan,nan,nan,0,1.969,0.044 +282.4315,-32.5608,-0.012,0.052,-2.657,0.074,-1.172,0.067,15.0,1.65,12.93,12.21,12.08,0.12,26.16,2.65,nan,nan,nan,nan,0,0.73,3.745 +248.509,-16.5568,0.099,0.082,-2.044,0.163,-0.738,0.095,16.24,2.5,13.23,12.19,11.97,0.55,32.49,3.16,nan,nan,nan,nan,0,34.831,-1.852 +280.5083,-30.091,-0.049,0.181,-2.731,0.334,-0.924,0.296,17.96,1.28,16.16,15.55,15.42,0.15,28.54,2.67,nan,nan,nan,nan,0,2.818,1.636 +282.198,-29.2845,0.095,0.065,-3.086,0.117,-1.199,0.107,16.07,1.45,14.15,13.5,13.38,0.16,27.34,1.2,nan,nan,nan,nan,0,1.532,0.562 +204.1837,17.371,-0.104,0.057,-1.588,0.107,-0.603,0.066,16.43,1.46,14.62,13.95,13.77,0.03,44.66,2.36,nan,nan,nan,nan,0,89.869,-10.237 +281.7825,-30.4497,0.007,0.055,-2.851,0.084,-1.351,0.07,15.64,1.9,13.28,12.43,12.23,0.19,28.36,0.89,157.42,0.91,nan,nan,6,1.668,1.774 +286.5543,-31.9351,0.011,0.041,-2.643,0.071,-1.455,0.063,14.82,1.87,12.53,11.69,11.5,0.1,26.74,0.86,nan,nan,nan,nan,0,-2.609,2.547 +284.4034,-29.5543,-0.025,0.093,-2.193,0.141,-1.037,0.124,16.67,1.76,14.51,13.74,13.63,0.18,27.88,1.52,nan,nan,nan,nan,0,-0.406,0.489 +245.0297,-10.3609,-0.011,0.111,-1.58,0.235,-0.49,0.146,16.89,1.78,14.64,13.85,13.73,0.25,41.04,4.46,nan,nan,nan,nan,0,40.545,-6.017 +283.7845,-26.5555,0.0,0.048,-2.913,0.074,-1.315,0.067,15.19,2.28,12.36,11.41,11.17,0.37,25.86,0.14,nan,nan,nan,nan,0,0.641,-2.374 +51.9376,9.3394,0.096,0.142,0.421,0.266,-1.561,0.234,17.64,1.59,15.5,14.84,14.66,0.25,34.53,6.14,nan,nan,nan,nan,0,-127.509,0.986 +288.7079,-31.8181,0.032,0.115,-2.898,0.219,-0.881,0.238,17.08,1.35,15.22,14.64,14.57,0.12,28.47,1.37,nan,nan,nan,nan,0,-4.405,2.175 +282.3979,-28.7586,-0.038,0.048,-2.604,0.096,-1.336,0.09,15.64,1.85,13.32,12.53,12.3,0.17,28.19,1.44,134.9,nan,nan,nan,4,1.458,0.013 +287.7338,-31.866,0.018,0.044,-2.842,0.08,-1.449,0.074,15.65,1.5,13.76,13.05,12.93,0.09,26.75,1.5,155.21,0.94,nan,nan,6,-3.591,2.334 +44.9676,8.7117,-0.051,0.112,-0.1,0.186,-1.242,0.164,17.08,1.52,15.09,14.47,14.34,0.24,30.36,3.37,-176.09,4.66,-0.62,0.17,2,-121.2,-1.841 +32.6234,7.6189,-0.098,0.117,0.052,0.16,-1.636,0.139,16.87,1.29,15.29,14.66,14.55,0.06,22.78,2.75,-188.09,6.84,-1.45,0.21,2,-110.07,-7.079 +287.1975,-31.0944,-0.013,0.05,-3.001,0.09,-1.637,0.075,15.69,1.65,13.64,12.87,12.73,0.1,28.41,3.62,nan,nan,nan,nan,0,-3.028,1.635 +281.5536,-30.9974,-0.317,0.161,-3.108,0.299,-1.039,0.284,17.91,1.24,16.38,15.69,15.44,0.13,27.19,1.03,nan,nan,nan,nan,0,1.757,2.349 +279.8877,-27.22,-0.058,0.108,-3.11,0.165,-1.428,0.139,16.35,2.07,13.78,12.84,12.71,0.46,27.88,0.63,nan,nan,nan,nan,0,3.931,-1.066 +279.0425,-27.846,-0.042,0.05,-2.553,0.088,-1.306,0.072,14.98,2.12,12.32,11.4,11.18,0.31,27.8,1.24,nan,nan,nan,nan,0,4.534,-0.298 +281.8101,-28.3068,0.001,0.094,-2.942,0.146,-1.331,0.125,16.75,1.61,14.75,13.95,13.82,0.22,26.3,1.66,nan,nan,nan,nan,0,2.049,-0.335 +276.3432,-24.7481,-0.789,0.103,-2.201,0.223,-1.528,0.199,16.52,1.9,13.97,13.25,13.05,0.42,28.43,0.47,nan,nan,nan,nan,0,7.582,-2.781 +33.5387,-9.6006,0.083,0.125,-0.335,0.173,-2.205,0.144,17.07,1.2,15.53,14.91,14.8,0.03,26.64,3.89,nan,nan,nan,nan,0,-102.071,8.209 +282.459,-29.1657,0.098,0.078,-2.492,0.155,-1.088,0.121,16.26,1.53,14.34,13.66,13.53,0.2,25.69,1.11,nan,nan,nan,nan,0,1.33,0.403 +278.1564,-26.0716,0.007,0.089,-2.682,0.154,-1.265,0.139,16.97,1.9,14.65,13.88,13.66,0.49,27.0,2.3,nan,nan,nan,nan,0,5.687,-1.861 +286.3164,-32.1145,-0.076,0.077,-2.725,0.131,-1.556,0.116,16.57,1.5,14.66,13.97,13.83,0.1,28.03,0.59,nan,nan,nan,nan,0,-2.436,2.755 +286.26,-30.5199,0.013,0.111,-2.422,0.176,-1.304,0.135,16.04,1.42,14.25,13.63,13.51,0.12,26.73,0.59,nan,nan,nan,nan,0,-2.147,1.186 +290.8615,-31.0307,-0.076,0.176,-2.478,0.307,-1.995,0.256,17.8,1.2,16.28,15.52,15.39,0.09,27.54,0.82,nan,nan,nan,nan,0,-6.134,1.17 +286.4082,-31.8937,-0.052,0.048,-2.721,0.082,-1.453,0.073,15.57,1.61,13.51,12.77,12.64,0.12,26.36,0.93,nan,nan,nan,nan,0,-2.48,2.525 +291.4328,-30.1859,-0.276,0.191,-1.909,0.326,-1.564,0.264,17.72,1.15,16.45,15.77,15.66,0.1,24.85,0.28,nan,nan,nan,nan,0,-6.53,0.277 +7.9138,-18.1702,0.029,0.073,-0.899,0.116,-2.633,0.082,16.14,1.38,14.44,13.7,13.61,0.02,25.07,1.97,nan,nan,nan,nan,0,-76.096,3.412 +223.9347,-10.2196,0.07,0.165,-1.208,0.249,-0.211,0.259,17.23,1.44,15.42,14.7,14.48,0.09,49.31,1.97,nan,nan,nan,nan,0,58.931,3.656 +280.2591,-29.7529,0.068,0.091,-2.821,0.137,-1.391,0.122,16.75,1.65,14.72,13.91,13.73,0.16,27.45,1.95,nan,nan,nan,nan,0,3.097,1.348 +236.4261,-12.5912,0.048,0.095,-1.159,0.195,-0.871,0.189,16.59,1.65,14.55,13.82,13.63,0.16,46.58,1.31,nan,nan,nan,nan,0,47.069,-0.217 +277.1155,-28.5107,0.038,0.207,-2.757,0.322,-0.997,0.278,17.28,1.68,15.08,14.32,14.18,0.36,26.69,1.47,nan,nan,nan,nan,0,6.046,0.722 +235.7629,-21.2923,-0.027,0.177,-1.481,0.322,-0.434,0.216,17.79,1.48,15.92,15.16,15.09,0.19,43.01,4.42,nan,nan,nan,nan,0,43.527,7.76 +216.4396,-0.677,0.034,0.058,-1.421,0.116,-0.311,0.094,16.05,1.59,14.11,13.4,13.32,0.05,48.48,1.53,49.31,9.31,-1.62,0.16,2,70.182,-0.832 +283.8319,-29.4035,0.059,0.054,-2.736,0.072,-1.439,0.064,15.2,2.04,12.71,11.84,11.65,0.19,26.79,2.6,nan,nan,nan,nan,0,0.11,0.425 +284.5841,-29.5323,0.088,0.177,-2.796,0.387,-1.349,0.382,17.88,1.36,16.17,15.56,15.49,0.18,28.0,0.62,nan,nan,nan,nan,0,-0.557,0.441 +242.9953,-12.8462,-0.039,0.112,-1.116,0.168,-0.56,0.134,16.66,1.9,14.3,13.42,13.24,0.3,38.94,0.27,nan,nan,nan,nan,0,41.246,-2.909 +293.0046,-31.0589,0.077,0.073,-2.221,0.145,-1.619,0.121,16.53,1.45,14.77,14.08,13.99,0.12,27.18,1.84,nan,nan,nan,nan,0,-7.963,1.009 +261.9901,-24.5,-0.04,0.124,-2.706,0.234,-0.559,0.17,16.2,3.12,12.55,11.5,11.17,1.14,32.97,3.11,nan,nan,nan,nan,0,20.152,0.72 +284.9244,-31.1272,-0.035,0.106,-2.64,0.193,-0.982,0.178,17.38,1.47,15.54,14.86,14.7,0.13,26.35,1.04,nan,nan,nan,nan,0,-1.109,1.966 +286.6226,-30.8527,0.001,0.079,-2.598,0.233,-1.388,0.162,15.95,1.69,13.87,13.09,12.9,0.11,28.91,3.55,nan,nan,nan,nan,0,-2.505,1.468 +214.2479,1.7191,-0.031,0.107,-1.408,0.139,-0.372,0.129,15.76,2.32,13.05,12.18,11.98,0.04,49.74,4.96,nan,nan,nan,nan,0,73.287,-1.786 +279.477,-32.1093,0.035,0.057,-2.875,0.084,-1.331,0.067,14.58,2.07,12.05,11.13,10.88,0.12,25.74,1.15,nan,nan,nan,nan,0,3.272,3.792 +289.1955,-32.0005,0.022,0.052,-2.642,0.082,-1.302,0.091,14.97,2.34,12.27,11.37,11.13,0.11,28.09,0.79,nan,nan,nan,nan,0,-4.839,2.303 +355.8959,-13.5442,-0.156,0.135,-2.102,0.185,-4.041,0.14,16.68,1.09,15.25,14.7,14.64,0.03,18.21,2.42,nan,nan,nan,nan,0,-67.652,-5.743 +30.6365,12.8057,0.039,0.047,-0.063,0.075,-1.81,0.06,14.71,1.77,12.57,11.81,11.63,0.08,23.2,2.08,nan,nan,nan,nan,0,-111.079,-12.533 +289.0234,-29.9046,-0.053,0.146,-2.774,0.229,-1.382,0.19,17.35,1.29,15.71,14.97,14.75,0.11,27.5,1.68,nan,nan,nan,nan,0,-4.426,0.243 +5.6148,-24.1615,0.0,0.034,-1.138,0.052,-2.754,0.039,14.61,1.61,12.65,11.85,11.73,0.01,21.97,1.55,nan,nan,nan,nan,0,-71.44,7.774 +286.0311,-29.8418,-0.023,0.091,-2.03,0.143,-1.213,0.129,16.2,1.42,14.3,13.66,13.56,0.13,26.77,0.31,nan,nan,nan,nan,0,-1.848,0.546 +5.0844,-18.2765,-0.085,0.135,-1.064,0.199,-2.654,0.165,17.4,1.2,16.03,15.35,15.25,0.04,22.15,5.83,nan,nan,nan,nan,0,-73.647,2.292 +129.7058,16.191,-0.043,0.087,-2.198,0.142,-3.163,0.094,16.55,1.1,15.17,14.53,14.44,0.02,17.83,1.94,nan,nan,nan,nan,0,156.933,14.126 +285.9011,-31.4879,0.011,0.049,-2.374,0.104,-1.609,0.086,15.71,1.85,13.44,12.59,12.44,0.11,28.91,1.71,nan,nan,nan,nan,0,-1.991,2.189 +227.0011,-3.1829,0.022,0.093,-1.127,0.144,-0.63,0.144,16.59,1.72,14.37,13.59,13.47,0.16,52.88,0.56,nan,nan,nan,nan,0,59.785,-3.963 +284.3462,-31.6407,0.022,0.065,-3.144,0.128,-1.093,0.11,14.65,3.02,11.12,10.27,9.95,0.15,26.82,2.09,nan,nan,nan,nan,0,-0.707,2.554 +278.5828,-30.9444,-0.064,0.078,-2.658,0.129,-1.321,0.107,16.46,1.62,14.45,13.74,13.56,0.17,27.06,1.8,nan,nan,nan,nan,0,4.265,2.815 +286.9183,-31.2837,0.026,0.049,-3.02,0.092,-1.515,0.083,15.79,1.51,13.91,13.18,13.1,0.09,26.35,0.55,nan,nan,nan,nan,0,-2.819,1.857 +329.2116,-34.2224,-0.0,0.075,-3.089,0.089,-2.921,0.074,13.53,2.42,10.91,9.99,9.75,0.02,21.71,2.37,83.08,0.98,nan,nan,3,-38.097,6.106 +220.3783,2.5248,-0.096,0.065,-1.214,0.107,-0.488,0.095,15.69,1.97,13.35,12.53,12.35,0.04,50.94,2.41,28.74,1.18,nan,nan,5,68.404,-5.587 +284.6094,-29.6239,-0.04,0.104,-2.608,0.199,-1.452,0.188,17.06,1.55,15.05,14.41,14.24,0.19,28.41,0.79,nan,nan,nan,nan,0,-0.594,0.528 +259.5289,-25.4615,0.049,0.305,-1.635,0.435,-0.993,0.304,18.48,2.59,15.45,14.64,14.41,1.05,32.97,1.77,nan,nan,nan,nan,0,21.915,2.392 +21.1704,-8.1046,0.048,0.184,-0.524,0.365,-2.504,0.156,17.69,1.24,16.1,15.35,15.27,0.04,26.16,0.71,nan,nan,nan,nan,0,-92.243,0.746 +212.5276,-0.008,-0.039,0.082,-0.865,0.139,-0.435,0.119,16.39,1.54,14.46,13.77,13.63,0.04,48.71,2.35,nan,nan,nan,nan,0,73.893,0.575 +287.5063,-31.6434,-0.086,0.123,-2.57,0.165,-1.148,0.147,17.03,1.48,15.19,14.52,14.38,0.08,28.18,0.64,nan,nan,nan,nan,0,-3.368,2.141 +259.7348,-24.276,0.029,0.142,-1.882,0.241,-0.985,0.164,17.59,2.54,14.44,13.5,13.17,0.93,32.97,1.77,nan,nan,nan,nan,0,22.158,1.217 +297.7017,-35.3508,0.037,0.099,-2.623,0.147,-1.419,0.106,17.03,1.46,15.15,14.52,14.38,0.15,26.76,1.26,nan,nan,nan,nan,0,-12.203,5.001 +285.295,-32.2486,-0.031,0.046,-2.642,0.09,-1.555,0.09,15.87,1.4,14.12,13.46,13.37,0.11,26.9,0.2,nan,nan,nan,nan,0,-1.602,3.022 +288.2707,-29.6607,-0.144,0.101,-2.341,0.154,-1.296,0.132,16.65,1.53,14.71,14.09,14.01,0.15,25.83,2.64,nan,nan,nan,nan,0,-3.746,0.087 +283.15,-31.8705,0.095,0.073,-2.681,0.118,-1.349,0.108,16.35,1.66,14.25,13.58,13.35,0.14,26.75,1.14,nan,nan,nan,nan,0,0.256,2.956 +287.3536,-32.0859,0.09,0.117,-2.293,0.214,-0.801,0.221,17.04,1.43,15.24,14.54,14.45,0.09,26.76,0.8,nan,nan,nan,nan,0,-3.302,2.597 +279.9259,-27.2775,0.092,0.199,-2.55,0.284,-1.242,0.254,17.85,1.72,15.68,14.84,14.59,0.45,27.88,0.63,nan,nan,nan,nan,0,3.886,-1.017 +281.293,-30.1197,-0.033,0.208,-2.493,0.351,-1.354,0.317,18.29,1.33,16.48,15.76,15.45,0.14,27.31,0.9,nan,nan,nan,nan,0,2.146,1.531 +284.1469,-32.9981,0.008,0.057,-2.864,0.106,-1.137,0.103,15.91,1.52,14.0,13.33,13.19,0.09,26.54,2.12,nan,nan,nan,nan,0,-0.77,3.92 +299.7026,-35.1946,-0.094,0.072,-2.77,0.11,-1.599,0.072,16.35,1.58,14.43,13.7,13.5,0.11,26.96,1.11,nan,nan,nan,nan,0,-13.834,4.773 +282.9159,-31.9054,-0.116,0.096,-2.927,0.158,-1.27,0.147,16.95,1.36,15.26,14.6,14.39,0.13,27.27,0.52,nan,nan,nan,nan,0,0.446,3.026 +303.6742,-37.8604,-0.006,0.037,-2.633,0.057,-1.61,0.044,14.49,1.98,12.13,11.28,11.1,0.07,25.95,4.24,nan,nan,nan,nan,0,-17.089,7.382 +283.4252,-32.1406,0.032,0.056,-3.058,0.086,-1.521,0.083,15.66,1.66,13.59,12.78,12.64,0.12,28.37,0.96,169.67,1.16,nan,nan,6,-0.022,3.181 +280.441,-26.7388,-0.132,0.103,-2.859,0.166,-1.592,0.149,16.73,1.78,14.48,13.65,13.48,0.33,26.79,1.67,nan,nan,nan,nan,0,3.545,-1.637 +288.0779,-32.3446,-0.021,0.051,-2.804,0.085,-1.524,0.078,15.76,1.63,13.66,12.96,12.71,0.1,27.73,0.62,nan,nan,nan,nan,0,-3.946,2.768 +219.2277,5.404,-0.041,0.169,-1.017,0.276,-0.339,0.273,17.86,1.21,16.45,15.64,15.53,0.03,47.86,1.45,nan,nan,nan,nan,0,70.871,-7.485 +200.3015,16.2155,0.045,0.093,-1.256,0.193,-0.617,0.141,17.19,1.27,15.63,14.98,14.86,0.02,42.66,5.76,nan,nan,nan,nan,0,92.531,-7.368 +281.9177,-32.7367,-0.006,0.071,-2.776,0.137,-1.052,0.112,16.19,1.58,14.26,13.46,13.35,0.11,26.85,0.24,nan,nan,nan,nan,0,1.123,3.998 +262.0714,-23.3457,-0.314,0.314,-1.997,0.413,-0.328,0.306,18.45,2.74,14.87,13.89,13.49,1.31,30.87,4.47,nan,nan,nan,nan,0,20.471,-0.392 +208.9277,2.0629,-0.061,0.107,-1.074,0.136,-0.414,0.104,16.4,1.77,14.22,13.41,13.19,0.04,44.27,1.65,63.78,1.2,-0.57,0.02,5,78.046,0.612 +288.3347,-31.8793,0.097,0.149,-2.528,0.298,-1.478,0.259,17.71,1.4,15.97,15.43,15.37,0.11,26.03,2.54,nan,nan,nan,nan,0,-4.098,2.278 +15.4415,-21.0354,0.066,0.042,-0.844,0.083,-2.558,0.063,15.71,1.46,13.87,13.19,13.12,0.02,20.23,1.16,nan,nan,nan,nan,0,-81.019,9.291 +282.0204,-26.6517,-0.047,0.106,-2.69,0.152,-1.179,0.14,16.89,1.62,14.86,14.12,13.95,0.22,26.51,2.08,nan,nan,nan,nan,0,2.177,-1.996 +287.7067,-30.8904,-0.049,0.086,-2.789,0.122,-1.446,0.101,16.23,1.49,14.32,13.62,13.43,0.09,26.2,0.6,nan,nan,nan,nan,0,-3.432,1.371 +315.164,-35.86,0.003,0.051,-2.519,0.068,-1.902,0.052,15.43,1.62,13.43,12.66,12.54,0.07,25.6,3.28,nan,nan,nan,nan,0,-26.429,5.859 +277.1407,-23.3643,0.016,0.1,-1.722,0.193,-1.514,0.171,17.12,1.71,14.81,14.12,13.96,0.4,28.75,4.59,nan,nan,nan,nan,0,7.191,-4.295 +38.5158,14.1781,-0.01,0.034,-0.148,0.069,-1.682,0.06,14.42,2.02,12.08,11.29,11.08,0.13,28.34,2.53,-221.12,3.64,-1.04,0.12,2,-118.488,-9.75 +289.4612,-29.1509,-0.015,0.038,-2.492,0.071,-1.296,0.063,15.43,1.87,13.18,12.31,12.11,0.13,28.35,1.37,nan,nan,nan,nan,0,-4.708,-0.553 +288.4282,-32.0433,-0.212,0.074,-2.836,0.241,-1.046,0.149,15.88,1.71,13.57,12.78,12.57,0.13,26.09,0.67,nan,nan,nan,nan,0,-4.199,2.43 +291.6802,-33.0121,-0.003,0.045,-2.697,0.079,-1.603,0.062,14.46,2.03,12.04,11.22,11.03,0.11,27.5,0.09,nan,nan,nan,nan,0,-7.04,3.064 +293.9056,-29.7376,0.091,0.098,-2.645,0.165,-1.784,0.162,17.04,1.49,15.15,14.5,14.38,0.12,28.12,1.78,nan,nan,nan,nan,0,-8.619,-0.377 +288.1209,-34.7594,-0.142,0.194,-2.403,0.256,-1.339,0.24,17.85,1.2,16.46,15.57,15.47,0.09,26.01,1.9,nan,nan,nan,nan,0,-4.311,5.156 +283.8481,-30.5252,0.013,0.09,-2.272,0.166,-1.594,0.149,16.9,1.5,14.98,14.31,14.19,0.15,26.21,0.58,nan,nan,nan,nan,0,-0.097,1.527 +317.3904,-34.436,-0.031,0.103,-3.162,0.13,-2.652,0.115,16.91,1.2,15.33,14.73,14.57,0.08,25.08,3.0,nan,nan,nan,nan,0,-28.405,4.646 +280.4843,-29.5453,0.028,0.054,-2.688,0.081,-1.277,0.07,15.42,1.81,13.17,12.43,12.22,0.15,26.14,1.86,nan,nan,nan,nan,0,2.947,1.105 +278.4762,-31.3278,0.041,0.042,-2.688,0.085,-1.297,0.078,15.09,1.78,12.92,12.07,11.89,0.19,25.45,0.59,nan,nan,nan,nan,0,4.271,3.209 +281.6682,-31.2058,-0.106,0.1,-2.993,0.213,-1.643,0.183,17.11,1.33,15.35,14.74,14.67,0.15,27.88,1.95,nan,nan,nan,nan,0,1.621,2.535 +299.3169,-35.1516,-0.112,0.065,-2.498,0.101,-1.567,0.067,16.36,1.48,14.47,13.81,13.63,0.1,26.96,0.36,nan,nan,nan,nan,0,-13.516,4.742 +304.1276,-35.0425,-0.015,0.18,-2.208,0.247,-1.927,0.156,17.82,1.24,16.32,15.67,15.56,0.08,24.66,2.6,nan,nan,nan,nan,0,-17.462,4.565 +179.1508,15.0264,-0.061,0.075,-2.018,0.075,-0.968,0.067,15.25,1.49,13.34,12.66,12.54,0.04,31.9,6.01,nan,nan,nan,nan,0,110.074,3.095 +288.7982,-31.4918,-0.155,0.114,-2.541,0.186,-1.563,0.175,17.43,1.48,15.59,14.95,14.75,0.13,27.33,1.09,nan,nan,nan,nan,0,-4.438,1.842 +286.6414,-29.8288,-0.062,0.123,-2.469,0.228,-1.461,0.222,17.2,1.44,15.44,14.66,14.56,0.13,27.39,0.79,nan,nan,nan,nan,0,-2.369,0.453 +285.2636,-31.8386,0.066,0.257,-2.853,0.375,-1.303,0.272,17.91,1.41,16.18,15.31,15.13,0.12,27.09,0.51,nan,nan,nan,nan,0,-1.51,2.621 +284.1345,-29.1543,-0.434,0.19,-2.849,0.294,-1.446,0.244,17.9,1.46,16.02,15.33,15.14,0.18,28.87,2.02,nan,nan,nan,nan,0,-0.108,0.134 +46.5297,7.7742,-0.07,0.167,-0.217,0.291,-1.631,0.237,17.63,1.97,15.11,14.35,14.09,0.53,27.72,3.13,nan,nan,nan,nan,0,-122.078,-0.26 +74.7351,24.9118,0.058,0.076,0.243,0.144,-0.926,0.084,16.64,2.57,13.6,12.75,12.45,0.75,47.16,3.18,nan,nan,nan,nan,0,-153.845,-3.479 +292.0609,-34.1977,-0.053,0.064,-2.532,0.111,-1.229,0.105,16.4,1.61,14.33,13.67,13.55,0.12,27.01,2.53,nan,nan,nan,nan,0,-7.479,4.211 +286.7653,-31.6859,-0.043,0.04,-2.829,0.063,-1.234,0.055,14.37,1.85,12.11,11.33,11.11,0.09,25.9,0.12,nan,nan,nan,nan,0,-2.749,2.274 +287.2612,-32.2397,-0.025,0.038,-2.391,0.06,-1.332,0.056,14.63,2.13,12.11,11.25,11.04,0.1,26.2,1.83,175.07,15.62,nan,nan,3,-3.246,2.761 +287.5538,-33.3801,0.042,0.068,-2.611,0.096,-1.474,0.085,15.76,1.49,13.87,13.17,13.03,0.09,27.66,0.22,nan,nan,nan,nan,0,-3.653,3.855 +283.6092,-31.1058,-0.07,0.19,-2.027,0.304,-1.445,0.278,18.21,1.37,16.45,15.63,15.49,0.14,28.95,1.78,nan,nan,nan,nan,0,0.005,2.134 +235.9634,-6.9632,0.007,0.103,-0.954,0.196,-0.544,0.143,17.19,1.61,15.18,14.45,14.3,0.16,48.12,4.17,nan,nan,nan,nan,0,50.116,-4.974 +354.4963,-30.8242,0.072,0.125,-2.686,0.154,-4.12,0.158,16.92,1.1,15.52,14.96,14.88,0.01,20.69,2.63,nan,nan,nan,nan,0,-59.551,9.591 +291.3025,-30.8768,-0.114,0.044,-2.39,0.071,-1.426,0.059,15.02,1.81,12.79,11.96,11.8,0.08,27.04,2.17,nan,nan,nan,nan,0,-6.493,0.975 +298.442,-28.57,0.018,0.104,-2.698,0.163,-1.91,0.1,17.09,1.42,15.3,14.65,14.44,0.14,27.36,2.09,nan,nan,nan,nan,0,-12.495,-1.803 +289.2943,-31.004,-0.142,0.07,-2.479,0.122,-1.579,0.114,16.12,1.54,14.27,13.54,13.43,0.11,28.22,0.67,nan,nan,nan,nan,0,-4.797,1.304 +295.562,-33.2325,-0.077,0.061,-3.153,0.096,-1.925,0.068,16.16,1.57,14.19,13.47,13.3,0.16,26.46,1.31,nan,nan,nan,nan,0,-10.302,2.996 +340.4915,-18.6256,-0.109,0.095,-1.753,0.179,-2.856,0.154,16.81,1.23,15.34,14.65,14.52,0.03,21.18,3.15,nan,nan,nan,nan,0,-51.934,-6.28 +295.161,-34.5187,0.05,0.18,-3.1,0.255,-1.833,0.188,17.98,1.36,16.44,15.65,15.55,0.18,27.63,2.05,nan,nan,nan,nan,0,-10.064,4.303 +284.3917,-31.6157,0.069,0.064,-2.344,0.113,-1.2,0.105,16.26,1.76,14.08,13.3,13.15,0.15,27.38,1.26,nan,nan,nan,nan,0,-0.741,2.523 +285.4284,-30.7284,0.093,0.079,-2.95,0.099,-1.355,0.086,15.11,2.53,12.26,11.31,11.06,0.15,25.91,0.54,145.68,1.97,nan,nan,6,-1.472,1.503 +283.9446,-32.0499,0.036,0.242,-2.408,0.424,-0.744,0.343,18.02,1.33,16.22,15.47,15.39,0.16,26.05,0.93,nan,nan,nan,nan,0,-0.44,3.015 +288.486,-31.941,-0.078,0.178,-2.255,0.327,-0.97,0.295,17.91,1.18,16.65,15.86,15.71,0.12,28.47,1.2,nan,nan,nan,nan,0,-4.234,2.322 +247.2227,-7.0769,-0.173,0.226,-1.709,0.366,-0.139,0.25,17.96,1.72,15.72,15.02,14.84,0.37,43.38,1.53,nan,nan,nan,nan,0,40.004,-9.915 +284.3152,-30.9504,-0.002,0.091,-2.78,0.114,-1.428,0.097,15.86,1.91,13.52,12.71,12.46,0.14,27.15,0.66,nan,nan,nan,nan,0,-0.565,1.878 +281.14,-30.5205,0.05,0.097,-2.518,0.107,-1.196,0.088,15.2,1.86,12.93,12.08,11.87,0.13,27.92,1.21,nan,nan,nan,nan,0,2.198,1.95 +283.6055,-30.0328,0.057,0.203,-2.063,0.415,-1.542,0.348,18.21,1.29,16.15,15.49,15.36,0.16,26.38,1.78,nan,nan,nan,nan,0,0.195,1.078 +285.7503,-29.5968,-0.421,0.361,-2.001,0.356,-1.458,0.295,17.83,1.3,15.95,15.35,15.21,0.13,28.38,0.1,nan,nan,nan,nan,0,-1.569,0.342 +277.8956,-27.6501,-0.054,0.151,-2.768,0.282,-1.194,0.253,17.44,1.74,15.12,14.31,14.1,0.39,26.05,0.69,nan,nan,nan,nan,0,5.567,-0.27 +282.8582,-29.8503,-0.156,0.118,-2.929,0.217,-1.437,0.196,17.3,1.38,15.64,14.95,14.86,0.17,26.46,0.3,nan,nan,nan,nan,0,0.864,1.013 +284.511,-30.5188,-0.009,0.048,-2.849,0.085,-1.483,0.073,15.49,1.64,13.42,12.69,12.52,0.14,29.19,3.83,nan,nan,nan,nan,0,-0.659,1.424 +284.0924,-30.959,-0.062,0.208,-3.047,0.254,-1.334,0.208,17.43,1.43,15.6,14.96,14.84,0.15,27.15,2.21,nan,nan,nan,nan,0,-0.378,1.919 +297.7243,-34.9992,-0.0,0.032,-3.312,0.055,-1.654,0.036,14.6,1.84,12.33,11.49,11.32,0.15,27.05,1.47,nan,nan,nan,nan,0,-12.203,4.649 +262.6754,-14.1915,0.087,0.088,-2.66,0.19,-0.766,0.138,16.75,2.04,14.23,13.41,13.2,0.55,28.03,3.44,nan,nan,nan,nan,0,23.021,-9.206 +198.654,14.5694,-0.052,0.076,-1.214,0.18,-0.802,0.104,16.89,1.4,15.07,14.4,14.3,0.02,41.37,5.13,-65.38,5.05,-1.05,0.19,2,93.1,-5.152 +280.3345,-29.9735,-0.126,0.08,-3.042,0.127,-1.39,0.11,16.07,1.59,14.08,13.36,13.21,0.16,27.59,1.26,nan,nan,nan,nan,0,2.989,1.551 +279.4966,-33.4856,-0.036,0.07,-2.658,0.133,-1.356,0.124,16.33,1.6,14.35,13.65,13.46,0.14,26.61,2.11,nan,nan,nan,nan,0,2.968,5.134 +282.9824,-29.351,-0.029,0.07,-2.913,0.147,-1.603,0.137,16.39,1.72,14.25,13.41,13.28,0.17,27.56,0.97,nan,nan,nan,nan,0,0.848,0.503 +282.7594,-31.9029,0.026,0.2,-2.569,0.319,-1.282,0.31,18.17,1.32,16.5,15.78,15.53,0.13,27.15,0.34,nan,nan,nan,nan,0,0.577,3.048 +290.0045,-31.0996,0.002,0.103,-2.493,0.143,-1.311,0.137,16.63,1.45,14.77,14.09,14.01,0.11,27.78,0.95,nan,nan,nan,nan,0,-5.412,1.324 +299.7819,-33.3152,-0.182,0.069,-2.848,0.104,-1.883,0.064,16.27,1.56,14.31,13.63,13.49,0.14,27.62,3.08,nan,nan,nan,nan,0,-13.834,2.893 +226.2366,-0.7421,0.059,0.074,-0.964,0.129,-0.484,0.127,16.4,1.68,14.29,13.51,13.36,0.06,52.51,2.58,nan,nan,nan,nan,0,61.666,-5.704 +284.9911,-32.3688,0.048,0.051,-2.355,0.12,-1.263,0.093,15.52,1.48,13.63,13.0,12.91,0.13,26.42,0.82,nan,nan,nan,nan,0,-1.368,3.182 +284.5675,-31.4395,-0.013,0.057,-2.797,0.092,-1.488,0.078,15.5,1.97,13.12,12.25,12.04,0.14,28.26,0.65,nan,nan,nan,nan,0,-0.86,2.324 +279.409,-31.602,-0.067,0.052,-2.709,0.078,-1.137,0.068,15.52,1.56,13.61,12.88,12.75,0.12,26.42,0.61,nan,nan,nan,nan,0,3.434,3.308 +287.7018,-28.8341,-0.075,0.074,-2.718,0.131,-1.292,0.105,16.31,1.61,14.22,13.53,13.37,0.16,28.64,2.33,nan,nan,nan,nan,0,-3.141,-0.664 +282.2934,-32.2734,0.082,0.074,-2.892,0.142,-1.275,0.121,16.53,1.44,14.77,14.07,13.88,0.15,26.56,0.69,nan,nan,nan,nan,0,0.898,3.484 +283.2891,-29.3815,0.031,0.085,-2.392,0.149,-0.867,0.128,16.74,1.48,14.83,14.2,14.1,0.17,27.18,0.51,nan,nan,nan,nan,0,0.579,0.485 +296.9892,-30.1578,0.054,0.065,-2.959,0.094,-2.042,0.052,15.71,1.67,13.6,12.84,12.71,0.17,28.23,0.98,nan,nan,nan,nan,0,-11.314,-0.151 +306.1051,-36.7668,-0.038,0.065,-2.672,0.092,-1.612,0.062,15.54,1.39,13.77,13.14,13.02,0.05,24.46,0.53,nan,nan,nan,nan,0,-19.048,6.31 +292.1508,-31.7534,-0.328,0.114,-2.836,0.174,-1.395,0.16,16.95,1.35,15.25,14.62,14.45,0.06,26.64,1.06,nan,nan,nan,nan,0,-7.306,1.771 +281.9122,-30.4067,0.006,0.046,-2.776,0.077,-1.286,0.069,15.33,1.9,12.96,12.14,11.91,0.18,25.65,2.5,160.0,0.02,-0.64,0.02,1,1.566,1.711 +286.1217,-29.5625,-0.01,0.057,-2.74,0.104,-1.309,0.096,16.18,1.51,14.28,13.62,13.49,0.13,26.86,1.56,nan,nan,nan,nan,0,-1.883,0.258 +279.6335,-30.2746,-0.058,0.045,-2.749,0.08,-1.208,0.072,15.13,1.77,12.9,12.13,11.99,0.14,27.38,0.07,nan,nan,nan,nan,0,3.521,1.969 +285.1817,-30.5501,0.03,0.075,-2.623,0.115,-1.432,0.099,16.12,1.72,14.01,13.22,13.04,0.12,26.94,0.96,nan,nan,nan,nan,0,-1.234,1.361 +282.4431,-28.5333,0.049,0.073,-2.754,0.109,-1.167,0.094,14.89,2.58,11.87,10.95,10.71,0.15,28.32,0.75,147.6,0.01,-0.58,0.02,1,1.46,-0.216 +290.8714,-31.0389,-0.086,0.065,-2.279,0.131,-1.587,0.105,15.87,1.5,13.98,13.29,13.18,0.09,27.53,0.81,nan,nan,nan,nan,0,-6.143,1.177 +283.7414,-30.4487,-0.078,0.054,-2.671,0.1,-1.534,0.089,15.8,1.63,13.71,12.99,12.8,0.15,27.21,1.31,134.48,nan,nan,nan,4,0.007,1.468 +214.9265,-1.4607,-0.015,0.064,-1.181,0.124,-0.611,0.098,16.25,1.63,14.25,13.45,13.31,0.06,49.74,1.22,nan,nan,nan,nan,0,71.089,0.61 +294.8716,-28.696,-0.078,0.076,-2.479,0.113,-1.86,0.089,14.6,2.44,11.87,10.96,10.75,0.11,27.9,1.63,nan,nan,nan,nan,0,-9.374,-1.484 +284.5718,-31.7852,0.086,0.076,-2.64,0.103,-1.259,0.095,16.1,1.54,13.9,13.2,12.98,0.13,26.51,0.78,nan,nan,nan,nan,0,-0.921,2.664 +235.3317,-17.3435,-0.183,0.108,-1.45,0.222,-0.412,0.144,17.01,1.6,15.04,14.16,14.01,0.1,43.03,4.09,nan,nan,nan,nan,0,45.768,4.475 +306.2619,-36.9133,-0.091,0.064,-2.767,0.09,-1.723,0.056,15.68,1.42,13.93,13.3,13.19,0.05,24.82,0.67,nan,nan,nan,nan,0,-19.171,6.46 +289.1088,-32.3578,0.002,0.165,-2.879,0.286,-1.252,0.263,17.81,1.35,16.09,15.46,15.31,0.1,27.54,0.87,nan,nan,nan,nan,0,-4.812,2.666 +291.8934,-34.1211,0.055,0.188,-2.426,0.311,-1.425,0.318,18.1,1.18,16.76,15.71,15.41,0.12,27.01,1.06,nan,nan,nan,nan,0,-7.333,4.149 +34.0403,13.4535,-0.192,0.173,0.16,0.279,-1.714,0.244,17.69,1.26,16.05,15.38,15.26,0.12,25.53,3.42,nan,nan,nan,nan,0,-114.32,-11.369 +286.3667,-30.7296,0.094,0.107,-2.546,0.141,-1.477,0.116,16.08,1.66,14.01,13.24,13.13,0.11,27.65,2.39,nan,nan,nan,nan,0,-2.269,1.379 +283.341,-31.2534,0.032,0.088,-2.591,0.173,-1.508,0.138,16.54,1.63,14.52,13.8,13.56,0.16,26.86,1.02,nan,nan,nan,nan,0,0.205,2.32 +289.3597,-31.9373,-0.097,0.108,-2.445,0.17,-1.31,0.162,16.93,1.42,15.15,14.45,14.16,0.11,27.52,1.13,nan,nan,nan,nan,0,-4.969,2.222 +283.7637,-31.3171,0.095,0.059,-2.715,0.134,-1.17,0.134,16.0,1.73,13.81,13.02,12.88,0.14,26.68,1.24,nan,nan,nan,nan,0,-0.162,2.32 +285.2477,-30.0828,-0.07,0.055,-2.57,0.091,-1.469,0.082,15.9,1.55,13.95,13.24,13.12,0.13,27.33,0.23,nan,nan,nan,nan,0,-1.215,0.89 +283.7097,-30.8897,-0.032,0.066,-2.762,0.092,-1.27,0.077,15.67,1.56,13.7,13.06,12.91,0.15,27.68,3.45,nan,nan,nan,nan,0,-0.043,1.907 +280.7128,-30.9409,0.073,0.034,-2.7,0.058,-1.448,0.052,14.84,1.88,12.53,11.68,11.52,0.13,26.49,2.14,nan,nan,nan,nan,0,2.476,2.434 +226.2079,-5.1558,-0.018,0.053,-0.97,0.093,-0.351,0.081,15.82,1.86,13.57,12.77,12.53,0.08,51.99,3.11,nan,nan,nan,nan,0,59.496,-1.857 +288.0792,-32.9903,-0.118,0.189,-2.227,0.296,-1.597,0.271,17.93,1.17,16.56,15.78,15.56,0.09,26.76,0.78,nan,nan,nan,nan,0,-4.035,3.408 +282.6327,-29.5988,0.013,0.055,-2.94,0.088,-1.342,0.077,15.15,2.17,12.59,11.7,11.45,0.16,27.79,0.57,nan,nan,nan,nan,0,1.103,0.801 +281.1442,-30.7253,-0.221,0.202,-2.384,0.309,-1.088,0.267,17.98,1.28,16.59,15.97,15.85,0.13,27.42,2.14,nan,nan,nan,nan,0,2.155,2.15 +281.5457,-30.0642,-0.159,0.224,-2.367,0.322,-1.373,0.289,18.13,1.37,16.37,15.8,15.73,0.15,26.54,2.51,nan,nan,nan,nan,0,1.942,1.434 +280.7155,-29.3242,-0.004,0.034,-2.662,0.058,-1.317,0.049,14.45,1.96,12.04,11.22,11.03,0.15,26.83,1.18,nan,nan,nan,nan,0,2.794,0.849 +231.6477,-8.6326,-0.184,0.108,-0.864,0.167,-0.647,0.134,16.92,1.63,14.83,14.12,13.9,0.1,48.66,6.92,nan,nan,nan,nan,0,53.083,-1.467 +284.9468,-29.3833,0.085,0.043,-2.536,0.083,-1.171,0.069,15.5,1.64,13.47,12.71,12.56,0.15,25.67,0.93,nan,nan,nan,nan,0,-0.845,0.242 +287.0711,-31.6227,0.062,0.044,-2.999,0.071,-1.556,0.07,15.15,1.97,12.79,11.9,11.72,0.09,26.92,1.09,152.05,1.02,nan,nan,6,-2.998,2.173 +283.355,-30.5678,0.012,0.056,-2.966,0.083,-1.265,0.071,15.3,1.59,13.33,12.58,12.45,0.13,25.44,0.41,nan,nan,nan,nan,0,0.314,1.643 +282.0465,-28.9143,0.077,0.205,-2.483,0.371,-0.927,0.311,18.17,1.35,16.39,15.61,15.27,0.18,26.37,1.0,nan,nan,nan,nan,0,1.731,0.223 +283.4768,-31.1959,0.099,0.075,-2.693,0.133,-1.4,0.106,16.17,1.75,13.96,13.17,13.05,0.15,27.5,1.6,nan,nan,nan,nan,0,0.101,2.243 +298.3499,-34.0945,-0.13,0.096,-2.825,0.124,-1.742,0.091,16.57,1.62,14.52,13.8,13.71,0.16,27.28,2.79,nan,nan,nan,nan,0,-12.674,3.719 +311.6874,-31.3743,-0.347,0.102,-1.99,0.143,-1.775,0.113,16.67,1.36,15.0,14.35,14.2,0.07,23.38,1.19,nan,nan,nan,nan,0,-23.927,1.14 +279.078,-28.378,0.092,0.09,-2.716,0.194,-1.315,0.178,17.02,1.7,14.91,14.13,13.81,0.24,27.27,1.88,nan,nan,nan,nan,0,4.391,0.215 +347.552,-15.9067,0.057,0.055,-2.359,0.102,-3.8,0.082,15.59,1.28,13.97,13.38,13.27,0.03,20.83,2.81,nan,nan,nan,nan,0,-59.241,-6.618 +39.0321,0.6896,-0.054,0.043,-0.218,0.076,-2.191,0.074,15.32,1.48,13.44,12.77,12.62,0.03,31.15,2.81,-144.39,0.67,-1.14,0.09,5,-112.061,2.133 +36.6714,-7.0017,-0.085,0.117,-0.335,0.14,-2.402,0.139,16.63,1.31,14.99,14.31,14.22,0.03,26.64,3.89,nan,nan,nan,nan,0,-106.102,7.563 +283.7606,-29.7597,-0.032,0.06,-2.513,0.117,-1.212,0.104,16.16,1.72,13.98,13.22,13.12,0.17,27.76,0.13,nan,nan,nan,nan,0,0.109,0.786 +285.5876,-30.2144,0.024,0.059,-2.814,0.109,-1.354,0.093,16.05,1.51,14.14,13.44,13.27,0.13,27.17,3.09,nan,nan,nan,nan,0,-1.526,0.974 +283.108,-28.7278,-0.232,0.128,-2.444,0.169,-1.399,0.146,17.2,1.49,15.36,14.64,14.53,0.18,26.15,0.95,nan,nan,nan,nan,0,0.851,-0.13 +282.9849,-33.9126,0.031,0.063,-2.974,0.124,-0.929,0.106,16.2,1.32,14.57,13.91,13.84,0.08,26.93,1.23,nan,nan,nan,nan,0,0.027,4.99 +145.9562,21.9052,-0.048,0.053,-2.123,0.076,-2.12,0.064,14.92,1.55,13.0,12.29,12.14,0.03,21.44,0.66,-23.69,5.28,-0.67,0.07,2,142.17,6.537 +285.863,-29.9851,0.019,0.045,-2.563,0.087,-1.45,0.079,15.25,2.11,12.7,11.8,11.54,0.13,26.63,0.45,nan,nan,nan,nan,0,-1.726,0.71 +282.8242,-30.8136,-0.01,0.06,-2.725,0.13,-1.247,0.111,15.78,1.68,13.67,12.92,12.74,0.15,26.96,0.45,nan,nan,nan,nan,0,0.72,1.966 +287.4835,-29.4801,-0.207,0.224,-2.327,0.339,-1.15,0.309,18.03,1.34,16.42,15.5,15.17,0.14,25.94,0.88,nan,nan,nan,nan,0,-3.043,0.002 +174.0333,30.764,-0.063,0.049,-1.951,0.062,-1.296,0.058,14.45,1.62,12.44,11.76,11.63,0.02,28.69,1.68,nan,nan,nan,nan,0,120.794,-9.377 +290.6217,-34.2093,-0.068,0.052,-2.483,0.081,-1.315,0.071,15.67,1.62,13.6,12.91,12.75,0.11,27.34,3.06,nan,nan,nan,nan,0,-6.294,4.351 +252.3941,-27.5354,-0.008,0.055,-1.786,0.118,-0.638,0.054,15.67,2.07,13.13,12.23,12.02,0.26,39.0,4.48,nan,nan,nan,nan,0,27.067,6.715 +8.6662,-13.1557,0.058,0.041,-1.278,0.086,-3.115,0.054,15.1,1.34,13.43,12.78,12.65,0.02,23.0,2.1,nan,nan,nan,nan,0,-79.041,-0.712 +14.7146,-6.2279,-0.075,0.029,-1.252,0.06,-3.501,0.038,14.61,1.53,12.73,11.98,11.88,0.07,25.09,0.74,nan,nan,nan,nan,0,-87.567,-4.02 +283.4244,-31.7979,-0.065,0.113,-2.864,0.198,-1.528,0.177,17.11,1.48,15.23,14.6,14.44,0.16,26.99,2.18,nan,nan,nan,nan,0,0.039,2.843 +287.4813,-29.8465,0.07,0.08,-2.63,0.147,-1.321,0.142,16.77,1.38,14.96,14.34,14.19,0.12,27.17,1.31,nan,nan,nan,nan,0,-3.093,0.365 +278.962,-29.8909,0.047,0.076,-2.744,0.136,-1.493,0.115,16.46,1.71,14.27,13.5,13.39,0.18,28.42,1.34,nan,nan,nan,nan,0,4.17,1.716 +286.5996,-32.0587,-0.005,0.079,-2.615,0.151,-1.549,0.138,16.19,1.46,14.32,13.64,13.53,0.1,26.83,1.19,nan,nan,nan,nan,0,-2.665,2.664 +288.8271,-31.8909,-0.067,0.211,-2.298,0.254,-1.457,0.217,17.78,1.37,16.14,15.31,14.9,0.11,28.74,0.41,nan,nan,nan,nan,0,-4.515,2.234 +285.6773,-30.4309,0.041,0.053,-2.557,0.085,-1.618,0.076,15.76,1.69,13.53,12.79,12.63,0.14,27.93,1.38,135.25,1.92,nan,nan,6,-1.637,1.175 +289.3878,-33.8493,-0.054,0.07,-2.853,0.121,-1.323,0.115,14.76,2.61,11.83,10.88,10.63,0.09,26.41,0.74,nan,nan,nan,nan,0,-5.233,4.116 +163.326,16.5415,-0.101,0.228,-1.931,0.332,-1.287,0.288,18.06,1.11,16.79,15.96,15.8,0.03,25.32,0.94,nan,nan,nan,nan,0,124.82,7.402 +353.3315,-16.5976,-0.072,0.174,-1.821,0.263,-3.678,0.293,17.48,1.12,16.13,15.46,15.38,0.03,19.14,2.15,nan,nan,nan,nan,0,-64.158,-3.917 +290.963,-31.349,0.027,0.047,-2.412,0.09,-1.262,0.089,15.67,1.63,13.63,12.88,12.77,0.08,27.44,1.0,nan,nan,nan,nan,0,-6.256,1.477 +250.4379,-15.5964,0.088,0.164,-1.594,0.363,-0.538,0.145,18.0,1.86,15.57,14.8,14.64,0.47,31.95,2.78,nan,nan,nan,nan,0,33.536,-3.49 +287.6938,-32.9438,-0.177,0.103,-2.715,0.213,-1.317,0.181,16.97,1.33,15.24,14.62,14.57,0.09,26.37,0.39,nan,nan,nan,nan,0,-3.708,3.406 +302.9136,-33.1516,-0.125,0.138,-2.061,0.203,-1.864,0.116,17.16,1.62,15.07,14.37,14.21,0.24,26.85,5.06,nan,nan,nan,nan,0,-16.453,2.676 +283.7538,-30.469,0.017,0.036,-2.396,0.065,-1.319,0.054,14.61,1.75,12.37,11.59,11.46,0.15,27.11,1.54,nan,nan,nan,nan,0,-0.007,1.486 +161.8978,29.6386,-0.006,0.049,-2.172,0.114,-1.715,0.07,15.47,1.28,13.84,13.23,13.15,0.03,23.82,1.93,-73.32,7.78,-1.64,0.06,2,130.26,-4.588 +55.2141,5.5078,-0.036,0.062,0.204,0.125,-1.848,0.101,16.35,1.77,14.14,13.31,13.12,0.28,31.44,5.16,nan,nan,nan,nan,0,-128.534,5.904 +292.5266,-34.0479,-0.008,0.041,-2.784,0.065,-1.503,0.062,15.03,1.75,12.88,12.06,11.91,0.13,27.04,2.91,nan,nan,nan,nan,0,-7.849,4.023 +281.5796,-30.7054,-0.021,0.083,-2.645,0.126,-1.086,0.108,16.22,1.7,14.06,13.31,13.12,0.16,27.19,0.39,nan,nan,nan,nan,0,1.791,2.058 +283.2181,-30.711,-0.06,0.083,-2.531,0.14,-1.178,0.127,16.73,1.54,14.8,14.09,13.98,0.14,25.44,0.64,nan,nan,nan,nan,0,0.405,1.805 +278.3687,-29.0347,-0.052,0.104,-2.458,0.218,-1.266,0.212,17.32,1.68,15.21,14.28,14.09,0.29,27.45,0.72,nan,nan,nan,nan,0,4.858,0.99 +292.3674,-33.3837,0.026,0.067,-2.552,0.103,-1.461,0.084,14.87,2.28,12.21,11.29,11.06,0.12,25.83,0.57,nan,nan,nan,nan,0,-7.651,3.375 +289.7065,-29.4842,-0.08,0.043,-2.609,0.09,-1.938,0.087,15.67,1.61,13.61,12.85,12.72,0.14,26.2,1.55,nan,nan,nan,nan,0,-4.962,-0.249 +21.4454,-10.3785,0.054,0.048,-1.121,0.093,-3.827,0.049,15.07,1.35,13.34,12.76,12.66,0.03,25.57,4.58,nan,nan,nan,nan,0,-91.351,2.855 +278.6609,-28.8679,0.051,0.046,-2.979,0.082,-1.22,0.068,15.25,2.15,12.65,11.77,11.52,0.27,27.45,0.51,nan,nan,nan,nan,0,4.645,0.772 +297.6547,-31.1596,-0.013,0.057,-2.403,0.096,-1.781,0.058,16.02,1.46,14.24,13.6,13.48,0.14,29.17,2.19,nan,nan,nan,nan,0,-11.942,0.817 +230.9658,-12.1478,-0.015,0.112,-0.809,0.214,-0.626,0.152,17.07,1.59,15.09,14.36,14.28,0.16,48.53,4.5,nan,nan,nan,nan,0,51.971,1.934 +293.0789,-33.1871,-0.023,0.036,-2.821,0.062,-2.088,0.058,14.61,1.94,12.29,11.41,11.22,0.09,26.71,0.55,nan,nan,nan,nan,0,-8.225,3.122 +164.1773,13.0404,-0.111,0.148,-1.449,0.236,-0.964,0.184,17.58,1.11,16.15,15.54,15.48,0.02,25.27,1.27,nan,nan,nan,nan,0,122.862,10.434 +287.9858,-31.7003,-0.059,0.056,-2.631,0.119,-1.558,0.093,15.94,1.44,14.09,13.41,13.34,0.08,28.68,1.56,nan,nan,nan,nan,0,-3.78,2.141 +291.6896,-30.654,-0.011,0.08,-2.819,0.128,-1.437,0.118,16.73,1.34,15.1,14.43,14.31,0.09,27.16,1.76,nan,nan,nan,nan,0,-6.8,0.718 +280.7647,-29.2827,-0.017,0.083,-2.763,0.149,-1.532,0.131,16.21,1.73,13.94,13.2,12.94,0.15,26.83,1.18,nan,nan,nan,nan,0,2.76,0.799 +293.9462,-32.601,-0.012,0.157,-2.009,0.262,-1.584,0.248,17.95,1.26,16.53,15.79,15.5,0.11,26.77,2.24,nan,nan,nan,nan,0,-8.899,2.473 +291.2716,-30.8882,0.071,0.166,-2.656,0.24,-1.699,0.264,17.46,1.33,15.86,15.14,15.08,0.08,27.53,0.73,nan,nan,nan,nan,0,-6.468,0.99 +286.4393,-30.9966,-0.265,0.148,-2.377,0.27,-1.581,0.251,17.96,1.34,16.47,15.74,15.67,0.1,28.99,1.45,nan,nan,nan,nan,0,-2.371,1.634 +289.6243,-32.9151,-0.119,0.227,-2.422,0.419,-1.909,0.325,17.84,1.32,16.24,15.49,15.44,0.1,27.02,1.3,nan,nan,nan,nan,0,-5.313,3.165 +46.5619,10.4873,-0.08,0.044,0.14,0.085,-1.564,0.072,15.25,1.91,12.96,12.06,11.86,0.23,30.36,0.78,nan,nan,nan,nan,0,-123.448,-2.602 +289.4315,-35.2056,0.056,0.064,-2.666,0.112,-1.36,0.111,14.77,2.46,11.97,11.07,10.81,0.1,27.36,1.77,nan,nan,nan,nan,0,-5.439,5.458 +13.1651,-13.4244,0.009,0.065,-0.733,0.131,-2.411,0.097,16.6,1.31,14.99,14.32,14.23,0.02,22.42,0.51,nan,nan,nan,nan,0,-82.785,1.573 +357.2289,-27.5487,0.038,0.032,-1.251,0.054,-2.829,0.046,14.36,1.7,12.31,11.47,11.38,0.02,24.19,1.26,nan,nan,nan,nan,0,-63.099,7.563 +288.169,-31.8852,-0.135,0.19,-2.967,0.394,-1.181,0.341,17.83,1.25,16.23,15.5,15.39,0.11,26.03,0.58,nan,nan,nan,nan,0,-3.96,2.303 +285.8691,-29.1833,-0.125,0.069,-2.507,0.118,-1.526,0.109,16.11,1.71,13.91,13.16,13.06,0.16,26.36,1.55,nan,nan,nan,nan,0,-1.607,-0.083 +283.8347,-30.9873,-0.016,0.06,-2.734,0.093,-1.293,0.083,16.0,1.74,13.83,12.99,12.91,0.15,27.63,2.35,nan,nan,nan,nan,0,-0.165,1.984 +277.795,-29.9867,-0.053,0.095,-2.702,0.145,-1.197,0.125,16.74,1.5,14.81,14.16,14.02,0.21,26.68,0.97,nan,nan,nan,nan,0,5.137,2.028 +296.9056,-33.2322,0.085,0.227,-2.046,0.306,-1.41,0.235,18.27,1.35,16.78,15.89,15.6,0.19,26.84,0.98,nan,nan,nan,nan,0,-11.425,2.922 +149.8325,18.0136,0.048,0.047,-1.904,0.073,-1.848,0.076,15.15,1.39,13.41,12.78,12.62,0.03,21.21,1.23,nan,nan,nan,nan,0,137.745,9.574 +283.4179,-30.2877,0.027,0.056,-2.705,0.084,-1.561,0.079,15.36,2.04,12.87,11.99,11.77,0.16,26.88,0.91,146.44,nan,nan,nan,4,0.31,1.358 +306.553,-33.015,0.066,0.063,-3.182,0.092,-2.445,0.072,16.15,1.29,14.51,13.91,13.84,0.07,24.02,0.5,nan,nan,nan,nan,0,-19.506,2.568 +284.539,-29.6292,0.062,0.055,-2.719,0.103,-1.247,0.106,15.51,1.84,13.27,12.42,12.27,0.19,27.88,0.79,nan,nan,nan,nan,0,-0.535,0.543 +283.2506,-32.3696,-0.12,0.072,-2.914,0.114,-1.337,0.092,15.78,1.64,13.68,12.98,12.77,0.13,26.35,0.98,146.1,nan,nan,nan,4,0.083,3.432 +287.4445,-32.3422,-0.028,0.034,-2.75,0.063,-1.419,0.066,14.83,1.78,12.55,11.8,11.61,0.1,27.0,1.54,nan,nan,nan,nan,0,-3.415,2.84 +261.642,-21.823,0.084,0.168,-2.431,0.326,-1.057,0.242,17.65,2.63,14.43,13.5,13.19,1.17,32.66,2.66,nan,nan,nan,nan,0,21.359,-1.691 +290.0495,-30.0161,0.067,0.063,-2.629,0.092,-1.543,0.079,15.83,1.81,13.59,12.79,12.63,0.14,27.67,0.09,nan,nan,nan,nan,0,-5.322,0.243 +196.9551,4.2819,-0.048,0.047,-1.223,0.103,-0.624,0.067,15.51,2.09,13.1,12.19,11.96,0.03,42.36,5.06,nan,nan,nan,nan,0,89.53,4.642 +79.2782,18.3587,-0.029,0.204,-0.058,0.35,-0.797,0.311,18.16,1.87,15.82,15.09,14.98,0.47,47.49,1.57,nan,nan,nan,nan,0,-155.351,4.168 +285.5904,-28.8784,-0.022,0.071,-2.876,0.136,-1.0,0.105,15.91,1.83,13.63,12.82,12.61,0.16,28.53,1.01,nan,nan,nan,nan,0,-1.319,-0.346 +278.4861,-30.1671,-0.07,0.227,-2.092,0.321,-0.932,0.268,18.03,1.52,16.29,15.56,15.33,0.21,27.02,1.17,nan,nan,nan,nan,0,4.514,2.073 +138.0136,15.6089,0.065,0.029,-2.301,0.047,-2.858,0.037,13.79,1.51,11.89,11.18,11.03,0.04,17.77,3.63,-9.57,0.02,-0.51,0.01,1,148.677,13.965 +238.1444,-15.4848,-0.113,0.085,-1.642,0.17,-0.438,0.105,15.48,2.61,12.56,11.62,11.3,0.18,41.39,4.99,nan,nan,nan,nan,0,44.248,1.571 +288.8523,-34.0233,0.052,0.17,-2.781,0.317,-1.366,0.303,18.03,1.3,16.45,15.67,15.57,0.08,26.0,1.0,nan,nan,nan,nan,0,-4.813,4.346 +290.9267,-29.6536,-0.004,0.061,-2.814,0.121,-1.625,0.115,15.78,1.74,13.67,12.79,12.69,0.11,26.91,1.13,nan,nan,nan,nan,0,-6.036,-0.205 +319.8927,-30.3133,0.029,0.074,-2.259,0.097,-2.167,0.085,15.63,1.71,13.48,12.74,12.52,0.1,26.46,4.45,nan,nan,nan,nan,0,-31.045,0.837 +283.0252,-30.7985,-0.11,0.104,-2.367,0.204,-0.842,0.186,17.28,1.34,15.57,14.92,14.8,0.15,26.01,2.28,nan,nan,nan,nan,0,0.552,1.92 +282.6851,-27.5192,0.072,0.137,-2.818,0.198,-1.513,0.177,17.42,1.53,15.48,14.75,14.65,0.24,28.41,1.46,nan,nan,nan,nan,0,1.435,-1.252 +226.7086,-4.6119,-0.135,0.121,-1.098,0.154,-0.309,0.151,16.34,1.95,13.97,13.13,12.94,0.1,52.88,1.31,nan,nan,nan,nan,0,59.332,-2.577 +299.5549,-30.0313,-0.142,0.199,-2.154,0.268,-1.793,0.179,17.89,1.23,16.31,15.7,15.59,0.12,25.45,1.29,nan,nan,nan,nan,0,-13.525,-0.383 +279.3059,-32.5073,-0.064,0.06,-2.778,0.106,-1.187,0.09,14.8,2.63,11.89,10.96,10.67,0.14,25.85,0.2,nan,nan,nan,nan,0,3.33,4.211 +285.2628,-30.0769,-0.023,0.1,-2.745,0.173,-1.275,0.157,17.13,1.47,15.31,14.61,14.41,0.13,27.33,0.23,nan,nan,nan,nan,0,-1.227,0.882 +243.4261,-14.3692,0.089,0.1,-0.992,0.19,-0.402,0.145,16.92,1.94,14.54,13.63,13.51,0.3,42.15,5.03,nan,nan,nan,nan,0,40.197,-1.728 +280.4061,-29.3096,0.008,0.098,-2.841,0.173,-1.389,0.159,17.3,1.33,15.44,14.77,14.57,0.17,26.73,0.16,nan,nan,nan,nan,0,3.061,0.888 +10.8307,-21.7519,-0.14,0.063,-0.946,0.086,-2.53,0.057,15.97,1.45,14.17,13.54,13.41,0.02,20.04,1.18,nan,nan,nan,nan,0,-76.863,7.857 +293.3103,-30.9877,0.047,0.065,-2.557,0.094,-1.506,0.082,15.8,1.51,13.92,13.2,13.03,0.12,26.4,1.49,nan,nan,nan,nan,0,-8.218,0.914 +290.6819,-30.7571,0.013,0.053,-2.498,0.1,-1.498,0.098,15.79,1.57,13.81,13.16,12.97,0.08,27.72,0.32,nan,nan,nan,nan,0,-5.95,0.916 +289.806,-34.8835,-0.045,0.046,-2.557,0.076,-1.442,0.069,15.46,1.54,13.46,12.78,12.64,0.11,26.61,2.79,nan,nan,nan,nan,0,-5.704,5.1 +283.3264,-29.8746,-0.096,0.06,-2.807,0.112,-1.399,0.091,15.64,1.95,13.23,12.39,12.19,0.16,25.93,0.67,nan,nan,nan,nan,0,0.46,0.965 +40.0601,-0.5582,-0.031,0.055,0.293,0.081,-2.138,0.064,14.51,1.74,12.36,11.57,11.39,0.03,28.08,4.56,nan,nan,nan,nan,0,-112.318,3.73 +289.0123,-31.9714,0.007,0.057,-2.566,0.079,-1.563,0.066,15.43,1.88,13.13,12.31,12.12,0.12,28.28,0.81,nan,nan,nan,nan,0,-4.681,2.294 +284.7059,-31.7521,-0.007,0.043,-2.66,0.066,-1.306,0.057,15.1,1.9,12.82,11.94,11.72,0.13,25.98,1.45,nan,nan,nan,nan,0,-1.028,2.613 +280.8902,-28.5942,0.014,0.064,-2.921,0.116,-1.435,0.107,16.27,1.71,14.11,13.33,13.26,0.15,26.69,0.09,nan,nan,nan,nan,0,2.788,0.103 +284.4339,-29.9824,-0.053,0.058,-2.954,0.094,-1.431,0.077,15.54,1.67,13.43,12.68,12.5,0.14,28.48,2.05,nan,nan,nan,nan,0,-0.504,0.907 +279.6263,-28.2765,0.018,0.072,-3.02,0.15,-1.201,0.126,16.55,1.68,14.39,13.64,13.53,0.23,28.14,4.3,nan,nan,nan,nan,0,3.94,0.015 +292.7514,-30.9373,-0.043,0.193,-2.761,0.356,-1.574,0.292,17.9,1.37,16.25,15.57,15.42,0.13,27.4,1.51,nan,nan,nan,nan,0,-7.736,0.909 +279.4596,-30.1913,0.035,0.078,-3.001,0.139,-1.251,0.124,16.64,1.55,14.75,14.05,13.82,0.16,28.27,1.31,nan,nan,nan,nan,0,3.686,1.919 +221.6236,-3.6411,-0.21,0.132,-0.858,0.229,-0.371,0.188,17.29,1.42,15.7,14.88,14.66,0.1,49.9,3.44,nan,nan,nan,nan,0,64.214,-0.889 +289.7363,-30.1067,0.014,0.12,-2.572,0.173,-1.514,0.159,16.88,1.54,14.95,14.25,14.13,0.14,26.92,1.77,nan,nan,nan,nan,0,-5.063,0.366 +281.6677,-32.1797,-0.038,0.051,-2.871,0.081,-1.433,0.075,15.6,1.82,13.35,12.55,12.36,0.14,25.85,1.8,nan,nan,nan,nan,0,1.436,3.491 +294.0612,-34.637,-0.029,0.117,-2.311,0.17,-1.503,0.149,16.95,1.4,15.14,14.49,14.34,0.13,27.6,2.47,nan,nan,nan,nan,0,-9.168,4.494 +49.4295,8.9757,-0.087,0.082,0.138,0.147,-1.611,0.127,16.66,1.91,14.31,13.49,13.27,0.32,32.57,2.9,-158.14,4.66,-0.5,0.15,2,-125.167,0.103 +218.1507,1.5005,0.038,0.106,-1.017,0.197,-0.477,0.151,17.16,1.54,15.26,14.43,14.35,0.04,48.75,1.94,nan,nan,nan,nan,0,69.81,-3.577 +283.3909,-31.6551,-0.214,0.281,-2.71,0.374,-1.306,0.321,17.92,1.43,16.28,15.59,15.36,0.16,28.46,0.37,nan,nan,nan,nan,0,0.092,2.708 +222.8767,-0.5964,0.093,0.091,-1.096,0.134,-0.306,0.124,16.85,1.65,14.81,14.02,13.88,0.05,50.94,2.94,nan,nan,nan,nan,0,64.66,-4.151 +289.6003,-30.1039,-0.055,0.116,-2.725,0.198,-1.603,0.188,17.22,1.39,15.52,14.89,14.82,0.13,28.3,2.04,nan,nan,nan,nan,0,-4.946,0.378 +287.0904,-29.3905,-0.048,0.065,-2.504,0.106,-1.329,0.102,16.26,1.71,14.15,13.39,13.33,0.12,26.81,0.26,nan,nan,nan,nan,0,-2.691,-0.038 +291.0486,-28.1807,-0.023,0.044,-2.709,0.068,-1.747,0.066,15.04,1.88,12.75,11.92,11.74,0.13,26.37,3.98,nan,nan,nan,nan,0,-5.978,-1.68 +286.4309,-30.6912,0.087,0.132,-1.908,0.331,-1.705,0.226,16.55,1.57,14.57,13.88,13.73,0.12,29.36,1.02,nan,nan,nan,nan,0,-2.318,1.333 +342.3429,-31.2484,-0.041,0.031,-1.901,0.049,-3.046,0.044,14.04,1.57,12.15,11.45,11.29,0.02,23.62,2.48,nan,nan,nan,nan,0,-49.593,6.237 +282.7582,-32.3957,-0.13,0.103,-2.825,0.17,-1.128,0.149,16.89,1.45,15.09,14.35,14.26,0.13,25.49,0.53,nan,nan,nan,nan,0,0.489,3.532 +288.1051,-30.8653,-0.039,0.085,-2.869,0.149,-1.394,0.143,16.93,1.45,15.17,14.51,14.27,0.11,27.23,1.56,nan,nan,nan,nan,0,-3.767,1.299 +283.924,-28.4256,-0.126,0.199,-2.708,0.302,-1.408,0.28,18.3,1.44,16.66,15.78,15.55,0.2,27.88,1.02,nan,nan,nan,nan,0,0.198,-0.553 +70.2686,5.4896,-0.073,0.066,0.206,0.123,-1.525,0.08,16.49,1.52,14.53,13.78,13.68,0.15,39.82,2.39,nan,nan,nan,nan,0,-142.109,12.634 +291.9645,-32.1903,-0.13,0.081,-2.246,0.13,-1.395,0.119,16.55,1.49,14.67,13.93,13.83,0.07,27.9,0.24,nan,nan,nan,nan,0,-7.193,2.222 +255.4555,-29.4246,-0.167,0.15,-1.889,0.228,-0.241,0.147,17.59,1.52,15.59,15.01,14.92,0.27,29.21,3.28,nan,nan,nan,nan,0,23.828,7.405 +294.159,-32.4993,0.098,0.139,-2.151,0.217,-1.214,0.215,17.37,1.41,15.57,14.99,14.89,0.12,26.14,0.94,nan,nan,nan,nan,0,-9.069,2.357 +262.5152,-24.1721,0.051,0.161,-1.295,0.195,-0.562,0.152,16.84,3.04,13.01,11.89,11.45,1.47,32.66,2.66,nan,nan,nan,nan,0,19.811,0.25 +19.8247,-9.9743,0.045,0.043,-0.958,0.11,-2.851,0.047,15.2,1.37,13.48,12.86,12.77,0.04,20.52,5.54,-104.82,1.32,-1.6,0.09,5,-90.163,1.715 +284.0173,-29.8634,0.028,0.133,-2.951,0.231,-1.592,0.199,17.5,1.37,15.99,15.3,15.17,0.15,29.06,2.35,nan,nan,nan,nan,0,-0.128,0.85 +284.6518,-29.7171,-0.019,0.088,-2.334,0.141,-1.176,0.131,16.77,1.51,14.91,14.21,14.1,0.19,28.41,1.49,nan,nan,nan,nan,0,-0.646,0.613 +299.4709,-29.5826,-0.089,0.041,-2.809,0.059,-2.177,0.036,14.89,1.97,12.53,11.65,11.45,0.1,25.71,0.75,nan,nan,nan,nan,0,-13.436,-0.828 +280.5058,-26.4424,0.038,0.105,-2.233,0.183,-0.896,0.172,17.01,1.93,14.59,13.72,13.51,0.43,26.79,1.67,nan,nan,nan,nan,0,3.548,-1.939 +286.3275,-28.9154,-0.008,0.057,-2.799,0.087,-1.504,0.084,15.43,2.13,12.85,12.0,11.75,0.18,27.5,0.37,nan,nan,nan,nan,0,-1.962,-0.409 +293.8132,-31.2896,-0.256,0.134,-2.377,0.172,-2.051,0.155,17.1,1.44,15.36,14.67,14.51,0.12,27.1,0.51,nan,nan,nan,nan,0,-8.673,1.177 +293.0096,-28.7068,0.014,0.11,-2.449,0.233,-1.828,0.157,16.72,1.32,15.13,14.53,14.44,0.1,28.14,2.59,nan,nan,nan,nan,0,-7.747,-1.333 +311.7116,-34.6284,-0.037,0.11,-2.585,0.147,-2.07,0.099,16.79,1.31,15.13,14.58,14.51,0.06,24.07,2.83,nan,nan,nan,nan,0,-23.716,4.388 +240.6878,-20.5006,-0.033,0.063,-1.973,0.142,-0.574,0.065,15.3,2.54,12.49,11.62,11.36,0.28,40.34,1.21,nan,nan,nan,nan,0,39.796,4.924 +282.5284,-28.4604,0.017,0.059,-2.641,0.09,-1.366,0.083,15.8,1.58,13.79,13.12,12.93,0.16,28.32,0.71,151.4,nan,nan,nan,4,1.4,-0.301 +210.9137,-1.0472,0.068,0.098,-0.955,0.143,-0.394,0.123,16.13,1.84,13.89,13.04,12.86,0.05,47.12,3.98,nan,nan,nan,nan,0,74.758,2.289 +280.4897,-30.461,-0.133,0.058,-2.762,0.11,-1.424,0.098,15.93,1.78,13.75,12.92,12.79,0.13,28.5,2.58,nan,nan,nan,nan,0,2.76,2.002 +289.1536,-31.2808,0.08,0.072,-3.001,0.158,-1.382,0.12,16.3,1.57,14.33,13.65,13.49,0.1,27.33,2.45,nan,nan,nan,nan,0,-4.712,1.593 +283.4288,-33.0154,-0.015,0.076,-2.382,0.115,-1.3,0.107,15.99,1.52,13.97,13.28,13.08,0.1,25.4,2.73,nan,nan,nan,nan,0,-0.179,4.041 +282.6585,-27.2772,-0.049,0.052,-2.505,0.08,-1.367,0.072,15.43,1.71,13.3,12.56,12.42,0.25,28.41,2.2,nan,nan,nan,nan,0,1.502,-1.486 +280.1592,-30.5828,0.018,0.045,-2.504,0.072,-1.216,0.062,15.07,1.95,12.7,11.84,11.61,0.14,27.54,0.61,nan,nan,nan,nan,0,3.014,2.178 +288.8949,-32.0044,-0.127,0.168,-2.508,0.235,-1.978,0.221,17.82,1.26,16.36,15.78,15.67,0.12,28.47,0.55,nan,nan,nan,nan,0,-4.586,2.339 +282.2554,-27.765,-0.074,0.11,-2.717,0.177,-1.293,0.155,17.2,1.55,15.29,14.52,14.39,0.23,27.62,0.16,nan,nan,nan,nan,0,1.764,-0.941 +287.7923,-31.2486,-0.038,0.045,-2.555,0.069,-1.465,0.063,15.14,1.98,12.69,11.86,11.64,0.1,27.32,1.78,145.93,1.0,nan,nan,6,-3.554,1.716 +279.2952,-30.925,-0.043,0.095,-2.925,0.166,-1.295,0.154,16.95,1.5,15.07,14.38,14.2,0.17,25.09,1.73,nan,nan,nan,nan,0,3.671,2.666 +284.1741,-30.0811,0.014,0.044,-2.822,0.08,-1.329,0.073,15.42,1.81,13.15,12.31,12.14,0.14,26.46,2.06,nan,nan,nan,nan,0,-0.299,1.042 +285.3456,-31.3785,0.006,0.09,-3.144,0.183,-1.388,0.161,16.52,1.58,14.6,13.79,13.71,0.12,27.68,0.65,nan,nan,nan,nan,0,-1.505,2.156 +171.839,7.3944,-0.078,0.283,-1.588,0.543,-0.945,0.61,17.78,1.2,16.21,15.6,15.48,0.09,34.83,3.11,nan,nan,nan,nan,0,113.598,12.967 +291.0582,-30.7961,0.021,0.042,-2.78,0.059,-1.654,0.055,15.0,1.94,12.63,11.82,11.63,0.08,27.66,1.09,nan,nan,nan,nan,0,-6.275,0.918 +277.3269,-30.2361,-0.271,0.109,-2.649,0.182,-1.48,0.155,16.4,1.78,14.1,13.36,13.08,0.26,26.68,0.65,nan,nan,nan,nan,0,5.477,2.362 +285.7311,-26.6197,-0.049,0.074,-2.566,0.13,-1.361,0.122,16.33,1.82,14.05,13.24,13.04,0.24,27.85,2.68,nan,nan,nan,nan,0,-1.088,-2.596 +286.1541,-30.4801,-0.022,0.1,-2.706,0.193,-1.316,0.152,16.48,1.64,14.4,13.65,13.46,0.13,26.73,0.36,nan,nan,nan,nan,0,-2.051,1.16 +292.4071,-36.3668,-0.034,0.143,-2.825,0.196,-1.815,0.195,17.4,1.44,15.62,14.94,14.85,0.15,30.55,2.57,nan,nan,nan,nan,0,-7.981,6.34 +30.1493,9.9665,0.032,0.074,-0.178,0.095,-2.272,0.087,15.81,1.38,13.77,13.18,13.06,0.08,23.06,2.29,nan,nan,nan,nan,0,-109.164,-10.348 +283.3688,-30.9366,0.06,0.042,-2.611,0.084,-1.264,0.078,15.32,1.99,12.95,12.07,11.88,0.14,26.64,1.05,nan,nan,nan,nan,0,0.237,2.004 +284.8564,-30.6515,-0.033,0.102,-2.849,0.22,-1.052,0.162,16.58,1.47,14.68,13.99,13.92,0.14,29.93,1.74,nan,nan,nan,nan,0,-0.974,1.506 +286.0676,-31.4926,-0.068,0.057,-3.097,0.108,-1.542,0.094,16.22,1.58,14.22,13.49,13.4,0.11,26.46,2.87,nan,nan,nan,nan,0,-2.132,2.172 +286.252,-29.683,0.082,0.088,-2.492,0.132,-1.223,0.124,16.61,1.52,14.69,13.99,13.76,0.14,27.05,1.46,nan,nan,nan,nan,0,-2.013,0.36 +289.2115,-33.5741,0.072,0.031,-2.732,0.057,-1.372,0.056,14.61,2.02,12.16,11.29,11.09,0.09,25.71,0.9,nan,nan,nan,nan,0,-5.052,3.862 +279.0932,-28.619,-0.094,0.113,-2.946,0.159,-1.294,0.13,16.66,1.79,14.43,13.68,13.52,0.24,28.53,2.14,nan,nan,nan,nan,0,4.327,0.448 +336.1109,-31.5332,0.035,0.072,-2.061,0.09,-2.75,0.1,16.16,1.36,14.46,13.84,13.66,0.01,23.35,5.38,nan,nan,nan,nan,0,-44.403,4.932 +299.6151,-34.4277,-0.042,0.175,-3.012,0.241,-1.924,0.152,17.7,1.26,16.24,15.4,15.29,0.11,26.96,3.1,nan,nan,nan,nan,0,-13.734,4.009 +277.0026,-26.9373,-0.095,0.074,-2.813,0.104,-1.419,0.086,15.91,2.01,13.35,12.52,12.29,0.34,27.0,0.96,nan,nan,nan,nan,0,6.5,-0.787 +258.9008,-24.8048,-0.042,0.216,-2.401,0.327,-0.638,0.217,17.35,2.24,14.18,13.36,13.15,0.75,32.97,1.77,nan,nan,nan,nan,0,22.68,1.98 +280.0478,-29.3713,-0.09,0.1,-2.281,0.157,-1.287,0.142,16.5,1.58,14.49,13.77,13.65,0.18,27.15,0.61,nan,nan,nan,nan,0,3.355,1.011 +277.7568,-31.338,-0.003,0.049,-2.767,0.083,-1.073,0.073,15.66,1.61,13.67,12.9,12.81,0.18,26.33,1.49,nan,nan,nan,nan,0,4.87,3.353 +5.9134,-21.0556,0.015,0.037,-1.242,0.067,-3.379,0.046,15.24,1.42,13.45,12.77,12.64,0.02,22.32,3.64,nan,nan,nan,nan,0,-73.098,5.127 +292.5405,-30.9372,-0.048,0.04,-3.083,0.074,-1.611,0.06,14.97,1.79,12.78,11.95,11.8,0.12,27.19,0.7,nan,nan,nan,nan,0,-7.556,0.926 +284.6079,-30.98,-0.031,0.059,-3.168,0.098,-1.676,0.088,14.78,2.42,11.6,10.82,10.45,0.14,27.97,1.84,128.21,1.77,nan,nan,6,-0.818,1.865 +282.815,-32.2129,0.037,0.088,-2.821,0.151,-0.917,0.131,16.0,1.67,13.93,13.08,12.99,0.13,25.34,0.6,165.9,nan,nan,nan,4,0.474,3.344 +289.7466,-32.7957,0.04,0.09,-2.967,0.145,-1.281,0.124,16.4,1.43,14.65,14.0,13.85,0.1,26.97,1.06,nan,nan,nan,nan,0,-5.4,3.034 +289.3306,-29.2741,0.082,0.148,-2.924,0.255,-1.851,0.236,17.97,1.23,16.32,15.67,15.52,0.12,28.35,3.02,nan,nan,nan,nan,0,-4.611,-0.417 +284.417,-31.6403,0.026,0.042,-2.977,0.077,-1.57,0.068,15.54,1.83,13.29,12.47,12.3,0.15,27.38,1.29,nan,nan,nan,nan,0,-0.767,2.543 +280.5985,-31.2331,-0.02,0.212,-2.477,0.253,-1.362,0.229,17.83,1.39,15.6,14.96,14.82,0.19,25.48,0.98,nan,nan,nan,nan,0,2.514,2.74 +277.8889,-31.0629,-0.144,0.126,-2.49,0.259,-0.976,0.246,17.73,1.36,15.99,15.22,15.12,0.17,26.33,1.3,nan,nan,nan,nan,0,4.82,3.06 +284.0613,-28.6037,0.033,0.055,-2.91,0.096,-1.358,0.084,15.66,2.04,13.23,12.35,12.12,0.18,28.57,2.56,nan,nan,nan,nan,0,0.049,-0.398 +289.8508,-33.1964,-0.036,0.097,-2.689,0.166,-1.386,0.159,16.7,1.43,14.96,14.27,14.16,0.09,26.24,2.82,nan,nan,nan,nan,0,-5.536,3.421 +255.8364,-26.96,-0.379,0.286,-2.378,0.618,-1.052,0.307,18.52,1.39,16.53,15.67,15.29,0.25,29.21,3.28,nan,nan,nan,nan,0,24.456,4.997 +289.8828,-33.3089,0.023,0.056,-2.606,0.097,-1.385,0.091,15.3,1.67,13.24,12.5,12.34,0.09,25.88,1.49,nan,nan,nan,nan,0,-5.576,3.529 +284.1723,-32.015,0.033,0.083,-2.811,0.143,-1.031,0.118,16.32,1.6,14.28,13.56,13.36,0.15,25.48,0.07,nan,nan,nan,nan,0,-0.625,2.948 +18.6548,-10.8896,0.044,0.06,-0.616,0.115,-2.953,0.089,16.12,1.29,14.5,13.86,13.76,0.03,22.81,3.46,-101.38,1.66,-1.25,0.11,5,-88.711,1.947 +280.0436,-28.9118,-0.102,0.144,-2.554,0.261,-1.235,0.273,17.31,1.44,15.43,14.8,14.67,0.19,26.51,1.2,nan,nan,nan,nan,0,3.451,0.562 +280.1588,-33.6958,-0.137,0.062,-2.752,0.086,-1.051,0.073,15.54,1.5,13.63,12.92,12.79,0.11,27.37,2.08,nan,nan,nan,nan,0,2.383,5.227 +297.3609,-27.5845,-0.04,0.167,-2.46,0.262,-1.186,0.146,17.99,1.28,16.4,15.6,15.23,0.12,27.4,1.29,nan,nan,nan,nan,0,-11.492,-2.738 +288.5069,-32.0185,0.061,0.041,-2.616,0.094,-1.432,0.074,15.11,1.61,13.09,12.38,12.26,0.13,26.09,0.89,nan,nan,nan,nan,0,-4.262,2.396 +285.3629,-28.9467,0.06,0.03,-2.656,0.053,-1.398,0.047,14.69,1.86,12.38,11.59,11.41,0.15,28.53,1.3,nan,nan,nan,nan,0,-1.133,-0.247 +206.9373,4.1951,0.024,0.061,-1.191,0.121,-0.319,0.098,16.28,1.45,14.5,13.79,13.72,0.02,42.74,2.25,nan,nan,nan,nan,0,80.837,-0.226 +282.696,-31.1406,-0.069,0.094,-2.67,0.118,-1.329,0.108,15.94,1.73,13.76,13.03,12.85,0.19,25.94,0.66,nan,nan,nan,nan,0,0.769,2.308 +286.9517,-29.8145,0.004,0.094,-2.357,0.173,-1.744,0.133,16.2,1.55,14.24,13.5,13.4,0.12,26.15,1.19,nan,nan,nan,nan,0,-2.633,0.399 +289.1237,-34.7019,-0.052,0.037,-2.427,0.069,-1.497,0.065,15.28,1.77,13.08,12.29,12.05,0.09,27.16,1.87,163.4,nan,nan,nan,4,-5.124,4.99 +282.3817,-29.5348,-0.105,0.148,-2.869,0.191,-1.091,0.177,17.05,1.61,14.94,14.29,14.17,0.16,27.39,0.5,nan,nan,nan,nan,0,1.329,0.778 +282.9045,-31.6959,0.018,0.118,-2.328,0.215,-0.885,0.209,17.36,1.46,15.47,14.74,14.67,0.17,26.92,1.68,nan,nan,nan,nan,0,0.493,2.822 +278.1009,-30.6331,0.068,0.049,-3.03,0.073,-1.348,0.061,14.93,1.92,12.54,11.69,11.53,0.2,25.65,2.05,nan,nan,nan,nan,0,4.737,2.601 +288.387,-32.7125,-0.134,0.112,-2.647,0.146,-1.326,0.128,16.61,1.43,14.76,14.09,13.98,0.11,26.09,1.0,nan,nan,nan,nan,0,-4.254,3.098 +298.0076,-34.0259,0.044,0.062,-2.778,0.083,-1.745,0.056,15.27,2.13,12.69,11.81,11.56,0.17,28.38,1.63,nan,nan,nan,nan,0,-12.386,3.665 +281.585,-31.4219,-0.246,0.116,-2.497,0.198,-1.171,0.175,17.43,1.46,15.61,14.81,14.6,0.16,25.93,0.81,nan,nan,nan,nan,0,1.65,2.761 +291.8334,-33.909,0.05,0.145,-2.366,0.267,-1.381,0.264,17.91,1.29,16.28,15.63,15.49,0.11,26.63,0.45,nan,nan,nan,nan,0,-7.261,3.943 +338.8129,-32.7517,0.087,0.043,-2.989,0.069,-3.851,0.067,14.71,1.32,13.03,12.4,12.3,0.01,20.0,2.7,nan,nan,nan,nan,0,-46.261,6.749 +283.8744,-29.2037,-0.375,0.2,-1.979,0.306,-1.589,0.28,18.08,1.3,16.69,15.76,15.56,0.17,26.66,2.41,nan,nan,nan,nan,0,0.108,0.221 +285.8427,-31.2564,-0.013,0.076,-2.407,0.125,-1.261,0.115,16.41,1.68,14.36,13.58,13.49,0.11,27.26,1.33,nan,nan,nan,nan,0,-1.906,1.969 +333.7786,-22.4763,-0.028,0.081,-2.325,0.12,-3.383,0.135,16.34,1.31,14.74,14.07,13.97,0.03,20.52,2.24,nan,nan,nan,nan,0,-44.79,-4.351 +67.1626,20.7802,0.076,0.065,0.282,0.135,-1.606,0.1,16.27,2.08,13.67,12.8,12.63,0.44,38.01,6.79,nan,nan,nan,nan,0,-145.784,-2.517 +286.6039,-32.445,-0.017,0.112,-2.627,0.183,-1.249,0.17,17.01,1.26,15.34,14.78,14.71,0.1,28.46,0.42,nan,nan,nan,nan,0,-2.726,3.045 +281.6438,-32.9604,-0.058,0.062,-2.47,0.116,-1.046,0.104,16.17,1.65,14.1,13.32,13.25,0.11,26.69,0.25,nan,nan,nan,nan,0,1.307,4.262 +289.268,-33.8207,0.074,0.218,-2.602,0.477,-1.958,0.376,17.98,1.26,16.5,15.79,15.61,0.1,26.17,0.92,nan,nan,nan,nan,0,-5.13,4.101 +281.4468,-30.3918,0.042,0.045,-2.801,0.069,-1.274,0.062,15.06,2.14,12.48,11.55,11.36,0.18,28.66,0.36,116.85,0.01,-0.7,0.02,1,1.964,1.772 +16.5761,-5.3057,-0.001,0.045,-1.203,0.078,-3.619,0.049,14.98,1.42,13.19,12.54,12.41,0.03,24.04,3.39,nan,nan,nan,nan,0,-89.639,-3.931 +283.9069,-28.7386,-0.071,0.059,-2.451,0.113,-1.303,0.099,15.77,1.71,13.66,12.93,12.79,0.19,28.35,2.24,nan,nan,nan,nan,0,0.159,-0.242 +291.8586,-30.1453,0.098,0.091,-2.477,0.156,-1.248,0.141,16.86,1.42,15.07,14.38,14.32,0.13,27.88,0.02,nan,nan,nan,nan,0,-6.892,0.197 +37.3566,-4.4767,-0.049,0.039,-0.162,0.093,-2.179,0.07,14.52,1.81,12.33,11.5,11.33,0.03,29.42,1.62,nan,nan,nan,nan,0,-107.989,5.738 +324.018,-28.0265,0.027,0.107,-2.067,0.166,-2.195,0.142,16.59,1.23,15.03,14.45,14.31,0.03,24.19,1.6,nan,nan,nan,nan,0,-34.965,-0.846 +48.1281,5.5642,-0.38,0.273,-0.253,0.408,-1.928,0.359,18.25,1.36,16.68,15.7,15.16,0.22,29.25,5.22,-174.59,3.68,-0.39,0.02,5,-122.37,2.446 +190.6083,1.8396,0.008,0.072,-1.059,0.17,-0.735,0.077,16.43,1.4,14.75,14.05,13.91,0.02,42.56,3.63,nan,nan,nan,nan,0,93.95,9.83 +283.3361,-28.7313,0.02,0.077,-2.735,0.133,-1.407,0.123,16.26,1.6,14.24,13.55,13.42,0.18,27.88,0.73,nan,nan,nan,nan,0,0.653,-0.162 +287.6308,-32.6098,-0.016,0.047,-2.448,0.076,-1.256,0.067,14.93,1.68,12.78,12.02,11.9,0.09,28.25,0.74,nan,nan,nan,nan,0,-3.608,3.083 +290.3931,-29.4691,-0.118,0.185,-2.324,0.364,-1.332,0.32,17.97,1.22,16.45,15.56,15.47,0.11,26.15,1.21,nan,nan,nan,nan,0,-5.553,-0.335 +276.2268,-31.8673,0.028,0.219,-2.46,0.358,-1.232,0.332,18.22,1.5,16.28,15.53,15.45,0.27,26.99,1.34,nan,nan,nan,nan,0,6.02,4.166 +227.2031,-9.9821,-0.074,0.119,-0.859,0.196,-0.859,0.208,17.05,1.58,15.04,14.24,14.15,0.11,49.24,1.59,nan,nan,nan,nan,0,56.253,1.851 +292.0219,-31.9937,0.064,0.165,-2.411,0.268,-1.363,0.254,17.72,1.18,16.45,15.56,15.4,0.06,28.06,1.45,nan,nan,nan,nan,0,-7.221,2.021 +295.735,-29.3919,0.013,0.094,-2.979,0.125,-1.951,0.107,16.44,1.49,14.58,13.94,13.79,0.13,26.98,1.33,nan,nan,nan,nan,0,-10.178,-0.845 +229.6447,-6.746,-0.032,0.051,-0.995,0.1,-0.565,0.083,16.03,1.83,13.75,12.94,12.78,0.09,46.18,1.8,nan,nan,nan,nan,0,55.735,-2.154 +178.5015,19.5892,0.002,0.082,-1.412,0.13,-0.783,0.113,16.5,1.33,14.85,14.14,14.08,0.03,31.94,3.69,nan,nan,nan,nan,0,112.539,-0.795 +282.9377,-29.6223,-0.011,0.102,-2.665,0.198,-1.081,0.162,16.67,1.63,14.62,13.89,13.68,0.16,27.57,0.13,nan,nan,nan,nan,0,0.837,0.777 +253.7603,-31.9032,0.081,0.28,-2.202,0.449,-0.656,0.266,18.45,1.85,15.98,15.21,15.06,0.46,30.46,5.13,nan,nan,nan,nan,0,24.216,10.255 +192.4237,11.7124,0.092,0.109,-1.559,0.201,-0.592,0.116,17.21,1.33,15.49,14.86,14.76,0.04,38.7,3.41,nan,nan,nan,nan,0,97.04,0.277 +283.8727,-29.366,-0.325,0.173,-2.853,0.219,-1.712,0.19,17.67,1.35,15.96,14.94,14.59,0.18,26.66,2.41,nan,nan,nan,nan,0,0.081,0.381 +294.5219,-30.0919,-0.393,0.185,-1.927,0.313,-1.436,0.327,18.26,1.25,16.75,15.81,15.68,0.12,28.05,2.96,nan,nan,nan,nan,0,-9.181,-0.068 +287.154,-32.3949,-0.069,0.094,-2.371,0.161,-1.366,0.14,16.82,1.43,15.01,14.38,14.3,0.1,27.0,1.54,nan,nan,nan,nan,0,-3.179,2.927 +286.0229,-33.3696,-0.025,0.064,-2.723,0.109,-1.404,0.094,15.35,1.59,13.33,12.56,12.44,0.09,26.54,0.55,nan,nan,nan,nan,0,-2.384,4.033 +288.9524,-31.9948,0.074,0.2,-2.533,0.27,-1.632,0.248,17.97,1.26,16.43,15.71,15.46,0.12,28.28,0.81,nan,nan,nan,nan,0,-4.634,2.323 +277.6795,-25.6887,-0.144,0.092,-2.854,0.166,-1.226,0.157,16.9,1.81,14.58,13.79,13.68,0.38,27.06,2.21,nan,nan,nan,nan,0,6.19,-2.14 +165.6961,29.1149,-0.008,0.048,-1.894,0.09,-1.751,0.073,16.08,1.3,14.45,13.83,13.77,0.03,24.84,2.97,nan,nan,nan,nan,0,126.95,-5.18 +222.3189,3.7597,-0.019,0.141,-1.024,0.154,-0.517,0.198,16.1,1.84,13.83,13.08,12.86,0.04,49.93,8.74,1.19,0.88,nan,nan,5,67.351,-7.633 +240.7386,-9.8087,0.056,0.109,-1.332,0.218,-0.516,0.115,17.04,1.66,15.0,14.17,14.03,0.22,45.48,2.92,nan,nan,nan,nan,0,44.592,-4.634 +303.7057,-29.6942,0.054,0.047,-2.671,0.079,-1.88,0.055,15.48,1.67,13.41,12.63,12.49,0.08,25.72,0.95,nan,nan,nan,nan,0,-17.118,-0.784 +286.6909,-30.425,0.006,0.071,-2.756,0.122,-1.453,0.116,16.51,1.61,14.51,13.8,13.62,0.11,28.59,1.39,nan,nan,nan,nan,0,-2.5,1.036 +230.4358,-12.051,-0.209,0.358,-0.781,0.436,-0.519,0.318,18.0,1.51,16.01,15.39,15.31,0.14,48.46,4.4,nan,nan,nan,nan,0,52.471,2.101 +314.1347,-23.3484,0.048,0.048,-2.448,0.081,-2.403,0.052,15.21,1.66,13.13,12.38,12.19,0.07,22.79,1.05,nan,nan,nan,nan,0,-26.754,-6.681 +285.9797,-32.5435,0.021,0.071,-2.783,0.108,-1.562,0.106,15.88,1.7,13.75,13.05,12.84,0.1,27.77,1.12,122.97,2.36,nan,nan,6,-2.22,3.222 +280.8191,-31.4545,-0.05,0.156,-2.553,0.27,-1.278,0.255,17.6,1.42,15.81,15.15,14.96,0.16,26.74,1.05,nan,nan,nan,nan,0,2.285,2.919 +292.7362,-29.8902,-0.039,0.117,-2.673,0.216,-1.669,0.209,17.44,1.27,15.81,15.15,15.06,0.13,27.31,2.05,nan,nan,nan,nan,0,-7.622,-0.132 +285.1906,-30.8229,0.006,0.076,-2.618,0.142,-1.709,0.122,16.67,1.61,14.64,13.89,13.81,0.15,26.94,0.56,nan,nan,nan,nan,0,-1.285,1.629 +282.4156,-33.6343,-0.086,0.09,-2.498,0.139,-1.096,0.129,16.55,1.55,14.61,13.88,13.82,0.1,26.49,1.95,nan,nan,nan,nan,0,0.545,4.803 +282.5554,-29.6516,-0.034,0.128,-2.392,0.19,-1.336,0.178,17.4,1.51,15.57,14.88,14.77,0.15,26.96,1.09,nan,nan,nan,nan,0,1.159,0.866 +284.3499,-31.4331,-0.15,0.108,-2.707,0.187,-1.415,0.165,17.35,1.4,15.53,14.81,14.6,0.15,27.69,0.65,nan,nan,nan,nan,0,-0.675,2.349 +350.5791,-17.5498,-0.122,0.104,-1.286,0.177,-2.56,0.171,16.92,1.26,15.42,14.77,14.65,0.03,22.55,1.49,nan,nan,nan,nan,0,-61.356,-4.035 +283.5873,-31.4223,0.012,0.134,-2.673,0.225,-1.181,0.198,17.19,1.45,15.4,14.73,14.53,0.15,28.27,0.81,nan,nan,nan,nan,0,-0.032,2.449 +289.584,-30.0695,0.061,0.098,-2.792,0.164,-1.528,0.146,17.09,1.29,15.4,14.76,14.61,0.12,26.92,2.04,nan,nan,nan,nan,0,-4.928,0.345 +284.4002,-31.2102,-0.127,0.083,-2.515,0.116,-1.398,0.097,15.77,1.94,13.4,12.54,12.36,0.14,28.99,0.18,nan,nan,nan,nan,0,-0.68,2.122 +249.6165,-28.6509,-0.256,0.391,-2.387,0.69,-0.329,0.431,18.69,1.7,16.72,15.75,15.6,0.45,36.68,9.23,nan,nan,nan,nan,0,28.868,8.73 +358.3407,-21.5702,0.034,0.056,-1.775,0.095,-3.403,0.069,15.7,1.3,14.06,13.43,13.32,0.02,22.73,0.79,nan,nan,nan,nan,0,-66.502,2.535 +284.4213,-30.7585,-0.019,0.047,-2.618,0.09,-1.273,0.08,15.13,1.93,12.73,11.86,11.72,0.14,28.79,2.47,142.7,nan,nan,nan,4,-0.623,1.673 +291.0843,-32.3497,-0.1,0.139,-2.698,0.213,-1.466,0.199,17.63,1.22,16.19,15.52,15.31,0.1,26.19,1.83,nan,nan,nan,nan,0,-6.469,2.46 +285.2959,-30.4933,0.034,0.079,-2.739,0.108,-1.321,0.094,16.19,1.56,14.25,13.55,13.3,0.11,26.94,0.76,nan,nan,nan,nan,0,-1.322,1.289 +285.2281,-31.5664,-0.136,0.081,-2.398,0.129,-1.467,0.127,16.48,1.51,14.53,13.86,13.76,0.12,26.85,1.37,nan,nan,nan,nan,0,-1.437,2.357 +283.1035,-31.1043,-0.115,0.101,-3.146,0.176,-1.438,0.149,17.05,1.55,15.18,14.52,14.41,0.15,25.26,1.71,nan,nan,nan,nan,0,0.432,2.209 +300.6199,-32.1052,-0.035,0.106,-2.015,0.166,-1.892,0.1,16.75,1.56,14.75,14.05,13.91,0.17,27.44,1.4,nan,nan,nan,nan,0,-14.503,1.662 +285.009,-28.8221,-0.083,0.053,-2.847,0.082,-1.384,0.071,15.12,2.0,12.63,11.78,11.59,0.2,27.75,1.12,nan,nan,nan,nan,0,-0.807,-0.32 +229.6423,0.2896,0.012,0.071,-1.097,0.126,-0.664,0.107,16.51,1.86,14.21,13.42,13.22,0.06,53.25,1.1,nan,nan,nan,nan,0,59.2,-8.286 +281.9014,-30.2106,-0.065,0.131,-2.778,0.208,-1.185,0.18,17.18,1.58,15.11,14.45,14.28,0.19,24.11,0.53,nan,nan,nan,nan,0,1.612,1.52 +284.7844,-30.849,-0.039,0.148,-2.707,0.207,-1.262,0.179,17.06,1.44,15.23,14.54,14.35,0.12,27.23,1.08,nan,nan,nan,nan,0,-0.945,1.711 +233.0987,-8.8151,0.04,0.097,-1.078,0.174,-0.727,0.132,16.84,1.79,14.64,13.84,13.61,0.14,44.22,3.41,nan,nan,nan,nan,0,51.737,-1.997 +278.3614,-29.1625,0.013,0.05,-2.751,0.084,-1.479,0.073,15.7,1.97,13.23,12.33,12.15,0.27,27.69,0.81,nan,nan,nan,nan,0,4.837,1.116 +280.3891,-30.2876,-0.114,0.066,-2.999,0.131,-1.113,0.107,16.0,1.86,13.75,12.85,12.7,0.14,29.52,1.33,nan,nan,nan,nan,0,2.88,1.849 +226.6095,-3.7412,-0.049,0.101,-1.038,0.178,-0.345,0.141,16.82,1.68,14.73,13.96,13.75,0.17,52.88,1.31,nan,nan,nan,nan,0,59.849,-3.285 +285.9774,-32.683,-0.056,0.147,-2.894,0.233,-1.524,0.213,17.76,1.2,16.12,15.47,15.39,0.1,27.77,1.45,nan,nan,nan,nan,0,-2.24,3.36 +299.3386,-36.4359,-0.07,0.038,-2.508,0.059,-1.706,0.041,14.81,2.23,12.21,11.32,11.08,0.1,25.41,0.71,132.3,nan,nan,nan,4,-13.583,6.024 +299.9597,-29.5677,-0.033,0.034,-3.521,0.052,-2.063,0.036,14.69,1.67,12.64,11.87,11.72,0.1,25.21,1.41,nan,nan,nan,nan,0,-13.86,-0.858 +286.6905,-32.8489,0.054,0.06,-2.513,0.09,-0.969,0.079,15.23,1.6,13.23,12.49,12.33,0.09,26.64,1.02,nan,nan,nan,nan,0,-2.859,3.434 +290.045,-34.0846,-0.094,0.156,-2.429,0.254,-1.309,0.251,17.84,1.26,16.24,15.46,15.11,0.1,27.34,2.32,nan,nan,nan,nan,0,-5.804,4.283 +285.4785,-30.3906,0.036,0.085,-2.407,0.146,-1.137,0.128,16.91,1.57,14.89,14.2,14.02,0.12,27.23,2.39,nan,nan,nan,nan,0,-1.461,1.162 +279.4959,-27.9783,0.011,0.089,-2.715,0.156,-1.294,0.135,16.74,1.6,14.63,13.96,13.76,0.28,26.98,2.44,nan,nan,nan,nan,0,4.114,-0.253 +296.475,-34.2255,-0.05,0.141,-2.445,0.28,-1.075,0.257,17.93,1.36,16.16,15.42,15.29,0.18,27.55,0.53,nan,nan,nan,nan,0,-11.128,3.936 +284.0095,-30.6923,-0.107,0.273,-3.006,0.335,-1.565,0.296,18.23,1.36,16.31,15.74,15.66,0.16,27.76,2.37,nan,nan,nan,nan,0,-0.262,1.668 +284.9299,-30.4427,-0.009,0.061,-2.713,0.097,-1.389,0.086,15.73,1.87,13.32,12.52,12.29,0.14,27.18,1.43,nan,nan,nan,nan,0,-1.002,1.29 +238.7371,-9.7835,-0.001,0.072,-1.263,0.148,-0.473,0.092,16.38,1.79,14.09,13.33,13.1,0.23,45.8,4.9,nan,nan,nan,nan,0,46.363,-3.756 +290.1626,-28.3802,-0.02,0.056,-2.615,0.091,-1.457,0.093,15.8,1.51,13.86,13.18,12.99,0.12,27.61,0.32,nan,nan,nan,nan,0,-5.225,-1.393 +232.7311,-26.5332,-0.068,0.086,-1.436,0.155,-0.627,0.11,16.66,1.7,14.51,13.72,13.61,0.19,47.3,6.45,nan,nan,nan,nan,0,43.416,13.687 +285.8821,-30.2657,0.081,0.124,-2.708,0.238,-1.285,0.197,16.98,1.41,15.18,14.54,14.4,0.15,27.93,0.55,nan,nan,nan,nan,0,-1.786,0.985 +285.5836,-24.9549,0.047,0.09,-3.04,0.148,-1.772,0.127,16.91,1.41,15.12,14.54,14.46,0.2,27.8,0.08,nan,nan,nan,nan,0,-0.695,-4.22 +290.058,-32.8788,-0.052,0.099,-3.053,0.185,-1.71,0.191,17.15,1.42,15.31,14.7,14.56,0.1,26.97,1.76,nan,nan,nan,nan,0,-5.67,3.085 +283.3874,-28.5341,-0.088,0.06,-2.981,0.117,-1.236,0.096,16.15,1.75,13.97,13.23,13.08,0.19,26.54,1.81,nan,nan,nan,nan,0,0.644,-0.364 +286.2138,-31.4508,0.006,0.077,-2.544,0.085,-1.397,0.071,15.55,1.53,13.62,12.92,12.8,0.11,26.12,3.37,nan,nan,nan,nan,0,-2.249,2.112 +228.671,-6.2765,-0.142,0.161,-1.412,0.323,-0.736,0.331,17.58,1.34,15.86,15.19,15.1,0.09,47.4,2.36,nan,nan,nan,nan,0,56.81,-2.09 +288.1323,-31.0374,0.045,0.086,-2.735,0.127,-1.032,0.124,16.58,1.4,14.71,14.15,14.05,0.1,27.23,0.74,nan,nan,nan,nan,0,-3.814,1.467 +291.5213,-30.4405,-0.014,0.034,-2.644,0.058,-1.447,0.051,14.68,1.64,12.66,11.96,11.86,0.08,27.14,3.96,nan,nan,nan,nan,0,-6.633,0.521 +291.6049,-33.6209,-0.052,0.181,-2.781,0.288,-1.989,0.305,17.96,1.34,16.31,15.43,15.24,0.12,27.57,1.14,nan,nan,nan,nan,0,-7.042,3.676 +322.173,-37.4537,0.082,0.048,-2.134,0.065,-1.903,0.065,14.82,2.11,12.3,11.41,11.19,0.11,19.49,2.37,nan,nan,nan,nan,0,-31.831,8.182 +276.7144,-28.6609,-0.074,0.09,-2.545,0.136,-1.225,0.115,16.25,2.07,13.76,12.82,12.65,0.36,26.89,1.19,nan,nan,nan,nan,0,6.355,0.949 +284.4647,-30.0056,-0.013,0.081,-2.815,0.115,-1.38,0.1,16.32,1.45,14.44,13.8,13.71,0.14,28.48,2.05,nan,nan,nan,nan,0,-0.534,0.925 +297.966,-34.3427,0.035,0.069,-2.431,0.104,-1.694,0.065,16.28,1.62,14.22,13.48,13.33,0.15,27.72,2.14,nan,nan,nan,nan,0,-12.368,3.983 +287.8633,-30.5739,-0.011,0.096,-2.679,0.17,-1.553,0.146,16.79,1.56,14.83,14.13,14.04,0.09,26.86,2.55,nan,nan,nan,nan,0,-3.521,1.039 +156.4946,24.3696,0.037,0.035,-1.58,0.069,-1.717,0.055,15.04,1.47,13.21,12.54,12.39,0.02,22.18,0.33,-85.49,4.8,-1.07,0.1,2,133.306,1.865 +279.8401,-29.0454,0.1,0.087,-2.708,0.145,-1.133,0.123,16.58,1.75,14.3,13.57,13.43,0.22,27.45,2.03,nan,nan,nan,nan,0,3.599,0.729 +288.6542,-32.9782,0.014,0.058,-2.685,0.084,-1.264,0.077,15.82,1.68,13.72,12.95,12.78,0.08,27.44,0.75,nan,nan,nan,nan,0,-4.512,3.331 +291.3053,-34.3461,-0.006,0.057,-2.541,0.091,-1.271,0.082,15.68,1.75,13.51,12.74,12.58,0.11,27.09,2.25,nan,nan,nan,nan,0,-6.872,4.424 +289.3237,-31.7011,0.003,0.039,-2.607,0.074,-1.404,0.074,14.82,2.14,12.3,11.45,11.21,0.11,28.09,1.36,nan,nan,nan,nan,0,-4.909,1.992 +285.3778,-30.6649,-0.064,0.085,-2.944,0.125,-1.669,0.114,16.55,1.41,14.76,14.13,14.03,0.14,26.29,1.12,nan,nan,nan,nan,0,-1.419,1.447 +226.6787,-18.7073,0.076,0.07,-1.259,0.147,-0.644,0.11,16.67,1.75,14.49,13.67,13.57,0.08,50.31,9.8,nan,nan,nan,nan,0,52.338,9.677 +286.9012,-30.2138,-0.031,0.203,-1.993,0.375,-1.69,0.346,18.25,1.31,16.72,15.89,15.67,0.15,27.34,0.83,nan,nan,nan,nan,0,-2.648,0.801 +286.3337,-31.1937,-0.032,0.075,-2.573,0.118,-1.219,0.104,16.58,1.5,14.65,13.98,13.82,0.1,27.62,0.41,nan,nan,nan,nan,0,-2.311,1.842 +283.8269,-31.1983,-0.091,0.079,-2.663,0.145,-1.461,0.139,16.65,1.44,14.8,14.15,14.05,0.15,27.63,0.39,nan,nan,nan,nan,0,-0.195,2.193 +289.2545,-33.5784,0.005,0.061,-2.177,0.121,-1.048,0.124,16.4,1.66,14.33,13.62,13.48,0.09,26.8,1.91,nan,nan,nan,nan,0,-5.088,3.862 +287.5631,-33.6164,-0.086,0.096,-2.598,0.119,-1.603,0.11,16.26,1.63,14.23,13.53,13.29,0.11,27.55,0.15,nan,nan,nan,nan,0,-3.694,4.088 +294.0626,-28.3387,0.055,0.079,-2.604,0.116,-1.502,0.101,16.26,1.58,14.25,13.58,13.37,0.11,27.39,2.35,nan,nan,nan,nan,0,-8.636,-1.782 +294.4123,-32.511,0.071,0.081,-2.574,0.126,-1.379,0.135,16.47,1.42,14.67,14.02,13.86,0.12,26.0,1.55,nan,nan,nan,nan,0,-9.283,2.351 +284.7071,-31.1559,-0.113,0.048,-2.533,0.091,-1.141,0.083,14.99,2.48,12.18,11.22,10.96,0.15,25.65,0.83,nan,nan,nan,nan,0,-0.93,2.025 +308.9271,-29.8949,-0.023,0.041,-2.721,0.068,-2.561,0.056,15.4,1.46,13.61,12.9,12.76,0.06,24.07,2.75,nan,nan,nan,nan,0,-21.643,-0.476 +285.8124,-31.1853,-0.021,0.084,-2.487,0.128,-1.067,0.114,16.32,1.7,14.24,13.47,13.27,0.12,27.15,1.49,nan,nan,nan,nan,0,-1.869,1.902 +285.5277,-28.5725,0.021,0.069,-2.422,0.14,-1.332,0.116,16.6,1.62,14.62,13.97,13.81,0.17,27.76,1.14,nan,nan,nan,nan,0,-1.216,-0.64 +284.6003,-32.3328,-0.154,0.101,-2.61,0.165,-1.507,0.158,17.08,1.47,15.21,14.47,14.37,0.12,26.29,1.19,nan,nan,nan,nan,0,-1.035,3.2 +276.7766,-28.616,0.034,0.105,-2.165,0.168,-1.127,0.145,16.74,1.79,14.52,13.75,13.57,0.37,26.89,1.19,nan,nan,nan,nan,0,6.312,0.893 +48.1498,17.8968,-0.054,0.22,0.483,0.446,-1.283,0.355,18.23,1.27,16.87,15.99,15.83,0.11,33.92,4.17,nan,nan,nan,nan,0,-128.478,-8.278 +306.9636,-31.9342,-0.03,0.1,-2.933,0.149,-1.917,0.114,17.02,1.31,15.42,14.72,14.66,0.1,23.96,1.26,nan,nan,nan,nan,0,-19.883,1.497 +295.6139,-33.8858,0.077,0.052,-2.195,0.085,-1.685,0.053,15.77,1.87,13.49,12.61,12.46,0.17,24.53,1.61,nan,nan,nan,nan,0,-10.392,3.645 +334.8662,-29.3056,-0.076,0.051,-1.807,0.094,-2.572,0.077,15.79,1.52,13.92,13.21,13.02,0.02,25.18,0.28,nan,nan,nan,nan,0,-43.966,2.498 +282.8327,-31.1363,-0.051,0.098,-2.471,0.135,-1.216,0.123,16.66,1.46,14.76,14.06,13.98,0.2,26.34,0.51,nan,nan,nan,nan,0,0.654,2.282 +335.0062,-34.9777,0.062,0.036,-2.244,0.05,-3.102,0.057,13.92,2.23,11.39,10.5,10.3,0.01,20.0,1.88,29.69,2.87,nan,nan,3,-42.573,7.999 +287.9317,-29.9818,-0.058,0.172,-2.525,0.3,-1.526,0.316,17.9,1.18,16.73,15.76,15.57,0.12,27.11,1.25,nan,nan,nan,nan,0,-3.498,0.445 +303.3908,-34.4431,0.077,0.043,-2.96,0.068,-1.906,0.042,14.87,1.67,12.83,12.07,11.92,0.1,27.5,0.68,nan,nan,nan,nan,0,-16.856,3.965 +284.0455,-30.8989,-0.109,0.055,-2.706,0.079,-1.411,0.07,15.14,2.26,12.47,11.58,11.35,0.15,26.76,2.39,132.94,0.01,-0.5,0.02,1,-0.328,1.866 +290.1262,-34.348,-0.062,0.063,-2.774,0.107,-1.515,0.093,16.16,1.61,14.06,13.32,13.21,0.11,26.0,1.54,nan,nan,nan,nan,0,-5.902,4.537 +211.9763,-0.1321,0.032,0.131,-1.159,0.237,-0.683,0.167,17.17,1.39,15.54,14.86,14.69,0.04,47.12,2.35,nan,nan,nan,nan,0,74.306,0.961 +250.0945,-25.073,-0.033,0.074,-1.506,0.142,-0.646,0.091,16.26,2.32,13.46,12.5,12.28,0.62,38.23,3.33,nan,nan,nan,nan,0,29.964,5.295 +285.0347,-30.9076,-0.084,0.062,-2.499,0.12,-1.582,0.109,16.46,1.66,14.44,13.61,13.49,0.14,26.59,1.39,144.77,0.05,-0.32,0.02,1,-1.167,1.734 +286.073,-30.0893,0.015,0.061,-2.711,0.077,-1.284,0.071,15.45,1.74,13.28,12.52,12.32,0.13,26.94,0.25,nan,nan,nan,nan,0,-1.922,0.785 +283.9091,-29.7225,-0.202,0.166,-2.469,0.289,-1.527,0.278,18.02,1.3,16.64,15.66,15.43,0.16,27.76,2.15,nan,nan,nan,nan,0,-0.011,0.727 +297.6587,-31.9382,-0.026,0.092,-2.627,0.132,-1.701,0.084,16.82,1.58,14.84,14.13,13.94,0.18,27.7,2.14,nan,nan,nan,nan,0,-11.986,1.595 +283.7804,-30.5248,-0.01,0.129,-2.692,0.206,-1.446,0.197,17.16,1.37,15.33,14.74,14.65,0.15,26.49,0.79,nan,nan,nan,nan,0,-0.039,1.537 +278.8944,-31.8537,-0.049,0.083,-2.802,0.148,-1.16,0.126,16.77,1.53,14.76,14.16,14.04,0.15,27.44,2.19,nan,nan,nan,nan,0,3.81,3.646 +333.749,-27.188,-0.061,0.056,-1.928,0.071,-2.578,0.067,14.68,1.99,12.31,11.45,11.26,0.02,17.95,1.32,nan,nan,nan,nan,0,-43.563,0.198 +288.8195,-31.787,0.049,0.041,-2.423,0.06,-1.098,0.058,14.84,1.65,12.76,12.09,11.93,0.12,28.47,0.59,nan,nan,nan,nan,0,-4.495,2.132 +283.5707,-32.9439,0.094,0.043,-2.573,0.08,-1.363,0.076,14.68,2.36,12.01,11.14,10.85,0.1,26.44,1.58,157.76,1.06,nan,nan,6,-0.284,3.95 +279.6472,-29.3753,0.07,0.077,-2.383,0.132,-1.273,0.118,16.14,1.77,13.87,13.05,12.87,0.19,28.92,1.44,154.9,nan,nan,nan,4,3.696,1.087 +285.7751,-32.5493,0.005,0.063,-2.757,0.116,-1.229,0.108,16.35,1.56,14.54,13.71,13.61,0.11,26.8,0.57,nan,nan,nan,nan,0,-2.051,3.255 +315.2044,-31.2882,0.051,0.044,-2.319,0.057,-2.376,0.037,14.27,1.72,12.16,11.43,11.26,0.1,26.38,1.19,nan,nan,nan,nan,0,-26.928,1.314 +279.5988,-32.2765,0.025,0.066,-2.576,0.124,-1.207,0.104,15.79,1.84,13.5,12.66,12.56,0.14,26.33,0.87,nan,nan,nan,nan,0,3.136,3.934 +289.1563,-29.5278,-0.038,0.066,-2.436,0.097,-1.594,0.091,15.83,1.4,14.17,13.46,13.31,0.12,25.98,0.5,nan,nan,nan,nan,0,-4.492,-0.146 +262.3745,-23.5267,-0.315,0.304,-1.714,0.51,-0.313,0.363,18.63,2.78,14.93,13.96,13.58,1.36,30.87,4.47,nan,nan,nan,nan,0,20.148,-0.315 +287.0476,-29.0266,-0.214,0.172,-2.566,0.21,-1.489,0.185,16.26,1.41,14.38,13.73,13.63,0.14,27.09,1.38,nan,nan,nan,nan,0,-2.602,-0.392 +281.504,-28.2747,0.085,0.053,-2.573,0.106,-1.158,0.092,15.93,1.63,13.76,13.0,12.91,0.25,26.56,3.11,nan,nan,nan,nan,0,2.32,-0.315 +283.7391,-29.6002,0.034,0.087,-2.368,0.121,-1.329,0.109,16.51,1.63,14.41,13.68,13.52,0.19,26.88,1.31,nan,nan,nan,nan,0,0.155,0.632 +295.1935,-33.5598,-0.106,0.062,-2.731,0.107,-1.417,0.096,14.61,2.65,11.6,10.7,10.44,0.16,27.48,1.03,nan,nan,nan,nan,0,-10.019,3.345 +180.1545,14.4223,0.097,0.049,-1.559,0.074,-0.932,0.05,15.01,1.67,12.94,12.18,12.04,0.03,31.92,3.66,nan,nan,nan,nan,0,108.938,3.234 +283.2087,-29.4682,-0.119,0.102,-2.543,0.202,-1.478,0.182,17.35,1.47,15.57,14.91,14.72,0.17,27.52,1.77,nan,nan,nan,nan,0,0.633,0.583 +280.5034,-31.8247,-0.076,0.05,-2.873,0.096,-1.2,0.09,14.67,2.3,11.95,11.03,10.81,0.14,26.02,0.98,nan,nan,nan,nan,0,2.476,3.336 +273.3397,-33.4133,0.029,0.051,-2.108,0.089,-0.899,0.077,15.72,1.76,13.5,12.74,12.55,0.26,25.41,4.24,nan,nan,nan,nan,0,8.005,6.262 +218.2799,2.9137,-0.001,0.056,-1.0,0.106,-0.653,0.104,15.81,1.8,13.64,12.79,12.6,0.03,49.31,1.13,-33.29,1.21,nan,nan,5,70.417,-4.86 +199.1924,11.4967,-0.07,0.05,-1.321,0.109,-0.694,0.072,15.75,1.54,13.91,13.18,13.0,0.02,41.02,1.35,nan,nan,nan,nan,0,91.128,-2.734 +286.3252,-30.8523,-0.11,0.086,-2.719,0.178,-1.561,0.177,16.99,1.52,15.06,14.31,14.09,0.1,27.65,2.39,nan,nan,nan,nan,0,-2.253,1.506 +188.6112,7.4018,0.048,0.098,-1.053,0.186,-0.933,0.126,16.55,1.44,14.75,14.06,13.94,0.02,38.33,3.47,nan,nan,nan,nan,0,98.352,5.844 +291.0709,-30.5202,0.035,0.059,-2.233,0.107,-1.326,0.103,16.17,1.63,14.15,13.39,13.29,0.08,28.17,0.34,nan,nan,nan,nan,0,-6.256,0.643 +201.5419,0.587,0.084,0.085,-1.226,0.168,-0.388,0.109,16.76,1.34,15.09,14.44,14.39,0.02,42.9,6.13,39.75,1.57,-1.54,0.0,5,83.699,5.597 +177.3612,21.9602,-0.061,0.116,-1.597,0.147,-1.279,0.122,16.3,1.27,14.77,14.12,14.01,0.02,30.65,1.92,nan,nan,nan,nan,0,114.488,-2.516 +291.9212,-31.5095,0.018,0.049,-2.9,0.074,-1.435,0.068,15.24,1.76,13.1,12.28,12.12,0.08,26.04,1.0,nan,nan,nan,nan,0,-7.086,1.549 +290.1631,-30.2496,0.024,0.167,-3.266,0.23,-1.491,0.204,17.75,1.28,16.32,15.66,15.54,0.12,27.56,1.34,nan,nan,nan,nan,0,-5.447,0.464 +286.1777,-33.2151,0.063,0.095,-2.567,0.163,-1.193,0.137,16.59,1.51,14.62,13.97,13.86,0.11,28.08,1.06,nan,nan,nan,nan,0,-2.488,3.86 +286.2441,-30.1772,0.044,0.038,-2.752,0.057,-1.29,0.05,14.54,1.97,12.14,11.33,11.14,0.12,26.9,0.11,nan,nan,nan,nan,0,-2.081,0.849 +284.6903,-31.4691,0.01,0.078,-2.515,0.104,-1.51,0.096,15.81,1.55,13.83,13.15,12.99,0.16,28.26,0.64,nan,nan,nan,nan,0,-0.968,2.336 +282.8633,-27.5467,-0.121,0.091,-2.638,0.137,-1.561,0.124,16.6,1.56,14.54,13.89,13.78,0.23,27.76,0.98,nan,nan,nan,nan,0,1.275,-1.254 +279.8689,-28.1261,0.021,0.045,-2.726,0.09,-1.452,0.081,14.87,2.38,11.88,10.93,10.53,0.2,28.96,2.07,147.46,1.96,nan,nan,3,3.762,-0.176 +286.3759,-31.7855,-0.094,0.146,-2.626,0.285,-1.573,0.26,17.94,1.24,16.32,15.67,15.54,0.13,26.36,2.05,nan,nan,nan,nan,0,-2.436,2.422 +290.5316,-32.3163,0.002,0.076,-2.438,0.161,-1.445,0.133,16.17,1.47,14.36,13.68,13.62,0.11,26.23,1.53,nan,nan,nan,nan,0,-6.001,2.479 +295.5555,-33.7246,0.051,0.069,-2.51,0.113,-1.34,0.076,16.72,1.61,14.68,13.96,13.83,0.17,27.16,2.88,nan,nan,nan,nan,0,-10.332,3.488 +289.459,-34.605,0.062,0.051,-2.577,0.076,-1.355,0.062,15.03,1.69,12.89,12.16,12.02,0.09,26.07,0.69,132.1,nan,nan,nan,4,-5.386,4.859 +285.3033,-31.3413,-0.008,0.047,-2.578,0.114,-1.441,0.095,15.1,1.91,12.78,11.88,11.73,0.12,27.33,0.51,nan,nan,nan,nan,0,-1.464,2.125 +338.62,-27.3702,-0.038,0.032,-2.942,0.048,-3.81,0.044,13.71,1.75,11.59,10.75,10.6,0.02,21.28,1.88,9.2,nan,nan,nan,4,-47.679,1.554 +284.3995,-29.9994,0.051,0.058,-2.857,0.101,-1.423,0.095,15.18,1.86,12.87,12.04,11.87,0.13,28.48,2.05,nan,nan,nan,nan,0,-0.477,0.928 +292.2892,-33.7691,-0.133,0.228,-2.534,0.317,-1.457,0.257,17.94,1.25,16.46,15.55,15.19,0.12,26.41,0.99,nan,nan,nan,nan,0,-7.625,3.765 +279.5516,-30.2015,-0.041,0.051,-2.455,0.098,-1.111,0.09,15.86,1.73,13.6,12.82,12.62,0.15,27.38,1.31,nan,nan,nan,nan,0,3.606,1.912 +290.787,-32.7218,-0.172,0.182,-2.1,0.317,-1.42,0.308,18.1,1.25,16.6,15.85,15.63,0.1,27.5,0.98,nan,nan,nan,nan,0,-6.262,2.858 +244.1977,-13.4744,-0.1,0.084,-1.907,0.167,-0.678,0.126,16.55,1.89,14.25,13.34,13.16,0.3,38.76,2.44,nan,nan,nan,nan,0,39.917,-2.861 +277.3063,-30.6437,0.064,0.088,-2.613,0.163,-0.95,0.146,17.0,1.6,14.96,14.26,14.08,0.25,25.05,3.28,nan,nan,nan,nan,0,5.402,2.763 +232.7642,-14.6759,-0.058,0.164,-1.499,0.296,-0.468,0.214,17.94,1.52,15.98,15.17,15.04,0.16,45.93,0.7,nan,nan,nan,nan,0,49.216,3.305 +278.2126,-29.7627,0.015,0.055,-2.679,0.081,-1.206,0.065,15.17,2.06,12.68,11.76,11.53,0.23,27.69,1.52,nan,nan,nan,nan,0,4.833,1.73 +78.9658,26.4171,-0.039,0.336,0.554,0.544,-0.997,0.36,18.68,2.93,14.88,13.68,13.23,1.53,49.25,0.72,nan,nan,nan,nan,0,-157.952,-3.465 +298.3194,-32.9841,-0.003,0.04,-3.299,0.062,-2.087,0.042,14.56,2.08,12.05,11.19,10.97,0.14,27.3,3.03,nan,nan,nan,nan,0,-12.596,2.611 +285.6166,-30.5107,0.081,0.211,-3.005,0.288,-1.629,0.268,18.08,1.22,16.64,15.73,15.42,0.15,28.84,1.17,nan,nan,nan,nan,0,-1.598,1.262 +285.6332,-29.4772,0.001,0.131,-2.335,0.189,-1.61,0.16,17.08,1.4,15.28,14.51,14.39,0.13,27.8,0.08,nan,nan,nan,nan,0,-1.45,0.239 +238.5944,-11.8956,-0.116,0.095,-1.117,0.17,-0.346,0.112,15.92,2.73,12.87,11.94,11.64,0.24,45.8,1.49,nan,nan,nan,nan,0,45.515,-1.816 +275.9524,-32.5313,0.042,0.067,-2.592,0.112,-0.998,0.096,16.14,1.81,13.91,13.08,12.95,0.24,27.55,0.52,nan,nan,nan,nan,0,6.09,4.866 +279.7478,-27.2805,-0.126,0.244,-2.491,0.361,-1.051,0.336,18.46,1.64,16.24,15.53,15.37,0.42,27.89,0.46,nan,nan,nan,nan,0,4.04,-0.981 +279.9683,-28.8269,-0.013,0.049,-2.481,0.069,-0.978,0.063,14.88,1.9,12.51,11.71,11.51,0.21,25.89,2.14,nan,nan,nan,nan,0,3.533,0.492 +318.1866,-30.429,0.044,0.066,-2.586,0.082,-2.231,0.055,14.91,1.6,12.94,12.2,12.07,0.11,22.98,2.93,nan,nan,nan,nan,0,-29.57,0.754 +284.5698,-27.5998,0.039,0.056,-3.057,0.094,-1.742,0.086,15.88,1.76,13.68,12.84,12.71,0.2,26.85,3.42,nan,nan,nan,nan,0,-0.225,-1.463 +283.2328,-32.2791,-0.064,0.057,-2.179,0.102,-1.482,0.092,16.03,1.65,13.92,13.2,13.1,0.13,27.01,0.98,120.9,nan,nan,nan,4,0.114,3.346 +226.8822,-2.745,0.005,0.085,-1.338,0.146,-0.359,0.176,16.26,1.77,14.05,13.37,13.22,0.11,52.88,0.56,nan,nan,nan,nan,0,60.106,-4.285 +286.9706,-30.3981,-0.017,0.078,-2.663,0.113,-1.529,0.098,16.46,1.62,14.51,13.73,13.55,0.13,26.52,0.56,nan,nan,nan,nan,0,-2.735,0.974 +283.2494,-28.9197,0.032,0.154,-2.616,0.168,-1.396,0.132,16.3,1.7,14.19,13.39,13.24,0.17,26.31,0.24,nan,nan,nan,nan,0,0.695,0.037 +285.7402,-32.3286,0.055,0.06,-2.9,0.088,-1.437,0.07,15.38,1.58,13.22,12.57,12.43,0.11,26.55,0.78,nan,nan,nan,nan,0,-1.987,3.041 +294.3465,-34.3586,-0.037,0.072,-2.629,0.111,-1.509,0.103,16.26,1.41,14.45,13.8,13.7,0.13,26.47,0.66,nan,nan,nan,nan,0,-9.38,4.197 +291.7541,-34.224,0.068,0.155,-1.875,0.283,-1.371,0.244,17.53,1.4,15.86,15.2,15.1,0.13,27.01,1.06,nan,nan,nan,nan,0,-7.229,4.263 +284.8245,-33.322,0.042,0.038,-2.835,0.067,-1.541,0.061,14.88,2.02,12.45,11.57,11.37,0.08,26.53,0.68,141.31,1.11,nan,nan,6,-1.385,4.145 +282.5748,-28.673,0.068,0.091,-2.681,0.14,-1.215,0.127,16.59,1.64,14.57,13.88,13.64,0.15,28.62,0.3,nan,nan,nan,nan,0,1.321,-0.1 +287.437,-30.7229,0.042,0.059,-2.308,0.081,-1.057,0.073,15.48,1.72,13.34,12.54,12.38,0.09,26.57,0.07,nan,nan,nan,nan,0,-3.179,1.238 +238.9193,-14.7283,0.066,0.061,-1.62,0.112,-0.559,0.071,16.14,1.65,14.05,13.34,13.16,0.14,44.79,6.27,nan,nan,nan,nan,0,43.932,0.555 +283.5749,-30.9394,-0.085,0.155,-2.612,0.288,-1.523,0.259,17.95,1.28,16.25,15.56,15.49,0.14,27.42,1.16,nan,nan,nan,nan,0,0.063,1.976 +285.4087,-29.1904,-0.05,0.113,-2.397,0.126,-1.257,0.109,16.07,1.77,13.87,13.15,12.92,0.19,27.73,2.0,nan,nan,nan,nan,0,-1.211,-0.013 +288.7957,-32.2215,-0.043,0.125,-2.172,0.231,-1.355,0.187,16.83,1.42,15.15,14.46,14.39,0.11,26.95,1.88,nan,nan,nan,nan,0,-4.531,2.565 +345.3262,-33.7336,0.046,0.032,-3.05,0.062,-3.666,0.058,14.85,1.35,13.14,12.53,12.42,0.02,20.44,1.31,nan,nan,nan,nan,0,-51.161,9.413 +233.7028,-4.9144,0.045,0.225,-1.2,0.395,-0.837,0.396,18.12,1.31,16.29,15.54,15.29,0.14,51.63,3.79,nan,nan,nan,nan,0,53.08,-5.709 +23.8231,-14.3565,-0.037,0.041,-0.455,0.097,-2.744,0.042,14.9,1.56,12.96,12.23,12.12,0.02,25.33,0.81,nan,nan,nan,nan,0,-91.373,7.461 +282.4802,-29.4724,-0.088,0.161,-3.068,0.302,-1.196,0.268,17.87,1.48,16.07,15.31,15.18,0.16,26.98,0.62,nan,nan,nan,nan,0,1.256,0.701 +349.9489,-25.2041,-0.155,0.075,-2.203,0.086,-3.638,0.087,15.87,1.33,14.2,13.54,13.46,0.02,19.48,5.63,nan,nan,nan,nan,0,-57.985,2.863 +283.0551,-27.3693,0.065,0.202,-2.437,0.293,-1.427,0.264,18.24,1.36,16.93,16.14,15.81,0.21,27.76,1.91,nan,nan,nan,nan,0,1.139,-1.459 +283.1008,-27.8279,-0.072,0.078,-2.731,0.128,-1.497,0.116,16.41,1.71,14.31,13.58,13.42,0.26,25.21,1.1,nan,nan,nan,nan,0,1.017,-1.014 +285.5958,-33.1251,0.049,0.045,-2.853,0.08,-1.169,0.073,14.94,2.07,12.48,11.59,11.37,0.1,26.7,0.75,123.95,1.23,nan,nan,6,-1.992,3.847 +300.946,-33.9212,0.024,0.072,-2.336,0.099,-1.737,0.064,15.98,1.4,14.18,13.59,13.47,0.1,26.25,4.12,nan,nan,nan,nan,0,-14.822,3.47 +284.9384,-31.3509,-0.016,0.192,-2.193,0.258,-1.538,0.235,17.99,1.27,16.44,15.49,15.07,0.13,26.35,1.05,nan,nan,nan,nan,0,-1.158,2.185 +316.9735,-33.5572,-0.023,0.048,-2.471,0.067,-2.207,0.058,15.1,1.67,13.03,12.26,12.1,0.09,23.52,2.32,nan,nan,nan,nan,0,-28.166,3.732 +278.9375,-29.3499,-0.077,0.068,-2.723,0.118,-1.281,0.107,16.43,1.72,14.28,13.53,13.34,0.24,27.51,2.37,158.6,nan,nan,nan,4,4.306,1.191 +222.617,8.0887,-0.078,0.166,-1.223,0.238,-0.348,0.308,17.52,1.27,15.96,15.35,15.21,0.02,48.99,1.68,nan,nan,nan,nan,0,69.324,-11.511 +281.9185,-27.9074,0.075,0.085,-3.052,0.128,-1.42,0.119,16.51,1.68,14.42,13.6,13.49,0.18,27.19,0.66,nan,nan,nan,nan,0,2.03,-0.745 +298.6282,-34.0393,0.096,0.09,-2.279,0.12,-1.765,0.075,16.45,1.59,14.43,13.72,13.62,0.13,27.28,3.68,nan,nan,nan,nan,0,-12.902,3.654 +300.9088,-34.8399,-0.023,0.133,-1.976,0.193,-1.657,0.139,17.54,1.38,15.92,15.17,14.86,0.1,26.21,0.81,nan,nan,nan,nan,0,-14.814,4.389 +290.1457,-33.0045,-0.054,0.075,-2.483,0.132,-1.53,0.126,16.45,1.37,14.72,14.09,13.9,0.1,26.97,1.34,nan,nan,nan,nan,0,-5.759,3.201 +336.2182,-27.6954,0.063,0.059,-2.186,0.114,-3.008,0.096,16.19,1.29,14.55,13.88,13.83,0.02,18.3,1.84,nan,nan,nan,nan,0,-45.543,1.266 +7.7723,-14.9394,0.024,0.193,-0.801,0.299,-3.358,0.251,17.95,1.09,16.65,15.94,15.84,0.03,21.29,1.99,nan,nan,nan,nan,0,-77.453,0.476 +286.5255,-30.6699,0.09,0.077,-2.839,0.183,-1.403,0.139,16.4,1.41,14.61,13.96,13.86,0.12,28.91,0.94,nan,nan,nan,nan,0,-2.395,1.3 +287.209,-32.0323,0.016,0.201,-2.843,0.284,-1.659,0.274,18.05,1.27,16.58,15.92,15.83,0.1,26.76,0.57,nan,nan,nan,nan,0,-3.173,2.562 +234.2592,-9.65,-0.091,0.134,-1.204,0.235,-0.581,0.176,17.17,1.64,15.17,14.45,14.19,0.13,45.31,1.79,nan,nan,nan,nan,0,50.331,-1.809 +282.6146,-30.7747,-0.004,0.063,-2.84,0.097,-1.408,0.081,14.93,2.46,12.09,11.15,10.88,0.15,26.33,0.57,nan,nan,nan,nan,0,0.904,1.961 +284.1969,-31.3752,-0.041,0.165,-3.091,0.288,-1.46,0.282,17.99,1.33,16.35,15.66,15.54,0.16,27.39,0.23,nan,nan,nan,nan,0,-0.537,2.314 +292.1308,-34.7473,0.027,0.102,-2.422,0.117,-1.284,0.106,15.92,1.7,13.83,13.11,12.94,0.11,29.63,1.98,nan,nan,nan,nan,0,-7.593,4.752 +224.1049,-7.4089,-0.056,0.06,-0.81,0.113,-0.523,0.096,16.32,1.77,14.12,13.31,13.14,0.1,49.31,4.49,nan,nan,nan,nan,0,60.19,1.136 +283.1865,-28.6549,0.009,0.122,-2.855,0.186,-0.896,0.181,17.05,1.4,15.28,14.66,14.54,0.18,25.65,2.21,nan,nan,nan,nan,0,0.796,-0.214 +280.4681,-28.3927,-0.067,0.107,-2.724,0.195,-1.51,0.17,16.94,1.54,15.1,14.4,14.26,0.18,26.89,0.79,nan,nan,nan,nan,0,3.191,-0.021 +299.4249,-33.7838,-0.056,0.05,-2.288,0.077,-1.436,0.054,15.59,1.53,13.68,12.91,12.79,0.12,26.56,3.69,nan,nan,nan,nan,0,-13.553,3.371 +284.1776,-29.4347,-0.025,0.06,-2.68,0.101,-1.496,0.094,16.0,1.85,13.72,12.88,12.67,0.17,26.6,2.12,112.3,nan,nan,nan,4,-0.192,0.404 +286.4546,-31.5421,-0.1,0.142,-2.726,0.204,-1.548,0.193,17.37,1.44,15.59,14.85,14.71,0.12,27.52,3.03,nan,nan,nan,nan,0,-2.466,2.171 +285.1271,-30.3513,0.048,0.126,-2.98,0.175,-1.609,0.156,17.07,1.39,15.29,14.7,14.62,0.13,27.38,1.15,nan,nan,nan,nan,0,-1.156,1.172 +57.6533,13.2752,0.024,0.208,0.111,0.307,-1.466,0.234,17.96,1.7,15.81,15.01,14.83,0.34,35.05,10.29,nan,nan,nan,nan,0,-134.308,0.168 +285.1882,-29.8077,-0.0,0.046,-2.759,0.108,-1.32,0.083,15.2,1.62,13.21,12.45,12.34,0.16,26.57,2.04,nan,nan,nan,nan,0,-1.12,0.627 +283.6164,-31.8028,-0.058,0.053,-2.652,0.071,-1.433,0.063,14.74,1.99,12.32,11.45,11.23,0.16,26.9,0.19,131.6,3.45,nan,nan,6,-0.123,2.82 +287.3196,-30.9487,0.01,0.079,-2.85,0.116,-1.54,0.109,16.56,1.44,14.7,14.1,13.98,0.11,26.55,1.59,nan,nan,nan,nan,0,-3.111,1.476 +290.488,-31.2831,-0.025,0.076,-2.09,0.133,-1.424,0.133,16.67,1.53,14.65,14.06,13.94,0.09,26.89,0.12,nan,nan,nan,nan,0,-5.845,1.457 +283.2987,-27.6688,-0.009,0.051,-2.484,0.08,-1.283,0.069,15.32,2.2,12.65,11.76,11.48,0.23,25.82,2.0,nan,nan,nan,nan,0,0.873,-1.202 +321.2889,-22.9083,0.028,0.051,-2.844,0.072,-2.886,0.056,15.48,1.51,13.57,12.9,12.74,0.05,22.13,2.52,nan,nan,nan,nan,0,-33.379,-6.301 +227.2684,-6.0353,0.094,0.08,-1.143,0.116,-0.497,0.099,16.12,1.67,14.01,13.27,13.13,0.09,51.01,4.27,nan,nan,nan,nan,0,58.143,-1.615 +311.1583,-38.8419,-0.017,0.057,-2.841,0.081,-2.035,0.059,15.51,1.4,13.76,13.05,12.99,0.04,21.21,1.47,nan,nan,nan,nan,0,-22.977,8.56 +281.0316,-30.4431,-0.031,0.063,-2.53,0.084,-1.426,0.069,15.21,1.96,12.8,11.9,11.77,0.13,28.84,2.94,152.0,nan,nan,nan,4,2.305,1.892 +280.5451,-29.9332,0.024,0.056,-2.631,0.091,-1.412,0.077,15.07,2.14,12.51,11.59,11.35,0.18,29.06,3.23,164.0,nan,nan,nan,4,2.818,1.475 +345.6559,-17.9385,-0.026,0.031,-2.228,0.058,-3.817,0.049,14.34,1.48,12.52,11.83,11.72,0.03,19.07,1.38,nan,nan,nan,nan,0,-56.821,-5.343 +279.6212,-30.0744,-0.045,0.066,-2.771,0.135,-1.442,0.123,16.46,1.37,14.67,14.03,13.93,0.14,28.05,0.96,nan,nan,nan,nan,0,3.573,1.775 +290.237,-34.2811,0.044,0.074,-2.58,0.132,-1.303,0.119,16.58,1.52,14.68,13.87,13.75,0.11,26.0,1.54,nan,nan,nan,nan,0,-5.985,4.46 +309.2435,-27.7465,0.028,0.038,-3.106,0.051,-2.565,0.036,13.98,1.78,11.83,11.03,10.87,0.05,23.7,3.68,nan,nan,nan,nan,0,-22.024,-2.609 +262.7171,-24.6738,-0.118,0.144,-1.264,0.297,-1.074,0.228,17.56,3.32,13.65,12.43,12.05,1.67,32.66,2.66,nan,nan,nan,nan,0,19.471,0.662 +277.3184,-28.288,0.087,0.078,-2.668,0.132,-1.34,0.12,16.39,1.8,14.02,13.22,13.03,0.39,25.7,0.26,nan,nan,nan,nan,0,5.922,0.465 +39.0217,-3.4727,-0.21,0.11,-0.064,0.12,-2.137,0.115,16.3,1.32,14.67,14.04,13.96,0.02,29.42,1.16,nan,nan,nan,nan,0,-109.939,5.718 +280.3702,-30.4017,-0.036,0.042,-2.69,0.073,-1.365,0.065,15.36,1.85,13.05,12.21,12.1,0.13,28.69,1.24,nan,nan,nan,nan,0,2.873,1.964 +231.9401,-7.5269,-0.017,0.083,-1.125,0.148,-0.377,0.12,16.56,1.87,14.24,13.38,13.26,0.1,46.18,3.46,nan,nan,nan,nan,0,53.363,-2.576 +266.0164,-17.2411,0.058,0.087,-1.485,0.142,-0.517,0.117,16.3,2.31,13.36,12.53,12.27,0.67,30.35,8.25,nan,nan,nan,nan,0,18.947,-7.366 +285.7618,-33.2808,-0.039,0.141,-2.722,0.231,-1.445,0.225,17.74,1.26,16.19,15.59,15.48,0.09,26.54,0.27,nan,nan,nan,nan,0,-2.154,3.979 +284.2712,-29.879,-0.209,0.179,-2.136,0.276,-1.092,0.257,17.78,1.31,16.1,15.29,15.17,0.14,26.82,1.68,nan,nan,nan,nan,0,-0.347,0.828 +312.1195,-38.0195,0.097,0.047,-2.93,0.069,-2.159,0.049,15.25,1.33,13.58,12.95,12.85,0.05,22.2,1.83,nan,nan,nan,nan,0,-23.795,7.794 +289.8837,-33.2967,-0.145,0.076,-2.216,0.125,-1.534,0.125,16.24,1.53,14.29,13.61,13.52,0.09,26.24,1.54,nan,nan,nan,nan,0,-5.575,3.517 +290.8739,-33.0859,-0.076,0.044,-2.493,0.067,-1.425,0.059,14.93,2.0,12.5,11.64,11.46,0.1,26.68,2.73,nan,nan,nan,nan,0,-6.375,3.211 +280.8847,-29.0857,0.049,0.061,-2.67,0.115,-1.345,0.101,15.64,1.93,13.22,12.38,12.12,0.22,26.79,1.32,126.35,1.12,nan,nan,6,2.696,0.586 +287.9008,-31.705,0.017,0.085,-2.465,0.193,-0.94,0.146,16.59,1.54,14.58,13.92,13.78,0.08,26.35,0.91,nan,nan,nan,nan,0,-3.709,2.155 +289.9946,-32.1768,-0.047,0.21,-2.354,0.311,-2.056,0.283,17.99,1.34,16.26,15.62,15.55,0.12,26.56,0.52,nan,nan,nan,nan,0,-5.533,2.394 +62.0775,12.1337,0.053,0.115,0.134,0.291,-1.134,0.155,16.76,2.02,14.25,13.4,13.26,0.48,39.84,5.28,nan,nan,nan,nan,0,-137.633,3.149 +275.9696,-25.241,-0.228,0.064,-2.755,0.129,-1.357,0.107,15.92,2.09,13.18,12.26,12.04,0.44,28.43,0.47,nan,nan,nan,nan,0,7.796,-2.223 +290.0809,-31.1122,0.076,0.089,-3.021,0.132,-1.476,0.124,16.81,1.41,15.07,14.34,14.26,0.12,27.78,0.95,nan,nan,nan,nan,0,-5.479,1.328 +294.6021,-31.6337,0.02,0.049,-2.844,0.077,-1.986,0.072,15.73,1.71,13.63,12.88,12.73,0.12,25.59,0.42,nan,nan,nan,nan,0,-9.373,1.463 +282.908,-30.4297,0.001,0.067,-2.607,0.115,-1.459,0.093,16.11,1.8,13.94,13.12,12.93,0.14,27.16,0.86,nan,nan,nan,nan,0,0.718,1.576 +284.9604,-30.761,-0.021,0.09,-2.338,0.183,-0.935,0.173,17.11,1.57,15.22,14.56,14.35,0.14,26.73,1.01,nan,nan,nan,nan,0,-1.08,1.6 +284.2558,-31.7086,0.045,0.068,-2.608,0.142,-1.424,0.121,16.42,1.63,14.35,13.62,13.46,0.16,27.24,0.62,nan,nan,nan,nan,0,-0.643,2.634 +281.8176,-30.9569,0.094,0.068,-2.714,0.123,-1.231,0.116,16.49,1.7,14.36,13.65,13.52,0.15,27.88,2.04,nan,nan,nan,nan,0,1.543,2.266 +281.628,-31.3528,-0.036,0.081,-2.937,0.175,-1.451,0.163,16.55,1.63,14.47,13.79,13.63,0.17,26.47,3.84,nan,nan,nan,nan,0,1.627,2.686 +294.4757,-30.5546,-0.111,0.085,-2.482,0.126,-1.739,0.121,16.44,1.57,14.5,13.74,13.68,0.13,25.73,1.68,nan,nan,nan,nan,0,-9.178,0.396 +286.0122,-32.118,0.098,0.048,-3.058,0.083,-1.398,0.081,15.38,1.61,13.34,12.63,12.47,0.12,26.99,0.68,nan,nan,nan,nan,0,-2.182,2.798 +287.1643,-29.9154,-0.343,0.252,-3.122,0.791,-1.464,0.528,18.08,1.27,16.68,15.77,15.65,0.11,25.63,3.04,nan,nan,nan,nan,0,-2.831,0.473 +277.5246,-27.0731,-0.004,0.091,-3.056,0.14,-1.48,0.12,16.75,1.86,14.27,13.48,13.24,0.34,27.0,1.63,nan,nan,nan,nan,0,6.017,-0.76 +179.5265,11.2836,0.024,0.096,-1.85,0.132,-1.148,0.095,16.41,1.28,14.83,14.2,14.1,0.02,28.69,5.3,nan,nan,nan,nan,0,108.162,6.337 +290.9165,-30.707,-0.009,0.091,-2.387,0.152,-1.486,0.146,16.74,1.51,14.85,14.2,14.01,0.08,27.78,0.91,nan,nan,nan,nan,0,-6.144,0.843 +230.5831,-11.0028,-0.126,0.096,-1.277,0.145,-0.34,0.117,16.47,1.81,14.23,13.36,13.19,0.13,52.26,2.11,nan,nan,nan,nan,0,52.854,1.114 +286.3718,-29.2448,-0.138,0.054,-2.494,0.12,-1.153,0.1,15.67,1.76,13.61,12.7,12.53,0.14,28.15,1.59,nan,nan,nan,nan,0,-2.05,-0.089 +278.8021,-29.5053,0.009,0.064,-2.581,0.111,-1.069,0.096,16.34,1.73,14.05,13.32,13.17,0.25,27.51,0.64,nan,nan,nan,nan,0,4.388,1.368 +280.3676,-32.001,0.083,0.131,-2.935,0.254,-0.991,0.218,17.54,1.25,16.03,15.45,15.36,0.13,26.89,1.48,nan,nan,nan,nan,0,2.554,3.531 +131.4803,22.0591,0.05,0.073,-1.831,0.12,-2.573,0.071,16.4,1.24,14.88,14.25,14.13,0.03,17.77,2.03,-39.99,4.47,-0.76,0.17,2,155.601,8.165 +284.2626,-30.3133,0.038,0.092,-2.83,0.139,-1.182,0.126,16.71,1.5,14.81,14.06,13.9,0.15,27.17,1.69,nan,nan,nan,nan,0,-0.413,1.257 +284.0249,-31.0127,0.029,0.081,-2.85,0.108,-1.377,0.088,15.83,1.67,13.72,12.95,12.8,0.15,27.15,2.21,nan,nan,nan,nan,0,-0.33,1.981 +282.909,-30.918,-0.025,0.113,-2.466,0.173,-1.394,0.14,16.33,1.66,14.21,13.46,13.37,0.16,26.32,2.05,nan,nan,nan,nan,0,0.629,2.056 +297.9447,-32.8886,0.036,0.049,-2.995,0.086,-1.715,0.055,15.95,1.5,14.04,13.36,13.25,0.15,26.57,1.92,nan,nan,nan,nan,0,-12.277,2.531 +286.0043,-28.7532,-0.033,0.114,-2.877,0.177,-1.735,0.166,16.92,1.47,15.0,14.41,14.34,0.2,27.5,2.2,nan,nan,nan,nan,0,-1.658,-0.526 +290.903,-29.8007,-0.048,0.071,-2.359,0.168,-1.675,0.15,16.26,1.58,14.3,13.56,13.44,0.12,27.01,1.27,nan,nan,nan,nan,0,-6.031,-0.056 +288.0437,-29.603,-0.216,0.1,-2.458,0.163,-1.444,0.149,16.95,1.43,15.06,14.42,14.26,0.15,26.53,1.53,nan,nan,nan,nan,0,-3.543,0.056 +275.3996,-28.3046,0.062,0.098,-2.718,0.147,-1.14,0.131,16.67,1.91,14.22,13.42,13.14,0.39,25.44,1.18,nan,nan,nan,nan,0,7.562,0.874 +294.5813,-30.0198,0.016,0.086,-2.858,0.144,-2.101,0.142,16.79,1.31,15.11,14.47,14.32,0.11,26.92,3.34,nan,nan,nan,nan,0,-9.226,-0.144 +278.7092,-28.2067,-0.026,0.069,-2.896,0.096,-1.333,0.079,15.63,2.0,13.18,12.34,12.09,0.27,27.8,2.76,nan,nan,nan,nan,0,4.745,0.117 +288.4684,-35.0283,-0.067,0.149,-2.549,0.246,-1.538,0.248,17.57,1.21,15.97,15.17,15.07,0.08,26.29,1.32,nan,nan,nan,nan,0,-4.631,5.384 +284.6236,-31.7162,0.075,0.059,-2.6,0.098,-1.466,0.093,15.91,1.44,14.1,13.42,13.3,0.13,26.51,0.78,nan,nan,nan,nan,0,-0.953,2.589 +280.0736,-30.3562,-0.031,0.148,-3.016,0.258,-1.28,0.24,18.01,1.24,16.25,15.59,15.45,0.13,26.98,0.81,nan,nan,nan,nan,0,3.133,1.971 +282.5977,-29.8378,-0.106,0.045,-2.695,0.07,-1.561,0.064,15.09,1.98,12.66,11.82,11.58,0.16,27.16,1.93,160.52,0.77,nan,nan,6,1.089,1.042 +299.3792,-29.351,-0.056,0.048,-2.368,0.079,-1.701,0.055,15.87,1.46,14.05,13.4,13.26,0.11,28.37,2.24,nan,nan,nan,nan,0,-13.348,-1.057 +277.0628,-23.7356,0.096,0.052,-2.509,0.077,-0.798,0.066,15.49,2.2,12.73,11.9,11.67,0.51,28.55,0.29,nan,nan,nan,nan,0,7.176,-3.917 +283.5344,-28.7926,-0.058,0.208,-2.331,0.352,-1.053,0.317,18.29,1.26,16.52,15.87,15.69,0.17,27.37,1.45,nan,nan,nan,nan,0,0.472,-0.132 +280.6069,-30.7084,-0.011,0.053,-2.688,0.098,-1.184,0.088,16.03,1.48,14.07,13.48,13.39,0.14,26.49,2.12,nan,nan,nan,nan,0,2.612,2.224 +285.1766,-32.3383,0.081,0.165,-2.707,0.366,-1.605,0.373,17.89,1.27,16.46,15.6,15.36,0.11,26.75,0.13,nan,nan,nan,nan,0,-1.518,3.126 +299.7213,-37.2923,-0.012,0.04,-2.556,0.064,-1.363,0.041,14.76,1.69,12.69,11.9,11.79,0.11,25.67,1.91,nan,nan,nan,nan,0,-13.923,6.869 +241.6567,-7.3613,-0.099,0.135,-0.839,0.307,-0.53,0.187,17.25,1.62,15.17,14.43,14.35,0.18,46.48,3.41,nan,nan,nan,nan,0,44.891,-7.228 +6.1343,-27.2097,-0.082,0.051,-1.808,0.082,-3.803,0.062,15.45,1.25,13.84,13.26,13.16,0.01,19.03,1.38,nan,nan,nan,nan,0,-70.458,10.702 +283.6271,-30.773,-0.068,0.088,-2.684,0.145,-1.509,0.128,16.58,1.61,14.57,13.89,13.65,0.15,26.58,1.82,145.7,nan,nan,nan,4,0.048,1.804 +284.5021,-31.9788,-0.097,0.085,-2.831,0.154,-1.264,0.134,16.4,1.49,14.43,13.74,13.67,0.14,25.98,2.4,nan,nan,nan,nan,0,-0.895,2.865 +43.9296,3.6564,0.021,0.041,-0.151,0.071,-2.223,0.069,15.02,1.95,12.67,11.79,11.59,0.09,26.28,2.39,-160.18,3.22,-0.3,0.11,2,-117.786,2.028 +344.1331,-28.1268,-0.026,0.039,-2.152,0.078,-3.217,0.067,15.42,1.3,13.75,13.16,13.04,0.03,20.31,1.42,nan,nan,nan,nan,0,-52.086,3.789 +284.9905,-31.3665,-0.274,0.17,-2.112,0.244,-1.099,0.212,17.74,1.3,16.09,15.37,15.29,0.13,26.35,1.05,nan,nan,nan,nan,0,-1.204,2.193 +277.8571,-31.5311,-0.078,0.078,-2.463,0.126,-1.132,0.11,16.6,1.58,14.61,13.87,13.75,0.19,25.45,2.8,nan,nan,nan,nan,0,4.743,3.522 +29.1267,-4.2261,0.009,0.07,-0.512,0.175,-2.087,0.089,16.05,1.31,14.37,13.73,13.6,0.02,21.34,1.63,nan,nan,nan,nan,0,-101.032,1.355 +287.5534,-31.3175,-0.197,0.094,-2.804,0.186,-1.335,0.158,17.09,1.38,15.27,14.6,14.42,0.1,26.75,0.93,nan,nan,nan,nan,0,-3.362,1.813 +309.6397,-32.8554,0.059,0.055,-3.12,0.091,-2.416,0.059,15.57,1.47,13.74,13.07,12.88,0.06,20.54,1.35,nan,nan,nan,nan,0,-22.104,2.511 +292.0538,-33.5165,0.032,0.073,-2.755,0.114,-1.517,0.108,16.29,1.5,14.37,13.69,13.56,0.11,27.77,2.01,nan,nan,nan,nan,0,-7.404,3.534 +287.6612,-28.6997,-0.006,0.094,-3.159,0.16,-1.674,0.148,16.79,1.61,14.76,13.98,13.84,0.17,28.95,0.8,nan,nan,nan,nan,0,-3.087,-0.792 +284.4705,-32.9553,-0.034,0.062,-2.474,0.121,-1.505,0.113,16.54,1.56,14.61,13.87,13.72,0.1,25.53,0.72,nan,nan,nan,nan,0,-1.031,3.832 +284.7566,-31.2056,0.076,0.083,-2.967,0.137,-0.92,0.132,16.76,1.48,14.91,14.21,14.11,0.14,25.65,0.83,nan,nan,nan,nan,0,-0.98,2.067 +246.9737,-15.945,0.049,0.127,-1.801,0.266,-0.366,0.157,17.37,2.46,14.37,13.31,13.08,0.79,36.97,5.45,nan,nan,nan,nan,0,36.427,-1.787 +285.6627,-30.0139,-0.129,0.173,-2.05,0.332,-1.076,0.293,18.23,1.22,16.48,15.81,15.6,0.14,27.04,0.2,nan,nan,nan,nan,0,-1.559,0.765 +283.4306,-30.4928,-0.129,0.109,-2.629,0.153,-1.309,0.137,17.03,1.56,15.07,14.4,14.22,0.14,27.09,1.69,nan,nan,nan,nan,0,0.263,1.558 +275.215,-33.3049,-0.144,0.243,-1.635,0.429,-1.195,0.39,18.05,1.35,16.38,15.63,15.36,0.19,27.42,0.57,nan,nan,nan,nan,0,6.507,5.765 +287.9346,-30.6903,0.054,0.079,-2.403,0.143,-1.501,0.129,16.84,1.43,15.02,14.38,14.29,0.1,26.6,2.18,nan,nan,nan,nan,0,-3.598,1.146 +298.3395,-35.8692,-0.05,0.043,-2.756,0.071,-1.563,0.05,15.47,1.43,13.67,13.01,12.94,0.1,25.57,1.7,nan,nan,nan,nan,0,-12.749,5.493 +283.7987,-30.6775,-0.018,0.089,-2.742,0.137,-1.35,0.119,16.66,1.54,14.68,14.02,13.87,0.16,26.65,1.63,nan,nan,nan,nan,0,-0.081,1.684 +284.4421,-31.0854,0.094,0.107,-2.686,0.144,-1.123,0.13,16.46,1.65,14.42,13.63,13.51,0.15,27.97,1.6,nan,nan,nan,nan,0,-0.695,1.993 +284.592,-29.5492,0.089,0.186,-2.25,0.355,-1.242,0.326,18.0,1.33,16.38,15.71,15.6,0.18,28.0,0.62,nan,nan,nan,nan,0,-0.567,0.456 +289.5994,-32.5972,0.054,0.046,-2.713,0.091,-1.453,0.076,14.99,1.9,12.66,11.68,11.46,0.11,26.77,1.09,nan,nan,nan,nan,0,-5.253,2.852 +113.3672,19.5874,0.048,0.042,-1.968,0.078,-3.361,0.062,15.35,1.33,13.66,13.04,12.93,0.04,15.87,2.19,-25.72,5.26,-0.92,0.07,2,172.776,10.443 +286.9847,-29.2821,-0.041,0.105,-2.978,0.129,-1.86,0.103,16.33,1.56,14.32,13.67,13.52,0.13,27.75,1.4,nan,nan,nan,nan,0,-2.584,-0.132 +289.6584,-32.787,-0.007,0.246,-1.87,0.363,-1.288,0.311,18.05,1.33,16.36,15.68,15.59,0.1,27.02,1.19,nan,nan,nan,nan,0,-5.325,3.034 +279.9718,-28.7691,0.026,0.082,-2.784,0.137,-0.886,0.121,16.55,1.58,14.47,13.72,13.53,0.21,25.89,2.14,nan,nan,nan,nan,0,3.542,0.435 +27.8876,-3.2224,-0.026,0.069,-0.31,0.156,-2.141,0.103,16.46,1.34,14.82,14.2,14.03,0.03,23.85,4.69,nan,nan,nan,nan,0,-100.472,-0.136 +258.4961,-19.453,-0.009,0.132,-2.149,0.196,-0.72,0.122,16.69,1.9,14.3,13.49,13.35,0.4,30.87,4.47,nan,nan,nan,nan,0,24.949,-2.881 +305.2535,-31.3732,0.063,0.061,-3.005,0.089,-2.151,0.065,15.98,1.46,14.18,13.53,13.43,0.07,26.69,1.77,nan,nan,nan,nan,0,-18.439,0.905 +277.7346,-24.4479,0.052,0.056,-2.051,0.077,-0.748,0.066,15.36,2.16,12.6,11.77,11.54,0.46,28.43,2.03,nan,nan,nan,nan,0,6.417,-3.361 +281.7175,-29.2349,0.022,0.102,-2.811,0.155,-1.423,0.139,16.98,1.44,15.22,14.51,14.38,0.16,25.94,1.43,nan,nan,nan,nan,0,1.953,0.592 +291.1879,-30.5599,0.009,0.065,-2.403,0.091,-1.655,0.081,15.92,1.65,13.85,13.08,13.0,0.07,28.4,1.29,nan,nan,nan,nan,0,-6.36,0.671 +280.2443,-29.4103,0.047,0.071,-2.575,0.134,-1.118,0.13,15.9,1.9,13.53,12.69,12.53,0.17,26.73,0.61,144.2,1.18,nan,nan,6,3.179,1.015 +305.1788,-35.5675,-0.08,0.048,-2.516,0.073,-1.739,0.046,14.76,2.17,12.22,11.3,11.09,0.07,25.72,1.03,nan,nan,nan,nan,0,-18.318,5.098 +283.9429,-31.5566,0.086,0.063,-2.606,0.119,-1.536,0.094,15.83,1.49,13.94,13.25,13.16,0.13,27.67,0.56,nan,nan,nan,nan,0,-0.354,2.529 +286.7809,-29.9687,0.055,0.057,-3.101,0.087,-1.827,0.08,15.88,1.43,14.07,13.45,13.33,0.13,26.96,0.85,nan,nan,nan,nan,0,-2.51,0.574 +279.8165,-28.7201,-0.013,0.069,-2.621,0.11,-1.367,0.1,16.36,1.63,14.2,13.44,13.26,0.25,27.45,0.28,nan,nan,nan,nan,0,3.685,0.415 +285.4135,-30.9416,0.067,0.105,-2.578,0.175,-1.228,0.152,16.98,1.31,15.32,14.67,14.56,0.14,26.42,1.06,nan,nan,nan,nan,0,-1.493,1.715 +236.4421,-5.5137,-0.049,0.152,-0.89,0.296,-0.629,0.214,17.89,1.55,15.88,15.27,15.19,0.18,49.02,1.75,nan,nan,nan,nan,0,50.38,-6.477 +296.6481,-31.8985,-0.024,0.094,-2.599,0.12,-1.586,0.079,16.34,1.7,14.23,13.4,13.2,0.18,26.74,2.25,nan,nan,nan,nan,0,-11.127,1.604 +307.2878,-40.6921,0.02,0.041,-3.31,0.058,-1.779,0.043,15.31,1.47,13.49,12.76,12.7,0.04,23.73,5.56,nan,nan,nan,nan,0,-19.872,10.259 +261.4527,-19.1812,-0.128,0.138,-1.552,0.238,-0.475,0.183,17.51,2.18,14.56,13.71,13.45,0.78,30.8,0.57,nan,nan,nan,nan,0,22.427,-4.115 +212.3344,-0.0598,0.018,0.063,-1.267,0.152,-0.434,0.108,16.39,1.63,14.43,13.61,13.41,0.04,48.71,2.35,nan,nan,nan,nan,0,74.034,0.717 +243.2152,-15.6015,-0.034,0.064,-1.459,0.132,-0.774,0.091,16.02,2.16,13.45,12.48,12.27,0.24,37.25,4.17,nan,nan,nan,nan,0,39.837,-0.532 +293.7538,-32.6013,0.078,0.046,-2.624,0.085,-1.678,0.096,15.27,1.57,13.33,12.64,12.53,0.09,26.77,0.94,nan,nan,nan,nan,0,-8.737,2.488 +284.0764,-31.3335,-0.25,0.169,-2.825,0.249,-1.43,0.214,17.62,1.29,15.99,15.39,15.29,0.15,28.32,1.58,nan,nan,nan,nan,0,-0.428,2.29 +24.5861,-7.0953,-0.073,0.062,-0.371,0.113,-2.235,0.086,15.94,1.35,14.29,13.59,13.51,0.03,21.27,4.6,nan,nan,nan,nan,0,-95.682,1.558 +277.4845,-30.9633,-0.098,0.068,-2.595,0.116,-1.391,0.105,14.46,3.13,11.97,11.09,10.65,0.2,27.61,1.9,nan,nan,nan,nan,0,5.181,3.04 +211.1553,-2.7376,-0.027,0.064,-1.296,0.143,-0.511,0.1,16.56,1.72,14.47,13.63,13.39,0.06,51.22,7.12,nan,nan,nan,nan,0,73.69,3.623 +185.2862,19.1533,-0.068,0.069,-1.416,0.105,-0.659,0.071,16.14,1.66,14.12,13.32,13.17,0.04,32.26,2.05,-40.45,0.98,-0.19,0.07,5,106.573,-3.162 +284.3106,-31.2931,-0.106,0.103,-3.106,0.152,-1.324,0.126,16.45,1.62,14.36,13.65,13.47,0.15,27.21,0.18,nan,nan,nan,nan,0,-0.619,2.216 +24.3959,-7.8006,0.078,0.122,-0.48,0.171,-2.265,0.107,16.74,1.33,15.0,14.35,14.23,0.02,24.65,2.23,nan,nan,nan,nan,0,-95.165,2.074 +279.2608,-27.4543,-0.133,0.141,-2.793,0.232,-1.391,0.187,16.89,1.73,14.7,13.86,13.7,0.35,27.88,1.11,nan,nan,nan,nan,0,4.427,-0.722 +283.7187,-30.0147,-0.102,0.077,-2.782,0.109,-1.186,0.092,15.16,1.83,12.84,12.02,11.82,0.15,27.43,0.61,nan,nan,nan,nan,0,0.101,1.044 +213.5616,-2.405,0.064,0.181,-1.106,0.338,-0.562,0.29,17.74,1.37,16.04,15.42,15.32,0.06,50.51,2.14,nan,nan,nan,nan,0,71.786,2.116 +356.5865,-20.2377,-0.053,0.088,-1.305,0.114,-2.738,0.137,16.47,1.2,14.95,14.38,14.29,0.02,22.2,2.54,nan,nan,nan,nan,0,-65.552,0.648 +290.0773,-31.8225,-0.061,0.153,-2.279,0.279,-1.371,0.263,17.69,1.32,16.01,15.37,15.17,0.11,26.16,1.45,nan,nan,nan,nan,0,-5.561,2.034 +282.0332,-30.3229,0.052,0.059,-2.601,0.101,-1.073,0.087,15.71,1.6,13.64,12.96,12.87,0.21,23.97,0.52,nan,nan,nan,nan,0,1.48,1.609 +7.1521,-39.0985,-0.02,0.039,-1.776,0.052,-3.947,0.042,15.19,1.32,13.55,12.89,12.8,0.01,16.76,1.59,nan,nan,nan,nan,0,-65.394,21.587 +297.3651,-35.409,-0.011,0.167,-2.697,0.225,-1.522,0.147,17.71,1.29,16.02,15.42,15.35,0.16,26.76,1.26,nan,nan,nan,nan,0,-11.931,5.074 +287.7881,-34.7945,0.048,0.163,-3.265,0.213,-1.186,0.174,17.43,1.31,16.02,15.19,14.95,0.08,26.73,1.07,nan,nan,nan,nan,0,-4.044,5.228 +280.989,-30.7128,-0.007,0.097,-2.614,0.214,-1.254,0.173,16.49,1.63,14.42,13.62,13.48,0.14,27.01,1.54,nan,nan,nan,nan,0,2.288,2.164 +288.4763,-33.8409,0.013,0.084,-2.788,0.141,-1.027,0.126,16.57,1.5,14.68,13.95,13.89,0.07,26.16,0.66,nan,nan,nan,nan,0,-4.479,4.206 +238.7827,-13.3299,-0.118,0.073,-0.991,0.149,-0.493,0.105,15.79,2.47,12.98,12.07,11.8,0.13,44.58,5.71,nan,nan,nan,nan,0,44.692,-0.627 +288.0383,-31.4106,0.002,0.045,-2.491,0.065,-1.212,0.059,14.87,1.78,12.59,11.83,11.64,0.09,27.84,2.38,nan,nan,nan,nan,0,-3.785,1.848 +289.4509,-32.229,0.014,0.037,-2.447,0.064,-1.275,0.062,15.14,1.89,12.83,11.99,11.81,0.1,26.6,0.47,nan,nan,nan,nan,0,-5.082,2.502 +198.3632,14.0928,-0.201,0.16,-1.045,0.298,-0.446,0.199,17.69,1.19,16.31,15.62,15.55,0.02,37.61,4.55,nan,nan,nan,nan,0,93.111,-4.598 +282.8957,-30.3735,0.087,0.116,-2.546,0.169,-0.995,0.151,17.26,1.48,15.41,14.68,14.48,0.14,27.16,0.86,nan,nan,nan,nan,0,0.738,1.522 +277.092,-32.8446,0.045,0.143,-2.527,0.173,-1.484,0.159,16.97,1.41,15.24,14.63,14.51,0.19,27.03,0.76,nan,nan,nan,nan,0,5.08,4.948 +283.3545,-31.3534,0.097,0.089,-2.638,0.139,-1.437,0.121,16.45,1.72,14.28,13.46,13.33,0.17,26.86,0.68,nan,nan,nan,nan,0,0.176,2.416 +288.0314,-30.7134,0.067,0.102,-2.863,0.186,-1.548,0.151,16.84,1.51,14.98,14.24,14.07,0.1,26.6,1.38,nan,nan,nan,nan,0,-3.684,1.158 +284.3168,-30.6113,0.027,0.076,-2.701,0.151,-1.245,0.111,15.93,1.84,13.63,12.84,12.61,0.16,29.1,1.93,154.45,0.04,nan,nan,1,-0.509,1.543 +288.1407,-34.3002,-0.044,0.147,-3.094,0.215,-1.837,0.187,17.4,1.34,15.75,15.04,14.79,0.09,27.5,2.09,nan,nan,nan,nan,0,-4.265,4.698 +287.9516,-34.2036,0.02,0.084,-2.654,0.152,-1.51,0.142,16.74,1.51,14.8,14.12,13.95,0.09,26.73,1.14,nan,nan,nan,nan,0,-4.096,4.624 +283.1113,-30.8907,0.036,0.073,-2.58,0.101,-1.292,0.086,15.54,1.93,13.07,12.2,12.0,0.16,25.93,0.96,nan,nan,nan,nan,0,0.463,1.998 +286.3669,-30.624,0.016,0.128,-2.602,0.249,-1.311,0.217,16.94,1.5,15.04,14.25,14.09,0.12,28.79,0.93,nan,nan,nan,nan,0,-2.254,1.275 +220.6534,-10.3099,-0.055,0.057,-1.063,0.105,-0.757,0.094,16.0,1.76,13.82,13.0,12.89,0.1,51.55,2.08,nan,nan,nan,nan,0,61.685,5.358 +287.8241,-34.3945,-0.023,0.096,-2.399,0.187,-1.498,0.157,16.96,1.37,15.27,14.51,14.43,0.08,26.73,3.31,nan,nan,nan,nan,0,-4.018,4.828 +282.8862,-29.6183,-0.255,0.119,-2.644,0.188,-1.385,0.166,17.13,1.44,15.11,14.48,14.39,0.17,27.65,0.17,nan,nan,nan,nan,0,0.882,0.781 +279.8561,-28.1296,0.005,0.045,-3.074,0.091,-1.222,0.078,15.17,2.28,12.42,11.55,11.29,0.21,28.96,2.07,nan,nan,nan,nan,0,3.772,-0.17 +61.1767,27.6429,-0.024,0.055,0.214,0.098,-0.95,0.052,15.82,1.91,13.43,12.67,12.45,0.18,35.23,3.52,-178.47,5.07,-1.36,0.06,2,-143.892,-11.08 +145.6104,31.9221,0.001,0.056,-2.095,0.074,-2.427,0.058,15.25,1.41,13.5,12.87,12.69,0.02,20.42,2.44,-87.22,4.22,-0.6,0.16,2,144.393,-3.236 +221.9159,-15.1251,0.039,0.068,-1.209,0.111,-0.456,0.089,16.02,2.01,13.58,12.76,12.55,0.09,49.49,3.42,nan,nan,nan,nan,0,58.156,8.885 +288.8535,-30.0064,0.042,0.143,-3.087,0.281,-1.876,0.267,17.68,1.18,16.14,15.28,14.88,0.11,28.23,2.15,nan,nan,nan,nan,0,-4.293,0.363 +285.0682,-32.9934,-0.207,0.116,-2.666,0.181,-1.529,0.128,16.31,1.49,14.45,13.73,13.64,0.11,25.18,0.26,nan,nan,nan,nan,0,-1.533,3.788 +196.4674,3.6079,-0.042,0.099,-1.275,0.222,-0.559,0.143,16.98,1.45,15.14,14.38,14.21,0.02,41.23,1.68,nan,nan,nan,nan,0,89.628,5.468 +312.7028,-22.5163,-0.167,0.097,-2.854,0.15,-2.476,0.107,16.75,1.28,15.19,14.55,14.41,0.11,22.38,1.91,nan,nan,nan,nan,0,-25.503,-7.624 +283.7744,-31.5458,0.064,0.09,-2.921,0.19,-1.505,0.148,16.54,1.49,14.6,13.91,13.8,0.14,27.68,0.64,nan,nan,nan,nan,0,-0.211,2.543 +285.7681,-31.1169,0.008,0.079,-2.653,0.121,-1.254,0.106,16.43,1.54,14.53,13.84,13.64,0.13,28.16,1.28,nan,nan,nan,nan,0,-1.821,1.841 +297.0561,-27.833,0.057,0.089,-2.71,0.132,-1.805,0.074,16.76,1.43,15.02,14.25,14.2,0.12,25.87,1.69,nan,nan,nan,nan,0,-11.236,-2.475 +39.8152,-2.3558,-0.084,0.048,-0.207,0.103,-2.105,0.089,15.55,1.47,13.7,13.03,12.93,0.03,29.42,3.16,nan,nan,nan,nan,0,-111.194,5.157 +283.0751,-30.0902,0.099,0.214,-2.19,0.274,-0.958,0.253,17.93,1.33,16.42,15.8,15.72,0.15,27.25,1.07,nan,nan,nan,nan,0,0.637,1.216 +286.3686,-30.0124,-0.018,0.07,-2.711,0.091,-1.336,0.075,15.33,1.81,13.09,12.24,12.07,0.12,26.41,0.71,nan,nan,nan,nan,0,-2.163,0.67 +282.5928,-30.42,0.035,0.068,-2.71,0.131,-1.022,0.121,16.39,1.42,14.57,13.91,13.77,0.16,26.91,1.84,nan,nan,nan,nan,0,0.987,1.615 +231.0966,-13.6352,-0.172,0.161,-1.248,0.283,-0.301,0.185,17.59,1.53,15.67,14.92,14.74,0.13,48.46,0.85,nan,nan,nan,nan,0,51.138,3.173 +165.0115,23.0052,-0.069,0.148,-2.158,0.236,-1.23,0.366,16.34,1.26,14.74,14.18,14.12,0.02,25.32,2.13,nan,nan,nan,nan,0,125.463,0.778 +287.0215,-32.0769,0.02,0.058,-2.371,0.099,-1.173,0.096,15.07,2.45,12.28,11.37,11.13,0.1,26.0,0.6,148.84,1.59,nan,nan,6,-3.022,2.629 +288.0639,-31.6487,0.031,0.051,-2.637,0.093,-1.358,0.079,15.91,1.61,13.93,13.16,13.03,0.09,26.9,2.81,137.7,nan,nan,nan,4,-3.839,2.08 +288.2492,-29.9801,-0.038,0.1,-2.545,0.142,-1.183,0.131,16.82,1.53,14.89,14.19,13.97,0.13,27.01,1.74,nan,nan,nan,nan,0,-3.77,0.406 +290.6788,-33.8369,0.095,0.203,-2.293,0.321,-1.267,0.299,18.05,1.3,16.37,15.45,15.24,0.12,27.97,1.5,nan,nan,nan,nan,0,-6.298,3.976 +312.1269,-32.5763,-0.287,0.105,-2.196,0.154,-2.093,0.125,16.98,1.37,15.26,14.56,14.5,0.07,22.81,2.54,nan,nan,nan,nan,0,-24.212,2.366 +282.9908,-30.6015,0.054,0.07,-2.676,0.13,-1.424,0.115,16.47,1.42,14.6,13.98,13.9,0.14,27.55,2.68,nan,nan,nan,nan,0,0.617,1.732 +213.5494,-3.1742,-0.129,0.155,-0.821,0.266,-0.727,0.184,17.81,1.42,15.9,15.22,15.13,0.06,51.95,2.14,nan,nan,nan,nan,0,71.405,2.785 +282.9481,-30.2292,0.042,0.069,-2.798,0.091,-1.242,0.081,15.65,2.06,13.22,12.34,12.13,0.16,28.14,0.73,nan,nan,nan,nan,0,0.72,1.372 +282.386,-31.4284,-0.065,0.095,-2.717,0.17,-1.249,0.16,16.89,1.6,14.97,14.16,13.95,0.18,25.94,1.28,nan,nan,nan,nan,0,0.977,2.639 +289.409,-34.6548,-0.039,0.085,-2.915,0.125,-1.515,0.119,16.45,1.46,14.59,13.97,13.85,0.09,26.07,0.69,125.9,nan,nan,nan,4,-5.351,4.913 +228.5529,-2.5886,0.028,0.062,-0.851,0.111,-0.52,0.093,16.05,2.06,13.66,12.77,12.53,0.14,52.88,0.54,nan,nan,nan,nan,0,58.727,-5.245 +285.8907,-31.091,-0.03,0.052,-2.518,0.079,-1.402,0.068,14.99,2.07,12.48,11.6,11.37,0.12,27.15,0.79,125.38,0.98,nan,nan,6,-1.921,1.799 +284.8409,-29.0196,0.071,0.09,-2.723,0.116,-1.531,0.105,16.23,1.44,14.39,13.75,13.58,0.17,27.38,2.02,nan,nan,nan,nan,0,-0.694,-0.102 +329.0874,-21.773,0.036,0.058,-1.927,0.075,-2.545,0.077,14.92,1.5,13.06,12.35,12.2,0.03,22.42,3.47,nan,nan,nan,nan,0,-40.719,-6.066 +283.3441,-28.6184,-0.004,0.078,-2.846,0.136,-1.568,0.119,16.55,1.54,14.51,13.87,13.77,0.18,27.88,0.73,nan,nan,nan,nan,0,0.666,-0.274 +298.2128,-34.1522,-0.073,0.125,-2.178,0.202,-1.863,0.157,17.65,1.34,16.14,15.36,15.19,0.16,27.28,2.79,nan,nan,nan,nan,0,-12.563,3.782 +283.6208,-30.4611,-0.087,0.122,-2.446,0.169,-1.385,0.153,17.12,1.5,15.24,14.49,14.42,0.16,26.98,1.12,nan,nan,nan,nan,0,0.107,1.498 +287.0032,-29.3888,-0.119,0.115,-2.703,0.184,-1.347,0.173,17.36,1.38,15.56,14.94,14.85,0.12,26.81,0.58,nan,nan,nan,nan,0,-2.616,-0.028 +277.0653,-26.6167,-0.337,0.099,-2.782,0.16,-1.496,0.146,16.57,1.74,14.08,13.3,13.14,0.33,25.9,1.63,nan,nan,nan,nan,0,6.519,-1.112 +285.9069,-28.5622,-0.528,0.175,-2.416,0.26,-1.622,0.256,17.94,1.32,16.21,15.62,15.54,0.19,27.8,1.54,nan,nan,nan,nan,0,-1.544,-0.702 +168.9016,7.8052,-0.046,0.221,-1.621,0.311,-0.917,0.328,17.77,1.17,16.39,15.7,15.52,0.04,31.64,4.74,nan,nan,nan,nan,0,116.527,13.692 +234.9018,-7.0738,-0.024,0.095,-1.123,0.187,-0.658,0.149,16.94,2.25,14.09,13.15,12.89,0.42,48.12,4.17,nan,nan,nan,nan,0,50.995,-4.378 +286.2748,-32.543,0.035,0.053,-2.779,0.083,-1.304,0.078,14.81,2.28,12.15,11.25,10.97,0.09,27.77,0.76,nan,nan,nan,nan,0,-2.466,3.184 +215.6267,-4.9588,-0.015,0.053,-1.162,0.114,-0.565,0.087,16.0,1.88,13.77,12.92,12.78,0.05,49.58,4.24,66.98,0.02,-1.12,0.02,1,68.71,3.27 +279.6857,-30.7725,-0.049,0.113,-2.971,0.198,-1.162,0.18,16.8,1.49,14.65,14.03,13.95,0.17,27.03,2.51,nan,nan,nan,nan,0,3.375,2.447 +285.9143,-30.7075,-0.029,0.055,-2.731,0.1,-1.196,0.095,16.05,1.6,14.03,13.31,13.18,0.14,28.05,1.41,nan,nan,nan,nan,0,-1.882,1.417 +279.2814,-28.2153,0.064,0.067,-2.893,0.106,-1.358,0.091,15.13,2.88,12.01,11.04,10.69,0.26,27.46,1.74,nan,nan,nan,nan,0,4.25,0.019 +290.8579,-33.0795,0.066,0.075,-2.489,0.109,-1.524,0.098,16.21,1.48,14.31,13.71,13.58,0.1,26.68,2.73,nan,nan,nan,nan,0,-6.361,3.206 +280.8648,-29.1115,0.018,0.047,-2.837,0.088,-1.481,0.076,15.14,1.75,13.03,12.27,12.1,0.22,26.79,1.32,nan,nan,nan,nan,0,2.708,0.614 +240.033,-15.3917,-0.166,0.084,-1.175,0.202,-0.495,0.12,16.59,1.97,14.17,13.28,13.07,0.24,43.3,4.72,nan,nan,nan,nan,0,42.672,0.654 +305.5366,-34.0268,0.042,0.063,-2.717,0.12,-1.548,0.072,15.99,1.49,14.07,13.45,13.37,0.07,26.06,4.93,nan,nan,nan,nan,0,-18.636,3.562 +281.6841,-30.3229,0.036,0.038,-2.678,0.074,-1.304,0.069,15.44,1.77,13.24,12.39,12.24,0.18,28.61,1.49,139.6,0.03,-0.25,0.01,1,1.776,1.666 +171.6863,16.7295,-0.139,0.106,-1.399,0.222,-1.374,0.317,16.31,1.3,14.71,14.13,14.03,0.03,28.61,4.31,-15.54,5.47,-1.37,0.17,2,117.352,4.399 +285.6992,-34.0406,0.044,0.213,-1.882,0.28,-0.759,0.259,17.97,1.26,16.51,15.7,15.62,0.11,27.24,1.49,nan,nan,nan,nan,0,-2.221,4.737 +289.3072,-31.0223,0.019,0.082,-2.331,0.14,-1.23,0.129,16.36,1.4,14.58,13.93,13.78,0.1,28.22,0.67,nan,nan,nan,nan,0,-4.81,1.32 +292.1201,-32.7254,0.051,0.048,-2.775,0.076,-1.311,0.07,15.65,1.64,13.64,12.85,12.71,0.1,27.18,1.26,nan,nan,nan,nan,0,-7.378,2.741 +275.66,-28.5072,0.008,0.06,-2.239,0.115,-1.256,0.101,15.62,2.42,12.61,11.64,11.32,0.43,26.09,2.15,nan,nan,nan,nan,0,7.291,1.016 +221.2277,-10.1361,-0.156,0.097,-1.249,0.171,-0.768,0.162,17.04,1.46,15.21,14.54,14.32,0.1,50.64,1.34,nan,nan,nan,nan,0,61.283,4.922 +245.3885,-16.4892,-0.228,0.19,-0.937,0.289,-0.61,0.208,17.25,1.69,15.2,14.37,14.09,0.31,36.97,4.58,nan,nan,nan,nan,0,37.57,-0.644 +279.9959,-29.2067,0.095,0.04,-2.728,0.069,-0.738,0.062,14.66,2.09,12.14,11.34,11.1,0.19,27.32,0.61,nan,nan,nan,nan,0,3.432,0.859 +280.7377,-28.8643,0.049,0.041,-2.876,0.085,-1.318,0.072,15.16,1.87,12.79,11.97,11.78,0.16,28.49,3.64,165.79,1.96,nan,nan,6,2.866,0.394 +289.5728,-30.4107,-0.049,0.037,-2.212,0.069,-1.166,0.061,15.24,2.04,12.79,11.92,11.72,0.13,28.3,0.43,nan,nan,nan,nan,0,-4.96,0.685 +303.7196,-31.3326,0.048,0.063,-2.897,0.097,-2.049,0.069,15.86,1.55,13.91,13.24,13.03,0.11,24.39,3.95,nan,nan,nan,nan,0,-17.129,0.855 +280.2356,-28.7776,0.006,0.055,-2.577,0.111,-1.233,0.095,15.36,1.76,13.14,12.35,12.24,0.19,25.61,1.6,nan,nan,nan,nan,0,3.314,0.397 +283.7381,-30.5704,-0.018,0.037,-2.743,0.064,-1.417,0.059,14.82,1.77,12.62,11.88,11.75,0.15,26.33,0.29,141.81,nan,nan,nan,4,-0.011,1.588 +282.5931,-31.7263,-0.105,0.062,-2.528,0.115,-0.925,0.104,16.47,1.44,14.63,13.98,13.91,0.16,27.32,0.64,nan,nan,nan,nan,0,0.748,2.9 +287.0828,-31.687,-0.018,0.063,-2.477,0.109,-1.349,0.097,16.16,1.59,14.15,13.4,13.26,0.09,26.92,1.52,nan,nan,nan,nan,0,-3.017,2.236 +283.3008,-30.22,-0.318,0.214,-2.451,0.323,-0.815,0.279,17.98,1.28,15.82,15.22,15.13,0.17,26.88,1.48,nan,nan,nan,nan,0,0.421,1.309 +358.5704,-11.5253,-0.048,0.061,-1.128,0.082,-2.501,0.077,15.01,1.5,13.16,12.36,12.3,0.03,21.03,1.47,nan,nan,nan,nan,0,-70.88,-6.512 +289.417,-30.116,-0.202,0.128,-2.568,0.275,-1.051,0.236,17.09,1.41,15.43,14.81,14.68,0.11,28.3,1.16,nan,nan,nan,nan,0,-4.79,0.409 +337.4149,-28.8691,0.008,0.143,-2.04,0.241,-2.883,0.199,17.41,1.17,16.09,15.36,15.32,0.02,21.28,4.42,nan,nan,nan,nan,0,-46.23,2.686 +277.9013,-31.9716,0.036,0.069,-2.514,0.093,-1.122,0.082,15.65,1.78,13.44,12.59,12.41,0.17,27.31,2.18,nan,nan,nan,nan,0,4.609,3.944 +285.233,-31.6348,-0.045,0.061,-2.724,0.109,-1.543,0.101,15.87,1.63,13.89,13.08,12.96,0.12,26.24,0.89,nan,nan,nan,nan,0,-1.452,2.424 +277.0241,-25.8272,-0.058,0.095,-2.743,0.142,-1.294,0.129,15.87,2.16,13.02,12.15,11.9,0.38,27.06,2.03,nan,nan,nan,nan,0,6.734,-1.873 +279.388,-32.6411,0.024,0.056,-2.846,0.102,-1.575,0.091,16.11,1.5,14.17,13.45,13.29,0.12,26.52,1.18,nan,nan,nan,nan,0,3.234,4.328 +284.4497,-30.0031,-0.116,0.087,-2.452,0.131,-1.242,0.112,16.56,1.53,14.61,13.95,13.82,0.14,28.48,2.05,nan,nan,nan,nan,0,-0.521,0.925 +31.9132,-10.2751,0.068,0.07,-0.287,0.095,-2.324,0.07,15.08,1.5,13.24,12.53,12.41,0.02,25.99,0.59,nan,nan,nan,nan,0,-100.333,7.968 +282.5732,-30.2469,-0.093,0.137,-2.312,0.28,-1.141,0.258,17.83,1.29,16.35,15.56,15.25,0.17,26.91,0.6,nan,nan,nan,nan,0,1.035,1.448 +231.8021,-8.0636,-0.04,0.151,-1.203,0.255,-0.324,0.209,17.6,1.46,15.82,15.12,15.04,0.1,46.18,3.68,nan,nan,nan,nan,0,53.224,-2.04 +260.1489,-27.0048,0.086,0.114,-1.947,0.246,-1.058,0.176,17.44,2.91,13.84,12.88,12.5,1.37,32.97,3.29,nan,nan,nan,nan,0,20.85,3.641 +286.5484,-31.6938,-0.047,0.056,-2.54,0.09,-1.343,0.089,15.27,2.19,12.72,11.85,11.58,0.11,27.74,3.6,122.87,2.17,nan,nan,6,-2.568,2.309 +135.3405,20.6368,-0.048,0.057,-2.139,0.088,-2.561,0.065,15.83,1.18,14.32,13.78,13.71,0.03,17.77,1.8,nan,nan,nan,nan,0,151.853,9.269 +282.8897,-29.3956,0.001,0.068,-2.777,0.147,-1.658,0.114,14.65,2.64,11.75,10.83,10.51,0.16,27.56,1.39,144.07,1.02,nan,nan,6,0.919,0.561 +298.3108,-34.6097,-0.012,0.054,-2.468,0.078,-1.648,0.048,15.57,2.32,12.9,11.99,11.72,0.14,27.44,1.39,nan,nan,nan,nan,0,-12.665,4.236 +287.394,-30.3652,0.011,0.179,-2.594,0.282,-1.224,0.261,17.92,1.24,16.51,15.54,15.29,0.12,28.08,0.55,nan,nan,nan,nan,0,-3.091,0.889 +283.3003,-30.7114,-0.031,0.055,-2.794,0.083,-1.637,0.071,14.94,2.08,12.38,11.52,11.37,0.14,25.44,0.64,146.84,0.01,-0.63,0.02,1,0.335,1.793 +284.1065,-30.6134,0.062,0.065,-2.91,0.087,-1.361,0.081,15.59,1.82,13.25,12.45,12.26,0.16,26.66,0.65,143.81,0.03,-0.54,0.02,1,-0.331,1.576 +282.4478,-32.0595,0.08,0.067,-2.64,0.119,-1.322,0.1,16.3,1.59,14.3,13.57,13.38,0.14,26.09,0.81,nan,nan,nan,nan,0,0.809,3.25 +294.7225,-32.8028,-0.131,0.151,-2.615,0.207,-1.886,0.192,17.27,1.49,15.45,14.73,14.65,0.18,26.02,0.76,nan,nan,nan,nan,0,-9.567,2.621 +284.8983,-31.0585,-0.181,0.204,-2.208,0.371,-1.809,0.33,18.14,1.3,16.61,15.84,15.6,0.13,26.49,2.0,nan,nan,nan,nan,0,-1.076,1.902 +238.9511,-17.9681,0.033,0.158,-1.113,0.313,-0.529,0.185,17.83,1.52,15.89,15.13,15.03,0.27,45.01,2.71,nan,nan,nan,nan,0,42.417,3.419 +282.8621,-29.9158,-0.193,0.088,-2.559,0.172,-1.483,0.155,16.39,1.58,14.4,13.63,13.51,0.17,26.28,0.82,nan,nan,nan,nan,0,0.849,1.077 +319.8843,-30.3356,0.014,0.052,-2.911,0.073,-2.927,0.06,14.72,1.68,12.61,11.87,11.71,0.1,26.46,4.45,nan,nan,nan,nan,0,-31.034,0.858 +286.7705,-31.1873,0.04,0.044,-2.566,0.076,-1.199,0.071,15.36,1.78,13.11,12.28,12.12,0.1,27.55,1.7,154.92,1.86,nan,nan,6,-2.68,1.78 +305.6515,-29.709,-0.461,0.21,-2.318,0.331,-2.319,0.229,18.16,1.18,16.52,15.89,15.81,0.08,23.88,4.91,nan,nan,nan,nan,0,-18.808,-0.754 +283.4837,-32.6697,0.065,0.17,-2.932,0.33,-1.534,0.352,17.9,1.16,16.41,15.71,15.57,0.11,26.77,0.49,nan,nan,nan,nan,0,-0.163,3.693 +284.8411,-31.7051,0.075,0.043,-2.936,0.067,-1.488,0.056,14.7,2.11,12.2,11.3,11.06,0.12,27.9,0.87,nan,nan,nan,nan,0,-1.134,2.548 +284.0883,-28.7003,0.005,0.061,-2.464,0.126,-1.597,0.113,16.01,1.83,13.75,12.97,12.71,0.18,28.88,2.74,nan,nan,nan,nan,0,0.009,-0.307 +225.4097,-7.3517,-0.121,0.09,-0.854,0.18,-0.55,0.142,16.79,1.73,14.65,13.9,13.7,0.09,51.05,4.22,nan,nan,nan,nan,0,59.096,0.443 +285.4773,-30.7016,0.073,0.068,-2.665,0.087,-1.308,0.075,15.6,1.87,13.3,12.45,12.27,0.15,25.91,0.67,nan,nan,nan,nan,0,-1.509,1.47 +289.5804,-30.8686,-0.135,0.082,-2.476,0.122,-1.624,0.122,16.56,1.48,14.64,13.97,13.9,0.11,26.47,0.42,nan,nan,nan,nan,0,-5.023,1.139 +283.7756,-30.3652,-0.162,0.076,-2.905,0.145,-1.664,0.131,16.74,1.5,14.78,14.11,13.97,0.15,27.21,1.31,nan,nan,nan,nan,0,-0.008,1.38 +283.2443,-29.5904,-0.053,0.06,-2.64,0.119,-1.32,0.103,16.32,1.77,14.12,13.34,13.16,0.17,27.52,1.77,nan,nan,nan,nan,0,0.581,0.698 +237.9703,-12.1649,0.084,0.112,-1.409,0.241,-0.69,0.15,17.41,1.65,15.32,14.54,14.42,0.2,44.79,1.49,nan,nan,nan,nan,0,45.932,-1.296 +276.7243,-29.058,-0.013,0.133,-2.512,0.191,-0.817,0.175,16.91,1.9,14.62,13.78,13.63,0.38,28.01,1.83,nan,nan,nan,nan,0,6.255,1.333 +288.2449,-32.4059,0.04,0.039,-2.539,0.064,-1.268,0.06,15.33,1.66,13.24,12.53,12.33,0.11,27.59,0.41,nan,nan,nan,nan,0,-4.094,2.81 +285.7819,-33.1427,0.02,0.035,-2.87,0.074,-1.367,0.066,14.99,2.22,12.37,11.5,11.27,0.08,26.54,1.0,166.97,1.16,nan,nan,6,-2.149,3.84 +291.5706,-29.7881,-0.068,0.038,-2.392,0.071,-1.557,0.064,15.04,2.02,12.61,11.74,11.54,0.11,26.53,0.61,nan,nan,nan,nan,0,-6.606,-0.132 +286.4367,-32.0135,0.088,0.109,-2.955,0.2,-1.463,0.169,17.06,1.41,15.22,14.57,14.48,0.11,26.83,0.99,nan,nan,nan,nan,0,-2.522,2.64 +174.4211,14.298,-0.054,0.067,-1.756,0.094,-1.138,0.082,15.53,1.52,13.63,12.94,12.77,0.03,33.06,1.27,-48.83,0.68,-1.19,0.03,5,113.969,5.622 +283.2611,-29.0559,0.014,0.102,-2.428,0.147,-1.347,0.134,16.31,1.61,14.3,13.54,13.45,0.18,26.31,0.32,nan,nan,nan,nan,0,0.661,0.169 +283.0842,-30.504,-0.058,0.051,-2.626,0.093,-1.315,0.082,15.91,1.52,13.94,13.32,13.2,0.16,27.53,1.29,nan,nan,nan,nan,0,0.555,1.621 +196.2366,2.0362,-0.162,0.071,-1.188,0.125,-0.464,0.071,15.32,1.92,13.05,12.2,12.02,0.02,41.23,7.14,nan,nan,nan,nan,0,89.06,6.952 +154.0525,23.1375,-0.141,0.156,-2.226,0.232,-1.558,0.261,17.51,1.05,16.15,15.57,15.51,0.03,22.11,0.43,nan,nan,nan,nan,0,135.135,3.648 +350.4236,-20.4946,0.067,0.038,-1.236,0.07,-2.331,0.052,14.99,1.58,13.07,12.31,12.18,0.03,19.07,5.16,nan,nan,nan,nan,0,-60.127,-1.354 +6.1006,-19.6589,0.078,0.046,-1.686,0.074,-3.852,0.056,15.32,1.37,13.6,12.94,12.79,0.02,25.37,1.53,nan,nan,nan,nan,0,-73.887,3.959 +284.4984,-30.5236,0.054,0.046,-2.729,0.091,-1.785,0.078,15.72,1.66,13.65,12.88,12.72,0.14,29.1,3.27,132.44,nan,nan,nan,4,-0.649,1.431 +288.6425,-26.4886,-0.287,0.222,-2.415,0.355,-1.685,0.326,18.0,1.28,16.44,15.69,15.45,0.12,26.59,1.08,nan,nan,nan,nan,0,-3.649,-3.101 +234.4521,-14.1324,0.045,0.08,-1.048,0.169,-0.489,0.11,16.13,2.44,13.3,12.38,12.12,0.15,46.58,3.33,nan,nan,nan,nan,0,48.038,2.047 +293.5006,-33.2313,-0.023,0.116,-2.488,0.173,-1.61,0.148,17.03,1.46,15.2,14.5,14.38,0.12,27.96,1.96,nan,nan,nan,nan,0,-8.581,3.134 +286.6332,-28.886,0.018,0.072,-2.911,0.111,-1.532,0.092,16.23,1.61,14.17,13.36,13.23,0.17,26.97,0.45,nan,nan,nan,nan,0,-2.222,-0.478 +282.1828,-29.3817,-0.105,0.179,-2.617,0.26,-1.645,0.239,17.77,1.39,16.01,15.26,15.12,0.15,27.39,0.65,nan,nan,nan,nan,0,1.527,0.66 diff --git a/gala/source/tests/coordinates/c_pm.npy b/gala/source/tests/coordinates/c_pm.npy new file mode 100644 index 0000000000000000000000000000000000000000..3aab19956b9e7477a6ac6ab0e266030184e363a1 --- /dev/null +++ b/gala/source/tests/coordinates/c_pm.npy @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7d776863725d8298281618159b2ab058fb4349dafefc656464b97e462eb5bf6 +size 2176 diff --git a/gala/source/tests/coordinates/gd1_coord.txt b/gala/source/tests/coordinates/gd1_coord.txt new file mode 100644 index 0000000000000000000000000000000000000000..51f6ada8c05fab2e807715fe136961c1b96824de --- /dev/null +++ b/gala/source/tests/coordinates/gd1_coord.txt @@ -0,0 +1,24 @@ +# ra dec phi1 phi2 +09:41:05.35 +31:51:11.6 -45.23 -0.04 +09:47:05.26 +33:29:39.8 -43.17 -0.09 +09:57:40.48 +36:23:33.0 -39.54 -0.07 +09:59:10.43 +36:32:06.6 -39.25 -0.22 +10:02:22.01 +37:41:13.3 -37.95 0.00 +10:02:22.02 +37:40:49.2 -37.96 -0.00 +10:10:33.02 +39:33:00.8 -35.49 -0.05 +10:11:10.08 +39:44:53.9 -35.27 -0.02 +10:12:54.83 +39:55:25.6 -34.92 -0.15 +10:13:12.05 +40:06:13.3 -34.74 -0.08 +10:17:02.15 +40:47:47.3 -33.74 -0.18 +10:19:51.76 +41:27:01.5 -32.90 -0.15 +10:22:16.20 +41:55:34.7 -32.25 -0.17 +10:30:03.87 +43:43:51.7 -29.95 -0.00 +10:43:41.92 +46:02:24.7 -26.61 -0.11 +10:48:40.98 +46:49:22.1 -25.45 -0.14 +10:50:36.96 +47:20:00.1 -24.86 0.01 +11:07:11.27 +49:44:15.9 -21.21 -0.02 +11:42:42.08 +53:38:41.4 -14.47 -0.15 +11:47:24.59 +53:55:46.8 -13.73 -0.28 +11:51:16.08 +54:21:42.7 -13.02 -0.21 +11:53:26.06 +54:29:30.6 -12.68 -0.26 +11:54:04.06 +54:35:11.4 -12.55 -0.23 diff --git a/gala/source/tests/coordinates/idl_vgsr_vhel.txt b/gala/source/tests/coordinates/idl_vgsr_vhel.txt new file mode 100644 index 0000000000000000000000000000000000000000..77f9da480a3ff9345f795f44a551318e5ed3859d --- /dev/null +++ b/gala/source/tests/coordinates/idl_vgsr_vhel.txt @@ -0,0 +1,104 @@ +# Generated on Pluto with: +# cd ~/idl; .run gen_vgsr_vlsr.pro +vgsr vhelio lon lat vx vy vz vcirc +145.27689 -9.7038757 133.81944 8.3894563 10.000000 5.2500000 7.1700000 220.00000 +169.04614 304.52197 -140.86899 -28.226425 10.000000 5.2500000 7.1700000 220.00000 +133.27568 330.82190 -86.572759 -30.131925 10.000000 5.2500000 7.1700000 220.00000 +87.451613 95.819095 -176.67310 -86.998226 10.000000 5.2500000 7.1700000 220.00000 +112.44316 51.358363 134.93058 69.092835 10.000000 5.2500000 7.1700000 220.00000 +168.52828 158.93319 62.899926 89.322302 10.000000 5.2500000 7.1700000 220.00000 +103.84485 131.61956 -36.095688 -80.436729 10.000000 5.2500000 7.1700000 220.00000 +82.072800 96.121045 -67.277774 84.037986 10.000000 5.2500000 7.1700000 220.00000 +50.947523 -152.88408 79.862452 -22.441270 10.000000 5.2500000 7.1700000 220.00000 +120.87593 142.44635 -174.48969 -61.029715 10.000000 5.2500000 7.1700000 220.00000 +17.195083 9.5552317 175.16499 -9.9948549 10.000000 5.2500000 7.1700000 220.00000 +76.202875 -65.174392 83.613896 -49.262701 10.000000 5.2500000 7.1700000 220.00000 +137.01190 140.71996 -61.775447 86.784600 10.000000 5.2500000 7.1700000 220.00000 +60.469735 33.176020 115.85167 -80.025637 10.000000 5.2500000 7.1700000 220.00000 +161.75715 209.64615 -15.593086 13.336673 10.000000 5.2500000 7.1700000 220.00000 +188.07985 25.653101 97.646441 -41.133322 10.000000 5.2500000 7.1700000 220.00000 +46.447626 -30.983915 102.24765 71.090587 10.000000 5.2500000 7.1700000 220.00000 +73.815143 258.81175 -127.24733 1.9942224 10.000000 5.2500000 7.1700000 220.00000 +51.664239 10.716315 37.137716 -70.644772 10.000000 5.2500000 7.1700000 220.00000 +170.25570 203.80804 -10.905401 -10.483135 10.000000 5.2500000 7.1700000 220.00000 +77.269733 -14.850929 99.013510 -63.508146 10.000000 5.2500000 7.1700000 220.00000 +78.440696 78.793989 -109.96917 87.997001 10.000000 5.2500000 7.1700000 220.00000 +107.48701 138.66585 -172.87862 -46.689373 10.000000 5.2500000 7.1700000 220.00000 +63.167393 -70.302844 34.186556 -5.6682158 10.000000 5.2500000 7.1700000 220.00000 +4.4647414 -17.844732 64.992800 -81.886292 10.000000 5.2500000 7.1700000 220.00000 +41.314867 -107.06390 40.135846 16.818459 10.000000 5.2500000 7.1700000 220.00000 +72.410160 -84.928541 105.10391 -41.045592 10.000000 5.2500000 7.1700000 220.00000 +160.79572 261.79841 -145.03000 -45.711338 10.000000 5.2500000 7.1700000 220.00000 +152.14952 159.38269 -11.952353 67.889714 10.000000 5.2500000 7.1700000 220.00000 +196.36809 379.21745 -76.688540 30.694277 10.000000 5.2500000 7.1700000 220.00000 +171.36635 232.74057 -60.954552 69.240013 10.000000 5.2500000 7.1700000 220.00000 +183.62998 -7.6627794 72.358081 30.451484 10.000000 5.2500000 7.1700000 220.00000 +130.48239 143.47340 -121.13025 -88.314268 10.000000 5.2500000 7.1700000 220.00000 +102.75310 -112.08993 94.981227 -14.291722 10.000000 5.2500000 7.1700000 220.00000 +52.358925 -39.194184 40.822749 -51.139823 10.000000 5.2500000 7.1700000 220.00000 +30.049574 26.190235 -3.1199348 48.467549 10.000000 5.2500000 7.1700000 220.00000 +57.134712 84.472978 -10.235331 -41.575597 10.000000 5.2500000 7.1700000 220.00000 +149.02765 201.12182 -159.78448 48.768793 10.000000 5.2500000 7.1700000 220.00000 +98.847246 -38.562605 139.01894 -8.8301486 10.000000 5.2500000 7.1700000 220.00000 +10.030560 145.93902 -47.149072 -33.558190 10.000000 5.2500000 7.1700000 220.00000 +1.9450342 27.967519 -140.83172 77.286233 10.000000 5.2500000 7.1700000 220.00000 +1.8910473 -52.645474 139.70198 69.738293 10.000000 5.2500000 7.1700000 220.00000 +120.37269 87.554328 13.617768 -52.176325 10.000000 5.2500000 7.1700000 220.00000 +52.171779 -122.69728 91.245596 -37.199509 10.000000 5.2500000 7.1700000 220.00000 +125.68437 45.764112 43.554204 63.089311 10.000000 5.2500000 7.1700000 220.00000 +17.516626 69.923546 -35.796461 61.646937 10.000000 5.2500000 7.1700000 220.00000 +98.616129 221.55214 -144.23160 25.616620 10.000000 5.2500000 7.1700000 220.00000 +85.392064 277.43480 -66.331394 -20.451608 10.000000 5.2500000 7.1700000 220.00000 +28.926778 253.53562 -88.103957 -4.7928768 10.000000 5.2500000 7.1700000 220.00000 +171.44415 88.082246 153.27198 -21.441295 10.000000 5.2500000 7.1700000 220.00000 +63.469237 253.17133 -61.092739 -11.888114 10.000000 5.2500000 7.1700000 220.00000 +56.194806 34.083141 96.410973 86.149206 10.000000 5.2500000 7.1700000 220.00000 +69.060171 185.36066 -145.46325 -34.332013 10.000000 5.2500000 7.1700000 220.00000 +88.575321 212.90764 -137.15085 36.745963 10.000000 5.2500000 7.1700000 220.00000 +182.83077 95.538532 118.99421 65.140976 10.000000 5.2500000 7.1700000 220.00000 +154.12424 204.18126 -44.269710 67.803186 10.000000 5.2500000 7.1700000 220.00000 +103.77641 59.016087 41.335330 76.001465 10.000000 5.2500000 7.1700000 220.00000 +184.73721 234.61932 -169.85702 -3.8651544 10.000000 5.2500000 7.1700000 220.00000 +110.74874 322.56280 -75.752642 9.3689454 10.000000 5.2500000 7.1700000 220.00000 +61.611992 -33.845084 22.953701 -6.9955122 10.000000 5.2500000 7.1700000 220.00000 +59.878671 -128.92697 106.11553 -26.030983 10.000000 5.2500000 7.1700000 220.00000 +50.308847 27.560162 18.396842 -68.582552 10.000000 5.2500000 7.1700000 220.00000 +180.82055 252.34830 -116.69878 67.671179 10.000000 5.2500000 7.1700000 220.00000 +65.158081 84.158616 -7.0217550 -24.356196 10.000000 5.2500000 7.1700000 220.00000 +50.380176 -100.14121 71.356223 -44.119935 10.000000 5.2500000 7.1700000 220.00000 +157.36456 228.76542 -93.951886 69.720097 10.000000 5.2500000 7.1700000 220.00000 +120.85403 229.01125 -100.78955 -62.863590 10.000000 5.2500000 7.1700000 220.00000 +127.49174 114.69633 123.77944 -83.704010 10.000000 5.2500000 7.1700000 220.00000 +92.034221 149.84488 -156.13862 -58.982804 10.000000 5.2500000 7.1700000 220.00000 +111.88915 -36.563154 124.11742 -32.634223 10.000000 5.2500000 7.1700000 220.00000 +193.73622 183.31131 175.52644 39.505881 10.000000 5.2500000 7.1700000 220.00000 +80.316168 299.36763 -87.484785 -14.712190 10.000000 5.2500000 7.1700000 220.00000 +136.31881 327.07343 -113.60722 23.078424 10.000000 5.2500000 7.1700000 220.00000 +65.155220 53.284689 129.85561 88.381330 10.000000 5.2500000 7.1700000 220.00000 +12.517159 56.956210 -148.86380 -72.498268 10.000000 5.2500000 7.1700000 220.00000 +11.832282 58.384103 -46.667122 -75.383374 10.000000 5.2500000 7.1700000 220.00000 +35.120487 94.150053 -104.02142 72.655152 10.000000 5.2500000 7.1700000 220.00000 +157.37199 -56.441566 104.49897 -5.6619394 10.000000 5.2500000 7.1700000 220.00000 +158.67301 -18.910976 119.51456 -19.625375 10.000000 5.2500000 7.1700000 220.00000 +197.48440 193.19573 -2.1077549 22.175560 10.000000 5.2500000 7.1700000 220.00000 +150.88863 68.111994 133.41778 60.778180 10.000000 5.2500000 7.1700000 220.00000 +156.35009 26.846421 62.251518 52.635691 10.000000 5.2500000 7.1700000 220.00000 +165.21263 126.88079 129.66011 79.203004 10.000000 5.2500000 7.1700000 220.00000 +190.70699 224.22333 -16.306844 -59.351988 10.000000 5.2500000 7.1700000 220.00000 +11.083476 -105.34446 106.74743 -54.924197 10.000000 5.2500000 7.1700000 220.00000 +79.980648 9.4684636 136.54562 -58.733007 10.000000 5.2500000 7.1700000 220.00000 +85.608834 -4.1354889 82.771769 68.301766 10.000000 5.2500000 7.1700000 220.00000 +117.99765 -36.171932 43.793886 -16.696665 10.000000 5.2500000 7.1700000 220.00000 +12.968862 56.345430 -171.60231 -10.679145 10.000000 5.2500000 7.1700000 220.00000 +190.09255 337.34509 -109.10203 -48.980455 10.000000 5.2500000 7.1700000 220.00000 +185.22285 131.08997 13.086133 -21.007415 10.000000 5.2500000 7.1700000 220.00000 +17.237936 8.9755399 14.344261 89.043417 10.000000 5.2500000 7.1700000 220.00000 +56.840372 64.661369 178.27013 -70.898742 10.000000 5.2500000 7.1700000 220.00000 +70.039821 217.82366 -135.76793 -28.515905 10.000000 5.2500000 7.1700000 220.00000 +197.89181 326.80680 -147.69186 -5.7187754 10.000000 5.2500000 7.1700000 220.00000 +66.007274 109.55954 -155.52948 60.891627 10.000000 5.2500000 7.1700000 220.00000 +21.133356 155.62241 -94.341375 -55.204746 10.000000 5.2500000 7.1700000 220.00000 +40.521133 -2.7274873 101.62888 -76.717379 10.000000 5.2500000 7.1700000 220.00000 +168.93314 144.51827 158.95423 75.875916 10.000000 5.2500000 7.1700000 220.00000 +182.01933 27.937780 116.30215 -36.692217 10.000000 5.2500000 7.1700000 220.00000 +105.36193 308.93934 -76.388454 -21.929778 10.000000 5.2500000 7.1700000 220.00000 diff --git a/gala/source/tests/coordinates/pm_cov.npy b/gala/source/tests/coordinates/pm_cov.npy new file mode 100644 index 0000000000000000000000000000000000000000..e487d543ffaa983baa20ad5d57cba6e75e6129fd --- /dev/null +++ b/gala/source/tests/coordinates/pm_cov.npy @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a5eeaaf33bd0790837c9470d6ad2d0af85288a932e8e33f4e73a9fa2fd71b48 +size 2176 diff --git a/gala/source/tests/coordinates/sergey_orphan.txt b/gala/source/tests/coordinates/sergey_orphan.txt new file mode 100644 index 0000000000000000000000000000000000000000..802248bf474227e355391b502ab942bea7c9e897 --- /dev/null +++ b/gala/source/tests/coordinates/sergey_orphan.txt @@ -0,0 +1,114 @@ +ra dec heldist pmra pmdec +331.656268727199 -39.1494735064355 48.94275665283203 0.275502094564918 -1.90136163958481 +293.207792053576 -72.1501688676437 23.54404067993164 -1.3184361011096 -3.05581782891287 +276.187586857079 -74.2053120701785 22.482805252075195 -2.73889205791694 -2.93815838761522 +150.647210839262 25.2475372502567 30.127817153930664 -1.14154408128601 0.11556699031599 +337.491208968931 -35.9809553138202 65.90168762207031 0.308802278558686 -2.39162424650324 +321.186789848392 -58.6864066475143 32.219417572021484 -0.0201789209868324 -3.1351474530663 +312.652439082338 -64.4924261188689 26.433963775634766 -0.294161055956984 -3.17663203568946 +151.355318100332 31.8214671652644 32.701927185058594 -1.38986708164431 -0.0624748870333711 +328.873164663832 -46.2751928589657 41.209957122802734 0.58055590481148 -2.18649050997115 +321.6763394678 -57.4584922673689 33.40642166137695 0.21922310431726 -2.59203742820928 +139.253641805087 59.99006112678 51.32103729248047 -1.05569944272779 -0.529217864574912 +163.830931646923 -7.17632329663041 20.638774871826172 -2.24310351642533 0.952962244335287 +227.707942296213 -74.5751407345744 20.46157455444336 -4.49398440731588 -0.0504445582946376 +322.460826733656 -53.3119852345322 31.669666290283203 -1.19697275065527 -2.49199751931073 +144.271652120049 42.6033461257059 40.673301696777344 -0.921642544189141 -0.329184060812681 +333.931325597367 -36.1278818683978 51.142250061035156 0.392143079100081 -1.29620938267014 +331.024930582206 -46.4820572528627 40.502647399902344 0.646912650955056 -1.90001440775087 +303.093499650652 -69.342947548902 24.661487579345703 -0.971822585078517 -3.44621427192038 +305.875180966415 -69.2976345947213 25.77305793762207 -0.888956244162022 -3.47780110580793 +167.347752830898 -15.075464717577 16.240188598632812 -2.2892922821692 2.06937239165835 +151.505215906905 21.8255435446019 27.48371696472168 -0.785819004590304 -0.333674055085537 +152.071670360829 23.9278787869736 30.73844337463379 -1.18730646461807 -0.353085892381391 +164.396123120276 -11.1175043906765 18.07723617553711 -2.11267126036593 1.5123454120638 +173.696689912493 -39.1855527815594 16.976722717285156 -2.6425840000258 2.77581360897632 +145.026602904993 37.0093221822231 37.711158752441406 -0.879038660753651 -0.301319747126616 +329.882858144102 -42.1832806382058 43.935791015625 0.227479963240135 -1.67182318539629 +247.349266645387 -75.0976666324103 19.71449851989746 -3.82435532776039 -1.4917599122383 +141.251727462669 47.4911157524096 45.669349670410156 -0.278242985109598 -0.307294228328076 +319.785515336155 -59.6393302694962 31.54485511779785 0.047739431612579 -2.40414998706106 +327.339698353149 -51.3576679680571 35.93385696411133 -0.220008264417831 -2.51701058290811 +152.582826821556 30.0942988810024 28.22689437866211 -0.58865006685347 -0.340007941899313 +320.988534580899 -57.7146011478339 29.099069595336914 -0.00958390800105136 -2.74560738568651 +220.852803879764 -66.7156233432215 17.022340774536133 -5.18777178618725 -0.97646436084972 +167.721883134802 -12.6323511530982 15.793251037597656 -2.83127757994372 1.54462685609338 +147.546673452101 40.7438838995295 37.3227653503418 -0.821214597441585 -0.480256930835946 +167.131444430117 -14.7717672020525 17.761720657348633 -2.1216105451589 1.7433114677517 +331.186545167623 -43.0982906857441 50.890968322753906 -0.610206067378906 -1.41189295152624 +328.194713983779 -54.7179905020298 38.57564926147461 -0.0462423261285688 -1.62430832304693 +152.216507095956 24.6900514244275 30.889453887939453 -0.651694950266064 -0.416279187880993 +158.493803400453 9.23572867234607 21.911914825439453 -1.46320930150025 0.257714602568118 +133.767250366739 63.421955667313 60.80267333984375 0.112499522111556 0.316383090930176 +267.792298438136 -76.5683647906811 22.964237213134766 -3.34683189339476 -2.67084505548613 +175.508519462991 -35.6355189006247 16.952136993408203 -3.63595288158065 2.63146918902653 +175.71430365816 -35.9059737865278 14.887802124023438 -3.23593395562877 2.67912110577346 +146.05782226 40.220710513616 35.53816223144531 -0.490328757988808 -0.614686197975269 +267.042326066391 -75.535904972213 22.184555053710938 -3.23887574250766 -2.50421707762825 +304.197528543648 -68.8783485676048 26.610563278198242 -1.03126733560467 -3.68941994712539 +331.528469068592 -40.1526580883535 49.20958709716797 0.771884448238906 -1.87558926441079 +334.320920119124 -36.9923997777259 53.25858688354492 -1.73868585679 -1.42629873074948 +176.611558291875 -42.0901937221425 16.994598388671875 -2.98034342610662 2.81674576466133 +268.911282774028 -74.9033693787264 22.947551727294922 -2.94603037254696 -2.74431509606787 +150.544325762388 24.2579579140146 28.53543472290039 -0.922626635857103 0.0551499801293074 +327.549050560892 -51.2316417356703 39.85669708251953 -0.130252415711607 -2.40474378525471 +277.274979991097 -74.5129426188047 23.65250015258789 -2.64959081741315 -3.3237918256725 +334.471022977277 -40.034551896847 47.84756851196289 -0.699663676139994 -2.42654873147082 +141.131353916363 49.3827280774458 44.929603576660156 -0.945642339734397 -0.177452337927581 +171.190128111526 -27.7661291336537 16.469024658203125 -2.88244358402989 2.61476835724803 +327.123824545822 -52.0011500934603 36.05494689941406 0.318899686944192 -1.95924813829475 +144.528109788428 37.5810665084326 42.98106384277344 -0.986676828657994 -0.36530337233171 +143.482582947134 39.1340164001215 43.57024002075195 -0.491155290464352 -0.395145775049435 +147.342795310205 33.3916195331805 38.829017639160156 -0.890086521291787 -0.349427851524645 +171.583614704294 -22.2226913600165 16.18654441833496 -2.7299976317042 1.98888975579952 +320.693552236731 -57.2344959529479 30.61488151550293 -0.041732230070479 -2.20466059702778 +241.991046389872 -75.4177099762491 21.731552124023438 -4.27061010736551 -1.02269959798722 +248.453690386539 -76.0054384119753 22.36843490600586 -3.9289132586056 -1.78723558624701 +169.221733207832 -19.063533544435 15.636909484863281 -2.46677738074977 2.11037669561487 +304.191404303552 -70.2919997010971 25.4582462310791 -0.991560747640472 -3.56114107437359 +318.52474048703 -62.094476954987 28.488649368286133 -0.13792348942345 -2.74599580635714 +323.55587264487 -53.2674825396641 34.99247360229492 0.0831773389682677 -1.93242207864066 +144.295039256419 43.4294338181809 42.850379943847656 -0.722842507984231 -0.480100444878437 +322.958291893299 -48.219714300802 31.142953872680664 0.463740748369967 -2.24300403048729 +335.156303783494 -33.6112047454209 71.57916259765625 -0.474563452735716 -1.73660771730043 +167.30175659002 -17.8882894537849 16.78110694885254 -1.98405160997099 1.81563886372094 +154.824907507749 18.2260249614617 26.938472747802734 -1.11771922828731 0.145513652389975 +207.095243805969 -69.2916071233026 19.035900115966797 -4.62431522020742 1.50199462500962 +253.1748346874 -75.8355883197752 21.842988967895508 -3.62503586421494 -1.60231398573103 +334.263622909749 -42.9058343330995 58.21311569213867 0.0392271270893364 -1.63070161603448 +114.553547848371 74.6587943369533 65.52909088134766 -0.877267840342211 0.853007624993862 +178.474275795131 -43.6816569706336 16.64564323425293 -3.17291182925756 2.84282912830923 +329.95072931208 -46.9623355161414 39.56492233276367 -0.0285605332440401 -2.61606556406385 +330.337830168447 -40.4510112504214 45.7772331237793 -0.19345196182297 -2.37985170079177 +170.885072935041 -25.0127518925711 16.357250213623047 -2.81841890440791 2.26621859788188 +151.892513608016 24.8314953198482 28.448436737060547 -1.04019302641556 -0.415071605841914 +294.105776263919 -70.9383939646023 25.22552490234375 -1.47870744415329 -3.29851132823831 +251.05257440862 -75.4327557866322 20.823348999023438 -3.88485576084044 -1.75632189659296 +262.382973531405 -75.112112612984 21.694849014282227 -3.44672014508187 -2.48450984862277 +325.476833773279 -51.8413860676659 37.11676025390625 0.415320207444506 -2.13989036858248 +328.79860468495 -40.2413375529223 39.092071533203125 0.241141208180398 -1.69217293341234 +174.223814490934 -39.6480955046629 16.168621063232422 -3.44019687847852 2.3912615210134 +335.047994202228 -37.761164463241 53.560089111328125 -0.63886325827331 -2.6173286275245 +293.888023000471 -72.8506477956235 24.643474578857422 -1.74487701840178 -3.46839127156476 +147.378997866161 38.736922992096 38.51921081542969 -0.574023368857901 -0.0972016594724718 +334.171079321919 -32.9669577313105 62.985260009765625 -0.146607454911846 -1.78339941217577 +333.465929342388 -29.9407695798036 58.635494232177734 -0.739486743788352 -0.972410346793908 +143.085975568686 48.6342412605024 46.698524475097656 -0.489004199766334 -0.306851373213502 +321.894898188485 -59.0889600406313 30.41132354736328 0.635148021296196 -2.9054525070692 +145.985156824802 43.113887013814 42.95452880859375 -1.18614435719525 -0.251294436840299 +150.57982949547 26.5980017685555 31.408964157104492 -0.76280730319545 -0.419197368507948 +229.579888093682 -70.684555715814 18.78251075744629 -5.86788930045218 -0.293514674482599 +142.596425247819 49.4408534432069 47.05882263183594 0.437290016028731 -0.034100016898537 +160.481467351754 0.588999297660749 19.39857292175293 -1.76072785444136 1.0732018487449 +206.993603024506 -69.2417779644621 20.374000549316406 -4.06902503388389 1.52594368287413 +332.192457544903 -38.9627922666303 49.94127655029297 0.178330606808803 -2.04361295006038 +164.408852142303 -7.64784553896387 18.94601058959961 -2.03320809763093 1.37753758006076 +140.409682708163 48.0145216867802 44.545806884765625 -0.406949849029145 -0.284396943764083 +173.14521858307 -32.1904810471076 16.879117965698242 -2.81165551145129 2.45360717035461 +154.125880106466 15.4385626491427 24.616390228271484 -1.61996798974885 0.294800883921636 +324.074335285375 -56.6715091653565 30.79861068725586 0.165515070836532 -2.70655223244457 +166.804842876522 -13.8978067021033 17.22536277770996 -2.19343555621679 1.67887067837575 +334.188789946946 -35.1367135276638 57.822120666503906 -0.0179573960051011 -1.26352830175094 +315.741834661905 -61.1982758908799 30.109464645385742 -0.00842909925511814 -3.11197268012093 +154.518851528449 17.1944519367271 24.422061920166016 -0.780532917511822 -0.132296479647955 +132.383725246011 63.2921537387669 64.04721069335938 -0.710689651513906 -0.619402328738766 diff --git a/gala/source/tests/coordinates/test_all_streamframes.py b/gala/source/tests/coordinates/test_all_streamframes.py new file mode 100644 index 0000000000000000000000000000000000000000..a24c64375f5c3e7b3671998ca320d209be8d82f3 --- /dev/null +++ b/gala/source/tests/coordinates/test_all_streamframes.py @@ -0,0 +1,38 @@ +import astropy.units as u +import numpy as np +import pytest + +from gala.coordinates import ( + GD1Koposov10, + MagellanicStreamNidever08, + OphiuchusPriceWhelan16, + OrphanKoposov19, + OrphanNewberg10, + Pal5PriceWhelan18, + SagittariusLaw10, +) + +stream_frames = ( + GD1Koposov10, + MagellanicStreamNidever08, + OphiuchusPriceWhelan16, + OrphanKoposov19, + OrphanNewberg10, + Pal5PriceWhelan18, + SagittariusLaw10, +) + + +@pytest.mark.parametrize("frame_cls", stream_frames) +def test_wrapping(frame_cls): + c = frame_cls([-60, 300] * u.deg, [15, -15] * u.deg) + lon_name = next(iter(c.get_representation_component_names().keys())) + lat_name = list(c.get_representation_component_names().keys())[1] + assert np.allclose(getattr(c, lon_name).value, -60) + + # with velocity data: + data = {} + data[f"pm_{lon_name}_cos{lat_name}"] = [1.0, 2.0] * u.mas / u.yr + data[f"pm_{lat_name}"] = [1.0, 2.0] * u.mas / u.yr + c = frame_cls([-60, 300] * u.deg, [15, -15] * u.deg, **data) + assert np.allclose(getattr(c, lon_name).value, -60) diff --git a/gala/source/tests/coordinates/test_gd1.py b/gala/source/tests/coordinates/test_gd1.py new file mode 100644 index 0000000000000000000000000000000000000000..a03408410f9781969c94fb2281ed4c66c31fefec --- /dev/null +++ b/gala/source/tests/coordinates/test_gd1.py @@ -0,0 +1,57 @@ +from pathlib import Path + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np + +from gala.coordinates import GD1Koposov10 + +this_path = Path(__file__).parent + + +def test_simple(): + c = coord.ICRS(coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree)) + c.transform_to(GD1Koposov10()) + + c = coord.Galactic(coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree)) + c.transform_to(GD1Koposov10()) + + c = GD1Koposov10(217.2141 * u.degree, -11.4351 * u.degree) + c.transform_to(coord.ICRS()) + c.transform_to(coord.Galactic()) + + c = coord.Galactic(coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree)) + c.transform_to(GD1Koposov10()) + + # with distance + c = GD1Koposov10( + coord.Angle(217.2141, u.degree), + coord.Angle(-11.4351, u.degree), + distance=15 * u.kpc, + ) + c.transform_to(coord.ICRS()) + c2 = c.transform_to(coord.Galactic()) + assert np.allclose(c2.distance.value, c.distance.value) + + +def test_koposov(): + # Compare against Table 1 in Koposov et al. 2010 + k10_data = np.genfromtxt( + this_path / "gd1_coord.txt", names=True, dtype=None, encoding="utf-8" + ) + + k10_icrs = coord.SkyCoord( + ra=k10_data["ra"].astype(str), + dec=k10_data["dec"].astype(str), + unit=(u.hourangle, u.degree), + ) + + k10_gd1 = GD1Koposov10( + phi1=k10_data["phi1"] * u.degree, phi2=k10_data["phi2"] * u.degree + ) + + gala_gd1 = k10_icrs.transform_to(GD1Koposov10()) + + # TODO: why are these so different from the values in Koposov? + assert np.allclose(k10_gd1.phi1.degree, gala_gd1.phi1.degree, atol=1e-1) + assert np.allclose(k10_gd1.phi2.degree, gala_gd1.phi2.degree, atol=0.2) diff --git a/gala/source/tests/coordinates/test_greatcircle.py b/gala/source/tests/coordinates/test_greatcircle.py new file mode 100644 index 0000000000000000000000000000000000000000..cdab8d1b224789d76aa1c50b4d6da0378181338d --- /dev/null +++ b/gala/source/tests/coordinates/test_greatcircle.py @@ -0,0 +1,247 @@ +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +import pytest + +import gala.coordinates as gc +from gala.coordinates.greatcircle import ( + GreatCircleICRSFrame, + make_greatcircle_cls, + pole_from_endpoints, + sph_midpoint, +) + + +@pytest.fixture(scope="module") +def rng(): + return np.random.default_rng(seed=42) + + +tmp = np.random.default_rng(123) +rand_lon = tmp.uniform(0, 2 * np.pi, 15) * u.rad +rand_lat = np.arcsin(tmp.uniform(-1, 1, 15)) * u.rad +poles = [ + coord.SkyCoord(ra=0 * u.deg, dec=90 * u.deg), + coord.SkyCoord(ra=0 * u.deg, dec=-90 * u.deg), + coord.SkyCoord(ra=12.3 * u.deg, dec=45.6 * u.deg, distance=1 * u.kpc), +] + [coord.SkyCoord(lon, lat) for lon, lat in zip(rand_lon, rand_lat)] + + +def get_random_orthogonal(skycoord, rng): + zhat = np.squeeze((skycoord.cartesian / skycoord.cartesian.norm()).xyz) + + # Random vector orthogonal to the pole: + x = rng.uniform(size=3) + x /= np.linalg.norm(x) + xhat = x - (x @ zhat) * zhat + xhat /= np.linalg.norm(xhat) + return coord.SkyCoord(coord.CartesianRepresentation(xhat), frame=skycoord.frame) + + +@pytest.mark.parametrize("pole", poles) +def test_init_cls(pole, rng): + origin = get_random_orthogonal(pole, rng) + + GreatCircleICRSFrame(pole=pole, origin=origin) + GreatCircleICRSFrame(pole=pole, origin=origin, priority="pole") + + with pytest.raises(ValueError): + GreatCircleICRSFrame(pole=pole, ra0=origin.ra) + + # Slightly adjust the origin so it is not orthogonal: + new_origin = origin.spherical_offsets_by( + 1.23 * u.deg, -2.42 * u.deg + ) # random values + + with pytest.warns(): + f1 = GreatCircleICRSFrame(pole=pole, origin=new_origin) + + # default priority="origin" + assert f1.origin.ra == new_origin.ra + assert f1.origin.dec == new_origin.dec + assert np.isclose(f1.origin.cartesian.xyz @ f1.pole.cartesian.xyz, 0.0) + + with pytest.warns(): + f2 = GreatCircleICRSFrame(pole=pole, origin=new_origin, priority="pole") + + assert f2.pole.ra == pole.ra + assert f2.pole.dec == pole.dec + assert np.isclose(f2.origin.cartesian.xyz @ f2.pole.cartesian.xyz, 0.0) + + +@pytest.mark.parametrize("pole", poles) +def test_init_from_pole_ra0(pole): + GreatCircleICRSFrame.from_pole_ra0(pole, ra0=153 * u.deg) + + disamb = coord.SkyCoord(ra=210 * u.deg, dec=-17 * u.deg) + GreatCircleICRSFrame.from_pole_ra0( + pole, ra0=153 * u.deg, origin_disambiguate=disamb + ) + + +fail_poles = [ + coord.SkyCoord(ra=90 * u.deg, dec=0 * u.deg), + coord.SkyCoord(ra=13.5399 * u.deg, dec=0 * u.deg), +] + + +@pytest.mark.parametrize("pole", fail_poles) +def test_init_from_pole_ra0_fail(pole): + with pytest.raises(ValueError): + test_init_from_pole_ra0(pole) + + +@pytest.mark.parametrize("c1", poles) +def test_init_from_endpoints(c1, rng): + # Random vector for other endpoint: + x = rng.uniform(size=3) + x /= np.linalg.norm(x) + c2 = coord.SkyCoord(coord.CartesianRepresentation(x)) + + midpt = coord.SkyCoord(sph_midpoint(c1.squeeze(), c2)) + origin_off = midpt.spherical_offsets_by(1.423 * u.deg, -2.182 * u.deg) + + f1 = GreatCircleICRSFrame.from_endpoints(c1, c2) + f2 = GreatCircleICRSFrame.from_endpoints(c1, c2, origin=midpt) + with pytest.warns(): + f3 = GreatCircleICRSFrame.from_endpoints(c1, c2, origin=origin_off) + assert u.isclose(f3.origin.ra, origin_off.ra) + assert u.isclose(f3.origin.dec, origin_off.dec) + + if np.abs(c1.dec) != 90 * u.deg: + f4 = GreatCircleICRSFrame.from_endpoints(c1, c2, ra0=origin_off.ra) + + with pytest.warns(): + f5 = GreatCircleICRSFrame.from_endpoints( + c1, c2, origin=origin_off, priority="pole" + ) + assert u.isclose(f5.pole.ra, f1.pole.ra) + assert u.isclose(f5.pole.dec, f1.pole.dec) + + +@pytest.mark.parametrize("pole", poles) +def test_make_function(pole, rng): + origin = get_random_orthogonal(pole, rng) + + cls = make_greatcircle_cls( + "Michael", "This is the docstring header", pole=pole, origin=origin + ) + fr = cls(phi1=100 * u.deg, phi2=10 * u.deg) + fr.transform_to(coord.ICRS()) + + +def test_pole_from_endpoints(): + c1 = coord.SkyCoord(0 * u.deg, 0 * u.deg) + c2 = coord.SkyCoord(90 * u.deg, 0 * u.deg) + pole = pole_from_endpoints(c1, c2) + assert u.allclose(pole.dec, 90 * u.deg) + + c1 = coord.SkyCoord(0 * u.deg, 0 * u.deg) + c2 = coord.SkyCoord(0 * u.deg, 90 * u.deg) + pole = pole_from_endpoints(c1, c2) + assert u.allclose(pole.ra, 270 * u.deg) + assert u.allclose(pole.dec, 0 * u.deg) + + # Should work even if coord has velocities: + c1 = coord.SkyCoord( + 0 * u.deg, 0 * u.deg, pm_ra_cosdec=10 * u.mas / u.yr, pm_dec=-0.5 * u.mas / u.yr + ) + c2 = coord.SkyCoord( + 0 * u.deg, + 90 * u.deg, + pm_ra_cosdec=10 * u.mas / u.yr, + pm_dec=-0.5 * u.mas / u.yr, + ) + pole = pole_from_endpoints(c1, c2) + assert u.allclose(pole.ra, 270 * u.deg) + assert u.allclose(pole.dec, 0 * u.deg) + + +def test_init_pole_from_xyz(): + xnew = coord.UnitSphericalRepresentation(185 * u.deg, 32.5 * u.deg).to_cartesian() + ynew = coord.UnitSphericalRepresentation(275 * u.deg, 0 * u.deg).to_cartesian() + znew = xnew.cross(ynew) + + fr1 = GreatCircleICRSFrame.from_xyz(xnew, ynew, znew) + fr2 = GreatCircleICRSFrame.from_xyz(xnew, ynew) + fr3 = GreatCircleICRSFrame.from_xyz(xnew, znew=znew) + fr4 = GreatCircleICRSFrame.from_xyz(ynew=ynew, znew=znew) + + for fr in [fr2, fr3, fr4]: + assert np.isclose(fr1.pole.ra.degree, fr.pole.ra.degree) + assert np.isclose(fr1.pole.dec.degree, fr.pole.dec.degree) + assert np.isclose(fr1.origin.ra.degree, fr.origin.ra.degree) + assert np.isclose(fr1.origin.dec.degree, fr.origin.dec.degree) + + with pytest.raises(ValueError): + GreatCircleICRSFrame.from_xyz(xnew) + + +def test_sph_midpoint(): + c1 = coord.SkyCoord(0 * u.deg, 0 * u.deg) + c2 = coord.SkyCoord(90 * u.deg, 0 * u.deg) + midpt = sph_midpoint(c1, c2) + assert u.allclose(midpt.ra, 45 * u.deg) + assert u.allclose(midpt.dec, 0 * u.deg) + + c1 = coord.SkyCoord(0 * u.deg, 0 * u.deg) + c2 = coord.SkyCoord(0 * u.deg, 90 * u.deg) + midpt = sph_midpoint(c1, c2) + assert u.allclose(midpt.ra, 0 * u.deg) + assert u.allclose(midpt.dec, 45 * u.deg) + + +def test_init_from_R(rng): + from gala.coordinates.gd1 import GD1Koposov10 + from gala.coordinates.gd1 import R as gd1_R + + N = 128 + + gd1_gc_frame = GreatCircleICRSFrame.from_R(gd1_R) + tmp_in = GD1Koposov10( + phi1=rng.uniform(0, 360, N) * u.deg, phi2=rng.uniform(-90, 90, N) * u.deg + ) + + tmp_out = tmp_in.transform_to(gd1_gc_frame) + + assert u.allclose(tmp_in.phi1, tmp_out.phi1) + assert u.allclose(tmp_in.phi2, tmp_out.phi2) + + +def test_regression_missing_R(rng): + """ + As reported in #396, GreatCircle frames in reflex_correct were somehow missing the _R property... + """ + v_sun = coord.CartesianDifferential([11.1, 220.0 + 12.24, 7.25] * u.km / u.s) + r_sun = 8.122 * u.kpc + gc_frame = coord.Galactocentric( + galcen_distance=r_sun, galcen_v_sun=v_sun, z_sun=0 * u.pc + ) + + df = { + "ra": rng.uniform(60, 180, 100), + "dec": rng.uniform(-30, 30, 100), + "pmra": rng.normal(0, 5, 100), + "pmdec": rng.normal(0, 5, 100), + } + + stream_icrs = coord.SkyCoord( + ra=df["ra"] * u.deg, + dec=df["dec"] * u.deg, + pm_ra_cosdec=df["pmra"] * u.mas / u.yr, + pm_dec=df["pmdec"] * u.mas / u.yr, + distance=np.ones(len(df["ra"])) * u.kpc, + radial_velocity=np.zeros(len(df["ra"])) * u.km / u.s, + frame="icrs", + ) + + test1 = gc.reflex_correct(stream_icrs, gc_frame) + assert np.isfinite(test1.pm_ra_cosdec).all() + + frame = gc.GD1Koposov10() + stream_sc = stream_icrs.transform_to(frame) + + stream_sc.transform_to(gc_frame) + + test2 = gc.reflex_correct(stream_sc, gc_frame) + assert np.isfinite(test2.pm_phi1_cosphi2).all() diff --git a/gala/source/tests/coordinates/test_jhelum.py b/gala/source/tests/coordinates/test_jhelum.py new file mode 100644 index 0000000000000000000000000000000000000000..454b1041b0a1ce49dab2698db4eecfb978258e52 --- /dev/null +++ b/gala/source/tests/coordinates/test_jhelum.py @@ -0,0 +1,30 @@ +import astropy.coordinates as coord +import astropy.units as u +import numpy as np + +from gala.coordinates import JhelumBonaca19 + + +def test_simple(): + c = coord.ICRS(coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree)) + c.transform_to(JhelumBonaca19()) + + c = coord.Galactic(coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree)) + c.transform_to(JhelumBonaca19()) + + c = JhelumBonaca19(217.2141 * u.degree, -11.4351 * u.degree) + c.transform_to(coord.ICRS()) + c.transform_to(coord.Galactic()) + + c = coord.Galactic(coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree)) + c.transform_to(JhelumBonaca19()) + + # with distance + c = JhelumBonaca19( + coord.Angle(217.2141, u.degree), + coord.Angle(-11.4351, u.degree), + distance=15 * u.kpc, + ) + c.transform_to(coord.ICRS()) + c2 = c.transform_to(coord.Galactic()) + assert np.allclose(c2.distance.value, c.distance.value) diff --git a/gala/source/tests/coordinates/test_orphan.py b/gala/source/tests/coordinates/test_orphan.py new file mode 100644 index 0000000000000000000000000000000000000000..a0c1ee46f05ceabb7e9f8c67d2b93181e495763a --- /dev/null +++ b/gala/source/tests/coordinates/test_orphan.py @@ -0,0 +1,53 @@ +""" +Test the coordinates class that represents the plane of orbit of the Sgr dwarf galaxy. +""" + +from pathlib import Path + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +from astropy.io import ascii +from astropy.table import Table + +from gala.coordinates import OrphanKoposov19, OrphanNewberg10 + +this_path = Path(__file__).parent + + +def test_table(): + """Test the transformation code against table 2 values from + Newberg et al. 2010 (below) + """ + + names = ["l", "b", "db", "Lambda", "Beta", "g0", "dg0"] + table = """255 48.5 0.7 22.34 0.08 17.1 0.1 +245 52.0 0.7 15.08 0.56 0. 0. +235 53.5 0.7 8.86 0.21 0. 0. +225 54.0 0.7 2.95 -0.23 17.6 0.2 +215 54.0 0.7 -2.93 -0.33 17.9 0.1 +205 53.5 0.7 -8.85 -0.09 18.0 0.1 +195 52.0 0.7 -15.08 0.05 0. 0. +185 50.5 0.7 -21.42 1.12 18.6 0.1 +175 47.5 0.7 -28.59 1.88 0. 0. +171 45.8 1.0 -31.81 2.10 0. 0.""" + + table = ascii.read(table, names=names) + + for line in table: + galactic = coord.Galactic(l=line["l"] * u.deg, b=line["b"] * u.deg) + + orp = galactic.transform_to(OrphanNewberg10()) + true_orp = OrphanNewberg10( + phi1=line["Lambda"] * u.deg, phi2=line["Beta"] * u.deg + ) + + # TODO: why does this suck so badly? + assert true_orp.separation(orp) < 20 * u.arcsec + + +def test_kopsov(): + tbl = Table.read(this_path / "sergey_orphan.txt", format="ascii") + c = coord.SkyCoord(ra=tbl["ra"] * u.deg, dec=tbl["dec"] * u.deg) + orp_gc = c.transform_to(OrphanKoposov19()) + assert np.percentile(orp_gc.phi2.degree, 95) < 5 diff --git a/gala/source/tests/coordinates/test_pal5.py b/gala/source/tests/coordinates/test_pal5.py new file mode 100644 index 0000000000000000000000000000000000000000..e9aed04a3768a1dd944660274f81a9a324481cc5 --- /dev/null +++ b/gala/source/tests/coordinates/test_pal5.py @@ -0,0 +1,30 @@ +import astropy.coordinates as coord +import astropy.units as u +import numpy as np + +from gala.coordinates import Pal5PriceWhelan18 + + +def test_simple(): + c = coord.ICRS(coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree)) + c.transform_to(Pal5PriceWhelan18()) + + c = coord.Galactic(coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree)) + c.transform_to(Pal5PriceWhelan18()) + + c = Pal5PriceWhelan18(217.2141 * u.degree, -11.4351 * u.degree) + c.transform_to(coord.ICRS()) + c.transform_to(coord.Galactic()) + + c = coord.Galactic(coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree)) + c.transform_to(Pal5PriceWhelan18()) + + # with distance + c = Pal5PriceWhelan18( + coord.Angle(217.2141, u.degree), + coord.Angle(-11.4351, u.degree), + distance=15 * u.kpc, + ) + c.transform_to(coord.ICRS()) + c2 = c.transform_to(coord.Galactic()) + assert np.allclose(c2.distance.value, c.distance.value) diff --git a/gala/source/tests/coordinates/test_pm_cov_transform.py b/gala/source/tests/coordinates/test_pm_cov_transform.py new file mode 100644 index 0000000000000000000000000000000000000000..cfc307784859aee1ca68a10c32fb8299163406d9 --- /dev/null +++ b/gala/source/tests/coordinates/test_pm_cov_transform.py @@ -0,0 +1,101 @@ +from pathlib import Path + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +import pytest + +from gala.coordinates import OrphanKoposov19, transform_pm_cov + +this_path = Path(__file__).parent + +sky_offset_frame = coord.SkyOffsetFrame( + origin=coord.ICRS(ra="20d", dec="30d"), rotation=135.7 * u.deg +) + + +def setup_function(fn): + ra, dec, pmra, pmdec = np.load(this_path / "c_pm.npy") + c = coord.SkyCoord( + ra=ra * u.deg, + dec=dec * u.deg, + pm_ra_cosdec=pmra * u.mas / u.yr, + pm_dec=pmdec * u.mas / u.yr, + ) + cov = np.load(this_path / "pm_cov.npy") + + fn.c = c + fn.cov = cov + + +@pytest.mark.parametrize( + "to_frame", + [ + coord.Galactic, + coord.Galactic(), + coord.Supergalactic, + coord.Supergalactic(), + OrphanKoposov19, + OrphanKoposov19(), + sky_offset_frame, + ], +) +def test_transform(to_frame): + c = test_transform.c + cov = test_transform.cov + + # First, don't validate, just check input paths: + transform_pm_cov(c[0], cov[0], to_frame) + transform_pm_cov(c[:4], cov[:4], to_frame) + + with pytest.raises(ValueError): + transform_pm_cov(c[:4], cov[:8], to_frame) + + with pytest.raises(ValueError): + transform_pm_cov(c[0], cov[0, :1], to_frame) + + new_cov1 = transform_pm_cov(c[0], cov[0], to_frame) + new_cov2 = np.squeeze(transform_pm_cov(c[0:1], cov[0:1], to_frame)) + assert np.allclose(new_cov1, new_cov2) + + +@pytest.mark.parametrize( + "to_frame", + [ + coord.Galactic, + coord.Galactic(), + coord.Supergalactic, + coord.Supergalactic(), + OrphanKoposov19, + OrphanKoposov19(), + sky_offset_frame, + ], +) +def test_transform_correctness(to_frame): + c = test_transform_correctness.c[:4] + cov = test_transform_correctness.cov[:4] + + # generate proper motion samples and transform the samples: + pm = np.vstack((c.pm_ra_cosdec.value, c.pm_dec.value)).T + rnd = np.random.RandomState(42) + + for i in range(len(c)): + pm_samples = rnd.multivariate_normal(pm[i], cov[i], size=2**16) + c1 = coord.SkyCoord( + ra=[c[i].ra.value] * pm_samples.shape[0] * u.deg, + dec=[c[i].dec.value] * pm_samples.shape[0] * u.deg, + pm_ra_cosdec=pm_samples[:, 0] * u.mas / u.yr, + pm_dec=pm_samples[:, 1] * u.mas / u.yr, + ) + new_c1 = c1.transform_to(to_frame) + + dsph = new_c1.represent_as( + coord.SphericalRepresentation, coord.SphericalCosLatDifferential + ).differentials["s"] + new_pm_samples = np.vstack((dsph.d_lon_coslat.value, dsph.d_lat.value)) + cov_est = np.cov(new_pm_samples) + cov_trans = transform_pm_cov(c[i], cov[i], to_frame) + assert np.allclose(cov_est, cov_trans, atol=1e-2) + assert np.allclose( + np.sort(np.linalg.eigvals(cov[i])), np.sort(np.linalg.eigvals(cov_trans)) + ) diff --git a/gala/source/tests/coordinates/test_reflex.py b/gala/source/tests/coordinates/test_reflex.py new file mode 100644 index 0000000000000000000000000000000000000000..1d9952e97806a439e3a4e46bb1631edfb8aee034 --- /dev/null +++ b/gala/source/tests/coordinates/test_reflex.py @@ -0,0 +1,46 @@ +import astropy.coordinates as coord +import astropy.units as u + +from gala.coordinates import reflex_correct + + +def test_reflex(): + c = coord.SkyCoord( + ra=162 * u.deg, + dec=-17 * u.deg, + distance=172 * u.pc, + pm_ra_cosdec=-11 * u.mas / u.yr, + pm_dec=4 * u.mas / u.yr, + radial_velocity=110 * u.km / u.s, + ) + + # First, test execution but don't validate + reflex_correct(c) + with coord.galactocentric_frame_defaults.set("v4.0"): + reflex_correct(c, coord.Galactocentric(z_sun=0 * u.pc)) + + # Reflext correct the observed, Reid & Brunthaler (2004) Sgr A* measurements + # and make sure the corrected velocity is close to zero + # https://ui.adsabs.harvard.edu/abs/2004ApJ...616..872R/abstract + # also using + # https://ui.adsabs.harvard.edu/abs/2018RNAAS...2d.210D/abstract + # https://ui.adsabs.harvard.edu/abs/2018A%26A...615L..15G/abstract + vsun = coord.CartesianDifferential([12.9, 245.6, 7.78] * u.km / u.s) + with coord.galactocentric_frame_defaults.set("v4.0"): + galcen_fr = coord.Galactocentric( + galcen_distance=8.122 * u.kpc, galcen_v_sun=vsun, z_sun=20.8 * u.pc + ) + + sgr_Astar_obs = coord.SkyCoord( + ra=galcen_fr.galcen_coord.ra, + dec=galcen_fr.galcen_coord.dec, + distance=galcen_fr.galcen_distance, + pm_ra_cosdec=-3.151 * u.mas / u.yr, + pm_dec=-5.547 * u.mas / u.yr, + radial_velocity=-12.9 * u.km / u.s, + ) + + new_c = reflex_correct(sgr_Astar_obs, galcen_fr) + assert u.allclose(new_c.pm_ra_cosdec, 0 * u.mas / u.yr, atol=1e-2 * u.mas / u.yr) + assert u.allclose(new_c.pm_dec, 0 * u.mas / u.yr, atol=1e-2 * u.mas / u.yr) + assert u.allclose(new_c.radial_velocity, 0 * u.km / u.s, atol=1e-1 * u.km / u.s) diff --git a/gala/source/tests/coordinates/test_sgr.py b/gala/source/tests/coordinates/test_sgr.py new file mode 100644 index 0000000000000000000000000000000000000000..a2630fda4c4a1013ce08b70bd0d1062dcb834397 --- /dev/null +++ b/gala/source/tests/coordinates/test_sgr.py @@ -0,0 +1,74 @@ +""" +Test the coordinates class that represents the plane of orbit of the Sgr dwarf galaxy. +""" + +from pathlib import Path + +import astropy.coordinates as coord +import astropy.table as at +import astropy.units as u +import numpy as np + +from gala.coordinates import SagittariusLaw10, SagittariusVasiliev21 + +this_path = Path(__file__).parent + + +def test_simple(): + c = coord.ICRS(coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree)) + c.transform_to(SagittariusLaw10()) + + c = coord.Galactic(coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree)) + c.transform_to(SagittariusLaw10()) + + c = SagittariusLaw10( + coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree) + ) + c.transform_to(coord.ICRS()) + c.transform_to(coord.Galactic()) + + c = coord.Galactic(coord.Angle(217.2141, u.degree), coord.Angle(-11.4351, u.degree)) + c.transform_to(SagittariusLaw10()) + + # with distance + c = SagittariusLaw10( + coord.Angle(217.2141, u.degree), + coord.Angle(-11.4351, u.degree), + distance=15 * u.kpc, + ) + c.transform_to(coord.ICRS()) + c2 = c.transform_to(coord.Galactic()) + assert np.allclose(c2.distance.value, c.distance.value) + + +def test_against_David_Law(): + """Test my code against an output file from using David Law's cpp code. Do: + + g++ SgrCoord.cpp; ./a.out + + to generate the data file, SgrCoord_data. + + """ + filename = this_path / "SgrCoord_data" + law_data = np.genfromtxt(filename, names=True, delimiter=",") + + c = coord.Galactic(law_data["l"] * u.deg, law_data["b"] * u.deg) + sgr_coords = c.transform_to(SagittariusLaw10()) + + law_sgr_coords = SagittariusLaw10( + Lambda=law_data["lambda"] * u.deg, Beta=law_data["beta"] * u.deg + ) + + sep = sgr_coords.separation(law_sgr_coords).arcsec * u.arcsec + assert np.all(sep < 1.0 * u.arcsec) + + +def test_v21(): + filename = this_path / "Vasiliev2020-Sagittarius-subset.csv" + test_data = at.Table.read(filename, format="ascii.csv") + + c = coord.SkyCoord(test_data["ra"] * u.deg, test_data["dec"] * u.deg) + sgr_c = c.transform_to(SagittariusVasiliev21()) + + assert np.allclose(sgr_c.Lambda.degree, test_data["Lambda"], atol=1e-3) + assert np.allclose(sgr_c.Beta.degree, test_data["Beta"], atol=1e-3) diff --git a/gala/source/tests/coordinates/test_velocity_frame_transforms.py b/gala/source/tests/coordinates/test_velocity_frame_transforms.py new file mode 100644 index 0000000000000000000000000000000000000000..ad254b5fdd534ac111b2ceb1b789ac199cf210d6 --- /dev/null +++ b/gala/source/tests/coordinates/test_velocity_frame_transforms.py @@ -0,0 +1,97 @@ +""" +Test conversions in core.py +""" + +from pathlib import Path + +import astropy.coordinates as coord +import astropy.units as u +import numpy as np + +from gala.coordinates.velocity_frame_transforms import vgsr_to_vhel, vhel_to_vgsr + +this_path = Path(__file__).parent + + +def test_vgsr_to_vhel(): + filename = this_path / "idl_vgsr_vhel.txt" + data = np.genfromtxt(filename, names=True, skip_header=2) + + # one row + row = data[0] + l = coord.Angle(row["lon"] * u.degree) + b = coord.Angle(row["lat"] * u.degree) + c = coord.Galactic(l, b) + vgsr = row["vgsr"] * u.km / u.s + vlsr = [row["vx"], row["vy"], row["vz"]] * u.km / u.s # this is right + vcirc = row["vcirc"] * u.km / u.s + + vsun = vlsr + [0, 1, 0] * vcirc + vhel = vgsr_to_vhel(c, vgsr, vsun=vsun) + assert np.allclose(vhel.value, row["vhelio"], atol=1e-3) + + # now check still get right answer passing in ICRS coordinates + vhel = vgsr_to_vhel(c.transform_to(coord.ICRS()), vgsr, vsun=vsun) + assert np.allclose(vhel.value, row["vhelio"], atol=1e-3) + + # all together now + l = coord.Angle(data["lon"] * u.degree) + b = coord.Angle(data["lat"] * u.degree) + c = coord.Galactic(l, b) + vgsr = data["vgsr"] * u.km / u.s + vhel = vgsr_to_vhel(c, vgsr, vsun=vsun) + assert np.allclose(vhel.value, data["vhelio"], atol=1e-3) + + # now check still get right answer passing in ICRS coordinates + vhel = vgsr_to_vhel(c.transform_to(coord.ICRS()), vgsr, vsun=vsun) + assert np.allclose(vhel.value, data["vhelio"], atol=1e-3) + + +def test_vgsr_to_vhel_misc(): + # make sure it works with longitude in 0-360 or -180-180 + l1 = coord.Angle(190.0 * u.deg) + l2 = coord.Angle(-170.0 * u.deg) + b = coord.Angle(30.0 * u.deg) + + c1 = coord.Galactic(l1, b) + c2 = coord.Galactic(l2, b) + + vgsr = -110.0 * u.km / u.s + vhel1 = vgsr_to_vhel(c1, vgsr) + vhel2 = vgsr_to_vhel(c2, vgsr) + + assert np.allclose(vhel1.value, vhel2.value) + + +def test_vhel_to_vgsr(): + filename = this_path / "idl_vgsr_vhel.txt" + data = np.genfromtxt(filename, names=True, skip_header=2) + + # one row + row = data[0] + l = coord.Angle(row["lon"] * u.degree) + b = coord.Angle(row["lat"] * u.degree) + c = coord.Galactic(l, b) + vhel = row["vhelio"] * u.km / u.s + vlsr = [row["vx"], row["vy"], row["vz"]] * u.km / u.s # this is right + vcirc = row["vcirc"] * u.km / u.s + + vsun = vlsr + [0, 1, 0] * vcirc + vgsr = vhel_to_vgsr(c, vhel, vsun=vsun) + assert np.allclose(vgsr.value, row["vgsr"], atol=1e-3) + + # now check still get right answer passing in ICRS coordinates + vgsr = vhel_to_vgsr(c.transform_to(coord.ICRS()), vhel, vsun=vsun) + assert np.allclose(vgsr.value, row["vgsr"], atol=1e-3) + + # all together now + l = coord.Angle(data["lon"] * u.degree) + b = coord.Angle(data["lat"] * u.degree) + c = coord.Galactic(l, b) + vhel = data["vhelio"] * u.km / u.s + vgsr = vhel_to_vgsr(c, vhel, vsun=vsun) + assert np.allclose(vgsr.value, data["vgsr"], atol=1e-3) + + # now check still get right answer passing in ICRS coordinates + vgsr = vhel_to_vgsr(c.transform_to(coord.ICRS()), vhel, vsun=vsun) + assert np.allclose(vgsr.value, data["vgsr"], atol=1e-3) diff --git a/gala/source/tests/dynamics/actionangle/_genfunc/__init__.py b/gala/source/tests/dynamics/actionangle/_genfunc/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/gala/source/tests/dynamics/actionangle/_genfunc/genfunc_3d.py b/gala/source/tests/dynamics/actionangle/_genfunc/genfunc_3d.py new file mode 100644 index 0000000000000000000000000000000000000000..686e7a928754f4e21f54d47ec4c7c1c0e0da08c0 --- /dev/null +++ b/gala/source/tests/dynamics/actionangle/_genfunc/genfunc_3d.py @@ -0,0 +1,540 @@ +# Solving the series of linear equations for true action +# and generating function Fourier components + +import time + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.ticker import MaxNLocator +from scipy.integrate import odeint + +# in units kpc, km/s and 10^11 M_solar +Grav = 430091.7270069976 +Conv = 0.9777922216 + +from . import solver +from . import test_potentials as pot +from . import toy_potentials as toy +from . import visualize_surfaces as vs +from .solver import unroll_angles as ua + + +def choose_NT(N_max, iffreq=True): + """calculates number of time samples required to constrain N_max modes + --- equation (21) from Sanders & Binney (2014)""" + if iffreq: + return max(200, 9 * N_max**3 / 4) + else: + return max(100, N_max**3 / 2) + + +def check_angle_solution(ang, n_vec, toy_aa, timeseries): + """Plots the toy angle solution against the toy angles --- + Takes true angles and frequencies ang, + the Fourier vectors n_vec, + the toy action-angles toy_aa + and the timeseries""" + f, a = plt.subplots(3, 1) + for i in range(3): + a[i].plot(toy_aa.T[i + 3], ".") + size = len(ang[6:]) / 3 + AA = np.array( + [ + np.sum( + ang[6 + i * size : 6 + (i + 1) * size] + * np.sin(np.sum(n_vec * K, axis=1)) + ) + for K in toy_aa.T[3:].T + ] + ) + a[i].plot((ang[i] + ang[i + 3] * timeseries - 2.0 * AA) % (2.0 * np.pi), ".") + a[i].set_ylabel(r"$\theta$" + str(i + 1)) + a[2].set_xlabel(r"$t$") + plt.show() + + +def check_target_angle_solution(ang, n_vec, toy_aa, timeseries): + """Plots the angle solution and the toy angles --- + Takes true angles and frequencies ang, + the Fourier vectors n_vec, + the toy action-angles toy_aa + and the timeseries""" + f, a = plt.subplots(3, 1) + for i in range(3): + # a[i].plot(toy_aa.T[i+3],'.') + size = len(ang[6:]) / 3 + AA = np.array( + [ + np.sum( + ang[6 + i * size : 6 + (i + 1) * size] + * np.sin(np.sum(n_vec * K, axis=1)) + ) + for K in toy_aa.T[3:].T + ] + ) + a[i].plot( + ((toy_aa.T[i + 3] + 2.0 * AA) % (2.0 * np.pi)) + - (ang[i] + timeseries * ang[i + 3]) % (2.0 * np.pi), + ".", + ) + a[i].plot(toy_aa.T[i + 3], ".") + a[i].set_ylabel(r"$\theta$" + str(i + 1)) + a[2].set_xlabel(r"$t$") + plt.show() + + +def eval_mean_error_functions(act, ang, n_vec, toy_aa, timeseries, withplot=False): + """Calculates sqrt(mean(E)) and sqrt(mean(F))""" + + Err = np.zeros(6) + NT = len(timeseries) + size = len(ang[6:]) / 3 + UA = ua(toy_aa.T[3:].T, np.ones(3)) + fig, axis = None, None + if withplot: + fig, axis = plt.subplots(3, 2) + plt.subplots_adjust(wspace=0.3) + for K in range(3): + ErrJ = np.array( + [ + ( + i[K] + - act[K] + - 2.0 * np.sum(n_vec.T[K] * act[3:] * np.cos(np.dot(n_vec, i[3:]))) + ) + ** 2 + for i in toy_aa + ] + ) + Err[K] = np.sum(ErrJ) + ErrT = np.array( + ( + ang[K] + + timeseries * ang[K + 3] + - UA.T[K] + - 2.0 + * np.array( + [ + np.sum( + ang[6 + K * size : 6 + (K + 1) * size] + * np.sin(np.sum(n_vec * i, axis=1)) + ) + for i in toy_aa.T[3:].T + ] + ) + ) + ** 2 + ) + Err[K + 3] = np.sum(ErrT) + if withplot: + axis[K][0].plot(ErrJ, ".") + axis[K][0].set_ylabel(r"$E$" + str(K + 1)) + axis[K][1].plot(ErrT, ".") + axis[K][1].set_ylabel(r"$F$" + str(K + 1)) + + if withplot: + for i in range(3): + axis[i][0].set_xlabel(r"$t$") + axis[i][1].set_xlabel(r"$t$") + plt.show() + + EJ = np.sqrt(Err[:3] / NT) + ET = np.sqrt(Err[3:] / NT) + + return np.array([EJ, ET]) + + +def box_actions(results, times, N_matrix, ifprint): + """ + Finds actions, angles and frequencies for box orbit. + Takes a series of phase-space points from an orbit integration at times t and returns + L = (act, ang, n_vec, toy_aa, pars) -- explained in find_actions() below. + """ + if ifprint: + pass + + time.time() + # Find best toy parameters + omega = toy.findbestparams_ho(results) + if ifprint: + pass + + # Now find toy actions and angles + AA = np.array([toy.angact_ho(i, omega) for i in results]) + AA = AA[~np.isnan(AA).any(1)] + if len(AA) == 0: + return None + + time.time() + act = solver.solver(AA, N_matrix) + if act is None: + return None + + if ifprint: + pass + + # np.savetxt("GF.Sn_box",np.vstack((act[1].T, act[0][3:])).T) + + ang = solver.angle_solver(AA, times, N_matrix, np.ones(3)) + if ifprint: + pass + + # Just some checks + if len(ang) > len(AA): + pass + + return act[0], ang, act[1], AA, omega + + +def loop_actions(results, times, N_matrix, ifprint): + """ + Finds actions, angles and frequencies for loop orbit. + Takes a series of phase-space points from an orbit integration at times t and returns + L = (act, ang, n_vec, toy_aa, pars) -- explained in find_actions() below. + results must be oriented such that circulation is about the z-axis + """ + if ifprint: + pass + + time.time() + # First find the best set of toy parameters + params = toy.findbestparams_iso(results) + if params[0] != params[0]: + params = np.array([10.0, 10.0]) + if ifprint: + pass + + # Now find the toy angles and actions in this potential + AA = np.array([toy.angact_iso(i, params) for i in results]) + AA = AA[~np.isnan(AA).any(1)] + if len(AA) == 0: + return None + + time.time() + act = solver.solver(AA, N_matrix, symNx=1) + if act is None: + return None + + if ifprint: + pass + + # Store Sn + # np.savetxt("GF.Sn_loop",np.vstack((act[1].T, act[0][3:])).T) + + # Find angles + sign = np.array( + [ + 1.0, + np.sign(results[0][0] * results[0][4] - results[0][1] * results[0][3]), + 1.0, + ] + ) + ang = solver.angle_solver(AA, times, N_matrix, sign, symNx=1) + if ifprint: + pass + + # Just some checks + if len(ang) > len(AA): + pass + + return act[0], ang, act[1], AA, params + + +def angmom(x): + """returns angular momentum vector of phase-space point x""" + return np.array( + [ + x[1] * x[5] - x[2] * x[4], + x[2] * x[3] - x[0] * x[5], + x[0] * x[4] - x[1] * x[3], + ] + ) + + +def assess_angmom(X): + """ + Checks for change of sign in each component of the angular momentum. + Returns an array with ith entry 1 if no sign change in i component + and 0 if sign change. + Box = (0, 0, 0) + S.A loop = (0, 0, 1) + L.A loop = (1, 0, 0) + """ + L = angmom(X[0]) + loop = np.array([1, 1, 1]) + for i in X[1:]: + L0 = angmom(i) + if L0[0] * L[0] < 0.0: + loop[0] = 0 + if L0[1] * L[1] < 0.0: + loop[1] = 0 + if L0[2] * L[2] < 0.0: + loop[2] = 0 + return loop + + +def flip_coords(X, loop): + """Align circulation with z-axis""" + if loop[0] == 1: + return np.array(np.array([i[2], i[1], i[0], i[5], i[4], i[3]]) for i in X) + else: + return X + + +def find_actions(results, t, N_matrix=8, use_box=False, ifloop=False, ifprint=True): + """ + Main routine: + Takes a series of phase-space points from an orbit integration at times t and returns + L = (act, ang, n_vec, toy_aa, pars) where act is the actions, ang the initial angles and + frequencies, n_vec the n vectors of the Fourier modes, toy_aa the toy action-angle + coords, and pars are the toy potential parameters + N_matrix sets the maximum |n| of the Fourier modes used, + use_box forces the routine to use the triaxial harmonic oscillator as the toy potential, + ifloop=True returns orbit classification, + ifprint=True prints progress messages. + """ + + # Determine orbit class + loop = assess_angmom(results) + arethereloops = np.any(loop > 0) + if arethereloops and not use_box: + L = loop_actions(flip_coords(results, loop), t, N_matrix, ifprint) + if L is None: + if ifprint: + pass + return None + # Used for switching J_2 and J_3 for long-axis loop orbits + # This is so the orbit classes form a continuous plane in action space + # if(loop[0]): + # L[0][1],L[0][2]=L[0][2],L[0][1] + # L[1][1],L[1][2]=L[1][2],L[1][1] + # L[1][4],L[1][5]=L[1][5],L[1][4] + # L[3].T[1],L[3].T[2]=L[3].T[2],L[3].T[1] + else: + L = box_actions(results, t, N_matrix, ifprint) + if L is None: + if ifprint: + pass + return None + if ifloop: + return L, loop + else: + return L + + +################### +# Plotting tests # +################### +from .solver import check_each_direction as ced + + +def plot_Sn_timesamples(PSP): + """Plots Fig. 5 from Sanders & Binney (2014)""" + TT = pot.stackel_triax() + f, a = plt.subplots(2, 1, figsize=[3.32, 3.6]) + plt.subplots_adjust(hspace=0.0, top=0.8) + + LowestPeriod = 2.0 * np.pi / 38.86564386 + Times = np.array([2.0, 4.0, 8.0, 12.0]) + Sr = np.arange(2, 14, 2) + + # Loop over length of integration window + for i, P, C in zip(Times, [".", "s", "D", "^"], ["k", "r", "b", "g"]): + diffact = np.zeros((len(Sr), 3)) + difffreq = np.zeros((len(Sr), 3)) + MAXGAPS = np.array([]) + # Loop over N_max + for k, j in enumerate(Sr): + NT = choose_NT(j) + timeseries = np.linspace(0.0, i * LowestPeriod, NT) + results = odeint( + pot.orbit_derivs2, PSP, timeseries, args=(TT,), rtol=1e-13, atol=1e-13 + ) + act, ang, n_vec, toy_aa, pars = find_actions( + results, timeseries, N_matrix=j, ifprint=False, use_box=True + ) + # Check all modes + checks, maxgap = ced(n_vec, ua(toy_aa.T[3:].T, np.ones(3))) + maxgap = np.max(maxgap) if len(maxgap) > 0 else 0 + diffact[k] = act[:3] / TT.action(results[0]) + MAXGAPS = np.append(MAXGAPS, maxgap) + difffreq[k] = ang[3:6] / TT.freq(results[0]) + size = 15 + if P == ".": + size = 30 + LW = np.array(0.5 + i * 0.5 for i in MAXGAPS) + a[0].scatter( + Sr, + np.log10(np.abs(diffact.T[2] - 1)), + marker=P, + s=size, + color=C, + facecolors="none", + lw=LW, + label=r"$T =\,$" + str(i) + r"$\,T_F$", + ) + a[1].scatter( + Sr, + np.log10(np.abs(difffreq.T[2] - 1)), + marker=P, + s=size, + color=C, + facecolors="none", + lw=LW, + ) + a[1].get_yticklabels()[-1].set_visible(False) + a[0].set_xticklabels([]) + a[0].set_xlim(1, 13) + a[0].set_ylabel(r"$\log_{10}|J_3^\prime/J_{3, \rm true}-1|$") + leg = a[0].legend( + loc="upper center", bbox_to_anchor=(0.5, 1.4), ncol=2, scatterpoints=1 + ) + leg.draw_frame(False) + a[1].set_xlim(1, 13) + a[1].set_xlabel(r"$N_{\rm max}$") + a[1].set_ylabel(r"$\log_{10}|\Omega_3^\prime/\Omega_{3,\rm true}-1|$") + plt.savefig("Sn_T_box.pdf", bbox_inches="tight") + + +def plot3D_stacktriax(initial, final_t, N_MAT, file_output): + """For producing plots from paper""" + + # Setup Stackel potential + TT = pot.stackel_triax() + times = choose_NT(N_MAT) + timeseries = np.linspace(0.0, final_t, times) + # Integrate orbit + results = odeint( + pot.orbit_derivs2, initial, timeseries, args=(TT,), rtol=1e-13, atol=1e-13 + ) + # Find actions, angles and frequencies + (act, ang, n_vec, toy_aa, pars), loop = find_actions( + results, timeseries, N_matrix=N_MAT, ifloop=True + ) + + toy_pot = 0 + if loop[2] > 0.5 or loop[0] > 0.5: + toy_pot = pot.isochrone(par=np.append(pars, 0.0)) + else: + toy_pot = pot.harmonic_oscillator(omega=pars[:3]) + # Integrate initial condition in toy potential + timeseries_2 = np.linspace(0.0, 2.0 * final_t, 3500) + results_toy = odeint(pot.orbit_derivs2, initial, timeseries_2, args=(toy_pot,)) + + # and plot + f, a = plt.subplots(2, 3, figsize=[3.32, 5.5]) + a[0, 0] = plt.subplot2grid((3, 2), (0, 0)) + a[1, 0] = plt.subplot2grid((3, 2), (0, 1)) + a[0, 1] = plt.subplot2grid((3, 2), (1, 0)) + a[1, 1] = plt.subplot2grid((3, 2), (1, 1)) + a[0, 2] = plt.subplot2grid((3, 2), (2, 0), colspan=2) + plt.subplots_adjust(wspace=0.5, hspace=0.45) + + # xy orbit + a[0, 0].plot(results.T[0], results.T[1], "k") + a[0, 0].set_xlabel(r"$x/{\rm kpc}$") + a[0, 0].set_ylabel(r"$y/{\rm kpc}$") + a[0, 0].xaxis.set_major_locator(MaxNLocator(5)) + # xz orbit + a[1, 0].plot(results.T[0], results.T[2], "k") + a[1, 0].set_xlabel(r"$x/{\rm kpc}$") + a[1, 0].set_ylabel(r"$z/{\rm kpc}$") + a[1, 0].xaxis.set_major_locator(MaxNLocator(5)) + # toy orbits + a[0, 0].plot(results_toy.T[0], results_toy.T[1], "r", alpha=0.2, linewidth=0.3) + a[1, 0].plot(results_toy.T[0], results_toy.T[2], "r", alpha=0.2, linewidth=0.3) + + # Toy actions + a[0, 2].plot(Conv * timeseries, toy_aa.T[0], "k:", label="Toy action") + a[0, 2].plot(Conv * timeseries, toy_aa.T[1], "r:") + a[0, 2].plot(Conv * timeseries, toy_aa.T[2], "b:") + # Arrows to show approx. actions + arrow_end = a[0, 2].get_xlim()[1] + arrowd = 0.08 * (arrow_end - a[0, 2].get_xlim()[0]) + a[0, 2].annotate( + "", + (arrow_end + arrowd, act[0]), + (arrow_end, act[0]), + arrowprops={"arrowstyle": "<-", "color": "k"}, + annotation_clip=False, + ) + a[0, 2].annotate( + "", + (arrow_end + arrowd, act[1]), + (arrow_end, act[1]), + arrowprops={"arrowstyle": "<-", "color": "r"}, + annotation_clip=False, + ) + a[0, 2].annotate( + "", + (arrow_end + arrowd, act[2]), + (arrow_end, act[2]), + arrowprops={"arrowstyle": "<-", "color": "b"}, + annotation_clip=False, + ) + # True actions + a[0, 2].plot( + Conv * timeseries, + TT.action(results[0])[0] * np.ones(len(timeseries)), + "k", + label="True action", + ) + a[0, 2].plot( + Conv * timeseries, TT.action(results[0])[1] * np.ones(len(timeseries)), "k" + ) + a[0, 2].plot( + Conv * timeseries, TT.action(results[0])[2] * np.ones(len(timeseries)), "k" + ) + a[0, 2].set_xlabel(r"$t/{\rm Gyr}$") + a[0, 2].set_ylabel(r"$J/{\rm kpc\,km\,s}^{-1}$") + leg = a[0, 2].legend( + loc="upper center", bbox_to_anchor=(0.5, 1.2), ncol=3, numpoints=1 + ) + leg.draw_frame(False) + + # Toy angle coverage + a[0, 1].plot(toy_aa.T[3] / (np.pi), toy_aa.T[4] / (np.pi), "k.", markersize=0.4) + a[0, 1].set_xlabel(r"$\theta_1/\pi$") + a[0, 1].set_ylabel(r"$\theta_2/\pi$") + a[1, 1].plot(toy_aa.T[3] / (np.pi), toy_aa.T[5] / (np.pi), "k.", markersize=0.4) + a[1, 1].set_xlabel(r"$\theta_1/\pi$") + a[1, 1].set_ylabel(r"$\theta_3/\pi$") + + plt.savefig(file_output, bbox_inches="tight") + return act + + +if __name__ == "__main__": + BoxP = np.array([0.1, 0.1, 0.1, 142.0, 140.0, 251.0]) + LoopP = np.array([10.0, 1.0, 8.0, 40.0, 152.0, 63.0]) + ResP = np.array([0.1, 0.1, 0.1, 142.0, 150.0, 216.5]) + LongP = np.array([-0.5, 18.0, 0.5, 25.0, 20.0, -133.1]) + + # Short-axis Loop + LowestPeriodLoop = 2 * np.pi / 15.30362865 + # Fig 1 + loop = plot3D_stacktriax( + LoopP, 8 * LowestPeriodLoop, 6, "genfunc_3d_example_LT_Stack_Loop.pdf" + ) + # Fig 3 + vs.Sn_plots("GF.Sn_loop", "loop", loop, 1) + + # Box + LowestPeriodBox = 2.0 * np.pi / 38.86564386 + # Fig 2 + box = plot3D_stacktriax( + BoxP, 8 * LowestPeriodBox, 6, "genfunc_3d_example_LT_Stack_Box.pdf" + ) + # Fig 4 + vs.Sn_plots("GF.Sn_box", "box", box, 0) + + # Res + LowestPeriodRes = 2.0 * np.pi / 42.182 + # Fig 5 + res = plot3D_stacktriax( + ResP, 8 * LowestPeriodBox, 6, "genfunc_3d_example_LT_Stack_Res.pdf" + ) + # vs.Sn_plots('GF.Sn_box','box',res, 0) + + # Long-axis loop + LowestPeriodLong = 2.0 * np.pi / 12.3 diff --git a/gala/source/tests/dynamics/actionangle/_genfunc/solver.py b/gala/source/tests/dynamics/actionangle/_genfunc/solver.py new file mode 100644 index 0000000000000000000000000000000000000000..4456933d3a853c4bad763ec11251ed2073b276fb --- /dev/null +++ b/gala/source/tests/dynamics/actionangle/_genfunc/solver.py @@ -0,0 +1,178 @@ +################# +# AA Solvers # +################# +from itertools import product + +import numpy as np +from scipy.linalg import solve + + +def check_each_direction(n, angs, ifprint=True): + """returns a list of the index of elements of n which do not have adequate + toy angle coverage. The criterion is that we must have at least one sample + in each Nyquist box when we project the toy angles along the vector n""" + checks = np.array([]) + P = np.array([]) + if ifprint: + pass + for _k, i in enumerate(n): + np.linalg.norm(i) + X = np.dot(angs, i) + if (np.abs(np.max(X) - np.min(X)) < 2.0 * np.pi) or ( + np.abs(np.max(X) - np.min(X)) / len(X) > np.pi + ): + if ifprint: + pass + checks = np.append(checks, i) + P = np.append(P, (2.0 * np.pi - np.abs(np.max(X) - np.min(X)))) + if ifprint: + pass + return checks, P + + +def solver(AA, N_max, symNx=2, throw_out_modes=False): + """Constructs the matrix A and the vector b from a timeseries of toy + action-angles AA to solve for the vector x = (J_0, J_1, J_2, S...) where + x contains all Fourier components of the generating function with |n|-x, y->-y, z->-z + # This can be relaxed by changing symN to 1 + # Additionally due to time reversal symmetry S_n = -S_-n so we only consider + # "half" of the n-vector-space + + angs = unroll_angles(AA.T[3:].T, np.ones(3)) + + symNz = 2 + NNx = range(-N_max, N_max + 1, symNx) + NNy = range(-N_max, N_max + 1, symNz) + NNz = range(-N_max, N_max + 1, symNz) + n_vectors = np.array( + [ + [i, j, k] + for (i, j, k) in product(NNx, NNy, NNz) + if ( + not (i == 0 and j == 0 and k == 0) # exclude zero vector + and ( + k > 0 # northern hemisphere + or (k == 0 and j > 0) # half of x-y plane + or (k == 0 and j == 0 and i > 0) + ) # half of x axis + and np.sqrt(i * i + j * j + k * k) <= N_max + ) + ] + ) # inside sphere + + check_each_direction(n_vectors, angs) + + if throw_out_modes: + n_vectors = np.delete(n_vectors, check_each_direction(n_vectors, angs), axis=0) + + n = len(n_vectors) + 3 + b = np.zeros(shape=(n,)) + a = np.zeros(shape=(n, n)) + + a[:3, :3] = len(AA) * np.identity(3) + + for i in AA: + a[:3, 3:] += 2.0 * n_vectors.T[:3] * np.cos(np.dot(n_vectors, i[3:])) + a[3:, 3:] += ( + 4.0 + * np.dot(n_vectors, n_vectors.T) + * np.outer( + np.cos(np.dot(n_vectors, i[3:])), np.cos(np.dot(n_vectors, i[3:])) + ) + ) + b[:3] += i[:3] + b[3:] += 2.0 * np.dot(n_vectors, i[:3]) * np.cos(np.dot(n_vectors, i[3:])) + + a[3:, :3] = a[:3, 3:].T + + return np.array(solve(a, b)), n_vectors + + +def unroll_angles(A, sign): + """Unrolls the angles, A, so they increase continuously""" + n = np.array([0, 0, 0]) + P = np.zeros(np.shape(A)) + P[0] = A[0] + for i in range(1, len(A)): + n = ( + n + + ((A[i] - A[i - 1] + 0.5 * sign * np.pi) * sign < 0) + * np.ones(3) + * 2.0 + * np.pi + ) + P[i] = A[i] + sign * n + return P + + +def angle_solver(AA, timeseries, N_max, sign, symNx=2, throw_out_modes=False): + """Constructs the matrix A and the vector b from a timeseries of toy + action-angles AA to solve for the vector x = (theta_0, theta_1, theta_2, omega_1, + omega_2, omega_3, dSdx..., dSdy..., dSdz...) where x contains all derivatives + of the Fourier components of the generating function with |n| < N_max""" + + # First unroll angles + angs = unroll_angles(AA.T[3:].T, sign) + + # Same considerations as above + symNz = 2 + NNx = range(-N_max, N_max + 1, symNx) + NNy = range(-N_max, N_max + 1, symNz) + NNz = range(-N_max, N_max + 1, symNz) + n_vectors = np.array( + [ + [i, j, k] + for (i, j, k) in product(NNx, NNy, NNz) + if ( + not (i == 0 and j == 0 and k == 0) # exclude zero vector + and ( + k > 0 # northern hemisphere + or (k == 0 and j > 0) # half of x-y plane + or (k == 0 and j == 0 and i > 0) + ) # half of x axis + and np.sqrt(i * i + j * j + k * k) <= N_max # inside sphere + ) + ] + ) + + if throw_out_modes: + n_vectors = np.delete(n_vectors, check_each_direction(n_vectors, angs), axis=0) + + nv = len(n_vectors) + n = 3 * nv + 6 + + b = np.zeros(shape=(n,)) + a = np.zeros(shape=(n, n)) + + a[:3, :3] = len(AA) * np.identity(3) + a[:3, 3:6] = np.sum(timeseries) * np.identity(3) + a[3:6, :3] = a[:3, 3:6] + a[3:6, 3:6] = np.sum(timeseries * timeseries) * np.identity(3) + + for i, j in zip(angs, timeseries): + a[6 : 6 + nv, 0] += -2.0 * np.sin(np.dot(n_vectors, i)) + a[6 : 6 + nv, 3] += -2.0 * j * np.sin(np.dot(n_vectors, i)) + a[6 : 6 + nv, 6 : 6 + nv] += 4.0 * np.outer( + np.sin(np.dot(n_vectors, i)), np.sin(np.dot(n_vectors, i)) + ) + + b[:3] += i + b[3:6] += j * i + + b[6 : 6 + nv] += -2.0 * i[0] * np.sin(np.dot(n_vectors, i)) + b[6 + nv : 6 + 2 * nv] += -2.0 * i[1] * np.sin(np.dot(n_vectors, i)) + b[6 + 2 * nv : 6 + 3 * nv] += -2.0 * i[2] * np.sin(np.dot(n_vectors, i)) + + a[6 + nv : 6 + 2 * nv, 1] = a[6 : 6 + nv, 0] + a[6 + 2 * nv : 6 + 3 * nv, 2] = a[6 : 6 + nv, 0] + a[6 + nv : 6 + 2 * nv, 4] = a[6 : 6 + nv, 3] + a[6 + 2 * nv : 6 + 3 * nv, 5] = a[6 : 6 + nv, 3] + a[6 + nv : 6 + 2 * nv, 6 + nv : 6 + 2 * nv] = a[6 : 6 + nv, 6 : 6 + nv] + a[6 + 2 * nv : 6 + 3 * nv, 6 + 2 * nv : 6 + 3 * nv] = a[6 : 6 + nv, 6 : 6 + nv] + + a[:6, :] = a[:, :6].T + + return np.array(solve(a, b)) diff --git a/gala/source/tests/dynamics/actionangle/_genfunc/test_potentials.py b/gala/source/tests/dynamics/actionangle/_genfunc/test_potentials.py new file mode 100644 index 0000000000000000000000000000000000000000..96e6042b9e449bdce7c8a1bfceedd24f847955eb --- /dev/null +++ b/gala/source/tests/dynamics/actionangle/_genfunc/test_potentials.py @@ -0,0 +1,325 @@ +################# +# Potentials # +################# +import numpy as np +from scipy.integrate import ode, quad + +Grav = 430091.7270069976 + + +class LMPot: + """Potential used in Law-Majewski 2010""" + + def __init__(self): + """Best-fit parameters - units = kpc, km/s and 10^11 M_sol""" + self.M_disk = 1.0 + self.a_disk = 6.5 + self.b_disk = 0.26 + self.M_bulge = 0.34 + self.c_bulge = 0.7 + self.vhalo2 = 121.7**2 + phi = 97.0 / 180.0 * np.pi + q1 = 1.38 + q2 = 1.0 + self.C_1 = (np.cos(phi) / q1) ** 2 + (np.sin(phi) / q2) ** 2 + self.C_2 = (np.cos(phi) / q2) ** 2 + (np.sin(phi) / q1) ** 2 + self.C_3 = 2.0 * np.sin(phi) * np.cos(phi) * (1.0 / q1 / q1 - 1.0 / q2 / q2) + self.q_z = 1.36 + self.rhalo2 = 144.0 + rot90 = np.array([[0.0, 1.0], [-1.0, 0.0]]) + self.rotmatrix = np.dot( + rot90, + np.linalg.svd( + np.array([[self.C_1, self.C_3 / 2.0], [self.C_3 / 2.0, self.C_2]]) + )[0], + ) + self.invrotmatrix = np.linalg.inv(self.rotmatrix) + + def disk_pot(self, x, y, z): + R = np.sqrt(x * x + y * y) + return ( + -Grav + * self.M_disk + / np.sqrt( + R * R + (self.a_disk + np.sqrt(z * z + self.b_disk * self.b_disk)) ** 2 + ) + ) + + def disk_force(self, x, y, z): + R = np.sqrt(x * x + y * y) + e = self.a_disk + np.sqrt(z * z + self.b_disk * self.b_disk) + d = -Grav * self.M_disk / np.sqrt(R * R + e**2) ** 3 + return np.array([x * d, y * d, z * d * e / (e - self.a_disk)]) + + def bulge_pot(self, x, y, z): + r = np.sqrt(x * x + y * y + z * z) + return -Grav * self.M_bulge / (r + self.c_bulge) + + def bulge_force(self, x, y, z): + r = np.sqrt(x * x + y * y + z * z) + if r == 0.0: + return -Grav * self.M_bulge / (r + self.c_bulge) ** 2 + else: + return ( + -Grav * self.M_bulge / (r + self.c_bulge) ** 2 / r * np.array([x, y, z]) + ) + + def halo_pot(self, x, y, z): + return self.vhalo2 * np.log( + self.C_1 * x * x + + self.C_2 * y * y + + self.C_3 * x * y + + (z / self.q_z) ** 2 + + self.rhalo2 + ) + + def halo_force(self, x, y, z): + p = -self.vhalo2 / ( + self.C_1 * x * x + + self.C_2 * y * y + + self.C_3 * x * y + + (z / self.q_z) ** 2 + + self.rhalo2 + ) + return np.array( + [ + (2.0 * x * self.C_1 + self.C_3 * y) * p, + (2.0 * y * self.C_2 + self.C_3 * x) * p, + 2.0 * z * p / self.q_z**2, + ] + ) + + def tot_pot(self, x, y, z): + return self.disk_pot(x, y, z) + self.bulge_pot(x, y, z) + self.halo_pot(x, y, z) + + def H(self, X): + return 0.5 * np.sum(X[3:] ** 2) + self.tot_pot(*X[:3]) + + def tot_force(self, x, y, z): + return ( + self.disk_force(x, y, z) + + self.bulge_force(x, y, z) + + self.halo_force(x, y, z) + ) + + def coordrot(self, x, y): + return np.dot(self.rotmatrix, np.array([x, y])) + + def invcoordrot(self, x, y): + return np.dot(self.invrotmatrix, np.array([x, y])) + + +class log_triax: + r"""test triaxial logarithmic potential + Phi(x, y, z) = 0.5 v_c^2 log(Rc^2+x^2+(y/qy)^2+(z/qz)^2)""" + + def __init__(self, vc, Rc, qy, qz): + self.vc2 = vc * vc + self.Rc2 = Rc * Rc + self.qy2 = qy * qy + self.qz2 = qz * qz + + def pot(self, x, y, z): + return ( + self.vc2 + / 2.0 + * np.log(self.Rc2 + x * x + y * y / self.qy2 + z * z / self.qz2) + ) + + def H(self, X): + return 0.5 * np.sum(X[3:] ** 2) + self.pot(*X[:3]) + + def tot_force(self, x, y, z): + p = self.Rc2 + x * x + y * y / self.qy2 + z * z / self.qz2 + return -self.vc2 / p * np.array([x, y / self.qy2, z / self.qz2]) + + +class quartic: + r"""Quartic potential + Phi(x, y, z) = 0.25(lam[0] x^4+lam[1] y^4+lam[2] z^4. + """ + + def __init__(self, lam=np.array([1.0, 0.8, 3.3])): + self.lambd = lam + + def H(self, x): + """Quartic potential Hamiltonian""" + return 0.5 * np.sum(x[3:] ** 2 + 0.5 * self.lambd * x[:3] ** 4) + + def tot_force(self, x, y, z): + """Derivatives of quartic potential for orbit integration""" + return np.array( + [-self.lambd[0] * x**3, -self.lambd[1] * y**3, -self.lambd[2] * z**3] + ) + + def action(self, x): + r"""Find true action for quartic potential \Phi = \sum_i 0.25*x_i**4""" + acts = np.ones(3) + for i in range(3): + En = 0.5 * x[i + 3] ** 2 + 0.25 * self.lambd[i] * x[i] ** 4 + xlim = (4.0 * En / self.lambd[i]) ** 0.25 + acts[i] = ( + 2.0 + * quad( + lambda y: np.sqrt(2.0 * En - 0.5 * self.lambd[i] * y**4), 0.0, xlim + )[0] + / np.pi + ) + return acts + + def freq(self, x): + r"""Find true freq. for quartic potential \Phi = 0.25*x**4""" + freq = np.ones(3) + for i in range(3): + En = 0.5 * x[i + 3] ** 2 + 0.25 * self.lambd[i] * x[i] ** 4 + xlim = (4.0 * En / self.lambd[i]) ** 0.25 + freq[i] = ( + np.pi + / quad( + lambda y: 2.0 / np.sqrt(2.0 * En - 0.5 * self.lambd[i] * y**4), + 0.0, + xlim, + )[0] + ) + return freq + + +# sys.path.append("new_struct") +# import triax_py + + +class stackel_triax: + """For interface with C code to find actions in triaxial Stackel potential""" + + def __init__(self): + pass + + def H(self, x): + """triaxial stackel potential Hamiltonian""" + return triax_py.Stack_Triax_H(x) + + def tot_force(self, x, y, z): + """Derivatives of triaxial stackel potential for orbit integration""" + X = np.array([x, y, z]) + return triax_py.Stack_Triax_Forces(X) + + def action(self, x): + """Find true action for triaxial stackel potential""" + return triax_py.Stack_Triax_Actions(x) + + def freq(self, x): + """Find true action for triaxial stackel potential""" + return triax_py.Stack_Triax_Freqs(x) + + +class harmonic_oscillator: + """ + Triaxial harmonic oscillator + Phi(x, y, z) = 0.5*(omega[0]^2 x^2+omega[1]^2 y^2 + omega[2]^2 z^2 + """ + + def __init__(self, omega=np.array([1.0, 1.0, 1.0])): + self.omega = omega + + def H(self, x): + """Hamiltonian""" + return 0.5 * np.sum(x[3:] ** 2 + (self.omega * x[:3]) ** 2) + + def tot_force(self, x, y, z): + """Derivatives of ho potential for orbit integration""" + return -np.array( + [self.omega[0] ** 2 * x, self.omega[1] ** 2 * y, self.omega[2] ** 2 * z] + ) + + +class isochrone: + """ + Isochrone potential + Phi(r) = -GM/(b+sqrt(b^2+r^2)) + """ + + def __init__(self, par=np.array([1.0 / Grav, 4.2, 0.0])): + """params = {M, b, r0}""" + self.params = par + + def H(self, x): + """Hamiltonian""" + r = (np.sqrt(np.sum(x[:3] ** 2)) - self.params[2]) ** 2 + return 0.5 * np.sum(x[3:] ** 2) - Grav * self.params[0] / ( + self.params[1] + np.sqrt(self.params[1] ** 2 + r) + ) + + def pot(self, x): + r = (np.sqrt(np.sum(x[:3] ** 2)) - self.params[2]) ** 2 + return ( + -Grav * self.params[0] / (self.params[1] + np.sqrt(self.params[1] ** 2 + r)) + ) + + def tot_force(self, x, y, z): + """Derivatives of isochrone potential for orbit integration""" + r = (np.sqrt(x * x + y * y + z * z) - self.params[2]) ** 2 + fac = np.sqrt(r) / (np.sqrt(r) + self.params[2]) + return ( + np.array([x, y, z]) + * fac + * -Grav + * self.params[0] + / (self.params[1] + np.sqrt(self.params[1] ** 2 + r)) ** 2 + / np.sqrt(self.params[1] ** 2 + r) + ) + + +def orbit_derivs(t, x, Pot): + """Simple interface for derivatives for orbit integration + t = time + x = Cartesian coordinates + Pot is an object which has a function tot_force(x, y, z) which + calculates the total force at Cartesian x, y, z""" + X = x[0] + Y = x[1] + Z = x[2] + return np.concatenate((x[3:], Pot.tot_force(X, Y, Z))) + + +def orbit_derivs2(x, t, Pot): + return orbit_derivs(t, x, Pot) + + +import warnings + + +def orbit_integrate(x, tmax, Pot): + """Integrates an orbit with initial coordinates x for time tmax in + potential Pot using Dormund Prince 8 adaptive step size""" + solver = ode(orbit_derivs).set_integrator( + "dopri5", n_steps=1, rtol=1e-10, atol=1e-10 + ) + solver.set_initial_value(x, 0.0).set_f_params(Pot) + solver._integrator.iwork[2] = -1 + warnings.filterwarnings("ignore", category=UserWarning) + t = np.array([0.0]) + while solver.t < tmax: + solver.integrate(tmax) + x = np.vstack((x, solver.y)) + t = np.append(t, solver.t) + warnings.resetwarnings() + return x, t + + +def leapfrog_integrator(x, tmax, NT, Pot): + deltat = tmax / NT + h = deltat / 100.0 + t = 0.0 + counter = 0 + X = np.copy(x) + results = np.array([x]) + while t < tmax: + X[3:] += 0.5 * h * Pot.tot_force(X[0], X[1], X[2]) + X[:3] += h * X[3:] + X[3:] += 0.5 * h * Pot.tot_force(X[0], X[1], X[2]) + # if(t==0.1): + if counter % 100 == 0: + results = np.vstack((results, X)) + t += h + counter += 1 + return results diff --git a/gala/source/tests/dynamics/actionangle/_genfunc/toy_potentials.py b/gala/source/tests/dynamics/actionangle/_genfunc/toy_potentials.py new file mode 100644 index 0000000000000000000000000000000000000000..03fa133c70bb93a95de9be14e96e31750e16904f --- /dev/null +++ b/gala/source/tests/dynamics/actionangle/_genfunc/toy_potentials.py @@ -0,0 +1,185 @@ +################## +# Toy Potentials # +################## +import numpy as np +from scipy.optimize import leastsq + +# in units kpc, km/s and 10^11 M_solar +# Grav = 430091.5694 +Grav = 430091.7270069976 # This was a bug in Sanders' code! + +# Triaxial harmonic + + +def H_ho(x, omega): + """Simple harmonic oscillator Hamiltonian = 0.5 * omega**2 * x**2""" + return 0.5 * np.sum(x[3:] ** 2 + (omega * x[:3]) ** 2) + + +def angact_ho(x, omega): + """Calculate angle and action variable in sho potential with + parameter omega""" + action = (x[3:] ** 2 + (omega * x[:3]) ** 2) / (2.0 * omega) + angle = np.array( + [ + np.arctan(-x[3 + i] / omega[i] / x[i]) + if x[i] != 0.0 + else -np.sign(x[3 + i]) * np.pi / 2.0 + for i in range(3) + ] + ) + for i in range(3): + if x[i] < 0: + angle[i] += np.pi + return np.concatenate((action, angle % (2.0 * np.pi))) + + +def deltaH_ho(omega, xsamples): + if np.any(omega < 1e-5): + return np.nan + H = 0.5 * np.sum(xsamples.T[3:] ** 2, axis=0) + 0.5 * np.sum( + (omega[:3] * xsamples.T[:3].T) ** 2, axis=1 + ) + return H - np.mean(H) + + +def Jac_deltaH_ho(omega, xsamples): + dHdparams = omega[:3] * xsamples.T[:3].T ** 2 + return dHdparams - np.mean(dHdparams, axis=0) + + +def findbestparams_ho(xsamples): + """Minimize sum of square differences of H_sho- for timesamples""" + return np.abs( + leastsq( + deltaH_ho, + np.array([10.0, 10.0, 10.0]), + Dfun=Jac_deltaH_ho, + args=(xsamples,), + )[0] + )[:3] + + +# Isochrone + + +def cart2spol(X): + """Performs coordinate transformation from cartesian + to spherical polar coordinates with (r, phi, theta) having + usual meanings.""" + x, y, z, vx, vy, vz = X + r = np.sqrt(x * x + y * y + z * z) + p = np.arctan2(y, x) + t = np.arccos(z / r) + vr = (vx * np.cos(p) + vy * np.sin(p)) * np.sin(t) + np.cos(t) * vz + vp = -vx * np.sin(p) + vy * np.cos(p) + vt = (vx * np.cos(p) + vy * np.sin(p)) * np.cos(t) - np.sin(t) * vz + return np.array([r, p, t, vr, vp, vt]) + + +def H_iso(x, params): + """Isochrone Hamiltonian = -GM/(b+sqrt(b**2+(r-r0)**2))""" + # r = (np.sqrt(np.sum(x[:3]**2))-params[2])**2 + r = np.sum(x[:3] ** 2) + return 0.5 * np.sum(x[3:] ** 2) - Grav * params[0] / ( + params[1] + np.sqrt(params[1] ** 2 + r) + ) + + +def angact_iso(x, params): + """Calculate angle and action variable in isochrone potential with + parameters params = (M, b)""" + GM = Grav * params[0] + E = H_iso(x, params) + r, p, t, vr, vphi, vt = cart2spol(x) + st = np.sin(t) + Lz = r * vphi * st + L = np.sqrt(r * r * vt * vt + Lz * Lz / st / st) + if E > 0.0: # Unbound + return (np.nan, np.nan, np.nan, np.nan, np.nan, np.nan) + Jr = GM / np.sqrt(-2 * E) - 0.5 * (L + np.sqrt(L * L + 4 * GM * params[1])) + action = np.array([Jr, Lz, L - abs(Lz)]) + + c = GM / (-2 * E) - params[1] + e = np.sqrt(1 - L * L * (1 + params[1] / c) / GM / c) + eta = np.arctan2( + r * vr / np.sqrt(-2.0 * E), params[1] + c - np.sqrt(params[1] ** 2 + r * r) + ) + OmR = np.power(-2 * E, 1.5) / GM + Omp = 0.5 * OmR * (1 + L / np.sqrt(L * L + 4 * GM * params[1])) + thetar = eta - e * c * np.sin(eta) / (c + params[1]) + + if abs(vt) > 1e-10: + psi = np.arctan2(np.cos(t), -np.sin(t) * r * vt / L) + else: + psi = np.pi / 2.0 + a = np.sqrt((1 + e) / (1 - e)) + ap = np.sqrt((1 + e + 2 * params[1] / c) / (1 - e + 2 * params[1] / c)) + + def F(x, y): + return ( + np.pi / 2.0 - np.arctan(np.tan(np.pi / 2.0 - 0.5 * y) / x) + if y > np.pi / 2.0 + else -np.pi / 2.0 + np.arctan(np.tan(np.pi / 2.0 + 0.5 * y) / x) + if y < -np.pi / 2.0 + else np.arctan(x * np.tan(0.5 * y)) + ) + + thetaz = ( + psi + + Omp * thetar / OmR + - F(a, eta) + - F(ap, eta) / np.sqrt(1 + 4 * GM * params[1] / L / L) + ) + + LR = Lz / L + sinu = LR / np.sqrt(1.0 - LR**2) / np.tan(t) + u = 0 + if sinu > 1.0: + u = np.pi / 2.0 + elif sinu < -1.0: + u = -np.pi / 2.0 + else: + u = np.arcsin(sinu) + if vt > 0.0: + u = np.pi - u + thetap = p - u + np.sign(Lz) * thetaz + angle = np.array([thetar, thetap, thetaz]) + return np.concatenate((action, angle % (2.0 * np.pi))) + + +def deltaH_iso(params, p, r): + deltaH = p - Grav * params[0] / (params[1] + np.sqrt(params[1] ** 2 + r)) + if params[0] < 0.0 or params[1] < 0.0 or np.any(deltaH > 0.0): + return np.nan + return deltaH - np.mean(deltaH) + # return JR-np.mean(JR) + + +def Jac_deltaH_iso(params, p, r): + H_o = -Grav / (params[1] + np.sqrt(params[1] ** 2 + r)) + H_1 = ( + Grav + * params[0] + * (1.0 + params[1] / np.sqrt(params[1] ** 2 + r)) + / (params[1] + np.sqrt(params[1] ** 2 + r)) ** 2 + ) + return np.array([(H_o - np.mean(H_o)), (H_1 - np.mean(H_1))]) + + +def findbestparams_iso(xsamples): + """Minimize sum of square differences of H_iso- for timesamples""" + p = 0.5 * np.sum(xsamples.T[3:] ** 2, axis=0) + r = np.sum(xsamples.T[:3] ** 2, axis=0) + return np.abs( + leastsq( + deltaH_iso, + np.array([10.0, 10.0]), + Dfun=None, + col_deriv=1, + args=( + p, + r, + ), + )[0] + ) # Jac_deltaH_iso diff --git a/gala/source/tests/dynamics/actionangle/_genfunc/visualize_surfaces.py b/gala/source/tests/dynamics/actionangle/_genfunc/visualize_surfaces.py new file mode 100644 index 0000000000000000000000000000000000000000..7f6d09b87ec8dcfad7ac4a020e01671d052e1fa8 --- /dev/null +++ b/gala/source/tests/dynamics/actionangle/_genfunc/visualize_surfaces.py @@ -0,0 +1,104 @@ +# For plotting the S_n + +import matplotlib.cm as cmx +import matplotlib.pyplot as plt +import numpy as np +from matplotlib import colors + + +def meshgrid2(*arrs): + arrs = tuple(reversed(arrs)) # edit + lens = map(len, arrs) + dim = len(arrs) + + sz = 1 + for s in lens: + sz *= s + + ans = [] + for i, arr in enumerate(arrs): + slc = [1] * dim + slc[i] = lens[i] + arr2 = np.asarray(arr).reshape(slc) + for j, sz in enumerate(lens): + if j != i: + arr2 = arr2.repeat(sz, axis=j) + ans.append(arr2) + + return tuple(ans) + + +def conv(x, acts): + return np.log((np.exp(x) - 0.1) / acts + 0.1) + + +def Sn_plots(inp, outp, actions, loop): + Sn = np.genfromtxt(inp) + loop_acts = actions + acts = np.sum(loop_acts) + acts = 1 + dx = 2 + if loop: + dx = 1 + x = np.arange(np.min(Sn.T[0]) - 2, np.max(Sn.T[0]) + 2, dx) + y = np.arange(np.min(Sn.T[1]) - 4, np.max(Sn.T[1]) + 4, 2) + z = np.arange(-np.max(Sn.T[2]) - 2, np.max(Sn.T[2]) + 4, 2) + length_x = len(x) + length_y = len(y) + length_z = len(z) + + X, Y, Z = meshgrid2(x, y, z) + S = np.zeros(np.shape(X.T)) + for i in Sn: + xindex = np.where(np.abs(x - i[0]) < 1e-5)[0][0] + yindex = np.where(np.abs(y - i[1]) < 1e-5)[0][0] + zindex = np.where(np.abs(z - i[2]) < 1e-5)[0][0] + S[xindex][yindex][zindex] = np.log(np.abs(i[3] / acts) + 0.1) + S[length_x - xindex - 1][length_y - yindex - 1][length_z - zindex - 1] = np.log( + np.abs(i[3] / acts) + 0.1 + ) + + R = np.zeros((length_x, length_z)) + np.zeros((length_x, length_y)) + for j, i in enumerate(S): + R[j] = i[length_y / 2] + + f, a = plt.subplots(2, 1, figsize=[3.32, 3.6]) + plt.subplots_adjust(right=0.75, wspace=0.4, hspace=0.3) + conts = (conv(x, acts) for x in [0.15, 0.5, 1.0, 1.5, 2.0, 2.5]) + if loop: + a[0].set_xlim(-6, 6) + a[0].set_ylim(-6, 6) + a[1].set_xlim(-6, 6) + a[1].set_ylim(-6, 6) + else: + a[0].set_xlim(-6, 6) + a[0].set_ylim(-6, 6) + a[1].set_xlim(-6, 6) + a[1].set_ylim(-6, 6) + + a[0].contour(x, y, S.T[length_z / 2], levels=conts) + a[0].set_xlabel(r"$n_1$") + a[0].set_ylabel(r"$n_2$") + a[0].set_aspect("equal") + a[0].text(2, 4, r"$n_3=0$") + a[0].text(3, 7, outp) + + R = np.zeros((length_x, length_z)) + for j, i in enumerate(S): + R[j] = i[length_y / 2] + + cNorm = colors.Normalize(vmin=np.min(conts), vmax=np.max(conts)) + sM = cmx.ScalarMappable(norm=cNorm) + sM._A = [] + a[1].contour(x, z, R.T, levels=conts) + # print R.T + a[1].set_aspect("equal") + a[1].set_xlabel(r"$n_1$") + a[1].set_ylabel(r"$n_3$") + a[1].text(2, 4.5, r"$n_2=0$") + # a[1].set_xlim(np.min(y), np.max(y)) + cbar_ax = f.add_axes([0.75, 0.15, 0.05, 0.7]) + ccc = f.colorbar(sM, cax=cbar_ax) + ccc.set_label(r"$\log(|S_n / \rm{kpc}\,\rm{km}\,\rm{s}^{-1}|+0.1)$") + plt.savefig(outp + "_planes.pdf") diff --git a/gala/source/tests/dynamics/actionangle/actionangle_helpers.py b/gala/source/tests/dynamics/actionangle/actionangle_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..bada61d406131befad4ffbf90d0782baceae3f5f --- /dev/null +++ b/gala/source/tests/dynamics/actionangle/actionangle_helpers.py @@ -0,0 +1,151 @@ +"""Test helpers""" + +import astropy.coordinates as coord +import astropy.units as u +import matplotlib.pyplot as plt +import numpy as np +from _genfunc import genfunc_3d + +from gala.potential import HarmonicOscillatorPotential, IsochronePotential +from gala.units import galactic + + +def sanders_nvecs(N_max, dx, dy, dz): + from itertools import product + + NNx = range(-N_max, N_max + 1, dx) + NNy = range(-N_max, N_max + 1, dy) + NNz = range(-N_max, N_max + 1, dz) + return np.array( + [ + [i, j, k] + for (i, j, k) in product(NNx, NNy, NNz) + if ( + not (i == 0 and j == 0 and k == 0) # exclude zero vector + and ( + k > 0 # northern hemisphere + or (k == 0 and j > 0) # half of x-y plane + or (k == 0 and j == 0 and i > 0) + ) # half of x axis + and np.sqrt(i * i + j * j + k * k) <= N_max + ) + ] + ) # inside sphere + + +def sanders_act_ang_freq(t, w, circ, N_max=6): + w2 = w.copy() + + if np.any(circ): + w2[3:] = (w2[3:] * u.kpc / u.Myr).to(u.km / u.s).value + (act, ang, n_vec, toy_aa, pars), loop2 = genfunc_3d.find_actions( + w2.T, t / 1000.0, N_matrix=N_max, ifloop=True + ) + else: + (act, ang, _n_vec, _toy_aa, pars), _loop2 = genfunc_3d.find_actions( + w2.T, t, N_matrix=N_max, ifloop=True + ) + + actions = act[:3] + angles = ang[:3] + freqs = ang[3:6] + + if np.any(circ): + toy_potential = IsochronePotential(m=pars[0] * 1e11, b=pars[1], units=galactic) + actions = (actions * u.kpc * u.km / u.s).to(u.kpc**2 / u.Myr).value + freqs = (freqs / u.Gyr).to(1 / u.Myr).value + else: + toy_potential = HarmonicOscillatorPotential( + omega=np.array(pars), units=galactic + ) + + return actions, angles, freqs, toy_potential + + +def _crazy_angle_loop(theta1, theta2, ax): + cnt = 0 + ix1 = 0 + while True: + cnt += 1 + + for ix2 in range(ix1, ix1 + 1000): + if ix2 > len(theta1) - 1: + ix2 = len(theta1) - 1 + break + + if theta1[ix2] < theta1[ix1] or theta2[ix2] < theta2[ix1]: + ix2 -= 1 + break + + if ( + theta1[ix2] != theta1[ix1 : ix2 + 1].max() + or theta2[ix2] != theta2[ix1 : ix2 + 1].max() + ): + ix1 = ix2 + 1 + continue + + if cnt > 100 or ix2 == len(theta1) - 1: + break + + if ix1 == ix2: + ix1 = ix2 + 1 + continue + + ax.plot( + theta1[ix1 : ix2 + 1], theta2[ix1 : ix2 + 1], alpha=0.5, marker="o", c="k" + ) + + ix1 = ix2 + 1 + + +def plot_angles(t, angles, freqs, subsample_factor=1000): + theta = angles[:, None] + freqs[:, None] * t[np.newaxis] + subsample = theta.shape[1] // subsample_factor + # subsample = 1 + theta = (theta[:, ::subsample] / np.pi) % 2.0 + print(theta.shape) + fig, axes = plt.subplots(1, 2, sharex=True, sharey=True, figsize=(10, 5)) + # _crazy_angle_loop(theta[0], theta[1], axes[0]) + # _crazy_angle_loop(theta[0], theta[2], axes[1]) + axes[0].plot(theta[0], theta[1], ls="none") + axes[0].plot(theta[0], theta[2], ls="none") + + axes[0].set_xlim(0, 2) + axes[0].set_ylim(0, 2) + return fig + # axes[1].scatter(theta[0, ix], theta[2], alpha=0.5, marker='o', c=t) + + +def isotropic_w0(N=100): + # positions + d = np.random.lognormal(mean=np.log(25), sigma=0.5, size=N) + phi = np.random.uniform(0, 2 * np.pi, size=N) + theta = np.arccos(np.random.uniform(size=N) - 0.5) + + vr = np.random.normal(150.0, 40.0, size=N) * u.km / u.s + vt = np.random.normal(100.0, 40.0, size=N) + vt = np.vstack((vt, np.zeros_like(vt))).T + + # rotate to be random position angle + pa = np.random.uniform(0, 2 * np.pi, size=N) + M = np.array([[np.cos(pa), -np.sin(pa)], [np.sin(pa), np.cos(pa)]]).T + vt = np.array([vv.dot(MM) for (vv, MM) in zip(vt, M)]) * u.km / u.s + vphi, vtheta = vt.T + + rep = coord.PhysicsSphericalRepresentation( + r=d * u.dimensionless_unscaled, phi=phi * u.radian, theta=theta * u.radian + ) + x = rep.represent_as(coord.CartesianRepresentation).xyz.T.value + + vr = vr.decompose(galactic).value * u.one + vphi = vphi.decompose(galactic).value * u.one + vtheta = vtheta.decompose(galactic).value * u.one + + vsph = coord.PhysicsSphericalDifferential( + d_phi=vphi / (d * np.sin(theta)), d_theta=vtheta / d, d_r=vr + ) + + with u.set_enabled_equivalencies(u.dimensionless_angles()): + v = vsph.represent_as(coord.CartesianDifferential, base=rep).d_xyz.value.T + + return np.hstack((x, v)).T diff --git a/gala/source/tests/dynamics/actionangle/staeckel_helpers.py b/gala/source/tests/dynamics/actionangle/staeckel_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..c45393991c0e4cc18bc96cb51b78546df36ec02d --- /dev/null +++ b/gala/source/tests/dynamics/actionangle/staeckel_helpers.py @@ -0,0 +1,88 @@ +from collections.abc import Iterable + +import astropy.coordinates as coord +import astropy.table as at +import astropy.units as u +import numpy as np + +from gala.dynamics import Orbit +from gala.dynamics.actionangle.actionangle_staeckel import get_staeckel_fudge_delta + +__all__ = ["galpy_find_actions_staeckel"] + + +def galpy_find_actions_staeckel(potential, w, mean=True, delta=None, ro=None, vo=None): + """ + Compute approximate actions, angles, and frequencies using the Staeckel + Fudge as implemented in Galpy. If you use this function, please also cite + Galpy in your work (Bovy 2015). + + Parameters + ---------- + potential : potential-like + A Gala potential instances. + w : `~gala.dynamics.PhaseSpacePosition` or `~gala.dynamics.Orbit` + Either a set of initial conditions / phase-space positions, or a set of + orbits computed in the input potential. + mean : bool (optional) + If an `~gala.dynamics.Orbit` is passed in, take the mean over actions + and frequencies. + delta : numeric, array-like (optional) + The focal length parameter, ∆, used by the Staeckel fudge. This is + computed if not provided. + ro : quantity-like (optional) + vo : quantity-like (optional) + + Returns + ------- + aaf : `astropy.table.QTable` + An Astropy table containing the actions, angles, and frequencies for + each input phase-space position or orbit. + + """ + from galpy.actionAngle import actionAngleStaeckel + + if delta is None: + delta = get_staeckel_fudge_delta(potential, w) + + galpy_potential = potential.as_interop("galpy", ro=ro, vo=vo) + if isinstance(galpy_potential, list): + ro = galpy_potential[0]._ro * u.kpc + vo = galpy_potential[0]._vo * u.km / u.s + else: + ro = galpy_potential._ro * u.kpc + vo = galpy_potential._vo * u.km / u.s + + if not isinstance(w, Orbit): + w = Orbit(w.pos[None], w.vel[None], t=[0.0] * potential.units["time"]) + + iter_ = [w] if w.norbits == 1 else w.orbit_gen() + + if isinstance(delta, u.Quantity): + delta = np.atleast_1d(delta) + + if not isinstance(delta, Iterable): + delta = [delta] * w.norbits + + if len(delta) != w.norbits: + raise ValueError( + "Input delta must have same shape as the inputted number of orbits" + ) + + rows = [] + for w_, delta_ in zip(iter_, delta): + o = w_.to_galpy_orbit(ro, vo) + aAS = actionAngleStaeckel(pot=galpy_potential, delta=delta_) + + aaf = aAS.actionsFreqsAngles(o) + aaf = { + "actions": np.array(aaf[:3]).T * ro * vo, + "freqs": np.array(aaf[3:6]).T * vo / ro, + "angles": coord.Angle(np.array(aaf[6:]).T * u.rad), + } + if mean: + aaf["actions"] = np.nanmean(aaf["actions"], axis=0) + aaf["freqs"] = np.nanmean(aaf["freqs"], axis=0) + aaf["angles"] = aaf["angles"][0] + rows.append(aaf) + return at.QTable(rows=rows) diff --git a/gala/source/tests/dynamics/actionangle/test_actionangle_o2gf.py b/gala/source/tests/dynamics/actionangle/test_actionangle_o2gf.py new file mode 100644 index 0000000000000000000000000000000000000000..8aff4170c591d7bbf64f08221fcdfd18d6b5614b --- /dev/null +++ b/gala/source/tests/dynamics/actionangle/test_actionangle_o2gf.py @@ -0,0 +1,255 @@ +"""Test action-angle stuff""" + +import logging +import warnings + +import astropy.units as u +import numpy as np +import pytest +from _genfunc import genfunc_3d, solver +from actionangle_helpers import isotropic_w0, sanders_act_ang_freq, sanders_nvecs +from scipy.linalg import solve + +from gala.dynamics.actionangle import ( + check_angle_sampling, + find_actions_o2gf, + fit_harmonic_oscillator, + fit_isochrone, + fit_toy_potential, + generate_n_vectors, +) +from gala.integrate import DOPRI853Integrator +from gala.logging import logger +from gala.potential import ( + Hamiltonian, + HarmonicOscillatorPotential, + IsochronePotential, + LeeSutoTriaxialNFWPotential, +) +from gala.units import galactic + +logger.setLevel(logging.DEBUG) + + +def test_generate_n_vectors(): + # test against Sanders' method + nvecs = generate_n_vectors(N_max=6, dx=2, dy=2, dz=2) + nvecs_sanders = sanders_nvecs(N_max=6, dx=2, dy=2, dz=2) + assert np.all(nvecs == nvecs_sanders) + + nvecs = generate_n_vectors(N_max=6, dx=1, dy=1, dz=1) + nvecs_sanders = sanders_nvecs(N_max=6, dx=1, dy=1, dz=1) + assert np.all(nvecs == nvecs_sanders) + + +def test_fit_isochrone(): + # integrate orbit in Isochrone potential, then try to recover it + true_m = 2.81e11 + true_b = 11.0 + potential = IsochronePotential(m=true_m, b=true_b, units=galactic) + H = Hamiltonian(potential) + orbit = H.integrate_orbit([15.0, 0, 0, 0, 0.2, 0], dt=2.0, n_steps=10000) + + fit_potential = fit_isochrone(orbit) + m, b = ( + fit_potential.parameters["m"].value, + fit_potential.parameters["b"].value, + ) + assert np.allclose(m, true_m, rtol=1e-2) + assert np.allclose(b, true_b, rtol=1e-2) + + +def test_fit_harmonic_oscillator(): + # integrate orbit in harmonic oscillator potential, then try to recover it + true_omegas = np.array([0.011, 0.032, 0.045]) + potential = HarmonicOscillatorPotential(omega=true_omegas, units=galactic) + H = Hamiltonian(potential) + orbit = H.integrate_orbit([15.0, 1, 2, 0, 0, 0], dt=2.0, n_steps=10000) + + fit_potential = fit_harmonic_oscillator(orbit) + omegas = fit_potential.parameters["omega"].value + assert np.allclose(omegas, true_omegas, rtol=1e-2) + + +def test_fit_toy_potential(): + # integrate orbit in both toy potentials, make sure correct one is chosen + true_m = 2.81e11 + true_b = 11.0 + true_potential = IsochronePotential(m=true_m, b=true_b, units=galactic) + H = Hamiltonian(true_potential) + orbit = H.integrate_orbit([15.0, 0, 0, 0, 0.2, 0], dt=2.0, n_steps=10000) + + potential = fit_toy_potential(orbit) + for k, v in true_potential.parameters.items(): + assert u.allclose(v, potential.parameters[k], rtol=1e-2) + + # ----------------------------------------------------------------- + true_omegas = np.array([0.011, 0.032, 0.045]) + true_potential = HarmonicOscillatorPotential(omega=true_omegas, units=galactic) + H = Hamiltonian(true_potential) + orbit = H.integrate_orbit([15.0, 1, 2, 0, 0, 0], dt=2.0, n_steps=10000) + + potential = fit_toy_potential(orbit) + + assert u.allclose( + potential.parameters["omega"], + true_potential.parameters["omega"], + rtol=1e-2, + ) + + +def test_check_angle_sampling(): + # frequencies + omegas = np.array([0.21, 0.3421, 0.4968]) + + # integer vectors + nvecs = generate_n_vectors(N_max=6) + + # loop over times with known failures: + # - first one fails needing longer integration time + # - second one fails needing finer sampling + for i, t in enumerate([np.linspace(0, 50, 500), np.linspace(0, 8000, 8000)]): + # periods = 2*np.pi/omegas + # print("Periods:", periods) + # print("N periods:", t.max() / periods) + + angles = t[np.newaxis] * omegas[:, np.newaxis] + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + _checks, failures = check_angle_sampling(nvecs, angles) + + assert np.all(failures == i) + + +class ActionsBase: + def test_classify(self): + # my classify + orb_type = self.orbit.circulation() + + # compare to Sanders' + for j in range(self.N): + sdrs = genfunc_3d.assess_angmom(self.w[..., j].T) + logger.debug(f"APW: {orb_type[:, j]}, Sanders: {sdrs}") + assert np.all(orb_type[:, j] == sdrs) + + def test_actions(self): + # t = self.t[::10] + t = self.t + + N_max = 6 + for n in range(self.N): + print("\n\n") + print(f"======================= Orbit {n} =======================") + # w = self.w[:, ::10, n] + w = self.w[..., n] + orb = self.orbit[:, n] + circ = orb.circulation() + + # get values from Sanders' code + print("Computing actions from genfunc...") + s_actions, s_angles, s_freqs, toy_potential = sanders_act_ang_freq( + t, w, circ, N_max=N_max + ) + + print("Computing actions with gala...") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + ret = find_actions_o2gf(orb, N_max=N_max, toy_potential=toy_potential) + actions = ret["actions"] + angles = ret["angles"] + freqs = ret["freqs"] + + print(f"Action ratio: {actions / s_actions}") + print(f"Angle ratio: {angles / s_angles}") + print(f"Freq ratio: {freqs / s_freqs}") + + assert np.allclose(actions.value, s_actions, rtol=1e-5) + assert np.allclose(angles.value, s_angles, rtol=1e-5) + assert np.allclose(freqs.value, s_freqs, rtol=1e-5) + + # logger.debug("Plotting orbit...") + # fig = plot_orbits(w, marker='.', alpha=0.2, linestyle='none') + # fig.savefig(str(self.plot_path.join("orbit_{}.png".format(n)))) + + # fig = plot_angles(t, angles, freqs) + # fig.savefig(str(self.plot_path.join("angles_{}.png".format(n)))) + + # fig = plot_angles(t, s_angles, s_freqs) + # fig.savefig(str(self.plot_path.join("angles_sanders_{}.png".format(n)))) + + # plt.close('all') + + # print("Plots saved at:", self.plot_path) + + +class TestActions(ActionsBase): + @pytest.fixture(autouse=True) + def _setup_method(self, tmpdir): + self.plot_path = tmpdir.mkdir("normal") + + self.units = galactic + self.potential = LeeSutoTriaxialNFWPotential( + v_c=0.2, r_s=20.0, a=1.0, b=0.77, c=0.55, units=galactic + ) + self.N = 8 + np.random.seed(42) + w0 = isotropic_w0(N=self.N) + n_steps = 20000 + + # integrate orbits + H = Hamiltonian(self.potential) + orbit = H.integrate_orbit( + w0, dt=2.0, n_steps=n_steps, Integrator=DOPRI853Integrator + ) + self.orbit = orbit + self.t = orbit.t.value + self.w = orbit.w() + + +def test_compare_action_prepare(): + from gala.dynamics.actionangle.actionangle_o2gf import ( + _action_prepare, + _angle_prepare, + ) + + logger.setLevel(logging.ERROR) + AA = np.random.uniform(0.0, 100.0, size=(1000, 6)) + t = np.linspace(0.0, 100.0, 1000) + + act_san, _n_vectors = solver.solver(AA, N_max=6, symNx=2) + A2, b2, n = _action_prepare(AA.T, N_max=6, dx=2, dy=2, dz=2) + act_apw = np.array(solve(A2, b2)) + + ang_san = solver.angle_solver(AA, t, N_max=6, symNx=2, sign=1) + A2, b2, _n = _angle_prepare(AA.T, t, N_max=6, dx=2, dy=2, dz=2) + ang_apw = np.array(solve(A2, b2)) + + assert np.allclose(act_apw, act_san) + # assert np.allclose(ang_apw, ang_san) + + # TODO: this could be critical -- why don't our angles agree? + + +def test_regression_113(): + """Test that fit_isochrone succeeds for a variety of orbits. See issue: + https://github.com/adrn/gala/issues/113 + """ + from gala.dynamics import PhaseSpacePosition + from gala.potential import Hamiltonian, MilkyWayPotential + + pot = MilkyWayPotential(version="v1") + + dt = 0.01 + n_steps = 50000 + + rvec = [0.3, 0, 0] * u.kpc + vinit = pot.circular_velocity(rvec)[0].to(u.km / u.s).value + vvec = [0, vinit * np.cos(0.01), vinit * np.sin(0.01)] * u.km / u.s + vvec *= 0.999 + + ics = PhaseSpacePosition(pos=rvec, vel=vvec) + H = Hamiltonian(pot) + orbit = H.integrate_orbit(ics, dt=dt, n_steps=n_steps) + toy_potential = fit_isochrone(orbit) + + assert u.allclose(toy_potential.energy(rvec), pot.energy(rvec), rtol=1e-2) diff --git a/gala/source/tests/dynamics/actionangle/test_actionangle_staeckel.py b/gala/source/tests/dynamics/actionangle/test_actionangle_staeckel.py new file mode 100644 index 0000000000000000000000000000000000000000..11052e2dd4c1ef92396aec8430faa273527ae05b --- /dev/null +++ b/gala/source/tests/dynamics/actionangle/test_actionangle_staeckel.py @@ -0,0 +1,132 @@ +import astropy.units as u +import numpy as np +import pytest +from astropy.constants import G +from staeckel_helpers import galpy_find_actions_staeckel + +import gala.potential as gp +from gala._optional_deps import HAS_GALPY +from gala.dynamics import PhaseSpacePosition +from gala.dynamics.actionangle import find_actions_o2gf, get_staeckel_fudge_delta +from gala.units import galactic + + +@pytest.mark.skipif(not HAS_GALPY, reason="requires galpy to run this test") +def test_staeckel_fudge_delta(): + import galpy.potential as galpy_pot + from galpy.actionAngle import estimateDeltaStaeckel + + ro = 8.1 * u.kpc + vo = 229 * u.km / u.s + + paired_potentials = [] + + # Miyamoto-Nagai + potential = gp.MiyamotoNagaiPotential( + m=6e10 * u.Msun, a=3 * u.kpc, b=0.3 * u.kpc, units=galactic + ) + amp = (G * potential.parameters["m"]).to_value(vo**2 * ro) + a = potential.parameters["a"].to_value(ro) + b = potential.parameters["b"].to_value(ro) + galpy_potential = galpy_pot.MiyamotoNagaiPotential(amp=amp, a=a, b=b, ro=ro, vo=vo) + paired_potentials.append((potential, galpy_potential)) + + # Hernquist + potential = gp.HernquistPotential(m=6e10 * u.Msun, c=0.3 * u.kpc, units=galactic) + amp = (G * potential.parameters["m"]).to_value(vo**2 * ro) + a = potential.parameters["c"].to_value(ro) + galpy_potential = galpy_pot.HernquistPotential(amp=amp, a=a, ro=ro, vo=vo) + paired_potentials.append((potential, galpy_potential)) + + # NFW + potential = gp.NFWPotential(m=6e11 * u.Msun, r_s=15.6 * u.kpc, units=galactic) + amp = (G * potential.parameters["m"]).to_value(vo**2 * ro) + a = potential.parameters["r_s"].to_value(ro) + galpy_potential = galpy_pot.NFWPotential(amp=amp, a=a, ro=ro, vo=vo) + paired_potentials.append((potential, galpy_potential)) + + # TEST: + # TODO: remove the randomness here + N = 1024 + rnd = np.random.default_rng(42) + w = PhaseSpacePosition( + pos=rnd.uniform(-10, 10, size=(3, N)) * u.kpc, + vel=rnd.uniform(-100, 100, size=(3, N)) * u.km / u.s, + ) + + R = w.cylindrical.rho.to_value(ro) + z = w.z.to_value(ro) + + for p, galpy_p in paired_potentials: + galpy_deltas = estimateDeltaStaeckel(galpy_p, R, z, no_median=True) + gala_deltas = get_staeckel_fudge_delta(p, w).value + assert np.allclose(gala_deltas, galpy_deltas, atol=1e-5, rtol=1e-3) + + +@pytest.mark.skipif(not HAS_GALPY, reason="requires galpy to run this test") +def test_find_actions_staeckel(): + """ + This test function performs some unit test checks of the API + """ + disk = gp.MiyamotoNagaiPotential(5e10, 3.5, 0.3, units=galactic) + halo = gp.NFWPotential.from_M200_c(1e12 * u.Msun, 15, units=galactic) + pot = disk + halo + + xyz = (np.zeros((3, 16)) + 1e-5) * u.kpc + xyz[0] = np.linspace(4, 20, xyz.shape[1]) * u.kpc + + vxyz = np.zeros((3, 16)) * u.km / u.s + vxyz[0] = 15 * u.km / u.s + vxyz[1] = pot.circular_velocity(xyz) + vxyz[2] = 15 * u.km / u.s + + w0_one = PhaseSpacePosition(xyz[:, 0], vxyz[:, 0]) + w0_many = PhaseSpacePosition(xyz, vxyz) + orbit_one = pot.integrate_orbit(w0_one, dt=1.0, n_steps=1000) + orbit_many = pot.integrate_orbit(w0_many, dt=1.0, n_steps=1000) + + inputs = [w0_one, w0_many, orbit_one, orbit_many] + shapes = [(1, 3), (xyz.shape[1], 3), (1, 3), (xyz.shape[1], 3)] + for w, colshape in zip(inputs, shapes): + aaf = galpy_find_actions_staeckel(pot, w) + + for colname in ["actions", "freqs"]: + assert aaf[colname].shape == colshape + + # Check that mean=False returns the right shape + aaf = galpy_find_actions_staeckel(pot, orbit_one, mean=False) + for colname in ["actions", "freqs", "angles"]: + assert aaf[colname].shape == (1, orbit_one.ntimes, 3) + + aaf = galpy_find_actions_staeckel(pot, orbit_many, mean=False) + for colname in ["actions", "freqs", "angles"]: + assert aaf[colname].shape == (xyz.shape[1], orbit_one.ntimes, 3) + + +@pytest.mark.skipif(not HAS_GALPY, reason="requires galpy to run this test") +def test_compare_staeckel_o2gf(): + """ + This test function performs some comparisons between actions, angles, + and frequencies solved from the staeckel fudge and O2GF. + """ + disk = gp.MiyamotoNagaiPotential(5e10, 3.5, 0.3, units=galactic) + halo = gp.NFWPotential.from_M200_c(1e12 * u.Msun, 15, units=galactic) + pot = disk + halo + + xyz = (np.zeros((3, 16)) + 1e-5) * u.kpc + xyz[0] = np.linspace(4, 20, xyz.shape[1]) * u.kpc + + vxyz = np.zeros((3, 16)) * u.km / u.s + vxyz[0] = 15 * u.km / u.s + vxyz[1] = pot.circular_velocity(xyz) + vxyz[2] = 15 * u.km / u.s + + orbits = pot.integrate_orbit(PhaseSpacePosition(xyz, vxyz), dt=1.0, n_steps=20_000) + + aaf_staeckel = galpy_find_actions_staeckel(pot, orbits) + aaf_o2gf = find_actions_o2gf(orbits, N_max=10) + + assert u.allclose(aaf_staeckel["actions"], aaf_o2gf["actions"], rtol=1e-3) + with u.set_enabled_equivalencies(u.dimensionless_angles()): + assert u.allclose(aaf_staeckel["freqs"], aaf_o2gf["freqs"], rtol=1e-3) + assert u.allclose(aaf_staeckel["angles"], aaf_o2gf["angles"], rtol=1.5e-2) diff --git a/gala/source/tests/dynamics/actionangle/test_analyticactionangle.py b/gala/source/tests/dynamics/actionangle/test_analyticactionangle.py new file mode 100644 index 0000000000000000000000000000000000000000..3131a6e55deff01953a459d03089874515f39195 --- /dev/null +++ b/gala/source/tests/dynamics/actionangle/test_analyticactionangle.py @@ -0,0 +1,177 @@ +import astropy.units as u +import numpy as np +from _genfunc import toy_potentials + +import gala.dynamics as gd +from gala._optional_deps import HAS_TWOBODY +from gala.dynamics.actionangle import ( + harmonic_oscillator_xv_to_aa, + isochrone_aa_to_xv, + isochrone_xv_to_aa, +) +from gala.logging import logger +from gala.potential import ( + Hamiltonian, + HarmonicOscillatorPotential, + IsochronePotential, +) +from gala.units import galactic +from gala.util import assert_angles_allclose + + +class TestIsochrone: + def setup_method(self): + logger.info("======== Isochrone ========") + N = 100 + rng = np.random.default_rng(42) + x = rng.uniform(-10.0, 10.0, size=(3, N)) + v = rng.uniform(-1.0, 1.0, size=(3, N)) / 33.0 + w0 = np.vstack((x, v)) + + self.potential = IsochronePotential(units=galactic, m=1.0e11, b=5.0) + H = Hamiltonian(self.potential) + self.w = H.integrate_orbit(w0, dt=0.1, n_steps=10000) + self.w = self.w[::10] + + def test_single(self): + n = 13 # MAGIC NUMBER to pick one orbit + + # First, check that value of the actions are stable + actions, angles, freqs = isochrone_xv_to_aa(self.w[:, n], self.potential) + for i in range(3): + assert u.allclose(actions[i, 1:], actions[i, 0], rtol=1e-5) + + for slice_ in [slice(None), 0]: + actions, angles, _freqs = isochrone_xv_to_aa( + self.w[slice_, n], self.potential + ) + + # Compare to genfunc + x = self.w.xyz[:, slice_, n] + v = self.w.v_xyz[:, slice_, n] + m = self.potential.parameters["m"].value / 1e11 + b = self.potential.parameters["b"].value + + if x.ndim > 1: + s_w = np.vstack((x.to_value(u.kpc), v.to_value(u.km / u.s))) + + aa = np.array( + [ + toy_potentials.angact_iso(s_w[:, i].T, params=(m, b)) + for i in range(s_w.shape[1]) + ] + ) + s_actions = aa[:, :3] * u.km / u.s * u.kpc + s_angles = aa[:, 3:] * u.rad + + else: + s_w = np.concatenate((x.to_value(u.kpc), v.to_value(u.km / u.s))) + + aa = toy_potentials.angact_iso(s_w.T, params=(m, b)) + s_actions = aa[:3] * u.km / u.s * u.kpc + s_angles = aa[3:] * u.rad + + assert u.allclose(actions, s_actions.T, rtol=1e-8) + assert_angles_allclose(angles, s_angles.T, rtol=1e-8) + + # Test round-tripping + if HAS_TWOBODY: + w_rt = isochrone_aa_to_xv(actions, angles, self.potential) + + assert u.allclose(x, w_rt.xyz, atol=1e-10 * u.kpc) + assert u.allclose(v, w_rt.v_xyz, atol=1e-10 * u.km / u.s) + + def test_many(self): + actions, angles, _freqs = isochrone_xv_to_aa(self.w, self.potential) + + # Compare first element of orbit to genfunc, for speed + x = self.w.xyz + v = self.w.v_xyz + m = self.potential.parameters["m"].value / 1e11 + b = self.potential.parameters["b"].value + + s_w = np.vstack((x[:, 0].to_value(u.kpc), v[:, 0].to_value(u.km / u.s))) + + aa = np.array( + [ + toy_potentials.angact_iso(s_w[:, i].T, params=(m, b)) + for i in range(s_w.shape[1]) + ] + ) + s_actions = aa[:, :3] * u.km / u.s * u.kpc + s_angles = aa[:, 3:] * u.rad + + assert u.allclose(actions[:, 0], s_actions.T, rtol=1e-8) + assert_angles_allclose(angles[:, 0], s_angles.T, rtol=1e-8) + + # Test round-tripping + if HAS_TWOBODY: + # Check round-tripping for full orbits: + w_rt = isochrone_aa_to_xv(actions, angles, self.potential) + + assert u.allclose(x, w_rt.xyz, atol=1e-10 * u.kpc) + assert u.allclose(v, w_rt.v_xyz, atol=1e-10 * u.km / u.s) + + def test_regression_dimensionless(self): + pot = IsochronePotential(m=1.0, b=1.0) + act, ang, freq = pot.action_angle( + gd.PhaseSpacePosition([5.0, 0, 0], [0, 0.35, 0.1]) + ) + assert act.unit == u.one + assert freq.unit == u.one + assert ang.unit == u.rad + + +class TestHarmonicOscillator: + def setup_method(self): + logger.info("======== Harmonic Oscillator ========") + self.N = 100 + np.random.seed(42) + x = np.random.uniform(-10.0, 10.0, size=(3, self.N)) + v = np.random.uniform(-1.0, 1.0, size=(3, self.N)) / 33.0 + w0 = np.vstack((x, v)) + + self.potential = HarmonicOscillatorPotential( + omega=np.array([0.013, 0.02, 0.005]), units=galactic + ) + H = Hamiltonian(self.potential) + self.w = H.integrate_orbit(w0, dt=0.1, n_steps=10000) + self.w = self.w[::10] + + def test(self): + """ + !!!!! NOTE !!!!! + For Harmonic Oscillator, Sanders' code works for the units I use... + """ + for n in range(self.N): + logger.debug(f"Orbit {n}") + + actions, angles, _freq = harmonic_oscillator_xv_to_aa( + self.w[:, n], self.potential + ) + actions = actions.value + angles = angles.value + + for i in range(3): + assert np.allclose(actions[i, 1:], actions[i, 0], rtol=1e-5) + + # Compare to genfunc + x = self.w.xyz.value[..., n] + v = self.w.v_xyz.value[..., n] + s_w = np.vstack((x, v)) + omega = self.potential.parameters["omega"].value + aa = np.array( + [ + toy_potentials.angact_ho(s_w[:, i].T, omega=omega) + for i in range(s_w.shape[1]) + ] + ) + s_actions = aa[:, :3] + s_angles = aa[:, 3:] + + assert np.allclose(actions, s_actions.T, rtol=1e-8) + assert_angles_allclose(angles, s_angles.T, rtol=1e-8) + + # test roundtrip + # x2, v2 = harmonic_oscillator_aa_to_xv(actions, angles, self.potential) + # TODO: transform back?? diff --git a/gala/source/tests/dynamics/mockstream/test_coord.py b/gala/source/tests/dynamics/mockstream/test_coord.py new file mode 100644 index 0000000000000000000000000000000000000000..e24f8ab587bca1be79d6e066f5f296871a393c6c --- /dev/null +++ b/gala/source/tests/dynamics/mockstream/test_coord.py @@ -0,0 +1,28 @@ +""" +Note: + This is just a way to get pytest to call tests implemented in Cython! + See _coord.pyx for the actual test functions. +""" + +from gala.dynamics.mockstream._coord import ( + _test_car_to_cyl_roundtrip, + _test_cyl_to_car_roundtrip, + _test_sat_rotation_matrix, + _test_to_sat_coords_roundtrip, +) + + +def test_sat_rotation_matrix(): + _test_sat_rotation_matrix() + + +def test_to_sat_coords_roundtrip(): + _test_to_sat_coords_roundtrip() + + +def test_car_to_cyl_roundtrip(): + _test_car_to_cyl_roundtrip() + + +def test_cyl_to_car_roundtrip(): + _test_cyl_to_car_roundtrip() diff --git a/gala/source/tests/dynamics/mockstream/test_df.py b/gala/source/tests/dynamics/mockstream/test_df.py new file mode 100644 index 0000000000000000000000000000000000000000..033a15d5d6e5c6ca5743ce49a5372c7f94fadea5 --- /dev/null +++ b/gala/source/tests/dynamics/mockstream/test_df.py @@ -0,0 +1,121 @@ +import astropy.units as u +import numpy as np +import pytest + +import gala.dynamics as gd +import gala.dynamics.mockstream as ms +import gala.integrate as gi +import gala.potential as gp +from gala.units import galactic + +_DF_CLASSES = [ + ms.StreaklineStreamDF, + ms.FardalStreamDF, + ms.LagrangeCloudStreamDF, + ms.ChenStreamDF, +] +_DF_KWARGS = [{}, {"gala_modified": True}, {"v_disp": 1 * u.km / u.s}] +_TEST_POTENTIALS = [ + gp.HernquistPotential(m=1e12, c=5, units=galactic), + gp.MilkyWayPotential(version="v1"), +] + + +@pytest.mark.parametrize(("DF", "DF_kwargs"), zip(_DF_CLASSES, _DF_KWARGS)) +@pytest.mark.parametrize("pot", _TEST_POTENTIALS) +def test_init_sample(DF, DF_kwargs, pot): + H = gp.Hamiltonian(pot) + + orbit = H.integrate_orbit([10.0, 0, 0, 0, 0.2, 0], dt=1.0, n_steps=100) + n_times = len(orbit.t) + + # Different ways to initialize successfully: + df = DF(**DF_kwargs) + o = df.sample(orbit, 1e4 * u.Msun) + assert len(o.x) == 2 * n_times + + df = DF(lead=False, **DF_kwargs) + o = df.sample(orbit, 1e4 * u.Msun) + assert len(o.x) == n_times + + df = DF(trail=False, **DF_kwargs) + o = df.sample(orbit, 1e4 * u.Msun) + assert len(o.x) == n_times + + df1 = DF(random_state=np.random.RandomState(42), **DF_kwargs) + o1 = df1.sample(orbit, 1e4 * u.Msun) + df2 = DF(random_state=np.random.RandomState(42), **DF_kwargs) + o2 = df2.sample(orbit, 1e4 * u.Msun) + assert u.allclose(o1.xyz, o2.xyz) + assert u.allclose(o1.v_xyz, o2.v_xyz) + assert len(o1.x) == 2 * n_times + + +@pytest.mark.parametrize(("DF", "DF_kwargs"), zip(_DF_CLASSES, _DF_KWARGS)) +def test_expected_failure(DF, DF_kwargs): + # Expected failure: + with pytest.raises(ValueError): + DF(lead=False, trail=False, **DF_kwargs) + + +def test_rotating_frame(): + DF = _DF_CLASSES[0] + H_static = gp.Hamiltonian(_TEST_POTENTIALS[0]) + + w0 = gd.PhaseSpacePosition( + pos=[10.0, 0, 0] * u.kpc, vel=[0, 220, 0.0] * u.km / u.s, frame=H_static.frame + ) + int_kwargs = { + "w0": w0, + "dt": 1, + "n_steps": 100, + "Integrator": gi.DOPRI853Integrator, + } + + orbit_static = H_static.integrate_orbit(**int_kwargs) + + rframe = gp.ConstantRotatingFrame([0, 0, -40] * u.km / u.s / u.kpc, units=galactic) + H_rotating = gp.Hamiltonian(_TEST_POTENTIALS[0], frame=rframe) + orbit_rotating = H_rotating.integrate_orbit(**int_kwargs) + + o = orbit_rotating.to_frame(H_static.frame) + assert u.allclose(o.xyz, orbit_static.xyz, atol=1e-13 * u.kpc) + assert u.allclose(o.v_xyz, orbit_static.v_xyz, atol=1e-13 * u.km / u.s) + + df_static = DF(trail=False) + xvt_static = df_static.sample(orbit_static, 1e6 * u.Msun) + + df_rotating = DF(trail=False) + xvt_rotating = df_rotating.sample(orbit_rotating, 1e6 * u.Msun) + xvt_rotating_static = xvt_rotating.to_frame( + H_static.frame, t=xvt_rotating.release_time + ) + + assert u.allclose(xvt_static.xyz, xvt_rotating_static.xyz, atol=1e-9 * u.kpc) + assert u.allclose( + xvt_static.v_xyz, xvt_rotating_static.v_xyz, atol=1e-9 * u.kpc / u.Myr + ) + + +def test_regression_415(): + """ + Regression test for #415: integration error when progenitor mass is an array with + length 1 when it should be a scalar. + """ + pot = gp.NFWPotential(1e12 * u.Msun, r_s=10.0 * u.kpc, units=galactic) + w0 = gd.PhaseSpacePosition( + pos=[39.54522670882826, -21.408405557971204, 67.2661672] * u.kpc, + vel=[-1.87539782e02, -3.59878933e02, 1.08545075e02] * u.km / u.s, + ) + progenitor_mass = np.array([10**8]) * u.Msun + dw_pot = gp.PlummerPotential(m=progenitor_mass[0], b=50 * u.pc, units=galactic) + df = ms.ChenStreamDF(random_state=np.random.default_rng(42)) + gen_pal5 = ms.MockStreamGenerator(df, pot, progenitor_potential=dw_pot) + xorbit = pot.integrate_orbit(w0, dt=-1 * u.Myr, n_steps=100) + w0 = gd.PhaseSpacePosition(pos=xorbit.pos[-1], vel=xorbit.vel[-1]) + xnsteps = 1000 + stream, _ = gen_pal5.run( + w0, progenitor_mass, dt=1 * u.Myr, n_steps=xnsteps, n_particles=1 + ) + + assert stream.shape == (2002,) diff --git a/gala/source/tests/dynamics/mockstream/test_mockstream.py b/gala/source/tests/dynamics/mockstream/test_mockstream.py new file mode 100644 index 0000000000000000000000000000000000000000..f3b7f3a1681605ca49472e59ee45205642e977db --- /dev/null +++ b/gala/source/tests/dynamics/mockstream/test_mockstream.py @@ -0,0 +1,810 @@ +import itertools +import os +import time + +import astropy.units as u +import numpy as np +import pytest + +import gala.integrate as gi +from gala._optional_deps import HAS_H5PY +from gala.dynamics import ChenStreamDF, FardalStreamDF, Orbit, PhaseSpacePosition +from gala.dynamics.mockstream import MockStream +from gala.dynamics.mockstream.mockstream_generator import MockStreamGenerator +from gala.dynamics.nbody import DirectNBody +from gala.potential import ( + ConstantRotatingFrame, + Hamiltonian, + HernquistPotential, + NFWPotential, +) +from gala.units import galactic + + +@pytest.fixture +def rng(): + """Random number generator with fixed seed for reproducibility.""" + return np.random.default_rng(1234) + + +@pytest.fixture +def basic_potential(): + """Standard NFW potential for most tests.""" + return NFWPotential.from_circular_velocity(v_c=0.2, r_s=20.0, units=galactic) + + +@pytest.fixture +def basic_hamiltonian(basic_potential): + """Standard Hamiltonian with NFW potential.""" + return Hamiltonian(basic_potential) + + +@pytest.fixture +def progenitor_w0(): + """Standard progenitor initial conditions.""" + return PhaseSpacePosition( + pos=[15.0, 0.0, 0] * u.kpc, vel=[0, 0, 0.13] * u.kpc / u.Myr + ) + + +@pytest.fixture +def progenitor_mass(): + """Standard progenitor mass.""" + return 2.5e4 * u.Msun + + +@pytest.fixture +def basic_stream_generator(basic_hamiltonian, rng): + """Basic MockStreamGenerator with Fardal DF.""" + df = FardalStreamDF(gala_modified=True, random_state=rng) + return MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + + +# Tests + + +def test_init(rng, basic_hamiltonian, basic_potential, progenitor_w0): + """Test MockStreamGenerator initialization and validation.""" + df = FardalStreamDF(gala_modified=True, random_state=rng) + + # Test that invalid arguments are caught + with pytest.raises(TypeError): + MockStreamGenerator(df="some df", hamiltonian=basic_hamiltonian) + + with pytest.raises(TypeError): + MockStreamGenerator( + df=df, hamiltonian=basic_hamiltonian, progenitor_potential="stuff" + ) + + # Test validating the input nbody + nbody_w0 = PhaseSpacePosition( + pos=[25.0, 0.0, 0] * u.kpc, vel=[0, 0, 0.13] * u.kpc / u.Myr + ) + + # Different external potential should fail + potential2 = NFWPotential.from_circular_velocity(v_c=0.2, r_s=25.0, units=galactic) + nbody = DirectNBody( + w0=nbody_w0, external_potential=potential2, particle_potentials=[None] + ) + gen = MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + with pytest.raises(ValueError): + gen._get_nbody(progenitor_w0, nbody) + + # Different frame should fail + frame2 = ConstantRotatingFrame([0, 0, 25.0] * u.km / u.s / u.kpc, units=galactic) + nbody = DirectNBody( + w0=nbody_w0, + external_potential=basic_potential, + frame=frame2, + particle_potentials=[None], + ) + with pytest.raises(ValueError): + gen._get_nbody(progenitor_w0, nbody) + + # Should succeed with matching potential and frame + nbody = DirectNBody( + w0=nbody_w0, external_potential=basic_potential, particle_potentials=[None] + ) + new_nbody = gen._get_nbody(progenitor_w0, nbody) + + +def test_run(rng, basic_hamiltonian, progenitor_w0, progenitor_mass): + """Test basic stream generation functionality.""" + df = FardalStreamDF(gala_modified=True, random_state=rng) + prog_pot = HernquistPotential(progenitor_mass, 4 * u.pc, units=galactic) + + # Basic run without self-gravity + gen = MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + stream1, _ = gen.run(progenitor_w0, progenitor_mass, dt=-1.0, n_steps=100) + + # Test that mass must have units + with pytest.raises(TypeError): + gen.run(progenitor_w0, progenitor_mass.value, dt=-1.0, n_steps=100) + + # With self-gravity - should produce different results + gen = MockStreamGenerator( + df=df, hamiltonian=basic_hamiltonian, progenitor_potential=prog_pot + ) + stream2, _ = gen.run(progenitor_w0, progenitor_mass, dt=-1.0, n_steps=100) + assert not u.allclose(stream1.xyz, stream2.xyz) + + # Test skipping release steps + gen = MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + stream3, _ = gen.run( + progenitor_w0, + progenitor_mass, + dt=-1.0, + n_steps=100, + release_every=4, + n_particles=4, + ) + assert stream3.shape == ((100 // 4 + 1) * 4 * 2,) + + # Test custom n_particles array + gen = MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + n_particles = np.random.randint(0, 4, size=101) + stream3, _ = gen.run( + progenitor_w0, + progenitor_mass, + dt=-1.0, + n_steps=100, + release_every=1, + n_particles=n_particles, + ) + assert stream3.shape[0] == 2 * n_particles.sum() + + +@pytest.mark.parametrize("dt", [1, -1]) +@pytest.mark.parametrize("save_all", [True, False]) +@pytest.mark.parametrize("Integrator", [gi.LeapfrogIntegrator, gi.DOPRI853Integrator]) +def test_mockstream_nbody_run( + rng, + basic_potential, + basic_hamiltonian, + progenitor_w0, + progenitor_mass, + dt, + save_all, + Integrator, +): + """Test stream generation with N-body perturbers using different integrators.""" + df = FardalStreamDF(gala_modified=True, random_state=rng) + + # Test passing custom N-body with perturber + nbody_w0 = PhaseSpacePosition([20, 0, 0] * u.kpc, [0, 100, 0] * u.km / u.s) + nbody = DirectNBody( + w0=nbody_w0, + external_potential=basic_potential, + particle_potentials=[ + NFWPotential(m=1e8 * u.Msun, r_s=0.2 * u.kpc, units=galactic) + ], + save_all=save_all, + ) + gen = MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + stream, prog = gen.run( + progenitor_w0, + progenitor_mass, + dt=dt, + n_steps=100, + nbody=nbody, + Integrator=Integrator, + ) + + # Basic sanity checks + assert stream.shape[0] > 0 + assert np.isfinite(stream.xyz).all() + assert np.isfinite(stream.v_xyz).all() + + +@pytest.mark.skipif(not HAS_H5PY, reason="h5py required for this test") +def test_nbody_hdf5_broadcast_bug( + tmpdir, rng, basic_potential, basic_hamiltonian, progenitor_w0, progenitor_mass +): + """ + Regression test for HDF5 broadcast bug when using nbody with output_filename, + reported in #158. + """ + import h5py + + prog_pot = HernquistPotential(progenitor_mass, 4 * u.pc, units=galactic) + + nbody_w0 = PhaseSpacePosition([20, 0, 0] * u.kpc, [0, 100, 0] * u.km / u.s) + nbody = DirectNBody( + w0=nbody_w0, + external_potential=basic_potential, + particle_potentials=[ + NFWPotential(m=1e8 * u.Msun, r_s=0.2 * u.kpc, units=galactic) + ], + ) + + # Use trail=False and n_particles=1 to ensure we have fewer stream particles + # than nbodies at early timesteps + df = FardalStreamDF(gala_modified=True, trail=False, random_state=rng) + gen = MockStreamGenerator( + df=df, hamiltonian=basic_hamiltonian, progenitor_potential=prog_pot + ) + + filename = os.path.join(str(tmpdir), "test_nbody.hdf5") + + # This should trigger the bug if not fixed: at first output, n=1 but nbodies=2 + stream, prog = gen.run( + progenitor_w0, + progenitor_mass, + dt=-1.0, + n_steps=8, + nbody=nbody, + release_every=1, + n_particles=1, + output_every=1, + output_filename=filename, + check_filesize=False, + overwrite=True, + ) + + # If we get here without error, verify the file was created correctly + with h5py.File(filename, mode="r") as f: + stream_orbits = Orbit.from_hdf5(f["stream"]) + nbody_orbits = Orbit.from_hdf5(f["nbody"]) + + # Check that nbody has the correct shape + assert nbody_orbits.shape[0] == 9 # noutput_times + assert nbody_orbits.shape[1] == 2 # progenitor + 1 perturber + + # Check that values are finite + assert np.isfinite(nbody_orbits.xyz).all() + assert np.isfinite(nbody_orbits.v_xyz).all() + + +# TODO: add LeapfrogIntegrator if animation support added +@pytest.mark.parametrize( + ("dt", "nsteps", "output_every", "release_every", "n_particles", "trail"), + list(itertools.product([1, -1], [16, 17], [1, 2], [1, 4], [1, 4], [True, False])), +) +@pytest.mark.parametrize("Integrator", [gi.DOPRI853Integrator]) +@pytest.mark.skipif(not HAS_H5PY, reason="h5py required for this test") +def test_animate( + tmpdir, + rng, + basic_hamiltonian, + progenitor_w0, + progenitor_mass, + dt, + nsteps, + output_every, + release_every, + n_particles, + trail, + Integrator, +): + """Test animation output to HDF5 with various parameter combinations.""" + import h5py + + # The basic run with animation output + df = FardalStreamDF(gala_modified=True, trail=trail, random_state=rng) + gen = MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + + filename = os.path.join(str(tmpdir), f"test_{Integrator.__name__}.hdf5") + _stream, _ = gen.run( + progenitor_w0, + progenitor_mass, + dt=dt, + n_steps=nsteps, + release_every=release_every, + n_particles=n_particles, + output_every=output_every, + output_filename=filename, + overwrite=True, + Integrator=Integrator, + ) + + with h5py.File(filename, mode="r") as f: + stream_orbits = Orbit.from_hdf5(f["stream"]) + nbody_orbits = Orbit.from_hdf5(f["nbody"]) + + noutput_times = 1 + nsteps // output_every + if nsteps % output_every != 0: + noutput_times += 1 + + tail_n_particles = (1 + int(trail)) * n_particles + expected_shape = (noutput_times, tail_n_particles * (nsteps // release_every + 1)) + + assert stream_orbits.shape == expected_shape + assert np.isfinite(stream_orbits[:, 0].xyz).all() + assert np.isfinite(stream_orbits[:, 0].v_xyz).all() + + assert u.allclose(nbody_orbits.t, stream_orbits.t) + + assert np.isfinite(nbody_orbits.xyz).all() + assert np.isfinite(nbody_orbits.v_xyz).all() + assert np.isfinite(nbody_orbits.t).all() + + +@pytest.mark.xfail(reason="Timing comparison depends on system load...") +def test_integrator_kwargs_dop853( + rng, basic_hamiltonian, progenitor_w0, progenitor_mass +): + """Test that integrator kwargs are properly passed through.""" + df = ChenStreamDF(random_state=rng) + gen = MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + + ti = time.time() + stream1, _ = gen.run( + progenitor_w0, + progenitor_mass, + dt=-1.0, + n_steps=1000, + Integrator_kwargs={"atol": 1e-12, "nmax": 0}, + ) + runtime1 = time.time() - ti + + ti = time.time() + stream2, _ = gen.run( + progenitor_w0, + progenitor_mass, + dt=-1.0, + n_steps=1000, + Integrator_kwargs={"atol": 1e-5, "nmax": 100, "err_if_fail": 0}, + ) + runtime2 = time.time() - ti + + print(f"stream 1, atol=1e-12, runtime = {runtime1}") + print(f"stream 2, atol=1e-5, runtime = {runtime2}") + + assert runtime2 < runtime1 + + +# ============================================================================== +# Integrator comparison tests +# ============================================================================== + + +def test_integrator_consistency_basic( + rng, basic_hamiltonian, progenitor_w0, progenitor_mass +): + """ + Test that Leapfrog and DOPRI853 produce qualitatively similar streams. + + Note: We don't expect exact agreement since they use different integration + schemes (symplectic vs adaptive Runge-Kutta), but they should produce + streams with similar overall structure. + """ + df = FardalStreamDF(gala_modified=True, random_state=np.random.default_rng(123)) + gen = MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + + # Use same parameters for both integrators + stream_dop, prog_dop = gen.run( + progenitor_w0, + progenitor_mass, + dt=-1.0, + n_steps=50, + release_every=5, + n_particles=2, + Integrator=gi.DOPRI853Integrator, + ) + + # Run with Leapfrog - reinitialize generator to reset random state + df = FardalStreamDF(gala_modified=True, random_state=np.random.default_rng(123)) + gen = MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + + stream_lf, prog_lf = gen.run( + progenitor_w0, + progenitor_mass, + dt=-1.0, + n_steps=50, + release_every=5, + n_particles=2, + Integrator=gi.LeapfrogIntegrator, + ) + + # Check that both produced the same number of particles + assert stream_dop.shape == stream_lf.shape + + # Check that progenitor final positions are reasonably close + # (within ~1 kpc after 50 Myr integration) + assert np.allclose(prog_dop.xyz.value, prog_lf.xyz.value, atol=1.0) + + # Check that stream positions are reasonably similar + # We use a loose tolerance since integrators have different error profiles + mean_sep = np.mean( + np.linalg.norm(stream_dop.xyz.value - stream_lf.xyz.value, axis=0) + ) + print(f"Mean particle separation between integrators: {mean_sep:.3f} kpc") + assert mean_sep < 2.0 # Less than 2 kpc mean separation + + +@pytest.mark.parametrize("dt", [1.0, -1.0]) +def test_integrator_energy_conservation( + rng, basic_hamiltonian, progenitor_w0, progenitor_mass, dt +): + """ + Test energy conservation for Leapfrog vs DOPRI853. + + Leapfrog is symplectic and should conserve energy better for long integrations. + """ + df = FardalStreamDF(gala_modified=True, random_state=rng, trail=False) + gen = MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + + # DOPRI853 with default tolerance + _, prog_dop = gen.run( + progenitor_w0, + progenitor_mass, + dt=dt, + n_steps=200, + release_every=200, # Only release at beginning + n_particles=1, + Integrator="dop853", + ) + E_dop_initial = basic_hamiltonian(prog_dop[0]) + E_dop_final = basic_hamiltonian(prog_dop[-1]) + dE_dop = float(np.squeeze(np.abs((E_dop_final - E_dop_initial) / E_dop_initial))) + + # Leapfrog + _, prog_lf = gen.run( + progenitor_w0, + progenitor_mass, + dt=dt, + n_steps=200, + release_every=200, # Only release at beginning + n_particles=1, + Integrator="leapfrog", + ) + E_lf_initial = basic_hamiltonian(prog_lf[0]) + E_lf_final = basic_hamiltonian(prog_lf[-1]) + dE_lf = float(np.squeeze(np.abs((E_lf_final - E_lf_initial) / E_lf_initial))) + + print(f"DOPRI853 relative energy error: {dE_dop:.6e}") + print(f"Leapfrog relative energy error: {dE_lf:.6e}") + + # Both should conserve energy reasonably well + assert dE_dop < 1e-5 + assert dE_lf < 1e-5 + + +def test_chen_vs_fardal_df(rng, basic_hamiltonian, progenitor_w0, progenitor_mass): + """Test that both distribution functions work with both integrators.""" + for DFClass in [ChenStreamDF, FardalStreamDF]: + df = DFClass(random_state=rng) + gen = MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + + for Integrator in [gi.DOPRI853Integrator, gi.LeapfrogIntegrator]: + stream, prog = gen.run( + progenitor_w0, + progenitor_mass, + dt=-1.0, + n_steps=20, + Integrator=Integrator, + ) + assert stream.shape[0] > 0 + assert np.isfinite(stream.xyz).all() + + +@pytest.mark.skipif(not HAS_H5PY, reason="h5py required for this test") +def test_integrator_comparison_with_nbody( + tmpdir, rng, basic_potential, basic_hamiltonian, progenitor_w0, progenitor_mass +): + """Test that both integrators work correctly with N-body interactions.""" + import h5py + + df = FardalStreamDF(gala_modified=True, random_state=rng) + gen = MockStreamGenerator(df=df, hamiltonian=basic_hamiltonian) + + # Create a perturbing N-body + nbody_w0 = PhaseSpacePosition([20, 0, 0] * u.kpc, [0, 100, 0] * u.km / u.s) + nbody = DirectNBody( + w0=nbody_w0, + external_potential=basic_potential, + particle_potentials=[ + NFWPotential(m=1e8 * u.Msun, r_s=0.2 * u.kpc, units=galactic) + ], + ) + + for Integrator in [ + gi.DOPRI853Integrator + ]: # TODO: add LeapfrogIntegrator if animation support added + filename = os.path.join(str(tmpdir), f"nbody_{Integrator.__name__}.hdf5") + + stream, prog = gen.run( + progenitor_w0, + progenitor_mass, + dt=-1.0, + n_steps=30, + nbody=nbody, + Integrator=Integrator, + output_every=5, + output_filename=filename, + overwrite=True, + check_filesize=False, + ) + + # Verify output file + with h5py.File(filename, mode="r") as f: + stream_orbits = Orbit.from_hdf5(f["stream"]) + nbody_orbits = Orbit.from_hdf5(f["nbody"]) + + # Check that nbody orbits are all finite (no NaNs) + assert np.isfinite(nbody_orbits.xyz).all() + assert nbody_orbits.shape[1] == 2 # progenitor + perturber + + # For stream orbits, NaNs are expected for particles not yet released + # Just check that we have some finite values + assert np.isfinite( + stream_orbits[:, 0].xyz + ).all() # First particle should always be finite + + +def test_rotate_to_progenitor_plane_unit(): + """Unit test for rotate_to_progenitor_plane with manually constructed data. + + This test verifies that the rotation correctly places the progenitor at + the origin with velocity along the x-axis, and the stream is in the xy-plane. + """ + prog_pos = [10.0, 5.0, 3.0] * u.kpc + prog_vel = [50.0, 100.0, 25.0] * u.km / u.s + prog_w = PhaseSpacePosition(pos=prog_pos, vel=prog_vel) + + # a fake "stream" with particles around the progenitor + stream_pos = ( + prog_pos[:, None] + + np.array( + [ + [1.0, 0.0, 0.0], # Leading particle along x + [-1.0, 0.0, 0.0], # Trailing particle along x + [0.0, 1.0, 0.0], # Particle along y + [0.0, 0.0, 1.0], # Particle along z + ] + ).T + * u.kpc + ) + + stream_vel = ( + prog_vel[:, None] + + np.array( + [ + [10.0, 0.0, 0.0], + [-10.0, 0.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 0.0, 10.0], + ] + ).T + * u.km + / u.s + ) + + stream = MockStream( + pos=stream_pos, + vel=stream_vel, + release_time=[0.0, 1.0, 2.0, 3.0] * u.Myr, + lead_trail=np.array([1, -1, 1, -1]), + ) + + # Rotate to xy-plane + rotated_stream = stream.rotate_to_progenitor_plane(prog_w) + + # the transformation should preserve distances from progenitor + # (rotation is a rigid transformation) + original_distances = np.sqrt(np.sum((stream.xyz - prog_pos[:, None]) ** 2, axis=0)) + rotated_distances = np.sqrt(np.sum(rotated_stream.xyz**2, axis=0)) + assert u.allclose(original_distances, rotated_distances, rtol=1e-10) + + # release times and lead_trail should be preserved + assert u.allclose(rotated_stream.release_time, stream.release_time) + assert np.array_equal(rotated_stream.lead_trail, stream.lead_trail) + + # the stream should be centered near the origin (progenitor was translated) + mean_pos = np.mean(rotated_stream.xyz, axis=1) + assert np.allclose(mean_pos.value, 0.0, atol=1.0) # Within 1 kpc of origin + + # shape should be preserved + assert rotated_stream.shape == stream.shape + + +def test_rotate_to_progenitor_plane_functional( + rng, basic_hamiltonian, progenitor_w0, progenitor_mass +): + """Functional test for rotate_to_progenitor_plane with a real generated stream. + + This test generates a full mock stream and verifies that the rotation works + correctly with realistic data. + """ + # Generate a mock stream + df = FardalStreamDF(gala_modified=True, random_state=rng) + prog_pot = HernquistPotential(progenitor_mass, 4 * u.pc, units=galactic) + + gen = MockStreamGenerator( + df=df, hamiltonian=basic_hamiltonian, progenitor_potential=prog_pot + ) + + # Generate stream with both leading and trailing tails + stream, prog = gen.run( + progenitor_w0, + progenitor_mass, + dt=-1.0, + n_steps=100, + n_particles=2, + ) + + # Get the progenitor position at the final time (same as stream) + # Need to extract as a PhaseSpacePosition, not an Orbit slice + prog_final = PhaseSpacePosition(pos=prog.xyz[:, -1], vel=prog.v_xyz[:, -1]) + + # Rotate to xy-plane + rotated_stream = stream.rotate_to_progenitor_plane(prog_final) + + # Verify key properties: + # 1. Original attributes should be preserved + assert u.allclose(rotated_stream.release_time, stream.release_time) + assert np.array_equal(rotated_stream.lead_trail, stream.lead_trail) + assert rotated_stream.shape == stream.shape + + # 2. Verify the transformation preserves distances from progenitor + # (rotation + translation) + original_distances = np.sqrt( + np.sum((stream.xyz - prog_final.xyz[:, None]) ** 2, axis=0) + ) + rotated_distances = np.sqrt(np.sum(rotated_stream.xyz**2, axis=0)) + assert u.allclose(original_distances, rotated_distances, rtol=1e-10) + + # 3. The stream should have particles in both +x and -x directions (lead/trail) + + # More than half of leading particles should have positive x + n_pos_x = np.sum(rotated_stream.x[stream.lead_trail == "l"] > 0) + assert n_pos_x > 0.5 * np.sum(stream.lead_trail == "l") + + # More than half of trailing particles should have negative x + n_neg_x = np.sum(rotated_stream.x[stream.lead_trail == "t"] < 0) + assert n_neg_x > 0.5 * np.sum(stream.lead_trail == "t") + + +# ============================================================================== +# Regression tests for progenitor final position bug +# ============================================================================== + + +def test_leapfrog_progenitor_final_position_uniform_release( + basic_hamiltonian, progenitor_mass +): + """Regression test: Leapfrog progenitor should match direct orbit integration. + + This tests the case where particles are released at every timestep. + Tests that the progenitor position from mockstream_leapfrog matches + a direct orbit integration with the leapfrog integrator. + + Regression test for bug where progenitor didn't end at correct position. + """ + from gala.potential import MilkyWayPotential + + prog_w0 = PhaseSpacePosition( + pos=[13.0, 0.0, 20.0] * u.kpc, vel=[0, 130.0, 50] * u.km / u.s + ) + + df = ChenStreamDF() + mw = MilkyWayPotential(version="latest") + gen = MockStreamGenerator(df, mw) + + # Short integration with particles at every timestep + t = np.arange(0, 100.0, 1.0) * u.Myr + n_particles = np.ones(len(t), dtype=int) # Particle at every timestep + + stream, prog_w = gen.run( + prog_w0, + prog_mass=progenitor_mass, + n_particles=n_particles, + t=t, + Integrator="leapfrog", + ) + + # Compare to direct orbit integration + expected_prog_w = mw.integrate_orbit(prog_w0, t=t, Integrator="leapfrog") + + # Progenitor final position should match within numerical precision + # Note: prog_w has shape (3, 1), need to squeeze for comparison + assert u.allclose( + expected_prog_w[-1].xyz, prog_w.xyz.squeeze(), rtol=1e-10, atol=1e-10 * u.kpc + ) + + +def test_leapfrog_progenitor_final_position_sparse_release( + basic_hamiltonian, progenitor_mass +): + """Regression test: Leapfrog progenitor with sparse particle release. + + This tests the critical case where particles are only released at some timesteps, + with nstream=0 at many times (including potentially the final time). + + This was the original bug: when the last timestep had nstream=0, the progenitor + would not be integrated to tfinal correctly. + + Regression test for bug where progenitor didn't end at correct position + when particles were released sparsely. + """ + from gala.potential import MilkyWayPotential + + prog_w0 = PhaseSpacePosition( + pos=[13.0, 0.0, 20.0] * u.kpc, vel=[0, 130.0, 50] * u.km / u.s + ) + + df = ChenStreamDF() + mw = MilkyWayPotential(version="latest") + gen = MockStreamGenerator(df, mw) + + # Integration with particles only at some timesteps + t = np.arange(0, 200.0, 1.0) * u.Myr + n_particles = np.zeros(len(t), dtype=int) + n_particles[::5] = 1 # Release particles every 5 Myr + # Note: last timestep (t=199) has nstream=0, second-to-last (t=195) has nstream=1 + + stream, prog_w = gen.run( + prog_w0, + prog_mass=progenitor_mass, + n_particles=n_particles, + t=t, + Integrator="leapfrog", + ) + + # Compare to direct orbit integration + expected_prog_w = mw.integrate_orbit(prog_w0, t=t, Integrator="leapfrog") + + # Progenitor final position should match within numerical precision + # This is the key test: the progenitor must reach tfinal=199, not stop at t=195 + # Note: prog_w has shape (3, 1), need to squeeze for comparison + assert u.allclose( + expected_prog_w[-1].xyz, prog_w.xyz.squeeze(), rtol=1e-10, atol=1e-10 * u.kpc + ) + + +def test_leapfrog_vs_dop853_consistency(basic_hamiltonian, progenitor_mass): + """Test that leapfrog and dop853 produce consistent results. + + While the integrators are different and will produce slightly different + trajectories, they should both correctly integrate to tfinal and produce + progenitor positions that match their respective direct orbit integrations. + """ + from gala.potential import MilkyWayPotential + + prog_w0 = PhaseSpacePosition( + pos=[13.0, 0.0, 20.0] * u.kpc, vel=[0, 130.0, 50] * u.km / u.s + ) + + df = ChenStreamDF() + mw = MilkyWayPotential(version="latest") + gen = MockStreamGenerator(df, mw) + + # Short integration with sparse particle release + t = np.arange(0, 100.0, 1.0) * u.Myr + n_particles = np.zeros(len(t), dtype=int) + n_particles[::5] = 1 + + # Generate with both integrators + stream_lf, prog_w_lf = gen.run( + prog_w0, + prog_mass=progenitor_mass, + n_particles=n_particles, + t=t, + Integrator="leapfrog", + ) + + stream_dop, prog_w_dop = gen.run( + prog_w0, + prog_mass=progenitor_mass, + n_particles=n_particles, + t=t, + Integrator="dop853", + ) + + # Compare each to direct orbit integration with same integrator + expected_lf = mw.integrate_orbit(prog_w0, t=t, Integrator="leapfrog") + expected_dop = mw.integrate_orbit(prog_w0, t=t, Integrator="dop853") + + # Each should match its corresponding direct integration + # Note: prog_w has shape (3, 1), need to squeeze for comparison + assert u.allclose( + expected_lf[-1].xyz, prog_w_lf.xyz.squeeze(), rtol=1e-10, atol=1e-10 * u.kpc + ) + assert u.allclose( + expected_dop[-1].xyz, prog_w_dop.xyz.squeeze(), rtol=1e-10, atol=1e-10 * u.kpc + ) + + # The two integrators will give different results, but they should both be + # reasonably close (within a few kpc for this short integration) + diff = np.linalg.norm((prog_w_lf.xyz - prog_w_dop.xyz).to_value(u.kpc)) + assert diff < 1.0 # Should differ by less than 1 kpc for this short integration diff --git a/gala/source/tests/dynamics/mockstream/test_mockstream_class.py b/gala/source/tests/dynamics/mockstream/test_mockstream_class.py new file mode 100644 index 0000000000000000000000000000000000000000..1d6a36befc2df8fa10f83c0c2e825afcf0d2ccfa --- /dev/null +++ b/gala/source/tests/dynamics/mockstream/test_mockstream_class.py @@ -0,0 +1,141 @@ +import astropy.units as u +import numpy as np +import pytest + +from gala.dynamics.core import PhaseSpacePosition +from gala.dynamics.mockstream import MockStream + + +def test_init(): + xyz = np.random.random(size=(3, 100)) * u.kpc + vxyz = np.random.random(size=(3, 100)) * u.km / u.s + t1 = np.random.random(size=100) * u.Myr + + lead_trail = np.empty(100, dtype="U1") + lead_trail[::2] = "t" + lead_trail[1::2] = "l" + + stream = MockStream(xyz, vxyz) + stream = MockStream(xyz, vxyz, release_time=t1) + stream = MockStream(xyz, vxyz, lead_trail=lead_trail) + + with pytest.raises(ValueError): + MockStream(xyz, vxyz, release_time=t1[:-1]) + + with pytest.raises(ValueError): + MockStream(xyz, vxyz, lead_trail=lead_trail[:-1]) + + +def test_no_copy(): + xyz = np.random.random(size=(3, 100)) * u.kpc + vxyz = np.random.random(size=(3, 100)) * u.km / u.s + + s1 = MockStream(xyz, vxyz, copy=True) + s2 = MockStream(xyz, vxyz, copy=False) + + xyz[0, 0] = 999.0 * u.kpc + assert s1.pos[0].x.value != 999.0 + assert s2.pos[0].x.value == 999.0 + + +def test_one_burst(): + # Regression test: Tests a bug found by Helmer when putting all particles at + # one timestep + import gala.dynamics as gd + import gala.potential as gp + from gala.dynamics import mockstream as ms + from gala.units import galactic + + # NFW MW with v_c = 232.8 km/s @ r = 8.2 kpc + pot = gp.NFWPotential.from_circular_velocity( + v_c=232.8 * u.km / u.s, r_s=15 * u.kpc, r_ref=8.2 * u.kpc, units=galactic + ) + + H = gp.Hamiltonian(pot) + + prog_w0 = gd.PhaseSpacePosition( + pos=[10, 0, 0.0] * u.kpc, vel=[0, 10, 0.0] * u.km / u.s + ) + + dt = 1 * u.Myr + nsteps = 100 + orbit = H.integrate_orbit(prog_w0, dt=dt, n_steps=nsteps) + + r = orbit.spherical.distance + + n_array = np.zeros(orbit.t.size, dtype=int) + argmin = r[0:150].argmin() + n_array[argmin] = 1000 + + df = ms.FardalStreamDF( + gala_modified=True, random_state=np.random.default_rng(seed=42) + ) + + dt = 1 * u.Myr + prog_mass = 2.5e4 * u.Msun + prog_pot = gp.PlummerPotential(m=prog_mass, b=4 * u.pc, units=galactic) + + gen = ms.MockStreamGenerator(df, H, progenitor_potential=prog_pot) + + stream, prog = gen.run( + prog_w0, prog_mass, n_particles=n_array, dt=dt, n_steps=nsteps, progress=False + ) + + # Sanity check the first stream particle and the progenitor + stream0_true = PhaseSpacePosition( + pos=[-10.07444187, -1.37424641, 0.06310397] * u.kpc, + vel=[-0.05672946, -0.01837671, 0.00038504] * u.kpc / u.Myr, + ) + prog_true = PhaseSpacePosition( + pos=[-9.72388107, -1.28632464, 0.0] * u.kpc, + vel=[-0.04714419, -0.016754, 0.0] * u.kpc / u.Myr, + ) + + assert u.allclose(stream[0].xyz, stream0_true.xyz) + assert u.allclose(stream[0].v_xyz, stream0_true.v_xyz) + assert u.allclose(prog[0].xyz, prog_true.xyz) + assert u.allclose(prog[0].v_xyz, prog_true.v_xyz) + + +def test_Fardal_vs_GalaModified(): + """ + Regression test: Check that one can actually use the original Fardal parameter + values, and that makes a different stream than the Gala-modified values: + https://github.com/adrn/gala/pull/358 + """ + import gala.dynamics as gd + import gala.potential as gp + from gala.dynamics import mockstream as ms + from gala.units import galactic + + # NFW MW with v_c = 232.8 km/s @ r = 8.2 kpc + pot = gp.NFWPotential.from_circular_velocity( + v_c=232.8 * u.km / u.s, r_s=15 * u.kpc, r_ref=8.2 * u.kpc, units=galactic + ) + + H = gp.Hamiltonian(pot) + + prog_w0 = gd.PhaseSpacePosition( + pos=[10, 0, 0.0] * u.kpc, vel=[0, 300, 20.0] * u.km / u.s + ) + + with pytest.warns(FutureWarning, match="Fardal"): + ms.FardalStreamDF() + + df_false = ms.FardalStreamDF( + gala_modified=False, random_state=np.random.default_rng(seed=42) + ) + df_true = ms.FardalStreamDF( + gala_modified=True, random_state=np.random.default_rng(seed=42) + ) + + gen_false = ms.MockStreamGenerator(df_false, H) + gen_true = ms.MockStreamGenerator(df_true, H) + + prog_mass = 2.5e4 * u.Msun + stream_false, _ = gen_false.run( + prog_w0, prog_mass, dt=1, n_steps=128, progress=False + ) + stream_true, _ = gen_true.run(prog_w0, prog_mass, dt=1, n_steps=128, progress=False) + + assert not u.allclose(stream_false.xyz, stream_true.xyz) diff --git a/gala/source/tests/dynamics/nbody/test_nbody.py b/gala/source/tests/dynamics/nbody/test_nbody.py new file mode 100644 index 0000000000000000000000000000000000000000..0377bd5c0ce895aec12de56b9a19198e72e76cad --- /dev/null +++ b/gala/source/tests/dynamics/nbody/test_nbody.py @@ -0,0 +1,255 @@ +import astropy.units as u +import numpy as np +import pytest + +from gala.dynamics import PhaseSpacePosition, combine +from gala.dynamics.nbody import DirectNBody +from gala.integrate import ( + DOPRI853Integrator, + LeapfrogIntegrator, + Ruth4Integrator, +) +from gala.potential import ( + ConstantRotatingFrame, + HernquistPotential, + NFWPotential, + NullPotential, + StaticFrame, +) +from gala.units import UnitSystem, galactic + + +class TestDirectNBody: + def setup_method(self): + self.usys = UnitSystem( + u.pc, u.Unit(1e-5 * u.Myr), u.Unit(1e6 * u.Msun), u.radian + ) + pot_particle2 = HernquistPotential( + m=1e6 * u.Msun, c=0.1 * u.pc, units=self.usys + ) + vcirc = pot_particle2.circular_velocity([1, 0, 0.0] * u.pc).to(u.km / u.s) + + self.particle_potentials = [NullPotential(units=self.usys), pot_particle2] + + w0_2 = PhaseSpacePosition(pos=[10, 0, 0] * u.kpc, vel=[0, 83, 0] * u.km / u.s) + w0_1 = PhaseSpacePosition( + pos=w0_2.xyz + [1, 0, 0] * u.pc, vel=w0_2.v_xyz + [0, 1.0, 0] * vcirc + ) + self.w0 = combine((w0_1, w0_2)) + + self.ext_pot = NFWPotential(m=1e11, r_s=10, units=galactic) + + def test_directnbody_init(self): + # another unit system for testing + usys2 = UnitSystem(u.pc, u.Unit(1e-3 * u.Myr), u.Unit(1e6 * u.Msun), u.radian) + + particle_potentials_None = [None, *self.particle_potentials[1:]] + + # Different VALID ways to initialize + nbody = DirectNBody(self.w0, particle_potentials=self.particle_potentials) + nbody = DirectNBody(self.w0, particle_potentials=particle_potentials_None) + nbody = DirectNBody( + self.w0, + particle_potentials=self.particle_potentials, + external_potential=self.ext_pot, + ) + nbody = DirectNBody( + self.w0, + particle_potentials=self.particle_potentials, + external_potential=self.ext_pot, + units=usys2, + ) + nbody = DirectNBody(self.w0, particle_potentials=[None, None], units=usys2) + nbody = DirectNBody( + self.w0, + particle_potentials=[None, None], + external_potential=self.ext_pot, + ) + + # Different INVALID ways to initialize + with pytest.raises(TypeError): + DirectNBody("sdf", particle_potentials=self.particle_potentials) + + with pytest.raises(ValueError): + DirectNBody(self.w0, particle_potentials=self.particle_potentials[:1]) + + with pytest.raises(ValueError): + DirectNBody(self.w0, particle_potentials=[None, None]) + + @pytest.mark.parametrize( + "Integrator", + [ + DOPRI853Integrator, + Ruth4Integrator, + LeapfrogIntegrator, + "dop853", + "leapfrog", + ], + ) + def test_directnbody_integrate(self, Integrator): + """ + TODO: this is really a unit test, but we should have some functional tests + that check that the orbit integration is making sense! + + Here, nbody1 has two test mass particles (massless) and nbody2 has + one potential with mass [1] and one without [0]. This means that the orbit of + particle [1] should be the same in both cases, but the orbit of particle [0] + should be different (because it feels the mass of the other particle in one + case). + """ + + atol = 1e-10 * u.pc + + # First, compare with/without mass with no external potential: + nbody1 = DirectNBody(self.w0, particle_potentials=[None, None], units=self.usys) + nbody2 = DirectNBody( + self.w0, particle_potentials=self.particle_potentials, units=self.usys + ) + + orbits1 = nbody1.integrate_orbit( + dt=1 * self.usys["time"], t1=0, t2=1 * u.Myr, Integrator=Integrator + ) + orbits2 = nbody2.integrate_orbit( + dt=1 * self.usys["time"], t1=0, t2=1 * u.Myr, Integrator=Integrator + ) + + dx0 = orbits1[:, 0].xyz - orbits2[:, 0].xyz + dx1 = orbits1[:, 1].xyz - orbits2[:, 1].xyz + assert np.abs(dx0).max() > 50 * u.pc + assert u.allclose(np.abs(dx1), 0 * u.pc, atol=atol) + + # Now compare with/without mass with external potential: + nbody1 = DirectNBody( + self.w0, + particle_potentials=[None, None], + units=self.usys, + external_potential=self.ext_pot, + ) + nbody2 = DirectNBody( + self.w0, + particle_potentials=self.particle_potentials, + units=self.usys, + external_potential=self.ext_pot, + ) + + orbits1 = nbody1.integrate_orbit( + dt=1 * self.usys["time"], t1=0, t2=1 * u.Myr, Integrator=Integrator + ) + orbits2 = nbody2.integrate_orbit( + dt=1 * self.usys["time"], t1=0, t2=1 * u.Myr, Integrator=Integrator + ) + + dx0 = orbits1[:, 0].xyz - orbits2[:, 0].xyz + dx1 = orbits1[:, 1].xyz - orbits2[:, 1].xyz + assert u.allclose(np.abs(dx1), 0 * u.pc, atol=atol) + assert np.abs(dx0).max() > 50 * u.pc + + def test_directnbody_acceleration(self): + pot1 = HernquistPotential(m=1e6 * u.Msun, c=0.1 * u.pc, units=self.usys) + pot2 = HernquistPotential(m=1.6e6 * u.Msun, c=0.33 * u.pc, units=self.usys) + + nbody = DirectNBody( + self.w0, particle_potentials=[pot1, pot2], external_potential=self.ext_pot + ) + + # Compute the acceleration we expect: + pot1_ = HernquistPotential( + m=1e6 * u.Msun, c=0.1 * u.pc, units=self.usys, origin=self.w0[0].xyz + ) + pot2_ = HernquistPotential( + m=1.6e6 * u.Msun, c=0.33 * u.pc, units=self.usys, origin=self.w0[1].xyz + ) + exp_acc = np.zeros((3, 2)) * self.usys["acceleration"] + exp_acc[:, 0] = pot2_.acceleration(self.w0[0])[:, 0] + exp_acc[:, 1] = pot1_.acceleration(self.w0[1])[:, 0] + exp_acc += self.ext_pot.acceleration(self.w0) + + acc = nbody.acceleration() + assert u.allclose(acc, exp_acc) + + @pytest.mark.parametrize( + "Integrator", [DOPRI853Integrator, Ruth4Integrator, LeapfrogIntegrator] + ) + def test_directnbody_integrate_dontsaveall(self, Integrator): + # If we set save_all = False, only return the final positions: + nbody1 = DirectNBody( + self.w0, + particle_potentials=self.particle_potentials, + units=self.usys, + external_potential=self.ext_pot, + save_all=False, + ) + nbody2 = DirectNBody( + self.w0, + particle_potentials=self.particle_potentials, + units=self.usys, + external_potential=self.ext_pot, + save_all=True, + ) + + w1 = nbody1.integrate_orbit( + dt=1 * self.usys["time"], t1=0, t2=1 * u.Myr, Integrator=Integrator + ) + orbits = nbody2.integrate_orbit( + dt=1 * self.usys["time"], t1=0, t2=1 * u.Myr, Integrator=Integrator + ) + w2 = orbits[-1] + assert u.allclose(w1.xyz, w2.xyz) + assert u.allclose(w1.v_xyz, w2.v_xyz) + + @pytest.mark.parametrize("Integrator", [DOPRI853Integrator]) + def test_directnbody_integrate_rotframe(self, Integrator): + # Now compare with/without mass with external potential: + frame = ConstantRotatingFrame( + Omega=[0, 0, 1] * self.w0[0].v_y / self.w0[0].x, units=self.usys + ) + nbody = DirectNBody( + self.w0, + particle_potentials=self.particle_potentials, + units=self.usys, + external_potential=self.ext_pot, + frame=frame, + ) + nbody2 = DirectNBody( + self.w0, + particle_potentials=self.particle_potentials, + units=self.usys, + external_potential=self.ext_pot, + ) + + orbits = nbody.integrate_orbit( + dt=1 * self.usys["time"], t1=0, t2=1 * u.Myr, Integrator=Integrator + ) + orbits_static = orbits.to_frame(StaticFrame(self.usys)) + + orbits2 = nbody2.integrate_orbit( + dt=1 * self.usys["time"], t1=0, t2=1 * u.Myr, Integrator=Integrator + ) + + assert u.allclose(orbits_static.xyz, orbits_static.xyz) + assert u.allclose(orbits2.v_xyz, orbits2.v_xyz) + + @pytest.mark.parametrize("Integrator", [DOPRI853Integrator]) + def test_nbody_reorder(self, Integrator): + N = 16 + rng = np.random.default_rng(seed=42) + w0 = PhaseSpacePosition( + pos=rng.normal(0, 5, size=(3, N)) * u.kpc, + vel=rng.normal(0, 50, size=(3, N)) * u.km / u.s, + ) + pots = [ + ( + HernquistPotential(1e9 * u.Msun, 1.0 * u.pc, units=galactic) + if rng.uniform() > 0.5 + else None + ) + for _ in range(N) + ] + sim = DirectNBody( + w0, + pots, + external_potential=HernquistPotential(1e12, 10, units=galactic), + units=galactic, + ) + orbits = sim.integrate_orbit(dt=1.0 * u.Myr, t1=0, t2=100 * u.Myr) + assert np.allclose(orbits.pos[0].xyz, w0.pos.xyz) diff --git a/gala/source/tests/dynamics/test_dynamics_core.py b/gala/source/tests/dynamics/test_dynamics_core.py new file mode 100644 index 0000000000000000000000000000000000000000..8a7633fb09c31c0f2dfab0a205f3a633bb6edbb0 --- /dev/null +++ b/gala/source/tests/dynamics/test_dynamics_core.py @@ -0,0 +1,451 @@ +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +import pytest +from astropy.coordinates import ( + CartesianDifferential, + CartesianRepresentation, + Galactic, + SphericalCosLatDifferential, + SphericalRepresentation, +) + +from gala._optional_deps import HAS_H5PY +from gala.dynamics import PhaseSpacePosition +from gala.potential import ( + ConstantRotatingFrame, + Hamiltonian, + HernquistPotential, + StaticFrame, +) +from gala.units import galactic, solarsystem + + +def test_initialize(): + with pytest.raises(ValueError): + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 8)) + PhaseSpacePosition(pos=x, vel=v) + + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + assert o.shape == (10,) + + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.random(size=(3, 10)) * u.km / u.s + o = PhaseSpacePosition(pos=x, vel=v) + assert o.xyz.unit == u.kpc + assert o.v_x.unit == u.km / u.s + o.data + assert "s" in o.data.differentials + + # Not 3D + x = np.random.random(size=(2, 10)) + v = np.random.random(size=(2, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + assert o.ndim == 2 + + o = PhaseSpacePosition(pos=x, vel=v, frame=StaticFrame(galactic)) + assert o.ndim == 2 + assert o.frame is not None + assert isinstance(o.frame, StaticFrame) + + x = np.random.random(size=(4, 10)) + v = np.random.random(size=(4, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + assert o.ndim == 4 + + # back to 3D + pos = CartesianRepresentation(np.random.random(size=(3, 10)) * u.one) + vel = CartesianDifferential(np.random.random(size=(3, 10)) * u.one) + o = PhaseSpacePosition(pos=pos, vel=vel) + assert hasattr(o, "x") + assert hasattr(o, "y") + assert hasattr(o, "z") + assert hasattr(o, "v_x") + assert hasattr(o, "v_y") + assert hasattr(o, "v_z") + + # passing a representation with a differential attached + pos = CartesianRepresentation(np.random.random(size=(3, 10)) * u.kpc) + vel = CartesianDifferential(np.random.random(size=(3, 10)) * u.km / u.s) + o = PhaseSpacePosition(pos.with_differentials({"s": vel})) + assert hasattr(o, "x") + assert hasattr(o, "y") + assert hasattr(o, "z") + assert hasattr(o, "v_x") + assert hasattr(o, "v_y") + assert hasattr(o, "v_z") + + o = o.represent_as(SphericalRepresentation) + assert hasattr(o, "distance") + assert hasattr(o, "lat") + assert hasattr(o, "lon") + assert hasattr(o, "radial_velocity") + assert hasattr(o, "pm_lon") + assert hasattr(o, "pm_lat") + + with pytest.raises(TypeError): + o = PhaseSpacePosition(pos=x, vel=v, frame="blah blah blah") + + +def test_no_copy(): + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o1 = PhaseSpacePosition(pos=x, vel=v, copy=True) + o2 = PhaseSpacePosition(pos=x, vel=v, copy=False) + assert np.all(o1.w() == o2.w()) + x[0, 0] = 9999.0 + assert o1.x[0].value != 9999.0 + assert o2.x[0].value == 9999.0 + + +def test_from_w(): + w = np.random.random(size=(6, 10)) + o = PhaseSpacePosition.from_w(w, galactic) + assert o.x.unit == u.kpc + assert o.v_x.unit == u.kpc / u.Myr + assert o.shape == (10,) + + +def test_slice(): + # simple + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + new_o = o[:5] + assert new_o.shape == (5,) + + # 1d slice on 3d + x = np.random.random(size=(3, 10, 8)) + v = np.random.random(size=(3, 10, 8)) + o = PhaseSpacePosition(pos=x, vel=v) + new_o = o[:5] + assert new_o.shape == (5, 8) + + # 3d slice on 3d + o = PhaseSpacePosition(pos=x, vel=v) + new_o = o[:5, :4] + assert new_o.shape == (5, 4) + + # boolean array + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + ix = np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]).astype(bool) + new_o = o[ix] + assert new_o.shape == (sum(ix),) + + # integer array + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + ix = np.array([0, 3, 5]) + new_o = o[ix] + assert new_o.shape == (len(ix),) + + +def test_reshape(): + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + new_o = o.reshape((10, 1)) + assert new_o.shape == (10, 1) + assert new_o.x.shape == (10, 1) + + +def test_represent_as(): + # simple / unitless + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + new_o = o.represent_as(SphericalRepresentation) + o.spherical + o.cylindrical + o.cartesian + + assert new_o.pos.distance.unit == u.one + assert new_o.vel.d_distance.unit == u.one + + # simple / with units + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = PhaseSpacePosition(pos=x, vel=v) + sph = o.represent_as(SphericalRepresentation) + assert sph.pos.distance.unit == u.kpc + + sph2 = o.represent_as("spherical") + for c in sph.pos.components: + assert u.allclose(getattr(sph.pos, c), getattr(sph2.pos, c), rtol=1e-12) + + # doesn't work for 2D + x = np.random.random(size=(2, 10)) + v = np.random.random(size=(2, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + with pytest.raises(ValueError): + o.represent_as(SphericalRepresentation) + + +def test_represent_as_expected_attributes(): + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + + new_o = o.spherical + assert hasattr(new_o, "distance") + assert hasattr(new_o, "lat") + assert hasattr(new_o, "lon") + assert hasattr(new_o, "radial_velocity") + assert hasattr(new_o, "pm_lat") + assert hasattr(new_o, "pm_lon") + + new_o = o.represent_as(SphericalRepresentation, SphericalCosLatDifferential) + assert hasattr(new_o, "distance") + assert hasattr(new_o, "lat") + assert hasattr(new_o, "lon") + assert hasattr(new_o, "radial_velocity") + assert hasattr(new_o, "pm_lat") + assert hasattr(new_o, "pm_lon_coslat") + + new_o = o.physicsspherical + assert hasattr(new_o, "r") + assert hasattr(new_o, "phi") + assert hasattr(new_o, "theta") + assert hasattr(new_o, "radial_velocity") + assert hasattr(new_o, "pm_theta") + assert hasattr(new_o, "pm_phi") + + new_o = o.cylindrical + assert hasattr(new_o, "rho") + assert hasattr(new_o, "phi") + assert hasattr(new_o, "z") + assert hasattr(new_o, "v_rho") + assert hasattr(new_o, "pm_phi") + assert hasattr(new_o, "v_z") + + new_o = new_o.cartesian + assert hasattr(new_o, "x") + assert hasattr(new_o, "y") + assert hasattr(new_o, "z") + assert hasattr(new_o, "xyz") + assert hasattr(new_o, "v_x") + assert hasattr(new_o, "v_y") + assert hasattr(new_o, "v_z") + assert hasattr(new_o, "v_xyz") + + # Check that this works with the NDCartesian classes too + x = np.random.random(size=(2, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(2, 10)) * u.km / u.s + new_o = PhaseSpacePosition(pos=x, vel=v) + + assert hasattr(new_o, "x1") + assert hasattr(new_o, "x2") + assert hasattr(new_o, "xyz") + assert hasattr(new_o, "v_x1") + assert hasattr(new_o, "v_x2") + assert hasattr(new_o, "v_xyz") + + +def test_to_coord_frame(): + # simple / unitless + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + + with ( + coord.galactocentric_frame_defaults.set("v4.0"), + pytest.raises(u.UnitConversionError), + ): + o.to_coord_frame(Galactic()) + + # simple / with units + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = PhaseSpacePosition(pos=x, vel=v) + with coord.galactocentric_frame_defaults.set("v4.0"): + coo = o.to_coord_frame(Galactic()) + assert coo.name == "galactic" + + # doesn't work for 2D + x = np.random.random(size=(2, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(2, 10)) * u.km / u.s + o = PhaseSpacePosition(pos=x, vel=v) + with coord.galactocentric_frame_defaults.set("v4.0"), pytest.raises(ValueError): + o.to_coord_frame(Galactic()) + + +def test_w(): + # simple / unitless + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + w = o.w() + assert w.shape == (6, 10) + + x = np.random.random(size=3) + v = np.random.random(size=3) + o = PhaseSpacePosition(pos=x, vel=v) + w = o.w() + assert w.shape == (6, 1) + + # simple / unitless, 2D + x = np.random.random(size=(2, 10)) + v = np.random.random(size=(2, 10)) + o = PhaseSpacePosition(pos=x, vel=v) + w = o.w() + assert w.shape == (4, 10) + + x = np.random.random(size=2) + v = np.random.random(size=2) + o = PhaseSpacePosition(pos=x, vel=v) + w = o.w() + assert w.shape == (4, 1) + + # simple / with units + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = PhaseSpacePosition(pos=x, vel=v) + with pytest.raises(ValueError): + o.w() + w = o.w(units=galactic) + assert np.allclose(x.value, w[:3]) + assert np.allclose(v.value, (w[3:] * u.kpc / u.Myr).to(u.km / u.s).value) + + # simple / with units and potential + p = HernquistPotential(units=galactic, m=1e11, c=0.25) + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = PhaseSpacePosition(pos=x, vel=v) + w = o.w(p.units) + assert np.allclose(x.value, w[:3]) + assert np.allclose(v.value, (w[3:] * u.kpc / u.Myr).to(u.km / u.s).value) + + w = o.w(units=solarsystem) + assert np.allclose(x.value, (w[:3] * u.au).to(u.kpc).value) + assert np.allclose(v.value, (w[3:] * u.au / u.yr).to(u.km / u.s).value) + + +# ------------------------------------------------------------------------ +# Computed dynamical quantities +# ------------------------------------------------------------------------ +def test_energy(): + # with units + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = PhaseSpacePosition(pos=x, vel=v) + KE = o.kinetic_energy() + assert KE.unit == (o.v_x.unit) ** 2 + assert KE.shape == o.shape + + # with units and potential + p = HernquistPotential(units=galactic, m=1e11, c=0.25) + H = Hamiltonian(p) + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = PhaseSpacePosition(pos=x, vel=v) + PE = o.potential_energy(p) + E = o.energy(H) + + +def test_angular_momentum(): + w = PhaseSpacePosition([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]) + assert u.allclose(np.squeeze(w.angular_momentum()), [0.0, -1, 0] * u.one) + + w = PhaseSpacePosition([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]) + assert u.allclose(np.squeeze(w.angular_momentum()), [0.0, 0, 1] * u.one) + + w = PhaseSpacePosition([0.0, 1.0, 0.0], [0.0, 0.0, 1.0]) + assert u.allclose(np.squeeze(w.angular_momentum()), [1.0, 0, 0] * u.one) + + w = PhaseSpacePosition([1.0, 0, 0] * u.kpc, [0.0, 200.0, 0] * u.pc / u.Myr) + assert u.allclose(np.squeeze(w.angular_momentum()), [0, 0, 0.2] * u.kpc**2 / u.Myr) + + # multiple - known + q = np.array([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0, 1.0, 0.0]]).T + p = np.array([[0, 0, 1.0], [0, 1.0, 0.0], [0, 0, 1]]).T + L = PhaseSpacePosition(q, p).angular_momentum() + true_L = np.array([[0.0, -1, 0], [0.0, 0, 1], [1.0, 0, 0]]).T * u.one + assert L.shape == (3, 3) + assert u.allclose(L, true_L) + + # multiple - random + q = np.random.uniform(size=(3, 128)) + p = np.random.uniform(size=(3, 128)) + L = PhaseSpacePosition(q, p).angular_momentum() + assert L.shape == (3, 128) + + +def test_guiding_radius(): + rng = np.random.default_rng(42) + + p = HernquistPotential(units=galactic, m=1e11, c=10.0) + + Rs = rng.uniform(4, 10, 128) * u.kpc + xyz = Rs[None] * np.array([1.0, 0, 0])[:, None] + + vc = p.circular_velocity(xyz) + vxyz = np.zeros((3, Rs.size)) * u.km / u.s + vxyz[1] = rng.normal(vc.to_value(u.km / u.s), 15.0) * u.km / u.s + + w0 = PhaseSpacePosition(xyz, vxyz) + Rgs = w0.guiding_radius(potential=p) + assert np.all(Rgs > 0) + assert np.all(Rgs < 25 * u.kpc) + + +def test_frame_transform(): + static = StaticFrame(galactic) + rotating = ConstantRotatingFrame( + Omega=[0.53, 1.241, 0.9394] * u.rad / u.Myr, units=galactic + ) + + x = np.array([[10.0, -0.2, 0.3], [-0.232, 8.1, 0.1934]]).T * u.kpc + v = np.array([[0.0034, 0.2, 0.0014], [0.0001, 0.002532, -0.2]]).T * u.kpc / u.Myr + + # no frame specified at init + psp = PhaseSpacePosition(pos=x, vel=v) + with pytest.raises(ValueError): + psp.to_frame(rotating) + + psp.to_frame(rotating, current_frame=static, t=0.4 * u.Myr) + + # frame specified at init + psp = PhaseSpacePosition(pos=x, vel=v, frame=static) + psp.to_frame(rotating, t=0.4 * u.Myr) + + +@pytest.mark.parametrize( + "obj", + [ + PhaseSpacePosition([1, 2, 3.0] * u.kpc, [1, 2, 3.0] * u.km / u.s), + PhaseSpacePosition( + [1, 2, 3.0] * u.kpc, [1, 2, 3.0] * u.km / u.s, StaticFrame(units=galactic) + ), + PhaseSpacePosition( + [1, 2, 3.0] * u.kpc, + [1, 2, 3.0] * u.km / u.s, + ConstantRotatingFrame(Omega=[1.0, 0, 0] * u.rad / u.Myr, units=galactic), + ), + ], +) +@pytest.mark.skipif(not HAS_H5PY, reason="h5py required for this test") +def test_io(tmpdir, obj): + import h5py + + filename = str(tmpdir.join("thing.hdf5")) + with h5py.File(filename, "w") as f: + obj.to_hdf5(f) + + obj2 = PhaseSpacePosition.from_hdf5(filename) + assert u.allclose(obj.xyz, obj2.xyz) + assert u.allclose(obj.v_xyz, obj2.v_xyz) + assert obj.frame == obj2.frame + + +def test_astropy_deprecation_rep_name(): + from gala.dynamics.core import _get_rep_name + + rep = coord.CartesianRepresentation(1, 2, 3, unit=u.kpc) + + assert _get_rep_name(rep) == "cartesian" diff --git a/gala/source/tests/dynamics/test_dynamics_util.py b/gala/source/tests/dynamics/test_dynamics_util.py new file mode 100644 index 0000000000000000000000000000000000000000..b8387b1f0f73b1df5b3f8455ea2935fd2007fcea --- /dev/null +++ b/gala/source/tests/dynamics/test_dynamics_util.py @@ -0,0 +1,124 @@ +import astropy.units as u +import numpy as np +import pytest + +from gala.dynamics import ( + Orbit, + PhaseSpacePosition, + combine, + estimate_dt_n_steps, + peak_to_peak_period, +) +from gala.potential import Hamiltonian, NFWPotential, StaticFrame +from gala.units import galactic + + +def test_peak_to_peak_period(): + ntimes = 16384 + + # trivial test + for true_T in [1.0, 2.0, 4.123]: + t = np.linspace(0, 10.0, ntimes) + f = np.sin(2 * np.pi / true_T * t) + T = peak_to_peak_period(t, f) + assert np.allclose(T, true_T, atol=1e-3) + + # modulated trivial test + true_T = 2.0 + t = np.linspace(0, 10.0, ntimes) + f = np.sin(2 * np.pi / true_T * t) + 0.1 * np.cos(2 * np.pi / (10 * true_T) * t) + T = peak_to_peak_period(t, f) + assert np.allclose(T, true_T, atol=1e-3) + + +def test_estimate_dt_n_steps(): + nperiods = 128 + pot = NFWPotential.from_circular_velocity(v_c=1.0, r_s=10.0, units=galactic) + w0 = [10.0, 0.0, 0.0, 0.0, 0.9, 0.0] + + H = Hamiltonian(pot) + dt, n_steps = estimate_dt_n_steps( + w0, H, n_periods=nperiods, n_steps_per_period=256, func=np.nanmin + ) + + orbit = H.integrate_orbit(w0, dt=dt, n_steps=n_steps) + T = orbit.physicsspherical.estimate_period()["r"] + assert int(np.squeeze(np.round((orbit.t.max() / T).decompose().value))) == nperiods + + +class TestCombine: + def setup_method(self): + x = np.random.random(size=(3,)) + v = np.random.random(size=(3,)) + p1 = PhaseSpacePosition(pos=x, vel=v) + p2 = PhaseSpacePosition(pos=x, vel=v, frame=StaticFrame(galactic)) + x = np.random.random(size=(3, 5)) + v = np.random.random(size=(3, 5)) + p3 = PhaseSpacePosition(pos=x, vel=v) + p4 = PhaseSpacePosition(pos=x * u.kpc, vel=v * u.km / u.s) + x = np.random.random(size=(2, 5)) + v = np.random.random(size=(2, 5)) + p5 = PhaseSpacePosition(pos=x, vel=v) + self.psps = [p1, p2, p3, p4, p5] + + x = np.random.random(size=(3, 8)) + v = np.random.random(size=(3, 8)) + o1 = Orbit(pos=x, vel=v) + o2 = Orbit(pos=x, vel=v, t=np.arange(8)) + + pot = NFWPotential.from_circular_velocity(v_c=1.0, r_s=10.0, units=galactic) + o3 = Orbit( + pos=x * u.kpc, + vel=v * u.km / u.s, + t=np.arange(8) * u.Myr, + potential=pot, + frame=StaticFrame(galactic), + ) + + x = np.random.random(size=(2, 8)) + v = np.random.random(size=(2, 8)) + o4 = Orbit(pos=x, vel=v, t=np.arange(8)) + self.orbs = [o1, o2, o3, o4] + + def test_combine_fail(self): + with pytest.raises(ValueError): + combine([]) + + with pytest.raises(ValueError): + combine(self.psps[0]) + + with pytest.raises(TypeError): + combine([self.psps[0], self.orbs[0]]) + + with pytest.raises(TypeError): + combine([5, 5, 5]) + + with pytest.raises(ValueError): + combine(self.psps) + + with pytest.raises(ValueError): + combine(self.orbs) + + def test_combine_psp(self): + for psp in self.psps: + psps = [psp] * 3 + new_psp = combine(psps) + assert new_psp.ndim == psp.ndim + + shp = psp.pos.shape if psp.pos.shape else (1,) + + assert new_psp.pos.shape == (3 * shp[0],) + assert new_psp.frame == psp.frame + + def test_combine_orb(self): + for orb in self.orbs: + orbs = [orb] * 4 + new_orb = combine(orbs) + assert new_orb.ndim == orb.ndim + + shp = orb.shape + shp = (*shp, 4) if len(shp) < 2 else (*shp[:-1], 4 * shp[-1]) + + assert new_orb.pos.shape == shp + assert new_orb.frame == orb.frame + assert new_orb.potential == orb.potential diff --git a/gala/source/tests/dynamics/test_nonlinear.py b/gala/source/tests/dynamics/test_nonlinear.py new file mode 100644 index 0000000000000000000000000000000000000000..d7175feacbb2884f4a1e04ab693c8abeb3e45906 --- /dev/null +++ b/gala/source/tests/dynamics/test_nonlinear.py @@ -0,0 +1,320 @@ +import numpy as np + +import gala.potential as gp +from gala.dynamics.nonlinear import fast_lyapunov_max, lyapunov_max, surface_of_section +from gala.integrate import DOPRI853Integrator +from gala.units import galactic + + +class TestForcedPendulum: + def setup_method(self): + def F(t, x, A, omega_d): + q, p = x + return np.array([p, -np.sin(q) + A * np.cos(omega_d * t)]) + + # initial conditions and parameter choices for chaotic / regular pendulum + self.regular_w0 = np.array([1.0, 0.0]) + self.regular_par = (0.055, 0.7) + self.regular_integrator = DOPRI853Integrator(F, func_args=self.regular_par) + + self.chaotic_w0 = np.array([3.0, 0.0]) + self.chaotic_par = (0.07, 0.75) + self.chaotic_integrator = DOPRI853Integrator(F, func_args=self.chaotic_par) + + def test_lyapunov_max(self, tmpdir): + n_steps = 20000 + dt = 1.0 + n_steps_per_pullback = 10 + d0 = 1e-5 + noffset = 2 + + regular_LEs, _regular_orbit = lyapunov_max( + self.regular_w0, + self.regular_integrator, + dt=dt, + n_steps=n_steps, + d0=d0, + n_steps_per_pullback=n_steps_per_pullback, + noffset_orbits=noffset, + ) + + regular_LEs = np.mean(regular_LEs, axis=1) + assert regular_LEs[-1] < 1e-3 + + chaotic_LEs, _chaotic_orbit = lyapunov_max( + self.chaotic_w0, + self.chaotic_integrator, + dt=dt, + n_steps=n_steps, + d0=d0, + n_steps_per_pullback=n_steps_per_pullback, + noffset_orbits=noffset, + ) + chaotic_LEs = np.mean(chaotic_LEs, axis=1) + assert chaotic_LEs[-1] > 1e-2 + + # pl.figure() + # pl.loglog(regular_LEs, marker='') + # pl.savefig(os.path.join(str(tmpdir),"pend_regular.png")) + + # pl.figure() + # pl.plot(t, regular_ws[:, 0], marker='') + # pl.savefig(os.path.join(str(tmpdir),"pend_orbit_regular.png")) + + # pl.figure() + # pl.loglog(chaotic_LEs, marker='') + # pl.savefig(os.path.join(str(tmpdir),"pend_chaotic.png")) + + # pl.figure() + # pl.plot(t, chaotic_ws[:, 0], marker='') + # pl.savefig(os.path.join(str(tmpdir),"pend_orbit_chaotic.png")) + + # pl.close('all') + + +# -------------------------------------------------------------------- + + +class HenonHeilesBase: + def potential(self, w, A, B, C, D): + x, y = w[:2] + term1 = 0.5 * (A * x**2 + B * y**2) + term2 = D * x**2 * y - C / 3.0 * y**3 + return term1 + term2 + + def acceleration(self, w, A, B, C, D): + x, y = w[:2] + ax = -(A * x + 2 * D * x * y) + ay = -(B * y + D * x * x - C * y * y) + return np.array([ax, ay]) + + def jerk(self, w, A, B, C, D): + x, y = w[:2] + dx, dy = w[4:6] + + dax = -(A + 2 * D * y) * dx - 2 * D * x * dy + day = -2 * D * x * dx - (B - 2 * C * y) * dy + + return np.array([dax, day]) + + def F_max(self, t, w, *args): + _x, _y, px, py = w + term1 = np.array([px, py]) + term2 = self.acceleration(w, *args) + return np.vstack((term1, term2)) + + def setup_method(self): + # parameter choices + self.par = (1.0, 1.0, 1.0, 1.0) + self.n_steps = 2000 + self.dt = 2.0 + + def test_integrate_orbit(self, tmpdir): + integrator = DOPRI853Integrator(self.F_max, func_args=self.par) + orbit = integrator(self.w0, dt=self.dt, n_steps=self.n_steps) + + def test_lyapunov_max(self, tmpdir): + n_steps_per_pullback = 10 + d0 = 1e-5 + noffset = 2 + + integrator = DOPRI853Integrator(self.F_max, func_args=self.par) + lyap, _orbit = lyapunov_max( + self.w0, + integrator, + dt=self.dt, + n_steps=self.n_steps, + d0=d0, + noffset_orbits=noffset, + n_steps_per_pullback=n_steps_per_pullback, + ) + lyap = np.mean(lyap, axis=1) + + # pl.clf() + # pl.loglog(lyap, marker='') + # pl.savefig(os.path.join(str(tmpdir),"hh_lyap_max_{}.png".format(self.__class__.__name__))) + + # pl.clf() + # pl.plot(ws[..., 0], ws[..., 1], marker='') + # pl.savefig(os.path.join(str(tmpdir),"hh_orbit_lyap_max_{}.png".format(self.__class__.__name__))) + + +# initial conditions from LP-VI documentation: +class TestHenonHeilesStablePeriodic(HenonHeilesBase): + def setup_method(self): + super().setup_method() + self.w0 = np.array([0.0, 0.295456, 0.407308431, 0.0]) + self.check = lambda x: x < 1e-3 + + +class TestHenonHeilesStableQuasi1(HenonHeilesBase): + def setup_method(self): + super().setup_method() + self.w0 = np.array([0.0, 0.483, 0.27898039, 0.0]) + self.check = lambda x: x < 2e-3 + + +class TestHenonHeilesStableQuasi2(HenonHeilesBase): + def setup_method(self): + super().setup_method() + self.w0 = np.array([0.0, 0.46912, 0.291124891, 0.0]) + self.check = lambda x: x < 2e-3 + + +class TestHenonHeilesStableChaos1(HenonHeilesBase): + def setup_method(self): + super().setup_method() + self.w0 = np.array([0.0, 0.509, 0.254624859, 0.0]) + self.check = lambda x: x > 2e-3 + + +class TestHenonHeilesStableChaos2(HenonHeilesBase): + def setup_method(self): + super().setup_method() + self.w0 = np.array([0.0, 0.56, 0.164113781, 0.112]) + self.check = lambda x: x > 1e-2 + + +# -------------------------------------------------------------------- + + +class TestLogarithmic: + def setup_method(self): + # set the potential + potential = gp.LogarithmicPotential( + v_c=np.sqrt(2), r_h=0.1, q1=1.0, q2=0.9, q3=1.0, units=galactic + ) + self.hamiltonian = gp.Hamiltonian(potential) + + # see figure 1 from Papaphillipou & Laskar + x0 = -0.01 + X0 = -0.2 + y0 = 0.0 + E0 = -0.4059 + Y0 = np.squeeze( + np.sqrt(E0 - self.hamiltonian.potential.energy([x0, y0, 0.0]).value) + ) + chaotic_w0 = [x0, y0, 0.0, X0, Y0, 0.0] + + # initial conditions from LP-VI documentation: + self.w0s = np.array( + [[0.49, 0.0, 0.0, 1.3156, 0.4788, 0.0], chaotic_w0] # regular + ) # chaotic + + self.n_steps = 25000 + self.dt = 0.004 + + def test_fast_lyapunov_max(self, tmpdir): + n_steps_per_pullback = 10 + d0 = 1e-5 + noffset = 2 + + for ii, w0 in enumerate(self.w0s): + print(ii, w0) + lyap, orbit = fast_lyapunov_max( + w0, + self.hamiltonian, + dt=self.dt, + n_steps=self.n_steps, + d0=d0, + noffset_orbits=noffset, + n_steps_per_pullback=n_steps_per_pullback, + ) + lyap = np.mean(lyap, axis=1) + + # energy conservation + E = orbit[:, 0].energy().value # returns 3 orbits + dE = np.abs((E[1:] - E[0]) / E[0]) + + assert np.all(dE < 1e-10) + + def test_compare_fast(self, tmpdir): + n_steps_per_pullback = 10 + d0 = 1e-5 + noffset = 2 + + def F(t, w): + w = np.ascontiguousarray(w) + return self.hamiltonian._gradient(w, np.array([t])) + + integrator = DOPRI853Integrator(F) + for _ii, w0 in enumerate(self.w0s): + lyap1, orbit1 = fast_lyapunov_max( + w0, + self.hamiltonian, + dt=self.dt, + n_steps=self.n_steps // 8, + d0=d0, + noffset_orbits=noffset, + n_steps_per_pullback=n_steps_per_pullback, + ) + lyap1 = np.mean(lyap1, axis=1) + + # check energy conservation + E = orbit1.energy().value + dE_fast = np.abs(E[1:] - E[0]) + assert np.all(dE_fast[:, 0] < 1e-10) + + lyap2, orbit2 = lyapunov_max( + w0.copy(), + integrator, + dt=self.dt, + n_steps=self.n_steps // 8, + d0=d0, + noffset_orbits=noffset, + n_steps_per_pullback=n_steps_per_pullback, + units=self.hamiltonian.units, + ) + lyap2 = np.mean(lyap2, axis=1) + + # check energy conservation + E = orbit2.energy(self.hamiltonian).value + dE_slow = np.abs(E[1:] - E[0]) + + if not np.all(dE_slow[:, 0] < 1e-10): + import matplotlib.pyplot as plt + + plt.figure() + plt.plot(orbit2.pos[0, -128:, 0], orbit2.pos[1, -128:, 0], marker=".") + plt.plot(orbit2.pos[0, -128:, 0], orbit2.pos[2, -128:, 0], marker=".") + + plt.figure() + plt.semilogy(dE_slow[:, 0], marker=".") + + plt.show() + + assert np.all(dE_slow[:, 0] < 1e-10) + + # plots + # import matplotlib.pyplot as plt + + # plt.figure() + # plt.loglog(orbit1.t[1:-10:10], lyap1, marker='') + # plt.loglog(orbit2.t[1:-10:10], lyap2, marker='') + # plt.savefig(os.path.join(str(tmpdir),"log_lyap_compare_{}.png".format(ii))) + + # plt.figure() + # plt.semilogy(dE_fast[:, 0], marker='') + # plt.semilogy(dE_slow[:, 0], marker='') + # # plt.savefig(os.path.join(str(tmpdir),"log_dE_{}.png".format(ii))) + + # fig, axes = plt.subplots(1, 2, figsize=(6, 6)) + # axes[0].plot(orbit1.pos[0, :, 0], orbit1.pos[1, :, 0], + # marker='.', linestyle='none', alpha=0.1) + # axes[1].plot(orbit2.pos[0, :, 0], orbit2.pos[1, :, 0], + # marker='.', linestyle='none', alpha=0.1) + # plt.savefig(os.path.join(str(tmpdir),"log_orbit_lyap_max_{}.png".format(ii))) + + # plt.show() + # plt.close('all') + + +def test_surface_of_section(): + pot = gp.LogarithmicPotential( + v_c=1.0, r_h=1.0, q1=1.0, q2=0.9, q3=0.8, units=galactic + ) + + w0 = np.array([0.0, 0.8, 0.0, 1.0, 0.0, 0.0]) + orbit = gp.Hamiltonian(pot).integrate_orbit(w0, dt=0.02, n_steps=100_000) + sos = surface_of_section(orbit, constant_idx=1) + sos_cyl = surface_of_section(orbit.cylindrical, constant_idx=1) diff --git a/gala/source/tests/dynamics/test_orbit.py b/gala/source/tests/dynamics/test_orbit.py new file mode 100644 index 0000000000000000000000000000000000000000..9b8b7322b364ef6e1ce76dd3e8f3d6fb84d8ad41 --- /dev/null +++ b/gala/source/tests/dynamics/test_orbit.py @@ -0,0 +1,678 @@ +import astropy.units as u +import numpy as np +import pytest +import scipy.optimize as so +from astropy.coordinates import ( + Galactic, + SphericalCosLatDifferential, + SphericalRepresentation, +) + +from gala._optional_deps import HAS_GALPY, HAS_H5PY +from gala.dynamics import Orbit, PhaseSpacePosition, combine +from gala.integrate import DOPRI853Integrator +from gala.potential import ( + ConstantRotatingFrame, + Hamiltonian, + HernquistPotential, + KeplerPotential, + LogarithmicPotential, + NFWPotential, + StaticFrame, +) +from gala.units import galactic, solarsystem + + +# Tests below should be cleaned up a bit... +def test_initialize(): + with pytest.raises(ValueError): + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 8)) + Orbit(pos=x, vel=v) + + with pytest.raises(ValueError): + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + t = np.arange(8) + Orbit(pos=x, vel=v, t=t) + + # TODO: always? + # x = np.random.random(size=(3, 10)) + # v = np.random.random(size=(3, 10)) + # o = Orbit(pos=x, vel=v) + # assert o.ndim == 3 + + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.random(size=(3, 10)) * u.km / u.s + o = Orbit(pos=x, vel=v) + assert o.xyz.unit == u.kpc + assert o.v_x.unit == u.km / u.s + + # TODO: don't support < 3 dim? + # x = np.random.random(size=(2, 10)) + # v = np.random.random(size=(2, 10)) + # o = Orbit(pos=x, vel=v) + # assert o.ndim == 2 + # assert o.hamiltonian is None + + # Check that passing in frame and potential or Hamiltonian works + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.random(size=(3, 10)) * u.km / u.s + frame = StaticFrame(galactic) + potential = LogarithmicPotential( + v_c=1.0, r_h=0.14, q1=1.0, q2=0.9, q3=1.0, units=galactic + ) + + o = Orbit(pos=x, vel=v, frame=frame) + assert o.hamiltonian is None + assert o.potential is None + + o = Orbit(pos=x, vel=v, potential=potential) + assert o.hamiltonian is None + assert o.frame is None + + o = Orbit(pos=x, vel=v, potential=potential, frame=frame) + o = Orbit(pos=x, vel=v, hamiltonian=Hamiltonian(potential, frame=frame)) + assert isinstance(o.hamiltonian, Hamiltonian) + assert isinstance(o.potential, LogarithmicPotential) + assert isinstance(o.frame, StaticFrame) + + +def test_no_copy(): + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o1 = Orbit(pos=x, vel=v, copy=True) + o2 = Orbit(pos=x, vel=v, copy=False) + x[0, 0] = 9999.0 + assert o1.x[0].value != 9999.0 + assert o2.x[0].value == 9999.0 + + +def test_from_w(): + w = np.random.random(size=(6, 10)) + o = Orbit.from_w(w, galactic) + assert o.xyz.unit == u.kpc + assert o.v_x.unit == u.kpc / u.Myr + + +def test_slice(): + # simple + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = Orbit(pos=x, vel=v) + new_o = o[:5] + assert new_o.shape == (5,) + + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + t = np.linspace(0, 10, 10) + o = Orbit(pos=x, vel=v, t=t) + new_o = o[:5] + assert new_o.shape == (5,) + + # 1d slice on 3d + x = np.random.random(size=(3, 10, 8)) + v = np.random.random(size=(3, 10, 8)) + t = np.arange(x.shape[1]) + o = Orbit(pos=x, vel=v, t=t) + new_o = o[:5] + assert new_o.shape == (5, 8) + assert new_o.t.shape == (5,) + + # pick a single orbit + new_o = o[:, 0] + assert isinstance(new_o, Orbit) + assert new_o.shape == (10,) + assert new_o.t.shape == (10,) + + # pick a single time + new_o = o[3] + assert isinstance(new_o, PhaseSpacePosition) + assert new_o.shape == (8,) + + # REGRESSION TEST: numpy int64 is not an int() + new_o = o[np.int64(3)] + assert isinstance(new_o, PhaseSpacePosition) + assert new_o.shape == (8,) + + # 3d slice on 3d + o = Orbit(pos=x, vel=v, t=t) + new_o = o[:5, :4] + assert new_o.shape == (5, 4) + assert new_o.t.shape == (5,) + + # boolean array + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + t = np.arange(x.shape[1]) + o = Orbit(pos=x, vel=v, t=t) + ix = np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]).astype(bool) + new_o = o[ix] + assert new_o.shape == (sum(ix),) + assert new_o.t.shape == (5,) + + # boolean array - 3D + x = np.random.random(size=(3, 10, 4)) + v = np.random.random(size=(3, 10, 4)) + t = np.arange(x.shape[1]) + o = Orbit(pos=x, vel=v, t=t) + ix = np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]).astype(bool) + new_o = o[ix] + assert new_o.shape == (sum(ix), x.shape[-1]) + assert new_o.t.shape == (5,) + + # integer array + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + t = np.arange(x.shape[1]) + o = Orbit(pos=x, vel=v, t=t) + ix = np.array([0, 3, 5]) + new_o = o[ix] + assert new_o.shape == (len(ix),) + assert new_o.t.shape == (len(ix),) + + +def test_reshape(): + # 1d slice on 3d + x = np.random.random(size=(3, 10, 8)) + v = np.random.random(size=(3, 10, 8)) + t = np.arange(x.shape[1]) + o = Orbit(pos=x, vel=v, t=t) + new_o = o.reshape((10, 4, 2)) + assert new_o.shape == (10, 4, 2) + assert new_o.x.shape == (10, 4, 2) + + +def test_represent_as(): + # simple / unitless + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = Orbit(pos=x, vel=v) + sph = o.represent_as(SphericalRepresentation) + + assert sph.pos.distance.unit == u.one + assert sph.vel.d_distance.unit == u.one + + # simple / with units + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = Orbit(pos=x, vel=v) + sph = o.represent_as(SphericalRepresentation) + assert sph.pos.distance.unit == u.kpc + assert sph.vel.d_distance.unit == u.km / u.s + + +def test_represent_as_expected_attributes(): + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = Orbit(pos=x, vel=v) + + new_o = o.spherical + assert hasattr(new_o, "distance") + assert hasattr(new_o, "lat") + assert hasattr(new_o, "lon") + assert hasattr(new_o, "radial_velocity") + assert hasattr(new_o, "pm_lat") + assert hasattr(new_o, "pm_lon") + assert new_o.norbits == o.norbits + + new_o = o.represent_as(SphericalRepresentation, SphericalCosLatDifferential) + assert hasattr(new_o, "distance") + assert hasattr(new_o, "lat") + assert hasattr(new_o, "lon") + assert hasattr(new_o, "radial_velocity") + assert hasattr(new_o, "pm_lat") + assert hasattr(new_o, "pm_lon_coslat") + + new_o = o.physicsspherical + assert hasattr(new_o, "r") + assert hasattr(new_o, "phi") + assert hasattr(new_o, "theta") + assert hasattr(new_o, "radial_velocity") + assert hasattr(new_o, "pm_theta") + assert hasattr(new_o, "pm_phi") + assert new_o.norbits == o.norbits + + new_o = o.cylindrical + assert hasattr(new_o, "rho") + assert hasattr(new_o, "phi") + assert hasattr(new_o, "z") + assert hasattr(new_o, "v_rho") + assert hasattr(new_o, "pm_phi") + assert hasattr(new_o, "v_z") + assert new_o.norbits == o.norbits + + new_o = new_o.cartesian + assert hasattr(new_o, "x") + assert hasattr(new_o, "y") + assert hasattr(new_o, "z") + assert hasattr(new_o, "xyz") + assert hasattr(new_o, "v_x") + assert hasattr(new_o, "v_y") + assert hasattr(new_o, "v_z") + assert hasattr(new_o, "v_xyz") + + # Check that this works with the NDCartesian classes too + x = np.random.random(size=(2, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(2, 10)) * u.km / u.s + new_o = Orbit(pos=x, vel=v) + + assert hasattr(new_o, "x1") + assert hasattr(new_o, "x2") + assert hasattr(new_o, "xyz") + assert hasattr(new_o, "v_x1") + assert hasattr(new_o, "v_x2") + assert hasattr(new_o, "v_xyz") + + +def test_to_coord_frame(): + # simple / unitless + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = Orbit(pos=x, vel=v) + + with pytest.raises(u.UnitConversionError): + o.to_coord_frame(Galactic()) + + # simple / with units + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = Orbit(pos=x, vel=v) + coo = o.to_coord_frame(Galactic()) + assert coo.name == "galactic" + + # simple / with units and time + x = np.random.random(size=(3, 128, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 128, 10)) * u.km / u.s + o = Orbit(pos=x, vel=v) + coo = o.to_coord_frame(Galactic()) + assert coo.name == "galactic" + + +def test_w(): + # simple / unitless + x = np.random.random(size=(3, 10)) + v = np.random.random(size=(3, 10)) + o = Orbit(pos=x, vel=v) + w = o.w() + assert w.shape == (6, 10) + + # simple / with units + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = Orbit(pos=x, vel=v) + with pytest.raises(ValueError): + o.w() + w = o.w(units=galactic) + assert np.allclose(x.value, w[:3, :]) + assert np.allclose(v.value, (w[3:, :] * u.kpc / u.Myr).to(u.km / u.s).value) + + # simple / with units and potential + p = HernquistPotential(units=galactic, m=1e11, c=0.25) + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = Orbit(pos=x, vel=v, potential=p, frame=StaticFrame(galactic)) + w = o.w() + assert np.allclose(x.value, w[:3, :]) + assert np.allclose(v.value, (w[3:, :] * u.kpc / u.Myr).to(u.km / u.s).value) + + w = o.w(units=solarsystem) + assert np.allclose(x.value, (w[:3, :] * u.au).to(u.kpc).value) + assert np.allclose(v.value, (w[3:, :] * u.au / u.yr).to(u.km / u.s).value) + + +def test_energy(): + # with units + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = Orbit(pos=x, vel=v) + KE = o.kinetic_energy() + assert KE.unit == (o.v_x.unit) ** 2 + assert KE.shape == o.pos.shape + + # with units and potential + p = HernquistPotential(units=galactic, m=1e11, c=0.25) + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = Orbit(pos=x, vel=v, potential=p, frame=StaticFrame(galactic)) + o.potential_energy() + o.energy() + + +def test_angular_momentum(): + # with units + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.normal(0.0, 100.0, size=(3, 10)) * u.km / u.s + o = Orbit(pos=x, vel=v) + L = o.angular_momentum() + assert L.unit == (o.v_x.unit * o.x.unit) + assert L.shape == ((3, *o.shape)) + + +def test_eccentricity(): + pot = KeplerPotential(m=1.0, units=solarsystem) + w0 = PhaseSpacePosition( + pos=[1, 0, 0.0] * u.au, vel=[0.0, 2 * np.pi, 0.0] * u.au / u.yr + ) + ham = Hamiltonian(pot) + w = ham.integrate_orbit(w0, dt=0.01, n_steps=10000, Integrator=DOPRI853Integrator) + e = w.eccentricity() + assert np.abs(e) < 1e-3 + + +def test_guiding_radius(): + q = [10.0, 0, 0] * u.kpc + pot = HernquistPotential(m=1e10, c=10.0, units=galactic) + vc = pot.circular_velocity(q).to_value(u.km / u.s) + w0 = PhaseSpacePosition(pos=q, vel=[0.0, 1.3, 0.0] * vc) + ham = Hamiltonian(pot) + w = ham.integrate_orbit(w0, dt=0.5, n_steps=1000, Integrator=DOPRI853Integrator) + w.guiding_radius() + + # Check that orbit in non-axisymmetric potential raises a warning + pot = NFWPotential(m=1e10, r_s=10.0, b=0.95, units=galactic) + vc = pot.circular_velocity(q).to_value(u.km / u.s) + w0 = PhaseSpacePosition(pos=q, vel=[0.0, 1.3, 0.0] * vc) + ham = Hamiltonian(pot) + orbit = ham.integrate_orbit(w0, dt=0.5, n_steps=1000, Integrator=DOPRI853Integrator) + + with pytest.warns(RuntimeWarning): + orbit.guiding_radius() + + +def test_apocenter_pericenter_period(): + pot = KeplerPotential(m=1.0, units=solarsystem) + w0 = PhaseSpacePosition( + pos=[1, 0, 0.0] * u.au, vel=[0.0, 1.5 * np.pi, 0.0] * u.au / u.yr + ) + + ham = Hamiltonian(pot) + w = ham.integrate_orbit(w0, dt=0.01, n_steps=10000, Integrator=DOPRI853Integrator) + + apo = w.apocenter() + per = w.pericenter() + zmax = w.zmax() + assert apo.shape == () + assert per.shape == () + assert zmax.shape == () + + assert apo.unit == u.au + assert per.unit == u.au + assert zmax.unit == u.au + assert apo > per + + # see if they're where we expect + E = np.mean(w.energy()).decompose(pot.units).value + L = ( + np.mean(np.sqrt(np.sum(w.angular_momentum() ** 2, axis=0))) + .decompose(pot.units) + .value + ) + + def func(r): + return 2 * (E - pot.energy([r, 0, 0]).value[0]) - L**2 / r**2 + + pred_apo = so.brentq(func, 0.9, 1.0) + pred_per = so.brentq(func, 0.3, 0.5) + + assert np.allclose(apo.value, pred_apo, rtol=1e-2) + assert np.allclose(per.value, pred_per, rtol=1e-2) + + # Return all peris, apos + apos = w.apocenter(func=None) + pers = w.pericenter(func=None) + zmax = w.zmax(func=None) + T = w.estimate_period() + + dapo = np.std(apos) / np.mean(apos) + assert dapo > 0 + assert np.allclose(dapo, 0.0, atol=1e-4) + + dper = np.std(pers) / np.mean(pers) + assert dper > 0 + assert np.allclose(dper, 0.0, atol=1e-4) + + # Now try for expected behavior when multiple orbits are integrated: + w0 = PhaseSpacePosition( + pos=([[1, 0, 0.0], [1.1, 0, 0]] * u.au).T, + vel=([[0.0, 1.5 * np.pi, 0.0], [0.0, 1.5 * np.pi, 0.0]] * u.au / u.yr).T, + ) + + w = ham.integrate_orbit(w0, dt=0.01, n_steps=10000) + + per = w.pericenter(approximate=True) + apo = w.apocenter(approximate=True) + zmax = w.zmax(approximate=True) + ecc = w.eccentricity(approximate=True) + + +def test_estimate_period(): + ntimes = 16384 + for true_T_R in [1.0, 2.0, 4.123]: + t = np.linspace(0, 10.0, ntimes) + R = 0.25 * np.sin(2 * np.pi / true_T_R * t) + 1.0 + phi = (2 * np.pi * t) % (2 * np.pi) + + pos = np.zeros((3, ntimes)) + pos[0] = R * np.cos(phi) + pos[1] = R * np.sin(phi) + vel = np.zeros_like(pos) + + orb = Orbit(pos * u.kpc, vel * u.kpc / u.Myr, t=t * u.Gyr) + T = orb.estimate_period() + assert "x" in T.colnames + assert "y" in T.colnames + assert "z" in T.colnames + + T = orb.cylindrical.estimate_period() + assert np.allclose(T["rho"].value, true_T_R, rtol=1e-3) + assert np.allclose(T["phi"].value, 1.0, rtol=1e-3) + + +def test_estimate_period_regression(): + pot = KeplerPotential(m=1.0, units=solarsystem) + w0 = PhaseSpacePosition( + pos=[1, 0, 0.0] * u.au, vel=[0.0, 1.5 * np.pi, 0.0] * u.au / u.yr + ) + w0 = combine((w0, w0, w0)) + + ham = Hamiltonian(pot) + w = ham.integrate_orbit(w0, dt=0.01, n_steps=10000, Integrator=DOPRI853Integrator) + T = w.estimate_period() + print(T) + + +def make_known_orbits(tmpdir, xs, vxs, potential, names): + # See Binney & Tremaine (2008) Figure 3.8 and 3.9 + E = -0.337 + y = 0.0 + + ws = [] + for x, vx, _name in zip(xs, vxs, names): + vy = np.sqrt(2 * (E - potential.energy([x, y, 0.0]).value))[0] + w = [x, y, 0.0, vx, vy, 0.0] + ws.append(w) + ws = np.array(ws).T + + ham = Hamiltonian(potential) + return ham.integrate_orbit(ws, dt=0.05, n_steps=10000) + + +def test_circulation(tmpdir): + potential = LogarithmicPotential( + v_c=1.0, r_h=0.14, q1=1.0, q2=0.9, q3=1.0, units=galactic + ) + + # individual + ws = make_known_orbits(tmpdir, [0.5, 0], [0.0, 1.5], potential, ["loop", "box"]) + + w1 = ws[:, 0] + circ = w1.circulation() + assert circ.shape == (3,) + assert circ.sum() == 1 + + w2 = ws[:, 1] + circ = w2.circulation() + assert circ.shape == (3,) + assert circ.sum() == 0 + + # try also for both, together + circ = ws.circulation() + assert circ.shape == (3, 2) + assert np.allclose(circ.sum(axis=0), [1, 0]) + + +def test_align_circulation(): + t = np.linspace(0, 100, 1024) + w = np.zeros((6, 1024, 4)) + + # loop around x axis + w[1, :, 0] = np.cos(t) + w[2, :, 0] = np.sin(t) + w[4, :, 0] = -np.sin(t) + w[5, :, 0] = np.cos(t) + + # loop around y axis + w[0, :, 1] = -np.cos(t) + w[2, :, 1] = np.sin(t) + w[3, :, 1] = np.sin(t) + w[5, :, 1] = np.cos(t) + + # loop around z axis + w[0, :, 2] = np.cos(t) + w[1, :, 2] = np.sin(t) + w[3, :, 2] = -np.sin(t) + w[4, :, 2] = np.cos(t) + + # box + w[0, :, 3] = np.cos(t) + w[1, :, 3] = -np.cos(0.5 * t) + w[2, :, 3] = np.cos(0.25 * t) + w[3, :, 3] = -np.sin(t) + w[4, :, 3] = 0.5 * np.sin(0.5 * t) + w[5, :, 3] = -0.25 * np.sin(0.25 * t) + + # First, individually + for i in range(w.shape[2]): + orb = Orbit.from_w(w[..., i], units=galactic) + new_orb = orb.align_circulation_with_z() + circ = new_orb.circulation() + + if i == 3: + assert np.sum(circ) == 0 + else: + assert circ[2] == 1.0 + + # all together now + orb = Orbit.from_w(w, units=galactic) + circ = orb.circulation() + assert circ.shape == (3, 4) + + new_orb = orb.align_circulation_with_z() + new_circ = new_orb.circulation() + assert np.all(new_circ[2, :3] == 1.0) + assert np.all(new_circ[:, 3] == 0.0) + + +def test_frame_transform(): + static = StaticFrame(galactic) + rotating = ConstantRotatingFrame( + Omega=[0.53, 1.241, 0.9394] * u.rad / u.Myr, units=galactic + ) + + x = np.random.random(size=(3, 10)) * u.kpc + v = np.random.random(size=(3, 10)) * u.km / u.s + t = np.linspace(0, 1, 10) * u.Myr + + # no frame specified at init + o = Orbit(pos=x, vel=v, t=t) + with pytest.raises(ValueError): + o.to_frame(rotating) + + o.to_frame(rotating, current_frame=static, t=o.t) + o.to_frame(rotating, current_frame=static) + + # frame specified at init + o = Orbit( + pos=x, + vel=v, + t=t, + frame=static, + potential=HernquistPotential(m=1e10, c=0.5, units=galactic), + ) + o.to_frame(rotating) + o.to_frame(rotating, t=o.t) + + +_x = ([[1, 2, 3.0], [1, 2, 3.0]] * u.kpc).T +_v = ([[1, 2, 3.0], [1, 2, 3.0]] * u.km / u.s).T + + +@pytest.mark.parametrize( + "obj", + [ + Orbit(_x, _v), + Orbit(_x, _v, t=[5, 99] * u.Myr), + Orbit(_x, _v, t=[5, 99] * u.Myr, frame=StaticFrame(galactic)), + Orbit( + _x, + _v, + t=[5, 99] * u.Myr, + frame=StaticFrame(galactic), + potential=HernquistPotential(m=1e10, c=0.5, units=galactic), + ), + ], +) +@pytest.mark.skipif(not HAS_H5PY, reason="h5py required for this test") +def test_io(tmpdir, obj): + import h5py + + filename = str(tmpdir.join("thing.hdf5")) + with h5py.File(filename, "w") as f: + obj.to_hdf5(f) + + obj2 = Orbit.from_hdf5(filename) + assert u.allclose(obj.xyz, obj2.xyz) + assert u.allclose(obj.v_xyz, obj2.v_xyz) + if obj.t is not None: + assert u.allclose(obj.t, obj2.t) + + assert obj.frame == obj2.frame + assert obj.potential == obj2.potential + + +@pytest.mark.parametrize( + "obj", + [ + Orbit(_x, _v), + Orbit(_x, _v, t=[5, 99] * u.Myr), + Orbit(_x, _v, t=[5, 99] * u.Myr, frame=StaticFrame(galactic)), + Orbit( + _x, + _v, + t=[5, 99] * u.Myr, + frame=StaticFrame(galactic), + potential=HernquistPotential(m=1e10, c=0.5, units=galactic), + ), + ], +) +@pytest.mark.skipif(not HAS_GALPY, reason="requires galpy to run this test") +def test_orbit_to_galpy(obj): + o1 = obj.to_galpy_orbit() + o2 = obj.to_galpy_orbit(ro=8 * u.kpc) + o3 = obj.to_galpy_orbit(vo=220 * u.km / u.s) + o4 = obj.to_galpy_orbit(ro=8 * u.kpc, vo=220 * u.km / u.s) + + +@pytest.mark.skipif(not HAS_GALPY, reason="requires galpy to run this test") +def test_orbit_from_galpy(): + import galpy.orbit as galpy_o + import galpy.potential as galpy_p + + mp = galpy_p.MiyamotoNagaiPotential(a=0.5, b=0.0375, amp=1.0, normalize=1.0) + galpy_orbit = galpy_o.Orbit([1.0, 0.1, 1.1, 0.0, 0.1, 1.0]) + ts = np.linspace(0, 100, 10000) + galpy_orbit.integrate(ts, mp, method="odeint") + gala_orbit = Orbit.from_galpy_orbit(galpy_orbit) + + assert len(gala_orbit.t) == len(ts) diff --git a/gala/source/tests/dynamics/test_plot.py b/gala/source/tests/dynamics/test_plot.py new file mode 100644 index 0000000000000000000000000000000000000000..7df177462a00030f71ba7532e57ebf4e175ea6ba --- /dev/null +++ b/gala/source/tests/dynamics/test_plot.py @@ -0,0 +1,162 @@ +"""Test dynamics plotting functions""" + +import subprocess + +import astropy.units as u +import numpy as np +import pytest + +from gala._optional_deps import HAS_MATPLOTLIB +from gala.dynamics import Orbit, PhaseSpacePosition +from gala.dynamics.plot import plot_projections +from gala.units import galactic + +if HAS_MATPLOTLIB: + import matplotlib.pyplot as plt +else: + plt = None + + +def pytest_generate_tests(metafunc): + if "obj" not in metafunc.fixturenames: + return + + object_list = [] + + norbits = 16 + object_list.append( + PhaseSpacePosition(pos=np.random.random(size=3), vel=np.random.random(size=3)) + ) + object_list.append( + PhaseSpacePosition( + pos=np.random.random(size=(3, norbits)), + vel=np.random.random(size=(3, norbits)), + ) + ) + object_list.append( + PhaseSpacePosition( + pos=np.random.random(size=(3, norbits)) * u.kpc, + vel=np.random.random(size=(3, norbits)) * u.km / u.s, + ) + ) + + nsteps = 16 + object_list.append( + Orbit( + pos=np.random.random(size=(3, nsteps)), + vel=np.random.random(size=(3, nsteps)), + t=np.linspace(0, 1, nsteps), + ) + ) + object_list.append( + Orbit( + pos=np.random.random(size=(3, nsteps, 2)), + vel=np.random.random(size=(3, nsteps, 2)), + t=np.linspace(0, 1, nsteps), + ) + ) + object_list.append( + Orbit( + pos=np.random.random(size=(3, nsteps)) * u.kpc, + vel=np.random.random(size=(3, nsteps)) * u.km / u.s, + t=np.linspace(0, 1, nsteps) * u.Myr, + ) + ) + + # 2D + object_list.append( + PhaseSpacePosition( + pos=np.random.random(size=(2, norbits)), + vel=np.random.random(size=(2, norbits)), + ) + ) + object_list.append( + Orbit( + pos=np.random.random(size=(2, nsteps)), + vel=np.random.random(size=(2, nsteps)), + t=np.linspace(0, 1, nsteps), + ) + ) + + test_names = [f"{obj.__class__.__name__}{i}" for i, obj in enumerate(object_list)] + + metafunc.parametrize(["i", "obj"], list(enumerate(object_list)), ids=test_names) + + +@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="Matplotlib is required") +def test_plot_projections(i, obj): + # Try executing the method + # TODO: no test of the actual figure drawn! + fig = obj.plot() + plt.close(fig) + + # Try with just 2D projection, and passing in a bunch of inputs... + x = obj.xyz.value + fig, axes = plt.subplots(1, 2) + fig = plot_projections( + x[:2], + autolim=True, + axes=axes, + subplots_kwargs={"sharex": True}, + labels=["x", "y"], + plot_function=plt.plot, + marker="o", + linestyle="--", + color="r", + ) + plt.close(fig) + + +@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="Matplotlib is required") +def test_units(i, obj): + comp_names = list(obj.pos_components.keys()) + + if getattr(obj, comp_names[0]).unit == u.one: + with pytest.raises(u.UnitConversionError): + obj.plot(comp_names[:2], units=u.kpc) + + with pytest.raises(u.UnitConversionError): + obj.plot(comp_names[:2], units=[u.kpc, u.pc]) + + fig = obj.plot(units=galactic) + plt.close(fig) + + else: + fig = obj.plot(comp_names[:2], units=u.kpc) + plt.close(fig) + + fig = obj.plot(comp_names[:2], units=[u.kpc, u.pc]) + plt.close(fig) + + fig = obj.plot(comp_names[:2], units=galactic) + plt.close(fig) + + +@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="Matplotlib is required") +def test_animate(tmpdir, i, obj): + if not isinstance(obj, Orbit): + pytest.skip() + + try: + proc = subprocess.run( + ["ffmpeg -version"], shell=True, check=True, capture_output=True + ) + except subprocess.CalledProcessError: + pytest.skip(reason="ffmpeg not installed") + + if proc.returncode > 0: + pytest.skip(reason="ffmpeg not installed") + + # Try executing the method - unfortunately no test of the actual figure + # drawn! + fig, anim = obj.animate(segment_nsteps=3) + anim.save(tmpdir / f"anim{i}.mp4") + + # test hiding the timestep label + fig, anim = obj.animate(segment_nsteps=3, show_time=False) + anim.save(tmpdir / f"anim{i}_no_time.mp4") + + if obj.ndim == 3: + # Also try cylindrical, and sub-selecting components: + _fig, anim = obj.cylindrical.animate(components=["rho", "z"]) + anim.save(tmpdir / f"anim{i}_cyl.mp4") diff --git a/gala/source/tests/dynamics/test_representation_nd.py b/gala/source/tests/dynamics/test_representation_nd.py new file mode 100644 index 0000000000000000000000000000000000000000..344c837009e06ccae1e5ad742aa0c264ee754a26 --- /dev/null +++ b/gala/source/tests/dynamics/test_representation_nd.py @@ -0,0 +1,64 @@ +import astropy.units as u +import numpy as np +import pytest + +from gala.dynamics.representation_nd import ( + NDCartesianDifferential, + NDCartesianRepresentation, +) + + +def test_init_repr(): + # Passing in x1, x2 + rep = NDCartesianRepresentation([1.0, 1.0]) + assert rep.xyz.shape == (2,) + + # Passing in x1, x2 + rep = NDCartesianRepresentation(np.random.random(size=(2, 8))) + assert rep.xyz.shape == (2, 8) + rep[:1] + + for n in range(1, 6 + 1): + print("N: " + str(n)) + + xs = np.random.uniform(size=(n, 16)) * u.one + rep = NDCartesianRepresentation(xs) + for i in range(1, n + 1): + assert hasattr(rep, "x" + str(i)) + + xs2 = rep.xyz + assert u.allclose(xs, xs2) + + rep2 = rep[:8] + + assert rep.shape == (16,) + assert rep2.shape == (8,) + + +def test_init_diff(): + # Passing in x1, x2 + rep = NDCartesianDifferential([1.0, 1.0]) + assert rep.d_xyz.shape == (2,) + with pytest.raises(TypeError): + rep[:1] + + # Passing in x1, x2 + rep = NDCartesianDifferential(np.random.random(size=(2, 8))) + assert rep.d_xyz.shape == (2, 8) + rep[:1] + + for n in range(1, 6 + 1): + print("N: " + str(n)) + + xs = np.random.uniform(size=(n, 16)) * u.one + rep = NDCartesianDifferential(xs) + for i in range(1, n + 1): + assert hasattr(rep, "d_x" + str(i)) + + xs2 = rep.d_xyz + assert u.allclose(xs, xs2) + + rep2 = rep[:8] + + assert rep.shape == (16,) + assert rep2.shape == (8,) diff --git a/gala/source/tests/integrate/__init__.py b/gala/source/tests/integrate/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/gala/source/tests/integrate/test_cyintegrators.py b/gala/source/tests/integrate/test_cyintegrators.py new file mode 100644 index 0000000000000000000000000000000000000000..a8242abe5ce86e4e8e1690471f52aa361511a9b5 --- /dev/null +++ b/gala/source/tests/integrate/test_cyintegrators.py @@ -0,0 +1,143 @@ +""" +Test the Cython integrators. +""" + +import time +from itertools import product + +import numpy as np +import pytest +from gala.integrate.cyintegrators.dop853 import dop853_integrate_hamiltonian +from gala.integrate.cyintegrators.leapfrog import leapfrog_integrate_hamiltonian +from gala.integrate.cyintegrators.ruth4 import ruth4_integrate_hamiltonian + +from gala.integrate.pyintegrators.dopri853 import DOPRI853Integrator +from gala.integrate.pyintegrators.leapfrog import LeapfrogIntegrator +from gala.integrate.pyintegrators.ruth4 import Ruth4Integrator +from gala.potential import Hamiltonian, HernquistPotential +from gala.units import galactic + +integrator_list = [LeapfrogIntegrator, DOPRI853Integrator, Ruth4Integrator] +func_list = [ + leapfrog_integrate_hamiltonian, + dop853_integrate_hamiltonian, + ruth4_integrate_hamiltonian, +] + +_list = [] +for dt in [2, -2]: + _list.extend([(x, y, dt) for x, y in zip(integrator_list, func_list)]) + + +@pytest.mark.parametrize(("Integrator", "integrate_func", "dt"), _list) +def test_compare_to_py(Integrator, integrate_func, dt): + p = HernquistPotential(m=1e11, c=0.5, units=galactic) + H = Hamiltonian(potential=p) + + def F(t, w): + w = np.ascontiguousarray(w) + return H._gradient(w, np.array([0.0])) + + cy_w0 = np.array( + [ + [0.0, 10.0, 0.0, 0.2, 0.0, 0.0], + [10.0, 0.0, 0.0, 0.0, 0.2, 0.0], + [0.0, 10.0, 0.0, 0.0, 0.0, 0.2], + ] + ) + cy_w0 = np.ascontiguousarray(cy_w0.T) + py_w0 = cy_w0.copy() + + n_steps = 1024 + t = np.linspace(0, dt * n_steps, n_steps + 1) + + cy_t, cy_w = integrate_func(H, cy_w0, t) + + integrator = Integrator(F) + orbit = integrator(py_w0, dt=dt, n_steps=n_steps) + + py_t = orbit.t.value + py_w = orbit.w() # (ndim, ntimes, n) + + assert py_w.shape == cy_w.shape + assert np.allclose(cy_w[:, -1], py_w[:, -1]) + assert np.allclose(cy_t, py_t) + + +@pytest.mark.parametrize(("integrate_func", "dt"), product(func_list, [-2.0, 2])) +def test_save_all(integrate_func, dt): + p = HernquistPotential(m=1e11, c=0.5, units=galactic) + H = Hamiltonian(potential=p) + + w0 = np.array( + [ + [0.0, 10.0, 0.0, 0.2, 0.0, 0.0], + [10.0, 0.0, 0.0, 0.0, 0.2, 0.0], + [0.0, 10.0, 0.0, 0.0, 0.0, 0.2], + ] + ) + w0 = np.ascontiguousarray(w0.T) + + # 1024 steps + t = np.linspace(0, dt * 1024, 1024 + 1) + + t_all, w_all = integrate_func(H, w0, t) + t_f, w_f = integrate_func(H, w0, t, save_all=False) + + assert t_all[-1] == t_f[0] + assert np.allclose(w_all[:, -1], w_f) + + +# TODO: move this to only run if a flag like --remote-data is passed, like +# --speed-scaling or something? +@pytest.mark.skipif(True, reason="Slow test - mainly for plotting locally") +@pytest.mark.parametrize( + ("Integrator", "integrate_func"), zip(integrator_list, func_list) +) +def test_scaling(tmpdir, Integrator, integrate_func): + p = HernquistPotential(m=1e11, c=0.5, units=galactic) + + def F(t, w): + dq = w[3:] + dp = -p._gradient(w[:3], t=np.array([0.0])) + return np.vstack((dq, dp)) + + step_bins = np.logspace(2, np.log10(25000), 7) + colors = ["k", "b", "r"] + dt = 1.0 + + for _c, nparticles in zip(colors, [1, 100, 1000]): + cy_w0 = np.array([[0.0, 10.0, 0.0, 0.2, 0.0, 0.0]] * nparticles) + py_w0 = np.ascontiguousarray(cy_w0.T) + + x = [] + cy_times = [] + py_times = [] + for n_steps in step_bins: + print(nparticles, n_steps) + t = np.linspace(0, dt * n_steps, n_steps + 1) + x.append(n_steps) + + # time the Cython integration + t0 = time.time() + integrate_func(p.c_instance, cy_w0, t) + cy_times.append(time.time() - t0) + + # time the Python integration + t0 = time.time() + integrator = Integrator(F) + orbit = integrator(py_w0, dt=dt, n_steps=n_steps) + py_times.append(time.time() - t0) + + # pl.loglog(x, cy_times, linestyle='-', lw=2., c=c, marker='', + # label="cy: {} orbits".format(nparticles)) + # pl.loglog(x, py_times, linestyle='--', lw=2., c=c, marker='', + # label="py: {} orbits".format(nparticles)) + + # pl.title(Integrator.__name__) + # pl.legend(loc='upper left') + # pl.xlim(90, 30000) + # pl.xlabel("N steps") + # pl.tight_layout() + # # pl.show() + # pl.savefig(os.path.join(tmpdir, "integrate-scaling.png"), dpi=300) diff --git a/gala/source/tests/integrate/test_pyintegrators.py b/gala/source/tests/integrate/test_pyintegrators.py new file mode 100644 index 0000000000000000000000000000000000000000..dd0eecb61270adc957dc218a9b813b7ea43b1f3f --- /dev/null +++ b/gala/source/tests/integrate/test_pyintegrators.py @@ -0,0 +1,166 @@ +""" +Test the integrators. +""" + +import os + +import numpy as np +import pytest +from astropy.utils.exceptions import AstropyDeprecationWarning + +from gala._optional_deps import HAS_TQDM +from gala.integrate import ( + DOPRI853Integrator, + LeapfrogIntegrator, + RK5Integrator, + Ruth4Integrator, +) + +# Integrators to test +integrator_list = [ + RK5Integrator, + DOPRI853Integrator, + LeapfrogIntegrator, + Ruth4Integrator, +] + + +# Gradient functions: +def sho_F(t, w, T): + """Simple harmonic oscillator""" + q, p = w + wdot = np.zeros_like(w) + wdot[0] = p + wdot[1] = -((2 * np.pi / T) ** 2) * q + return wdot + + +def forced_sho_F(t, w, A, omega_d): + q, p = w + wdot = np.zeros_like(w) + wdot[0] = p + wdot[1] = -np.sin(q) + A * np.cos(omega_d * t) + return wdot + + +def lorenz_F(t, w, sigma, rho, beta): + x, y, z, *_ = w + wdot = np.zeros_like(w) + wdot[0] = sigma * (y - x) + wdot[1] = x * (rho - z) - y + wdot[2] = x * y - beta * z + return wdot + + +def ptmass_F(t, w): + x, y, px, py = w + a = -1.0 / (x * x + y * y) ** 1.5 + + wdot = np.zeros_like(w) + wdot[0] = px + wdot[1] = py + wdot[2] = x * a + wdot[3] = y * a + return wdot + + +@pytest.mark.parametrize("Integrator", integrator_list) +def test_sho_forward_backward(Integrator): + integrator = Integrator(sho_F, func_args=(1.0,)) + + dt = 1e-4 + n_steps = 10_000 + + forw = integrator([0.0, 1.0], dt=dt, n_steps=n_steps) + back = integrator([0.0, 1.0], dt=-dt, n_steps=n_steps) + + assert np.allclose(forw.w()[:, -1], back.w()[:, -1], atol=1e-6) + + +@pytest.mark.parametrize("Integrator", integrator_list) +def test_deprecated_run_method(Integrator): + """Test the deprecated run method.""" + integrator = Integrator(sho_F, func_args=(1.0,)) + + dt = 1e-4 + n_steps = 10_000 + + with pytest.warns(AstropyDeprecationWarning): + run = integrator.run([0.0, 1.0], dt=dt, n_steps=n_steps) + + call = integrator([0.0, 1.0], dt=dt, n_steps=n_steps) + + assert np.allclose(run.w()[:, -1], call.w()[:, -1], atol=1e-6) + + +@pytest.mark.parametrize("Integrator", integrator_list) +def test_point_mass(Integrator): + q0 = np.array([1.0, 0.0]) + p0 = np.array([0.0, 1.0]) + + integrator = Integrator(ptmass_F) + orbit = integrator(np.append(q0, p0), t1=0.0, t2=2 * np.pi, n_steps=1e4) + + assert np.allclose(orbit.w()[:, 0], orbit.w()[:, -1], atol=1e-6) + + +@pytest.mark.skipif(not HAS_TQDM, reason="requires tqdm to run this test") +@pytest.mark.parametrize("Integrator", integrator_list) +def test_progress(Integrator): + q0 = np.array([1.0, 0.0]) + p0 = np.array([0.0, 1.0]) + + integrator = Integrator(ptmass_F, progress=True) + _ = integrator(np.append(q0, p0), t1=0.0, t2=2 * np.pi, n_steps=1e2) + + +@pytest.mark.parametrize("Integrator", integrator_list) +def test_point_mass_multiple(Integrator): + w0 = np.array([[1.0, 0.0, 0.0, 1.0], [0.8, 0.0, 0.0, 1.1], [2.0, 1.0, -1.0, 1.1]]).T + + integrator = Integrator(ptmass_F) + _ = integrator(w0, dt=1e-3, n_steps=1e4) + + +@pytest.mark.parametrize("Integrator", integrator_list) +def test_driven_pendulum(Integrator): + integrator = Integrator(forced_sho_F, func_args=(0.07, 0.75)) + _ = integrator([3.0, 0.0], dt=1e-2, n_steps=1e4) + + +@pytest.mark.parametrize("Integrator", integrator_list) +def test_lorenz(Integrator): + sigma, rho, beta = 10.0, 28.0, 8 / 3.0 + integrator = Integrator(lorenz_F, func_args=(sigma, rho, beta)) + + _ = integrator([0.5, 0.5, 0.5, 0, 0, 0], dt=1e-2, n_steps=1e4) + + +@pytest.mark.parametrize("Integrator", integrator_list) +def test_memmap(tmpdir, Integrator): + dt = 0.1 + n_steps = 1000 + nw0 = 10000 + + filename = os.path.join(str(tmpdir), "test_memmap.npy") + mmap = np.memmap(filename, mode="w+", shape=(2, n_steps + 1, nw0)) + + w0 = np.random.uniform(-1, 1, size=(2, nw0)) + + integrator = Integrator(sho_F, func_args=(1.0,)) + + _ = integrator(w0, dt=dt, n_steps=n_steps, mmap=mmap) + + +@pytest.mark.parametrize("Integrator", integrator_list) +def test_py_save_all(Integrator): + integrator_all = Integrator(sho_F, func_args=(1.3,), save_all=True) + integrator_final = Integrator(sho_F, func_args=(1.3,), save_all=False) + + dt = 1e-4 + n_steps = 10_000 + + out_all = integrator_all([0.0, 1.0], dt=dt, n_steps=n_steps) + out_final = integrator_final([0.0, 1.0], dt=dt, n_steps=n_steps) + + assert np.allclose(out_all.w()[:, -1], out_final.w()[:, 0]) diff --git a/gala/source/tests/integrate/test_timespec.py b/gala/source/tests/integrate/test_timespec.py new file mode 100644 index 0000000000000000000000000000000000000000..6b657d979fac7da3dfd2e146608c6f6bac14cd9e --- /dev/null +++ b/gala/source/tests/integrate/test_timespec.py @@ -0,0 +1,76 @@ +""" +Test the time specification parser. +""" + +import astropy.units as u +import numpy as np +import pytest + +from gala.integrate.timespec import parse_time_specification +from gala.units import DimensionlessUnitSystem, galactic + + +def test_dt_n_steps(): + # dt, n_steps[, t1] : (numeric, int[, numeric]) + t = parse_time_specification(DimensionlessUnitSystem(), dt=0.1, n_steps=100) + np.testing.assert_allclose(np.min(t), 0.0) + np.testing.assert_allclose(np.max(t), 10.0) + assert len(t) == 101 + + t = parse_time_specification( + DimensionlessUnitSystem(), dt=0.1, n_steps=100, t1=10.0 + ) + np.testing.assert_allclose(np.min(t), 10.0) + np.testing.assert_allclose(np.max(t), 20.0) + assert len(t) == 101 + + +def test_dt_t1_t2(): + # dt, t1, t2 : (numeric, numeric, numeric) + t = parse_time_specification(DimensionlessUnitSystem(), dt=0.1, t1=10.0, t2=130.0) + np.testing.assert_allclose(np.min(t), 10.0) + np.testing.assert_allclose(np.max(t), 130.0) + + t = parse_time_specification( + DimensionlessUnitSystem(), dt=-0.1, t1=10.0, t2=-13.412 + ) + np.testing.assert_allclose(np.min(t), -13.412) + np.testing.assert_allclose(np.max(t), 10.0) + + with pytest.raises(ValueError): + parse_time_specification(DimensionlessUnitSystem(), dt=-0.1, t1=10.0, t2=130.0) + + with pytest.raises(ValueError): + parse_time_specification( + DimensionlessUnitSystem(), dt=0.1, t1=130.0, t2=-10.142 + ) + + +def test_n_steps_t1_t2(): + # n_steps, t1, t2 : (int, numeric, numeric) + t = parse_time_specification( + DimensionlessUnitSystem(), n_steps=100, t1=24.124, t2=91.412 + ) + np.testing.assert_allclose(np.min(t), 24.124) + np.testing.assert_allclose(np.max(t), 91.412) + + t = parse_time_specification( + DimensionlessUnitSystem(), n_steps=100, t1=24.124, t2=-91.412 + ) + np.testing.assert_allclose(np.max(t), 24.124) + np.testing.assert_allclose(np.min(t), -91.412) + + +def test_t(): + # t : array_like + input = np.arange(0.0, 10.0, 0.1) + t = parse_time_specification(DimensionlessUnitSystem(), t=input) + assert (t == input).all() + + +def test_t_units(): + # t : array_like + input = np.linspace(0.0, 10.0, 100) * u.Gyr + t = parse_time_specification(galactic, t=input) + + assert np.allclose(t[-1], 10000.0) diff --git a/gala/source/tests/integration/README.md b/gala/source/tests/integration/README.md new file mode 100644 index 0000000000000000000000000000000000000000..7dfe1d18c3463fbb5c8ca9e72e349884522e5883 --- /dev/null +++ b/gala/source/tests/integration/README.md @@ -0,0 +1,3 @@ +Note: This directory contains integration tests in that they test the +interaction between different parts of Gala. These are _not_ the tests for the +`gala.integrate` subpackage! diff --git a/gala/source/tests/integration/test_bar_rotating_frame.py b/gala/source/tests/integration/test_bar_rotating_frame.py new file mode 100644 index 0000000000000000000000000000000000000000..0fca5dcfe0c0858ba77f4ed9fb9fc8a211d35a22 --- /dev/null +++ b/gala/source/tests/integration/test_bar_rotating_frame.py @@ -0,0 +1,218 @@ +""" +Integration test for time-dependent barred potential vs. bar with a rotating frame. + +This test validates that orbits integrated in a rotating frame match orbits integrated +in an inertial frame with a time-dependent rotating bar potential when transformed to +the rotating frame. +""" + +import astropy.units as u +import numpy as np +import pytest +from gala._cconfig import GSL_ENABLED +from scipy.spatial.transform import Rotation + +import gala.dynamics as gd +import gala.potential as gp + +pytestmark = pytest.mark.skipif( + not GSL_ENABLED, + reason="requires Gala compiled with GSL support", +) + + +class TestBarRotatingFrameIntegration: + """Test integration of orbits in rotating bar potentials.""" + + @pytest.fixture + def setup_potentials(self): + """Set up the barred Milky Way potential and rotating frame.""" + # Set up rotation parameters + with u.set_enabled_equivalencies(u.dimensionless_angles()): + Omega = 30 * u.km / u.s / u.kpc + Omega = Omega.to(u.rad / u.Gyr) + + dt = 2 * np.pi * u.rad / Omega / 200 # 200 steps per rotation period + + # Create time-dependent rotation matrices + time_knots = np.arange(0, 5, dt.to(u.Gyr).value) * u.Gyr + bar_angle = (-Omega * time_knots).to_value(u.rad) + Rs = Rotation.from_euler("z", bar_angle).as_matrix() + + # Base Milky Way potential + mw = gp.MilkyWayPotential(version="latest") + + # Time-dependent barred potential (inertial frame) + bar_mw = gp.CCompositePotential() + bar_mw["bar"] = gp.TimeInterpolatedPotential( + gp.LongMuraliBarPotential, + time_knots=time_knots, + m=1e10 * u.Msun, + a=4 * u.kpc, + b=0.8 * u.kpc, + c=0.25 * u.kpc, + units="galactic", + alpha=25 * u.deg, + R=Rs, + ) + bar_mw["disk"] = mw["disk"].replicate(m=4.1e10 * u.Msun) + bar_mw["halo"] = mw["halo"] + bar_mw["nucleus"] = mw["nucleus"] + + # Static bar potential in rotating frame + bar_mw_static = gp.CCompositePotential() + bar_mw_static["bar"] = gp.LongMuraliBarPotential( + m=1e10 * u.Msun, + a=4 * u.kpc, + b=0.8 * u.kpc, + c=0.25 * u.kpc, + alpha=25 * u.deg, + units="galactic", + ) + bar_mw_static["disk"] = mw["disk"].replicate(m=4.1e10 * u.Msun) + bar_mw_static["halo"] = mw["halo"] + bar_mw_static["nucleus"] = mw["nucleus"] + + # Rotating frame + frame = gp.ConstantRotatingFrame( + Omega=[0, 0, Omega.value] * Omega.unit, units="galactic" + ) + bar_H_frame = gp.Hamiltonian(potential=bar_mw_static, frame=frame) + + return { + "bar_mw": bar_mw, + "bar_H_frame": bar_H_frame, + "time_knots": time_knots, + "Omega": Omega, + "bar_mw_static": bar_mw_static, + } + + @pytest.fixture + def corotation_initial_conditions(self, setup_potentials): + """Find initial conditions near the corotation radius.""" + import scipy.optimize as so + + bar_mw_static = setup_potentials["bar_mw_static"] + Omega = setup_potentials["Omega"] + + def func(r): + with u.set_enabled_equivalencies(u.dimensionless_angles()): + Om = bar_mw_static.circular_velocity([r[0], 0, 0] * u.kpc)[0] / ( + r[0] * u.kpc + ) + return (Om - Omega).to(Omega.unit).value ** 2 + + res = so.minimize(func, x0=10.0, method="powell") + + r_corot = res.x[0] * u.kpc + v_circ = Omega * r_corot * u.kpc + + return gd.PhaseSpacePosition( + pos=[r_corot.value, 0, 0] * r_corot.unit, + vel=[0, v_circ.value, 0.0] * v_circ.unit, + ) + + def test_rotating_frame_vs_inertial_frame( + self, setup_potentials, corotation_initial_conditions + ): + """ + Test that orbits in rotating frame match transformed inertial frame orbits. + + This test integrates an orbit at the corotation radius in two ways: + 1. In a rotating frame with a static bar potential + 2. In an inertial frame with a time-dependent rotating bar potential + + The orbit from (2) is then transformed to the rotating frame and should + match the orbit from (1) to within numerical precision. + """ + bar_mw = setup_potentials["bar_mw"] + bar_H_frame = setup_potentials["bar_H_frame"] + time_knots = setup_potentials["time_knots"] + w0 = corotation_initial_conditions + + # Integrate in rotating frame with static bar + orbit_rot_frame = bar_H_frame.integrate_orbit( + w0, + t1=time_knots.min(), + t2=time_knots.max(), + dt=0.1 * u.Myr, + Integrator="dopri853", + Integrator_kwargs={"atol": 1e-14, "rtol": 1e-14}, + ) + + # Integrate in inertial frame with time-dependent bar + orbit_inertial = bar_mw.integrate_orbit( + w0, + t1=time_knots.min(), + t2=time_knots.max(), + dt=0.1 * u.Myr, + Integrator="dopri853", + Integrator_kwargs={"atol": 1e-14, "rtol": 1e-14}, + ) + + # Transform inertial orbit to rotating frame + orbit_inertial_in_rot_frame = orbit_inertial.to_frame(bar_H_frame.frame) + + assert orbit_rot_frame.shape == orbit_inertial_in_rot_frame.shape + assert u.allclose( + orbit_inertial_in_rot_frame.xyz, + orbit_rot_frame.xyz, + rtol=5e-5, + atol=2e-3 * u.kpc, + ) + assert u.allclose( + orbit_inertial_in_rot_frame.v_xyz, + orbit_rot_frame.v_xyz, + rtol=5e-5, + atol=3e-5 * u.kpc / u.Myr, + ) + + def test_energy_conservation(self, setup_potentials, corotation_initial_conditions): + """ + Test that energy is conserved during orbit integration. + + For both the rotating frame and inertial frame integrations, + the energy should be conserved to within the numerical tolerance + of the integrator. + """ + bar_mw = setup_potentials["bar_mw"] + bar_H_frame = setup_potentials["bar_H_frame"] + time_knots = setup_potentials["time_knots"] + w0 = corotation_initial_conditions + + # Integrate in rotating frame + orbit_rot_frame = bar_H_frame.integrate_orbit( + w0, + t1=time_knots.min(), + t2=time_knots.max(), + dt=0.1 * u.Myr, + Integrator="dopri853", + Integrator_kwargs={"atol": 1e-14, "rtol": 1e-14}, + ) + + # Integrate in inertial frame + orbit_inertial = bar_mw.integrate_orbit( + w0, + t1=time_knots.min(), + t2=time_knots.max(), + dt=0.1 * u.Myr, + Integrator="dopri853", + Integrator_kwargs={"atol": 1e-14, "rtol": 1e-14}, + ) + + orbit_inertial_in_rot_frame = orbit_inertial.to_frame(bar_H_frame.frame) + + # compute jacobi energies: + E_rot = bar_H_frame.energy(orbit_rot_frame) + E_inertial = bar_H_frame.energy(orbit_inertial_in_rot_frame) + + # check fractional energy conservation + frac_E_rot = np.abs((E_rot[1:] - E_rot[0]) / E_rot[0]) + frac_E_inertial = np.abs((E_inertial[1:] - E_inertial[0]) / E_inertial[0]) + + assert frac_E_rot.max() < 1e-12, ( + f"Rotating frame energy not conserved: max error = {frac_E_rot.max()}" + ) + assert frac_E_inertial.max() < 1e-6, ( + f"Inertial frame energy not conserved: max error = {frac_E_inertial.max()}" + ) diff --git a/gala/source/tests/potential/frame/test_builtin.py b/gala/source/tests/potential/frame/test_builtin.py new file mode 100644 index 0000000000000000000000000000000000000000..c59be8b6c1e78bd0530ffff102da912a0d845044 --- /dev/null +++ b/gala/source/tests/potential/frame/test_builtin.py @@ -0,0 +1,86 @@ +import pickle + +import astropy.units as u +import pytest + +from gala.potential import ConstantRotatingFrame, StaticFrame +from gala.units import DimensionlessUnitSystem, galactic + + +class TestStaticFrame: + def test_init(self): + fr = StaticFrame() + assert isinstance(fr.units, DimensionlessUnitSystem) + + fr = StaticFrame(galactic) + + def test_compare(self): + fr1 = StaticFrame(galactic) + fr2 = StaticFrame(galactic) + assert fr1 == fr2 + + fr2 = StaticFrame() + assert fr1 != fr2 + + def test_pickle(self, tmpdir): + fr1 = StaticFrame(galactic) + + filename = tmpdir / "static.pkl" + with open(filename, "wb") as f: + pickle.dump(fr1, f) + + with open(filename, "rb") as f: + fr2 = pickle.load(f) + + assert fr1 == fr2 + + +class TestConstantRotatingFrame: + def test_init(self): + fr = ConstantRotatingFrame(Omega=[1e-3, 0.0, 0.0]) + assert isinstance(fr.units, DimensionlessUnitSystem) + + fr = ConstantRotatingFrame(Omega=1e-3) + assert isinstance(fr.units, DimensionlessUnitSystem) + + with pytest.raises(ValueError): + fr = ConstantRotatingFrame(Omega=[-13.0, 1.0, 40.0] * u.km / u.s / u.kpc) + + with pytest.raises(ValueError): + fr = ConstantRotatingFrame(Omega=40.0 * u.km / u.s / u.kpc) + + fr = ConstantRotatingFrame( + Omega=[-13.0, 1.0, 40.0] * u.km / u.s / u.kpc, units=galactic + ) + fr = ConstantRotatingFrame(Omega=40.0 * u.km / u.s / u.kpc, units=galactic) + fr = ConstantRotatingFrame( + [-13.0, 1.0, 40.0] * u.km / u.s / u.kpc, units=galactic + ) + + def test_compare(self): + # frame comparison + fr1 = ConstantRotatingFrame(Omega=[1e-3, 0.0, 0.0] / u.Myr, units=galactic) + fr2 = ConstantRotatingFrame(Omega=[1e-3, 0.0, 0.0] / u.Myr, units=galactic) + fr3 = ConstantRotatingFrame(Omega=[2e-3, 0.0, 0.0] / u.Myr, units=galactic) + fr4 = ConstantRotatingFrame(Omega=[2e-3, 0.0, 0.0]) + assert fr1 == fr2 + assert fr1 != fr3 + assert fr3 != fr4 + + st_fr = StaticFrame(galactic) + assert st_fr != fr1 + + st_fr = StaticFrame(DimensionlessUnitSystem()) + assert st_fr != fr1 + + def test_pickle(self, tmpdir): + fr1 = ConstantRotatingFrame(Omega=[1e-3, 0.0, 0.0] / u.Myr, units=galactic) + + filename = tmpdir / "rotating.pkl" + with open(filename, "wb") as f: + pickle.dump(fr1, f) + + with open(filename, "rb") as f: + fr2 = pickle.load(f) + + assert fr1 == fr2 diff --git a/gala/source/tests/potential/frame/test_transformations.py b/gala/source/tests/potential/frame/test_transformations.py new file mode 100644 index 0000000000000000000000000000000000000000..875981ec0b041f00464d8d8c5d0998f2cdc9683b --- /dev/null +++ b/gala/source/tests/potential/frame/test_transformations.py @@ -0,0 +1,110 @@ +import astropy.units as u +import numpy as np +import pytest + +from gala.dynamics import Orbit, PhaseSpacePosition +from gala.potential import ConstantRotatingFrame, StaticFrame +from gala.potential.frame.builtin.transformations import ( + constantrotating_to_static, + rodrigues_axis_angle_rotate, + static_to_constantrotating, +) +from gala.units import galactic + + +def test_axis_angle_rotate(): + for x in [np.random.random(size=(3, 32)), np.random.random(size=(3, 32, 8))]: + vec = np.random.random(size=(3, 32)) + theta = np.random.random(size=(32,)) + out = rodrigues_axis_angle_rotate(x, vec, theta) + assert out.shape == x.shape + + vec = np.random.random(size=(3,)) + theta = np.random.random(size=(32,)) + out = rodrigues_axis_angle_rotate(x, vec, theta) + assert out.shape == x.shape + + vec = np.random.random(size=(3,)) + theta = np.random.random(size=(1,)) + out = rodrigues_axis_angle_rotate(x, vec, theta) + assert out.shape == x.shape + + +def _helper(fi, fr, w, t=None): + pos_r, vel_r = static_to_constantrotating(fi, fr, w, t=t) + if isinstance(w, Orbit): + w2 = Orbit(pos=pos_r, vel=vel_r, t=t) + else: + w2 = PhaseSpacePosition(pos=pos_r, vel=vel_r) + pos_i, vel_i = constantrotating_to_static(fr, fi, w2, t=t) + + assert u.allclose(pos_i, w.xyz) + assert u.allclose(vel_i, w.v_xyz) + + pos_i, vel_i = constantrotating_to_static(fr, fi, w, t=t) + if isinstance(w, Orbit): + w2 = Orbit(pos=pos_i, vel=vel_i, t=t) + else: + w2 = PhaseSpacePosition(pos=pos_i, vel=vel_i) + pos_r, vel_r = static_to_constantrotating(fi, fr, w2, t=t) + + assert u.allclose(pos_r, w.xyz) + assert u.allclose(vel_r, w.v_xyz) + + +def test_frame_transforms_3d(): + frame_i = StaticFrame(units=galactic) + frame_r = ConstantRotatingFrame( + Omega=[0.112, 1.235, 0.8656] * u.rad / u.Myr, units=galactic + ) + + w = Orbit( + pos=np.random.random(size=(3, 32)) * u.kpc, + vel=np.random.random(size=(3, 32)) * u.kpc / u.Myr, + t=np.linspace(0, 1, 32) * u.Myr, + ) + _helper(frame_i, frame_r, w, t=w.t) + + w = Orbit( + pos=np.random.random(size=(3, 32, 8)) * u.kpc, + vel=np.random.random(size=(3, 32, 8)) * u.kpc / u.Myr, + t=np.linspace(0, 1, 32) * u.Myr, + ) + _helper(frame_i, frame_r, w, t=w.t) + + w = PhaseSpacePosition( + pos=np.random.random(size=3) * u.kpc, + vel=np.random.random(size=3) * u.kpc / u.Myr, + ) + with pytest.raises(ValueError): + _helper(frame_i, frame_r, w) + _helper(frame_i, frame_r, w, t=0.0 * u.Myr) + _helper(frame_i, frame_r, w, t=0.0) + + +def test_frame_transforms_2d(): + frame_i = StaticFrame(units=galactic) + frame_r = ConstantRotatingFrame(Omega=0.529 * u.rad / u.Myr, units=galactic) + + w = Orbit( + pos=np.random.random(size=(2, 32)) * u.kpc, + vel=np.random.random(size=(2, 32)) * u.kpc / u.Myr, + t=np.linspace(0, 1, 32) * u.Myr, + ) + _helper(frame_i, frame_r, w, t=w.t) + + w = Orbit( + pos=np.random.random(size=(2, 32, 8)) * u.kpc, + vel=np.random.random(size=(2, 32, 8)) * u.kpc / u.Myr, + t=np.linspace(0, 1, 32) * u.Myr, + ) + _helper(frame_i, frame_r, w, t=w.t) + + w = PhaseSpacePosition( + pos=np.random.random(size=2) * u.kpc, + vel=np.random.random(size=2) * u.kpc / u.Myr, + ) + with pytest.raises(ValueError): + _helper(frame_i, frame_r, w) + _helper(frame_i, frame_r, w, t=0.0 * u.Myr) + _helper(frame_i, frame_r, w, t=0.0) diff --git a/gala/source/tests/potential/hamiltonian/hamiltonian_helpers.py b/gala/source/tests/potential/hamiltonian/hamiltonian_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..823c073bb62ca236b5ce8d13470bcabab7a8e940 --- /dev/null +++ b/gala/source/tests/potential/hamiltonian/hamiltonian_helpers.py @@ -0,0 +1,187 @@ +import astropy.units as u +import numpy as np + +from gala.dynamics import Orbit, PhaseSpacePosition +from gala.units import galactic + +PSP = PhaseSpacePosition +ORB = Orbit + + +class _TestBase: + use_half_ndim = False + E_unit = u.erg / u.kg + + @classmethod + def setup_class(cls): + np.random.seed(42) + + ndim = 6 + r_ndim = ndim # return ndim + if cls.use_half_ndim: + r_ndim //= 2 + norbits = 16 + ntimes = 8 + + # some position or phase-space position arrays we will test methods on: + cls.w0s = [] + cls.energy_return_shapes = [] + cls.gradient_return_shapes = [] + cls.hessian_return_shapes = [] + + # 1D - phase-space position + cls.w0s.append( + PSP( + pos=np.random.random(size=ndim // 2), + vel=np.random.random(size=ndim // 2), + ) + ) + cls.w0s.append( + PSP( + pos=np.random.random(size=ndim // 2) * u.kpc, + vel=np.random.random(size=ndim // 2) * u.km / u.s, + ) + ) + cls.energy_return_shapes += [(1,)] * 2 + cls.gradient_return_shapes += [(r_ndim, 1)] * 2 + cls.hessian_return_shapes += [(r_ndim, r_ndim, 1)] * 2 + + # 2D - phase-space position + cls.w0s.append( + PSP( + pos=np.random.random(size=(ndim // 2, norbits)), + vel=np.random.random(size=(ndim // 2, norbits)), + ) + ) + cls.w0s.append( + PSP( + pos=np.random.random(size=(ndim // 2, norbits)) * u.kpc, + vel=np.random.random(size=(ndim // 2, norbits)) * u.km / u.s, + ) + ) + cls.energy_return_shapes += [(norbits,)] * 2 + cls.gradient_return_shapes += [(r_ndim, norbits)] * 2 + cls.hessian_return_shapes += [(r_ndim, r_ndim, norbits)] * 2 + + # 3D - phase-space position + cls.w0s.append( + PSP( + pos=np.random.random(size=(ndim // 2, norbits, ntimes)), + vel=np.random.random(size=(ndim // 2, norbits, ntimes)), + ) + ) + cls.w0s.append( + PSP( + pos=np.random.random(size=(ndim // 2, norbits, ntimes)) * u.kpc, + vel=np.random.random(size=(ndim // 2, norbits, ntimes)) * u.km / u.s, + ) + ) + cls.energy_return_shapes += [(norbits, ntimes)] * 2 + cls.gradient_return_shapes += [(r_ndim, norbits, ntimes)] * 2 + cls.hessian_return_shapes += [(r_ndim, r_ndim, norbits, ntimes)] * 2 + + # 2D - orbit + cls.w0s.append( + ORB( + pos=np.random.random(size=(ndim // 2, ntimes)), + vel=np.random.random(size=(ndim // 2, ntimes)), + ) + ) + cls.w0s.append( + ORB( + pos=np.random.random(size=(ndim // 2, ntimes)) * u.kpc, + vel=np.random.random(size=(ndim // 2, ntimes)) * u.km / u.s, + ) + ) + cls.energy_return_shapes += [(ntimes,)] * 2 + cls.gradient_return_shapes += [ + ( + r_ndim, + ntimes, + ) + ] * 2 + cls.hessian_return_shapes += [ + ( + r_ndim, + r_ndim, + ntimes, + ) + ] * 2 + + # 3D - orbit + cls.w0s.append( + ORB( + pos=np.random.random(size=(ndim // 2, ntimes, norbits)), + vel=np.random.random(size=(ndim // 2, ntimes, norbits)), + ) + ) + cls.w0s.append( + ORB( + pos=np.random.random(size=(ndim // 2, ntimes, norbits)) * u.kpc, + vel=np.random.random(size=(ndim // 2, ntimes, norbits)) * u.km / u.s, + ) + ) + cls.energy_return_shapes += [(ntimes, norbits)] * 2 + cls.gradient_return_shapes += [(r_ndim, ntimes, norbits)] * 2 + cls.hessian_return_shapes += [(r_ndim, r_ndim, ntimes, norbits)] * 2 + + obj_w0s = cls.w0s[:] + for w0, eshp, gshp, hshp in zip( + obj_w0s, + cls.energy_return_shapes, + cls.gradient_return_shapes, + cls.hessian_return_shapes, + ): + cls.w0s.append(w0.w(galactic)) + cls.energy_return_shapes.append(eshp) + cls.gradient_return_shapes.append(gshp) + cls.hessian_return_shapes.append(hshp) + + def test_energy(self): + for arr, shp in zip(self.w0s, self.energy_return_shapes): + if ( + self.E_unit.is_equivalent(u.one) + and hasattr(arr, "pos") + and not arr.xyz.unit.is_equivalent(u.one) + ): + continue + + v = self.obj.energy(arr) + assert v.shape == shp + assert v.unit.is_equivalent(self.E_unit) + + t = np.zeros(np.array(arr).shape[1:]) + 0.1 + self.obj.energy(arr, t=0.1) + self.obj.energy(arr, t=t) + self.obj.energy(arr, t=0.1 * self.obj.units["time"]) + + def test_gradient(self): + for arr, shp in zip(self.w0s, self.gradient_return_shapes): + if ( + self.E_unit.is_equivalent(u.one) + and hasattr(arr, "pos") + and not arr.xyz.unit.is_equivalent(u.one) + ): + continue + + v = self.obj.gradient(arr) + assert v.shape == shp + # TODO: check return units + + t = np.zeros(np.array(arr).shape[1:]) + 0.1 + self.obj.gradient(arr, t=0.1) + self.obj.gradient(arr, t=t) + self.obj.gradient(arr, t=0.1 * self.obj.units["time"]) + + def test_hessian(self): + for arr, shp in zip(self.w0s, self.hessian_return_shapes): + if ( + self.E_unit.is_equivalent(u.one) + and hasattr(arr, "pos") + and not arr.xyz.unit.is_equivalent(u.one) + ): + continue + + g = self.obj.hessian(arr) + assert g.shape == shp + # TODO: check return units diff --git a/gala/source/tests/potential/hamiltonian/test_hamiltonian.py b/gala/source/tests/potential/hamiltonian/test_hamiltonian.py new file mode 100644 index 0000000000000000000000000000000000000000..22e30c10a220258143173246a86f7bf725ebf818 --- /dev/null +++ b/gala/source/tests/potential/hamiltonian/test_hamiltonian.py @@ -0,0 +1,75 @@ +import pickle + +import astropy.units as u +import numpy as np +import pytest + +from gala.potential import ( + ConstantRotatingFrame, + Hamiltonian, + KeplerPotential, + StaticFrame, +) +from gala.units import galactic, solarsystem + + +def test_init(): + p = KeplerPotential(m=1.0) + f = StaticFrame() + H = Hamiltonian(potential=p, frame=f) + H2 = Hamiltonian(H) + assert H2.potential is H.potential + + str_ = repr(H) + assert "KeplerPotential" in str_ + assert "StaticFrame" in str_ + + p = KeplerPotential(m=1.0, units=solarsystem) + f = StaticFrame(units=solarsystem) + H = Hamiltonian(potential=p, frame=f) + H = Hamiltonian(potential=p) + + p = KeplerPotential(m=1.0) + f = StaticFrame(galactic) + with pytest.raises(ValueError): + H = Hamiltonian(potential=p, frame=f) + + p = KeplerPotential(m=1.0, units=solarsystem) + f = StaticFrame() + with pytest.raises(ValueError): + H = Hamiltonian(potential=p, frame=f) + + p = KeplerPotential(m=1.0, units=solarsystem) + f = ConstantRotatingFrame(Omega=1.0 / u.yr, units=solarsystem) + with pytest.raises(ValueError): + H = Hamiltonian(potential=p, frame=f) + + +def test_pickle(tmpdir): + filename = tmpdir / "hamil.pkl" + + p = KeplerPotential(m=1.0, units=solarsystem) + + for fr in [ + StaticFrame(units=solarsystem), + ConstantRotatingFrame(Omega=[0, 0, 1] / u.yr, units=solarsystem), + ]: + H = Hamiltonian(potential=p, frame=fr) + + with open(filename, "wb") as f: + pickle.dump(H, f) + + with open(filename, "rb") as f: + H2 = pickle.load(f) + + +def test_regression_integrate_orbit_shape(): + """ + Test that integrate_orbit validates input shape correctly. + """ + p = KeplerPotential(m=1.0) + f = StaticFrame() + H = Hamiltonian(potential=p, frame=f) + + with pytest.raises(ValueError): + H.integrate_orbit(np.zeros((5, 6)), t=np.linspace(0, 1, 128)) diff --git a/gala/source/tests/potential/hamiltonian/test_with_frame_potential.py b/gala/source/tests/potential/hamiltonian/test_with_frame_potential.py new file mode 100644 index 0000000000000000000000000000000000000000..8b9cea50c545a1028b65509ccc0c738ad26548a1 --- /dev/null +++ b/gala/source/tests/potential/hamiltonian/test_with_frame_potential.py @@ -0,0 +1,227 @@ +import astropy.units as u +import numpy as np +import pytest +from hamiltonian_helpers import _TestBase + +from gala.dynamics import Orbit, PhaseSpacePosition +from gala.integrate import DOPRI853Integrator +from gala.potential import ( + ConstantRotatingFrame, + Hamiltonian, + HernquistPotential, + KeplerPotential, + NFWPotential, + StaticFrame, +) +from gala.units import dimensionless, galactic + +# ---------------------------------------------------------------------------- + + +def to_rotating_frame(omega, w, t=None): + """ + TODO: figure out units shit for omega and t + TODO: move this to be a ConstantRotatingFrame method + """ + + if not hasattr(omega, "unit"): + raise TypeError("Input frequency vector must be a Quantity object.") + + try: + omega = omega.to(u.rad / u.Myr, equivalencies=u.dimensionless_angles()).value + except u.UnitsError: + omega = omega.value + + if isinstance(w, Orbit) and t is not None: + raise TypeError( + "If passing in an Orbit object, do not also specify a time array, t." + ) + + if not isinstance(w, Orbit) and t is None: + raise TypeError( + "If not passing in an Orbit object, you must also specify a time array, t." + ) + + if t is not None and not hasattr(t, "unit"): + raise TypeError("Input time must be a Quantity object.") + + t = np.atleast_1d(t) if t is not None else w.t + + try: + t = t.to(u.Myr).value + except u.UnitsError: + t = t.value + + if isinstance(w, PhaseSpacePosition | Orbit): + Cls = w.__class__ + x_shape = w.xyz.shape + x_unit = w.x.unit + v_unit = w.v_x.unit + + x = w.xyz.reshape(3, -1).value + v = w.v_xyz.reshape(3, -1).value + + else: + Cls = None + ndim = w.shape[0] + x_shape = (ndim // 2, *w.shape[1:]) + x = w[: ndim // 2] + v = w[ndim // 2 :] + + if hasattr(x, "unit"): + raise TypeError( + "If w is not an Orbit or PhaseSpacePosition, w cannot have units!" + ) + + x_unit = u.one + v_unit = u.one + + # now need to compute rotation vector, ee, and angle, theta + ee = omega / np.linalg.norm(omega) + theta = (np.linalg.norm(omega) * t)[None] + + # we use Rodrigues' rotation formula to rotate the position + x_rot = ( + np.cos(theta) * x + + np.sin(theta) * np.cross(ee, x, axisa=0, axisb=0, axisc=0) + + (1 - np.cos(theta)) * np.einsum("i, ij->j", ee, x) * ee[:, None] + ) + + v_cor = np.cross(omega, x, axisa=0, axisb=0, axisc=0) * x_unit + v_rot = v - v_cor.to(v_unit, u.dimensionless_angles()).value + + x_rot = x_rot.reshape(x_shape) * x_unit + v_rot = v_rot.reshape(x_shape) * v_unit + + if Cls is None: + return np.vstack((x_rot, v_rot)) + + if issubclass(Cls, Orbit): + return Cls(pos=x_rot, vel=v_rot, t=t) + return Cls(pos=x_rot, vel=v_rot) + + +# ---------------------------------------------------------------------------- + + +class TestWithPotentialStaticFrame(_TestBase): + obj = Hamiltonian( + NFWPotential.from_circular_velocity(v_c=0.2, r_s=20.0, units=galactic), + StaticFrame(units=galactic), + ) + + @pytest.mark.skip("Not implemented") + def test_hessian(self): + pass + + +class TestKeplerRotatingFrame(_TestBase): + Omega = [0.0, 0, 1.0] * u.one + E_unit = u.one + obj = Hamiltonian( + KeplerPotential(m=1.0, units=dimensionless), + ConstantRotatingFrame(Omega=Omega, units=dimensionless), + ) + + @pytest.mark.skip("Not implemented") + def test_hessian(self): + pass + + def test_integrate(self): + w0 = PhaseSpacePosition(pos=[1.0, 0, 0.0], vel=[0, 1.0, 0.0]) + + for bl in [True, False]: + orbit = self.obj.integrate_orbit( + w0, + dt=1.0, + n_steps=1000, + cython_if_possible=bl, + Integrator=DOPRI853Integrator, + ) + + assert np.allclose(orbit.x.value, 1.0, atol=1e-7) + assert np.allclose(orbit.xyz.value[1:], 0.0, atol=1e-7) + + +class TestKepler2RotatingFrame(_TestBase): + Omega = [1.0, 1.0, 1.0] * u.one + E_unit = u.one + obj = Hamiltonian( + KeplerPotential(m=1.0, units=dimensionless), + ConstantRotatingFrame(Omega=Omega, units=dimensionless), + ) + + @pytest.mark.skip("Not implemented") + def test_hessian(self): + pass + + def test_integrate(self): + # -------------------------------------------------------------- + # when Omega is off from orbital frequency + # + w0 = PhaseSpacePosition(pos=[1.0, 0, 0.0], vel=[0, 1.1, 0.0]) + + for bl in [True, False]: + orbit = self.obj.integrate_orbit( + w0, + dt=0.1, + n_steps=10000, + cython_if_possible=bl, + Integrator=DOPRI853Integrator, + Integrator_kwargs={"atol": 1e-12, "rtol": 1e-12}, + ) + + L = orbit.angular_momentum() + C = orbit.energy() - np.sum(self.Omega[:, None] * L, axis=0) + dC = np.abs((C[1:] - C[0]) / C[0]) + assert np.all(dC < 1e-9) # conserve Jacobi constant + + +@pytest.mark.parametrize( + ("name", "Omega", "tol"), + [ + ("z-aligned co-rotating", [0, 0, 1.0] * u.one, 1e-10), + ("z-aligned", [0, 0, 1.5834] * u.one, 1e-10), + ("random", [0.95792653, 0.82760659, 0.66443135] * u.one, 1e-10), + ], +) +def test_velocity_rot_frame(name, Omega, tol): + # _i = inertial + # _r = rotating + + r0 = 1.245246 + potential = HernquistPotential(m=1.0, c=0.2, units=dimensionless) + vc = potential.circular_velocity([r0, 0, 0]).value[0] + w0 = PhaseSpacePosition(pos=[r0, 0, 0.0], vel=[0, vc, 0.0]) + Omega = [1.0, 1.0, vc / r0] * Omega # fmt: skip + + H_r = Hamiltonian( + potential, ConstantRotatingFrame(Omega=Omega, units=dimensionless) + ) + H = Hamiltonian(potential, StaticFrame(units=dimensionless)) + + orbit_i = H.integrate_orbit( + w0, + dt=0.1, + n_steps=1000, + Integrator=DOPRI853Integrator, + Integrator_kwargs={"atol": 1e-12, "rtol": 1e-12}, + ) + orbit_r = H_r.integrate_orbit( + w0, + dt=0.1, + n_steps=1000, + Integrator=DOPRI853Integrator, + Integrator_kwargs={"atol": 1e-12, "rtol": 1e-12}, + ) + + orbit_i2r = orbit_i.to_frame( + ConstantRotatingFrame(Omega=Omega, units=dimensionless) + ) + orbit_r2i = orbit_r.to_frame(StaticFrame(units=dimensionless)) + + assert u.allclose(orbit_i.xyz, orbit_r2i.xyz, atol=tol) + assert u.allclose(orbit_i.v_xyz, orbit_r2i.v_xyz, atol=tol) + + assert u.allclose(orbit_r.xyz, orbit_i2r.xyz, atol=tol) + assert u.allclose(orbit_r.v_xyz, orbit_i2r.v_xyz, atol=tol) diff --git a/gala/source/tests/potential/potential/Composite.yml b/gala/source/tests/potential/potential/Composite.yml new file mode 100644 index 0000000000000000000000000000000000000000..58e88542ed7be2a282bb8d6e79b38aaf77d02cfe --- /dev/null +++ b/gala/source/tests/potential/potential/Composite.yml @@ -0,0 +1,28 @@ +type: composite +class: CompositePotential +components: + - class: PlummerPotential + name: halo + parameters: + b: 0.26 + m: 100000000000.0 + units: + angle: rad + angular speed: mas / yr + length: kpc + mass: solMass + speed: km / s + time: Myr + - class: MiyamotoNagaiPotential + name: disk + parameters: + a: 6.5 + b: 0.26 + m: 100000000000.0 + units: + angle: rad + angular speed: mas / yr + length: kpc + mass: solMass + speed: km / s + time: Myr diff --git a/gala/source/tests/potential/potential/EXP-Hernquist-basis.yml b/gala/source/tests/potential/potential/EXP-Hernquist-basis.yml new file mode 100644 index 0000000000000000000000000000000000000000..3012c7ac5e44471d366136629c03de004c4cfe6e --- /dev/null +++ b/gala/source/tests/potential/potential/EXP-Hernquist-basis.yml @@ -0,0 +1,12 @@ + +--- +id: sphereSL +parameters : + numr: 1024 + rmin: 0.0003 + rmax: 52.0 + Lmax: 4 + nmax: 10 + modelname: EXP-Hernquist.model + cachename: EXP-Hernquist.cache +... diff --git a/gala/source/tests/potential/potential/EXP-Hernquist-multi-coefs-snap-time-Gyr.hdf5 b/gala/source/tests/potential/potential/EXP-Hernquist-multi-coefs-snap-time-Gyr.hdf5 new file mode 100644 index 0000000000000000000000000000000000000000..4e68db4aeec633405e8ef15197c982632a534334 Binary files /dev/null and b/gala/source/tests/potential/potential/EXP-Hernquist-multi-coefs-snap-time-Gyr.hdf5 differ diff --git a/gala/source/tests/potential/potential/EXP-Hernquist-multi-coefs.hdf5 b/gala/source/tests/potential/potential/EXP-Hernquist-multi-coefs.hdf5 new file mode 100644 index 0000000000000000000000000000000000000000..8ada38d48f9b8d37871fdd3b292a757b07e3cba9 Binary files /dev/null and b/gala/source/tests/potential/potential/EXP-Hernquist-multi-coefs.hdf5 differ diff --git a/gala/source/tests/potential/potential/EXP-Hernquist-single-coefs.hdf5 b/gala/source/tests/potential/potential/EXP-Hernquist-single-coefs.hdf5 new file mode 100644 index 0000000000000000000000000000000000000000..996ebc8bb1dfe69ae9d3174b74dfb5320501a3f2 Binary files /dev/null and b/gala/source/tests/potential/potential/EXP-Hernquist-single-coefs.hdf5 differ diff --git a/gala/source/tests/potential/potential/EXP-Hernquist.cache b/gala/source/tests/potential/potential/EXP-Hernquist.cache new file mode 100644 index 0000000000000000000000000000000000000000..9e3bde7d801861be68b4ced2e4ed11683c240d09 --- /dev/null +++ b/gala/source/tests/potential/potential/EXP-Hernquist.cache @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21fed3fa1c5f024aba1cf6d3ace786a8d01f85b7a79f2c7f2e150b760a830d11 +size 428328 diff --git a/gala/source/tests/potential/potential/EXP-Hernquist.model b/gala/source/tests/potential/potential/EXP-Hernquist.model new file mode 100644 index 0000000000000000000000000000000000000000..6c621a7044d9d22fd6d36e4e24df93d82a1f68b6 --- /dev/null +++ b/gala/source/tests/potential/potential/EXP-Hernquist.model @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:766f00b6d061aa9f836f4ef170e438bdcc28c29618929b423513d6cb057b9a37 +size 82686 diff --git a/gala/source/tests/potential/potential/EXP-field-basis.yml b/gala/source/tests/potential/potential/EXP-field-basis.yml new file mode 100644 index 0000000000000000000000000000000000000000..82450b2be0c47503676a496db56e8932d54d88d1 --- /dev/null +++ b/gala/source/tests/potential/potential/EXP-field-basis.yml @@ -0,0 +1,7 @@ + +--- +# dummy field basis +id: field +parameters : + modelname: EXP-Hernquist.model +... diff --git a/gala/source/tests/potential/potential/HarmonicOscillator1D.yml b/gala/source/tests/potential/potential/HarmonicOscillator1D.yml new file mode 100644 index 0000000000000000000000000000000000000000..2102a29bdd14290bc91a07b9a783731e4d53e001 --- /dev/null +++ b/gala/source/tests/potential/potential/HarmonicOscillator1D.yml @@ -0,0 +1,3 @@ +class: HarmonicOscillatorPotential +parameters: + omega: 1.0 diff --git a/gala/source/tests/potential/potential/Plummer.yml b/gala/source/tests/potential/potential/Plummer.yml new file mode 100644 index 0000000000000000000000000000000000000000..351fbea904fc230886cb05d15edc88c499585647 --- /dev/null +++ b/gala/source/tests/potential/potential/Plummer.yml @@ -0,0 +1,12 @@ +class: PlummerPotential +parameters: + b: 0.26 + b_unit: kpc + m: 100000000000.0 +units: + angle: rad + angular speed: mas / yr + length: kpc + mass: solMass + speed: km / s + time: Myr diff --git a/gala/source/tests/potential/potential/agama_cylspline_test.fits b/gala/source/tests/potential/potential/agama_cylspline_test.fits new file mode 100644 index 0000000000000000000000000000000000000000..ea3e405fddd3f68c1f59b6b1604893915cd1fd9f --- /dev/null +++ b/gala/source/tests/potential/potential/agama_cylspline_test.fits @@ -0,0 +1,2569 @@ +SIMPLE = T / conforms to FITS standard BITPIX = 8 / array data type NAXIS = 0 / number of array dimensions EXTEND = T END XTENSION= 'BINTABLE' / binary table extension BITPIX = 8 / array data type NAXIS = 2 / number of array dimensions NAXIS1 = 56 / length of dimension 1 NAXIS2 = 16384 / length of dimension 2 PCOUNT = 0 / number of group parameters GCOUNT = 1 / number of groups TFIELDS = 3 / number of table fields TTYPE1 = 'xyz ' TFORM1 = '3D ' TUNIT1 = 'kpc ' TDIM1 = '(3) ' TTYPE2 = 'pot ' TFORM2 = 'D ' TUNIT2 = 'km2 s-2 ' TDIM2 = '(1) ' TTYPE3 = 'acc ' TFORM3 = '3D ' TUNIT3 = 'km2 kpc-1 s-2' TDIM3 = '(3) ' COMMENT --BEGIN-ASTROPY-SERIALIZED-COLUMNS-- COMMENT datatype: COMMENT - {name: xyz, unit: kpc, datatype: string, subtype: 'float64[3]'} COMMENT - {name: pot, unit: km2 / s2, datatype: string, subtype: 'float64[1]'} COMMENT - {name: acc, unit: km2 / (kpc s2), datatype: string, subtype: 'float6\ COMMENT 4[3]'} COMMENT meta: COMMENT __serialized_columns__: COMMENT acc: COMMENT __class__: astropy.units.quantity.Quantity COMMENT unit: !astropy.units.Unit {unit: km2 / (kpc s2)} COMMENT value: !astropy.table.SerializedColumn {name: acc} COMMENT pot: COMMENT __class__: astropy.units.quantity.Quantity COMMENT unit: !astropy.units.Unit {unit: km2 / s2} COMMENT value: !astropy.table.SerializedColumn {name: pot} COMMENT xyz: COMMENT __class__: astropy.units.quantity.Quantity COMMENT unit: !astropy.units.Unit {unit: kpc} COMMENT value: !astropy.table.SerializedColumn {name: xyz} COMMENT --END-ASTROPY-SERIALIZED-COLUMNS-- END ÀYÀ³ráu5”•€€@GãЩ;?òå˗.\¹ÀYÀ³rŒ¢Z¿á™­ž- €@Gâ­ßˆI›@å˗.\¹ÀYÀ³qž©«šT¿ñ1ÐF݀@GßF œëÖ@ X±bŋÀYÀ³p €/›¿ù–ÅÀó€@GٚÇг@å˗.\¹ÀYÀ³mײÂ[zÀ ” +ÁÞ>€@GÑ®ºò¶@Ÿ>|ùóçÀYÀ³kÞ®ÀBýYX >€@GDž™)þµ@X±bŋÀYÀ³gÏygÀ vs⌌T€@G»$ žîd@ ‰$H‘"ÀYÀ³cz®ZÀ ¢Ñ`ËÁ€@G¬Ú÷ú@"å˗.\¹ÀYÀ³^Ñ-Àãz‡Ý™€@G›Ð" ©@%B… +(PÀYÀ³Y†³ôøüÀðâ@®_׀@GˆìœÎ @'Ÿ>|ùóçÀYÀ³S¢‚ÄŽÀù4Œë˜€@Gsì^7Ÿl@)û÷ïß¿~ÀYÀ³M$‹Â"ˆÀû¯(À̀@G\ÚŠ*œµ@,X±bŋÀYÀ³F¢d+À÷ÐFŸ €@GCÁ9·aŸ@.µjÕ«V­ÀYÀ³>fƒœQÏÀíX2¯ˆ€@G(«A‘T¬@0‰$H‘"ÀYÀ³6)éI1ÀÛ°”ÕR€@G €©v*P@1·nÝ»víÀYÀ³-\tãšÀÂfÆ8•|€@Fìº øÌ@2å˗.\¹ÀYÀ³$w³”QÀ P†Çæv¥€@FËøÎ-۟@4(P¡B…ÀYÀ³bn› À!;ž²Ž€@F©nÁ絋@5B… +(PÀYÀ³ŠÃYÀ""JIiwù€@F…*hñè†@6páÇÀYÀ³®EH _À#[³÷ñŠ€@F_:¿ ìµ@7Ÿ>|ùóçÀYÀ²ù1¬WÀ#áš*m€]€@F7¯4ƒç@8͛6lÙ³ÀYÀ²í3Փ%6À$ºFú €@F—ž-âj@9û÷ïß¿~ÀYÀ²à·ŽbžÀ%V¶ž²€@Eä+»ˆþ@;*T©R¥JÀYÀ²ÓÀPÍPÀ&[ríw”X€@EžU”ºÌ@wòŽ€@EŠ«ÑUx„@=‡8páÀYÀ²žl>‚ bÀ'çžQ +Þ¬€@E\ƒîŒ²@>µjÕ«V­ÀYÀ²ªö/±À(¥z—‘鈀@E,,t…a,@?ãǏÀ*Xdøx €@DÉ‹ X@A @ÀYÀ²|‰›ÞHKÀ*œ9€ÞÞG€@D•îùDZC@A·nÝ»víÀYÀ²l†KpÑÀ+dV9¿Û€@DaÛ ƒø@BN:téÓÀYÀ²\0v×y¡À,¥;›ç#€@D,ãûOqÁ@Bå˗.\¹ÀYÀ²Kuܬ"~À,¡ Ö6 .€@C÷cñ@C|ùóçϟÀYÀ²:a)Ã*À-6Åy:π@CÀ£L@D(P¡B…ÀYÀ²(õÑU^~À-ƒQQã +€@C‰RTùê¬@D«V­ZµjÀYÀ²7FZš‹À.P‰ %Ž4€@CQtŽi‰@EB… +(PÀYÀ²(úŸÀ.Ô­»?f €@C£uÁ@EÙ³f͛6ÀYÀ±òÎZž#HÀ/S¹kí%€@Bà:€ùZ@FpáÇÀYÀ±à*ÒàŠÀ/˜Žò°€@BвŠÚ€a@G @ÀYÀ±ÍAÄ$\GÀ0<èq€@Blìâù @GŸ>|ùóçÀYÀ±ºŽ\^ÄÀ0UՄŠ,ý€@B2ҋPÝ^@H6lÙ³fÍÀYÀ±Š¬†n”À0‰ŸKܝ=€@AøqÙÅ¡@H͛6lÙ³ÀYÀ±“÷~ŒÀ0º¢HºT €@AœØ¡¯;@Idɓ&L™ÀYÀ±)% CøÀ0èçSbZ-€@AƒLmÞ5@Iû÷ïß¿~ÀYÀ±kFfï×À1x €”€@AH1և9@J“&L™2dÀYÀ±VчQÀ1=^œ€@A =ÍEÀ@K*T©R¥JÀYÀ±B^TÀ1cŠ Ñ+·€@@ÒDLÒe|@KÁƒ 0ÀYÀ±-ŸÖ/À1‡YÑ> H€@@—PþÉÍ-@LX±bŋÀYÀ±öú¹{£À1š…í‡Õ€@@\o5ù@Lïß¿~ýûÀYÀ± i›ÏÀ1Ç6àIÖ9€@@!©]ò‰Ú@M‡8páÀYÀ°îù I¡gÀ1ãy—’iF€@?Î4ölH@NäË×Ðò@OL™2dɒÀYÀ°¯$ã‹XÀ2*2ûÿÃo€@>pæ°ôT¿@OãǏ|ùóçÀYÀ¬Ë3ìùAdÀ1ÜÜǘjf€@3¥$š‰˜…@WêÕ«V­ZÀYÀ¬¡ ñ[À1É#RŠv†€@3OЃÍÌ@X6lÙ³fÍÀYÀ¬w-žÀ¶ÐÀ1ެvÙú€@2ûÊMËrx@X‚ @ÀYÀ¬Msa;UÀ1Ÿ‚„¢·[€@2©’ãâh@X͛6lÙ³ÀYÀ¬#ëëWÀ1‰¯…œñ€@2W§Ò@Y2dɓ&ÀYÀ«ú˜Á®ãŽÀ1s=5Îw€@2ˆ²ÒÕò@Ydɓ&L™ÀYÀ«Ñ{PŽùJÀ1\5j õ€@1ž³™Ã@Y°`Áƒ ÀYÀ«š”ïdR+À1D "`Ò0€@1k&¬œ@Yû÷ïß¿~ÀYÀ«æà àÀ1,‡dàHï€@1ß֟€)@ZG@aÝ:téÓ§ÀYÀŠÎ»”À+/Z†OÀ€@#Tk“@b 0`ÀYÀŠ® VDfÀ*÷z»€@#/V:MJ‹@b(Ñ£FÀYÀŠŽ^3ÑÈÕÀ*¿Û ~m€@"Þäóè@bN:téÓÀYÀŠnæçH€èÀ*ˆ~Þ2Ñb€@"öiB@bthÑ£FÀYÀŠO°Ô™TÀ*QiXŸ1o€@"B…ÒÛÏÜ@bš4hÑ£FÀYÀŠ0»¥ðÀ*u£³ €@!ö‹.\gÝ@bÀÀYÀŠ“ÐzÀ)äür‘Ѐ@!«ÿe_ÀfÀX›6lÙ³gÀ³¿C4Ãt̀€@Hž3ŒŠî?òå˗.\¹ÀX›6lÙ³gÀ³ŸîÌŠñ¿áÝHŸº×΀@Hœÿ'óŠ@å˗.\¹ÀX›6lÙ³gÀ³œñ¬ØÜ¿ñÚ°n tn€@H™bO.¬@ X±bŋÀX›6lÙ³gÀ³ŒLÖΙ¿úÁøK–þ€@H“^êœ@å˗.\¹ÀX›6lÙ³gÀ³¹þœà»”ÀÐU€e/—€@HŠö$^W@Ÿ>|ùóçÀX›6lÙ³gÀ³· э«À:œé²k€€@H€.c»p@X±bŋÀX›6lÙ³gÀ³³n—E«]À +žŸ†ã†ñ€@Hs ó߀ž@ ‰$H‘"ÀX›6lÙ³gÀ³¯-÷õbÀûÍab%€@Hc•¬@"å˗.\¹ÀX›6lÙ³gÀ³ªI*C +™À§N'PŸ€@HQÑ;§[@%B… +(PÀX›6lÙ³gÀ³€Á”‚?äÀÌ Ñûq6€@H=ÈŽ“N@'Ÿ>|ùóçÀX›6lÙ³gÀ³ž˜Ç­FŽÀë,§°“ÿ€@H'„òS|@)û÷ïß¿~ÀX›6lÙ³gÀ³—Ѐ hÖÀ'Hߓ€@HTK'@,X±bŋÀX›6lÙ³gÀ³j£Ü`¶Ào·ƒJ€@Gôv&‘1\@.µjÕ«V­ÀX›6lÙ³gÀ³ˆiAØUÀ!~ïŠ›€@Gו.@0‰$H‘"ÀX›6lÙ³gÀ³ΌŒÇ À$ÕŠ÷Æc€@G¹žàŒè@1·nÝ»víÀX›6lÙ³gÀ³vœàýÀ ý id€@G˜DHÒ@2å˗.\¹ÀX›6lÙ³gÀ³lÖž±HëÀ! ;ÄAñр@Gu•LÜ@4(P¡B…ÀX›6lÙ³gÀ³b~ŽÈފÀ!ýïÿhò€@GQŽ×÷ @5B… +(PÀX›6lÙ³gÀ³W—’Ug×À"íåï€Ú݀@G*€˜@o@6páÇÀX›6lÙ³gÀ³L$,ìÃÊÀ#ØíCÖ;€@G‚Ûdö@7Ÿ>|ùóçÀX›6lÙ³gÀ³@'{ÈM˜À$ŸØ[Ý+á€@FذWփ@8͛6lÙ³ÀX›6lÙ³gÀ³3€ÃýdÀ%Ÿ~Œô€@F­?ò‚Q@9û÷ïß¿~ÀX›6lÙ³gÀ³&ž‘V{!À&z·á~U€@F€@JQ#o@;*T©R¥JÀX›6lÙ³gÀ³Ÿ…ÆÝÀ'PcŒQK€@FQÅÓršJ@µjÕ«V­ÀX›6lÙ³gÀ²í©ÐfQ6À)®åfâ +º€@EŸ%î7íú@?ãǏí‹@A @ÀX›6lÙ³gÀ²Ÿ7üoÀ+×Êy[à3€@E¿j€3Ö@A·nÝ»víÀX›6lÙ³gÀ²­“ÒX,À,ƒáƒÞ+€@Dèãˆe,@BN:téÓÀX›6lÙ³gÀ²œŠÛÁ–À-)ͧoò€@D±™6Zë@Bå˗.\¹ÀX›6lÙ³gÀ²‹"réæ¿À-ɉ²ŸEc€@Dx‚ªž˜@C|ùóçϟÀX›6lÙ³gÀ²y]‰›ŸìÀ.cYsµ€@D?!”ZÝ@D(P¡B…ÀX›6lÙ³gÀ²g?ȬâzÀ.ökLv’€@D î¢9ù@D«V­ZµjÀX›6lÙ³gÀ²TÌ×Þ°¡À/ƒ”¶â€@CÊV HQÞ@EB… +(PÀX›6lÙ³gÀ²B\RÒEÀ0JØ­4€@C ã›}@EÙ³f͛6ÀX›6lÙ³gÀ².õ÷¿ŠÀ0E¹‹2ßù€@CSE$ú¶„@FpáÇÀX›6lÙ³gÀ²™CªéÀ0ƒÖZŸÞ€@C * Ç@G @ÀX›6lÙ³gÀ²õÖӃÜÀ0œ}5aYø€@BÚr›S&@GŸ>|ùóçÀX›6lÙ³gÀ±ô=:@À0ôÞý¡ƒÞ€@Bˆ.»êk@H6lÙ³fÍÀX›6lÙ³gÀ±ßèúL~šÀ1)Kza–€@B`[Ü%–e@H͛6lÙ³ÀX›6lÙ³gÀ±Ë†‡$JŒÀ1ZË܇„ñ€@B"ü<Ï­@Idɓ&L™ÀX›6lÙ³gÀ±¶ëQ€ƒAÀ1‰j*p>“€@Aåwt—"@Iû÷ïß¿~ÀX›6lÙ³gÀ±¢ºÒҋÀ1µ1/üÂ<€@A§Û,³Ú3@J“&L™2dÀX›6lÙ³gÀ±_Œ3À1Þ,nä*j€@Aj4–n_‚@K*T©R¥JÀX›6lÙ³gÀ±wæ­p À2hX1§€@A,dxË^@KÁƒ 0ÀX›6lÙ³gÀ±b‰Ž–ÐòÀ2'ðÑÇì€@@îúÌ/Ў@LX±bŋÀX›6lÙ³gÀ±MU‡œÀ2HÓü‰’g€@@±„Zh@Lïß¿~ýûÀX›6lÙ³gÀ±7YŠónEÀ2gU6ŒÑ€@@t)ÄÕç @M‡8páÀX›6lÙ³gÀ±!Œ²žÀ2‚á {ƒ¡€@@7Fšì@NŠ,4MHf@P=zõëׯÀX›6lÙ³gÀ°²ïß]À2é¡4ÁK€@>ã&íðä@P‰$H‘"ÀX›6lÙ³gÀ°œJ‡À2÷d³0€@=œfÖ°ïO@PÔ©R¥J•ÀX›6lÙ³gÀ°†"}£À3 »ý3ò€@=&ƚä@Q @ÀX›6lÙ³gÀ°oš(«ýÀ3  8Œ€@<²{ +]c@Qkׯ^œ{ÀX›6lÙ³gÀ°Y#¯ãu€À38Ù»F€@<>M:•n«@Q·nÝ»víÀX›6lÙ³gÀ°B—dހ‚À3áUE>—€@;ˎW3)B@R 0`ÀX›6lÙ³gÀ°,†ra÷À3«Ò‰M€@;Yݧ~T@RN:téÓÀX›6lÙ³gÀ°p@°I:À3§nƒŽ^€@:éCÙ(œs@Rš4hÑ£FÀX›6lÙ³gÀ¯ý³Yö+À3ãîdV€@:yÌQ5cè@Rå˗.\¹ÀX›6lÙ³gÀ¯Ð‡€J(ÓÀ3púåµÓ€@: W• ”@S1bŋ,ÀX›6lÙ³gÀ¯£aI,ì‹À3^ÌV€€@9že +xš@S|ùóçϟÀX›6lÙ³gÀ¯vD ò$ŽÀ3ºšRÄs€@92„η\@Sȑ"D‰ÀX›6lÙ³gÀ¯I3×¡ÆÀ3•ÕñOê€@8ÇåV Â@T(P¡B…ÀX›6lÙ³gÀ¯3NwÃÀ3þŸ’Z,€@8^Œ¥Ð»ê@T_¿~ýû÷ÀX›6lÙ³gÀ®ïF¢BbúÀ3Ë/¶,€@7ö€®qø@T«V­ZµjÀX›6lÙ³gÀ®Âp +ëÀ2õ³ãՋ¬€@7Äyœ¶–@TöíÛ·nÝÀX›6lÙ³gÀ®•ŽÂYþÀ2ê6§€@7*]â”cÛ@UB… +(PÀX›6lÙ³gÀ®i—¢7À2ÝḾ®á€@6ÆO鹿8@UŽ8páÃÀX›6lÙ³gÀ®<–µÀ2ÏSmZ€@6c”ý¹–@UÙ³f͛6ÀX›6lÙ³gÀ®8嚌À2À;—psۀ@6IcÁsŠ@V%J•*T©ÀX›6lÙ³gÀ­ä€Žó8À2°€7І€@5¢UU-^Œ@VpáÇÀX›6lÙ³gÀ­·ï’÷èÀ2žèË®Ÿ€@5CÂîQ¢É@VŒxñãǏÀX›6lÙ³gÀ­Œ•\tÀ2ŒÅéQÆ4€@4æ“@-Œ!@W @ÀX›6lÙ³gÀ­`MU$ÖÀ2y¹U¢ø€@4ŠÆíœŠŽ@WS§N:tÀX›6lÙ³gÀ­4Àm=»À2eÎV8€@40^1"XÊ@WŸ>|ùóçÀX›6lÙ³gÀ­ bíÂQôÀ2QšVÜM€@3×X⚠ž@WêÕ«V­ZÀX›6lÙ³gÀ¬Þ7ªs øÀ2;‹€zóڀ@3¶|Öº@X6lÙ³fÍÀX›6lÙ³gÀ¬³@TGÀ2%Jø1]€@3)v"¥Å @X‚ @ÀX›6lÙ³gÀ¬ˆ}ö~=œÀ2WÑ¢W€@2Ԗ€Ö=¬@X͛6lÙ³ÀX›6lÙ³gÀ¬]òÍ: À1öŒíÂna€@2†’!‰@Y2dɓ&ÀX›6lÙ³gÀ¬3 é*ÜÀ1ޅŽtò6€@2.ô²wè@Ydɓ&L™ÀX›6lÙ³gÀ¬ ‡Vq°{À1Å»3äÃä€@1Þ-w€P@Y°`Áƒ ÀX›6lÙ³gÀ«ß©ÁOê¯À1¬fäè`?€@1Ž¿h*à@@Yû÷ïß¿~ÀX›6lÙ³gÀ«¶›)HÀ1’‘ðßZ³€@1@š‡˜:H@ZGÿ€@0^Pjy@[*T©R¥JÀX›6lÙ³gÀ«öœ‡<À1&ä=á>€@0w“âcP@[uëׯ^œÀX›6lÙ³gÀªé’ž\£áÀ1 +ÞTœf€@/›Ì<3ª@[Áƒ 0ÀX›6lÙ³gÀªÁq† +‰8À0îáƒ#„#€@/0ø Ä»@\ 4hÑ£ÀX›6lÙ³gÀª™“1&œrÀ0ÒoEN„÷€@.…à k@\X±bŋÀX›6lÙ³gÀªqø]¿À0µºø_(€@-ýsÍh T@\€H‘"DˆÀX›6lÙ³gÀªJ¡ Õ}À0˜Ë+ †ç€@-xBw¿‚@\ïß¿~ýûÀX›6lÙ³gÀª#}3Q™À0{Š)&m€@,õz[CB@];víÛ·nÀX›6lÙ³gÀ©üÂk ÖùÀ0^QüZÝ}€@,u2¶W@]‡8páÀX›6lÙ³gÀ©Ö:Óãé3À0@Ôn˜úЀ@+÷|ŽRå@]Ò¥J•*TÀX›6lÙ³gÀ©¯ù"°òÀ0#3 F©ï€@+{F<Çݖ@^ٙV.À/“W–émˀ@*˜ LÇ@_ ÀX›6lÙ³gÀ©±Áèï±À/W[ž7ù€@)¢Æ}çŽ@_L™2dɒÀX›6lÙ³gÀšôÐÑÓP†À/HÁ0ž€@)2É+(Ç@_˜0`ÁƒÀX›6lÙ³gÀšÐ6ßW-À.ß'!HP€@(ÉÍrEP@_ãǏ£€@(W Ӆè˜@`¯^œzöÀX›6lÙ³gÀš‡Ø ØÀ.f×¢¿ÜP€@'ì$–ùd@`=zõëׯÀX›6lÙ³gÀšdHý€À.*ž°lú+€@'„/ Ôö@`cF4hÀX›6lÙ³gÀš@•oåƒ`À-îšò°E€@'ºÞòi@`‰$H‘"ÀX›6lÙ³gÀš^zæY…À-²®ú4ðj€@&¹7ô«üo@`®Ý»víÛÀX›6lÙ³gÀ§únL“t—À-vÑ€0€@&V²ßÆ@`Ô©R¥J•ÀX›6lÙ³gÀ§×ÄÀV^À-;ïôr€@%õ㊫jQ@`útéÓ§NÀX›6lÙ³gÀ§µaªÏ(À,ÿ€\Èħ€@%—û€h!@a @ÀX›6lÙ³gÀ§“DÚ1eÞÀ,Ä‘î:¿€@%9í”áx+@aF 0`ÁÀX›6lÙ³gÀ§qnÁÀ,ˆâŽè ¹€@$Þ ÷úÎp@akׯ^œ{ÀX›6lÙ³gÀ§OÝ"x<ƒÀ,M㠓8 €@$…Ù$è@a‘£F4ÀX›6lÙ³gÀ§.‘º»89À,zÜ ú€@$-;óeš@a·nÝ»víÀX›6lÙ³gÀ§ ‹—GKvÀ+ؙ gò1€@#×MòŒ×@aÝ:téÓ§ÀX›6lÙ³gÀŠìÊk0(À+žVŽ$:…€@#‚µ4ÌF@b 0`ÀX›6lÙ³gÀŠÌMå¶&À+d["Ϻ‚€@#/šCªZ@b(Ñ£FÀX›6lÙ³gÀЬ¯/dÀ+*©Òj6€@"ÞYoG€@bN:téÓÀX›6lÙ³gÀЌ!oÎΑÀ*ñF•ÌH€@"Ž˜„›Åd@bthÑ£FÀX›6lÙ³gÀŠlpÉÁš À*ž2ÀæTҀ@"@^Îè;÷@bš4hÑ£FÀX›6lÙ³gÀŠM\ ýÀ*ráJ®€@!ó€pËXö@bÀÀX›6lÙ³gÀŠ-ØÂØrÊÀ*G `\΀@!šaøã7LÀX6lÙ³fÎÀŽ ý_Ž€€@Ia*O®?òå˗.\¹ÀX6lÙ³fÎÀŽ ¥ / ¿â±Gñº\ €@I_âŽ,ÃÚ@å˗.\¹ÀX6lÙ³fÎÀŽ œ2#ȹ¿ò®{wD¥€@I\ íz£Ä@ X±bŋÀX6lÙ³fÎÀŽ +ã'@„¿ûþŒZ©‰~€@IUšXJ…î@å˗.\¹ÀX6lÙ³fÎÀŽzR sÀ£P™5$*€@ILºýêƒ@Ÿ>|ùóçÀX6lÙ³fÎÀŽbˆÉ#ÉÀAµÆûˀ@IAHN‹œ@X±bŋÀX6lÙ³fÎÀŽœ¬Ö ”À Ù3ý®€@I3UõÊ9@ ‰$H‘"ÀX6lÙ³fÎÀ³ý)ÜqHŸÀ49ÜÀ /€@I"êÔ¶–@"å˗.\¹ÀX6lÙ³fÎÀ³ø g®ŠYÀwÆ»Ÿ}€@IøDuŽ@%B… +(PÀX6lÙ³fÎÀ³òBÏcœÍÀއ)[À@Hú˒Y:Î@'Ÿ>|ùóçÀX6lÙ³fÎÀ³ëÑÃîš5ÀëòFÜøŠ€@Hã*í z?@)û÷ïß¿~ÀX6lÙ³fÎÀ³äº#ØmÿÀŒ;*0/€@HÉ8` mä@,X±bŋÀX6lÙ³fÎÀ³ÜýúUÍ*ÀFPiŽÞë€@H­CœaW@.µjÕ«V­ÀX6lÙ³fÎÀ³ÔŸ}šþÍÀhâ+º€@HŽâéŠ>@0‰$H‘"ÀX6lÙ³fÎÀ³Ë¡ föêÀ¡Ÿ®Êk€@HmõmwXÏ@1·nÝ»víÀX6lÙ³fÎÀ³Â0¢hÀ É)ºòþ€@HK?çÔsú@2å˗.\¹ÀX6lÙ³fÎÀ³·Î“ÿŒÝÀ!ÌܒæLñ€@H&®®@4(P¡B…ÀX6lÙ³fÎÀ³­³~òÀ"Ë®éýeC€@GÿÇcô¹@5B… +(PÀX6lÙ³fÎÀ³¡œ}ršÀ#ÅiÒÔ€@G×M0éR@6páÇÀX6lÙ³fÎÀ³•§L¬3À$¹Ú)Op€@G¬¡".!@7Ÿ>|ùóçÀX6lÙ³fÎÀ³‰"ІcðÀ%šÏè! ž€@G€^<uÊ@8͛6lÙ³ÀX6lÙ³fÎÀ³|%^QÀ&’Ž¿€W€@GRhC„@9û÷ïß¿~ÀX6lÙ³fÎÀ³n{fÓZâÀ'u¡c}Hb€@G"Ò<‹’?@;*T©R¥JÀX6lÙ³fÎÀ³`_ hqfÀ(S0œm!€@Fñ¯{g`Ñ@µjÕ«V­ÀX6lÙ³fÎÀ³37îÿÀ*ÆÿvZ–"€@FU¿SÆ@?ãǏ<‘€@E:«2…Ÿ@Bå˗.\¹ÀX6lÙ³fÎÀ²Ìm¶»äÀ/‚ ?€@DþtP„Û3@C|ùóçϟÀX6lÙ³fÎÀ²¹ïb$A5À/žþ¬ç*~€@DÂȍ=ô@D(P¡B…ÀX6lÙ³fÎÀ²§ýg\ÌÀ0ô'ô€@D„õâ[ïÁ@D«V­ZµjÀX6lÙ³fÎÀ²“åhžvÀ0c##ŒZU€@DG9¬éá@EB… +(PÀX6lÙ³fÎÀ²€a€2_œÀ0šßÍŸÔ€@Dë‰Qû@EÙ³f͛6ÀX6lÙ³fÎÀ²lŽK!<À0éŒÚd`€@CÊím,¬@FpáÇÀX6lÙ³fÎÀ²Xo +Tœ)À1(3`aVC€@CŠä’ðA@G @ÀX6lÙ³fÎÀ²D™_yÀ1cyæè&Ā@CKMTGE@GŸ>|ùóçÀX6lÙ³fÎÀ²/\ûñ°À1›˜õ9'Š€@C i—EzÙ@H6lÙ³fÍÀX6lÙ³fÎÀ²qq‡ —À1КYoá€@BËID.I@H͛6lÙ³ÀX6lÙ³fÎÀ²IŽ6‘À2‡Œ¥Dz€@BŠûÃêœ@Idɓ&L™ÀX6lÙ³fÎÀ±ïçžï>šÀ21mCúç€@BJüTþ/@Iû÷ïß¿~ÀX6lÙ³fÎÀ±ÚPÑ3*À2]VÌ1UM€@B +M—Œù@J“&L™2dÀX6lÙ³fÎÀ±Ä‡@.Ù{À2†Q1ë²€@Aɖ$@K*T©R¥JÀX6lÙ³fÎÀ±®OEüÀ2¬iûÒԉ€@A‰$=.@KÁƒ 0ÀX6lÙ³fÎÀ±˜l þDCÀ2ϯJM¡Ñ€@AHɜDW@LX±bŋÀX6lÙ³fÎÀ±‚ Ä= ÑÀ2ð/Ǭ/L€@A“e ¶µ@Lïß¿~ýûÀX6lÙ³fÎÀ±k°ŽKŠ5À3 ú˜ã"€@@ȍWŽ&@M‡8páÀX6lÙ³fÎÀ±UN»ãÀ3)NÐsZ€@@ˆÁíÁt—@NnÑÐY*À3A­Øˆ|€@@IBc'@P‰$H‘"ÀX6lÙ³fÎÀ°ËšÝŠkjÀ3—ê­Žœ€@>" ó2Ÿr@PÔ©R¥J•ÀX6lÙ³fÎÀ°Žp3k# À3¢PÛÈ8â€@=§ÆcÉT¿@Q @ÀX6lÙ³fÎÀ°:„(?À3ª–zá]³€@=.…Ì™­E@Qkׯ^œ{ÀX6lÙ³fÎÀ°…üHëB“À3°Ì°v3€@<¶UT6â@Q·nÝ»víÀX6lÙ³fÎÀ°n·æ„…ªÀ3µšîŸK€@|ùóçÀX6lÙ³fÎÀ­H/IG–{À2Èl}]ã€@4 yWÚN@WêÕ«V­ZÀX6lÙ³fÎÀ­ìQ@ÿDÀ2±/^€@3¯ÑMÖ+@X6lÙ³fÍÀX6lÙ³fÎÀ¬ïአµÀ2˜Û<«Û€@3V‰`ü’è@X‚ @ÀX6lÙ³fÎÀ¬ÄŸH³¯À2€Ìq§€@2ÿwƒNˆ¶@X͛6lÙ³ÀX6lÙ³fÎÀ¬˜{"Ž«èÀ2f‹¬€ Y€@2©ØE>U@Y2dɓ&ÀX6lÙ³fÎÀ¬m"ŒãßýÀ2LwU…§¡€@2U©j·@Ydɓ&L™ÀX6lÙ³fÎÀ¬B>‡À21ÒæÑ‹€@2èrHä@Y°`Áƒ ÀX6lÙ³fÎÀ¬-ôúÀ2š*8OY€@1±’Ÿ$ @Yû÷ïß¿~ÀX6lÙ³fÎÀ«ì“äJ±À1û“ðô €@1a€÷õ[@ZG ±€@)·ŠÛ‹@_L™2dɒÀX6lÙ³fÎÀ©¹EG`À/±HËÛ8€@)E*>š^K@_˜0`ÁƒÀX6lÙ³fÎÀš÷DKV€À/rOy\‘€@(ÔÝÝ •@_ãǏڀ@%üõWq¶è@`útéÓ§NÀX6lÙ³fÎÀ§×fsLGNÀ-}™·÷™Ù€@%œÙ²ÂEì@a @ÀX6lÙ³fÎÀ§Ž¶"öL}À-?ÄìK$€@%>›Hßç@aF 0`ÁÀX6lÙ³fÎÀ§’N¶]‚ˆÀ--Yÿ¿€@$â0€~‰@akׯ^œ{ÀX6lÙ³fÎÀ§p/ârb²À,Äלoߌ€@$‡‘b%@a‘£F4ÀX6lÙ³fÎÀ§NYVß ðÀ,‡È«Þ@€@$.µ„@a·nÝ»víÀX6lÙ³fÎÀ§,ÊŸXüšÀ,K¬.ɶ€@#ג«úæ&@aÝ:téÓ§ÀX6lÙ³fÎÀ§ ƒŸïhÀ,‹Yl­Í€@#‚!Ý (Ÿ@b 0`ÀX6lÙ³fÎÀŠêƒúV„@À+Òe {.5€@#.Z??8@b(Ñ£FÀX6lÙ³fÎÀŠÉË/¡¥À+–”ÖŠŒÐ€@"Ü3‹¥ë/@bN:téÓÀX6lÙ³fÎÀŠ©X”NM°À+[ó" €@"‹¥™ª­@bthÑ£FÀX6lÙ³fÎÀЉ,"ú‰À+þI‹Z€@"<š]l£}@bš4hÑ£FÀX6lÙ³fÎÀŠiEM0+À*å>Vڀ@!ï3éGMÿ@bÀÀX6lÙ³fÎÀŠI£¢Û‡éÀ*ªÞcŽ€@!£@n ùÀWÑ£F4ÀŽ_+Ë)ª!€€@J-<à9„?òå˗.\¹ÀWÑ£F4ÀŽ^ÏPÚ{–¿ã’ŽáÝì]€@J+àlxÿØ@å˗.\¹ÀWÑ£F4ÀŽ]¹þ};à¿ó‰7Y8“€@J'ËÄó€Æ@ X±bŋÀWÑ£F4ÀŽ[ì)ª©¿ýOÂGÐE€@J!üœ@å˗.\¹ÀWÑ£F4ÀŽYf`¶©ÀƒzgŽŸ{€@Jƒ§ð:…@Ÿ>|ùóçÀWÑ£F4ÀŽV)j;ÜÀYcëg€@J XŽî%Å@X±bŋÀWÑ£F4ÀŽR6DhvÐÀ '<: ì€@Iü…ðñ @ ‰$H‘"ÀWÑ£F4ÀŽMŽ$*Û¶Àö?ž[ˆ@€@IëXHš€@"å˗.\¹ÀWÑ£F4ÀŽH2t%äÀS»¢^/€@I× ˜‰1­@%B… +(PÀWÑ£F4ÀŽB$ÓK¥À«aÿ©¯ã€@IÀrÄ€~@'Ÿ>|ùóçÀWÑ£F4ÀŽ;g‚6öÀüˆSéú¬€@I§ZÚ8@)û÷ïß¿~ÀWÑ£F4ÀŽ3û;‹ÍÀF‰4MEh€@I‹ÌÍ2.@,X±bŋÀWÑ£F4ÀŽ+ã{+<ÀˆÅ(:€@ImÖ*Ù|?@.µjÕ«V­ÀWÑ£F4ÀŽ#"6Â5¿À£4ÄЀ@IM†åÄSZ@0‰$H‘"ÀWÑ£F4À޹ü,ËAÀ yÈ'Èu€@I*íÈ·_ƒ@1·nÝ»víÀWÑ£F4ÀŽ­ƒï©$À!€Ï»á€@I; +dœ@2å˗.\¹ÀWÑ£F4ÀŽÿ®¥kÙÀ"œ9sÚ=€@Hß x]…@4(P¡B…ÀWÑ£F4À³ù³‚ÇàÀ#¥³ßÔ1²€@H¶~&F–@5B… +(PÀWÑ£F4À³íÌ*aÑ0À$©µÎO ڀ@HŠúøÒ$­@6páÇÀWÑ£F4À³áLðŒæÝÀ%š IwՀ@H]ö0¡n@7Ÿ>|ùóçÀWÑ£F4À³Ô9?óï¥À& {Nè1{€@H/ö\Õ@8͛6lÙ³ÀWÑ£F4À³Æ”ž‡ÚüÀ'’Þ··IU€@GþkE{@9û÷ïß¿~ÀWÑ£F4À³žb¬êt À( c|ðª€@GÌ¥¬‘‡@;*T©R¥JÀWÑ£F4À³©§#iÒÀ)dÕ§ÉU_€@G˜.kð•@µjÕ«V­ÀWÑ£F4À³zaOzÑpÀ+îɔsöo€@Fó>AUŒ@?ãǏ|ùóçÀWÑ£F4À²l íi±À2Js¢$4€@C| T‘@H6lÙ³fÍÀWÑ£F4À²VO‹‹HyÀ2øÓˆ—–€@C9OҊ¿„@H͛6lÙ³ÀWÑ£F4À²@WÝíò›À2²A Žëé€@Bõê4(§@Idɓ&L™ÀWÑ£F4À²*&°16ÄÀ2áYR‰†€@B²nòÓbt@Iû÷ïß¿~ÀWÑ£F4À²¿Ÿ\\PÀ3 N•Ê:ž€@Bnì'D¥@J“&L™2dÀWÑ£F4À±ý&³ñ?À36/Úüçø€@B+p„$Õn@K*T©R¥JÀWÑ£F4À±æ_+¶/À3\ ñ»ØŒ€@Aè +)ÐýÂ@KÁƒ 0ÀWÑ£F4À±Ïl«Î¡À3~ò|íòπ@A€Æ`ªŠ@LX±bŋÀWÑ£F4À±žR«[¢À3žó©u`€@Aa±é™šÝ@Lïß¿~ýûÀWÑ£F4À±¡‹’IxÀ3Œ Žâ€@AØîYYŽ@M‡8páÀWÑ£F4À±‰µšbU–À3ֈé¿g/€@@ÜGR_@NÒÙ ñ€@>©Þÿv…@PÔ©R¥J•ÀWÑ£F4À°ãŠ€HòÀ4GÕë ]h€@>*ÆKÝX«@Q @ÀWÑ£F4À°Ë’Pi+ŽÀ4NŠîjŸ»€@=¬ÌZ< +S@Qkׯ^œ{ÀWÑ£F4À°³“2Ðß§À4SW†¡u€@=/þy»Áp@Q·nÝ»víÀWÑ£F4À°›Ã© >À4UúkŒá€@<Žhÿ ~@R 0`ÀWÑ£F4À°ƒŠew -À4V ÓÿZå€@<:Ki5¹@RN:téÓÀWÑ£F4À°k…e>ºˆÀ4U]òþ"%€@;ÁÔúŠ@Rš4hÑ£FÀWÑ£F4À°S‚ú¬Š°À4RCjýQ +€@;Ih)IB @Rå˗.\¹ÀWÑ£F4À°;…HJGÎÀ4Mc ›‡ž€@:Óþ•‚@S1bŋ,ÀWÑ£F4À°#Ž[žâÎÀ4FÎg þ€@:^:1Æ œ@S|ùóçϟÀWÑ£F4À°  -ô2À4>–॑ã€@9êÆÒü–l@Sȑ"D‰ÀWÑ£F4À¯çyG6H—À44͒ž#ž€@9xÉ,_j×@T(P¡B…ÀWÑ£F4À¯·Ëy€À4)ƒT‚Êy€@9FÉà€<@T_¿~ýû÷ÀWÑ£F4À¯ˆ9OuàÀ4ȳg9‡€@8™Dµ+@T«V­ZµjÀWÑ£F4À¯XÇ9 +«À4­ïÀ@8+Æx‹=x@TöíÛ·nÝÀWÑ£F4À¯)x5؎À3ÿBõéS>€@7¿Ð0C @UB… +(PÀWÑ£F4À®úNž¢óÀ3î—bŒ€@7Udˆ€±3@UŽ8páÃÀWÑ£F4À®ËN9ŽÓ‹À3ܺvµ§ €@6ì…ɲ€—@UÙ³f͛6ÀWÑ£F4À®œyF”â+À3É»“„f€@6…5«Ëæs@V%J•*T©ÀWÑ£F4À®mÒ|~3À3µ§å8Îր@6u]?e@VpáÇÀWÑ£F4À®?\V žÀ3 ŽÿM—€@5»E‹¯TT@VŒxñãǏÀWÑ£F4À®-2œ4À3Š~?MJ€@5XŠgçMl@W @ÀWÑ£F4À­ã ;={À3sƒ“œY€@4÷—¯ÃÈ*@WS§N:tÀWÑ£F4À­µ4šj/À3[ª¢£” €@4˜³_§p@WŸ>|ùóçÀWÑ£F4À­‡—Fq@À3CŽÁx)€@4:([衇@WêÕ«V­ZÀWÑ£F4À­Z5y£-À3)”1ÈŒ€@3ÝÅ1÷µö@X6lÙ³fÍÀWÑ£F4À­-àýYbÀ3n;Ž€@3‚íc¹ý@X‚ @ÀWÑ£F4À­)6¬ÌaÀ2ôœ +“:4€@3)žÊè‡2@X͛6lÙ³ÀWÑ£F4À¬Ó‚©Q äÀ2Ù(œ‹|€@2ÑÖòŽD±@Y2dɓ&ÀWÑ£F4À¬§©¬®‘À2œKÌ"Š€@2{“›+i@Ydɓ&L™ÀWÑ£F4À¬zûZû)À2 †MÒËò€@2&ÐGD )@Y°`Áƒ ÀWÑ£F4À¬O™¬¯fÀ2ƒm'ì}€@1Ӌ2.Ç×@Yû÷ïß¿~ÀWÑ£F4À¬#„ð‚N’À2eۘf4ǀ@1Àcj­w@ZG@[*T©R¥JÀWÑ£F4À«wë`ò nÀ1ë—Ö±… €@0I†‹5 @[uëׯ^œÀWÑ£F4À«M»’RíÀ1Ì21fPì€@/üÚª}±@[Áƒ 0ÀWÑ£F4À«#ÖAsj_À1¬‡`i€@/jY€€0Ç@\ 4hÑ£ÀWÑ£F4Àªú<ëùœÀ1ŒžÉé§€@.ڒÖ6I@\X±bŋÀWÑ£F4ÀªÐír9!<À1l„`>\€@.M|çø€W@\€H‘"DˆÀWÑ£F4Àª§êösW À1L0ZÔ«€@-ÃWB¶@\ïß¿~ýûÀWÑ£F4Àª4ÿNgÀ1+·Éõ^q€@-;=|Ñå@];víÛ·nÀWÑ£F4ÀªVËæKžáÀ1  +cڐ€@,¶Žóó+@]‡8páÀWÑ£F4Àª.¯ø\»¯À0êc +Š©€@,3M®6 @]Ò¥J•*TÀWÑ£F4Àªás/ŠÀ0ɒt²Or€@+³ãBÿ=@^2Ó}@_˜0`ÁƒÀWÑ£F4À©kvmßLÀ0ÜîTg€@(ä8$ÿWÔ@_ãǏt›À/„#§€@(ÎÏàÖ¢@`=zõëׯÀWÑ£F4Àš®JýôBçÀ/B†š‚M€@'›WU›æå@`cF4hÀWÑ£F4Àš‰…ÜŽfqÀ/—‚4—€@'1ûd£aÑ@`‰$H‘"ÀWÑ£F4Àše ð‚ÿÀ.¿Êàfð€@&ʱ ߎ@`®Ý»víÛÀWÑ£F4Àš@âÿ2À.~ž’ªˆü€@&enÁšÚ@`Ô©R¥J•ÀWÑ£F4ÀšÆà©ÝÀ.=àgAl΀@&*“œÝè@`útéÓ§NÀWÑ£F4À§ùs@@À-ýGÁ°f€@% Úû“Å@a @ÀWÑ£F4À§Ö-]p.À-Œó±øœ €@%Avt¹ú@aF 0`ÁÀWÑ£F4À§³3‰«åÀ-|èý0²Õ€@$ãó•ë|#@akׯ^œ{ÀWÑ£F4À§…,:;À-=,æC€@$ˆIËW’@a‘£F4ÀWÑ£F4À§n!æÓ QÀ,ýÁ;Œ¯8€@$.m¹ÊÓ¹@a·nÝ»víÀWÑ£F4À§L V À,Ÿ¬IÑ^݀@#ÖXw¯Œ@aÝ:téÓ§ÀWÑ£F4À§*;]€ÑÀ,ðî”Go€@#€W߂@b 0`ÀWÑ£F4À§¶¯È À,A’“+Ó"€@#+\…2ªÊ@b(Ñ£FÀWÑ£F4ÀŠç{ŸšíŽÀ,”d8žÕ€@"ØdJ t˜@bN:téÓÀWÑ£F4ÀŠÆ‰Ë{µTÀ+ÅùTHlŀ@"‡Úç@bthÑ£FÀWÑ£F4ÀŠ¥à_µŒÁÀ+ˆÄaøÖ€@"7TmÊ]ô@bš4hÑ£FÀWÑ£F4ÀŠ…zSMÀ+K÷H}uo€@!é,ÿP(@bÀÀWÑ£F4ÀŠee4ûÀÀ+•%æe€@!œ¯ÿ+jÀWlÙ³f͛À޲ì +ñ܀€@Kþ¹ÝÞê?òå˗.\¹ÀWlÙ³f͛À޲‹(|ߜ¿ä‚-Î(΀@K‹éKÝa@å˗.\¹ÀWlÙ³f͛Àޱh“Žwà¿ô~蒓÷˜€@Jý4=Ì»@ X±bŋÀWlÙ³f͛Àޝ„­;Ì¿þ¶4Š+{T€@JõúžP~@å˗.\¹ÀWlÙ³f͛ÀެàðëÀq߅·û2€@Jëá}2ž@Ÿ>|ùóçÀWlÙ³f͛ÀŽ©{†À ‚*`æ ‘€@JÞîËoäÇ@X±bŋÀWlÙ³f͛ÀŽ¥XAŸäÀŠg$™]€@JÏ)÷ċ¢@ ‰$H‘"ÀWlÙ³f͛ÀŽ ws šïÀĄ— žD€@JŒšâÐ!l@"å˗.\¹ÀWlÙ³f͛ÀŽšÚ«ÉuBÀ>F$Ô]À€@J§K<¬C@%B… +(PÀWlÙ³f͛ÀŽ”ƒ«û`ñÀ±ºù±Më€@JFKj@'Ÿ>|ùóçÀWlÙ³f͛ÀŽtk0]+À+EZyv€@Jt—Àô¿@)û÷ïß¿~ÀWlÙ³f͛ÀŽ…¯In¡À‚å8‘ŠÛ€@JWMâ“h@,X±bŋÀWlÙ³f͛ÀŽ}6 ŽÍLÀß=Šõåj€@J7wFü9@.µjÕ«V­ÀWlÙ³f͛ÀŽt ãnúÀ Gúþ£×€@J#ØúàÒ@0‰$H‘"ÀWlÙ³f͛ÀŽj3XÛérÀ!>ÒC»í€@Iðd“`/@1·nÝ»víÀWlÙ³f͛ÀŽ_¯\Ž,‹À"]ÛËA±¢€@IÉK^ª³@2å˗.\¹ÀWlÙ³f͛ÀŽTƒàçÌÀ#x5|úB€@IŸëà:@4(P¡B…ÀWlÙ³f͛ÀŽH±œ‡ KÀ$Œê¶#8€@ItWdÎoÛ@5B… +(PÀWlÙ³f͛ÀŽ<>‚$âÀ%›œŠœEˀ@IF€¢Ú¬…@6páÇÀWlÙ³f͛ÀŽ/-C’°À&€tëßÖä€@IçØŒ Y@7Ÿ>|ùóçÀWlÙ³f͛ÀŽ!ŠÐBõÀ'ŠÛª\׀@Hå6›áí@8͛6lÙ³ÀWlÙ³f͛ÀŽ?"‡y±À(¢Á—¥›j€@H±ŠûÚvÀ@9û÷ïß¿~ÀWlÙ³f͛ÀŽiîsqÌÀ)—úÿúŽö€@H|Olñz@;*T©R¥JÀWlÙ³f͛À³õëR])À*†`Æ.§i€@HEF³ UÎ@µjÕ«V­ÀWlÙ³f͛À³Ãª9 éÀ-'YYçp1€@G–ì&iÁ@?ãǏÇEÀ-ùCúfRŽ€@GZØ¥!í@@‰$H‘"ÀWlÙ³f͛À³ D¥ê«À.Ãڵ͚€@Gâžÿ;@A @ÀWlÙ³f͛À³ß‹_W˜À/‡Êú€@Fܘ1×þ@A·nÝ»víÀWlÙ³f͛À³{ Y`BÀ0!nŸý€&€@Fœ>TŠè#@BN:téÓÀWlÙ³f͛À³gÆq'ÜÀ0{þqV׀@FZëªÖ±î@Bå˗.\¹ÀWlÙ³f͛À³T6»qÀ0Ò˜ar¶€@F¶IÔWƒ@C|ùóçϟÀWlÙ³f͛À³@ ÝzÀ1$Ö̱·>€@EÕŽJñæ@D(P¡B…ÀWlÙ³f͛À³+U{ûšÀ1sä‘jŀ@E‘ú¡Ù6@D«V­ZµjÀWlÙ³f͛À³Óle5hÀ1¿CzÐó€@EMžî„â:@EB… +(PÀWlÙ³f͛À³²§ç +À2ù©Ÿò€@Eµ—BÖ@EÙ³f͛6ÀWlÙ³f͛À²ì?V ëÀ2K­žª€@DÃR®Žg©@FpáÇÀWlÙ³f͛À²Ö}»"r À2‹‹uöSð€@D}‰Œ‚ @G @ÀWlÙ³f͛À²Àr$`ÖÀ2Èz5 Ç€@D7m¶b @GŸ>|ùóçÀWlÙ³f͛À²ª OËÐÀ3æKõ§š€@Cñün]|@H6lÙ³fÍÀWlÙ³f͛À²“,ÉlÑÀ37Ü3­ß€@Cª…Sœ @H͛6lÙ³ÀWlÙ³f͛À²|Œ!`ZŒÀ3jifù69€@CcÛáL(@Idɓ&L™ÀWlÙ³f͛À²e±]jo¥À3™œL³º€@C%)v8@Iû÷ïß¿~ÀWlÙ³f͛À²Np͹ö|À3ń"°ÿœ€@BÖq BÈ@J“&L™2dÀWlÙ³f͛À²6þL«õ†À3î0é3 ʀ@BÎÄ¡5æ@K*T©R¥JÀWlÙ³f͛À²]¡NXôÀ4³Oˆ€@BIL珝±@KÁƒ 0ÀWlÙ³f͛À²’~2•À46žV'a€@Bùcí\ø@LX±bŋÀWlÙ³f͛À±ï ‚Ö8WÀ4U~ª(!²€@AŒá‚äî±@Lïß¿~ýûÀWlÙ³f͛À±×‹6á¢÷À4q댯T(€@AwèÑ·‡@M‡8páÀWlÙ³f͛À±¿V ψ`À4‹v†PE’€@A1––›r@N¯ºrdž@Q @ÀWlÙ³f͛À°ú²\ôøWÀ4ù°J€@>,Õ,I@Qkׯ^œ{ÀWlÙ³f͛À°áêó%ëÀ4üÜjÄš€@=«9õŠ~ @Q·nÝ»víÀWlÙ³f͛À°É!;©ðÀ4üü(‹„€@=*õ~Ž<@R 0`ÀWlÙ³f͛À°°W©‰OÀ4ûØ#]ÆÝ€@<¬ –@RN:téÓÀWlÙ³f͛À°—›Â6‘À4øœ0—-º€@<.Fe@Rš4hÑ£FÀWlÙ³f͛À°~ÎXr66À4󟁀^€@;²œÝû¯›@Rå˗.\¹ÀWlÙ³f͛À°f·áŸÀ4ìï Ô\€@;8rÖú@S1bŋ,ÀWlÙ³f͛À°M`ÛpŽŠÀ4äaˆ°g€@:¿q>Œ@S|ùóçϟÀWlÙ³f͛À°4¹¿‡€èÀ4Ú(gÞÄë€@:G°ŠŸßÿ@Sȑ"D‰ÀWlÙ³f͛À°ªL–{À4ÎUÏ<'w€@9ÑÔÀÜ4x@T(P¡B…ÀWlÙ³f͛À°”tÎÝ9À4Àû”èy€@9]ùlþŽ@T_¿~ýû÷ÀWlÙ³f͛À¯Ö3Æ~ÉÆÀ4²+75Ï=€@8êéËσß@T«V­ZµjÀWlÙ³f͛À¯¥cJ­š0À4¡õàÉ;y€@8yâý(Œà@TöíÛ·nÝÀWlÙ³f͛À¯tº­êbÀ4l]5ýM€@8 +€Äæ£@UB… +(PÀWlÙ³f͛À¯D<ÿ ”?À4}Ÿ¯1€@7œÂ4aŠ™@UŽ8páÃÀWlÙ³f͛À¯í)šÍÀ4iž#`hg€@70¬€Uó‘@UÙ³f͛6ÀWlÙ³f͛À®ãÍïÎÿÀ4Ty!碀@6Æ?ǜ†·@V%J•*T©ÀWlÙ³f͛À®³áó ãöÀ4>?Xò.ڀ@6]|»µUƒ@VpáÇÀWlÙ³f͛À®„+®ø?FÀ4&ÿ¥$,K€@5öcˆ_מ@VŒxñãǏÀWlÙ³f͛À®T­}ZPÀ4È{]à€@5óÛ(•œ@W @ÀWlÙ³f͛À®%i•Z€õÀ3õ§çü4€@5-,êÈp@WS§N:tÀWlÙ³f͛À­öbàŽÀ3Û«Žgì/€@4Ë ~P–Q@WŸ>|ùóçÀWlÙ³f͛À­Ç˜á!À3ÀàšèŽ €@4j“ô(Ï@WêÕ«V­ZÀWlÙ³f͛À­™äËŒ!À3¥TÀs€€@4 ŸHшv@X6lÙ³fÍÀWlÙ³f͛À­jÈÔÄűÀ3‰ƒê€@3®Šu +k@X‚ @ÀWlÙ³f͛À­<ÅOéÀ3l&Ò§è€@3RôŸ>õà@X͛6lÙ³ÀWlÙ³f͛À­ÖG^À3NÖMY£€@2øû({»®@Y2dɓ&ÀWlÙ³f͛À¬áŽÐžm˜À30‚T:Ѐ@2 š @Ydɓ&L™ÀWlÙ³f͛À¬Ž^ŒˆÏ©À3ßù\Հ@2IÍçPç@Y°`Áƒ ÀWlÙ³f͛À¬‡w=«^)À2òŸ‘.Z€@1ô’à!Ä@Yû÷ïß¿~ÀWlÙ³f͛À¬ZÚ4ÙvÀ2Ó*Áöu€@1 äõ€ã1@ZG™À1†8ÿ^u€@-ZùaDŸY@];víÛ·nÀWlÙ³f͛Àª„Š[ÔÀ1cßç©C€@,ÓPk¬-@]‡8páÀWlÙ³f͛Àª[.~ë×À1Apwxy€@,NK6å6@]Ò¥J•*TÀWlÙ³f͛Àª2”UùµÀÀ1ðdÊOÀ€@+ËÞìiv³@^lƒßE€@&iïIÌ^@`Ô©R¥J•ÀWlÙ³f͛Àš?°O€Q7À.Áºhº¡€@&n;gÛv@`útéÓ§NÀWlÙ³f͛Àš„[P×À.~ƒ ÃȀ@%¢ð…g9@a @ÀWlÙ³f͛À§÷§›r»À.;CŸ€@%Bli³=@aF 0`ÁÀWlÙ³f͛À§Ô«°”×À-ù ª-䶀@$ãÖïv€@akׯ^œ{ÀWlÙ³f͛À§°Ú$ÆKÀ-¶Ø‘øß€@$‡'D ¿ @a‘£F4ÀWlÙ³f͛À§è™ +jÀ-uýòµ€@$,S]# @a·nÝ»víÀWlÙ³f͛À§kD–škKÀ-3°ŠŠÏ€@#ÓQšbæ­@aÝ:téÓ§ÀWlÙ³f͛À§Hí§OǶÀ,ò%µ s€@#|¶Á23@b 0`ÀWlÙ³f͛À§&ãQ +±°À,±Ù™Jðç€@#&Ÿ<ÌDÑ@b(Ñ£FÀWlÙ³f͛À§%þ0rÀ,q  +Ñ.€@"ÒÜU•Ê@bN:téÓÀWlÙ³f͛ÀŠã²töž†À,1Õ<·†€@"€Æ7î=@bthÑ£FÀWlÙ³f͛ÀŠÂŠéôÀ+ò{»ê'€@"0TÍJt…@bš4hÑ£FÀWlÙ³f͛ÀŠ¡­ì»qjÀ+³•Ýn0ž€@!áõÙ@bÀÀWlÙ³f͛ÀЁôrSþÀ+u%Ʊ÷€@!”<’ŸÀÀW @Àµ ]¢ÐÃȀ€@Kãïzx?òå˗.\¹ÀW @ÀµøŸk¿åLpÇÜž€@Ká„Ô{ú@å˗.\¹ÀW @ÀµÇSõпõ}ÂòZEy€@KÜãÖэ`@ X±bŋÀW @ÀµËî£KøÀçúgE€@KÕ1 ¶o@å˗.\¹ÀW @Àµ}T™ÚÀoŠÅœKۀ@KÊoՄ€ì@Ÿ>|ùóçÀW @ÀŽÿwè_JžÀ +ŸbÝQòÀ@KŒŠùÅŒ@X±bŋÀW @ÀŽû!Y UTÀ3Ø[ð€@K«Û_ò1Ä@ ‰$H‘"ÀW @ÀŽö8ƒ=@À QL1†€@K˜»Ãµ@"å˗.\¹ÀW @ÀŽð".‹²*À7Ñ@*ó‡€@Kh՘Ž@%B… +(PÀW @ÀŽé} ðsÀÈÎtxx€@Kg×Ñô…Ö@'Ÿ>|ùóçÀW @ÀŽâ-8ÀR5ЈÀ€@KKsDjýl@)û÷ïß¿~ÀW @ÀŽÙò¯öNKÀÓGèÿÛ³€@K,Jœ8è@,X±bŋÀW @ÀŽÑ9„MÏÀKL˜Ëc€@K +lžwç³@.µjÕ«V­ÀW @ÀŽÇxdFœÀ ÜÉÄcùˀ@Jåì9;Œ@0‰$H‘"ÀW @ÀŽœ(­ór¬À"ºU,Ük€@JŸÛ‡ØSð@1·nÝ»víÀW @À޲%»çìˆÀ#;(R/žr€@J•N0iá©@2å˗.\¹ÀW @ÀŽŠs°$À$așÔH€@JiXϞFÕ@4(P¡B…ÀW @Àޚ1·» À%‚T¹«À€€@J;áWÉæ@5B… +(PÀW @ÀŽ Ƙ‰oÀ&œ‹m€@J +ŒšÖÐ/@6páÇÀW @ÀŽ` 8 +À'°/…ù€@I×㚎 @7Ÿ>|ùóçÀW @ÀŽq°Ù]À(œÊu π@I£+º&$à@8͛6lÙ³ÀW @ÀŽb* +&UÄÀ)ÂåÉA{Z€@Il~•ÖÑ@9û÷ïß¿~ÀW @ÀŽR§Ú3uÀ*Á˜g²ô€@I3ôõÃÄ@;*T©R¥JÀW @ÀŽB‘hƒÙÏÀ+žøäïl€@Hù¥/œË@µjÕ«V­ÀW @ÀŽ“lŒ«À.qÛ܂€@HA|Î'@?ãǏ|ùóçÀW @À²é«t—š¬À3ÂoŸX}?€@Did»<@H6lÙ³fÍÀW @À²Ò4~IُÀ3ø¿Ü€€@Dÿ„òk@H͛6lÙ³ÀW @À²ºíšÀ4+y5/Žä€@CÔâÁÿ@@Idɓ&L™ÀW @À²¢³4FvÀ4Z«ø;î]€@CŠÃµÄc@Iû÷ïß¿~ÀW @À²Šl2,Ö8À4†i1Ô\€@C@±5ûtÇ@J“&L™2dÀW @À²r +Nq+À4®Â¯ž¹Ã€@Böœ °Ü@K*T©R¥JÀW @À²Y’1ªÀ4ÓÊó! K€@B¬õÚVŒ @KÁƒ 0ÀW @À²@ä‡æÌ€À4õ•œ²*€@Bcj&}/µ@LX±bŋÀW @À²(Ö ¬À54³Û̀@B'ò֊@Lïß¿~ýûÀW @À²ͱŠ÷À5/œãêû€@AÑ;Ñ<@M‡8páÀW @À±ö7%œÀ5HEà€@Aˆ²»ãš„@N®bVÇ@Qkׯ^œ{ÀW @À±ö0PÀ5«GG°œ€@>'ö‚-£@Q·nÝ»víÀW @À°÷npDÔ4À5ªG4ì Ԁ@=¢Óýüž”@R 0`ÀW @À°ÝÙW£‚ÁÀ5§.œ|ÇI€@=3ý«'~@RN:téÓÀW @À°ÄIîÜgÀ5¢óöu€@< Õ§—@Rš4hÑ£FÀW @À°ªÀ ¬ À5›±Õšg€@<£Í–áµ@Rå˗.\¹ÀW @À°‘@lïþ?À5’ö÷š€@;Å7=º@S1bŋ,ÀW @À°wÌ`ÇH/À5‡nØLœ€@; Œw‘@S|ùóçϟÀW @À°^eöæ9À5{ ·Â8ڀ@:¥|x;@Sȑ"D‰ÀW @À°E% @À5máJG€@:+%šmé}@T(P¡B…ÀW @À°+ÉÏ!è4À5]v3¯ŀ@9³ÂV@T_¿~ýû÷ÀW @À°—œõ»À5Li-ƒ>ë€@9<™tX@T«V­ZµjÀW @À¯òõNՁøÀ59òé$ÓŠ€@8Çï Rªž@TöíÛ·nÝÀW @À¯ÀèW^>"À5&%yœÉ€@8U}9'œ@UB… +(PÀW @À¯ ¬ŠÃ0À5#G³€@7ãÞŒöüê@UŽ8páÃÀW @À¯]b= zÙÀ4úǎ"Š'€@7t|µ§@UÙ³f͛6ÀW @À¯+îÐGđÀ4ãYèJĀ@7ÞLŒ¿@V%J•*T©ÀW @À®úŽQUÀ4ÊÕ­ÊŠ•€@6›w|B@VpáÇÀW @À®ÉŽ^ôSÀ4±LÏÚõ²€@60ñ5gZi@VŒxñãǏÀW @À®˜ò+üÀ4–Íœb€@5È ¡ßÚh@W @ÀW @À®ho¥ÂSÀ4{f¹ëºÏ€@5b]_Ãü@WS§N:tÀW @À®8.ÝŒ1ñÀ4_&^!ä€@4ýD—N¯@WŸ>|ùóçÀW @À®1ÅL¥ôÀ4BJ€Cî€@4š5ýhµ@WêÕ«V­ZÀW @À­Øz-ܖgÀ4$OϏ猀@48á8Þ•@X6lÙ³fÍÀW @À­© Éé>ðÀ4ÓË1ºB€@3ÙF |×k@X‚ @ÀW @À­yâ.)MÀ3沫;a”€@3{`8;øC@X͛6lÙ³ÀW @À­KÒøÀ3ÆømÉù€@3,'λ›@Y2dɓ&ÀW @À­sèîÀ3а¢¯K%€@2Ä¥òu™œ@Ydɓ&L™ÀW @À¬î..²I‰À3…ælè8ÿ€@2kÉoú@Y°`Áƒ ÀW @À¬À7OÆNßÀ3d€„;ñÁ€@2’;j9 @Yû÷ïß¿~ÀW @À¬’ƒ€2XÀ3Bõ6ÿÂM€@1Ÿûž•›@ZGÕ@€@*d*|]pœ@_ ÀW @À©Œ€•šÀ0äÏ ?.u€@)éÚ4gÝ&@_L™2dɒÀW @À©”b>Ÿ·%À0À­ø²Ãý€@)r„ûK@_˜0`ÁƒÀW @À©lú«˜×À0œ rØ/À€@(ü”ËŸ~@_ãǏ3‹A„%@`‰$H‘"ÀW @Àš¬èP¹À/Ô +CäP€@&Ô6÷Ü2‚@`®Ý»víÛÀW @Àš‡z'qó•À/pŒ“›€@&lbeMÕ]@`Ô©R¥J•ÀW @Àšb_fKX>À/G,±‰Éº€@&ªäŽ@`útéÓ§NÀW @Àš=—mܳëÀ/C8àq€@%£Š-’0@a @ÀW @Àš!ÐVŸÜÀ.»¹ï€-€@%AhRŸÛ@aF 0`ÁÀW @À§ôþ}Â%À.v’«* €@$áÇka¥ï@akׯ^œ{ÀW @À§Ñ+ÔÀ.1Ô'.ü™€@$„…]r”@a‘£F4ÀW @À§­ªøÖÀ-í[t›¬€@$(Te5N@a·nÝ»víÀW @À§Šyœòù9À-©ÖucE€@#Îlýƒæo@aÝ:téÓ§ÀW @À§g˜¥“À-f,â”â€@#vZY`ƒ«@b 0`ÀW @À§E¶X{À-#1‰žf€@# `ÐlÔ@b(Ñ£FÀW @À§"ÄQÉÎÇÀ,க®í€@"ˋwN +Œ@bN:téÓÀW @À§ÏÙ)À,žŠ–xž€@"xŒ)U±ý@bthÑ£FÀW @ÀŠß)ê­ À,]ä·èž€@"'›,ªš@bš4hÑ£FÀW @ÀŠœÏm*bÀ,¡‚u}€@!Ø`…­ç@bÀÀW @ÀМÂLÙiÀ+ۆ»aQ‚€@!Š?ͯÔ5ÀV£F4iÀµb¢$J€€@LÎ÷£P?òå˗.\¹ÀV£F4iÀµb7g; †¿æ‘4áTüƒ€@LÌt¿áÃ@å˗.\¹ÀV£F4iÀµ`÷šœ<¿ö_[c €@LdžŒq.@ X±bŋÀV£F4iÀµ^ã9>íÇÀå>qN€@L¿PŸÁeç@å˗.\¹ÀV£F4iÀµ[úÍE`ãÀ~;ç €@L³Ù+Ôº•@Ÿ>|ùóçÀV£F4iÀµX?`™;À Q6¹Ä&€@L¥&'î“J@X±bŋÀV£F4iÀµS²5XbàÀˎ^—ڀ@L“?Ú'CÑ@ ‰$H‘"ÀV£F4iÀµNTÒŎYÀ‰Óˆ¯ß€@L~0H¿©z@"å˗.\¹ÀV£F4iÀµH)ØÕÀA•|à¡R€@Lf,ì–÷@%B… +(PÀV£F4iÀµA0Փ¹ŒÀñ÷®eTù€@LJÅã‚óv@'Ÿ>|ùóçÀV£F4iÀµ9n•­–Àš$Xþ3€@L,‡[й0@)û÷ïß¿~ÀV£F4iÀµ0ä͞vOÀ9M0$—±€@L X£&Ú@,X±bŋÀV£F4iÀµ'–F,ÍÀ gVÂé€@KçIŽ0|@.µjÕ«V­ÀV£F4iÀµ…þrå"À!¬Áºô`€@KÀo™œ~ú@0‰$H‘"ÀV£F4iÀµ·-šI*À"쏙ºu€@K–Þ.'@1·nÝ»víÀV£F4iÀµ->-¬zÀ$&j^¬ö&€@KjªË]Î×@2å˗.\¹ÀV£F4iÀŽúëËkK"À%ZÓˆK€@K;ì:°ž@4(P¡B…ÀV£F4iÀŽíöžŸ…5À&‡ +ørõ€@K +¹ñ=-+@5B… +(PÀV£F4iÀŽàQ«ŸýtÀ'­@, +Ø~€@J×,IÕÚc@6páÇÀV£F4iÀŽÒBˆÀ(Ìa@۔€@J¡\XÞ©Þ@7Ÿ>|ùóçÀV£F4iÀŽÃ çÖKÀ)ä3Âij€@JicÑÀ2@8͛6lÙ³ÀV£F4iÀ޳mói¬£À*ô‚dêJ€@J/\è„"{@9û÷ïß¿~ÀV£F4iÀŽ£4U4pƒÀ+ý,4€@Iób=¢? @;*T©R¥JÀV£F4iÀŽ’`ďßÀ,ýÛ çàc€@IµŽœ$Àj@µjÕ«V­ÀV£F4iÀŽ\yŸ\ÎmÀ/ϔµ­€@HòúQ}ï@?ãǏ:w؀@H­èµ-_@@‰$H‘"ÀV£F4iÀŽ5à7ÝV_À0ín£&•€@Hhp%ϓ]@A @ÀV£F4iÀŽ!Õ¯1À1+SWÀ/t€@H!‰ÃÛ@A·nÝ»víÀV£F4iÀŽ S8§°À1ŽÂ+¢—€@GÙø$”/ö@BN:téÓÀV£F4iÀ³ø]Î$.4À1íù'ŠËr€@G‘,¢øòx@Bå˗.\¹ÀV£F4iÀ³âúkº€À2Hú 3œ®€@GGy(ΐ@C|ùóçϟÀV£F4iÀ³Í.OØKÀ2ŸÈlÚ$€@FüöÞGb¿@D(P¡B…ÀV£F4iÀ³¶ý•âÓ7À2òiâžò-€@F±ŸcrÑï@D«V­ZµjÀV£F4iÀ³ mþº÷cÀ3@åß3р@FeçœÇ?Ÿ@EB… +(PÀV£F4iÀ³‰„#NÄÀ3‹EŒØÃ€@FŠQQÏ@EÙ³f͛6ÀV£F4iÀ³rDØIU À3ѓŽy€)€@EÌŒ×%e@FpáÇÀV£F4iÀ³ZŽä²·VÀ4ܟ俚€@E•UÓRž@G @ÀV£F4iÀ³BÙ:EËÀ4R-ý"8h€@E2)J@GŸ>|ùóçÀV£F4iÀ³*µÑ¢š©À4Œ–Â4ní€@D䌶£í@H6lÙ³fÍÀV£F4iÀ³OíNÛüÀ4Ã'l· €@D–ÓöDà8@H͛6lÙ³ÀV£F4iÀ²ù«ÓïÀ4õðpW^€@DIⵜµ@Idɓ&L™ÀV£F4iÀ²àÍñOKÀ5%ú €@CûXœ¬ª@Iû÷ïß¿~ÀV£F4iÀ²Çº›?ˆžÀ5PuúÍŸ*€@C­º¬Ï2@J“&L™2dÀV£F4iÀ²®vìRÀ5xY É£á€@C`F\ªøº@K*T©R¥JÀV£F4iÀ²•x{ûùÀ5œÃ‚刀@C ºm7@KÁƒ 0ÀV£F4iÀ²{iá]†ëÀ5œÈ²1 l€@BÆ;• #@LX±bŋÀV£F4iÀ²aª@’“,À5Û~Ç(÷ƀ@By‰Š Q6®æ@Q·nÝ»víÀV£F4iÀ±&yfܔhÀ6^nH4€@>ï Ñ.@R 0`ÀV£F4iÀ± +éylÀ6XßTŒµ)€@=“cU1Ðé@RN:téÓÀV£F4iÀ°ñ°WïúÀ6Q”E š€@= ‡K.—@Rš4hÑ£FÀV£F4iÀ°×YNÆysÀ6HNº©€@<‡cö/mÊ@Rå˗.\¹ÀV£F4iÀ°œZž}?À6="*ísç€@<]žB^@S1bŋ,ÀV£F4iÀ°¢Ñ¬ãNAÀ60%»¶[d€@;‚f[~@S|ùóçϟÀV£F4iÀ°ˆ¥]§³úÀ6!mƒCãx€@;™¯)Ì@Sȑ"D‰ÀV£F4iÀ°n‹mŀÀ6 ß1Mµ€@:„Ÿ÷ ‹™@T(P¡B…ÀV£F4iÀ°T…ɇûÀ5ÿÃÕ €@:}˘×@T_¿~ýû÷ÀV£F4iÀ°:–1ãÂ'À5ë§ž–9€@9Ž6Á }@T«V­ZµjÀV£F4iÀ° ŸrS^†À5ÖÇÒÀ€@9ÍŠ¯h@TöíÛ·nÝÀV£F4iÀ°(¹œÍÀ5À³bHù€@8ŸD5ðD@UB… +(PÀV£F4iÀ¯Ú¹ÆxÄ À5© ‡rdº€@8*œàJÖ@UŽ8páÃÀV£F4iÀ¯§¬5€Å?À5S¡ +ɀ@7·×P‡º@UÙ³f͛6ÀV£F4iÀ¯tÚgšAiÀ5vuVàßX€@7FóýšH!@V%J•*T©ÀV£F4iÀ¯BFýtUÀ5[ƒFd—Á€@6×òm·¶@VpáÇÀV£F4iÀ¯ôo™ƒÀ5?œÆö€@6jњéâÓ@VŒxñãǏÀV£F4iÀ®ÝåûŽèÀ5"¢rZŠ€@5ÿû~Ÿ`@W @ÀV£F4iÀ®¬l“šÀ5Ò¶XÄV€@5–+Š~<–@WS§N:tÀV£F4iÀ®z˜…›GÀ4æ,¢³.‹€@5.¡Ïß@WŸ>|ùóçÀV£F4iÀ®I_Iß>RÀ4ÆŸ‚±˜€@4ÈïèxŸœ@WêÕ«V­ZÀV£F4iÀ®q+^šÀ4Š–%§“]€@4e¯é§@X6lÙ³fÍÀV£F4iÀ­çÏÒ1ü©À4…Àßñè܀@4ߍð@X‚ @ÀV£F4iÀ­·|Dž ³À4dKŒ0Îq€@3¢Æžxϝ@X͛6lÙ³ÀV£F4iÀ­‡yvŽí)À4BBŒ¹.€@3DOÌÝÚZ@Y2dɓ&ÀV£F4iÀ­WÇ.j0bÀ4±Í7êЀ@2çôÍ @Ydɓ&L™ÀV£F4iÀ­(g!¯uÀ3ü€Ä‚Bk€@2Œ©ºs ‹@Y°`Áƒ ÀV£F4iÀ¬ùZidÕÀ3Ù&vŽ–”€@23p¡å7=@Yû÷ïß¿~ÀV£F4iÀ¬Ê¢^ø†À3µAv‘­€@1Ûì’h#b@ZG×MÓ©À3ÿé8+å€@1†'äé@Z“&L™2dÀV£F4iÀ¬n1³ÞJÀ3lk‡û·€@11íš‘–ù@ZÞœzõë×ÀV£F4iÀ¬@{Q§ž›À3Gž¶ò€@0ßguE1Ô@[*T©R¥JÀV£F4iÀ¬S¿[À3"oԐ<€@0ޢĝú@[uëׯ^œÀV£F4iÀ«æH©òÀ2ýu=ÿ€@0?03â‰â@[Áƒ 0ÀV£F4iÀ«¹f«BOYÀ2ב×ÊO/€@/âæ%â8Á@\ 4hÑ£ÀV£F4iÀ«ã¥þÀ2±ãöê€@/J„+iR[@\X±bŋÀV£F4iÀ«aH À2ŒU”Í‹€@.µ.3q…@\€H‘"DˆÀV£F4iÀ«5qœ¿À2f)àˆž€@."×coç@\ïß¿~ýûÀV£F4iÀ« +'™H 2À2@-U{Z³€@-“rËïw@];víÛ·nÀV£F4iÀªß7àð&À2$˜V€@-ôfŸÀ@]‡8páÀV£F4iÀªŽ¢ +@u]À1ô(Cîm€@,}O._‡.@]Ò¥J•*TÀV£F4iÀªŠfDRiÀ1ÎQÏAæ€@+övdY?@^ÔÎÀ0zjx®þ_€@'® ¥tˆé@`cF4hÀV£F4iÀš÷FºTXÀ0UO²il%€@'AÄbvž@`‰$H‘"ÀV£F4iÀšÐÝõ[ÛÀ00a°]F7€@&ÕÄõ™@`®Ý»víÛÀV£F4iÀšªÊšÕ=€À0 £Aø,U€@&l±|Ë!@`Ô©R¥J•ÀV£F4iÀš…ÈÔl_À/Î.8S€@&ÊÃ_Ë]@`útéÓ§NÀV£F4iÀš_©p^mÀ/…~äVi„€@%¡Yæ@a @ÀV£F4iÀš:˜Ð’ªõÀ/==” £:€@%>UùZM@aF 0`ÁÀV£F4iÀšÝŽõŠ7À.õnWãÍ©€@$ݱ„AZ@akׯ^œ{ÀV£F4iÀ§ñw$ƒÚÌÀ.®`©€@$ -H@a‘£F4ÀV£F4iÀ§Íd‘ŽÑûÀ.g5|¹€@$"]»œ+;@a·nÝ»víÀV£F4iÀ§©¥jãzÃÀ. ÒÓðó)€@#Ǚè¯D@aÝ:téÓ§ÀV£F4iÀ§†9¢(gÀ-Úð2€ïR€@#nŽsíDd@b 0`ÀV£F4iÀ§c ÿÌÀ-•iqǀ@#¥ÆI·@b(Ñ£FÀV£F4iÀ§@V—»À-P¶ä9þ€@"ÂbçÓNt@bN:téÓÀV£F4iÀ§ß(žÊ]À- ch.o€@"náðKٍ@bthÑ£FÀV£F4iÀŠûž7óYÀ,Ț€` ǀ@"&'Œ%@bš4hÑ£FÀV£F4iÀŠÙàÊØ‘ŸÀ,…]¥M€€@!ÌþýGt0@bÀÀV£F4iÀŠžX‘e˜âÀ,B®£â €@!~Š5J8ÀV>|ùóçÐÀµŸÜõCEȀ€@MÄÚFðW?òå˗.\¹ÀV>|ùóçÐÀµŸlù‡"Ó¿ç³ON7×S€@MÃscj@å˗.\¹ÀV>|ùóçÐÀµœ-šš"¿÷¯%냜<€@MœÔ‹šû@ X±bŋÀV>|ùóçÐÀµºî‚píÀŸ*ÃOã€@Mµy XG@å˗.\¹ÀV>|ùóçÐÀµ·àK OÀžŒ±wƒ€@MšÔ_ì–@Ÿ>|ùóçÀV>|ùóçÐÀµ³õ \‰÷À v°N«çˀ@M™$e „@X±bŋÀV>|ùóçÐÀµ¯- âÊÀ¢Jžtíñ€@M† +ª “€@ ‰$H‘"ÀV>|ùóçÐÀµ©‹·œ§öÀƒ"ŠðB߀@Mo’=*¶@"å˗.\¹ÀV>|ùóçÐÀµ£A®CêÀ\ê ×Ïâ€@MUÈ •„z@%B… +(PÀV>|ùóçÐÀµ›Àw€ÐÀ.ŽZÊV€@M8ºÏ€SÁ@'Ÿ>|ùóçÀV>|ùóçÐÀµ“›×—áíÀ÷˜Ì÷€Å€@Mzý7U@)û÷ïß¿~ÀV>|ùóçÐÀµŠŠ#v³ÎÀ¶¹j;Ž€@Lõ«øùÛ@,X±bŋÀV>|ùóçÐÀµ€â\ÿyÀ!5 ]€€@Lέ»Ùá@.µjÕ«V­ÀV>|ùóçÐÀµvSðÝSÀ"Š1%u€@L¥H%n@0‰$H‘"ÀV>|ùóçÐÀµjýѲÀ#دcɚ·€@LyHãvÙ@1·nÝ»víÀV>|ùóçÐÀµ^ä8²ÀÀ% ŸŒÃ€@LIòWš+Ô@2å˗.\¹ÀV>|ùóçÐÀµR +Þ¥9ßÀ&b Ùžo݀@L1…Ã@4(P¡B…ÀV>|ùóçÐÀµDuÚaÚžÀ'œ@ÞLR€@Kãٜ"Ø@5B… +(PÀV>|ùóçÐÀµ6)p*ÓþÀ(ÏÞÓÓ*€@K­JlI@6páÇÀV>|ùóçÐÀµ'*ËxÀ)úQ˜׀@KsÐþ °@7Ÿ>|ùóçÀV>|ùóçÐÀµ|H£ˆÁÀ+šŽQç€@K8UڀB0@8͛6lÙ³ÀV>|ùóçÐÀµ$Ö¹³ÛÀ,8èöþ®J€@Jú³rÑÛ,@9û÷ïß¿~ÀV>|ùóçÐÀŽö(ŽbùÀ-KàِÝˀ@J»Ó%k@;*T©R¥JÀV>|ùóçÐÀŽäŒ`ö4SÀ.Vdõû €@JyjNýw@|ùóçÐÀŽÒUWL«À/XPèj%€@J5þiß!º@=‡8páÀV>|ùóçÐÀŽ¿ˆwš8À0(ÁˆPùl€@Iðߺˆo@>µjÕ«V­ÀV>|ùóçÐÀެ+6­7À0 ò=Qò_€@Iª+Ñ,œß@?ãǏ|ùóçÐÀޘBˆx4À1°Uç –€@IbK¿Ð@@‰$H‘"ÀV>|ùóçÐÀރÓÊäÀ1ƒô¿FY€@IyÛª@A @ÀV>|ùóçÐÀŽnäGŸØYÀ1î»aÜ{€@H͵ó4'@A·nÝ»víÀV>|ùóçÐÀŽYyPº »À2Ut)ŠÚ€@HÐðŒ0@BN:téÓÀV>|ùóçÐÀŽC˜5؞ÉÀ2¶È‚ôu€@H4æè>ÔÈ@Bå˗.\¹ÀV>|ùóçÐÀŽ-FENsêÀ3.j¶T€@GçixT(@C|ùóçϟÀV>|ùóçÐÀŽˆÈ€ žÀ3læGmo€@G˜qn#Ÿ€@D(P¡B…ÀV>|ùóçÐÀ³ÿe ÍTÀ3ÁJ F=i€@GIM­@D«V­ZµjÀV>|ùóçÐÀ³çà*† À4F­‹Ā@Fù*­Ðwº@EB… +(PÀV>|ùóçÐÀ³Ïÿm–¬À4\ç‡ÌÔš€@Fšž~BhÝ@EÙ³f͛6ÀV>|ùóçÐÀ³·ÇéÇâ8À4€9U†žš€@FWÜ鉩s@FpáÇÀV>|ùóçÐÀ³Ÿ>®ŽgÀ4çJdjDP€@F¯P•Dâ@G @ÀV>|ùóçÐÀ³†hž1gÀ5&*uH€@EµFDö@GŸ>|ùóçÀV>|ùóçÐÀ³mJ𠺓À5`ꜞçр@Ec·R}#@H6lÙ³fÍÀV>|ùóçÐÀ³Sê*ŒÉˆÀ5—#îìá€@EåOÛ®@H͛6lÙ³ÀV>|ùóçÐÀ³:K%í@þÀ5ÊUkÿáñ€@DÀ{}žÅ@Idɓ&L™ÀV>|ùóçÐÀ³ rˆäG§À5ù'Ð$‡€@DnõsMt‚@Iû÷ïß¿~ÀV>|ùóçÐÀ³dᙛ€À6$)Š—úü€@D˜XÌõ@J“&L™2dÀV>|ùóçÐÀ²ì&€¬ ‰À6Kp™ÿt*€@CÌtÒ +‘@K*T©R¥JÀV>|ùóçÐÀ²ÑŒ,X•ÎÀ6ošBc€@C{œBê€@KÁƒ 0ÀV>|ùóçÐÀ²·)·Ÿ–‘À6)ñ«#€@C+ ‡“W@LX±bŋÀV>|ùóçÐÀ²œsj?áÿÀ6«Ë/ˆCV€@BÛ Qø.@Lïß¿~ýûÀV>|ùóçÐÀ²Jû=À6Å€óm°€@B‹kæÀW{@M‡8páÀV>|ùóçÐÀ²f«D^ÞÃÀ6ÛWŸt€@B|ùóçÐÀ²K¡#ÔˊÀ6íãa T?€@AíËkºÌ@NµjÕ«V­ÀV>|ùóçÐÀ²0‚™‚=ÐÀ6ý€|®%€@AŸàvk×$@OL™2dɒÀV>|ùóçÐÀ²S89GÀ7 +kŸ˜û€@AR%Ôc@OãǏ|ùóçÐÀ±útÚºPÀ7QÍג€@A žÿÂu@P=zõëׯÀV>|ùóçÐÀ±Þϧ`X€À7pÔ³†€@@º5M;ë;@P‰$H‘"ÀV>|ùóçÐÀ±Ã‚ ÚÿqÀ7ß1Q7œ€@@o"摃@PÔ©R¥J•ÀV>|ùóçÐÀ±š0¹”.À7!ž$ +!€@@$Üq#è„@Q @ÀV>|ùóçÐÀ±ŒÞŽªh-À7!Ó©€@?¶Ò‘_E@Qkׯ^œ{ÀV>|ùóçÐÀ±qŽß2eðÀ7 ²“¢–€@?% HEAx@Q·nÝ»víÀV>|ùóçÐÀ±VCþ”íÀ7²éöÔs€@>–.6’i–@R 0`ÀV>|ùóçÐÀ±;ŒTbfÀ7' îx&€@>‡Y1@RN:téÓÀV>|ùóçÐÀ±Ç¥ës\À7}€(s€@=|µ…:Ò)@Rš4hÑ£FÀV>|ùóçÐÀ±›-33ÝÀ6ûÍïaê€@<òÁtٚ^@Rå˗.\¹ÀV>|ùóçÐÀ°é}šÒ!LÀ6î.ÁŽšß€@|ùóçÐÀ°ÎqT³|ùóçÐÀ°³xR„f<À6Í{Cß4u€@;`_l³†@Sȑ"D‰ÀV>|ùóçÐÀ°˜”ª;7NÀ6º’€JbG€@:Þ%wՌ@T(P¡B…ÀV>|ùóçÐÀ°}ÈJŸ“uÀ6ŠS€@:]åa_@T_¿~ýû÷ÀV>|ùóçÐÀ°c ÛAÀ6 V<²€@9ߢmB}@T«V­ZµjÀV>|ùóçÐÀ°H|Š ÕÂÀ6x—£ìŸx€@9c_XçÙG@TöíÛ·nÝÀV>|ùóçÐÀ°.ÅäQ&À6_ÆÕãø€@8éLiŠ7@UB… +(PÀV>|ùóçÐÀ°¢ù3ÆÏÀ6E­ô¹4€@8pÝøÞŠ@UŽ8páÃÀV>|ùóçÐÀ¯òÉs-ÙÀ6*\Ž9sž€@7úž—š@UÙ³f͛6ÀV>|ùóçÐÀ¯ŸŽÖFÀ6 ç÷{ü÷€@7†an +Ÿ@V%J•*T©ÀV>|ùóçÐÀ¯Š˜¹)5øÀ5ð`J@§À€@7$b<Ã8@VpáÇÀV>|ùóçÐÀ¯V鑾ÞÀ5ÑÖ£9ÿπ@6£åžº`@VŒxñãǏÀV>|ùóçÐÀ¯#ƒ­Šñ¡À5²[p€³€@65£2ªE®@W @ÀV>|ùóçÐÀ®ði4š9WÀ5‘þ•ÙÓ²€@5ÉZÂ/@WS§N:tÀV>|ùóçÐÀ®œœ)†òõÀ5pÏq;I€@5_+ž@WŸ>|ùóçÀV>|ùóçÐÀ®‹kcÈ<À5NÜ×íµ€@4öŠÝáw @WêÕ«V­ZÀV>|ùóçÐÀ®Xñ¶ôv¬À5,5@­€@45+l@X6lÙ³fÍÀV>|ùóçÐÀ®'§¿ƒfÀ5åù²­z€@4+­·‚é-@X‚ @ÀV>|ùóçÐÀ­õ‘¹Tc°À4äüÇܳ€@3É ÓÃA@X͛6lÙ³ÀV>|ùóçÐÀ­ÄaH‘%À4À†CÑ(€@3hJƒ5¬º@Y2dɓ&ÀV>|ùóçÐÀ­“‡”z ÄÀ4›Žµª¡5€@3 dƒF«Ü@Ydɓ&L™ÀV>|ùóçÐÀ­cÀájÀ4v!åÕd€@2¬TQù$R@Y°`Áƒ ÀV>|ùóçÐÀ­2ÜÒÙBÀ4PKŽÑD€@2Q45ÑA@Yû÷ïß¿~ÀV>|ùóçÐÀ­ º1ëÃÀ4*:Oœ€@1÷ž;Ì@á@ZG|ùóçÐÀ¬Ó™Jz~×À4Š”­ýï€@1ŸìM'¥@Z“&L™2dÀV>|ùóçÐÀ¬€€@ŒÆÀ3ܵö +b€@1Iø$ž”Û@ZÞœzõë×ÀV>|ùóçÐÀ¬uÃ@‚"‹À3µžI*¬€@0õ»\+~@[*T©R¥JÀV>|ùóçÐÀ¬GbÛM¡ À3ŽO-æáõ€@0£/nÑ7.@[uëׯ^œÀV>|ùóçÐÀ¬_‹&œ1À3fÐmI°È€@0RM¿,Ñ@[Áƒ 0ÀV>|ùóçÐÀ«ë¹¶ËŠÀ3?*Cír€@0™ÒÑP@\ 4hÑ£ÀV>|ùóçÐÀ«Ÿq±üzQÀ3d‰çY€@/jÜv,ò@\X±bŋÀV>|ùóçÐÀ«‘‡Ÿf@îÀ2Émɀ@.ÒÅ¢y‡Š@\€H‘"DˆÀV>|ùóçÐÀ«dü „™À2Ǘ߫@=€@.=Í¿*c@\ïß¿~ýûÀV>|ùóçÐÀ«8ÎŒ}ãHÀ2ŸžÄ,m“€@-«äÓ· à@];víÛ·nÀV>|ùóçÐÀ« ÿÞ÷ŠŽÀ2w¡ÇxZǀ@-ÿK–­@]‡8páÀV>|ùóçÐÀªáuãùàÀ2OŠøCŽX€@,‘ŸŠò@]Ò¥J•*TÀV>|ùóçÐÀª¶}uGã À2'Ž‹‡€@, œ~@@^|ùóçÐÀª‹ÉÃúÄŠÀ1ÿ΃˜?€@+Ô̋óå@^iÓ§N:ÀV>|ùóçÐÀªat<^àÀ1×ûjºyπ@*þp9tì8@^µjÕ«V­ÀV>|ùóçÐÀª7|­yÀ1°?žKà€@*}ɳyæÿ@_ ÀV>|ùóçÐÀª âٞþÀ1ˆŸ­dÞÆ€@)ÿÓ³»ÑV@_L™2dɒÀV>|ùóçÐÀ©äŠ{€ÒÀ1aâÚ_û€@)„€Ìâä@_˜0`ÁƒÀV>|ùóçÐÀ©»Ç@®jÀ19ÄGç;€@) îXKÃ@_ãǏ|ùóçÐÀ©“DÐoŒ1À1ŠÐԞ€@(•'4ÌO@`¯^œzöÀV>|ùóçÐÀ©kǪyLÀ0눍yµÙ€@(!Ö(èv@`=zõëׯÀV>|ùóçÐÀ©CT»‘†åÀ0įOᇬ€@'°‹ÉŸh?@`cF4hÀV>|ùóçÐÀ©æ9ŒÎŽÀ0ž +–•#€@'A£Fgov@`‰$H‘"ÀV>|ùóçÐÀšôÒÈ©íéÀ0w•¥S/€@&ÕIÆ@`®Ý»víÛÀV>|ùóçÐÀšÎè6€áÀ0QZÔÁ׀@&jŖ@`Ô©R¥J•ÀV>|ùóçÐÀš§» À0+ZÍYQ€@&·µiƒ@`útéÓ§NÀV>|ùóçÐÀšµº@â +À0•Òn®€@%œÚM`dG@a @ÀV>|ùóçÐÀš\ O` ‚À/À @ìø€@%9!u_à¥@aF 0`ÁÀV>|ùóçÐÀš6µ;35@À/u– ¶¥®€@$ׁtÿÛ7@akׯ^œ{ÀV>|ùóçÐÀšžâñú®À/+µ¶Èõ€@$wîÃÍð›@a‘£F4ÀV>|ùóçÐÀ§í§šÊxÀ.âjTŀ@$^ +(¹û@a·nÝ»víÀV>|ùóçÐÀ§ÈÄæ‘W(À.™!Ü?ú9€@#ŸÄ!Ä«Ö@aÝ:téÓ§ÀV>|ùóçÐÀ§€ËùfÈqÀ.PŸE€@#eù$@b 0`ÀV>|ùóçÐÀ§(6µÔµÀ.ëhÎD€@# I$~ÜU@b(Ñ£FÀV>|ùóçÐÀ§]Øò(éaÀ-Á«™9]€@"·RŒ­ˆ@bN:téÓÀV>|ùóçÐÀ§:Ý|ЈsÀ-{엉}€@"c(€|â_@bthÑ£FÀV>|ùóçÐÀ§5%hÀ-4í@s÷þ€@"ÀD ¯@bš4hÑ£FÀV>|ùóçÐÀŠõß8– ŽÀ,ïr<”€€@!À Ž@bÀÀV>|ùóçÐÀŠÓÛ-•BÀ,ª‘UÙ€@!qG·üÀUÙ³f͛6À¶4„ug[€€@NÈÄúÐ.?òå˗.\¹ÀUÙ³f͛6À¶ŸÐúV¿èé)Þ[z˜€@NÆ9>N_>@å˗.\¹ÀUÙ³f͛6À¶]ß¡^ž¿øä£ÖW_€@NÀ˜Î–Lv@ X±bŋÀUÙ³f͛6À¶1ÂìtÀ¥Õ¥â]߀@N·;á?w@å˗.\¹ÀUÙ³f͛6À¶ܛâýýÀҙŒšªž€@Nª(#7âë@Ÿ>|ùóçÀUÙ³f͛6À¶ŸFìEVÀöh_ç@M€@N™e|ZÒ§@X±bŋÀUÙ³f͛6À¶ ž®˜ÅÀ‡Š÷ñsž€@N„þÁ 9@ ‰$H‘"ÀUÙ³f͛6À¶͟òޜÀ@ŸÍö`€@NlýíI_î@"å˗.\¹ÀUÙ³f͛6À¶ÿ7†1À‹KIh؀@NQs‚XS@%B… +(PÀUÙ³f͛6ÀµùOßG1‚À€š¯c )€@N2oj³@'Ÿ>|ùóçÀUÙ³f͛6ÀµðÂL/Œ Àl_ Lœ€@N™šßo@)û÷ïß¿~ÀUÙ³f͛6ÀµçY{šÚÌÀ Š¿ ß{V€@MêB7<9×@,X±bŋÀUÙ³f͛6ÀµÝ°a@RÀ"ÇYS€@MÁC‚]Bé@.µjÕ«V­ÀUÙ³f͛6ÀµÒoŒñpÀ#v4Ñ€@M•µ™#@0‰$H‘"ÀUÙ³f͛6ÀµÆ}ù¡¥À$ÔD A©B€@Meé‚>ù@1·nÝ»víÀUÙ³f͛6Àµ¹jÚ÷·†À&+]ÓðL€@M3Àï]ē@2å˗.\¹ÀUÙ³f͛6Àµ«ïŸˆ'RÀ'{%áU%€@Lþ¿;QbÀ@4(P¡B…ÀUÙ³f͛6Àµ°”§{À(ÃG˶:€@LÇ·–«@5B… +(PÀUÙ³f͛6ÀµŽ±ùžvcÀ*s Öl€@LŒ¢§¿ž@6páÇÀUÙ³f͛6Àµ~ø¶ÍÉÀ+;aúŸ•2€@LOë;G@7Ÿ>|ùóçÀUÙ³f͛6Àµn‰ºò[kÀ,jÓ1n€@L€Ûÿ Š@8͛6lÙ³ÀUÙ³f͛6Àµ]j“%6À-‘Œ ëi_€@KÎû)èþ¬@9û÷ïß¿~ÀUÙ³f͛6ÀµKŸ‚YžÀ.¯Y۞@K‹Q»l)ô@;*T©R¥JÀUÙ³f͛6Àµ9-î”/÷À/ÄöidԀ@KE€‹w¢N@µjÕ«V­ÀUÙ³f͛6ÀŽþ,eËþÀ1e%b³„€@JiÇâGv@?ãǏ..·€@I€J‰zb@A·nÝ»víÀUÙ³f͛6Àާ¹ú™.ŒÀ3&˜ê­(P€@I0JÐdž@BN:téÓÀUÙ³f͛6ÀސßÅŽŸÀ3ŠõI­þˆ€@HÞ°wMQ@Bå˗.\¹ÀUÙ³f͛6ÀŽy‘فÞ>À3ê‰æýFg€@HŒuª˜^¡@C|ùóçϟÀUÙ³f͛6ÀŽaÕÙd‡À4E]¬}|l€@H9mÏåQ}@D(P¡B…ÀUÙ³f͛6ÀŽI±\Å¡ÆÀ4›yœMš8€@G嵇z@D«V­ZµjÀUÙ³f͛6ÀŽ1)ï€ÒÏÀ4ìéP‰ûu€@G‘fá”ÿ@EB… +(PÀUÙ³f͛6ÀŽEšÉÀ59¹‹ûUD€@G<Â™oë@EÙ³f͛6ÀUÙ³f͛6À³ÿ)§ØÀ5ù^ÿV…€@Fçskye@FpáÇÀUÙ³f͛6À³åx˜)êÀ5Ź]íÚ €@F’©¹™_@G @ÀUÙ³f͛6À³Ë›¡¥²À6 ž4¬²€@F<]`<{ò@GŸ>|ùóçÀUÙ³f͛6À³±vsïRÀ6@“[ï€@E栂ö@H6lÙ³fÍÀUÙ³f͛6À³—(çtŒÀ6vµí"ó€@EàõÅ4@H͛6lÙ³ÀUÙ³f͛6À³|gŸÒ À6©8vˆT!€@E;1!š_á@Idɓ&L™ÀUÙ³f͛6À³aˆ>ȱÀ6סö×ìԀ@Då§Æ6·þ@Iû÷ïß¿~ÀUÙ³f͛6À³FtP! À7 + ÑL€@DW'ãÅ@J“&L™2dÀUÙ³f͛6À³+0)ƅ©À7(‰1bï)€@D;QzO÷‘@K*T©R¥JÀUÙ³f͛6À³Á,W2À7K8^že€@C暈Ô@KÁƒ 0ÀUÙ³f͛6À²ô+G!Z)À7j11'Cñ€@C’kÇ¢í@LX±bŋÀUÙ³f͛6À²ØsÃÖëÀ7…¶°œ]€@C>ªÙ@Lïß¿~ýûÀUÙ³f͛6À²Œœ„5Á:À7hW€@BësþäÉ@M‡8páÀUÙ³f͛6À² «Ý~ƒJâW@RN:téÓÀUÙ³f͛6À±N'Dî^À7Äõ󣘀@=í|OD/@Rš4hÑ£FÀUÙ³f͛6À±2†‡¢Ï>À7µŒáÕT­€@=^MõD@Rå˗.\¹ÀUÙ³f͛6À±Ž÷W€_À7¥xW/`€@<ѹÆkç@S1bŋ,ÀUÙ³f͛6À°ú«Œbè=À7“RnÂà4€@|ùóçÀUÙ³f͛6À®ÍkÆuŒmÀ5ڄ*0'†€@5#:—¶A@WêÕ«V­ZÀUÙ³f͛6À®™øKßí{À5µ9ÿœÜâ€@4º*€@3)ß»à@Ydɓ&L™ÀUÙ³f͛6À­žƒºÀ4òdÁÊ -€@2Ê­jš+c@Y°`Áƒ ÀUÙ³f͛6À­lºƒOlpÀ4ÊaÇ +Œ€@2maÍÆ¹Õ@Yû÷ïß¿~ÀUÙ³f͛6À­;Αi"ÙÀ4¡ue×P€@2ö9Ų8@ZG|ùóçÀUtéÓ§NÀ¶tÃç ÀHJ>ÑÖµ€@OŠŸØ›X@X±bŋÀUtéÓ§NÀ¶ozóV+À|ñûVê€@OìŒºR\@ ‰$H‘"ÀUtéÓ§NÀ¶iAžCÃ:À©ŸŸRóT€@OwBÉde@"å˗.\¹ÀUtéÓ§NÀ¶b‰6zÀÎYîÆ;€@OYÑXÀO@%B… +(PÀUtéÓ§NÀ¶ZBöiÐÀé£ýäEҀ@O8ª4À³Þ@'Ÿ>|ùóçÀUtéÓ§NÀ¶Q™¥†2ÀúrÔõZ€@OámoÃ@)û÷ïß¿~ÀUtéÓ§NÀ¶G#áAÀ!ߞ‡f¥€@Në àû‘@,X±bŋÀUtéÓ§NÀ¶<]£€YÀ"üI˜Î¡Ü€@N¿ÄøÓí@.µjÕ«V­ÀUtéÓ§NÀ¶0ž³^zÀ$rÊî•e€@N¢Ð§â@0‰$H‘"ÀUtéÓ§NÀ¶$9&l\œÀ%à•„¯L€@N^AÍm +Õ@1·nÝ»víÀUtéÓ§NÀ¶ãRª®‡À'GžÙ ÛN€@N(Ÿ›)=L@2å˗.\¹ÀUtéÓ§NÀ¶»ÉòÉÀ(Šºà›7ã€@Mð74ÒWË@4(P¡B…ÀUtéÓ§NÀµùÇUÌ¢ËÀ)ýŽï‚s€@MŽÊœÏ*@5B… +(PÀUtéÓ§NÀµê +ó"ÂkÀ+KÇŸÛ¬«€@Mv™['@6páÇÀUtéÓ§NÀµÙ‹ÍԒÀ,‘‰‘€€@M5Ä vl@7Ÿ>|ùóçÀUtéÓ§NÀµÈOµjÕ«V­ÀUtéÓ§NÀµR•_QNÀ252î‹(¢€@K1BøóÏó@?ãǏœ‡Ž0€@J9ÌëV¡z@A·nÝ»víÀUtéÓ§NÀŽø)žœ>‘À4R<Ûð€@IäÐsþ;þ@BN:téÓÀUtéÓ§NÀŽàGøhR¬À4kFTð˙€@IŽÊÜåÉ@Bå˗.\¹ÀUtéÓ§NÀŽÇï»É`7À4Í"Õq€@I7Ûõ5@f@C|ùóçϟÀUtéÓ§NÀޝ&èŸTWÀ5)íÿ8÷c€@Hà"¿w²×@D(P¡B…ÀUtéÓ§NÀŽ•ós#Ù}À5µ€Ä&÷€@H‡œb”ê@D«V­ZµjÀUtéÓ§NÀŽ|[@"ú°À5ԆzLˊ€@H.ÉôHÎ@EB… +(PÀUtéÓ§NÀŽbd#±hÀ6"p7’4€@GÕb9€F@EÙ³f͛6ÀUtéÓ§NÀŽHÞA*³À6kƒÙÈ@€@G{€$;@FpáÇÀUtéÓ§NÀŽ-p§qŽÀ6¯Ôùý\€@G!šÔNš`@G @ÀUtéÓ§NÀŽ~l+[òÀ6ïx%r^y€@Fljâ[¬Õ@GŸ>|ùóçÀUtéÓ§NÀ³÷DLÉo©À7*ƒvÝ €@Fm_j²ÍS@H6lÙ³fÍÀUtéÓ§NÀ³ÛÇ¡ÅÀ7aHÈ׀€@F@—ê*@H͛6lÙ³ÀUtéÓ§NÀ³À ‘øÀ7“13]7€@E¹C…÷R†@Idɓ&L™ÀUtéÓ§NÀ³€qøž÷À7ÁO€Q€@E_}B‘ ”@Iû÷ïß¿~ÀUtéÓ§NÀ³‡ñ% XœÀ7ê¥FFAè€@EΜBK@J“&L™2dÀUtéÓ§NÀ³k›Ðq¡À8,’^€@D¬ä ‡‚÷@K*T©R¥JÀUtéÓ§NÀ³O!"XÀ81µ,z1€@DT6'wP @KÁƒ 0ÀUtéÓ§NÀ³2uÖ±d À8O\ì‚CV€@CüÏ& +Ù@LX±bŋÀUtéÓ§NÀ³¯ÁÜe–À8i?Ög†M€@C€l`@Lïß¿~ýûÀUtéÓ§NÀ²øÍEŒ²bÀ8zÌ.hV€@CMnºo€@M‡8páÀUtéÓ§NÀ²ÛÒ¢ ,ùÀ8’*é¥ÛR€@B÷îwNÕ@NŽZŸûÀ8­Ãßjv€@@DC@Q·nÝ»víÀUtéÓ§NÀ±žŸ KAÀ8£Ek”Ñ€@?Šãœ¹@R 0`ÀUtéÓ§NÀ±› IÌÊÀ8–vfäQå€@>õ6GþS@RN:téÓÀUtéÓ§NÀ±~ +ÍnµÀ8‡ry–n€@>^íÄpF¡@Rš4hÑ£FÀUtéÓ§NÀ±a +}CµÀ8vS—-€@=ÊÕ-€L@Rå˗.\¹ÀUtéÓ§NÀ±DB±ÄŽoÀ8c3}á‚߀@=8ó+pçZ@S1bŋ,ÀUtéÓ§NÀ±'}JÌÀ8N+b6h׀@<©ML¬™@S|ùóçϟÀUtéÓ§NÀ± +Ù^nBÎÀ87Sé€k$€@<è Ìvª@Sȑ"D‰ÀUtéÓ§NÀ°îMª™Ó<À8Å%Ãy€@;ÆåðV·@T(P¡B…ÀUtéÓ§NÀ°ÑßïîjnÀ8–Ž_ó)€@;ìa [@T_¿~ýû÷ÀUtéÓ§NÀ°µ’ ü[ÙÀ7èß.{€@:ZP×@T«V­ZµjÀUtéÓ§NÀ°™eÁ¯=œÀ7ËŽÂÚ÷C€@9ýÜa<ý@TöíÛ·nÝÀUtéÓ§NÀ°}\º +?‹À7­-lŸ&€@9{—é•@UB… +(PÀUtéÓ§NÀ°ax„æËÀ7]ý;†-€@8ûXƒë”Z@UŽ8páÃÀUtéÓ§NÀ°Eº™ŽË5À7lZÎ29Ȁ@8}çπ@UÙ³f͛6ÀUtéÓ§NÀ°*$X;ýÊÀ7J7•å€@8º@Ñpé@V%J•*T©ÀUtéÓ§NÀ°· ]ŒTÀ7'cHр@7‰ÏÓÜù@VpáÇÀUtéÓ§NÀ¯æç¿­hTÀ7ÜŠoҊ€@7"Yç¥ý@VŒxñãǏÀUtéÓ§NÀ¯°·òÀ6ÝÉ(÷ñ<€@6ž°,ƒ£@W @ÀUtéÓ§NÀ¯zà» +dÀ6·Þ7¿â€@6,sÙ W@WS§N:tÀUtéÓ§NÀ¯Ed¬5=À6‘+ì×ê€@5ŒhÞ#9@WŸ>|ùóçÀUtéÓ§NÀ¯C‘ù£dÀ6ižŽÕ¿€@5N‰ÙÇq@WêÕ«V­ZÀUtéÓ§NÀ®Û~ŒvÀ6A±t(:•€@4âÑ%-_'@X6lÙ³fÍÀUtéÓ§NÀ®§ÎGX!À6Ñ¡€@4y8À0(@X‚ @ÀUtéÓ§NÀ®sQ_éOÀ5ïÑ· º(€@4ºY®Ñ«@X͛6lÙ³ÀUtéÓ§NÀ®?zÃ7šðÀ5ÆÂYØã€@3¬OWaA@Y2dɓ&ÀUtéÓ§NÀ® =<þցÀ5›û0ZÛŠ€@3HðÞa[’@Ydɓ&L™ÀUtéÓ§NÀ­Ùc¹ü² À5qsc¹Ü +€@2ç—Ø“Ö@Y°`Áƒ ÀUtéÓ§NÀ­ŠïÞ">À5F“2… q€@2ˆ<ý_Ö·@Yû÷ïß¿~ÀUtéÓ§NÀ­tàü…çÀ5eê"wǀ@2*Ø×F› @ZGûÿ +À4)A€@%­E£Þn@a @ÀUtéÓ§NÀšžÈóýlõÀ0dèJG–“€@%'ý›ŒHÍ@aF 0`ÁÀUtéÓ§NÀšx>nÃÀ0<Ìéµ}³€@$Ă»%5¹@akׯ^œ{ÀUtéÓ§NÀšRE©ÝÉÀ0òÎO€@$c/ä+;@a‘£F4ÀUtéÓ§NÀš,CŽÊßÍÀ/ÛõÍn€@$ø“9á@a·nÝ»víÀUtéÓ§NÀšÑôL¢¶À/ŒÞ,lx€@#ŠÐóE|@aÝ:téÓ§ÀUtéÓ§NÀ§áŒ9ž–À/?JèCH*€@#K«§•œ@b 0`ÀUtéÓ§NÀ§œž-íÀ.òf&®[Ž€@"ò~6êæ@b(Ñ£FÀUtéÓ§NÀ§˜¡ A[À.Š1¥ o€@"›<Ÿ€Õ°@bN:téÓÀUtéÓ§NÀ§t›!ìaÀ.Z®è«€@"Eې:G@bthÑ£FÀUtéÓ§NÀ§PígîÛÀ.ß9ـ@!òOñBÕ£@bš4hÑ£FÀUtéÓ§NÀ§-— TOÀ-ÅîV/‡€@! ŽçN\ö@bÀÀUtéÓ§NÀ§ +˜õ±À-|],q€@!PÒ;íõÀU @À¶æß%åà€€@P{Í')vÜ?òå˗.\¹ÀU @À¶æ\ÈQ¿ë—-Óšqö€@Pz¹ÌAÍ`@å˗.\¹ÀU @À¶äÕâPò\¿û‘Ï¥_¶ß€@Pw€o@ X±bŋÀU @À¶âK äš&ÀŠšðx(Հ@Pr#)IË{@å˗.\¹ÀU @À¶ÞœAšsaÀ |h{ùŒ€@Pj¥wL+@Ÿ>|ùóçÀU @À¶Ú-ãÂŒŒÀ#Äjàj€@Pa 31\ñ@X±bŋÀU @À¶ÔžŽeÑŽÀ‚»íؕ[€@PU]VÖK@ ‰$H‘"ÀU @À¶ÎÕê“jÀÙØÎ“Æ€@PG¡!j@"å˗.\¹ÀU @À¶Æ‰È†ÖUÀ'áv&üŠ€@P7ßSÙ£M@%B… +(PÀU @À¶Ÿ g€pÛÀk¥î… +0€@P&"sÂÕŒ@'Ÿ>|ùóçÀU @À¶Ž“æÞ;sÀ ҂RE¥€@Pu}Œ‹W@)û÷ïß¿~ÀU @À¶ª,ΚúÀ"g쮅b<€@OùÉD¿v@,X±bŋÀU @À¶ž×ø®»²À#÷7÷€@OÊú/«8î@.µjÕ«V­ÀU @À¶’™‹ãÃ"À%~î±N2é€@O˜š hË@0‰$H‘"ÀU @À¶…uø\zÿÀ&ÿ ð‚›€@ObÆêfG@1·nÝ»víÀU @À¶wqòìY À(vû\‚"û€@O) i~zŽ@2å˗.\¹ÀU @À¶h’p•Ü}À)æQ9튀@NíG•â@4(P¡B…ÀU @À¶XÜ¡ÔæÀ+L­äE|€@N­Þ¹a-I@5B… +(PÀU @À¶HUíÅÒÀ,©žùƒf€@Nk‰0 ñ‹@6páÇÀU @À¶7íD&™À-ý"wM™W€@N&k;÷1@7Ÿ>|ùóçÀU @À¶$ìeêÍÓÀ/F¢Éëõb€@MÞ©Ù—0@8͛6lÙ³ÀU @À¶E¬ßÀ0BýfªÊö€@M”j‘¶ú @9û÷ïß¿~ÀU @Àµþ„› ] +À0Ýyàly¢€@MGÓSžH@;*T©R¥JÀU @Àµê@•ÉÝ&À1r¯–þl€@Lù +G>V@µjÕ«V­ÀU @Àµ©Š™‹xÀ3üW«±€@L@*î@?ãǏ|ùóçÀU @ÀŽ>À³ŒÉðÀ8!.·›E€@Fø /§s@H6lÙ³fÍÀU @ÀŽ" séè„À8WL•²y€@F™L£4X@H͛6lÙ³ÀU @ÀŽBì(@”À8ˆàž5ڀ@F:ÂPžd@Idɓ&L™ÀU @À³è-…yJðÀ8µí0›†€@E܂;Yjî@Iû÷ïß¿~ÀU @À³Ê凹÷À8ސ­–JK€@E~¡?ˆš@J“&L™2dÀU @À³­pó“¹À9èû%›W€@E!3éå)@K*T©R¥JÀU @À³Ò/ÎuÀ9#kõ\\€@DÄJS…Ô!@KÁƒ 0ÀU @À³r³Ã¡æÀ9?1õ¢ €@DgøxÔ_G@LX±bŋÀU @À³T0X;–À9W`çæ%€@D Mí„-¶@Lïß¿~ýûÀU @À³65¯zàÀ9kÀÓEã€@C±Z Ÿ­0@M‡8páÀU @À³%&d#jÀ9|qoîŸz€@CW+%Òºn@NбJü)å@Rš4hÑ£FÀU @À±\)Uš¢À9=ËÌ+–€@>7C.³‹!@Rå˗.\¹ÀU @À±r™ £8›À9'— —€@= 8oM~z@S1bŋ,ÀU @À±TñJ·QlÀ9sÿù‚6€@= •å¡iî@S|ùóçϟÀU @À±7g$ØC±À8õ|Ó Ù€@|ùóçÀU @À¯S¡™yBÀ6ü¥vÎu1€@5xqw€BÓ@WêÕ«V­ZÀU @À¯‡…“7^À6ѧˆrf€@5 +pnÚ@X6lÙ³fÍÀU @À®çÓ®­ôÀ6Šm+4€@4Ú'@X‚ @ÀU @À®²‡X&0>À6zã9Ҁ@43ãlŒtË@X͛6lÙ³ÀU @À®}£€6£À6Mƒ^’EO€@3̰²ò‚@Y2dɓ&ÀU @À®I)•sÀ6 ˜…®m€@3fxŒFÒç@Ydɓ&L™ÀU @À®P÷uÀ5óRÎً'€@3õU€·@Y°`Áƒ ÀU @À­áuÖUíîÀ5Å¿Û~«€@2¡‡þt@Yû÷ïß¿~ÀU @À­®=—©Œ$À5—éޘT΀@2B)¯DÆ@ZG€@0‚øoç@[Áƒ 0ÀU @À¬ƒàqaXÀ4³šßW€@0/:.Œv@\ 4hÑ£ÀU @À¬SŠ“5­À4S"W,Ž€@/ºšÔõÐ@\X±bŋÀU @À¬#Ú¯SŠÀ4$˜¯$€@/R«vTó@\€H‘"DˆÀU @À«ô|«x!HÀ3öúšý̀@.}ŠP3Ÿ8@\ïß¿~ýûÀU @À«ÅŒ^õSÀ3ǎóR€@-ä0A+ÐŒ@];víÛ·nÀU @À«— ªê€À3™h¿®¬4€@-N3f¿í@]‡8páÀU @À«hóùAWÀ3k=ßn‡¥€@,»n€Á§@]Ò¥J•*TÀU @À«;KCÇ¿kÀ3=9·,4~€@,, +&H®@^ëÙOÙÀ2áº&Ï +€@+†˜Sy@^µjÕ«V­ÀU @ÀªŽÚbÛ­~À2ŽH9j²€@*X¶ ¹ƒ@_ ÀU @Àªˆàð=ùiÀ2‡ŽÓš€@* !ü!ã@_L™2dɒÀU @Àª]RíŽÀ2Zx8þ€@)ŒÒ¡’U@_˜0`ÁƒÀU @Àª2-ØLÀ2-\TxG(€@)X瀕@_ãǏ)@`®Ý»víÛÀU @À©7ۉ…¢+À1'AlÞE€@&VµÈY@`Ô©R¥J•ÀU @À©‰RcžŒÀ0üuÅGDb€@%ëk +fŽ·@`útéÓ§NÀU @Àšç›]—á}À0Ò/¹žÌ€€@%‚^œÄ@a @ÀU @ÀšÀÜá„À0šA×q׀@%äÿ:@aF 0`ÁÀU @Àš˜èþ"ŽÀ0~­ªpí€@$·‹’kWé@akׯ^œ{ÀU @Àšr"ëÄôÀ0Ut–xœ€@$Uhk ž4@a‘£F4ÀU @ÀšKœÍZÀ0,—Ùs§€@#õm‚ôŽ @a·nÝ»víÀU @Àš%žÆ²j×À0‰³v?€@#—þ¡îÐ@aÝ:téÓ§ÀU @Àšú±ÛÏÀ/·ï:©í’€@#;œE±rÑ@b 0`ÀU @À§Úˉ"À/hkÏò„Á€@"áï¡T @b(Ñ£FÀU @À§µá<ƒsÀ/š=tE€@"Š"h(U@bN:téÓÀU @À§‘T,²Ê\À.Ë¥°Ö<*€@"4)Óø·J@bthÑ£FÀU @À§m"yñl +À.~e# +<£€@!à‡±-x@bš4hÑ£FÀU @À§IK’_®ŽÀ.1çW¬€@!àî·ÕI@bÀÀU @À§%Ώ›[9À-æ,ß5݀@!=núGSÂÀT«V­ZµkÀ·PŒTg€€@QçctŒa?òå˗.\¹ÀT«V­ZµkÀ·Pò¥¿íY’I.€€@QÀWp@å˗.\¹ÀT«V­ZµkÀ·NgCƒã¿ý }:IÉ €@QJæ‡nÔ@ X±bŋÀT«V­ZµkÀ·K¹-À·ÀÂÍÍ-óҀ@QŠ5bi‹@å˗.\¹ÀT«V­ZµkÀ·Gú…>» À ö°’΀@QÞw±¢@Ÿ>|ùóçÀT«V­ZµkÀ·C,Š€ŽÀìéÏ6€@Pö7EgÓ@X±bŋÀT«V­ZµkÀ·=Q'ªŠ©À›—_ +K<€@Pé±Iýò;@ ‰$H‘"ÀT«V­ZµkÀ·6j±õø@À°|lK΀@PÚø<šð @"å˗.\¹ÀT«V­ZµkÀ·.{ãG6“À™âý o€@PÊÒš|@%B… +(PÀT«V­ZµkÀ·%‡Ù OÀ r9ãJ‡€@P·Ôq1@'Ÿ>|ùóçÀT«V­ZµkÀ·’°‰À!µ»K0ǀ€@P¢Yû¢@)û÷ïß¿~ÀT«V­ZµkÀ·žcc§cÀ#`481Pè€@PŠëù˜®@,X±bŋÀT«V­ZµkÀ·±uQkÀ%Kœä€@PqÞñP@.µjÕ«V­ÀT«V­ZµkÀ¶÷Îr±Ë\À&žxL®Á$€@PVê²ê¯@0‰$H‘"ÀT«V­ZµkÀ¶éû‡¿>ÇÀ(18zq[R€@P:!ÏñS•@1·nÝ»víÀT«V­ZµkÀ¶Û=_2sÀ)»^SE”€@P”ÑÌ;ñ@2å˗.\¹ÀT«V­ZµkÀ¶Ë™[pmÀ+;˜á[ž€€@Oö«ëŸ¢ @4(P¡B…ÀT«V­ZµkÀ¶»dq'À,²aü”V€@O²ðH_“”@5B… +(PÀT«V­ZµkÀ¶©¶„ =À.æß•€@Ol­õ¬ô@6páÇÀT«V­ZµkÀ¶—ƒ>RýÀ/Q26ø*€@O"\úë@7Ÿ>|ùóçÀT«V­ZµkÀ¶„‚©›,À0lkìA(ۀ@NÕÓé‡*%@8͛6lÙ³ÀT«V­ZµkÀ¶pºZhÀ1±œ9ŒÛ€@N†®—à‡Ž@9û÷ïß¿~ÀT«V­ZµkÀ¶\0d㝌À1³]{k€@N5žA€z@;*T©R¥JÀT«V­ZµkÀ¶FìLýþkÀ2NX þSï€@Má2œã@µjÕ«V­ÀT«V­ZµkÀ¶–ÐõÀ3üvÕØã/€@LÙoVû¥@?ãǏ€@Kcvi&@BN:téÓÀT«V­ZµkÀµ…Ì_‰QÀ6S¿àlƔ€@K¬k@Bå˗.\¹ÀT«V­ZµkÀµk0~¥ãÀ6ºÏª¶€@J¡®#v™@C|ùóçϟÀT«V­ZµkÀµP)ë®àÀ7¥3~€J€@J?˜_ºî—@D(P¡B…ÀT«V­ZµkÀµ4Ÿ ŸžšÀ7uŠÙ¥Nƒ€@IÜå<ž6)@D«V­ZµjÀT«V­ZµkÀµ· €švÀ7ÊÕÿjœ0€@Iy¶(.T@EB… +(PÀT«V­ZµkÀŽümÌùÀ8œƟЀ@I*Ê[Q@EÙ³f͛6ÀT«V­ZµkÀŽßÈñW¿ÖÀ8dô³>hր@H²bPÐg@FpáÇÀT«V­ZµkÀŽÂÏ»¬,VÀ8©ùi€@HNzK·}@G @ÀT«V­ZµkÀŽ¥ˆQebÀ8éÄ#ýç&€@GêŽP1\@GŸ>|ùóçÀT«V­ZµkÀއø(æÀ9$rO¯k€@G†¹Øò„;@H6lÙ³fÍÀT«V­ZµkÀŽj%Ý©ûxÀ9Z!3”@€€@G#D @H͛6lÙ³ÀT«V­ZµkÀŽL؉8À9Šï‹[@€@F¿»ÖU:‹@Idɓ&L™ÀT«V­ZµkÀŽ-Ñn¹.ƒÀ9¶üÿ4€@F\ÁŠp|@Iû÷ïß¿~ÀT«V­ZµkÀŽZ„zâXÀ9Þj1ÁN€@Eú=êŒ @J“&L™2dÀT«V­ZµkÀ³ð·Ž–XÀ:W¥H‡‘€@E˜Bî ÿ@K*T©R¥JÀT«V­ZµkÀ³Ñî;·ÓÀ:爿X÷€@E6æNl‚%@KÁƒ 0ÀT«V­ZµkÀ³³,5·ˆÀ::;©ÙúQ€@DÖ9>ߊö@LX±bŋÀT«V­ZµkÀ³“ûs­cáÀ:PvOñø…€@DvL›#—@Lïß¿~ýûÀT«V­ZµkÀ³tÛÔºöÀ:b¹ïÈ¥N€@D0HuÁ@M‡8páÀT«V­ZµkÀ³UšéÐc1À:q)Ô$|€@Cžò\Ÿ¡Ä@N˜@NµjÕ«V­ÀT«V­ZµkÀ³ÎRìÀ:ƒèªáá€@BÿH/q@OL™2dɒÀT«V­ZµkÀ²÷È_«À:†ÔQœ7€@B£ó·ŒŠ!@OãǏ€@@D£ Ÿò@R 0`ÀT«V­ZµkÀ±þœuÀ:9!É!Lˆ€@?ä.wg!À@RN:téÓÀT«V­ZµkÀ±ß2¬–TÀ:#×>ÿI€@?B¯ó8Ú@Rš4hÑ£FÀT«V­ZµkÀ±ÀGK5À: `-ZÑ׀@>£œ•rƒ>@Rå˗.\¹ÀT«V­ZµkÀ±¡‘ðÕR‘À9òÙv…í€@>_AšWÔ@S1bŋ,ÀT«V­ZµkÀ±‚ü_f¿À9×_n©ÂŒ€@=m˜ë™4@S|ùóçϟÀT«V­ZµkÀ±d‡Í3èÔÀ9º }z¶¬€@<Ök j€W@Sȑ"D‰ÀT«V­ZµkÀ±F7,ÅP9À9šþs>€@¶ñ€@7úÝn¶.Û@VpáÇÀT«V­ZµkÀ°<Éû8À8ExÁ[‰€@7}‚ ÓÇX@VŒxñãǏÀT«V­ZµkÀ° 9G4<À8®šosK€@7ŠëÛ}@W @ÀT«V­ZµkÀ°Ü<ÄÝ,À7íŽÀ^P€@6ŠC鹖œ@WS§N:tÀT«V­ZµkÀ¯Ïg}$š²À7À¶…1*ڀ@6RÆ\¯þ@WŸ>|ùóçÀT«V­ZµkÀ¯—5!ÕÅÀ7“8óóËF€@5 ÌB“¹ @WêÕ«V­ZÀT«V­ZµkÀ¯`^ÓWÀ7e%›ªŒ€@5/šÁÐOÌ@X6lÙ³fÍÀT«V­ZµkÀ¯(úp)ÎÀ76ŒxP€@4ÀàS[J@X‚ @ÀT«V­ZµkÀ®ò\mãŠcÀ7{Bÿ€@4Tj»ƒ‡@X͛6lÙ³ÀT«V­ZµkÀ®Œ.˜ÊÍÀ6ØZJæ€@3ê?|f^ú@Y2dɓ&ÀT«V­ZµkÀ®†pXÂáÀ6š+M@ZÞœzõë×ÀT«V­ZµkÀ­M^<èéÀ5„MÛØŸ‘€@1@A’€J@[*T©R¥JÀT«V­ZµkÀ­ÄnŽíÀ5S>ê„#·€@0ç!ˁ-¥@[uëׯ^œÀT«V­ZµkÀ¬èžŠ=œfÀ5"+JL/ž€@0ù™'@[Áƒ 0ÀT«V­ZµkÀ¬¶ìòO)À4ñnªFi€@0:Ÿë.Ý@\ 4hÑ£ÀT«V­ZµkÀ¬…®pCpŠÀ4ÀQ4Š€@/ÎÐaçÏ @\X±bŋÀT«V­ZµkÀ¬Tä}]fÀ4&u"À@/+×Ã6¬Ð@\€H‘"DˆÀT«V­ZµkÀ¬$²qÑÀ4^OíÁҀ@.Œ€ûFʵ@\ïß¿~ýûÀT«V­ZµkÀ«ô©]ÀôÀ4-š`ñ̯€@-ð¹%R@];víÛ·nÀT«V­ZµkÀ«Å8‰N¬úÀ3ý fb€@-Xm|ì~˜@]‡8páÀT«V­ZµkÀ«–:3ºPlÀ3̪ɜV€@,Ëc—¯š@]Ò¥J•*TÀT«V­ZµkÀ«g­ìûäÀ3œ|¬ª4€@,2eÒÑß@^€@$EA„ŸŠ¯@a‘£F4ÀT«V­ZµkÀšk+™ +nÀ0l4ðä;€@#䎕»p@a·nÝ»víÀT«V­ZµkÀšD…ÇOÀ0B4š°+s€@#†W ñ@aÝ:téÓ§ÀT«V­ZµkÀšNäžgÀ0Žâ"’Ÿ€@#)‘Þ(ô^@b 0`ÀT«V­ZµkÀ§øyŽ+À/ß9a€øZ€@"Ï. ]ô@b(Ñ£FÀT«V­ZµkÀ§ÓÐ#HnÀ/Ù µ €@"vË2š4@bN:téÓÀT«V­ZµkÀ§­ï³ì=À/=IÄ$èJ€@" \zžË£@bthÑ£FÀT«V­ZµkÀ§‰9BH)ÉÀ.í‹úó€@!ËÖad&@bš4hÑ£FÀT«V­ZµkÀ§dàƒsä”À.ž &ûE_€@!y,öÀn@bÀÀT«V­ZµkÀ§@äK ÅÀ.P†„Æ…€@!(T¹4@DÀTF4hÒÀ·Ÿ +ã ÿð€€@Q²76®Ä7?òå˗.\¹ÀTF4hÒÀ·œyù#WL¿î«VʝB€@Q°úŸ…Í@å˗.\¹ÀTF4hÒÀ·»ÇwŸ—=¿þ€î!$蚀@Q­CÂæ@ X±bŋÀTF4hÒÀ·žôh—rÀó³ƒìó€@Q§@g\@å˗.\¹ÀTF4hÒÀ·µü€æÀ‹aì‡C؀@Qžvt»gI@Ÿ>|ùóçÀTF4hÒÀ·¯ïÔ¥œjÀ 2#/$€@Q“j,Жš@X±bŋÀTF4hÒÀ·©Âµcè:ÀÈ՜A}€@Q…ù +ÀO@ ‰$H‘"ÀTF4hÒÀ·¢|*4dvÀ}Á¡€@Qv,WàŠ@"å˗.\¹ÀTF4hÒÀ·š-Üm9À&“£N¿€@QdÔ°+ @%B… +(PÀTF4hÒÀ·¯'.À áéIÍ,G€@QO¬ÉùáS@'Ÿ>|ùóçÀTF4hÒÀ·†/å5Ž‘À"©¿Ê'—<€@Q9ä6-ù@)û÷ïß¿~ÀTF4hÒÀ·z¥šôÖªÀ$j'ÙҔ’€@Q S"jt@,X±bŋÀTF4hÒÀ·nÚÈíÀ&"„!Òåè€@Qz¿OZ.@.µjÕ«V­ÀTF4hÒÀ·`‚‘oeœÀ'Ò?ùhz)€@Pèœ :@0‰$H‘"ÀTF4hÒÀ·QôÁpÖÀ)xÏàò¢‹€@PÉɗŽžå@1·nÝ»víÀTF4hÒÀ·Bnº+SÀ+±êÊ-‡€@P©‘ø^m@2å˗.\¹ÀTF4hÒÀ·1ø˜éð1À,šn^ÿ€@P†—2ù%¶@4(P¡B…ÀTF4hÒÀ· —Œ¬.À.0–{(v +€@Pb`\Œ4o@5B… +(PÀTF4hÒÀ·R€ŒSIÀ/­Ç©g¢€@P<‡Œlh @6páÇÀTF4hÒÀ¶û/{`ð›À0ÔK7{N€@P"œ¥}Ò@7Ÿ>|ùóçÀTF4hÒÀ¶ç5r"®À1Bõ_‚sñ€@Oؐœ"ì@8͛6lÙ³ÀTF4hÒÀ¶ÒkV\€qÀ1ð% |؀@O„Âæ4ª@9û÷ïß¿~ÀTF4hÒÀ¶ŒØ>…IÀ2—F:é€@O-Œ¹qY@;*T©R¥JÀTF4hÒÀ¶Šƒ`SÓÀ38AûïAJ€@NÓŽ·ž$@µjÕ«V­ÀTF4hÒÀ¶_C‰Ÿ¯€À4õ­\d-œ€@MºúÅàØÏ@?ãǏœ˜ŠÀ6z9…1š€@L“@A·nÝ»víÀTF4hÒÀµ÷l!ýÓBÀ6ï ü{* +€@L-éÕ¹E2@BN:téÓÀTF4hÒÀµÜÄcR“À7]Épダ@KDZ]Øg @Bå˗.\¹ÀTF4hÒÀµÀ<]e؂À7ÆA.}2€@K`›®•Ç@C|ùóçϟÀTF4hÒÀµ£í2Å^À8(™+~€@JøÍ?€íä@D(P¡B…ÀTF4hÒÀµ‡-t1áÀ8„æÃ;#M€@Jl±b>@D«V­ZµjÀTF4hÒÀµj7yLÀ8Û?ƒnSԀ@J'œï)t1@EB… +(PÀTF4hÒÀµLxvôIkÀ9+ŒÞœwú€@IŸ€S^ +@EÙ³f͛6ÀTF4hÒÀµ.‘ ›…*À9vyøuO;€@IU6²¯?@FpáÇÀTF4hÒÀµTµ^z¿À9»“Ïû¡€@Hëà +}¬@G @ÀTF4hÒÀŽñÊð­³À9û)ršQ€@H‚™ÌÊ¥Ä@GŸ>|ùóçÀTF4hÒÀŽÒ÷mùΊÀ:5Y©ožG€@H€9ÚL +@H6lÙ³fÍÀTF4hÒÀ޳ã;‚P~À:jG ™š©€@G°®k‹@H͛6lÙ³ÀTF4hÒÀŽ”“™ÌÀ:š†«G€@GH<Ҍ¬w@Idɓ&L™ÀTF4hÒÀŽug.ØäÀ:ÄâX')F€@FàDHƞŠ@Iû÷ïß¿~ÀTF4hÒÀŽUY#¬À:ê×w‰mã€@FxÛ +g£@J“&L™2dÀTF4hÒÀŽ5z­€@RÀ; oN/ €@FC¶ÜÃ@K*T©R¥JÀTF4hÒÀŽw;â?ÐÀ;(Ç:R©‡€@E¬ Êïò@KÁƒ 0ÀTF4hÒÀ³õTˆ À;A #@Ë%€@EFÈ(Ë@LX±bŋÀTF4hÒÀ³Õ³zȂÀ;U Š™èr€@Dâb¡ƒžÅ@Lïß¿~ýûÀTF4hÒÀ³ŽÅ³|S°À;dëWv/€@D~é>4‚K@M‡8páÀTF4hÒÀ³”cPãSÀ;pÐÄj@DjÖuïÆ@Njï€@BûTØEÛ@OãǏn9QbT@S1bŋ,ÀTF4hÒÀ±± èm„¶À:Š!:^í€@=Ï%PXk@S|ùóçϟÀTF4hÒÀ±’:‰ÎmœÀ:…5»H÷€@=2Üjü\_@Sȑ"D‰ÀTF4hÒÀ±rü¬À:bŒ<Ş€@<™_hMÑ@T(P¡B…ÀTF4hÒÀ±S珫À:>?œÇèB€@<®çcÒ@T_¿~ýû÷ÀTF4hÒÀ±4þÐ]3”À:k*}`"€@;nÆôˆ© @T«V­ZµjÀTF4hÒÀ±C›%À9ñ(|qœ€@:Ýš7øB@TöíÛ·nÝÀTF4hÒÀ°÷·‘_6ùÀ9ȏY¢š}€@:ONG÷$&@UB… +(PÀTF4hÒÀ°Ù\8mÍÀ9žžù_cP€@9õÁa\@UŽ8páÃÀTF4hÒÀ°»2ù¬3À9sŒyh€@9:Ùá°ú¶@UÙ³f͛6ÀTF4hÒÀ°=#ŠÝÀ9G¯Gv"€@8޵_ŸâÀ@V%J•*T©ÀTF4hÒÀ°{ë¹À9§t²Ï €@81BM®®c@VpáÇÀTF4hÒÀ°aðk‘ÚTÀ8ì¹Ì®qـ@7°z)@ U@VŒxñãǏÀTF4hÒÀ°D›©p±À8œùÿe ”€@72Uçd@W @ÀTF4hÒÀ°'~ïAÀ8Ž{³i(€@6¶Íÿ}Ÿø@WS§N:tÀTF4hÒÀ° +™øÁøÎÀ8^OU[¥|€@6=ÚzXëµ@WŸ>|ùóçÀTF4hÒÀ¯ÛÝFí*ËÀ8-ˆ0“@€@5Çrú@WêÕ«V­ZÀTF4hÒÀ¯¢ú|‰Z À7ü6]»‘U€@5SŽÆ©þ\@X6lÙ³fÍÀTF4hÒÀ¯jŒÆñŠ\À7ÊiѲÊI€@4â$׀ßQ@X‚ @ÀTF4hÒÀ¯2•5ì1uÀ7˜1Åîc€@4s+Ýiž@X͛6lÙ³ÀTF4hÒÀ®û¶!FŠÀ7eœœa{€@4šK£ËÞ@Y2dɓ&ÀTF4hÒÀ®Ä Å¥ÚÀ72ž‡Ór¥€@3œf`޵™@Ydɓ&L™ÀTF4hÒÀ®{÷9VÀ6ÿ’I]-€@34†-ÕK^@Y°`Áƒ ÀTF4hÒÀ®WdðœH|À6Ì6|°=Հ@2Îï °@Yû÷ïß¿~ÀTF4hÒÀ®!ÇoSÍ À6˜°ùrjL€@2k˜†6^*@ZG|ùóçÀSáÇ8Àž ¬+Uô–À+Y…Ä €@R9;‡áþ@X±bŋÀSáÇ8Àž'Aõ8‡À Z_È*ۀ@R*È¿·Ì@ ‰$H‘"ÀSáÇ8Àžy‰;^}ÀôK,1g+€@RΣ£o0@"å˗.\¹ÀSáÇ8Àž ŠC°ŽÀÐgËûåp€@RXžÔ[G@%B… +(PÀSáÇ8À·ÿ±)º†UÀ!ϔ@€@Qðt§¬@'Ÿ>|ùóçÀSáÇ8À·ôžeEwçÀ#¯‰ÏòU|€@QØ1Š–Øs@)û÷ïß¿~ÀSáÇ8À·èrŒøõžÀ%‡aM–M݀@Qœ b\@,X±bŋÀSáÇ8À·Û2žýÖ À'Voìr䮀@Q Òâo"n@.µjÕ«V­ÀSáÇ8À·ÌãûbðÊÀ)Ø kþ€@QÜp‡‘@0‰$H‘"ÀSáÇ8À·œŒ^+$®À*×¹¯«Ô€@Q`Ñx®Q@1·nÝ»víÀSáÇ8À·­1ÙÏÀ,ˆÒZL¶è€@Q=Çi4ÆR@2å˗.\¹ÀSáÇ8À·›ÚÍ (ÍÀ..Þ€ÕM`€@Qԙj’2@4(P¡B…ÀSáÇ8À·‰ã¢RÌÀ/ÉiJˋ&€@Pò#*@5B… +(PÀSáÇ8À·vRTÀ0¬dVs§€@PɑÅðN@6páÇÀSáÇ8À·b.`£U«À1m/¬ÓҀ@PŸqÂo?Ú@7Ÿ>|ùóçÀSáÇ8À·M*G8o‡À2( {xÛc€@PsÈÁÖùs@8͛6lÙ³ÀSáÇ8À·7MBßõDÀ2Üz«z°<€@PF¯­ëê{@9û÷ïß¿~ÀSáÇ8À· Ÿ³„ÂÀ3ŠYÝÏ£ž€@P?–Nìå@;*T©R¥JÀSáÇ8À· 'MbÀ41“a-à[€@OÑ##Šï.@µjÕ«V­ÀSáÇ8À¶ŸVÒNµÀ5þÂ͓Ë€@NŠõ\qð@?ãǏœæ ,@@‰$H‘"ÀSáÇ8À¶‰x2ÑÀ79#ú~Ȁ@MÕÚdöWD@A @ÀSáÇ8À¶m“UÊ£À7ŽÇ ‡C4€@MkÅÏäp@A·nÝ»víÀSáÇ8À¶Qv0¶¶ÞÀ8˜²ÓÑñ€@M5*°8i@BN:téÓÀSáÇ8À¶4Ò<ÃO4À8wŒiŠên€@L“Ìg@Bå˗.\¹ÀSáÇ8À¶¬ãWÀ8âD +fº€@L&éË×@C|ùóçϟÀSáÇ8Àµú ïtPÀ9FD°”è€@Kž¢€Ö™@D(P¡B…ÀSáÇ8ÀµÛû!ÅœÀ9£Öjv$€@KJ2ܰ@D«V­ZµjÀSáÇ8Àµœ~L>uÀ9ûö)œð€@JÛaÔ"Ýt@EB… +(PÀSáÇ8Àµžž=[\QÀ:Luuɀ@JlVP%5@EÙ³f͛6ÀSáÇ8ÀµbsÀ:— *|-&€@Iý2֞Õ@FpáÇÀSáÇ8Àµ_Ðù¶àÀ:Ü9¥}׀@IŽèœs@G @ÀSáÇ8Àµ?ñÚã¿€À;%h1ý{€@I!75©@GŸ>|ùóçÀSáÇ8Àµˇ̱ À;T™ãÙÈ €@H°q˜rgG@H6lÙ³fÍÀSáÇ8ÀŽÿd¡Ï…hÀ;ˆ„ ð‹ª€@HB#ß]æ@H͛6lÙ³ÀSáÇ8ÀŽÞ݊ÀxÀ;· +> ò÷€@GÔP/Ñÿ@Idɓ&L™ÀSáÇ8ÀŽœîÁÌÄ|À;àS©×]€@Gg}õžÅ@Iû÷ïß¿~ÀSáÇ8Àޜì&žysÀ<ˆÁý‚€@Fú~€Fˆæ@J“&L™2dÀSáÇ8ÀŽ{ÁµHäÀ<#ÏîÁI9€@FŽ­a;¬"@K*T©R¥JÀSáÇ8ÀŽZu%ðx¹À<>SŒ›Pô€@F#²Æœ@KÁƒ 0ÀSáÇ8ÀŽ9 (”À@P‰$H‘"ÀSáÇ8À³ +‡ªdÏÀ=Q€@B1©µ@U@PÔ©R¥J•ÀSáÇ8À²éÜñDÓÀԔî¶$@S1bŋ,ÀSáÇ8À±àÞÛ/¡1À;{í+aV߀@>0 +¹jä?@S|ùóçϟÀSáÇ8À±À~@ ê1À;W%jŒ¥®€@=Ž€€­–Á@Sȑ"D‰ÀSáÇ8À± J òOÀ;0ŸüÊÀ@<ïõgƒ—@T(P¡B…ÀSáÇ8À±€Dxr:À;y¢¶ž €@|ùóçÀSáÇ8À°X5xÀ8˜øûú*€@5ì<:o!?@WêÕ«V­ZÀSáÇ8À¯æ\ݎÔÀ8–áTg€@5u‘(ËP@X6lÙ³fÍÀSáÇ8À¯¬„îŠÝ÷À8ažRy\€@5€ÇJw@X‚ @ÀSáÇ8À¯s+Þ‡OÀ8,0úm£4€@4Ÿµe,@X͛6lÙ³ÀSáÇ8À¯:Q U#ÕÀ7öZDJ €@4!ä0[@Y2dɓ&ÀSáÇ8À¯ößþWŸÀ7ÀB=­V€@3ކ …dã@Ydɓ&L™ÀSáÇ8À®Ê'z €À7‰ö;ó €@3Ju¹Ñ…K@Y°`Áƒ ÀSáÇ8À®’Á₁#À7S‚âlò€@2âÉçvŽ?@Yû÷ïß¿~ÀSáÇ8À®[è_ÅšÀ7ô'mû?€@2}wYɱ@ZG@^iÓ§N:ÀSáÇ8À«a<$?ZÀ3öxï™ØÂ€@+0׏@@^µjÕ«V­ÀSáÇ8À«2QT{jFÀ3ÂœÅøO€@*‡ÚŠ¡]@_ ÀSáÇ8À«àIš˜&À3`rRe€@)ÿÄŸ7èú@_L™2dɒÀSáÇ8ÀªÕè!û©wÀ3\dgƒ€@)zۇWŸ¬@_˜0`ÁƒÀSáÇ8Àªšgóފ¥À3)Ì̗çI€@(ù ¿ÜN@_ãǏÄ÷€@S +„GC @å˗.\¹ÀS|ùóçϟÀž¢ÏñN:ÀãÜ\€@S7ØrÃ1@ X±bŋÀS|ùóçϟÀžŸšÊu©ÀÀ ›º>çë€@RÿbêÑí@å˗.\¹ÀS|ùóçϟÀž›@ÂÛÆÉÀ ‹QỀ@RõDí¥É@Ÿ>|ùóçÀS|ùóçϟÀž•™Óµ"À=ž,_G€@RèMõ'@X±bŋÀS|ùóçϟÀžŽ¶ɝKÀh@IŶ€@RØÁ÷öOP@ ‰$H‘"ÀS|ùóçϟÀž†™ÖLmyÀ‡šŒ)8€@RÆ}Ë^œV@"å˗.\¹ÀS|ùóçϟÀž}Gi)JÝÀ Í *º¥€@R±Ž×œ±ê@%B… +(PÀS|ùóçϟÀžrÃKŠ3À"ÎõY3w€@RšWŒLš@'Ÿ>|ùóçÀS|ùóçϟÀžg áp¢À$È¿X”V€@Rï>À‰š@)û÷ïß¿~ÀS|ùóçϟÀžZ8²dK¬À&¹§„å@Q€@RcbGz@,X±bŋÀS|ùóçϟÀžL<²8ëoÀ( ôœ^3(€@RDq >ÍQ@.µjÕ«V­ÀS|ùóçϟÀž=#ì”w~À*}ønGÑm€@R#1u4Çx@0‰$H‘"ÀS|ùóçϟÀž,ô¥ÚjÀ,PsfŠ€@QÿºžÉ@1·nÝ»víÀS|ùóçϟÀžµ{íÝ9À.ŠL/ œ€@QÚ"¥ƒ†?@2å˗.\¹ÀS|ùóçϟÀž mf,ÈÄÀ/Ñ0,[€@Q²ƒÿ—F?@4(P¡B…ÀS|ùóçϟÀ·ö#Š¢C‡À0¿˜’«Åñ€@Qˆ÷¶6 +@5B… +(PÀS|ùóçϟÀ·á߯hGÀ1®ô>€@Q]˜–‚r@6páÇÀS|ùóçϟÀ·Ì©‹#2À2Yó’5ä€@Q0å:öÒ@7Ÿ>|ùóçÀS|ùóçϟÀ·¶ˆóEPIÀ3ñÕ!€@QÊdž_B@8͛6lÙ³ÀS|ùóçϟÀ·Ÿ†*;;<À3ØòY¯Ø4€@Pѓ>Ö@9û÷ïß¿~ÀS|ùóçϟÀ·‡©‚ÂöìÀ4ÙO³C‰€@PŸõ`¿hï@;*T©R¥JÀS|ùóçϟÀ·nûnöONÀ5;Žƒb-€@Pm »^@µjÕ«V­ÀS|ùóçϟÀ· ^[æBÀ7ôn}€@O›HqBš@?ãǏ|ùóçÀS|ùóçϟÀµn²À<ƒ ‡“ €@IKŸR¬û@H6lÙ³fÍÀS|ùóçϟÀµL¶P›‡ À<µ©©™E܀@Hׁì2LØ@H͛6lÙ³ÀS|ùóçϟÀµ*²„_wÀ<✠7$€@Hcþ'#œ?@Idɓ&L™ÀS|ùóçϟÀµ|ãó|À= +p]ºç€@Gñ-Æ¢î6@Iû÷ïß¿~ÀS|ùóçϟÀŽæÒ0¹_À=,4"jh]€@G(âGê@J“&L™2dÀS|ùóçϟÀŽÃ•}²§ÆÀ=I11ý€@Gñ»Ý.@K*T©R¥JÀS|ùóçϟÀŽ ïà~™pÀ=a4ÿÞÚM€@FÙÙ|Î@KÁƒ 0ÀS|ùóçϟÀŽ~0¿ÕÞ­À=tlM@O€@F.·÷â:°@LX±bŋÀS|ùóçϟÀŽ[]¬/åÀ=ƒFœt'€@EÀ²2hiŸ@Lïß¿~ýûÀS|ùóçϟÀŽ8|R‡rÀ=(Ú¬)xž§@S|ùóçϟÀS|ùóçϟÀ±ïQ‚LüdÀ<0 EåúZ€@=é £ìŒp@Sȑ"D‰ÀS|ùóçϟÀ±Î£×ú3À<eäUS€@=Edÿ7Ã0@T(P¡B…ÀS|ùóçϟÀ±­!)öÀ;Ù"ßW€@<€Ûn ó @T_¿~ýû÷ÀS|ùóçϟÀ±ŒWÂ6Ö§À;«^î!á€@<€%@P@T«V­ZµjÀS|ùóçϟÀ±kÅKûDÀ;|5ìß଀@;mN; '×@TöíÛ·nÝÀS|ùóçϟÀ±KkKhXpÀ;KÄÀ‰où€@:Ö@õk@UB… +(PÀS|ùóçϟÀ±+K8'ˆCÀ;$šiÎӀ@:BOEÆÁp@UŽ8páÃÀS|ùóçϟÀ± fh;yÀ:çn»è€@9±tmw“@UÙ³f͛6ÀS|ùóçϟÀ°ëŸu-yÀ:³»¹ha€@9#§¡r«~@V%J•*T©ÀS|ùóçϟÀ°ÌS]ÈKÀ: Æï:ˀ@8˜àNsò@VpáÇÀS|ùóçϟÀ°­'DnÏžÀ:Iµþdb€@8K’ð•@VŒxñãǏÀS|ùóçϟÀ°Ž:³bÀ:æ»Ð±€@7Œ<ïü@W @ÀS|ùóçϟÀ°oŽzÖMÒÀ9ÜŸYêU€@7 +Mïú|@WS§N:tÀS|ùóçϟÀ°Q#U˜yÄÀ9¥aØ&qd€@6‹;8ér@WŸ>|ùóçÀS|ùóçϟÀ°2ùæÜ• À9m £Ù €@6ümÎW‘@WêÕ«V­ZÀS|ùóçϟÀ°Œí ÎÀ95-£Q6€@5•…}«@X6lÙ³fÍÀS|ùóçϟÀ¯îÜ£»¢À8ü|L_–€@5ÊêÞЂ@X‚ @ÀS|ùóçϟÀ¯Žð§À8Ã|B*8|€@4ªÀüšàW@X͛6lÙ³ÀS|ùóçϟÀ¯yރ#ûûÀ8Š;Ãûˆ7€@49[Éc›@Y2dɓ&ÀS|ùóçϟÀ¯@*hŠPÀ8PÉWuK€@3ʏ>C¹¶@Ydɓ&L™ÀS|ùóçϟÀ¯þ/$fÈÀ82{3ã€@3^O+2ž*@Y°`Áƒ ÀS|ùóçϟÀ®ÎZÎm9À7݃ñ’€@2ôGЀþ@Yû÷ïß¿~ÀS|ùóçϟÀ®–>^Ÿ€À7£Éą‘€@2C<š‚.@ZGœÐÀ6ƒï97*€@0«ßÊœ.p@[Áƒ 0ÀS|ùóçϟÀ­PÂÔ_žÀ6JÇm#Ò±€@0RGxµ¶@\ 4hÑ£ÀS|ùóçϟÀ­]‹ïËÎÀ6Ör¢€@/õ™óâòo@\X±bŋÀS|ùóçϟÀ¬è~}²ÚÀ5Ù#ƒ\ހ@/Jȟ+z#@\€H‘"DˆÀS|ùóçϟÀ¬µ%ŒŽMÀ5 µ^;7ä€@.€3YÆ@\ïß¿~ýûÀS|ùóçϟÀ¬‚P•µ$À5h’Eû¶à€@.24%ñ@];víÛ·nÀS|ùóçϟÀ¬PXx@À50À +þeè€@-b>p;ÊT@]‡8páÀS|ùóçϟÀ¬3“wžÀ4ùDaË`€@,Ç€~•@]Ò¥J•*TÀS|ùóçϟÀ«ìéta"À4Â#Ld,“€@,/“Zœ€ý@^šSŽ@`=zõëׯÀS|ùóçϟÀªG«-°þ‡À2䇮ÜΘ€@'t2¡p›-@`cF4hÀS|ùóçϟÀªFdíHÀ2±»ó=Ҏ€@&ýíÜH +@`‰$H‘"ÀS|ùóçϟÀ©ïYgØüÀ2lOkö€@&ˆ¯f׍@`®Ý»víÛÀS|ùóçϟÀ©Ãáî8TÀ2Mš4ѐA€@&Éx)@`Ô©R¥J•ÀS|ùóçϟÀ©˜ßïVG±À2FÝï˜/€@%š#^óÚ@`útéÓ§NÀS|ùóçϟÀ©nQÞñ‘À1ësSH=î€@%;ΊL‹@a @ÀS|ùóçϟÀ©D6‹sÿ.À1» pU9V€@$ÒÏ®â@aF 0`ÁÀS|ùóçϟÀ©ŒÆÃœSÀ1‹Nåℹ€@$j³Z…g@akׯ^œ{ÀS|ùóçϟÀšñS]Ê QÀ1[ÿ<±[ž€@$ÌGɋs@a‘£F4ÀS|ùóçϟÀšÈ‰=ŠåÀ1-1×û·€@#£?:n&Æ@a·nÝ»víÀS|ùóçϟÀš ,Ñ߀À0þæ÷ԛ#€@#BüåNªé@aÝ:téÓ§ÀS|ùóçϟÀšx=DÒ\ À0Ñ»tœ €@"äöZ _@b 0`ÀS|ùóçϟÀšP¹CÙ¡åÀ0£Ù#[Ý̀@"‰Œ,@b(Ñ£FÀS|ùóçϟÀš)Ÿ™«DœÀ0wbyç€@"/b¹)Ó@bN:téÓÀS|ùóçϟÀšï+GÀ0JÕTª̀@!×¹“€3%@bthÑ£FÀS|ùóçϟÀ§ÜŠyÝ1À0—vu%€@!‚Ô`@bš4hÑ£FÀS|ùóçϟÀ§¶ÄžȈÀ/ç²éÚøÝ€@!.e7¢h@bÀÀS|ùóçϟÀ§‘HMO‘xÀ/’:á ü€@ ܟœ%ÈÀS0`ÁƒÀ¹›&ŸŽ€€@SÇÄ$l;?òå˗.\¹ÀS0`ÁƒÀ¹ð ÁпòâÒoŸ€@SÆ8< Œ‚@å˗.\¹ÀS0`ÁƒÀ¹ï&l—À§ƒ’š©€@SÁ•ž•õ-@ X±bŋÀS0`ÁƒÀ¹™&i…ÀÀ l}–ó·€@S¹à9 +M€@å˗.\¹ÀS0`ÁƒÀ¹ïՇd€Àʹ…»e€@S¯¿2Œæ@Ÿ>|ùóçÀS0`ÁƒÀ¹õJÝʌÀwƒá‘•€@S¡V¥ ¡r@X±bŋÀS0`ÁƒÀ¹¬JbEàÀÞßÈÝŠ€@S•ŽÁk@ ‰$H‘"ÀS0`ÁƒÀžÿ.šÀ9ç ±À€@S|çTÊÍÐ@"å˗.\¹ÀS0`ÁƒÀžõ<áÔ2À!ÃZì®­f€@SfZôU©R@%B… +(PÀS0`ÁƒÀžêÞD`À#á»ÆŒd‹€@SMoËO@'Ÿ>|ùóçÀS0`ÁƒÀžÝÃ#gEÀ%÷6¬l€@S0í±œÇ@)û÷ïß¿~ÀS0`ÁƒÀžÐ/2^ÿ•À(ô~ӊ”€@S4s÷c@,X±bŋÀS0`ÁƒÀžÁiÀuºÀ*-ÎÛôÀ€@RðìÈc@.µjÕ«V­ÀS0`ÁƒÀž±wf±iÀ+ú%b×õ€@RÍ,|ÝàÕ@0‰$H‘"ÀS0`ÁƒÀž `&ÚUìÀ-ä*žOšV€@R§ë8º@1·nÝ»víÀS0`ÁƒÀžŽ+‹\ø{À/Áœy’©€@R~­Úk=@2å˗.\¹ÀS0`ÁƒÀžzàÖš¿WÀ0ÈópbŒ€@RT$Ñ#@4(P¡B…ÀS0`ÁƒÀžf‡õu,À1ªBK ‡O€@R'8@5B… +(PÀS0`ÁƒÀžQ)ÒS À2„‹Œ€@Qù 5èû@6páÇÀS0`ÁƒÀž:ÌËkûÀ3WwZ&Ì̀@Qȹvô‘@7Ÿ>|ùóçÀS0`ÁƒÀž#{¬Æ"ŸÀ4"ýKO…€@Q–³N—\@8͛6lÙ³ÀS0`ÁƒÀž >€„9µÀ4æìJ°ë€@Qc^s@9û÷ïß¿~ÀS0`ÁƒÀ·ò»ËéOÀ5£&9}5€@Q.·ÙŽ¿@;*T©R¥JÀS0`ÁƒÀ·Ø%ӑÉÀ6W•›Ï€@P÷Ÿqр=@µjÕ«V­ÀS0`ÁƒÀ·…z ­ÃÀ8Eœ 9§ç€@PMƒê…!Ð@?ãǏ|ùóçÀS0`ÁƒÀµ¿'\‚‰À=Áš*#z`€@Iënñ¹@H6lÙ³fÍÀS0`ÁƒÀµ›ä¬U}À=ò•Y&鿀@IpÔë~•N@H͛6lÙ³ÀS0`ÁƒÀµxk©jýÀ>ž«S'Ž€@H÷LãšÊ­@Idɓ&L™ÀS0`ÁƒÀµTÃ=:J,À>Bæ[) €@H~šñ +Œ@Iû÷ïß¿~ÀS0`ÁƒÀµ0ò)ÅíÀ>b 4ü€@H×ڏ6g@J“&L™2dÀS0`ÁƒÀµ þ­ž.­À>|óŒµìœ€@G£4,²@K*T©R¥JÀS0`ÁƒÀŽèï=ruÀ>’ЩA(€@Gxšk3@KÁƒ 0ÀS0`ÁƒÀŽÄÉÈYÁwÀ>¢C®Ÿ9p€@FŠjG¬Ç@LX±bŋÀS0`ÁƒÀŽ ”Šp"À>­žÐCMÁ€@F2Ó*\ãÈ@Lïß¿~ýûÀS0`ÁƒÀŽ|Sœ„À>Ž\‘Üüb€@EÀòlKÑ@M‡8páÀS0`ÁƒÀŽX;|ßÀ>¶¬êîX€@EPrL³óÓ@NŽ¿Rq0р@Dá`‚› +@NµjÕ«V­ÀS0`ÁƒÀކõ}Ä>À>®Â²Ì H€@DsÉkG‚ú@OL™2dɒÀS0`ÁƒÀ³ëO"°ó†À>€åN®Ѐ@Dž§Ä³@OãǏ—TŽu4€@C6w’q@P=zõëׯÀS0`ÁƒÀ³£ Q+/À>†=®B”€@C4M$’ç‘@P‰$H‘"ÀS0`ÁƒÀ³ Š/Ê À>qÌ3wØí€@BÍŽâ;$@PÔ©R¥J•ÀS0`ÁƒÀ³[$T€&À>Z+]ÞCc€@Bg`¥¥Œ²@Q @ÀS0`ÁƒÀ³7ZxdÀ>?…\ÿŒÆ€@Biní@Ä@Qkׯ^œ{ÀS0`ÁƒÀ³±^XÕ%À>"lþ_ €@A¡"o˜*@Q·nÝ»víÀS0`ÁƒÀ²ð-•ø{À>ÍÍ¡_€@A@ž†Ï@R 0`ÀS0`ÁƒÀ²ÌÐ`ݟãÀ=ß ºž2û€@@á³Käc{@RN:téÓÀS0`ÁƒÀ²©ž&$1ëÀ=¹ãdì€Ï€@@„zª‘Š@Rš4hÑ£FÀS0`ÁƒÀ²†™&â×÷À=’yí4߀@@)%C_@@Rå˗.\¹ÀS0`ÁƒÀ²cÃ÷\ñÀ=hó^Š +å€@?žêfôÎ@S1bŋ,ÀS0`ÁƒÀ²A!åfXÀ==r¬/9'€@>îüÃÕ:e@S|ùóçϟÀS0`ÁƒÀ²²ˆ4•ÓÀ=¬žðX€@>B€Ò”éB@Sȑ"D‰ÀS0`ÁƒÀ±üz€°-À<á Žs€@=™shˆ¹@T(P¡B…ÀS0`ÁƒÀ±Ú{KÀÝùÀ<°`ñ=U€@<óÏ Îçd@T_¿~ýû÷ÀS0`ÁƒÀ±ž¶M%S¶À<~>…cã€@Ué…qo@VŒxñãǏÀS0`ÁƒÀ°³q:0ÒmÀ:ÄõNsj^€@7¶Lã*8@W @ÀS0`ÁƒÀ°“õÓIk¹À:Š4£ˆ€@70ãÉ|å@WS§N:tÀS0`ÁƒÀ°tÀ!ÕèÀ:Nî<&0»€@6®ž`è;„@WŸ>|ùóçÀS0`ÁƒÀ°Uз° À:6ÓæC€@6/…Ÿ/ùú@WêÕ«V­ZÀS0`ÁƒÀ°7(ºkÀ9×¶ i€@5³?B.‘à@X6lÙ³fÍÀS0`ÁƒÀ°Ə,ŒÒÀ9š¹ãéž-€@59Ø4H:@X‚ @ÀS0`ÁƒÀ¯õY– ÒÀ9^zê×ã€@4ÃCûÖl@X͛6lÙ³ÀS0`ÁƒÀ¯¹Ž|”ŸÀ9!AŒ=€@4Osðݜ@Y2dɓ&ÀS0`ÁƒÀ¯~ŸÛ«y3À8äMjÊ€@3ÞZºÌ&-@Ydɓ&L™ÀS0`ÁƒÀ¯D@ŠìgÀ8§Eƒڀ@3o쑊*w@Y°`Áƒ ÀS0`ÁƒÀ¯ +&Ú·}&À8j6°:KQ€@3Àà1@Yû÷ïß¿~ÀS0`ÁƒÀ®Ðªž¥ÙÀ8--áÛ+€@2šØÐZå@ZGÀG@_ ÀS0`ÁƒÀ«UžÕf?7À4D¡³B.€@)æ@P”@_L™2dɒÀS0`ÁƒÀ«%ÿž•ä|À4 XVŽŠ;€@)^@yÐ]@_˜0`ÁƒÀS0`ÁƒÀªöâjùõÀ3֎U <€@(Ú&|$GŸ@_ãǏy7#KȀ@#'Æ€9¶@aÝ:téÓ§ÀS0`ÁƒÀš•èûÖFàÀ15/\³÷€@"É,@  ·@b 0`ÀS0`ÁƒÀšmÔ×±üÀ0à}’×GȀ@"lòÃR@b(Ñ£FÀS0`ÁƒÀšF,ÉcãôÀ0²RTý€@"àŸŠ@bN:téÓÀS0`ÁƒÀšñö$ÅDÀ0„²þ¹€@!ºéb(Ÿà@bthÑ£FÀS0`ÁƒÀ§ø"DôŸ¡À0W2CüW€@!dþgV£@bš4hÑ£FÀS0`ÁƒÀ§ÑŒn ÊÀ0+» š1€@! ]‹u@bÀÀS0`ÁƒÀ§«¿+[ÌùÀ/þ#ô“€@ ¿¢ŽGÀR³f͛6mÀ¹ž¡õfó€€@TŽ–™If?òå˗.\¹ÀR³f͛6mÀ¹ëá8`<¿ó-÷Z€@TŒëÙo7@å˗.\¹ÀR³f͛6mÀ¹›Ìµ·À(vZ¥€@T‡éâÜÀ^@ X±bŋÀR³f͛6mÀ¹˜D+N'QÀ ±DÀët€@T—ÊË@å˗.\¹ÀR³f͛6mÀ¹“TköŠŽÀæ¬'|Ԁ@Tsù`œ7þ@Ÿ>|ùóçÀR³f͛6mÀ¹Œÿ³#`ûÀÊþ|ã€@Te)ϑ@X±bŋÀR³f͛6mÀ¹…I Á#ÁÀrÖKIŠ€@TSW#Y@ ‰$H‘"ÀR³f͛6mÀ¹|4%ŒFdÀ ‡ü¶U€@T=ÉQI5 +@"å˗.\¹ÀR³f͛6mÀ¹qÅP9ÏŽÀ"ÌÏ¢X"€@T%vÛè³@%B… +(PÀR³f͛6mÀ¹fuÛ£À% ÇŒ¶m€@T + ÿ\„i@'Ÿ>|ùóçÀR³f͛6mÀ¹Xî‰ÐßÀ'<ù]æÇƀ@SëÜå/ˆ¿@)û÷ïß¿~ÀR³f͛6mÀ¹J‘7j;À)e{öŠò€@SÊÁŽ8›@,X±bŋÀR³f͛6mÀ¹:ñoˆùÀ+‚tIá7€@SŠèi‹ëþ@.µjÕ«V­ÀR³f͛6mÀ¹*Én‡À-“N4Ÿó€@S€k®æÝº@0‰$H‘"ÀR³f͛6mÀ¹É*†À/– å{܀@SWg®ôUú@1·nÝ»víÀR³f͛6mÀ¹É\i­ÙÀ0Æ4>öWs€@S+ùçÄþB@2å˗.\¹ÀR³f͛6mÀžðhÔ<šÀ1¹æÇÖµc€@Rþ@ûÜu³@4(P¡B…ÀR³f͛6mÀžÚìٜ4À2Š üÛËm€@RÎ\‚:þM@5B… +(PÀR³f͛6mÀžÄ^e‡À3Š€hÄŀ@RœlÕÒÒI@6páÇÀR³f͛6mÀž¬ÆµŠ²GÀ4g9Ê ,‘€@Rh’äà&µ@7Ÿ>|ùóçÀR³f͛6mÀž”/B©P…À5;³õ‘_À@R2ð¡Óï@8͛6lÙ³ÀR³f͛6mÀžz¡·b XÀ6ìÙsš€@Qû¥­õƒŒ@9û÷ïß¿~ÀR³f͛6mÀž`'æ'ÀÀ6ËÆÈ>ÞV€@QÂÕw\d@;*T©R¥JÀR³f͛6mÀžDË¿>̙À7‡+üÒhä€@Qˆ Àè²£@e€@QM(ž–ßö@=‡8páÀR³f͛6mÀž ”•>߈À8äfïÑ·€@Q­x%ž@>µjÕ«V­ÀR³f͛6mÀ·íͺcv À9†5ñ‡Šd€@PÒïð…9@?ãǏÀ:þgæØ€@P”n®}tœ@@‰$H‘"ÀR³f͛6mÀ·°ÌÁÅÀ:°W×^YG€@PU(Y¹õ@A @ÀR³f͛6mÀ·D³u1ÛÀ;8Éå¯L €@P:s£1z@A·nÝ»víÀR³f͛6mÀ·oÑ[ÙýÀ;žïÂ|=р@O©‚ô>ù@BN:téÓÀR³f͛6mÀ·NˁäŠÒÀ<0圔°\€@O'±®·Ëÿ@Bå˗.\¹ÀR³f͛6mÀ·-<Ÿ3Ÿ5À< Ìd¹Ç€@N¥5ŠÏ§ø@C|ùóçϟÀR³f͛6mÀ· .€¬©øÀ=È +á΀@N"Až¹@D(P¡B…ÀR³f͛6mÀ¶èª HL€À=i`‰.€@MŸàÂÚë@D«V­ZµjÀR³f͛6mÀ¶ÅžsfAÈÀ=ÁŸíÛ&=€@M«rµõZ@EB… +(PÀR³f͛6mÀ¶¢b”ž +TÀ>ÓćŽÜ€@L˜bÜI˜j@EÙ³f͛6ÀR³f͛6mÀ¶~±†ÄÀ>\Ëù:€@LRÇ­-@FpáÇÀR³f͛6mÀ¶Z¬lò–TÀ>Ÿ¶º%%Ӏ@K’¡ƒÑÑ@G @ÀR³f͛6mÀ¶6\È^mÀ>ÛÉ!Õ7؀@Ks¥"ì@GŸ>|ùóçÀR³f͛6mÀ¶Ê!žœ{À?5Ĭø‚€@JŽé'âr @H6lÙ³fÍÀR³f͛6mÀµìü5)wÀ?@0ÿT&y€@J#VãÁ@H͛6lÙ³ÀR³f͛6mÀµÇú8†÷À?hïÎ{ k€@IŽ?㉋@Idɓ&L™ÀR³f͛6mÀµ¢Ì=î9¢À?‹§—Wþ€@IWE€@Iû÷ïß¿~ÀR³f͛6mÀµ}xp3FÀ?šöN3€@H‘†Qj[ä@J“&L™2dÀR³f͛6mÀµXÕ;ëÄÀ?¿Ø‘¿râ€@HâÁЫŸ@K*T©R¥JÀR³f͛6mÀµ2zíoaãÀ?ÑŒõ39*€@G™‚€4pÚ@KÁƒ 0ÀR³f͛6mÀµ ÝùÁaÀ?ÞplI™i€@Gyi}gŠ@LX±bŋÀR³f͛6mÀŽç4ü}sÉÀ?æ'åA €@FŠÙªW¡Ý@Lïß¿~ýûÀR³f͛6mÀŽÁ…¹°1¶À?éÔVŸ¿€@F/³Ñ]Dz@M‡8páÀR³f͛6mÀޛշÅ!ÈÀ?çtIﷀ@Eºàšß@N@PÔ©R¥J•ÀR³f͛6mÀ³•ÇdÞîÀ?l?Yß©N€@B±‹PÆ®Œ@Q @ÀR³f͛6mÀ³p„æ¹)À?Lퟒё€@BI­YÅ÷@Qkׯ^œ{ÀR³f͛6mÀ³K Ÿ(ÉÀ?*¶ãŒ×€@Aã€Ü>Î@Q·nÝ»víÀR³f͛6mÀ³&栎-ÃÀ?ÃZâï€@ArˆC3Ý@R 0`ÀR³f͛6mÀ³Y¯ \ÎÀ>Þ? +ýZ$€@A–L_¥@RN:téÓÀR³f͛6mÀ²ÝüÚW‚ìÀ>ŽQ~øŽÝ€@@ŒnMJŒ@Rš4hÑ£FÀR³f͛6mÀ²¹Òä; À>ˆ!äógQ€@@]ûgHlÏ@Rå˗.\¹ÀR³f͛6mÀ²•Þ`èºÀ>YÖ6b*€@@4RÛ²@S1bŋ,ÀR³f͛6mÀ²r!žlÌ-À>)“5e€@?LŒµk$"@S|ùóçϟÀR³f͛6mÀ²NŸ()kÐÀ=÷|hÀÏ>€@>š`Ӑƒ@Sȑ"D‰ÀR³f͛6mÀ²+XÄ@ßÀ=ÃŽŠíœ€@=ëßµè@T(P¡B…ÀR³f͛6mÀ²Py Ì¿À=Ž[hGµ€@=Aa†Œ¹@T_¿~ýû÷ÀR³f͛6mÀ±åˆ ‹À=W’åä€@<™ÄЃ|r@T«V­ZµjÀR³f͛6mÀ±Ãæ[8À=vØqÉb€@;öþgH#@TöíÛ·nÝÀR³f͛6mÀ± œ0Í ×À<æ'zÿl€@;V+°'@UB… +(PÀR³f͛6mÀ±~œšíh¢À<«ŸÏ|˜€@:¹g:• @UŽ8páÃÀR³f͛6mÀ±]™\qCÀu(€@: FӔî@UÙ³f͛6ÀR³f͛6mÀ±;Gû?ÀÀ<4$ª €@9Š“þ4¹¯@V%J•*T©ÀR³f͛6mÀ±d€Öô À;öü0ñ«€@8øB×¶ÂÐ@VpáÇÀR³f͛6mÀ°ù‘‚™ÅÀ;¹4îžiހ@8iG,Î@VŒxñãǏÀR³f͛6mÀ°ØçÔjgÀ;zÐÍa¿€@7ݓá-«U@W @ÀR³f͛6mÀ°ž˜ÊéÀ;;å%ãø|€@7UZÕ@WS§N:tÀR³f͛6mÀ°˜’öÐÙÀ:ü†C×€@6ÏÒšH @WŸ>|ùóçÀR³f͛6mÀ°xØæ…peÀ:ŒÇfŽçé€@6Mš`ã@WêÕ«V­ZÀR³f͛6mÀ°YjPQlÁÀ:|ºÑ†‡’€@5Ώȳ’@X6lÙ³fÍÀR³f͛6mÀ°:G…yÎÀ:€@ ŸS“ŠåTÀRN:téÔÀº"°mPR€€@UaKäq ì?òå˗.\¹ÀRN:téÔÀº!ðC¢ï ¿ôUy|ùóçÀRN:téÔÀºÿAáXªÀ7Á_©¹ô€@U4pÿ-zÂ@X±bŋÀRN:téÔÀºÒ-3À'6Zʀ@U æ"çˆ4@ ‰$H‘"ÀRN:téÔÀ¹þ27êˆÀ!ƒ»6©Bf€@U ôG³€|@"å˗.\¹ÀRN:téÔÀ¹ó$ “uEÀ#ëPÿø`ÿ€@Tï®<°/@%B… +(PÀRN:téÔÀ¹æ­P•ìÀ&I1ûwr€@TÒ)V¡@'Ÿ>|ùóçÀRN:téÔÀ¹ØÓÓ,ŽÀ(œRbr€@T±}Iæ)ý@)û÷ïß¿~ÀRN:téÔÀ¹ÉžMÙûOÀ*ã³°<€@TÄât@,X±bŋÀRN:téÔÀ¹¹÷¿ãýÀ-f +žã£€@TgoAԂ@.µjÕ«V­ÀRN:téÔÀ¹§<išýÀ/KŒ3›€@T=›bn[@0‰$H‘"ÀRN:téÔÀ¹” V +¶À0µ+ÆúŒ€@TiO™‡Ž@1·nÝ»víÀRN:téÔÀ¹ÇûH4>À1œâ³;í€@S※3p@2å˗.\¹ÀRN:téÔÀ¹j<œ¬Ð-À2ŒÿVhI€@S±mùã>Î@4(P¡B…ÀRN:téÔÀ¹S‡µÉÅ À3ŽÌQ¢\8€@S}ê UŽ(@5B… +(PÀRN:téÔÀ¹;³/qÀ4€*m~~o€@SH|ùóçÀRN:téÔÀ¹ÓL5R9À6h¿›OO-€@RÖö>Å÷§@8͛6lÙ³ÀRN:téÔÀžíݹOÀ7= åw±p€@R›§õ]9#@9û÷ïß¿~ÀRN:téÔÀžÑð€ZÅ­À8 gsИY€@R^áHû@@;*T©R¥JÀRN:téÔÀžµÂÑúÀ8Ëÿ¶©€@R n8Ð@µjÕ«V­ÀRN:téÔÀžY{{£_ÿÀ:Ü^ƒ–Ê€@Q^/=q)˜@?ãǏ{]€@P +EÖÛ.a@Bå˗.\¹ÀRN:téÔÀ·(ŒÙ6À>TÕ6€@O‰î­É²B@C|ùóçϟÀRN:téÔÀ·kpç=9À>pöÝ7¥,€@Nþó'h‹ð@D(P¡B…ÀRN:téÔÀ·GBÅòŽÀ>ыÍ5Ð €@NsÌ 7>@D«V­ZµjÀRN:téÔÀ·"§[`Ç!À?*’¶؀@M詀kž@EB… +(PÀRN:téÔÀ¶ýš|ùóçÀRN:téÔÀ¶fwœÌ¹{À@9q1Vj€@K7\œa]@H6lÙ³fÍÀRN:téÔÀ¶@ |ÍÆ`À@O¹ª[,À@J¯pôY8à@H͛6lÙ³ÀRN:téÔÀ¶jzƒeÀ@bœš2Ÿµ€@J(Ó«LŽ@Idɓ&L™ÀRN:téÔÀµò¢L&þ[À@ršƒ/cQ€@I£^~ºtò@Iû÷ïß¿~ÀRN:téÔÀµËžA9yqÀ@mæÐDê€@I+FŠ(@J“&L™2dÀRN:téÔÀµ€³cŸŽ&À@‰U/ϷՀ@HœQÊñ@K*T©R¥JÀRN:téÔÀµ}šwïn$À@mžøüž€@Hçە.‘@KÁƒ 0ÀRN:téÔÀµVsýæ‘)À@”Ô:hþÁ€@G›fÎë•@LX±bŋÀRN:téÔÀµ/F1§À@–¥Ÿæ"€@G°ŽË²º@Lïß¿~ýûÀRN:téÔÀµ UÕàÀ@•þ“,K€@F ŸŒ‰#@M‡8páÀRN:téÔÀŽàìÀ@U1­š¬^€@Ci ’\;€@PÔ©R¥J•ÀRN:téÔÀ³Ðå9¯{kÀ@DŽmœt€@Bû¿¯ˆPú@Q @ÀRN:téÔÀ³ªŒáœÀÀ@2]Ôþ}€@B×`ŸÇb@Qkׯ^œ{ÀRN:téÔÀ³„aB€'À@Ž­5°Â€@B%êÅ'ú@Q·nÝ»víÀRN:téÔÀ³^e·—ÜNÀ@ ¬{ôu€@Aœûýb‡X@R 0`ÀRN:téÔÀ³8f€ªÀ?æ²ÖH*€@AX ;c@RN:téÓÀRN:téÔÀ³ Bڔ³À?·¡ì”ö&€@@ôÍZ|Ð@Rš4hÑ£FÀRN:téÔÀ²í² »»À?†Q„.H€@@’*.ÿ†c@Rå˗.\¹ÀRN:téÔÀ²È”XËõùÀ?RçLn‘€@@26­ð=@S1bŋ,ÀRN:téÔÀ²£Ž‡- ŠÀ?‹WJµé€@?šzéÉÌ@S|ùóçϟÀRN:téÔÀ²Ï0›?À>æbiíΡ€@>ð{=QÁÈ@Sȑ"D‰ÀRN:téÔÀ²Z·;ôlrÀ>­kš3ž€@>s7éØjî€@=Œ4Å ‡`@T_¿~ýû÷ÀRN:téÔÀ²ÉáÂ9.À>7zaŸw€@<ßÝ> ‹@T«V­ZµjÀRN:téÔÀ±ï=g[SôÀ=úvÙ͊€@<7V9’@TöíÛ·nÝÀRN:téÔÀ±Ëù­úÔïÀ=ŒLŽrÊ€€@;’”B€Í@UB… +(PÀRN:téÔÀ±©‡àíÀ=}ècS€@:ñŒkLU­@UŽ8páÃÀRN:téÔÀ±†Qˆ%ÊÀ=<÷g¶ +€@:T2$ze¹@UÙ³f͛6ÀRN:téÔÀ±cïM³¢ñÀ<üÝ­¢ €@9ºxn÷ÿu@V%J•*T©ÀRN:téÔÀ±AÚ;OögÀ<ºTº2‹w€@9$Qµm @VpáÇÀRN:téÔÀ± ÆÂÀ|ùóçÀRN:téÔÀ°œ…6soÀ;j4œ O€@6i0Ю@WêÕ«V­ZÀRN:téÔÀ°{Տÿ•À;%ÿÿ° +Q€@5çF46ƒ @X6lÙ³fÍÀRN:téÔÀ°[íB^ +}À:᣷‚á€@5h„wy5Z@X‚ @ÀRN:téÔÀ°Ê^ž€@3þ“|²0»@Ydɓ&L™ÀRN:téÔÀ¯ŸèääX¶À9ÏܵsÊǀ@3‹Óøi“F@Y°`Áƒ ÀRN:téÔÀ¯‚@iÂÛÀ9‹œP&œ€@3ìñíž@Yû÷ïß¿~ÀRN:téÔÀ¯F8õŠœÀ9GˆðÁ ;€@2®Í€žU@ZGc€@1w@[*T©R¥JÀRN:téÔÀ®\Z’õþ +À89ÒÂ7U€@1àoÿ¥@[uëׯ^œÀRN:téÔÀ®#oT ÐÀ7÷9þôЀ@0Ž»Q©@[Áƒ 0ÀRN:téÔÀ­ë ð{kŸÀ7µÉ&ˆÃ€@0Vÿ‚ Ña@\ 4hÑ£ÀRN:téÔÀ­³nnWcÀ7s@C™ŠÒ€@/÷=§‹3Ÿ@\X±bŋÀRN:téÔÀ­|VÄÙõÀ71ìþÞþü€@/EHH§í@\€H‘"DˆÀRN:téÔÀ­EØÝH{)À6ñ­²ú€@.—e­f9@\ïß¿~ýûÀRN:téÔÀ­ó’PÚâÀ6°·Ë.õ€@-îåÉV@];víÛ·nÀRN:téÔÀ¬Ú¥²oNœÀ6pàc<³€@-IKþix@]‡8páÀRN:téÔÀ¬¥îÑôÀ61‘Wñõ€@,šй!‹@]Ò¥J•*TÀRN:téÔÀ¬qË6gR'À5òÎÈßCǀ@, ?žê:@^<Þî{À5ޜk¥Q³€@+rQړ¬W@^iÓ§N:ÀRN:téÔÀ¬ ? ›“¯À5vý’hë|€@*Ý:ŒÇò@^µjÕ«V­ÀRN:téÔÀ«ØÒö—ÜÀ59õ1cro€@*KÞÃÊ|µ@_ ÀRN:téÔÀ«ŠöW=¿À4ý…äW” €@)Ÿ&ÌcŒ@_L™2dɒÀRN:téÔÀ«u§Ã0fÀ4Á±óʂ‰€@)3ù³çåŽ@_˜0`ÁƒÀRN:téÔÀ«DåÉ µ@À4†{ZZ¶€@(­?‡ŽG@_ãǏ}ÑðÜÀ3Ÿä6`·‰€@&³B]‚Ž@`‰$H‘"ÀRN:téÔÀªY%_mÀ3gԇ›“ÿ€@&<8˜?@`®Ý»víÛÀRN:téÔÀª+ì€BGÀ30hžý$€@%ÈW”Ñâ,@`Ô©R¥J•ÀRN:téÔÀ©þ|¢øUÁÀ2ù ÊÞ5€@%WQ { +û@`útéÓ§NÀRN:téÔÀ©Ñéþ€û²À2Ã}-©j €@$éJ»ž.@a @ÀRN:téÔÀ©¥Ö{˜ÔÀ2ýÀž»J€@$}…/: +@aF 0`ÁÀRN:téÔÀ©z@–<óqÀ2Y"RÀ^¿€@$š Þ<@akׯ^œ{ÀRN:téÔÀ©O&Ë{” À2$ꏔ €@#®=·íl"@a‘£F4ÀRN:téÔÀ©$‡™C–À1ñUþ|ä9€@#J^€ž@a·nÝ»víÀRN:téÔÀšúa~ŸC²À1Ÿd +CÅu€@"èë+Նn@aÝ:téÓ§ÀRN:téÔÀšÐ²ü›ÕMÀ1Œþ…ðè€@"‰ÒûÍ)þ@b 0`ÀRN:téÔÀš§z•U§xÀ1Ze y­€@"-¢õ@b(Ñ£FÀRN:téÔÀš~¶Ík8}À1)VL‘é€@!ÒsDÄê^@bN:téÓÀRN:téÔÀšVf+š9*À0øæÀ†Qÿ€@!z v¿®­@bthÑ£FÀRN:téÔÀš.‡9ú*À0ÉU=g€@!#Â9Yiò@bš4hÑ£FÀRN:téÔÀš¢{'À0™àä’ž€@ υ÷Ń@bÀÀRN:téÔÀ§à“ç]›À0kH7r©€@ }I„þÑÔÀQéÓ§N:Àº¬™ ‡€€@V@Ü [‘?òå˗.\¹ÀQéÓ§N:Àº«JÿcX[¿õ–8‰ªG~€@V>ç?írµ@å˗.\¹ÀQéÓ§N:ÀºšçgSÑÀ‹-C©—€@V9 +F@ X±bŋÀQéÓ§N:Àº€íyhæÀ%SÍ>€@V/KÏo@å˗.\¹ÀQéÓ§N:ÀºŸ_K€²Àyîf¢ +N€@V!±sM÷ž@Ÿ>|ùóçÀQéÓ§N:Àº˜?ÃîßLÀÃW,îPú€@VIaÊ &@X±bŋÀQéÓ§N:Àº’—× ÀþßæÎkހ@Uû!øås@ ‰$H‘"ÀQéÓ§N:Àº…\AÆcÙÀ"”öÿ^þ„€@UâM_¢®@"å˗.\¹ÀQéÓ§N:Àºy¡ÿÕl“À% þú Ž’€@UÅງ€ß@%B… +(PÀQéÓ§N:ÀºliÉ8G;À'¢SH“–ÿ€@U¥óüEï@'Ÿ>|ùóçÀQéÓ§N:Àº]ºGh®nÀ*Íð²h®€@U‚¡œ¿—¿@)û÷ïß¿~ÀQéÓ§N:ÀºMšÌԘÀ,€Xò†Kǀ@U\ …ýY@,X±bŋÀQéÓ§N:Àº<K3À.ÚïlJ$€@U2C<šqå@.µjÕ«V­ÀQéÓ§N:Àº),H`HãÀ0“O†gJ€@Uwšuܛ@0‰$H‘"ÀQéÓ§N:ÀºîԟÙ%À1±D ¹_£€@TÕǁˆL @1·nÝ»víÀQéÓ§N:À¹ÿd}·ÞÀ2ÆïÝØ%†€@T£Wv§ý@2å˗.\¹ÀQéÓ§N:À¹è—C›YóÀ3Ó÷W_B€@TnMøŽ™b@4(P¡B…ÀQéÓ§N:À¹Ð‘ŒY«À4Ø ª“ì€@T6Ñù_@5B… +(PÀQéÓ§N:À¹·^îeþÀ5Òà›<íØ€@Sý ­/Í#@6páÇÀQéÓ§N:À¹ìóO®À6Ä@ŸVñ‚€@SÁ#Ì7P@7Ÿ>|ùóçÀQéÓ§N:À¹š\¶ýþÀ7«øÓ’ €@SƒCi8Ð@8͛6lÙ³ÀQéÓ§N:À¹e äacŽÀ8‰âÔ¿}ê€@SC“ŽQÞà@9û÷ïß¿~ÀQéÓ§N:À¹G§*V»ÏÀ9]â‰îÞx€@S=œ™@;*T©R¥JÀQéÓ§N:À¹)8ðBGMÀ:'åÍ?²€@R¿j:¶ +å@µjÕ«V­ÀQéÓ§N:ÀžÈ©}3b¿ÀŸýzš€@Q±Ž 3Û@A·nÝ»víÀQéÓ§N:Àž=U~!äÐÀ>—ŸÆÚ=q€@PΜóÖ·Ž@BN:téÓÀQéÓ§N:Àžé|ŸõÀ?Z†µ€@P…-úRC@Bå˗.\¹ÀQéÓ§N:À·óðñšà)À?…Á.ë³m€@P;‚Û›¶@C|ùóçϟÀQéÓ§N:À·ÎvŸ)ÚÀ?ï%Ÿ< Ž€@OãpySÍì@D(P¡B…ÀQéÓ§N:À·š…û.eÀ@'ßO¿b€@OOÒVžu2@D«V­ZµjÀQéÓ§N:À·‚&q/¿tÀ@Sâã W€@NŒ^@R“ë@EB… +(PÀQéÓ§N:À·[dԄDÀ@{ºiÂÏ€@N)D},@EÙ³f͛6ÀQéÓ§N:À·4Iè2¥äÀ@Ÿ…¡ð­€@M–°p€8à@FpáÇÀQéÓ§N:À· ßóo›À@¿c«ì³G€@MÍB€^<@G @ÀQéÓ§N:À¶å-„‡Ê^À@Ût‚£ }€@LsÁeq@GŸ>|ùóçÀQéÓ§N:À¶œ>³ä+À@óØz£zt€@Kã°ùí@H6lÙ³fÍÀQéÓ§N:À¶•™b¢ÀA°Ã4å€@KTœwõX@H͛6lÙ³ÀQéÓ§N:À¶lÇfœ*ÀAÿÂxʀ@JÇÇ nù@Idɓ&L™ÀQéÓ§N:À¶DO·O!ùÀA(<µ|Ÿ€@J:Šdô‰@Iû÷ïß¿~ÀQéÓ§N:À¶ºËÃnVÀA32š8EW€@I¯¹yŠ @J“&L™2dÀQéÓ§N:Àµó™Þ¹ ÀA; +Zóf€@I&Vø+ô@K*T©R¥JÀQéÓ§N:ÀµÊU+ ÀA@Ãg|€@Hž”¹'î@KÁƒ 0ÀQéÓ§N:Àµ¡’=q¡ÀABT`m£€@H†•r×@LX±bŋÀQéÓ§N:ÀµxÍF +ðmÀAAÞž€@G”>}…@Lïß¿~ýûÀQéÓ§N:ÀµP oœWþÀA>ÚhPpr€@G̒ôB@M‡8páÀQéÓ§N:Àµ'U²;âÀA9g¹÷—.€@F‘??®A @NŠœšs§é@T(P¡B…ÀQéÓ§N:À²e_Š20*À?_Óô3Œ€@=Õ²@T_¿~ýû÷ÀQéÓ§N:À²@xA§i À?—n€@=#‘ óÉ@T«V­ZµjÀQéÓ§N:À²Þ}|{ŠÀ>ÛÜH’¢€@˜‹x𡐀@;̜R+Á@UB… +(PÀQéÓ§N:À±Ó˜Ì4À À>TBƒ}Ú€@;'—„ r@UŽ8páÃÀQéÓ§N:À±¯ï*G(À>Á‡€@:…sæXlE@UÙ³f͛6ÀQéÓ§N:À±Œ—Šd>·À=É9š"©í€@9çš>)p{@V%J•*T©ÀQéÓ§N:À±i“»gŸÀ=‚¯eYjʀ@9M£÷:ü@VpáÇÀQéÓ§N:À±Fâ'a ÏÀ=;˜ éR€@8·TL TØ@VŒxñãǏÀQéÓ§N:À±$…y×èŸÀ<ô gËÛ*€@8$«}ÈÅï@W @ÀQéÓ§N:À±}ˆpÀ<¬‡jµ¬€@7•—ҁÌ@WS§N:tÀQéÓ§N:À°àÊ·X€À|ùóçÀQéÓ§N:À°¿mQÌ24À<}›0€@6ë:0Ÿ@WêÕ«V­ZÀQéÓ§N:À°žeŒ®åÂÀ;Òîׂ”€@5ý/pþ@X6lÙ³fÍÀQéÓ§N:À°}³‡>ÉÀ;ŠLÙø,Š€@5{Ã.@ g@X‚ @ÀQéÓ§N:À°]WLyÃeÀ;Aª” } €@4ý”žw²Å@X͛6lÙ³ÀQéÓ§N:À°=PÔ_žÀ:ùÌë| €@4‚’M/>@Y2dɓ&ÀQéÓ§N:À° S‡À:° êB"€@4 +ª+S@Ydɓ&L™ÀQéÓ§N:À¯ü‰hJøÏÀ:hT,Æú€@3•Ê›šË@Y°`Áƒ ÀQéÓ§N:À¯Ÿ}NË=kÀ: ?»â3š€@3#áûˆþ<@Yû÷ïß¿~ÀQéÓ§N:À¯,iŸÀ9Øn¬Baq€@2ŽÞÂãž@ZGjçì@];víÛ·nÀQéÓ§N:À­·4%VÀ6Þ í⚀@-7øž!dé@]‡8páÀQéÓ§N:À¬ÓžœAœÀ6œ ‡p„ €@,•Pt‡,Ô@]Ò¥J•*TÀQéÓ§N:À¬å™ýˆëÀ6Z—ña(€@+öѓÊX_@^lŸÇ@bthÑ£FÀQéÓ§N:ÀšIhۇÈuÀ1óëQJ€@ ÿ¬/·@bš4hÑ£FÀQéÓ§N:Àš!um ÍBÀ0ÑbZ"Åր@ «4?€4/@bÀÀQéÓ§N:À§ùóóœ?BÀ0¡vêžÆX€@ XíZýÖÀQ… +(P¡À»;)ÈT @W.XèÙú?òå˗.\¹ÀQ… +(P¡À»:PF9@¿öñìºAœ?€@W,9“Àì@å˗.\¹ÀQ… +(P¡À»7Æ6a>šÀ룯fY)€@W%݂öV@ X±bŋÀQ… +(P¡À»3ŒRO'À(ä8ŒÜ€@WJƒ»@å˗.\¹ÀQ… +(P¡À»-€çð#ÛÀқÿxs™€@W Š3V–B@Ÿ>|ùóçÀQ… +(P¡À»&.²Àoðàύۀ@Vù©ìÀ†@X±bŋÀQ… +(P¡À»Û=5ûÛÀ þöy—è>€@V⺊ö¯@ ‰$H‘"ÀQ… +(P¡À» +Œ[qÀ#ŒØlZXQ€@VÇÐ×fZN@"å˗.\¹ÀQ… +(P¡À»`®²ŒÀ&p;Ñd/a€@V©JfÓ@%B… +(PÀQ… +(P¡Àº÷ƒ×+èÍÀ)Í7+ ‚€@V†oïçYÝ@'Ÿ>|ùóçÀQ… +(P¡ÀºçìÉ/i6À+²IÚNڀ@V`1¬eg¬@)û÷ïß¿~ÀQ… +(P¡ÀºÖÐK3‹ƒÀ.>'jqw€@V6j!EQV@,X±bŋÀQ… +(P¡ÀºÄ7ÛªïÀ0]« +å®Ì€@V |ùóçÀQ… +(P¡À¹þº;H±"À9l² ̀@T8^M*y/@8͛6lÙ³ÀQ… +(P¡À¹à œ ¹À9îÀFáDQ€@Sóås–Ž+@9û÷ïß¿~ÀQ… +(P¡À¹Á|fl,‚À:ËDVO>€@S­¶µjÕ«V­ÀQ… +(P¡À¹;3ðíŒÀ=П<ŠTπ@R‡R “š@?ãǏvû# èç€@R;%…FT‘@@‰$H‘"ÀQ… +(P¡ÀžóŒ•OÜÀ?¿±‰€@QîA'-îL@A @ÀQ… +(P¡ÀžÎ‚]÷•¡À?€ ª«…€@Q Ë9ÖAÃ@A·nÝ»víÀQ… +(P¡ÀžšÒ”føÀ@’åV^¿€@QRç·ü‡@BN:téÓÀQ… +(P¡Àž‚‰92ôÀ@T üù@QžÒ~“æ@Bå˗.\¹ÀQ… +(P¡Àž[² Œ‹|À@©‚=åf€@P¶^æü@C|ùóçϟÀQ… +(P¡Àž4Xú‘ÕÀ@ÂqUò˜Ÿ€@PgøvÁ@D(P¡B…ÀQ… +(P¡Àž ‡íé iÀ@ò‹K†‰€@P¢1µŸ@D«V­ZµjÀQ… +(P¡À·äK9ÀA…œ €@O–íå6å@EB… +(PÀQ… +(P¡À·»¬—†ÝQÀAE;:¯•Ò€@Nû›‚+n@EÙ³f͛6ÀQ… +(P¡À·’¶œž;âÀAhobÎP€@N`:ÿ@FpáÇÀQ… +(P¡À·isx.ˆsÀA†Í»ý€@MÅÓ0ù)@G @ÀQ… +(P¡À·?ìd‡ÿIÀA¡…«|ùóçÀQ… +(P¡À·*ÊÍIÀAž`–0ò€@L”­Lus@H6lÙ³fÍÀQ… +(P¡À¶ì7ŽæóÀA˄r²ä€@KþݯÓÕ@H͛6lÙ³ÀQ… +(P¡À¶Âz ·ÒÀAÛ·{5€@Khˍ)©f@Idɓ&L™ÀQ… +(P¡À¶—Þ©þ+ÀAç5<Ø(‡€@JÕ!!Çíç@Iû÷ïß¿~ÀQ… +(P¡À¶m‰ãU[ÀA𠏙¯Ï€@JC䝻1@J“&L™2dÀQ… +(P¡À¶C"|Þž€ÀAõŽÙˆÄ1€@I²ÜÂßi@K*T©R¥JÀQ… +(P¡À¶²ñ$ôÀAøYÓ2r€@I$pnôy@KÁƒ 0ÀQ… +(P¡Àµî>ÒªŽÀAø¶Lý€@H—íz|¢q@LX±bŋÀQ… +(P¡ÀµÃÏtcÿ„ÀAõ1â_€@H e{yUi@Lïß¿~ýûÀQ… +(P¡Àµ™jQ’ 0ÀAïv_ŒAY€@G„èJ²@M‡8páÀQ… +(P¡Àµoz+ó5ÀAçRÀŠFŠ€@FþƒBýǗ@N֖ÊÃ@T(P¡B…ÀQ… +(P¡À²”’1k’À@)‹ã€@>g‡ÁÚ{@T_¿~ýû÷ÀQ… +(P¡À²n- x™À@Áá®Òþ€@=d•ãÃkŽ@T«V­ZµjÀQ… +(P¡À²HàBô±À?ÃÀ1?ä€@<²è\ñ]@TöíÛ·nÝÀQ… +(P¡À²#†·ƒ·À?zù,µ”a€@<Ï{=N@UB… +(PÀQ… +(P¡À±þƒª}Œ.À?1Mø¡rɀ@;Y»¹n–@UŽ8páÃÀQ… +(P¡À±ÙØ—rNÀ>æÜUŽ€@:³ÇLŽ·@UÙ³f͛6ÀQ… +(P¡À±µ„ÚëlÀ>›À†Às€@:àIw,Û@V%J•*T©ÀQ… +(P¡À±‘Šªy) À>P`; +y€@9sõ³­÷§@VpáÇÀQ… +(P¡À±mê!ke0À>ôJPغ€@8Ùõ"¡1{@VŒxñãǏÀQ… +(P¡À±J£Œ¹¿À=·uMýn2€@8CÌ šsç@W @ÀQ… +(P¡À±'·ÝÀýàÀ=j¯a#2€@7±g•dJr@WS§N:tÀQ… +(P¡À±&Ë×nãÀ=·#%,˜€@7"ŽšJjß@WŸ>|ùóçÀQ… +(P¡À°âðµÆükÀ<Сƒ*u€@6—Ÿþ¡@WêÕ«V­ZÀQ… +(P¡À°Á³E3À<ƒ/nD€@60bê @X6lÙ³fÍÀQ… +(P¡À°Ÿ•ÆZ‰À<6gîý €@5ŒŲ +Œ@X‚ @ÀQ… +(P¡À°~pܺ4›À;éfeŽST€@5 U>*-œ@X͛6lÙ³ÀQ… +(P¡À°]ŠÑeöÀ;œŒ(5j8€@4÷ÐÓw@Y2dɓ&ÀQ… +(P¡À°=7lQ-ãÀ;OçŸP‡O€@4Õü (J@Ydɓ&L™ÀQ… +(P¡À°"fÅÑ +À;†².€@3œÞƒ.@Y°`Áƒ ÀQ… +(P¡À¯úÎÒŽÅÀ:·uœC?€@3(ýƒ0÷§@Yû÷ïß¿~ÀQ… +(P¡À¯Œ AcƒÀ:kÀ)ëcà€@2žôû@ZGÃì€@/àÁE @\X±bŋÀQ… +(P¡À­Þ®Mj›\À8foîßs€@/*›‹‹s@\€H‘"DˆÀQ… +(P¡À­Šš_±ŒÀ7ØuŽ…–€@.x8t HC@\ïß¿~ýûÀQ… +(P¡À­nÒlÀ7’,?I:€@-Êý*à@];víÛ·nÀQ… +(P¡À­6ª:ËÍÀ7LŽMHP€@-"GSÁ'Ý@]‡8páÀQ… +(P¡À¬ÿòFðÞÀ7Ÿ¡å? €@,}ø (¢I@]Ò¥J•*TÀQ… +(P¡À¬ÉÜV€§À6ÃcŽמ€@+ÝðÐï”_@^ÿ†ªÀ4A6<ý™€@&p¹ßà³@`‰$H‘"ÀQ… +(P¡ÀªžÆÛz^µÀ4bŠ£Ï€@%ø©ÌÄ€i@`®Ý»víÛÀQ… +(P¡Àªo‘Æ9aÀ3ÉŸçóè€@%ƒ§n*˕@`Ô©R¥J•ÀQ… +(P¡ÀªAIœC”*À3#:$Œt€@%œÍÔ»{@`útéÓ§NÀQ… +(P¡ÀªZ6SÀ3UFŸ Ÿ€@$¢t‘5Æe@a @ÀQ… +(P¡À©åòãÀ3(±‹f7€@$6õžfÆ@aF 0`ÁÀQ… +(P¡À©¹qÀ2ãÈ-Õj‚€@#ÌxÍ$›ÿ@akׯ^œ{ÀQ… +(P¡À©Œ³Ò°Ä À2¬$+)f¬€@#e}{|Ž @a‘£F4ÀQ… +(P¡À©`Ù%¯žàÀ2u;ƒ6]ó€@#òÆfŠ@a·nÝ»víÀQ… +(P¡À©5QS%ðÀ2? óËàâ€@"Ÿ,°Qŧ@aÝ:téÓ§ÀQ… +(P¡À© +€Ÿ˜q°À2 —!Nhـ@"?²¹_1ð@b 0`ÀQ… +(P¡ÀšàG]Ív‘À1ÔØ™Ff€@!╗Ý*€@b(Ñ£FÀQ… +(P¡Àš¶eÜÅ ãÀ1 ÏÓDG€@!‡ÄW&ˆ@bN:téÓÀQ… +(P¡ÀšŒþqô•À1m{5q‡s€@!/.€Ãkc@bthÑ£FÀQ… +(P¡ÀšdrõcÀ1:Ùí~—€@ ØÄ.å5@bš4hÑ£FÀQ… +(P¡Àš;—>ý¡À1ç³×ê€@ „uœ£%t@bÀÀQ… +(P¡Àš”5¬üŽÀ0×¥LºY?€@ 23ûíÅxÀQ @À»ÐB%y“û€€@X*ñ]E‚?òå˗.\¹ÀQ @À»Ï[]m¡i¿økyŠ›Ì€@X(¢ãŽ•˜@å˗.\¹ÀQ @À»Ì§‰!z…Àd€p„$€@X!¹šÀÓÈ@ X±bŋÀQ @À»È(3JºÈÀB®|©îñ€@X<>³ ˜@å˗.\¹ÀQ @À»Áßë þþÀHœ%¶×€@X5‡f,@Ÿ>|ùóçÀQ @À»¹Ò?î·LÀAgÒ â€@Wñޜ1ù@X±bŋÀQ @À»°ŒWÆ$À"0݂Ay€@WØÌ«+iQ@ ‰$H‘"ÀQ @À»€yދ³À$ýÂI²÷:€@W»”ÏHQ@"å˗.\¹ÀQ @À»—;Z…QÀ'ÛµbÑR€@Wš'ÞeȂ@%B… +(PÀQ @À»ˆN>ûÀ*¬”D4w€@Wt€44š@'Ÿ>|ùóçÀQ @À»wŒ©/¢À-nüÂB.€@WK+wRQ-@)û÷ïß¿~ÀQ @À»eŽ!МÀ0Ï_ç (€@WâZya•@,X±bŋÀQ @À»QÌŽÔJÀ1a¢'n;û€@VìðZúk@.µjÕ«V­ÀQ @À»<‚Àí{À2©f3iåM€@Vžve@0‰$H‘"ÀQ @À»%»GÝäÀ3痮Øáž€@V€»ê«]ª@1·nÝ»víÀQ @À» ß‘æÀ5¿ÊF΀@VEÓàî6@2å˗.\¹ÀQ @Àºó⢮|zÀ6EtCÚØè€@V÷#èŸ@4(P¡B…ÀQ @ÀºØê ÌB°À7dYp€Œ€@UÇVÈÌ£˜@5B… +(PÀQ @ÀºŒ¥NE1 À8x ®¥â¥€@U„$ä][”@6páÇÀQ @ÀºŸ!sú]ÕÀ9€‰U~U€@U>”,/ @7Ÿ>|ùóçÀQ @Àº€l ·äÀ:}`u»>`€@Tö×§'€@8͛6lÙ³ÀQ @Àº`“À[bÀ;n€©ð_€@T­"SÊ9à@9û÷ïß¿~ÀQ @Àº?€.±uÀzŒ@Œ³%朊€@Sv~`ÖFD@>µjÕ«V­ÀQ @À¹²&Qâ"À?rÊ[#es€@S%Ód«ô@?ãǏ|ùóçÀQ @À·qKé+òªÀB‡ ÕIG±€@MJ +CÜx @H6lÙ³fÍÀQ @À·Eq}7 üÀB˜Æ +à΀@L«9Â#‰š@H͛6lÙ³ÀQ @À·sÁß7ÀBŠ/pfÌ¥€@L̑Ó@Idɓ&L™ÀQ @À¶íY^ëÀB°gåžê€@Kr»7Ž +N@Iû÷ïß¿~ÀQ @À¶Á, ÂñªÀB¶kÒÀK‡€@JÙD/Ü-Ž@J“&L™2dÀQ @À¶”óŒÆJÀB¹ŒŽ€@JAÈaMÑ{@K*T©R¥JÀQ @À¶h·ÖAJÕÀB¹Š€MLn€@I¬]­¯°@KÁƒ 0ÀQ @À¶<xWaPÀB¶ŒÅ2‚š€@I~¯L@LX±bŋÀQ @À¶Q޲›õÀB°¶»$ƒ€@Hˆ†B×@Lïß¿~ýûÀQ @Àµä4§01šÀBš,%_d€@Gù3;EÂ7@M‡8páÀQ @Àµž.üfƒÀBå»ÄZ€@GlŽÆnžŠ@N.á§ÀA|¹§ ±€@A•ŠENÀ@Rš4hÑ£FÀQ @À³¥n·_ÀA[0cá…€@A)9›æâo@Rå˗.\¹ÀQ @À³d?†9ßéÀA8§ŸqºÐ€@@¿@)0&@S1bŋ,ÀQ @À³;»$\ÀA5ç;·€@@WŽ…-˜¶@S|ùóçϟÀQ @À³‹VŽùÄÀ@ðð)ªi9€@?å!&xFy@Sȑ"D‰ÀQ @À²ë²01çÕÀ@ËêíÝ 5€@?›"DÖJ@T(P¡B…ÀQ @À²Ä1K·§ŒÀ@Š9ºoƒ&€@>^ÈK{ÀŠ@T_¿~ýû÷ÀQ @À² +)KlÀ@ï6ΏC€@=¢˜Þ€^@T«V­ZµjÀQ @À²v>åo7À@Y.&ë€@<êü!éàŒ@TöíÛ·nÝÀQ @À²OÎUx²äÀ@1Ԓi +€@<7à5þ@UB… +(PÀQ @À²)»ÔëàUÀ@ +%€Ï­€@;‰3¥}—W@UŽ8páÃÀQ @À²| ^öÀ?Ä>Šâ¡Ø€@:Þâb/r@UÙ³f͛6ÀQ @À±Þ²z÷äÀ?s Àٕ€@:8Ù¬¹¬p@V%J•*T©ÀQ @À±¹Œ~bÂÀ?"îº€@9—ïa!ü@VpáÇÀQ @À±•&Ý¥-À>ÑrÒ³¥€@8ùOáî)F@VŒxñãǏÀQ @À±pðxžš£À>jR!}ã€@8_¥»R§–@W @ÀQ @À±MsœÚÀ>-й«˜n€@7ÉñqJýY@WS§N:tÀQ @À±)§1Ó’À=۔P™·€@78ɘŽf@WŸ>|ùóçÀQ @À±“Á³ãÀ=‰›fŸ ±€@6ªj ¹@WêÕ«V­ZÀQ @À°ãáòÚõÀ=7³ЬÀ€@6ÂçhÓ¶@X6lÙ³fÍÀQ @À°Ái^¶À<åìücÄـ@5™ÓŸ’@X‚ @ÀQ @À°Ÿœœ À<”Yäꣿ€@5éÇî˜@X͛6lÙ³ÀQ @À°~ M6?À2ßå€@/ÿ*-‚E@\€H‘"DˆÀQ @À­ÕðÿDÀ8N"‚„€@.a,~hO@\ïß¿~ýûÀQ @À­œÝêØkùÀ8¹›Â璀@-²"€L@];víÛ·nÀQ @À­dwNâÛÀ7ŒMÅd΀@-ÀŒÔŒØ@]‡8páÀQ @À­,»^ý2À7t9œZN€@,aå– øß@]Ò¥J•*TÀQ @À¬õšGµfÀ7-#\lž€@+Àp¯ÃŠŠ@^€@%ќøõ/œ@`®Ý»víÛÀQ @Àª‘…hÕõqÀ4äÛ šº€@%\+<¬B,@`Ô©R¥J•ÀQ @ÀªbX‹Ýõ À3ÚQQ•Š€@$é¿<鰒@`útéÓ§NÀQ @Àª3¹ÓïcÁÀ3ž‹Iá]€@$zBÅh±F@a @ÀQ @Àª§\ð™¬À3c‘™›eš€@$  EcÆc@aF 0`ÁÀQ @À©ØE±ZèÀ3)bô{áá€@#£ÂËëŒ%@akׯ^œ{ÀQ @À©«°7z×À2ïýïC ©€@#<–3ÖË@a‘£F4ÀQ @À©~ŠÂ­ À2·a…q€@"Ø1ÚìN@a·nÝ»víÀQ @À©R²€UðÀ2ŠI•Þ€@"v-.AÓ@aÝ:téÓ§ÀQ @À©'A„\ùÀ2Hx׀*$€@"q_oȖ@b 0`ÀQ @ÀšüQ“tÀ2*M{)€@!¹G¿€¥@b(Ñ£FÀQ @ÀšÑáLSØÀ1ܜd1ĵ€@!^qÌD¹Q@bN:téÓÀQ @Àš§îîÀ1§Í× Œ]€@!ތÎ\@bthÑ£FÀQ @Àš~w +®ŒÄÀ1sŒmøHž€@ ¯}ˆçvË@bš4hÑ£FÀQ @ÀšUz®nÀ1@f⮀@ [>Çi¡À@bÀÀQ @Àš,õ˜™À1 ÈÍÞ €@ ÊQ^ªÀP»víÛ·nÀŒkÅQ›&n€€@Y7õ&»*#?òå˗.\¹ÀP»víÛ·nÀŒjÏ`Jÿ¿ú#°¯#”€@Y5rPÖ‚@å˗.\¹ÀP»víÛ·nÀŒgîéÎÂÀ þbφ>«€@Y-ìN†p¬@ X±bŋÀP»víÛ·nÀŒc#B× Àu‚W5€€@Y!j—a‹a@å˗.\¹ÀP»víÛ·nÀŒ\q€ËEÀ߆*ò€@Yù‡áÀÝ@Ÿ>|ùóçÀP»víÛ·nÀŒSÝ:çº9À U¥ý€@XùªB1»@X±bŋÀP»víÛ·nÀŒIkƒ®À#AvÈ/B%€@Xޒ„gî@ ‰$H‘"ÀP»víÛ·nÀŒ=!;Ën À&ZdäÆ8a€@XŸÌuǍ|@"å˗.\¹ÀP»víÛ·nÀŒ/Ý_ŸMÀ)foåïŸk€@XšvlÄ€ê@%B… +(PÀP»víÛ·nÀŒ$ Q3À,cûw/vE€@Xq²¯9=ó@'Ÿ>|ùóçÀP»víÛ·nÀŒ ÉÜ óÀ/Q 1¢€@XD§.(Çm@)û÷ïß¿~ÀP»víÛ·nÀ»ú)ý=“À1ÈÑÇH€@X}=ÜKG@,X±bŋÀP»víÛ·nÀ»å'Zú4À2{kÃ󁺀@WÞaJΐÅ@.µjÕ«V­ÀP»víÛ·nÀ»Î…XrˆÀ3Ö Ô·,Ž€@W¥‚‹zÂ@0‰$H‘"ÀP»víÛ·nÀ»¶P!J; À5&|ýç€@Wi®ßá¡@1·nÝ»víÀP»víÛ·nÀ»œ”~ÅäÀ6kÅrÏg€@W)E‡qœc@2å˗.\¹ÀP»víÛ·nÀ»_ʌOšÀ7€ˆŸšU©€@VæP²@b,@4(P¡B…ÀP»víÛ·nÀ»d¿ÛƱ.À8ÒÝ5[Ҁ@V k:\@5B… +(PÀP»víÛ·nÀ»FÂôËÆ…À9óh6Ñš>€@VWÍ8Ìà%@6páÇÀP»víÛ·nÀ»'w°˜H À;<šCàՀ@V ¯qçÛ@7Ÿ>|ùóçÀP»víÛ·nÀ»ìðA¬ÞÀ<_€Û€õ{÷€@TË«ÜI@µjÕ«V­ÀP»víÛ·nÀº,ÉI!’‰À@™ZwÚ{π@SË`\ÐÙ8@?ãǏ·@Bå˗.\¹ÀP»víÛ·nÀ¹4Œ# Ä§ÀBJ¯Cž¹W€@Q¹‡šcŒ€@C|ùóçϟÀP»víÛ·nÀ¹ ï*Ž7ÀB~éÍb€˜€@Q`ä€Þ@D(P¡B…ÀP»víÛ·nÀžÝ.¢e¹ÇÀB­Ú|@Ã΀@Q†Œõ¡@D«V­ZµjÀP»víÛ·nÀž°Ü‡oÒÀB×­uÈùœ€@P°‹Õæ÷A@EB… +(PÀP»víÛ·nÀž„-5Ç®ÀBü“ /\€@PY­¿@EÙ³f͛6ÀP»víÛ·nÀžW-7JÀC® ò€@P)Fir7@FpáÇÀP»víÛ·nÀž)æô ÀC866N«R€@OWã%@³»@G @ÀP»víÛ·nÀ·üc`ùmœÀCOU?Rå €@N¬ùÀudJ@GŸ>|ùóçÀP»víÛ·nÀ·Î¯mÃsÀCb8f¶€@NŒ¢Á0@H6lÙ³fÍÀP»víÛ·nÀ· Óþª­ÀCq +ö!š=€@M\Nš©0Œ@H͛6lÙ³ÀP»víÛ·nÀ·rؑ 2ÀC{ùݹY€@L¶ÏA¡±˜@Idɓ&L™ÀP»víÛ·nÀ·Dȶ‹@¶ÀCƒ/äZ•€@L[+ßh€@Iû÷ïß¿~ÀP»víÛ·nÀ·¬åßÒÀC†×sP0Հ@Kr ¶,ÿ@J“&L™2dÀP»víÛ·nÀ¶èŠÏ“­<ÀC‡*Å%n€@JÒù>ß%'@K*T©R¥JÀP»víÛ·nÀ¶ºl̚UgÀC„ ÙÂj€@J67;h;ù@KÁƒ 0ÀP»víÛ·nÀ¶ŒYz™2^ÀC~tŠ€@I›ØtŸæŽ@LX±bŋÀP»víÛ·nÀ¶^WðÌÄÉÀCunN€@Ií ȑ@Lïß¿~ýûÀP»víÛ·nÀ¶0nëÏîÀCiWütŒx€@HnƒgŠ^õ@M‡8páÀP»víÛ·nÀ¶€Ì$çÀCZõ|©®-€@GÛ§‡DDÉ@N.¥œÀAŸÒ‡Ž €@@€ÏËϪ@S|ùóçϟÀP»víÛ·nÀ³FúѱÛÀAxAsO•»€@@€ S@Sȑ"D‰ÀP»víÛ·nÀ³íxôœÀAOû5#+~€@?er>ì<@T(P¡B…ÀP»víÛ·nÀ²ô7úõ–ÀA'Ð‡€@>žäiƒÛB@T_¿~ýû÷ÀP»víÛ·nÀ²Ëä7S( +À@ýŸ^qÍã€@=ÝDúâU@T«V­ZµjÀP»víÛ·nÀ²£òöÀ@Ó¯|ŸÝU€@= }å&™@TöíÛ·nÝÀP»víÛ·nÀ²|eEe•XÀ@©U戜=€@sÀ>ô®_9«­€@7ÞôttÑÎ@WS§N:tÀP»víÛ·nÀ±NF”ço.À>{!p1 €@7J iÖ©@WŸ>|ùóçÀP»víÛ·nÀ±*Q)UzÀ>Fc]?Hë€@6¹\Ÿ%,@WêÕ«V­ZÀP»víÛ·nÀ±®oQÀ=ïzL±L€@6+ûЏAù@X6lÙ³fÍÀP»víÛ·nÀ°ãš\i5À=˜ÑHbŠ€@5¢²â8E@X‚ @ÀP»víÛ·nÀ°ÀØcō×À=BxÜøž€@5&ñ}ì@X͛6lÙ³ÀP»víÛ·nÀ°ž|/A¬ÛÀ<쀀1ž€@4›#[°@Y2dɓ&ÀP»víÛ·nÀ°|…Dù +À<–öš°Êà€@4®lœ§@Ydɓ&L™ÀP»víÛ·nÀ°Zóö=ßÀø€@3)øt |@Yû÷ïß¿~ÀP»víÛ·nÀ°ú— åÀ;™kGº€@2µ‰ÅÙ@ZGÀ;F[NŒ€@2DF¯ +Æ@Z“&L™2dÀP»víÛ·nÀ¯±Bš‡À:ó_Bavõ€@1Ö.Pu5@ZÞœzõë×ÀP»víÛ·nÀ¯qÑÉ<ç£À:¡Z6 К€@1jì¯üø@[*T©R¥JÀP»víÛ·nÀ¯3Iûk%iÀ:P +Ïs U€@1ªDÑ$@[uëׯ^œÀP»víÛ·nÀ®õbzsnÀ9ÿwè£d€@0?}ëz»@[Áƒ 0ÀP»víÛ·nÀ®žv9¿ƒ6À9¯§§q÷܀@0:—–Å£ò@\ 4hÑ£ÀP»víÛ·nÀ®|&®ÓŠyÀ9`Ÿˆç€@/µ=ÈC€±@\X±bŋÀP»víÛ·nÀ®@ãhÀ9dgaž€@.ú„MGdw@\€H‘"DˆÀP»víÛ·nÀ®²í)¬hÀ8ÄúŒIè!€@.DÝt5g@\ïß¿~ýûÀP»víÛ·nÀ­ËŠÚ*VAÀ8xe±kÚ܀@-”#b/£]@];víÛ·nÀP»víÛ·nÀ­’¯k‡À8,© Óîh€@,è3@‹P+@]‡8páÀP»víÛ·nÀ­YTiDz£À7áÇ]‚$ù€@,@駯QÛ@]Ò¥J•*TÀP»víÛ·nÀ­!BUœÀ7—Âé\î€@+ž$iœþv@^DO_@å˗.\¹ÀPV­ZµjÖÀŒýÃن³Àš˜\~öó€@Z+?çKÜ@Ÿ>|ùóçÀPV­ZµjÖÀŒôœ°™ŽÜÀ!0žLÈW>€@Zîpœ~µ@X±bŋÀPV­ZµjÖÀŒéxVmÀ$‰n(îj€@YõhsojŒ@ ‰$H‘"ÀPV­ZµjÖÀŒÜ]”+ȁÀ'ÕɄx9ۀ@YÒË¡³@"å˗.\¹ÀPV­ZµjÖÀŒÍTR-JÀ+Òs~T€@Y«:]§r@%B… +(PÀPV­ZµjÖÀŒŒeŽª·tÀ.AÁÊ]t€@Y~ÛiúØv@'Ÿ>|ùóçÀPV­ZµjÖÀŒ©›J©zšÀ0®ô£¥w€@YMÙ žcP@)û÷ïß¿~ÀPV­ZµjÖÀŒ•.äúÀ23ZÀÕ珀@Ycša%á@,X±bŋÀPV­ZµjÖÀŒ~¡ +¯‡rÀ3­WÔÀÝπ@XÞ«^)§@@.µjÕ«V­ÀPV­ZµjÖÀŒf‰ !ÐÀ5?O+)€@X æ +û)@0‰$H‘"ÀPV­ZµjÖÀŒLÇŽ»’À6uqžî¥€@X_KyœÚ”@1·nÝ»víÀPV­ZµjÖÀŒ1ilÜ·À7Ömtþ>€@XéC¡\@2å˗.\¹ÀPV­ZµjÖÀŒ}ˆI­À9 ¬š-9G€@Wс’ ]@4(P¡B…ÀPV­ZµjÖÀ»öMŸ-iÀ:]É_ ô€@W…ÌCY·H@5B… +(PÀPV­ZµjÖÀ»Ö:v€À;l6<€@W74óà™%@6páÇÀPV­ZµjÖÀ»µ4/À<¯Nñ«H+€@VåûPU§§@7Ÿ>|ùóçÀPV­ZµjÖÀ»’}Žš£À=Ã>6ŒkŸ€@V’_E™@8͛6lÙ³ÀPV­ZµjÖÀ»nºlÄ &À>ÉÄ¡õ€@V< ŠG–ë@9û÷ïß¿~ÀPV­ZµjÖÀ»IÊ_ßãÀ?ÀÊŒ«Ề@Uäþ)ž)Õ@;*T©R¥JÀPV­ZµjÖÀ»#Ÿ g|ƒÀ@U+g ¬¢€@U‹¶Q =@µjÕ«V­ÀPV­ZµjÖÀº«–v%gjÀA‰ygâ<;€@TxMä É4@?ãǏî6àö€@Rÿ]ß7Á@BN:téÓÀPV­ZµjÖÀ¹ÓGYL§ÀCðÝ]€@R €÷õq@Bå˗.\¹ÀPV­ZµjÖÀ¹¥Þ‡Ô<ÀC>ÞÁ¥­Î€@RBD¿M³@C|ùóçϟÀPV­ZµjÖÀ¹x*Mtï7ÀCr^Þr ó€@QãœS˜Ç'@D(P¡B…ÀPV­ZµjÖÀ¹J]žŽÀC Al<=€@Q…Ó—³Èû@D«V­ZµjÀPV­ZµjÖÀ¹uíiÞûÀCȹEN—¬€@Q(oÄ0»]@EB… +(PÀPV­ZµjÖÀžì^Ío–ÀCëùŠ«c€@PË­î|ž@EÙ³f͛6ÀPV­ZµjÖÀžœ[œ£ôÀD +5Òùŀ@Po§Ž!ëb@FpáÇÀPV­ZµjÖÀžåóÞÀD# É%­ €@PtbvÏÃ@G @ÀPV­ZµjÖÀž^9ؕѷÀD8m¶ŒG€@OtR=Yxê@GŸ>|ùóçÀPV­ZµjÖÀž.aàçàåÀDHÌc„€@NÁ²a[‰@H6lÙ³fÍÀPV­ZµjÖÀ·þhFõÆÀDTï܌}܀@N*îØHï@H͛6lÙ³ÀPV­ZµjÖÀ·ÎVϙ„öÀD]ŒéáՀ@MbÛޏ*{@Idɓ&L™ÀPV­ZµjÖÀ·ž6Ô3ÎÉÀDaB•šV€@L¶àà :«@Iû÷ïß¿~ÀPV­ZµjÖÀ·n<«£³ÀDaÏÚWQ€@L RøsŸú@J“&L™2dÀPV­ZµjÖÀ·=î†öÑÀD^ÚšFU€@KfHqÈ£I@K*T©R¥JÀPV­ZµjÖÀ· ÖÄÖÀDXµU/á€@JÁÕ Iî@KÁƒ 0ÀPV­ZµjÖÀ¶ÝÑ£ÃS +ÀDOÝI#Á€@J +$¯î@LX±bŋÀPV­ZµjÖÀ¶­æmÓVÑÀDB¥…΀@I€öä‡g@Lïß¿~ýûÀPV­ZµjÖÀ¶~ H&ÀD3X”Ã2€@HäšZa>E@M‡8páÀPV­ZµjÖÀ¶NxüøßèÀD![ìñ#€@HK)Vªw@N0Ìÿ€@DîËF-@PÔ©R¥J•ÀPV­ZµjÖÀµ+ぉ‰ÀCdyØ/YR€@Di ˆ¶>š@Q @ÀPV­ZµjÖÀŽÙ… RÖøÀCB Êı€@Cçbé\Œ @Qkׯ^œ{ÀPV­ZµjÖÀެ1ⳞÀCOÝ%”€@Ch¢ëð(@Q·nÝ»víÀPV­ZµjÖÀŽ3e âÀBù$€^Š€@Bë¢û?ž@R 0`ÀPV­ZµjÖÀŽRŽô†êÓÀBÒœ֕€@Br'á¥É@RN:téÓÀPV­ZµjÖÀŽ&F—žÎ¢ÀB«3¿Oɀ@Aûi`|3«@Rš4hÑ£FÀPV­ZµjÖÀ³ú\ۋQ1ÀB‚£-1 €@A‡ñў@Rå˗.\¹ÀPV­ZµjÖÀ³ÎԐƒ€ÀBY$#*÷€@A„LXõØ@S1bŋ,ÀPV­ZµjÖÀ³£®L› 1ÀB.Ï0H€@@š>#e[@S|ùóçϟÀPV­ZµjÖÀ³xímĔCÀB»)Zæs€@@<ŽXhِ@Sȑ"D‰ÀPV­ZµjÖÀ³N“Ó±MÀA×þ\õ^ý€@?§ºœ}rv@T(P¡B…ÀPV­ZµjÖÀ³$ Ïµ£üÀA«­¯‘ù€@>Û]uNñ@T_¿~ýû÷ÀPV­ZµjÖÀ²ûËðIªÀA~ݧŽI€@>;«ª)ž@T«V­ZµjÀPV­ZµjÖÀ²Ñù) +¯ÀAQŸ˜Û©©€@=R>Ç·é@TöíÛ·nÝÀPV­ZµjÖÀ²©EÒêÈÒÀA$ åŽá€@<•O‡‹Û @UB… +(PÀPV­ZµjÖÀ²€þŒ&ò#À@ö$µš€@;ÝUüšÌ@UŽ8páÃÀPV­ZµjÖÀ²Y#ðIñÀ@Ècì°8€@;*9€=Õ@UÙ³f͛6ÀPV­ZµjÖÀ²1¶vb3À@™ÁP7'¥€@:{ᇍ]ô@V%J•*T©ÀPV­ZµjÖÀ² +¶qf’lÀ@k]·€1€@9Ò4N ö@VpáÇÀPV­ZµjÖÀ±ä$ÍîÃÀ@<ê÷£¬„€@9-Wċã@VŒxñãǏÀPV­ZµjÖÀ±œÿxZÔÀ@u™ßœD€@8ŒsÑÞÑ.@W @ÀPV­ZµjÖÀ±˜H_Þ;À?À­Dr€@7ð,Êýæ@WS§N:tÀPV­ZµjÖÀ±rÿ<%=vÀ?cb>J9M€@7X)E•n@WŸ>|ùóçÀPV­ZµjÖÀ±N#?Ø2iÀ?îIÂOP€@6ÄOI %š@WêÕ«V­ZÀPV­ZµjÖÀ±)ŽH¶<À>ªÉÂÜD.€@64„ñY§þ@X6lÙ³fÍÀPV­ZµjÖÀ±±îkò%À>O<É«ƒ€@5š°}PŒ@X‚ @ÀPV­ZµjÖÀ°âެê¢À=ó³úpÎe€@5 ž[ø8@X͛6lÙ³ÀPV­ZµjÖÀ°Ÿñ ¶G²À=˜áýû#€@4œƒ67ÈQ@Y2dɓ&ÀPV­ZµjÖÀ°œ1VÁ+>À=>ž1‰â€@4÷ýXӏ@Ydɓ&L™ÀPV­ZµjÖÀ°yÛãbQ‹À<äô÷C³€@3žýðTP@@Y°`Áƒ ÀPV­ZµjÖÀ°WïôØ+À<‹ò7­‹€@3%|Šœ¶@Yû÷ïß¿~ÀPV­ZµjÖÀ°6lÀGµ2À<3 lŸ{Հ@2¯\Zé!@ZG@ËÀ;…5Kå¿€@1ÌÞð +yÕ@ZÞœzõë×ÀPV­ZµjÖÀ¯šËŠ7À;/,„Nöh€@1`TWËÖ@[*T©R¥JÀPV­ZµjÖÀ¯h˜ćÀ:Ùõê~€@0öÎr7’@[uëׯ^œÀPV­ZµjÖÀ¯)Á¹"-æÀ:…—óàê€@07^©îQ@[Áƒ 0ÀPV­ZµjÖÀ®ë~;ä‚À:2 N €@0,y²`H@\ 4hÑ£ÀPV­ZµjÖÀ®­þ³¿ºÀ9ßzbÚš€@/—øÂHÏ@\X±bŋÀPV­ZµjÖÀ®qA`z"ŒÀ9ÄMCó€@.Ún’…S@\€H‘"DˆÀPV­ZµjÖÀ®5Cþ°|À9<ù oŽ€@.#A²Ùß@\ïß¿~ýûÀPV­ZµjÖÀ­ú]0!ÅÀ8íµôÖê€@-pËiï@];víÛ·nÀPV­ZµjÖÀ­¿€HÅTbÀ8ž.ìX©€@,Ãmœr¹@]‡8páÀPV­ZµjÖÀ­…µ…ö² À8P4Ë➞€@,ÕY=Ž@]Ò¥J•*TÀPV­ZµjÖÀ­L¡Õ|þvÀ8/=¡€@+vß=p® @^$F2À55ßh>Ã[€@%ñœˆd.ƒ@`‰$H‘"ÀPV­ZµjÖÀ«r_¶zÐÀ4ód¢3Á¯€@%x}°97í@`®Ý»víÛÀPV­ZµjÖÀªÔB‰õ4¡À4±Ùg+ù€@%vš/^@`Ô©R¥J•ÀPV­ZµjÖÀª£¬oíáÀ4q;çÚò€@$žiЧ@`útéÓ§NÀPV­ZµjÖÀªs­ß›dŒÀ41Š2§ÿ€@$°Ó™B@a @ÀPV­ZµjÖÀªDD±&ێÀ3òÂ4T,Ž€@#²Ã Ùë@aF 0`ÁÀPV­ZµjÖÀªnŸ‰šÀ3ŽáÀŽ©€@#H¯×Ýv6@akׯ^œ{ÀPV­ZµjÖÀ©ç)æ<»À3wæ)@ـ@"áannBµ@a‘£F4ÀPV­ZµjÖÀ©¹tž#pÀ3;ÎE'¹z€@"|»‡4Ÿ@a·nÝ»víÀPV­ZµjÖÀ©ŒK"uÕ¹À3–nFbü€@"¿Q„‘@aÝ:téÓ§ÀPV­ZµjÖÀ©_­5SËÀ2Æ<ˆŠ7€@!»CfZŠ~@b 0`ÀPV­ZµjÖÀ©3—Ô·À2ŒŸ0ŒË€@!^;ÎãŽ*@b(Ñ£FÀPV­ZµjÖÀ© fÞ îÀ2T;®Á€@!•ú7u@bN:téÓÀPV­ZµjÖÀšÜÿËÌq†À2HŠ·Ù€@ «?í9@bthÑ£FÀPV­ZµjÖÀš²y ûÂÀ1åL;|Èä€@ U(=*@bš4hÑ£FÀPV­ZµjÖÀšˆs:P¢1À1¯ ’qÅÆ€@ >Tì‚@bÀÀPV­ZµjÖÀš^ìj,äðÀ1yÂÍ×îɀ@^ânFÀOãǏ™¿a)@å˗.\¹ÀOãǏ|ùóçÀOãǏ|ùóçÀOãǏxêÀ@WË<Ò?3d@7Ÿ>|ùóçÀOãǏ%²€@Vµ€J;Œê@;*T©R¥JÀOãǏµjÕ«V­ÀOãǏµE€@Rjðæ`J@D(P¡B…ÀOãǏ|ùóçÀOãǏK1œkÀEŽÛ»ëæ€@I[k›Ëú=@M‡8páÀOãǏGÕÍ'@T«V­ZµjÀOãǏ|ùóçÀOãǏ§ø‹jl€@5 ‚€‚S@X͛6lÙ³ÀOãǏH˜K”€@4š!Û@Y2dɓ&ÀOãǏÚ@]‡8páÀOãǏe¬à+À8 D„•ÿ€@*ª +å]_@^iÓ§N:ÀOãǏ€@#/× O@akׯ^œ{ÀOãǏ(ގÀ3»×Ú£kt€@"®ó[ÂPó@a‘£F4ÀOãǏ§€@!èãŸ¢5@aÝ:téÓ§ÀOãǏ@bN:téÓÀOãǏ|ùóçÀO4hÑ£FÀŸL +v±zžÀ#¥) w›€@\º<4œ"@X±bŋÀO4hÑ£FÀŸ?Ob 7oÀ'uÝ瀬€@\\xÚ1ç<@ ‰$H‘"ÀO4hÑ£FÀŸ0WúJ)>À+7¿¢Å€@\3,6ì,ö@"å˗.\¹ÀO4hÑ£FÀŸ.Ï;QÀ.ætC\• €@\䊁Œ@%B… +(PÀO4hÑ£FÀŸ ÜÚN™’À1@ñ>÷ʀ@[Ï(ÑjP(@'Ÿ>|ùóçÀO4hÑ£FÀœöp±-Î(À3§~æU׀@[”ÚÙ+;‘@)û÷ïß¿~ÀO4hÑ£FÀœÞ÷*8ưÀ4ºhô‡Âó€@[UR\9DÙ@,X±bŋÀO4hÑ£FÀœÅ~ë¹£YÀ6dSÎç+|€@[ÎÝ4»o@.µjÕ«V­ÀO4hÑ£FÀœª›‘ÞŸÀ8™5Ž÷C€@ZǓ¡z)Œ@0‰$H‘"ÀO4hÑ£FÀœŒÑÈP ,À9Ž~'o9œ€@ZyçP¡^@1·nÝ»víÀO4hÑ£FÀœmŸÑ8xyÀ; \b$⪀@Z(Ž>Lâ@2å˗.\¹ÀO4hÑ£FÀœLðÍ;ÑÀ<|£H‚Nç€@YÒd‰•Qü@4(P¡B…ÀO4hÑ£FÀœ*zpå-IÀ=ÛØ¹LVº€@Yy(€PW@5B… +(PÀO4hÑ£FÀœnó])žÀ?*™ÂŸKþ€@Y¯1 Cì@6páÇÀO4hÑ£FÀŒàáòžÀ@4MŽø³€@XœIC µî@7Ÿ>|ùóçÀO4hÑ£FÀŒ¹çWÝgžÀ@ÊÔ¯aRɀ@X[G ï‹í@8͛6lÙ³ÀO4hÑ£FÀŒ‘“:jˆBÀAXÔdq×¶€@WöúÇO/@9û÷ïß¿~ÀO4hÑ£FÀŒgùŐdÀAÞJ`[|߀@W²ê{@;*T©R¥JÀO4hÑ£FÀŒ=/‰OØÀB[?jÈà`€@W(»@÷*@µjÕ«V­ÀO4hÑ£FÀ»¶n»ôjÖÀC °‰‚Œ€@Ué¢é2[@?ãǏ|ùóçÀO4hÑ£FÀžôêúI_œÀF<šÿt«€@P$ú÷j–@H6lÙ³fÍÀO4hÑ£FÀžÀ\]XŠcÀFB+[ô¥€@O…Ž€G[p@H͛6lÙ³ÀO4hÑ£FÀž‹ÅèXßöÀFCVŽì燎@NÄWùr«`@Idɓ&L™ÀO4hÑ£FÀžW1›}mÀF@a“b!š€@N÷íå4(@Iû÷ïß¿~ÀO4hÑ£FÀž"šóë’ÀF9‚xހ@MJª”/Ë¥@J“&L™2dÀO4hÑ£FÀ·î4ô8jnÀF.íx Sm€@L’ƒQ3“¬@K*T©R¥JÀO4hÑ£FÀ·¹Þ#£JÏÀF Õ=ìÙy€@Kݒò!"@KÁƒ 0ÀO4hÑ£FÀ·…¬“÷»‰ÀFjôåãå€@K+çÝz?G@LX±bŋÀO4hÑ£FÀ·Q§ä•€ÀEúÞY82€@J}Žê@OãǏõ¹@Rš4hÑ£FÀO4hÑ£FÀŽiàÇÀCœÐkIG‹€@AÞÚÓÈo@Rå˗.\¹ÀO4hÑ£FÀŽ;x/ÅjÛÀCŒ_斪 €@Afu”& c@S1bŋ,ÀO4hÑ£FÀŽ …òºÚÀCZ5&¥G€@@ñ7züAŸ@S|ùóçϟÀO4hÑ£FÀ³à +ú8=ŽÀC'ixCa€@@쯚æ@Sȑ"D‰ÀO4hÑ£FÀ³³§.ÈRÀBôJ¶Ë}€@@ý­u‚øS©ô@T«V­ZµjÀO4hÑ£FÀ³.à9.-iÀBW²Š6€@=šý6Ÿ[@TöíÛ·nÝÀO4hÑ£FÀ³Ê/Õ\€ÀB# àÜÄy€@<âœ3Hß@UB… +(PÀO4hÑ£FÀ²Ù0¹ î»ÀAî|ùóçÀO4hÑ£FÀ±•ìÌyšxÀ@I‡m"-€@6Κïe(@WêÕ«V­ZÀO4hÑ£FÀ±o°˜k —À@Þý í/€@69&ªÛ@X6lÙ³fÍÀO4hÑ£FÀ±Iî‘gÃÀ?Ń\ûù€@5šÎÉTç@X‚ @ÀO4hÑ£FÀ±$€BÑ +OÀ?_0=$Tƒ€@5Aa@@X͛6lÙ³ÀO4hÑ£FÀ°ÿÒg !À>ú> —}€@4“Ç ðé‚@Y2dɓ&ÀO4hÑ£FÀ°Ûw•Ï…À>•ÆQ›€€@4C–r°€@Ydɓ&L™ÀO4hÑ£FÀ°·’†æòsÀ>2^ +Ÿ|"€@3Ž˜‹Ei…@Y°`Áƒ ÀO4hÑ£FÀ°”"l͍À=ÏáÔÖ€@3ª F”@Yû÷ïß¿~ÀO4hÑ£FÀ°q&ØNxÀ=n[1éA€@2˜\‚=d@ZGŸ@\ïß¿~ýûÀO4hÑ£FÀ®V:tÛÀÀ9ÙHΣi€@-<' …Ö@];víÛ·nÀO4hÑ£FÀ®XÔvŸÀ9ƒ»]&O€@,io +'ÍS@]‡8páÀO4hÑ£FÀ­Ý®ù§Õ'À9/Sæ»D€@+Ÿ¥µÏzó@]Ò¥J•*TÀO4hÑ£FÀ­¢“ 8þúÀ8Ü«ix¥€@+žFbZ@^ÆÀ5M=Ø +nK€@$™Ý×òë€@`Ô©R¥J•ÀO4hÑ£FÀªãÍ ÿ0äÀ5vûj €@$&äBÑ¡Ž@`útéÓ§NÀO4hÑ£FÀª²n¢5¥À4ĺ–Ïa΀@#·çŸXT@a @ÀO4hÑ£FÀª®hÂÒèÀ4‚A&üW€@#J3}›^@aF 0`ÁÀO4hÑ£FÀªQІyËoÀ4@[æÜìb€@"àJªÁ=@akׯ^œ{ÀO4hÑ£FÀª"”,¶ÁÀ3ÿ²Gî,p€@"y8¬ŒÒ˜@a‘£F4ÀO4hÑ£FÀ©ó2“ý!À3À*²µ€@"æŠÀ¯@a·nÝ»víÀO4hÑ£FÀ©Ä± +H·À3Yò² ç€@!³>ïW8™@aÝ:téÓ§ÀO4hÑ£FÀ©–æËßæÒÀ3C€z?'p€@!T,“Ö8º@b 0`ÀO4hÑ£FÀ©i­0}À3ä=FY€@ ÷›RÍŒ@b(Ñ£FÀO4hÑ£FÀ©=÷tÀ2ËÆè¡£€@ w–-Ž@bN:téÓÀO4hÑ£FÀ©âë6"5À25Ÿâ€@ E®mµà3@bthÑ£FÀO4hÑ£FÀšåMÜwæœÀ2V@E¯ò€@à[$X#@bš4hÑ£FÀO4hÑ£FÀšº@€ª8«À22@‚4f€@9Æj*/@bÀÀO4hÑ£FÀš¹%|ŽëÀ1å |A€@—|¢é€)ÀNP¡B… +À¿"þHh›ÿ€€@^/ù^}e?òå˗.\¹ÀNP¡B… +À¿!ŒhüÞ(ÀHw<; €@^,_þ1BŽ@å˗.\¹ÀNP¡B… +À¿÷±žrïÀ- xŠ€@^!™B3÷Ê@ X±bŋÀNP¡B… +À¿²Õ\~ÀrŽcÉÔ>€@^²3Füz@å˗.\¹ÀNP¡B… +À¿òK×kÆÀ è쁚Ҁ@]öÀœ¬@Ÿ>|ùóçÀNP¡B… +À¿ŒG¹¹æÀ% £öJ:_€@]Öâñ%%@X±bŋÀNP¡B… +ÀŸöš/€À)!¥Q|;ò€@]°>|-@ ‰$H‘"ÀNP¡B… +ÀŸæè/­VÀ-%FÔ: €@]ƒ êü@"å˗.\¹ÀNP¡B… +ÀŸÓ° ~øÊÀ0Š€1¢|M€@]OZ²ýìŒ@%B… +(PÀNP¡B… +ÀŸ¿Š»ÐÀ2w6äùäd€@]‡TÛÒ.@'Ÿ>|ùóçÀNP¡B… +ÀŸš:‘à©À4Wš0jð€@\ÕÄ ìZ¥@)û÷ïß¿~ÀNP¡B… +ÀŸŽú;Qí¿À6*È3‹@À€@\T6Ά@,X±bŋÀNP¡B… +ÀŸsŸÛhWJÀ7ïŸ,_JX€@\E~ÌŠŽŸ@.µjÕ«V­ÀNP¡B… +ÀŸVu‚?óÀ9¥J=M“·€@[õÁ7³@0‰$H‘"ÀNP¡B… +ÀŸ70–i7rÀ;Jü]6IŸ€@[ ÖWî4Ü@1·nÝ»víÀNP¡B… +ÀŸc7aºÀ<ßÿqøðž€@[G¥O«€O@2å˗.\¹ÀNP¡B… +Àœóüë‡åÀ>cµ‡=•y€@ZêR\­:{@4(P¡B…ÀNP¡B… +ÀœÎA#yjýÀ?՚¬€¥€@Z‰5€€ž€@5B… +(PÀNP¡B… +Àœ§Ö#ã·JÀ@š¡kž-B€@Z$©"^DŽ@6páÇÀNP¡B… +ÀœÖžo†ÀAA0–Q$¢€@Yœñfª@7Ÿ>|ùóçÀNP¡B… +ÀœVXè æIÀAÞa‡íí€@YR­…xݑ@8͛6lÙ³ÀNP¡B… +Àœ+rå‚Q7ÀBr'߅)€@XåôÔL`/@9û÷ïß¿~ÀNP¡B… +ÀŒÿ:îèu~ÀBü„µD®X€@Xw7zNÎÁ@;*T©R¥JÀNP¡B… +ÀŒÑÇ..ñšÀC}ˆïŸ–œ€@XÌã¿9À@ +ò@>µjÕ«V­ÀNP¡B… +ÀŒBßBäÀDÉÃ6 _Հ@V®·=y:2@?ãǏ¹@GŸ>|ùóçÀNP¡B… +À¹[ڕF@ÀGK_Öý(€@P‰ø/܍X@H6lÙ³fÍÀNP¡B… +À¹$Ñ92ZaÀGLÙ¢°‰E€@P"„Nî)Ž@H͛6lÙ³ÀNP¡B… +Àžíɶ|ŸZÀGIÙ^»¿G€@Oyf͏Í6@Idɓ&L™ÀNP¡B… +Àž¶ÎY¡èÀGBšéYT–€@N±"•@Iû÷ïß¿~ÀNP¡B… +ÀžèâÉÀG7X„¬6€@MìN@ 8Ö@J“&L™2dÀNP¡B… +ÀžI"Œ-ÜÀG(H‰cΑ€@M*üÆþM@K*T©R¥JÀNP¡B… +Àž„§ÀG¢`;z €@LmÏʀ@Hï`'™÷@NµjÕ«V­ÀNP¡B… +À¶ÏŸåGû&ÀFe`Œ` €@HGÁ#Ýh9@OL™2dɒÀNP¡B… +À¶›e£‹œÀF>üŽ. €@G£ß¯&€@OãǏrHÀEì"•Ž6€@FgP‘çã @P‰$H‘"ÀNP¡B… +Àµÿ(xH ÀE¿žÕÌì€@E̚H[@PÔ©R¥J•ÀNP¡B… +ÀµËíw©ÊÀE‘ÉE*Ï7€@E9™µÑŒÆ@Q @ÀNP¡B… +Àµ™1+»,¿ÀEbV7Öaˀ@DšA/qÊR@Qkׯ^œ{ÀNP¡B… +Àµfç}x$ÀE1ƒÇÙW€@D‹ìÝ)@Q·nÝ»víÀNP¡B… +Àµ5ÎîmÓÀDÿqðªuã€@Cn lÇè@R 0`ÀNP¡B… +ÀµŽ&r®ÀDÌCmj'g€@C ßæl€ú@RN:téÓÀNP¡B… +ÀŽÒЖã[ŸÀD˜ÖÊ@N€@B†Õ;]‡9@Rš4hÑ£FÀNP¡B… +ÀŽ¢iTÚ!ÉÀDc +蚶€@BAž ]x@Rå˗.\¹ÀNP¡B… +ÀŽr€OïØ^ÀD-9¥ÎH¿€@A‹.€í@S1bŋ,ÀNP¡B… +ÀŽC6{ÀCöÀ³!;Œ€@AJ¥ýH@S|ùóçϟÀNP¡B… +ÀŽ/vqÃéÀC¿¹þ†n€@@œÊu5d@Sȑ"D‰ÀNP¡B… +À³åÊETVúÀCˆ:ûûò€@@*ˆVF@T(P¡B…ÀNP¡B… +À³·èž¢&#ÀCP]Œ@$í€@?vèöì›@T_¿~ýû÷ÀNP¡B… +À³Š‹IQ‚!ÀC7 Ž~x€@>žýGԞ@T«V­ZµjÀNP¡B… +À³]²ÚdwcÀBßÛmМ€@=Í,ZZú@TöíÛ·nÝÀNP¡B… +À³1_·äM+ÀB§^$9R€@=TR»Š%@UB… +(PÀNP¡B… +À³’É9ÀBnÑ=È€@<;RÙôÛ@UŽ8páÃÀNP¡B… +À²ÚJ̙…ÀB6Eš~wž€@;{BfY @UÙ³f͛6ÀNP¡B… +À²¯‡“$JUÀAýÊ÷d;0€@:ÀH¥å_q@V%J•*T©ÀNP¡B… +À²…JW%é6ÀAÅoù †%€@: +ú[4Y@VpáÇÀNP¡B… +À²[’ÑÀAB6p΀@9ZöT9cÚ@VŒxñãǏÀNP¡B… +À²2^+@1OÀAUNBZ?ô€@8°®Âš@W @ÀNP¡B… +À² ®*þ¯ÀAŸ¹Å®ü€@8 +DR/ÒŽ@WS§N:tÀNP¡B… +À±áVDÈSÀ@æAKÆýu€@7iP¿ËäŒ@WŸ>|ùóçÀNP¡B… +À±¹Öä>ãÀ@¯<Æh‰Œ€@6Í̲£@WêÕ«V­ZÀNP¡B… +À±’­õ\OÀ@x›!tÊK€@65‰®”º@X6lÙ³fÍÀNP¡B… +À±l–«lÈÀ@Bd‰tƒ€@5¢sàl@X‚ @ÀNP¡B… +À±EÜÂ>u\À@  j~ۀ@5¹fŸ@X͛6lÙ³ÀNP¡B… +À± 2aœœ(À?®ªõ”oG€@4‰;|‹Èµ@Y2dɓ&ÀNP¡B… +À°ûO?kÇÀ?EŠ £Z€@4ÚzqzÒ@Ydɓ&L™ÀNP¡B… +À°ÖTXÑ6À>܅d®3±€@3€vò¢ù¢@Y°`Áƒ ÀNP¡B… +À°²=mÏÀ>u +(#Q"€@3òdÿ>•@Yû÷ïß¿~ÀNP¡B… +À°ŽaŽJtŒÀ>ªZ&Ä­€@2‡.øg,@ZG¶@^µjÕ«V­ÀNP¡B… +À­fwçÀ8Meݹ€@) °&>@_ ÀNP¡B… +À¬ä^_Ö 'À7ûáò;—þ€@(uV™-ôó@_L™2dɒÀNP¡B… +À¬¬ÈÍ@zÀ7«—ã Ýڀ@'å64'ń@_˜0`ÁƒÀNP¡B… +À¬t‰k?xÙÀ7\„| h€@'Y,kr®@_ãǏ„í'@a @ÀNP¡B… +ÀªŸâ:ZâîÀ4ɉ/»¬€@#œ±Šë@aF 0`ÁÀNP¡B… +Àªo»{€ÄÀ4…î,7LÀ@"ŠðÀe@akׯ^œ{ÀNP¡B… +Àª>몠á”À4Ce\Ðsô€@"@"ْŠ$@a‘£F4ÀNP¡B… +Àª[„Œf(À4êÝ]O€@!ÜõÎÀ&@a·nÝ»víÀNP¡B… +À©àdÏÒ)2À3ÁzÒÅý€@!zÅÓWŽV@aÝ:téÓ§ÀNP¡B… +À©² ±ÝÀ3‚[¥åø€@! +ño°ô@b 0`ÀNP¡B… +À©„9þº€ûÀ3Cª’\çV€@ ¿Ö‰”×@b(Ñ£FÀNP¡B… +À©Wsç-À3BŽßl_€@ f‰DAÙ@bN:téÓÀNP¡B… +À©*X« À2ÉÕhl캀@ ±‹ëêq@bthÑ£FÀNP¡B… +Àšþ<ºfÜÀ2Ž_7i€@s5ªD>@bš4hÑ£FÀNP¡B… +ÀšÒ¬«ä&ÐÀ2SÜ>Øô€@Í|”€ép@bÀÀNP¡B… +Àš§¥¹æý À2H ŽQ€@,ÜIáÓÀM‡8páÀ¿åº§u‹€€@_š¡R!`¯?òå˗.\¹ÀM‡8páÀ¿äaSƒgÀII»¥Û…€@_€«›04X@å˗.\¹ÀM‡8páÀ¿àUÇt÷ÀB]Ýø‰€@_˜Ï҃;…@ X±bŋÀM‡8páÀ¿Ù™ç[ÈUÀRSLƒ0€@_…åûÀy@å˗.\¹ÀM‡8páÀ¿Ð4’†À"&ê3×NA€@_i¯æ> 8@Ÿ>|ùóçÀM‡8páÀ¿Ä,µàÀ&—3º«Ø€@_F©\¿ôÀ@X±bŋÀM‡8páÀ¿µ‰H;ÓÀ*öë|ùóçÀM‡8páÀ¿aåh~‡eÀ5ËŽ;H€@^,¢¶›1@)û÷ïß¿~ÀM‡8páÀ¿FÿŠÇݐÀ7œœ)æ-3€@]àœÖÍ@,X±bŋÀM‡8páÀ¿)×9WâÀ9Ÿšº”ƒv€@]Žœ- @.µjÕ«V­ÀM‡8páÀ¿ ++¶Ä•À;p·iIœW€@]7X: +{@0‰$H‘"ÀM‡8páÀŸé JL<¯À=0Ø¥å׀@\ÚÆáJ;ñ@1·nÝ»víÀM‡8páÀŸÅ“쫯ÝÀ>Üފ €Ç€@\yeאr@2å˗.\¹ÀM‡8páÀŸ ,cÏLÀ@;9Jâ‡Ë€@\•mû @4(P¡B…ÀM‡8páÀŸxìœy9¿À@þÃâ¶v€@[©žû_Í?@5B… +(PÀM‡8páÀŸO졆FÀA¶ãv|€@[<60bÈÜ@6páÇÀM‡8páÀŸ%D-OrÀBe_yÐ¥€@ZËtBËÜÁ@7Ÿ>|ùóçÀM‡8páÀœù ͗,ÀC y†nòþ€@ZWÚöä@8͛6lÙ³ÀM‡8páÀœË\Ã¥”ÀC£(?ãr€@Yáы?™@9û÷ïß¿~ÀM‡8páÀœœM£;a™ÀD2r×ô€€@YiœŠÓ×J@;*T©R¥JÀM‡8páÀœkøãážÿÀD·pµ³€@Xð9Šî@µjÕ«V­ÀM‡8páÀŒÔDƒe“CÀF +JŒ5!€@W|ƒ$ïz@?ãǏ|ùóçÀM‡8páÀ¹ÅL"?­€@JÀåq0`@M‡8páÀM‡8páÀ·‹ŠŠü“ÐÀG—$~÷E€@J + ³ ¶Î@NBO=c@NµjÕ«V­ÀM‡8páÀ·ÐÀ×¶}ÀGFûš‘€@Hšƒ–%ŸÌ@OL™2dɒÀM‡8páÀ¶æw{žÀGeNge€@GýØq0s@OãǏ1\Jk€@C8A“K@RN:téÓÀM‡8páÀµ m[EݗÀEGA˧܀@B°Â»Ü1@Rš4hÑ£FÀM‡8páÀŽÛmíÆ;ÀE wŒ €@B-#“ Ÿÿ@Rå˗.\¹ÀM‡8páÀŽ©÷Ø|9ÀDÒý ÒZ€@A­(QFÆN@S1bŋ,ÀM‡8páÀŽy ™ÄœöÀD—ï–¢Òl€@A0ÀºäÌ@S|ùóçϟÀM‡8páÀŽH­oQf]ÀD\h@oó®€@@·Ûâ=îŠ@Sȑ"D‰ÀM‡8páÀŽÛYŠ&¬ÀD €É­Œ €@@BhFù¹`@T(P¡B…ÀM‡8páÀ³é—†ÅŠÀCäPìØåK€@? §Éxõ@T_¿~ýû÷ÀM‡8páÀ³ºáQVŸÀC§îúž,ˀ@>Ú +øÄ@T«V­ZµjÀM‡8páÀ³ŒºLeå€ÀCkoâ·e€@=ëýv™^K@TöíÛ·nÝÀM‡8páÀ³_">(>–ÀC.ç|ùóçÀM‡8páÀ±ÝºætÒÀAª绲€@6ÆÕ«QI@WêÕ«V­ZÀM‡8páÀ±µ¡T»[6À@ÜîòxùF€@6,у³Š@X6lÙ³fÍÀM‡8páÀ±ŽŠs£À@£µê€ m€@5—zEiG@X‚ @ÀM‡8páÀ±gJЧ)À@kôÖ+€@5«Æ)*ú@X͛6lÙ³ÀM‡8páÀ±@~K nÀ@2å^g:€@4zC ÉŒF@Y2dɓ&ÀM‡8páÀ±|33}À?ö³ƒù.ž€@3ò"eçÌ@Ydɓ&L™ÀM‡8páÀ°ôü›:€À?ˆÐ,Ɂ€@3nQ¡- @Y°`Áƒ ÀM‡8páÀ°Ïþ9˜‚À?)j…¹®€@2îôˆµ@Yû÷ïß¿~ÀM‡8páÀ°« ŽþÀ>°ÆÎSÞ^€@2qú)îº@ZGF®ÙéèȀ@1ùœ… +r@Z“&L™2dÀM‡8páÀ°cùèþRlÀ=Ýç)­€@1„â߄A@ZÞœzõë×ÀM‡8páÀ°@ðŠTvjÀ=vtß_C€@1¯‹9A­@[*T©R¥JÀM‡8páÀ°`­%tÀ=Y•£)r€@0¥åºš ¶@[uëׯ^œÀM‡8páÀ¯øÀ–ÎÀ<«š•ßÕ5€@0;ikþ-š@[Áƒ 0ÀM‡8páÀ¯µLÀÃûšÀÐzI-@\ 4hÑ£ÀM‡8páÀ¯rñã×:VÀ;æ7EÓ{k€@.ßڃ‹ö@\X±bŋÀM‡8páÀ¯1|êÕ¡xÀ;…•r +€E€@.r€œ#Œ@\€H‘"DˆÀM‡8páÀ®ðê•@kYÀ;&T-¯é€@-`ÕqƒS@\ïß¿~ýûÀM‡8páÀ®±7¢_nRÀ:ÈsM€€@,©Ò«“>Ò@];víÛ·nÀM‡8páÀ®r`ÒpÕÈÀ:kò-•N€@+ø;’ˆ®Ô@]‡8páÀM‡8páÀ®4bç¿©À:Ï·ÚX—€@+KâÜû*@]Ò¥J•*TÀM‡8páÀ­÷:§ž)ˆÀ9· +qÆ5€@*€œ±¿@^œÔ_c@^iÓ§N:ÀM‡8páÀ­^PåöÀ9ºÕt³€@)dŸÔWú@^µjÕ«V­ÀM‡8páÀ­D£ÛÞIÀ8±Õ¢Ï’£€@(Ë—É j@_ ÀM‡8páÀ­ +²UïkyÀ8]o{6O¢€@(7â%—@_L™2dɒÀM‡8páÀ¬Ñ†Ÿ“ÝsÀ8 +ZGž€@'е²î‘y@_˜0`ÁƒÀM‡8páÀ¬™ •{^À7ž’ÔWbƒ€@'’Ru…Û@_ãǏÀ5è~Ì'ÿ€@$!º”Ò5@`Ô©R¥J•ÀM‡8páÀ«"Š¹fËÀ5Ÿo "ij€@#¯!IÖò@`útéÓ§NÀM‡8páÀªïÎÔ]XîÀ5W$³q€@#?¹ ¥Ö=@a @ÀM‡8páÀªœžƒê„ýÀ5Û/Œ‚€@"Óg‘£Ó@aF 0`ÁÀM‡8páÀªŒGÝñ¯À4ËL•v“ž€@"j|Cœ@akׯ^œ{ÀM‡8páÀª[z-·Ž&À4†ßó@秀@"€LŒS@a‘£F4ÀM‡8páÀª+LÎ –GÀ4Cëæî€@! \—ð@a·nÝ»víÀM‡8páÀ©ûœ –QTÀ4[XpF€@!?×íb[@aÝ:téÓ§ÀM‡8páÀ©ÌȒ/ŠôÀ3À:'ÈA~€@ àËŽº²@b 0`ÀM‡8páÀ©žlšýÊÀ3€)¥‹“€@ … ¬w¬u@b(Ñ£FÀM‡8páÀ©pй×Ï(À3A%6×R‘€@ +Â5G›é@bN:téÓÀM‡8páÀ©Ct}cSJÀ3(~Wh³€@©¶ö¡7@bthÑ£FÀM‡8páÀ©Ózè¢ÛÀ2Æ/#–Îñ€@ˆŽ¬T'@bš4hÑ£FÀM‡8páÀšêÁRÖ£®À2Š4ÔLV§€@[Ô¯w§@bÀÀM‡8páÀš¿;¯É7À2O5Eƒç—€@»w6Éu.ÀLœzõëׯÀÀY †Gx @`žŸ}ô!0?òå˗.\¹ÀLœzõëׯÀÀXO—ŒÏèÀ¬5ùK¿€@`œŽé3$R@å˗.\¹ÀLœzõëׯÀÀV"`ø6;À€M²©³€@`–tTš@ X±bŋÀLœzõëׯÀÀRƒ¡@‡ÎÀbÌ.#­+€@`‹%ÜGÏd@å˗.\¹ÀLœzõëׯÀÀMv:îrÀ#…Qÿ-“€@`|ønì@Ÿ>|ùóçÀLœzõëׯÀÀFþ&é­À(IP\ +Gñ€@`h¹1x@X±bŋÀLœzõëׯÀÀ? nƒâ™À,úÞo9Tk€@`QYߐ²@ ‰$H‘"ÀLœzõëׯÀÀ5ãýSÀ0Ë2\£ €@`6›Eð@"å˗.\¹ÀLœzõëׯÀÀ+M€ +À3 h…—æ€@`á Vt¥@%B… +(PÀLœzõëׯÀÀfPk~ÙÀ5?¢)Ë?€@_è º7þ@'Ÿ>|ùóçÀLœzõëׯÀÀ7]vÓ+À7cˆwv€Ä€@_›u y%]@)û÷ïß¿~ÀLœzõëׯÀÀɯþÁˆÀ9vÜVKˀ@_Hç÷l@,X±bŋÀLœzõëׯÀ¿èNÓҝãÀ;xuÎø(€@^î\ËDu@.µjÕ«V­ÀLœzõëׯÀ¿Æ¶ªèÀ=gD)FÒ€@^ŽšìŽ@0‰$H‘"ÀLœzõëׯÀ¿¢áª~š]À?BNè-Îo€@^)Zԝ‡@1·nÝ»víÀLœzõëׯÀ¿|ç²äÜ5À@„[ÔЃŸ€@]ŸÜŸHÞZ@2å˗.\¹ÀLœzõëׯÀ¿Tá—>Ê8ÀA\ÞZÙ|“€@]O™uTƒÕ@4(P¡B…ÀLœzõëׯÀ¿*èýê§£ÀB*]÷ƒ¡k€@\Ü?Ÿ—@5B… +(PÀLœzõëׯÀŸÿ8¡IþÀBìšàݖ+€@\d‡ì@6páÇÀLœzõëׯÀŸÑŠúâÀC£g՞Œ^€@[é QSTB@7Ÿ>|ùóçÀLœzõëׯÀŸ¢YÉ3QcÀDN«q €@[kÃ/ˆåŒ@8͛6lÙ³ÀLœzõëׯÀŸq¢’[ÿ«ÀDî^X°â<€@Zëe“‚{g@9û÷ïß¿~ÀLœzõëׯÀŸ?¹ø>ÀE‚m(ã€@Zhû|‚?Ç@;*T©R¥JÀLœzõëׯÀŸ Jq÷ìÀF ^”΋#€@Yäõ–ŽŸÜ@µjÕ«V­ÀLœzõëׯÀœjÖhhí«ÀGcœ£€®€@XSVט@?ãǏ|ùóçÀLœzõëׯÀº1J¥‰T9ÀI–I²÷€@QYB»ð@H6lÙ³fÍÀLœzõëׯÀ¹ôâÆ%ÀIÔ*j€@PæR®Lô–@H͛6lÙ³ÀLœzõëׯÀ¹ž”&ÀI€ØwžŽ€@Puv¯eš@Idɓ&L™ÀLœzõëׯÀ¹|iSˆ åÀIomEY{¿€@P¶ïºX@Iû÷ïß¿~ÀLœzõëׯÀ¹@l·T@hÀIYÓ¬òŠ€@O44•ÍØ@J“&L™2dÀLœzõëׯÀ¹§ÓœÔÕÀI@JÙ €@N_LڎÄ'@K*T©R¥JÀLœzõëׯÀžÉ#®xߟÀI# ‘Ø¢u€@MŽ¿ÞÞy6@KÁƒ 0ÀLœzõëׯÀžèÁüÓ~ÀITý…ö,€@LÂ”Ó Óõ@LX±bŋÀLœzõëׯÀžRÿU³ÀHÞ[M匏€@KúÑø)@Lïß¿~ýûÀLœzõëׯÀžmá‡vŸÀH·U­úŠq€@K7wÝÝŽ[@M‡8páÀLœzõëׯÀ·ÞmÜOÀE}º{5€A€@AÌ]c{²Ê@S1bŋ,ÀLœzõëׯÀޝ]tÒ9¬ÀE=ÌÊ “ú€@ALS==x'@S|ùóçϟÀLœzõëׯÀŽ}|%ËÁ²ÀDý~dž΀@@Ðí_JO@Sȑ"D‰ÀLœzõëׯÀŽL3é*(ÀDŒé,8Á€@@W[}2¡@T(P¡B…ÀLœzõëׯÀŽ‚ÒpåOÀD|%r‹A€@?Ċú%µ@T_¿~ýû÷ÀLœzõëׯÀ³ëk«2ÀD;J ™å€@>áZúŽ †ú@TöíÛ·nÝÀLœzõëׯÀ³ý#Û×ÀC¹Ÿ²–z€@=/AT’)@UB… +(PÀLœzõëׯÀ³^œ%kF"ÀCx÷#B±€@<`³ù́@UŽ8páÃÀLœzõëׯÀ³1 ÎæµûÀC8ƒ3'ò€@;—µU]@UÙ³f͛6ÀLœzõëׯÀ³îi‹YjÀBøTŒ?‹{€@:ÔOh¬ÉŠ@V%J•*T©ÀLœzõëׯÀ²×jAy~öÀBžy=9ç€@:ƒ˜2€@VpáÇÀLœzõëׯÀ²«|Ý²ÅÀBxþkñ±¢€@9`‡åTz@VŒxñãǏÀLœzõëׯÀ²€$7°lŽÀB9ðwW/€@8¯1:v;@W @ÀLœzõëׯÀ²U`TTþÀAûZ¢^(L€@8Wöhe‚@WS§N:tÀLœzõëׯÀ²+/°³ÀAœFìþۀ@7\Ò +ŠÑI@WŸ>|ùóçÀLœzõëׯÀ²‘ pÎ×ÀAŸ‡ŒM{€@6»våþÊ1@WêÕ«V­ZÀLœzõëׯÀ±ØƒwjÀABÉ­û¶L€@6¡â&@X6lÙ³fÍÀLœzõëׯÀ±°hŒŒÙÀAo¿“Lê€@5‡¢öUe@X‚ @ÀLœzõëׯÀ±ˆf‡À@Ê·L+ˆ%€@4ôÚřWù@X͛6lÙ³ÀLœzõëׯÀ±`¯³À@Š! Ȁ@4f£%d®@Y2dɓ&ÀLœzõëׯÀ±9Õ/@IÀ@UAU=&ï€@3ÜÖg’@Ydɓ&L™ÀLœzõëׯÀ±„~šûÀ@U®Þ,€@3WPŸ3ùò@Y°`Áƒ ÀLœzõëׯÀ°í»GÓ›À?ÅálF€@2ÕîÀ(@Yû÷ïß¿~ÀLœzõëׯÀ°ÈwÜ]UxÀ?TŒÀ2^À€@2XŽ¡ˆ|µ@ZGår«.ÑE€@1ß@ä@Z“&L™2dÀLœzõëׯÀ°{‹‡Ø À>wÒ¥Zí€@1iOƒDYè@ZÞœzõë×ÀLœzõëׯÀ°[¿*ðveÀ> ®G£­ö€@0÷0Ž9¹H@[*T©R¥JÀLœzõëׯÀ°8¡hÆÀ=¡ +Œ±ì€@0ˆ”Ðz»@[uëׯ^œÀLœzõëׯÀ°Á(͎À=7ç3Aـ@0[ÓVC+@[Áƒ 0ÀLœzõëׯÀ¯æ÷ò~НÀ<ÐFÏ@*K€@/jÖªñ%‰@\ 4hÑ£ÀLœzõëׯÀ¯£`“ç¶ÓÀ€@+ ˜4¹ê@]Ò¥J•*TÀLœzõëׯÀ® ü6'¹À:%ùÔE`€@*aè˜Syú@^œ @a·nÝ»víÀLœzõëׯÀªŽå·$À4@ê4Úî€@!'CÝ'ã@aÝ:téÓ§ÀLœzõëׯÀ©ç,AT+À3þ«ÏNƀ@ ¢døë0§@b 0`ÀLœzõëׯÀ©ž@7¹ŽÀ3ŒQűöä€@ G1×ŒÃ@b(Ñ£FÀLœzõëׯÀ©‰î)È ÞÀ3{®‹ÇÅ €@Üðn†Šö@bN:téÓÀLœzõëׯÀ©\3ƒÛëÑÀ3< &ó4π@0IŽDw@bthÑ£FÀLœzõëׯÀ©/ œ³òÞÀ2ý¡ÉW:”€@ˆFÌ4±@bš4hÑ£FÀLœzõëׯÀ©zZ\€‰À2À.¯}•$€@äÁÌ:‚@bÀÀLœzõëׯÀšÖvèBpÀ2ƒÂ!])݀@E—äO“ˆÀKóçϟ>}ÀÀÄa^å`€€@axŸ.Ö“?òå˗.\¹ÀKóçϟ>}ÀÀØŠ{šÄÀ4ÄԘ€«€@avRÖá‹@å˗.\¹ÀKóçϟ>}ÀÀÁ@ ¶™À+¢E+ó€@ao€¬“@ X±bŋÀKóçϟ>}ÀÀœYAŸ21ÀªÆ¿Þhր@ac `‰©%@å˗.\¹ÀKóçϟ>}ÀÀ·çŸt»§À%“SÀ€@aRMçÂa@Ÿ>|ùóçÀKóçϟ>}ÀÀ°ïŽ,,À*(AŒ¶IN€@a<÷k1@X±bŋÀKóçϟ>}ÀÀšw>èrÝÀ/3ÜS €@a#'ŽžS@ ‰$H‘"ÀKóçϟ>}ÀÀž…pLÙÀ2ñe&뙀@a}Œ4»@"å˗.\¹ÀKóçϟ>}ÀÀ“!$/Ž«À4}ñ +ÄÛâ€@`â²µÇØà@%B… +(PÀKóçϟ>}ÀÀ†T38óÀ6Ù: +³âñ€@`Œ`p”\@'Ÿ>|ùóçÀKóçϟ>}ÀÀx'û¯%§À9#[ÆÑz€@`’:ÎϪ@)û÷ïß¿~ÀKóçϟ>}ÀÀh§œDÀ;ZþÇ»ž"€@`doDßÑž@,X±bŋÀKóçϟ>}ÀÀWܝà¥[À=~âˆqR_€@`31©ð9ˆ@.µjÕ«V­ÀKóçϟ>}ÀÀEÔÅҗÕÀ?ÜµVj€@_ýjœšÑd@0‰$H‘"ÀKóçϟ>}ÀÀ2œ)Û{[À@ÃmmQB€@_ŽbƄå@1·nÝ»víÀKóçϟ>}ÀÀ@§ôÀAŽpÅg0ê€@_ŒY‰Ê¡@2å˗.\¹ÀKóçϟ>}ÀÀÎLùÐ:ÀB™‹²«€@^Ÿñþ~ù6@4(P¡B…ÀKóçϟ>}À¿äªN#T<ÀCrahmfÀ€@^!€õl~@5B… +(PÀKóçϟ>}À¿µÆ”¿œ¯ÀD>©6§It€@]žëŸ/æ­@6páÇÀKóçϟ>}À¿…V,&qÀDþ0R «€@]ž70ë3@7Ÿ>|ùóçÀKóçϟ>}À¿R¢×Fä5ÀE°Ûm;vÿ€@\nMñ@8͛6lÙ³ÀKóçϟ>}À¿Ÿ€Íá„ÀFV§…EâA€@\”€ÍĈ@9û÷ïß¿~ÀKóçϟ>}ÀŸé#¥ìLXÀFï©Éi堀@[u±†z1@;*T©R¥JÀKóçϟ>}ÀŸ²MLcÁÀG|š+Íǀ@ZæEõ€¢%@}ÀŸz9ö?r)ÀGü³¥Ž€@ZUÍQŽáý@=‡8páÀKóçϟ>}ÀŸAuKô ÀHpššB€@YÄ»{h`È@>µjÕ«V­ÀKóçϟ>}ÀŸοX—ÀHØxßÊhš€@Y3{™Ÿ3þ@?ãǏ}ÀœË­Ì‡Ø®ÀI5ŠHJÔû€@X¢oB>ŠL@@‰$H‘"ÀKóçϟ>}Àœœ€¿‚JÀIˆûÒᔀ@Xî¶q@A @ÀKóçϟ>}ÀœS–†RÀIÐYüf/ހ@W‚E["†œ@A·nÝ»víÀKóçϟ>}ÀœЖYQåÀJã5 ?ô€@VóžÙ¿¢{@BN:téÓÀKóçϟ>}ÀŒØÒ€ÏåÀJD:Ù}ÀŒ™¿iÆ\lÀJpâ4f§ð€@UÚԙŠ@C|ùóçϟÀKóçϟ>}ÀŒ[L®nÀJ•Vð«ã:€@UPØ-üö'@D(P¡B…ÀKóçϟ>}ÀŒ.IEÀJ²ӇÐ&€@TȰN–‚p@D«V­ZµjÀKóçϟ>}À»ÝØÍ™ÀJDžè: +€@TByϕÄ@EB… +(PÀKóçϟ>}À»¯a£OwÀJÖ ë€@SŸLh­?@EÙ³f͛6ÀKóçϟ>}À»^?á:ï,ÀJÞFÆMQ€@S<;‰5$ @FpáÇÀKóçϟ>}À»Ä^šï…ÀJàZn§L·€@RŒWT8µ@G @ÀKóçϟ>}ÀºßJÇ=‹ÀJܵpÅ茀@R>¬êÔ@GŸ>|ùóçÀKóçϟ>}ÀºŸà9”œÀJÓ¬ŸOäm€@QÃEDdç@H6lÙ³fÍÀKóçϟ>}Àº`‘d?;ÀJŐN:ú²€@QJ*÷A‰ú@H͛6lÙ³ÀKóçϟ>}Àº!hòêá®ÀJ²«€§®‰€@PÓdÌÿ|"@Idɓ&L™ÀKóçϟ>}À¹ârÔŸAÀJ›FYj¡4€@P^ø|<ÈU@Iû÷ïß¿~ÀKóçϟ>}À¹£¹|pÂÀJ€– (€@OÙՎ–žs@J“&L™2dÀKóçϟ>}À¹eEBW–‹ÀJ`ŠÀ:°€@Nú~ë 4^@K*T©R¥JÀKóçϟ>}À¹' Ÿ­wžÀJ<­Ë{€@NòÑ7;=@KÁƒ 0ÀKóçϟ>}ÀžéS¬ÃbZÀJÓš'Ü6€@MJ5QˆSá@LX±bŋÀKóçϟ>}Àž«æiÕ oÀIë³k¿4Ȁ@LyHÔ¯è§@Lïß¿~ýûÀKóçϟ>}ÀžnàO™îŽÀIŸ…9^€@K­.‚¹p@M‡8páÀKóçϟ>}Àž2HSdŸ9ÀIŽ€ÝÆZ_€@JåäL0o@N}À·ö$êû€ÿÀI[ڌ² €@J#hÐWîî@NµjÕ«V­ÀKóçϟ>}À·º|F_ÑÀI&ÆHùœô€@Ie·pÍoÉ@OL™2dɒÀKóçϟ>}À·SDäqÀHïv<’›™€@H¬ÊE ԕ@OãǏ}À·D¯•ŸJ˜ÀH¶ i\k€@Gø™¹ez@P=zõëׯÀKóçϟ>}À· +•ž£A}ÀHzã×Õ#)€@GI“GŒ@P‰$H‘"ÀKóçϟ>}À¶Ñ ñ3ñÀH=þ&%ö݀@FžGÿ9?B@PÔ©R¥J•ÀKóçϟ>}À¶˜(W {ÀGÿ•æ©L €@Eø›V§@Q @ÀKóçϟ>}À¶_©Ì°ÏËÀG¿ÕaJ[A€@EVe‹ž@Qkׯ^œ{ÀKóçϟ>}À¶'Üwóg ÀG~å-ž'{€@D¹:‹§î<@Q·nÝ»víÀKóçϟ>}Àµð©É9ì ÀG<ì*ĵՀ@D ~ xõ¿@R 0`ÀKóçϟ>}ÀµºÜŽ3ÀFúwË»€@CŒHu·(@RN:téÓÀKóçϟ>}Àµ„™·þÀF¶rnd€6€@Büaô0@Rš4hÑ£FÀKóçϟ>}ÀµNÆ®Å"'ÀFr6¢ÌMC€@Bp(=À@Rå˗.\¹ÀKóçϟ>}Àµ +}áÀF-{àŒp€@Aèi篭Á@S1bŋ,ÀKóçϟ>}ÀŽæ8ÛdÀEè`/œA€@Ad· ÷šp@S|ùóçϟÀKóçϟ>}À޲‘ýô»ÀE¢ÿÓ¯:å€@@äú ì»@Sȑ"D‰ÀKóçϟ>}ÀŽÇÙÉÿÀE]uZe*º€@@i¿mã@T(P¡B…ÀKóçϟ>}ÀŽM¢šˆlÀEٜëS €@?â Ôgï9@T_¿~ýû÷ÀKóçϟ>}ÀŽ ºÏ…ÀDÒCÍ5Ë€@>ù@¿7 ä@T«V­ZµjÀKóçϟ>}À³ëC€lþvÀDŒÉýŒ€@>§zäî„@TöíÛ·nÝÀKóçϟ>}À³» +xýÀDG~ŸÄ v€@==xê @UB… +(PÀKóçϟ>}À³‹t°ÂÝÀDv u}(€@}À³\œ†õbÀCœÀyk+²€@;œ*š±—÷@UÙ³f͛6ÀKóçϟ>}À³.0dµJ-ÀCym·©øì€@:Õ|­ºêŒ@V%J•*T©ÀKóçϟ>}À³€«®5ÀC5Œ!µ ±€@:&֞X@VpáÇÀKóçϟ>}À²Óo_–&ÀBò(Ï¡…€@9Z¿Sp4Ý@VŒxñãǏÀKóçϟ>}À²Šý~F= ÀB¯OŠD&€@8ŠS I‹¡@W @ÀKóçϟ>}À²{(žNÀBm g`|π@7÷ œâzþ@WS§N:tÀKóçϟ>}À²OïÁi‚ÙÀB+eÁ«R€@7N{šø­÷@WŸ>|ùóçÀKóçϟ>}À²%Q)©îÀAêg`•Ö€@6ª·If@WêÕ«V­ZÀKóçϟ>}À±ûK*ìÂwÀAªûÂÎT€@6 )bÁX¬@X6lÙ³fÍÀKóçϟ>}À±ÑÜQeÀAj~f‡€@5rйH01@X‚ @ÀKóçϟ>}À±©× ô”ÀA+ œOOB€@4Þ'U¥/@X͛6lÙ³ÀKóçϟ>}À±€ŒúÇRœÀ@íƒÓ$k€@4N5}Ð @Y2dɓ&ÀKóçϟ>}À±Yîÿ§ŒÀ@°,„Ò‡²€@3ÂÊh!`@Ydɓ&L™ÀKóçϟ>}À±1äܘ4ŽÀ@sž~M‹€@3;áDlû‡@Y°`Áƒ ÀKóçϟ>}À± Nä<îìÀ@7ÜêÆ?9€@2¹>SÑDŸ@Yû÷ïß¿~ÀKóçϟ>}À°åEÎÐWÀ?ùÔŸ`a€@2:œ&ú{@ZG}À°¿Å£¶ßŸÀ?…‘Ü¡YŸ€@1À:RPqß@Z“&L™2dÀKóçϟ>}À°šÎ€Z"À?ô>(íS€@1I“vwZ@ZÞœzõë×ÀKóçϟ>}À°v]Â[ŸÀ>¡þbȒ€@0Ö§6c[ÿ@[*T©R¥JÀKóçϟ>}À°RqtdÀÀ>2°ÁÑ€@0gU9«Êª@[uëׯ^œÀKóçϟ>}À°/¡a«À=ŠΖšÙ€@/öüQȇ@[Áƒ 0ÀKóçϟ>}À° R$GlÀ=Yˆš€@/&Tc®â@\ 4hÑ£ÀKóçϟ>}À¯Óg!ìZaÀ<î¿êŒ^Ѐ@.[ºšK;@\X±bŋÀKóçϟ>}À¯ŠÒt1À<†Öîá)€@-—_®ooæ@\€H‘"DˆÀKóçϟ>}À¯L£Ð€SXÀ<þžeð€@,Ù=:Ií@\ïß¿~ýûÀKóçϟ>}À¯ +®;Ÿ™TÀ;¹¬ñÜõ€@, ô?:O¬@];víÛ·nÀKóçϟ>}À®ÉŠ9ýÖ À;Ué¿Å6€@+nQiêi}@]‡8páÀKóçϟ>}À®‰‡ø(5À:óÂú¿±z€@*Á#'x‰8@]Ò¥J•*TÀKóçϟ>}À®JO«#"À:“6/ñz!€@*9™õ‰r@^}À® ù—¬À:4>ÔEՀ@)vfŒª&@^iÓ§N:ÀKóçϟ>}À­Îï€ð–À9ÖØÃDb€@(Ø}h6ÉÛ@^µjÕ«V­ÀKóçϟ>}À­‘å~-^À9zÿšÉŀ@(?S&¡@_ ÀKóçϟ>}À­ViöÀ9 ®¿ËN €@'ªŸG[Nõ@_L™2dɒÀKóçϟ>}À­-GøGÝÀ8ÇáhMzJ€@'–ÃVm$@_˜0`ÁƒÀKóçϟ>}À¬á %óìÀ8p’ t€@&޶'í¶@_ãǏ}À¬§µ‚5ò3À8œPÏ}À¬o(ç6âÀ7Æ\Càu_€@%ƒ5:žA@`=zõëׯÀKóçϟ>}À¬7aëÛœÀ7sj++˜ +€@%NŸÛ$Û@`cF4hÀKóçϟ>}À¬]3«ljÀ7!á£Ý )€@$‡!ÿ†?@`‰$H‘"ÀKóçϟ>}À«Ênù¹ØÀ6Ñœ; ­Á€@$ŽÚçt¡@`®Ý»víÛÀKóçϟ>}À«”[¢7À6‚÷qœ'ˆ€@#™vYS³Ì@`Ô©R¥J•ÀKóçϟ>}À«_»Â áŠÀ65Š¿Îø}€@#'º¢²LÆ@`útéÓ§NÀKóçϟ>}À«+Ÿ{¢{œÀ5éq˜|’À€@"¹>ôH@a @ÀKóçϟ>}Àªø5l øÚÀ5žŠl\€@"Mç–Æ|]@aF 0`ÁÀKóçϟ>}ÀªÅz…gÀ5U#«7€@!å™Ô¢‚ë@akׯ^œ{ÀKóçϟ>}Àª“kÅPú÷À5 ãȲ満@!€;ð±ìÃ@a‘£F4ÀKóçϟ>}Àªb8Æ'YÀ4Åá=G€€@!µoZ@a·nÝ»víÀKóçϟ>}Àª1FøIí{À4€‡ …‰€@ œírR~™@aÝ:téÓ§ÀKóçϟ>}Àª+)¶§¬À4;~-òÒ€@ `ÍæðèP@b 0`ÀKóçϟ>}À©Ñ¯ÿÇjÀ3øÃ[GÀ@ @G›ö@b(Ñ£FÀKóçϟ>}À©¢Ò¹þº‹À3µÎäf€Ÿ€@\^X÷£6@bN:téÓÀKóçϟ>}À©t€ˆÝ$À3t­; ô€@± íëÚ@bthÑ£FÀKóçϟ>}À©FçúçÀ34š}À©ÓyڋzÀ2õ»wÖeò€@h8ÖœÃ@bÀÀKóçϟ>}ÀšíS;%NÀ2·àûŒäA€@Êlá—È«ÀK*T©R¥JÀÁ5KCeÒq€€@bdj;o*?òå˗.\¹ÀK*T©R¥JÀÁ4rº€\‡Àèڀ7†Î€@baM€hÍ@å˗.\¹ÀK*T©R¥JÀÁ1éêÐ0ÀÞ"·²Ú€@bY:й]@ X±bŋÀK*T©R¥JÀÁ-³0…ÈÜÀ!S+Õ{ò€@bKØÈ@å˗.\¹ÀK*T©R¥JÀÁ'Òk(ޛÀ&³ýµÀ`€@b9?%ešP@Ÿ>|ùóçÀK*T©R¥JÀÁ LèaeÙÀ,:bìñ‘‚€@b!‘þI#è@X±bŋÀK*T©R¥JÀÁ)IÕ.§À0ÔjG&ƒ€@bøë™p@ ‰$H‘"ÀK*T©R¥JÀÁ ohæüÀ3|ŸÇÿ/Ȁ@aã àÇcõ@"å˗.\¹ÀK*T©R¥JÀÁ(;IÀ6VŒª&Œ€@aœ¹ðöß9@%B… +(PÀK*T©R¥JÀÀò]žªÞãÀ8œ=¥‚B¢€@a“v+ +ÎI@'Ÿ>|ùóçÀK*T©R¥JÀÀãÈYøÀ;ïÍ3Ö¿€@ae auä@)û÷ïß¿~ÀK*T©R¥JÀÀÒk-ÖwÀ=o€ÆP€@a2§*º?@,X±bŋÀK*T©R¥JÀÀÀ[}áuÀ?žóFŒ€@`ü†ÿ†…‰@.µjÕ«V­ÀK*T©R¥JÀÀ¬ù ç À@ôÿúqt€@`Âá ¶*-@0‰$H‘"ÀK*T©R¥JÀÀ˜Q÷° >ÀB¹sÆ+ˆ€@`…ðºýӀ@1·nÝ»víÀK*T©R¥JÀÀ‚tù_Ÿ ÀC­íI'Œ€@`Eõvî‡@2å˗.\¹ÀK*T©R¥JÀÀkquýLÒÀCô^µÍ€@`0µãº@4(P¡B…ÀK*T©R¥JÀÀSW[RlOÀDÙ\ßž{-€@_{ÔçGÞ@5B… +(PÀK*T©R¥JÀÀ:7 +¶ÌÀE°TU*×è€@^ìØµÅm>@6páÇÀK*T©R¥JÀÀ !<ˆàÀFyƒ3)€@^ZiSõ@7Ÿ>|ùóçÀK*T©R¥JÀÀ&Ý"øÀG3Z“œ­€@]Ãú»E?h@8͛6lÙ³ÀK*T©R¥JÀ¿Ò±Üˆ‘ÀGßJ6®d*€@]+RêVqý@9û÷ïß¿~ÀK*T©R¥JÀ¿™ºbúÀH|õžߊ€@\©æOñ@;*T©R¥JÀK*T©R¥JÀ¿_ ÇylÀI ˜®q‹€@[ô•„CÉ@µjÕ«V­ÀK*T©R¥JÀŸšiJƒÀJk7÷™€@Z/šU|&@?ãǏ(ü û€@TFcÌ d@EÙ³f͛6ÀK*T©R¥JÀ»Øþ +HAÀL@')·7€€@SŒªXñqÚ@FpáÇÀK*T©R¥JÀ»–EùD|qÀL;ñÕØïî€@S5pí/w@G @ÀK*T©R¥JÀ»SžÓaöÀL1éš‘™€@R°À45iZ@GŸ>|ùóçÀK*T©R¥JÀ»çù‹ýÀL"hÑ¢ +ñ€@R.žùQš=@H6lÙ³fÍÀK*T©R¥JÀºÎ·Ÿ…TŒÀL Ãq05€@Q¯wY€}@H͛6lÙ³ÀK*T©R¥JÀºŒÆŸÀKôH`¶€A€@Q2¶_@Idɓ&L™ÀK*T©R¥JÀºJª¿ IÀKÖAù%×ñ€@P·ÆËÏ€À@Iû÷ïß¿~ÀK*T©R¥JÀº …¹íÀK³öÃXN€@P@ `?@J“&L™2dÀK*T©R¥JÀ¹ÇËìÃéÆÀKª”÷#€@O•æ¢w @K*T©R¥JÀK*T©R¥JÀ¹†æ®ýŸÀKcœ}Û:–€@N°ó‘€åž@KÁƒ 0ÀK*T©R¥JÀ¹Fh÷?ï^ÀK6 Xèêb€@MÑC< 0ó@LX±bŋÀK*T©R¥JÀ¹ZËG#0ÀK5ñÓI€@LöÔcöè÷@Lïß¿~ýûÀK*T©R¥JÀžÆÃ Ök+ÀJÑSÐÍÇü€@L!€ôfÙ@M‡8páÀK*T©R¥JÀž‡ªf?«qÀJšžäZE5€@KQ±Z2ú@N#Ô»Cm@TöíÛ·nÝÀK*T©R¥JÀ³étx‰ÀDØw距€@=D!24Æ@UB… +(PÀK*T©R¥JÀ³ž5­S¶ÀDŽÕŒÎ“€@U>€@9NÂ|ùóçÀK*T©R¥JÀ²HòWjŸÀBVŽž €@6”I–a¡Q@WêÕ«V­ZÀK*T©R¥JÀ²ñyr™ÀBßæ–á€@5󚰍]@X6lÙ³fÍÀK*T©R¥JÀ±óŽöÜÁÀAÏ˔ÿR•€@5XC^9Éi@X‚ @ÀK*T©R¥JÀ±ÉÊQPÀA«‡©ôæ€@4Áí 7f @X͛6lÙ³ÀK*T©R¥JÀ±  X_ÔÝÀALh—)€@40zDp¿ @Y2dɓ&ÀK*T©R¥JÀ±xÃ{(sÀA  g€@3£Àœš@Ydɓ&L™ÀK*T©R¥JÀ±P:ÚäÙÀ@̅jÐûZ€@3—R&î +@Y°`Áƒ ÀK*T©R¥JÀ±(±¢5ö;À@ë±ï`#€@2—Ö îµ@Yû÷ïß¿~ÀK*T©R¥JÀ±ߨ2±À@P9ž}߀@2VKÉe@ZGþ€@1%„ùŸµ@ZÞœzõë×ÀK*T©R¥JÀ°ÅӋ-yÀ?9: +/N€@0±ëùKrª@[*T©R¥JÀK*T©R¥JÀ°l)œf²ýÀ>Å$üαQ€@0BŒXð«@[uëׯ^œÀK*T©R¥JÀ°H­âøÀ>R㊏·£€@/«\fåŸ:@[Áƒ 0ÀK*T©R¥JÀ°$‡}…DÀ=ât4…ev€@.ِÑòÐ@\ 4hÑ£ÀK*T©R¥JÀ°}ú3À=sÔÙ¢~]€@.hó(©@\X±bŋÀK*T©R¥JÀ¯œèO‘ 5À=Éæ.-€@-I©$ì‡@\€H‘"DˆÀK*T©R¥JÀ¯yՁáõÀ<›úԞqì€@,‹Íú@\ïß¿~ýûÀK*T©R¥JÀ¯6œnüÇÀ<2¹Uš‘Ì€@+Ò}X“…@];víÛ·nÀK*T©R¥JÀ®ô›ê +œZÀ;Ë:Aœ¬€@+€$F<€@]‡8páÀK*T©R¥JÀ®³lÐ5(QÀ;ey1Ô¬•€@*rXxE•Î@]Ò¥J•*TÀK*T©R¥JÀ®s, jÙÒÀ;qm²œŽ€@)Êhu‹€@@^y‹s—¿€@(‰ÜÜßú@^µjÕ«V­ÀK*T©R¥JÀ­·×f“ÔÛÀ9ß~ºÁýK€@'ðæKó¥6@_ ÀK*T©R¥JÀ­{'éÒõ‹À9‚'à’Š#€@'\•R…²l@_L™2dɒÀK*T©R¥JÀ­?RÿEWÿÀ9&o2S¢µ€@&ÌÀ6šý@_˜0`ÁƒÀK*T©R¥JÀ­Tۇ™¬À8ÌNÄGG€@&A?ïT¿¡@_ãǏmî‰@`cF4hÀK*T©R¥JÀ¬ vn!]À7sEÕÈ?׀@$;œzy@`‰$H‘"ÀK*T©R¥JÀ«érç÷ø¡À7 ÂÐÈˀ@#ÜUO‚@`®Ý»víÛÀK*T©R¥JÀ«³0áË$+À6ϲó¥Z¥€@#O/›³º@`Ô©R¥J•ÀK*T©R¥JÀ«}¬§‘þ²À6€÷\Š*€@"Þh»R@`útéÓ§NÀK*T©R¥JÀ«HââÎnºÀ61ӓÕ߀@"p.„mi„@a @ÀK*T©R¥JÀ«ÐKÒ¹TÀ5ä÷† +P&€@"ƒu )@aF 0`ÁÀK*T©R¥JÀªáq©Ž/‡À5™u‘ÌÖ €@!åå ®@akׯ^œ{ÀK*T©R¥JÀª®ÃÒ8U +À5OG„|z€@!9<„ˆ)}@a‘£F4ÀK*T©R¥JÀª|霺À5g4˜ýU€@ ×m§°R_@a·nÝ»víÀK*T©R¥JÀªKn#{OÀ4ŸÎˆU—Ÿ€@ x`èîùø@aÝ:téÓ§ÀK*T©R¥JÀªÀ?ŒŸÀ4xwr]ƒW€@ þÊ2µf@b 0`ÀK*T©R¥JÀ©ê·{à:À43[õŸo€@„aWûC @b(Ñ£FÀK*T©R¥JÀ©»O­j0ŸÀ3ïv& +<{€@ÕÁ‰˜|ùóçÀJ`Áƒ ÀÁ•‚‹ÆÀ.‡@ àE€@c?óÉÙú@X±bŋÀJ`Áƒ ÀÁ‹ uÄn²À20¿Ï¯€B€@bøn(ÊsV@ ‰$H‘"ÀJ`Áƒ ÀÁ€ @ö?[À5 I蟀р@bÓj¥Wþ@"å˗.\¹ÀJ`Áƒ ÀÁrÆÂÒˆÀ7׈‘XÌÆ€@b©p³’L@%B… +(PÀJ`Áƒ ÀÁcäIX¶À:ÐÍÀ@bz¹Ï3s@'Ÿ>|ùóçÀJ`Áƒ ÀÁSnƒwŒÀ=.›‡Îـ@bGTŸé@)û÷ïß¿~ÀJ`Áƒ ÀÁAq@˜ÝÀ?žu< €@bý.mWÍ@,X±bŋÀJ`Áƒ ÀÁ-ûÆŸ²$ÀAüW1€@aÔk·1Lh@.µjÕ«V­ÀJ`Áƒ ÀÁn6ÖÀB@áÎág€@a•Ï:¶‚@0‰$H‘"ÀJ`Áƒ ÀÁâÌãäoÀC_?>;#n€@aR k•“#@1·nÝ»víÀJ`Áƒ ÀÀë_A‰JöÀDonì,\<€@a Ã)§Öž@2å˗.\¹ÀJ`Áƒ ÀÀÒ¢æôTvÀEp×ü€K€@`Âq35ۅ@4(P¡B…ÀJ`Áƒ ÀÀž¿ë<ÎÀFbôà•€@`vf‚îÑe@5B… +(PÀJ`Áƒ ÀÀÇf ‡*ÀGEZ@)à€@`'÷y·º|@6páÇÀJ`Áƒ ÀÀÍS^‰ÀHœ0vž€@_®ùžšXª@7Ÿ>|ùóçÀJ`Áƒ ÀÀdäMÝ=ÀHÙ÷fX#ö€@_ +£ûþNó@8͛6lÙ³ÀJ`Áƒ ÀÀGk§ˆÀIŒ +pJ<€@^cŠ®&-@9û÷ïß¿~ÀJ`Áƒ ÀÀ(‘®È ÀJ.Ë)S€@]º·äÆE†@;*T©R¥JÀJ`Áƒ ÀÀ MΟUœÀJÀ†FO£L€@]‡X59Ì@µjÕ«V­ÀJ`Áƒ À¿Oâ~“¹ÀL™•i.€@[ Û€ž@?ãǏ69€@Y¿s/ÅŠÄ@A @ÀJ`Áƒ ÀŸ…+@Ÿ­ÀMö»'{€@YP26@A·nÝ»víÀJ`Áƒ ÀŸ@]¬ 9ÀM:¢ÅZڀ@Xu5~PõŠ@BN:téÓÀJ`Áƒ ÀœûTŽ®ZÀMf*Wp€@WÓ]^\Yz@Bå˗.\¹ÀJ`Áƒ Àœµ€ªÆñÀM‡Æò`þ€@W3õô÷!@C|ùóçϟÀJ`Áƒ ÀœoŸä+fÀM ž¯ÂÝD€@V—!+Ü@D(P¡B…ÀJ`Áƒ Àœ)ŽY—-RÀM±* íi€@Uüù翝Ç@D«V­ZµjÀJ`Áƒ ÀŒã^öL—:ÀM¹ûRpÉš€@Ue’ØäÖK@EB… +(PÀJ`Áƒ ÀŒ#Y,LìÀM»™s’ÍŠ€@TÐøÿ/#@EÙ³f͛6ÀJ`Áƒ ÀŒVëðaŸƒÀM¶ ‚Å|€@T?5%7 +@FpáÇÀJ`Áƒ ÀŒÈºZÀM«?ÄXo€@S°Lí4fÝ@G @ÀJ`Áƒ À»ÊÆ`"PÀM™Ü +Ê%k€@S$CŸ˜U8@GŸ>|ùóçÀJ`Áƒ À»„ó?˜ÀMƒFBó+€@R›}A„@H6lÙ³fÍÀJ`Áƒ À»?\ؓ!åÀMg&üC!‡€@RÕc0Q@H͛6lÙ³ÀJ`Áƒ Àºú ú4;ÀMF\'Üv€@Q‘pžžM6@Idɓ&L™ÀJ`Áƒ Àºµ5*†ÀM!ÁŒ€@QîRLÚ±@Iû÷ïß¿~ÀJ`Áƒ Àºps_ ÅÀL÷b±o™>€@P“Mf5h«@J“&L™2dÀJ`Áƒ Àº,;ØËñÜÀLÉÀ“³aT€@P6%Pˆ@K*T©R¥JÀJ`Áƒ À¹èt˜Õª—ÀL˜^XYž#€@OAYf?Ü@KÁƒ 0ÀJ`Áƒ À¹¥&/wÓÀLc{ÀÌè€@NWT×;jr@LX±bŋÀJ`Áƒ À¹bX˜™émÀL+V¹èͅ€@Msé1cÉ@Lïß¿~ýûÀJ`Áƒ À¹ ?ÊÓØÀKð+™8¶¡€@L”p¡Ý}%@M‡8páÀJ`Áƒ ÀžÞ]ËîÉÀK²5Ar䚀@K»…³ŽL @N(àìmt@TöíÛ·nÝÀJ`Áƒ ÀŽ3ìM&qÀElz¡:š€@=CñG› @UB… +(PÀJ`Áƒ À³äõ?{1ÀEå±àµ€@|ùóçÀJ`Áƒ À²lk•\ñÀBÄ¡f O€@6wÞî³ @WêÕ«V­ZÀJ`Áƒ À²@kÑiYÖÀB|³æ0ÏĀ@5ÕRC{‹@X6lÙ³fÍÀJ`Áƒ À² +À'ÀB6>0öû€@582-Äن@X‚ @ÀJ`Áƒ À±ê`·3MÀAð¿Aº€€@4 Mà÷çT@X͛6lÙ³ÀJ`Áƒ À±ÀPÔ¬9ÀA¬:wD¯€@4 vÍQÕ@Y2dɓ&ÀJ`Áƒ À±–á£8`¶ÀAh²%çè€@3žeQ@Ydɓ&L™ÀJ`Áƒ À±nÉþÏÓÀA&)š“u€@2ö<Ÿ4Mb@Y°`Áƒ ÀJ`Áƒ À±EÛëŸçÙÀ@ä¢#Ñ>C€@2qƒQF@@Yû÷ïß¿~ÀJ`Áƒ À±@šÂ^À@€Ÿu#’€@1ñ*8eÃ@ZGÜ@[*T©R¥JÀJ`Áƒ À°…£÷ À?X:ÉÀîr€@0~‰«1@[uëׯ^œÀJ`Áƒ À°`äüÄ¡qÀ>á@nHÍ£€@/W”ž‰®c@[Áƒ 0ÀJ`Áƒ À°<±[ù‡]À>lCd֏€@.…4†§Ž@\ 4hÑ£ÀJ`Áƒ À°œƒûÜÀ=ù?> ‚€@-¹›¯€Ë@\X±bŋÀJ`Áƒ À¯ëŝ§:ZÀ=ˆ0¥¶qñ€@,ô‹³ x@\€H‘"DˆÀJ`Áƒ À¯Š†… dÀ=º-!|€@,5ÈVÇÞè@\ïß¿~ýûÀJ`Áƒ À¯bK¡ÉŒ:À<«àuÚÓʀ@+}ŒJÅ@];víÛ·nÀJ`Áƒ À¯pÐüŠÀ<@”uàÒ³€@*ÊAhQn@]‡8páÀJ`Áƒ À®ÜÐ|#µ—À;×)­¹F€@*ekø@]Ò¥J•*TÀJ`Áƒ À®›‡[–›‘À;o˜)–ÿ€@)uO¥õϰ@^ÔOÀ:¥í.„^€@(5[Ðmê,@^µjÕ«V­ÀJ`Áƒ À­ÝIŒjûÀ:CÅÕŸßì€@'œÊ€,Ý@_ ÀJ`Áƒ À­Ÿ±Œ À9ã_+ÜÝ]€@'ìœæï}@_L™2dɒÀJ`Áƒ À­bùõŠz7À9„²;B„Õ€@&y˜‹  @_˜0`ÁƒÀJ`Áƒ À­' Š{m}À9'ž +s€@%4Ä@_ãǏ¡`ú€@#땪–äù@`‰$H‘"ÀJ`Áƒ À¬Tí`SÀ7oW^È€@#tN©ƒ@`®Ý»víÛÀJ`Áƒ À«Ñ\wžTÀ7ùtxð€@#Ž1‹@`Ô©R¥J•ÀJ`Áƒ À«›&AÑÚHÀ6Êq†æ/€@"7ÕRŸÆ@`útéÓ§NÀJ`Áƒ À«e°õÐDÀ6y¹‰…m€@"#+–¬Ïì@a @ÀJ`Áƒ À«0ö*?*À6*ÉœLb¡€@!¹Lۘeœ@aF 0`ÁÀJ`Áƒ Àªüôû„£0À5ÝFˑ[€@!R¹†Ql@akׯ^œ{ÀJ`Áƒ ÀªÉ©@i§<À5‘'ÄG˜€@ î©UǶ±@a‘£F4ÀJ`Áƒ Àª—ŽÁMÀ5Fgæœ €@ ¯ÚÕË@a·nÝ»víÀJ`Áƒ Àªe%$œdÀ4üÿÈ|h€@ /zmüô>@aÝ:téÓ§ÀJ`Áƒ Àª3ækۅÌÀ4ŽèÀðÜ€@§âJÒf"@b 0`ÀJ`Áƒ ÀªPuþArÀ4n?و»€@õùý#i1@b(Ñ£FÀJ`Áƒ À©Ó`=ëòÙÀ4(“ÊO/ €@IªvÏ@bN:téÓÀJ`Áƒ À©€ÍŸD À3äHýyf,€@ ø²ñèN@bthÑ£FÀJ`Áƒ À©ue>ˆNÀ3¡5Žçô݀@ýŒÖb@bš4hÑ£FÀJ`Áƒ À©GTžÁÀ3_SM+Ú€@^¢M–î·@bÀÀJ`Áƒ À©ÞpË&\À3œ S銀@Ä`b~ÄÀI—.\¹råÀÂ)°VK€€@dvZIsµ?òå˗.\¹ÀI—.\¹råÀÂ(±Oê©xÀ +ôáw_û€@ds"r#©@å˗.\¹ÀI—.\¹råÀÂ%¶M'`Àäúª.€@dhæÐ!.@ X±bŋÀI—.\¹råÀ Ááf¬&À$1|c¡„€@dWüÁÿû@å˗.\¹ÀI—.\¹råÀÂÚbG`xÀ*§Z( 9ü€@d@•ûjæœ@Ÿ>|ùóçÀI—.\¹råÀÂ+€ À0Œçg ëy€@d"òkÐ}©@X±bŋÀI—.\¹råÀÂQ‚üíÀ3µ'_J|Ȁ@cÿYO ‹@ ‰$H‘"ÀI—.\¹råÀÁùÄmNbÀ6Ê;^LiZ€@cÖü€­8@"å˗.\¹ÀI—.\¹råÀÁëjҟ‹áÀ9ÊN˜ºy€€@c§q'ÀRÌ@%B… +(PÀI—.\¹råÀÁÛRŒ*¹pÀ<³ÎÍf!€@cs°ŸX„Y@'Ÿ>|ùóçÀI—.\¹råÀÁɉŒõ–À?…O%¥©{€@c;(=®Ž@)û÷ïß¿~ÀI—.\¹råÀÁ¶ÝôPÀA¶äž‹€@býÛzªÌÎ@,X±bŋÀI—.\¹råÀÁ¡p"¶ÀBm`X枂€@bŒ>VPÜÊ@.µjÕ«V­ÀI—.\¹råÀÁŠ›V<ÀC­æHüö݀@bvya†‡i@0‰$H‘"ÀI—.\¹råÀÁrŠÂhëIÀD߃XV@V€@b,Ì6ÚrÙ@1·nÝ»víÀI—.\¹råÀÁYR¹ÉíÀFpRF°Ÿ€@aß} Ë;Í@2å˗.\¹ÀI—.\¹råÀÁ>±åD|ìÀGí¶×ڀ@aŽÚ‹ÓBD@4(P¡B…ÀI—.\¹råÀÁ"ت%ÀHO=:=€@a;E@5B… +(PÀI—.\¹råÀÁÙÚeÍÀI‹Åï'€@`åGqbD@6páÇÀI—.\¹råÀÀçÌKE«hÀIÞ²ni¥‚€@`Œ–ƒ¡so@7Ÿ>|ùóçÀI—.\¹råÀÀÈÄàÁmÀJ©c|î€@`2bs(Çr@8͛6lÙ³ÀI—.\¹råÀÀšÙ, +­_ÀKaFºß&€@_­ªUFÊe@9û÷ïß¿~ÀI—.\¹råÀÀˆ¡í÷bÀLh•Š€@^ô¶í š@;*T©R¥JÀI—.\¹råÀÀfªUŠK5ÀL›égâ®A€@^:»Ê]Ì¢@µjÕ«V­ÀI—.\¹råÀ¿ýwn³©·ÀMö509¿€@\ ㈭1@?ãǏƒt^ʶ@BN:téÓÀI—.\¹råÀŸ“… ”fˆÀO!—Ž>€@X‘9AŠìp@Bå˗.\¹ÀI—.\¹råÀŸIÛh:‹gÀO;nÏ_ր@WæõÑì†ÿ@C|ùóçϟÀI—.\¹råÀœÿþ—ÓçÀOLŽÇº³y€@W?Ô|õŎ@D(P¡B…ÀI—.\¹råÀœ¶|nã®ÀOU1•h#p€@V›æŠˆž1@D«V­ZµjÀI—.\¹råÀœký]Ž:ZÀOUûÒS÷å€@Uû6'‹@EB… +(PÀI—.\¹råÀœ!þzEWÀOOV'7£€@U]Æé™Øª@EÙ³f͛6ÀI—.\¹råÀŒØ&ìOKÀOBF€°ô€@Tـýí@FpáÇÀI—.\¹råÀŒŽTÿ/VEÀO.Áßß(Ÿ€@T,«®dÚ»@G @ÀI—.\¹råÀŒDÈõä#–ÀO^%ÓV€@S˜ùåÀ!@GŸ>|ùóçÀI—.\¹råÀ»ûvI)ýÀNö{Wò€@SõJŽ–@H6lÙ³fÍÀI—.\¹råÀ»²… œ°ÀNÒqí3oހ@R{9’¬2ï@H͛6lÙ³ÀI—.\¹råÀ»iå“uûÀN©“×õ€@Qñ" ìdz@Idɓ&L™ÀI—.\¹råÀ»!¬ ŸÒÀN|*êñ&€@Qj7U«D®@Iû÷ïß¿~ÀI—.\¹råÀºÙâ×y[ÀNJF°s?€@Pæt9¯$Å@J“&L™2dÀI—.\¹råÀº’“žWŽœÀNÚ÷¥/•€@PeÖ3Ú@K*T©R¥JÀI—.\¹råÀºKÇÈm•ÀMÛzbˆ¢7€@Oгƒ6ÄÜ@KÁƒ 0ÀI—.\¹råÀº‡‰Z^žÀMž ª‡C€@NÛø"ÐmŽ@LX±bŋÀI—.\¹råÀ¹¿ÚææžÀM^‹#]zK€@Míscœ@Lïß¿~ýûÀI—.\¹råÀ¹zÉ5ö#;ÀMyeut€@M§€aæ@M‡8páÀI—.\¹råÀ¹6Y?Ã63ÀLÕ§Žàk€@L"ëÔsì™@Nž¥@@NµjÕ«V­ÀI—.\¹råÀž¯vØKMˆÀLB°«|©€@JpÏ܆@OL™2dɒÀI—.\¹råÀžm99§šÀKõÿÚ:O€@I Êس@OãǏ$•Gä`@TöíÛ·nÝÀI—.\¹råÀŽEE ÷kJÀFqÒÓ©€@=;øÛy­M@UB… +(PÀI—.\¹råÀާÄb6¿ÀE¯éƍ÷€@<[D—KÂ@UŽ8páÃÀI—.\¹råÀ³ÞÎî•×pÀE]#e-<€@;‚8ÈoҎ@UÙ³f͛6ÀI—.\¹råÀ³¬ž­UÂÀE -@‚·»€@:°–L„ý@V%J•*T©ÀI—.\¹råÀ³{cÆ>òÀDºVKŽF€@9æû=â›@VpáÇÀI—.\¹råÀ³JËÞ†RÀDiä'wÀ­€@9"•Å ¿Í@VŒxñãǏÀI—.\¹råÀ³ñïÀDŠÏèՀ@8eŸÐ‰cq@W @ÀI—.\¹råÀ²ëÐ4ÞæêÀCÌe³ +€@7¯_Šä}@WS§N:tÀI—.\¹råÀ²œg(ÀC&§ÌØe€@6ÿ>¿å™\@WŸ>|ùóçÀI—.\¹råÀ²³%ômTÀC2ñãór€@6U$¥•E¹@WêÕ«V­ZÀI—.\¹råÀ²b²JõqÀBçÌ;ÇgH€@5°Úè Ó]@X6lÙ³fÍÀI—.\¹råÀ²6a>[rÀBº!îV€@5,±«‰¯@X‚ @ÀI—.\¹råÀ² +œçˆh)ÀBT¿ Ô ø€@4xæ°©ÀT@X͛6lÙ³ÀI—.\¹råÀ±ßÅÊ{3xÀB Ýö +Œ-€@3ä×2.˜@Y2dɓ&ÀI—.\¹råÀ±µv)Ý)ªÀAÆš›8^€@3UÍ«k37@Ydɓ&L™ÀI—.\¹råÀ±‹Ìe6ÀA€pWî‹€@2˛š¡@Y°`Áƒ ÀI—.\¹råÀ±bÅÛa_xÀA;åÖPŒI€@2FÚžî@Yû÷ïß¿~ÀI—.\¹råÀ±:_çòðÀ@øy+k©€@1Å +‹xcü@ZGÍa@[uëׯ^œÀI—.\¹råÀ°yo5>®À?oõS30”€@.ûcÊhŠÕ@[Áƒ 0ÀI—.\¹råÀ°T•z§TÀ>öQñ,ÌB€@.(µ£.bQ@\ 4hÑ£ÀI—.\¹råÀ°0J˜ä3ªÀ>~Óe³€@-\ð|à³@\X±bŋÀI—.\¹råÀ° ‹uŽqyÀ> sòÄ»€@,—Ó­+%@\€H‘"DˆÀI—.\¹råÀ¯Ò«k9ÀÀ=–-rŒX€@+ÙPŽ v@\ïß¿~ýûÀI—.\¹råÀ¯MÅaŸAÀ=$ù^Πˀ@+ •ìׂ£@];víÛ·nÀI—.\¹råÀ¯Hù¡ˆ!À<µÐà–uƒ€@*mÿ£dÓ]@]‡8páÀI—.\¹råÀ¯š€ƒ?ùÀL„y+@`útéÓ§NÀI—.\¹råÀ«‚ó{E2À6Á ñНs€@!Ò+érº@a @ÀI—.\¹råÀ«L ûB?ÿÀ6pSá €@!i;‹‡hÞ@aF 0`ÁÀI—.\¹råÀ«þ¡—ûhÀ6 €N[]˜€@!_Xœ@akׯ^œ{ÀI—.\¹råÀªäg²ûTÀ5ÒpJÂÉL€@  zóQ¥ù@a‘£F4ÀI—.\¹råÀª°äà˜‘ÞÀ5…ÏËÚ€@ @uN @a·nÝ»víÀI—.\¹råÀª~f°àkÀ5:—n»G–€@Æh=IÜ@aÝ:téÓ§ÀI—.\¹råÀªL˜ŽsjTÀ4ð¿ëéy€@?Ž× %@b 0`ÀI—.\¹råÀªw@M JÀ4šB7ñҀ@aAd“eR@b(Ñ£FÀI—.\¹råÀ©êÿž4bÀ4a刚<€@¶@Ò0ñT@bN:téÓÀI—.\¹råÀ©».xKÄÀ47chVW€@€M€@bthÑ£FÀI—.\¹råÀ©ŒšycÀ3֜¿‰Ï&€@nNR=e@bš4hÑ£FÀI—.\¹råÀ©]t$MÀÀ3“@F#nž€@шÙ*»@bÀÀI—.\¹råÀ©/„æ¡°À3Qb2Ôô€@8ÞÒ@U¡ÀH͛6lÙ³À­€Žy‘"€€@e2RËv<3?òå˗.\¹ÀH͛6lÙ³À¬‡Óò{cÀàSPÐj—€@e,¥ÃîÔ;@å˗.\¹ÀH͛6lÙ³À©83fÃÀ’EQ̱‰€@e#MÈòÆ@ X±bŋÀH͛6lÙ³À£Á#²À&LLá:1€@eaˆV6™@å˗.\¹ÀH͛6lÙ³ÀœÜSŸÀ-‹ `KJ€@dåÙv'ˆŸ@Ÿ>|ùóçÀH͛6lÙ³À’ZgYkÀ22}dÓïP€@dŸ‹Pwèu@X±bŋÀH͛6lÙ³À† X‡!úÀ5ƒó•ùé+€@dE$P–[@ ‰$H‘"ÀH͛6lÙ³ÀÂxíàbÑÔÀ8׊ȟb€@d[*Sô@"å˗.\¹ÀH͛6lÙ³ÀÂiSñ‡ßÀ<1-¢Ó€@d £Å?‹ƒ@%B… +(PÀH͛6lÙ³ÀÂWÕsëï+À?6ŠÈºº€@cà(OK@'Ÿ>|ùóçÀH͛6lÙ³ÀÂDÔjmÀA—›å€@cš€“±!„@)û÷ïß¿~ÀH͛6lÙ³ÀÂ/p JîÀBòó@3r€@cP‚NŠäQ@,X±bŋÀH͛6lÙ³À­¯wÎÀCôŒñ>“E€@cÔÖ`¢í@.µjÕ«V­ÀH͛6lÙ³ÀÂXË¢×ëÀE9þðœa€@b®íƒT®@0‰$H‘"ÀH͛6lÙ³ÀÁ挐Fè—ÀFtwê®Wr€@bX3x;@1·nÝ»víÀH͛6lÙ³ÀÁËR +TÉÊÀG€@1Þ/€@aþ¿„“˜@2å˗.\¹ÀH͛6lÙ³ÀÁ®»±Œ)/ÀHÀfŸç*̀@a Ä¹UÆÊ@4(P¡B…ÀH͛6lÙ³ÀÁã«£;/ÀIÄ«è-P(€@a@é›™Ð@5B… +(PÀH͛6lÙ³ÀÁqáŽP÷ÀJ»ö5gB€@`Þßä%šz@6páÇÀH͛6lÙ³ÀÁQÁ ®åÀKš¶Z,ú]€@`{Ëa‘+@7Ÿ>|ùóçÀH͛6lÙ³ÀÁ0‘k«úPÀL†eÊǞE€@`øD8gË@8͛6lÙ³ÀH͛6lÙ³ÀÁf5”vEÀMRz/°ó€@__À"Ȱ\@9û÷ïß¿~ÀH͛6lÙ³ÀÀëUöxþÀN Œ§Ì߀@^’J1àBH@;*T©R¥JÀH͛6lÙ³ÀÀÇtÆSì„ÀN°z†Ã›€@]Ä3p +¹@µjÕ«V­ÀH͛6lÙ³ÀÀWö4m†¥ÀP8»`ø€@[]³­R­°@?ãǏ|ùóçÀH͛6lÙ³ÀŒk‡k·&ÀPYd8ï ~€@Q”bAç @H6lÙ³fÍÀH͛6lÙ³ÀŒŠìoÝWÀP=3á-26€@QÙ¿¿uq@H͛6lÙ³ÀH͛6lÙ³À»ÒšØo ÀPM9·9ÿ€@PqÌû©§@Idɓ&L™ÀH͛6lÙ³À»†L«Î~‰ÀOôzŸ%pÀK:ƒíA“’€@Cù1·Ús&@Q @ÀH͛6lÙ³À·s ?˜J€ÀJèê#k€@CUiÚ +¶ï@Qkׯ^œ{ÀH͛6lÙ³À·3×ÿ"UoÀJ˜)Å}ŸT€@B·OnÑø@Q·nÝ»víÀH͛6lÙ³À¶õcÔ:Ù§ÀJHªsV„€@B?çUžœ@R 0`ÀH͛6lÙ³À¶·ª/ï¬ÀIú;K‚9߀@AŒzðœýS@RN:téÓÀH͛6lÙ³À¶zššjœrÀI¬ÓŒ7΀@@ÿ4<”Š@Rš4hÑ£FÀH͛6lÙ³À¶>\°Qú‚ÀI`i»ñ†€@@v€îö‰‰@Rå˗.\¹ÀH͛6lÙ³À¶Ä xå:ÀI÷ç7)8€@?æs”³3@S1bŋ,ÀH͛6lÙ³ÀµÇܫەÀHÊfƒ‹Ø€@>é)Š ²w@S|ùóçϟÀH͛6lÙ³Àµ§Œ_•ÀH|æÚ/[€@=õÈW±@Sȑ"D‰ÀH͛6lÙ³ÀµT/iTÂòÀH*ÔÝÙҀ@= ŒEøÑ@T(P¡B…ÀH͛6lÙ³Àµ}š‹ÀGÔãçA€@<&Ádô@T_¿~ýû÷ÀH͛6lÙ³ÀŽãšÛŸkŒ@T«V­ZµjÀH͛6lÙ³ÀެþŒ3 +ÀGÙ²~ž€@:xC³$Š@TöíÛ·nÝÀH͛6lÙ³ÀŽv\Çò¹ëÀFÁÐõ÷‹€@9¬gKS@UB… +(PÀH͛6lÙ³ÀŽA ΋è4ÀFb:°€@8梎ÝÈÖ@UŽ8páÃÀH͛6lÙ³ÀŽ žŠ÷Ÿ<ÀFþìš[€@8'ÌŸŸ”@UÙ³f͛6ÀH͛6lÙ³À³Ùÿ·ÕÑÀEžöIY!_€@7o0ÉÑS*@V%J•*T©ÀH͛6lÙ³À³ŠyºZcÔÀE|ùóçÀH͛6lÙ³À²·cfŸ_ÀCN7º7;€@3ŽóÛÒé¬@WêÕ«V­ZÀH͛6lÙ³À²‰í=fÀB쥬aՀ@2úƒå˜Œ>@X6lÙ³fÍÀH͛6lÙ³À²]«¯€žÀB‹Ù)ÆF€@2jU-0Ö@X‚ @ÀH͛6lÙ³À²2MçEÀB+ï|ƒ°€@1Þ5çÆÙ@X͛6lÙ³ÀH͛6lÙ³À²ÑB:QÚÀAÍX¬^W€@1UöƉl=@Y2dɓ&ÀH͛6lÙ³À±ÓĘlk¥ÀB$u—=X€@3&q7(ŠC@Ydɓ&L™ÀH͛6lÙ³À±©@”ËntÀAÛ<Ñ|€@2›~©q&È@Y°`Áƒ ÀH͛6lÙ³À±g<ç‚íÀA“šïÀc€@2Uûáu4@Yû÷ïß¿~ÀH͛6lÙ³À±V5Š³ÈŒÀAM35‚b€@1“Èí~}W@ZGŠŸ’_U€@,3MéÍD@\€H‘"DˆÀH͛6lÙ³À¯þ8{tÀ>FhŽ€@+t닔ð<@\ïß¿~ýûÀH͛6lÙ³À¯·ž†;ÂÀ=ÙŒÿ}€@*ŒÍâô¯<@];víÛ·nÀH͛6lÙ³À¯rJü ²JÀ=*Ɗþx€@* +·˜ÖÁb@]‡8páÀH͛6lÙ³À¯-ê·%XNÀ<¹Ýº)“€@)^n€€G@]Ò¥J•*TÀH͛6lÙ³À®ê’¥s!„À„ÞSÀ;sÂG$r¹€@'zA•ÒÐ@^µjÕ«V­ÀH͛6lÙ³À®&Š/÷©€À; &g0u€@&㎩+`@_ ÀH͛6lÙ³À­ç!âo6À:€‡"| €@&P·©—‡ÿ@_L™2dɒÀH͛6lÙ³À­š©¯\Í À:?ÛD)F”€@%Â÷{ÉP¹@_˜0`ÁƒÀH͛6lÙ³À­kñ+òÀ9Ý–yž¥€@%9ªn÷Ò@_ãǏF@`cF4hÀH͛6lÙ³À¬}Åh +[žÀ8d‚xÃ8€@#=ɕõmí@`‰$H‘"ÀH͛6lÙ³À¬DèË•À8 +ÉÉðZº€@"ÈiDªKÓ@`®Ý»víÛÀH͛6lÙ³À¬ .S€ì]À7²Åælf£€@"V˜ùtr@`Ô©R¥J•ÀH͛6lÙ³À«Ô™­ +±«À7\m')€@!è7ý:@`útéÓ§NÀH͛6lÙ³À«Ïè|À7¶– ø€@!}&ç œ@a @ÀH͛6lÙ³À«gÊŠ÷°/À6Ž™ê;És€@!G²l@aF 0`ÁÀH͛6lÙ³À«2ˆ·0ÃvÀ6c™«®€@ °}°éô@akׯ^œ{ÀH͛6lÙ³Àªþ•Z À6 O‚™€@ N«rt€@a‘£F4ÀH͛6lÙ³ÀªÊ=ªœõÀ5Ċۻ @€@ßpTBˆ±@a·nÝ»víÀH͛6lÙ³Àª—-s׊°À5w‚-Œ€@' +8Ÿ@aÝ:téÓ§ÀH͛6lÙ³Àªdр +BRÀ5+êWrÝØ€@t ¿£K—@b 0`ÀH͛6lÙ³Àª3&qF§À4ỏ"­˜€@Æ1ð”š@b(Ñ£FÀH͛6lÙ³Àª(û»<À4˜î-ÿ¢‰€@O¬‚Ùm@bN:téÓÀH͛6lÙ³À©ÑÕå}ÔEÀ4Qz±qŒ{€@y=³­ÄÏ@bthÑ£FÀH͛6lÙ³À©¢*8QÂÀ4 Y»‹ñ€@ÙÑ3S$n@bš4hÑ£FÀH͛6lÙ³À©s"FÐÎ À3Ƅûžž€@>áˆO@bÀÀH͛6lÙ³À©D»¡êÀ3‚òfäN€@šH«ù±2ÀH @ÀÃ7;e<ûj€€@f+Õès?òå˗.\¹ÀH @ÀÃ5ùIðÀqpŒÞ€§€@f{.¢@å˗.\¹ÀH @ÀÃ2CæÎ.&À ÆŠªzù€@fj3W‡Oæ@ X±bŋÀH @ÀÃ,0߬ OÀ(¯^‰nf€@fPd+Ah±@å˗.\¹ÀH @ÀÃ#µ‹œ±uÀ0PŒÞ’h€@f.œH?J@Ÿ>|ùóçÀH @ÀÃøþ·§ÊÀ4#…ê€@f`Õ¥îì@X±bŋÀH @Àà ýÜœÀ7ª“Ä{3€@eÑñ+^@ ‰$H‘"ÀH @ÀÂý?×!À;E!‡Ð߀@e—º“ò±eÍ3€@eWÆÝ£>Ã@%B… +(PÀH @ÀÂØÀRC—©ÀA,ùN»€@e¿žõŒ@'Ÿ>|ùóçÀH @ÀÂÀ_ÒÀB©|Üú‘€@dÆJyÒ˜@)û÷ïß¿~ÀH @À¬¬¡ÕÀD9[hq‹@€@duÜ»DӖ@,X±bŋÀH @À“æn$› ÀEµUÔV"µ€@d ‰Â9M@.µjÕ«V­ÀH @ÀÂyr.³oÀGá,B"€@cÆÁ.q…@0‰$H‘"ÀH @ÀÂ]mÁŠÝÀH_gl+р@châf‡y@1·nÝ»víÀH @ÀÂ?ä—7§IÀI¡žûK€@cY¢K\]@2å˗.\¹ÀH @À ëßÏVÀJ̹A9"Œ€@b¢›]\y@4(P¡B…ÀH @À  ÄÀKÞ #a¯¹€@b;!À4Ýè@5B… +(PÀH @ÀÁßOÃk…ÀLßñ¶ E €@aÑg.—¿ä@6páÇÀH @ÀÁŒpslçìÀMÔõŒ˜A€@aeäæõ@7Ÿ>|ùóçÀH @ÀÁ˜¬IŸÀNžÀIèE‚€@`ù œšÒ@8͛6lÙ³ÀH @ÀÁsæM<ÀOˆûϚh–€@`‹Bw­ ò@9û÷ïß¿~ÀH @ÀÁN6"ÞCÃÀP"Pjx{ڀ@`åÌGg£@;*T©R¥JÀH @ÀÁ'µ|In%ÀPtè²ÉJI€@_\œ ›¶+@µjÕ«V­ÀH @ÀÀ°cŸkìvÀQ'¡£W°€@\ÊTÝ•ð@?ãǏ»ÀQ•WŠï€@Y~H~óEæ@BN:téÓÀH @À¿Äž*°“šÀQžÔŠBñ€@X³Þ•CÐì@Bå˗.\¹ÀH @À¿qSp ~ÀQ¢ây°,±€@WíŽ:@B¶@C|ùóçϟÀH @À¿p9ÀQ¡Á^üwހ@W+”µ[ý«@D(P¡B…ÀH @ÀŸÊÁ0ŽžNÀQ›ëÕ4ý¶€@Vnk0å@D«V­ZµjÀH @ÀŸwŠ.E ÀQ‘ÒçäuԀ@UµN…ÝŽ·@EB… +(PÀH @ÀŸ$Ä#˜€×ÀQƒÞ«%êT€@U7+fÆÄ@EÙ³f͛6ÀH @ÀœÒ,còþ³ÀQrnÊØ3¹€@TQåVŒkò@FpáÇÀH @Àœî‘XAåÀQ]ÚéF6€@S§]cò"@G @ÀH @Àœ.H{¡ŠÀQDèQ£³M€@S¡X •@GŸ>|ùóçÀH @ÀŒÜÊB4StÀQ&ØñqŠ€@R`»&:΍@H6lÙ³fÍÀH @ÀŒŒÏdDÀQo“†H€@QĝеíÎ@H͛6lÙ³ÀH @ÀŒ<ã¶ÙaÀPÞU󯥀@Q-|FJ¡@Idɓ&L™ÀH @À»ì³‡Á©ÈÀPµ wܹ€@P›Z™»@Iû÷ïß¿~ÀH @À»ž+4 L™ÀP‰OG×îÿ€@P vCF{&@J“&L™2dÀH @À»Pw(Ñ͇ÀP[TVˆó¯€@O ºdo–@K*T©R¥JÀH @À» ¯ðucÀP+‘îL[d€@N[ž² c@KÁƒ 0ÀH @Àº·¯a‚‰$ÀOôŒ™Ž`3€@M¶nË_Á@LX±bŋÀH @Àºl©WÈÖvÀO Ž—±â€@L æuÜ"g@Lïß¿~ýûÀH @Àº"“_¢ÍÀO) )Ý­€@K¶:=@M‡8páÀH @À¹Ùq5ä ÀNÁó4•ŠS€@J5í!dá@NüïÀN[ÐËü%€@IXNYóö@NµjÕ«V­ÀH @À¹J9ÜÀM÷Œ6áO€@H‚”È®•s@OL™2dɒÀH @À¹¬OmÉÀM•• חj€@GŽ~«jQÇ@OãǏ²±VšÁ@T(P¡B…ÀH @ÀµHš$K$ÀHlÁՌˆ €@=1Únòûü@T_¿~ýû÷ÀH @Àµdìb§©ÀHDj6ìh€@|ùóçÀH @À²ÖmÙ¬åUÀCŽ«fHG€@4>u¥Þ@WêÕ«V­ZÀH @À²šX:"ÆÈÀCP PÍō€@3€ž;+ª@X6lÙ³fÍÀH @À²{/æt~ÀBì}­ò]€@32BÀNú@X‚ @ÀH @À²NðWi`ÀB‰ß4Óßw€@2|‹!çšL@X͛6lÙ³ÀH @À²#˜ŸÔæÀB(\œ̀@1îòrQoÖ@Y2dɓ&ÀH @À±ñÃÖÝžÀB‚–ÿ¢=€@2ñ1Š2k@Ydɓ&L™ÀH @À±Æd‹DÀB6lìêq€@2e±'«,"@Y°`Áƒ ÀH @À±›·ŽÐTÀAëŸhKú«€@1ßöpù@Yû÷ïß¿~ÀH @À±q¹«~š·ÀA¢,Kle€@1]8;AÕn@ZGº,·€@+ sîL@\ïß¿~ýûÀH @À¯á€OX¿À>TéÉ͕€@*Q—Œ/ñ@];víÛ·nÀH @À¯šúâ9gñÀ=ŸJW¹ãà€@) Dºóuˆ@]‡8páÀH @À¯U‹ë³ÑÀ=* ñh9€@(ôÐqºé@]Ò¥J•*TÀH @À¯-þ– 1À<ž«€ì–€@(NÿùƒïÇ@^À1ÿÚvÄ/í€@gUÛ(@Ÿ>|ùóçÀG:téÓ§NÀçä{è ÚÀ6Lò €@gaŒLMdí@X±bŋÀG:téÓ§NÀÙ¯ŒðÀ:W=›|±€@g*vZ"$º@ ‰$H‘"ÀG:téÓ§NÀÉ(VdHûÀ=ë.A#ì€@fëª6Oÿ@"å˗.\¹ÀG:téÓ§NÀÃvf§r”ÔÀ@ÎÙÕÄI€@f¥¥R~ò™@%B… +(PÀG:téÓ§NÀÃasš|_@ÀBŠàJNï€@fXþ~· @'Ÿ>|ùóçÀG:téÓ§NÀÃJfª•BŠÀD\&@–á“€@fm +2®<@)û÷ïß¿~ÀG:téÓ§NÀÃ1]HÉ~ÀF{¡®Ā@e®wÊK‹¢@,X±bŋÀG:téÓ§NÀÃe¥úºÀGh¿ +€@eQFh1ݔ@.µjÕ«V­ÀG:téÓ§NÀÂù¢—í³ÀIŸTÕŁ€@dïDG(Ÿ@0‰$H‘"ÀG:téÓ§NÀÂÛ5ÝÒ¹ÀJt\gÙŀ@dˆó®šV@1·nÝ»víÀG:téÓ§NÀ»+¡ÅmÀKÈ׀ß5b€@dÉOê²E@2å˗.\¹ÀG:téÓ§NÀ™œË}¡3ÀM£×“̀@c±F°:z*@4(P¡B…ÀG:téÓ§NÀÂvª¿ºWÀN#sj6ހ@c@òk²× @5B… +(PÀG:téÓ§NÀÂRqçŽÔ²ÀO0$0@Ä€@bÎSì9?@6páÇÀG:téÓ§NÀÂ-€ºß0ÀP¯€¿F€@bYïmºá@7Ÿ>|ùóçÀG:téÓ§NÀÂwFZÑÀP‹„f^¢€@aäBø §@8͛6lÙ³ÀG:téÓ§NÀÁÞâŒþNÀPõí†ڀ@amŒTª1-@9û÷ïß¿~ÀG:téÓ§NÀÁ¶a]>)ÀQTA+²1î€@`öÁéÎî@;*T©R¥JÀG:téÓ§NÀÁ ÒÜ +ÀQŠŒ·+ɀ@`±€Ýáü@µjÕ«V­ÀG:téÓ§NÀÁ WU\æÀRRØ#!µó€@^<|ÝÅØ@?ãǏ€@[‘é{Z@A·nÝ»víÀG:téÓ§NÀÀ^S©rÀR®š¢ŽD”€@Zµºóµ€­@BN:téÓÀG:téÓ§NÀÀ1ò§eÀR²ÅSŽ‘Ñ€@YÝÐ~}±@Bå˗.\¹ÀG:téÓ§NÀÀÈo÷åÛÀR°ÝútUD€@Y +|ƒ-‡@C|ùóçϟÀG:téÓ§NÀ¿³Ro„‹ÀR© =€@X;üê( £@D(P¡B…ÀG:téÓ§NÀ¿[BÒY ÀR= ì€@Wr~›¿ 3@D«V­ZµjÀG:téÓ§NÀ¿w›(W­ÀRŒ““Ò0€@V® e…Òt@EB… +(PÀG:téÓ§NÀŸ¬” -ÈÀRwú…,—u€@UîõbG*Û@EÙ³f͛6ÀG:téÓ§NÀŸTûTxRÀR_Üûc@U5ûfH}@FpáÇÀG:téÓ§NÀœþkãÏÀRDœPÀ@T€V•î’¢@G @ÀG:téÓ§NÀœše鵟ôÀR$õEaP€@SÐã&ƒŽ@GŸ>|ùóçÀG:téÓ§NÀœSN‘ÕvÀR'H4rš€@S&³òäb¬@H6lÙ³fÍÀG:téÓ§NÀŒþW%úGÎÀQ×á$ó€@RÊóäZ@H͛6lÙ³ÀG:téÓ§NÀŒªwK?ÖÀQª2bC5B€@Qâ"fÛ@@Idɓ&L™ÀG:téÓ§NÀŒWr„]&qÀQzYÓl’k€@QG®= +$@Iû÷ïß¿~ÀG:téÓ§NÀŒUêùȘÀQGýšðLŽ€@P²]NÈÐ@J“&L™2dÀG:téÓ§NÀ»Ž,CSŠÿÀQ“lé4Ѐ@P"\ȟ1@K*T©R¥JÀG:téÓ§NÀ»cþJ ©ÀP݁ÀáӀ@O-™ÓŸ(d@KÁƒ 0ÀG:téÓ§NÀ»ÒúVÉÀPŠ!ͯf¡€@N ³àØž@LX±bŋÀG:téÓ§NÀºÆ¯ÁjÀPmÁ>çS»€@MH÷¿kØ@Lïß¿~ýûÀG:téÓ§NÀºy˜ž³ŸÀP4£®[N3€@L#PpD@M‡8páÀG:téÓ§NÀº-Á.—JÀOö<(8Cñ€@K1ï²<Û@N"n°ju6@T_¿~ýû÷ÀG:téÓ§NÀµ<±V)—RÀHŠÓ0DP΀@=5 +º2I@T«V­ZµjÀG:téÓ§NÀµî<`ÀH@šj¶N€@|ùóçÀG:téÓ§NÀ²öÌiW„ÀDý£©Ÿ‰€@4ّe²@D@WêÕ«V­ZÀG:téÓ§NÀ²Ç¿UƒbžÀC·Vû-Kú€@49Á.DÅ@X6lÙ³fÍÀG:téÓ§NÀ²™¥áÆ»ÀCPœ“3‰ð€@3ž–ہ×@X‚ @ÀG:téÓ§NÀ²l}rx4ŠÀBëKB=Q@€@3ÛJqš>@X͛6lÙ³ÀG:téÓ§NÀ²@C3ÅŽ©ÀB‡l³,€@2uZ+ßx[@Y2dɓ&ÀG:téÓ§NÀ²jqM+ÀBáj «|r€@2µ×PÔÖ@Ydɓ&L™ÀG:téÓ§NÀ±ã/**žØÀB‘à$Å6?€@2)ÿ¹‹×µ@Y°`Áƒ ÀG:téÓ§NÀ±·®ÑW{ÀBCÔLœ@€@1£-U[#Î@Yû÷ïß¿~ÀG:téÓ§NÀ±ŒãwaŸkÀA÷C ø2‹€@1!,³š+@ZGŽ;ù¬~6€@)ÞÎêÇ@];víÛ·nÀG:téÓ§NÀ¯ÂýjŸŠÅÀ>/ ˆ®±€@).…‡ÖAÖ@]‡8páÀG:téÓ§NÀ¯|DʌÀ=šš1ðš€@(„)IF²@]Ò¥J•*TÀG:téÓ§NÀ¯7Á°À=$qjŸ¹’€@'ß~ !žb@^ô€@"}\cFK¯@`‰$H‘"ÀG:téÓ§NÀ¬~“? €À8£AKŸ#;€@" +‰ *@`®Ý»víÛÀG:téÓ§NÀ¬DÎŒ@À8F’E:π@!›GŒ#ŠÉ@`Ô©R¥J•ÀG:téÓ§NÀ¬ á [©cÀ7ë¹@/¥ð€@!/uáRš†@`útéÓ§NÀG:téÓ§NÀ«ÓÉ©ùåŸÀ7’¬ŽR«€@ Æó¯-Œê@a @ÀG:téÓ§NÀ«œøÊžÀ7;a©ü¢€@ a¡ã®¯@aF 0`ÁÀG:téÓ§NÀ«fr4³À6å΀žï€@þÅm²ä@akׯ^œ{ÀG:téÓ§NÀ«0S28¿À6‘é1Ý0€@@38Ԛ@a‘£F4ÀG:téÓ§NÀªûcïÕ9WÀ6?š랜€@‡VgsŸ @a·nÝ»víÀG:téÓ§NÀªÇ52Ӊ<À5ñ̀@Óú|Ê@d@aÝ:téÓ§ÀG:téÓ§NÀª“Ã75À5ŸìŽšŒc€@%íÆO@b 0`ÀG:téÓ§NÀªa +ŽÀ5R_й̀@|þ¥÷š@b(Ñ£FÀG:téÓ§NÀª/VÚplÀ5Rzàî€@ØþÏÌ*@bN:téÓÀG:téÓ§NÀ©ýŽ’ÞGáÀ4»»Ûƒ:;€@9ÃuG‹@bthÑ£FÀG:téÓ§NÀ©ÍNÓ-À4r“oéŸE€@Ÿ !ušÓ@bš4hÑ£FÀG:téÓ§NÀ©1fö(À4*Ðæò¿€@íò;Ô@bÀÀG:téÓ§NÀ©mÈøùÂoÀ3älÞÖӀ@wl v–ÀFpáÇÀÄdÌc‚ +Y€€@irQGi?òå˗.\¹ÀFpáÇÀÄc6?MnbÀö +/jë̀@ijÜRé0@å˗.\¹ÀFpáÇÀÄ^š{[µ™À$ W¥¹€@iW +žŸ @ X±bŋÀFpáÇÀÄW#ÌlÔ$À.$—ñÀ@i8¬@å˗.\¹ÀFpáÇÀÄLÎŒå3À3Ùc ÒN&€@iÍ\^w@Ÿ>|ùóçÀFpáÇÀÄ?Ã2inÀ8VJ—8€@hÛ6,%L·@X±bŋÀFpáÇÀÄ0!f°×`À<—Ó4•ºG€@hžm§O#@ ‰$H‘"ÀFpáÇÀÄû)›ôÀ@jPߘƀ@hY’z]@"å˗.\¹ÀFpáÇÀÄ j|ùóçÀFpáÇÀÃÙLþùÀF9®çC‘œ€@g\%•Kø@)û÷ïß¿~ÀFpáÇÀÜý÷|nåÀHŸ["€@fû'Ώç¯@,X±bŋÀFpáÇÀàžnÏ<ÀÀIŽÇLé2D€@f”„ž)r@.µjÕ«V­ÀFpáÇÀÁT%b7ÖÀK@ÙÄŒ·À€@f(œÇϓ@0‰$H‘"ÀFpáÇÀÃ`Cnn2 ÀLºì>•€@ežjøÚP£@1·nÝ»víÀFpáÇÀÃ=}ÅTçÀN#PÀ1€@eDbSöÒ@2å˗.\¹ÀFpáÇÀÃöT#šÀOos"Ù݀@dÌIEŒG@4(P¡B…ÀFpáÇÀÂóHu¬ÀPMü©îÕҀ@dQ¢Pš@5B… +(PÀFpáÇÀÂÌá,ÀPÙ±LÎÊ€@cÔ²ÓoÔ@6páÇÀFpáÇÀ£ŽžÀQ\=Fùª+€@cVŽÍWO@7Ÿ>|ùóçÀFpáÇÀÂz"rbæÀQÓ¶£\±¶€@bÖGõ‰‘·@8͛6lÙ³ÀFpáÇÀÂO„«CŠÀR?1üš—±€@bUÐÌ@9û÷ïß¿~ÀFpáÇÀÂ#÷z"ÃÀRžiYrœf€@aÕC#d7@;*T©R¥JÀFpáÇÀÁ÷˜™G”ÀRðBÔœ_°€@aTíRyH@µjÕ«V­ÀFpáÇÀÁnÜBåùïÀS“ã8³ ä€@_° Ÿˆì@?ãǏ~ÀSÛ4A„î€@[é+Ãee8@BN:téÓÀFpáÇÀÀ…:+æFÀSÙÌÉöì€@[¬ÚþãV@Bå˗.\¹ÀFpáÇÀÀV`m âÀSÐ}ÊÂì'€@Z![ûŽ.@C|ùóçϟÀFpáÇÀÀ'¢iü8ÀSÂ.d1ð€@YEsÔÒ@D(P¡B…ÀFpáÇÀ¿òŒ]ÀS®Ç\IÐ^€@Xo!dEW¥@D«V­ZµjÀFpáÇÀ¿•P›öÀS–ÛñÜ«€@Wž€ÖdØi@EB… +(PÀFpáÇÀ¿9ÔhšŠÀSzð’&³D€@VÓ¡Ë“_@EÙ³f͛6ÀFpáÇÀŸÝEz«@ÀS[|_×;€@V‰Rñ#»@FpáÇÀFpáÇÀŸ‚">ò;ÆÀS8êY§Ýπ@UO5.öÉ¥@G @ÀFpáÇÀŸ'«®IÝôÀSïÔr=€@T•Ÿæ|5w@GŸ>|ùóçÀFpáÇÀœÍùÚ±•éÀRåÈþùF€@SáÌá «@H6lÙ³fÍÀFpáÇÀœu#+žÀRµLŒ‰Á €@S3º ¶*@H͛6lÙ³ÀFpáÇÀœ:I?P.ÀR9•I€;€@R‹]bulŸ@Idɓ&L™ÀFpáÇÀŒÆN¢ÀŒ¬ÀRJ2Gý‡ý€@QèŠfD»@Iû÷ïß¿~ÀFpáÇÀŒplÖÞËÀRÄaˀ@QKreƒx@J“&L™2dÀFpáÇÀŒŸˆµ[ÀQÕiYýWŸ€@P³ÎŎ€1@K*T©R¥JÀFpáÇÀ»Çíw ûÀQ˜ŒØ>K€@P!wfâc@KÁƒ 0ÀFpáÇÀ»u^9Ý8’ÀQZ‰¿ºwö€@O(³Ãÿ8O@LX±bŋÀFpáÇÀ»#ö +äÍXÀQ°ÅöOå€@N©Ý eŠ@Lïß¿~ýûÀFpáÇÀºÓž6ŽèœÀPÜFÅðÄҀ@M‹ÚÀ£Õ@M‡8páÀFpáÇÀº„ŠÊÙáWÀPœ¢Pãt€@Lœ`¡t@N€@DÅ{\Ž@R 0`ÀFpáÇÀ·f¡< ª5ÀLašt™gV€@Cazˆéˆœ@RN:téÓÀFpáÇÀ·$ çSÙÀKý÷žOò€@BÁlšºž@Rš4hÑ£FÀFpáÇÀ¶âbÂE³ÀK›Q£Ê‚€@B'a›ƒ‹Ú@Rå˗.\¹ÀFpáÇÀ¶¡ž€ŸÀK:ÌÑË%¥€@A“!‘¢èp@S1bŋ,ÀFpáÇÀ¶aŒ‡“†ÀJÛæ¯˜t€@Av„ŽÊÌ@S|ùóçϟÀFpáÇÀ¶"»ó§6­ÀJzö¥mWÀ@@{'³š˜^@Sȑ"D‰ÀFpáÇÀµä€È.}°ÀJfFӓ‹€@?íå&÷Þ@T(P¡B…ÀFpáÇÀµ§~ˍæíÀI®Ýs8²€@>ï,á}œ@T_¿~ýû÷ÀFpáÇÀµkPMÞ¿~ÀIDñêӈï€@=ùš€ÄQ)@T«V­ZµjÀFpáÇÀµ0Qr*ÀHÙ)d'š€@= åu˜HØ@TöíÛ·nÝÀFpáÇÀŽõì­ÄK„ÀHkù²!€{€@<(sì¡ÒŠ@UB… +(PÀFpáÇÀŽŒŸ3㠙ÀGýÍ&\Ó@€@;KêÄ Yé@UŽ8páÃÀFpáÇÀŽ„”ÈƘrÀG÷1°F€@:væh•yp@UÙ³f͛6ÀFpáÇÀŽMqÇ»{ÀGçæ¿š±€@9©Ž ! @V%J•*T©ÀFpáÇÀŽTŒ`rÀF°Ë Òàb€@8á÷閠î@VpáÇÀFpáÇÀ³â>3Ø$ôÀFAêÊ˵N€@8!_Õ@VŒxñãǏÀFpáÇÀ³®-Á/ÀEÓ¶QVû€@7fðƒŸŒ@W @ÀFpáÇÀ³{ |ÐìÀEe»Z ÅV€@6²]ö{G4@WS§N:tÀFpáÇÀ³Ibú„œÀDøÈò¿€@6`]*yA@WŸ>|ùóçÀFpáÇÀ³ +ôØß[ÀDŒÎ@ê9€@5Y³Àuù°@WêÕ«V­ZÀFpáÇÀ²çþTk²ÀD!ë-{P€@4µŽu_s@X6lÙ³fÍÀFpáÇÀ²žìÉs;ÀCž<7^Äπ@4O/1/S@X‚ @ÀFpáÇÀ²ŠÓ`£«þÀCOÙ5ÂK€@3z XÙ@X͛6lÙ³ÀFpáÇÀ²]®ñvNSÀBèÕÀ¬ €@2ãTY‹M!@Y2dɓ&ÀFpáÇÀ²,®¢MmÀC@nÉ ‰€@2t, íǗ@Ydɓ&L™ÀFpáÇÀ±ÿ—ÕFÀBípL­‡·€@1è8ŽÀN—@Y°`Áƒ ÀFpáÇÀ±ÓA‚©æÀBœX^.H€@1aeUùÇ@Yû÷ïß¿~ÀFpáÇÀ±§ªG×èËÀBLVÒð€@0ß|SÜ/:@ZGk¯ÞZFÀ?‡%žnˆ€@*|)C5@\ïß¿~ýûÀFpáÇÀ°{¿ÚdÀ?]ŒÚH€@)dPLlåÒ@];víÛ·nÀFpáÇÀ¯êFþççâÀ>†ENĀ@(µ[éšÜ€@]‡8páÀFpáÇÀ¯¢¿›£K~À> ÍýbGQ€@( _/EßÐ@]Ò¥J•*TÀFpáÇÀ¯\[.œÇ‘À=éý“S[€@'i–?_@^@_ ÀFpáÇÀ®M°”Éø>À;Á ÜÅe¡€@%õ†-j,@_L™2dɒÀFpáÇÀ® £,' žÀ;S:$U€@$‡¢f +š@_˜0`ÁƒÀFpáÇÀ­Ì–zMVÀ:ç­ËíW€@$ÈU¡:@_ãǏ@`útéÓ§NÀFpáÇÀ«íé\-€À7ÖÇkéFá€@ eœÇ«9Ø@a @ÀFpáÇÀ«¶CÜ 7À7}gHV€@ ê¹x«@aF 0`ÁÀFpáÇÀ«~î*Ž/0À7%Òiœlò€@BK€‡w7@akׯ^œ{ÀFpáÇÀ«H¥àSw)À6ÏþðϬ€@†§ÞRf@a‘£F4ÀFpáÇÀ«&M}xàÀ6{ßõ+΀@в†_9@a·nÝ»víÀFpáÇÀªÞksdDÀ6)m®«:‹€@ 5lÈ@aÝ:téÓ§ÀFpáÇÀªªqkNUÀ5؝M¬Ûû€@tþ;×»U@b 0`ÀFpáÇÀªw4elÛÀ5‰e ?a€@ÎÜëaÿô@b(Ñ£FÀFpáÇÀªD°š…’HÀ5;»b…Fà€@-¢æ‰@bN:téÓÀFpáÇÀªâ’õšÚÀ4ï–ûà…€@‘"M qŸ@bthÑ£FÀFpáÇÀ©áƗ§ þÀ4€îÀ0\®€@ù1ooæ@bš4hÑ£FÀFpáÇÀ©±Y? ÅÀ4[¹ÎšÕ€@eŠÊ€ù¿@bÀÀFpáÇÀ©—&„[ÑÀ4ïz»™€@ÖZëÕVfÀE§N:téÀÅ +96¹Šy€€@k?äÐŒ?òå˗.\¹ÀE§N:téÀÅsËr“.À~ß ‡€@kq`šØO@å˗.\¹ÀE§N:téÀÅUˆÏ¥À&Öù>Áì#€@jû՜3Î@ X±bŋÀE§N:téÀÄûˆcÀ0¥ÐËDä^€@jÙÇ_I)Z@å˗.\¹ÀE§N:téÀÄï«q‡40À5å9u³5ـ@j«õz!q±@Ÿ>|ùóçÀE§N:téÀÄáIn +!ËÀ:Ðp» †«€@jr€Ñ[«ƒ@X±bŋÀE§N:téÀÄÐî!°À?sž+÷ŸÍ€@j/ ˆ_8®@ ‰$H‘"ÀE§N:téÀÄŒ!<ëBžÀBkžwø€@iáì:2ł@"å˗.\¹ÀE§N:téÀÄ¥Žã0ŽMÀD1óèÔá€@i‹ëòû˜{@%B… +(PÀE§N:téÀČnÒ²ÛÀFTM$¹€@i-ɳ=X@'Ÿ>|ùóçÀE§N:téÀÄpâs&ÜòÀHJÅVú €@hÈi$‰@)û÷ïß¿~ÀE§N:téÀÄSFo•ÀJ4.rBV€@h\Œ(©îs@,X±bŋÀE§N:téÀÄ3™ïÀL8ÖÅ[Z€@gê“3R`o@.µjÕ«V­ÀE§N:téÀÄñœo±ÝÀMªÌ9ùæ6€@gs q®ü@0‰$H‘"ÀE§N:téÀÃìù#èÂÀO<; +W³€@föæc @1·nÝ»víÀE§N:téÀÃÇ1re!“ÀP\B.€X»€@fv„Õ>$ž@2å˗.\¹ÀE§N:téÀߺG rÀQ +€@eò£SGåd@4(P¡B…ÀE§N:téÀÃv» µù;ÀQšbSº€@ekè°IÕ @5B… +(PÀE§N:téÀÃLXù3g3ÀR8Þ@„²;€@dâûkSÄ@6páÇÀE§N:téÀà ¬ä $«ÀRŸõñ”^€@dX„"†€@7Ÿ>|ùóçÀE§N:téÀÂóҔŠ&\ÀS8Q\î<€@cÍ¥Ï3î@8͛6lÙ³ÀE§N:téÀÂÅè˜ßÐÀS€éÖÿÅù€@cAK))f@9û÷ïß¿~ÀE§N:téÀ—wñ2ÀTæãÑ×€@bµ„ì––9@;*T©R¥JÀE§N:téÀÂgd}òå³ÀTT\$³)£€@b*5Šûȇ@µjÕ«V­ÀE§N:téÀÁÔŷÀTìÿæ&+P€@`¯Á²ª@?ãǏøÞ\@D«V­ZµjÀE§N:téÀÀtÆ-ðÀT±Šê2‡ð€@X€M…hp@EB… +(PÀE§N:téÀ¿ËyiÈ2ÀTƒ*Q+,€@W©#%»ðŸ@EÙ³f͛6ÀE§N:téÀ¿jŒ‡ÝSÀTeôšL>ó€@VØ`OŠ­—@FpáÇÀE§N:téÀ¿ +Àîú~ÀT;Tw쫬€@V ü‰bŸx@G @ÀE§N:téÀŸ«˜~ àxÀT O=Ȁ@UIìÌ%'Õ@GŸ>|ùóçÀE§N:téÀŸMZ̶³ÄÀSØzôËŸ€@TŒ/,+o@H6lÙ³fÍÀE§N:téÀœð#wn¥ÀSŸ£€pÇú€@SÔ»íBÉŽ@H͛6lÙ³ÀE§N:téÀœ“ôÜ.Ú ÀSc§ò„Œ(€@S#ƒ”:ìÝ@Idɓ&L™ÀE§N:téÀœ8íëHvýÀS$Õ:±9²€@Rxoê­7'@Iû÷ïß¿~ÀE§N:téÀŒßV‘çÆÀRãŸý€@QÓe AK,@J“&L™2dÀE§N:téÀŒ†t›·&ÀR ä˜~LR€@Q4EY|aÔ@K*T©R¥JÀE§N:téÀŒ/ǶÍÀR\Ž›ãè€@Pšìž;9¹@KÁƒ 0ÀE§N:téÀ»ØöÉ|ÀRŒ˜Ž'€@P6 vk9@LX±bŋÀE§N:téÀ»„!f ŒSÀQÑŸ·²ê΀@NñùÃ[Ë@Lïß¿~ýûÀE§N:téÀ»0—ƒ«îúÀQ‹’YÂÝ¢€@Mà/x›t@M‡8páÀE§N:téÀºÞY€SцÀQE_‰°€@LØœ‹ªÕ@NÐ|Nãú€@CÏ(ÄOR@RN:téÓÀE§N:téÀ·_Ì"¥àòÀLÒfÙÊ}Š€@C)ÖÎÍV@Rš4hÑ£FÀE§N:téÀ·4пaKÀLh‹8h€@BŠÔ±¥íñ@Rå˗.\¹ÀE§N:téÀ¶Ù–9'˅ÀKÿÍ17I>€@AñèJ±^@S1bŋ,ÀE§N:téÀ¶—뀺ϊÀK™l.’zª€@A^՗Ò_÷@S|ùóçϟÀE§N:téÀ¶W3âjþ ÀK1XfƒK€@@Ñ`@_Ë@Sȑ"D‰ÀE§N:téÀ¶u÷»DÈÀJÆx)µ€@@I@¿­Qÿ@T(P¡B…ÀE§N:téÀµØž×„`0ÀJXsـ0€@?Œcúq@T_¿~ýû÷ÀE§N:téÀµ›övæÀIçþ€q9€@>ázÖߐ@T«V­ZµjÀE§N:téÀµ^UÏÀԕÀIvf€®›€@=œ€%H'@TöíÛ·nÝÀE§N:téÀµ"·P6c?ÀI²gªŽ€@<±É&¯Ç@UB… +(PÀE§N:téÀŽè(¯ÒˆÀHGB(€@;ÏLúïÌ@UŽ8páÃÀE§N:téÀŽ®«2x°ãÀH~ðõt€@:ô¢+è(ƒ@UÙ³f͛6ÀE§N:téÀŽv?TÀGoÀGšš€?a€€@:!dÿ^—@V%J•*T©ÀE§N:téÀŽ>ä㗠ÆÀG5 +~ùŠ€@9U7'Ú·*@VpáÇÀE§N:téÀŽ›|ЇÀFÁàjƒQ~€@8¿xõ@VŒxñãǏÀE§N:téÀ³Ó`‰“Ï&ÀFOa<0s€@7Щœ× @W @ÀE§N:téÀ³Ÿ3€¿²ZÀEÝ»7O\€@7¥Ò2Ð1@WS§N:tÀE§N:téÀ³lÄþ·;ÀEmåçA€@6dhНTZ@WŸ>|ùóçÀE§N:téÀ³9øË/*³ÀDý—L°`u€@5¶ª¹Ê#Œ@WêÕ«V­ZÀE§N:téÀ³å»eå²ÀDZi&€@5(€çuS@X6lÙ³fÍÀE§N:téÀ²ØÕ{û±ÀD"z,bݛ€@4j¢åò@X‚ @ÀE§N:téÀ²©Ä»n EÀC· ¡ˆ°€@3ËÚÞ¿;@X͛6lÙ³ÀE§N:téÀ²{¯ù<³±ÀCM"(Îh€@31™¡*¡¬@Y2dɓ&ÀE§N:téÀ²I†MHÐÓÀCŸyޙ>8€@2+û¥Œž1@Ydɓ&L™ÀE§N:téÀ²’]ØrÀCHõñKf€@1 +ä‚Ûk@Y°`Áƒ ÀE§N:téÀ±îh° ×¥ÀBô8š1*ž€@1•Qqb~@Yû÷ïß¿~ÀE§N:téÀ±ÂÛHÀB¡=Ñib€@0—ÿEk…1@ZGö¿Ü&€@*O¯º~žT@\€H‘"DˆÀE§N:téÀ°RÅNkÄ£À@Æs±‹€@)••4””È@\ïß¿~ýûÀE§N:téÀ°-GQ þåÀ?{†ÒEå€@(âµYt@];víÛ·nÀE§N:téÀ°eî7ÅÀ>ø[ͅÛ€@(4­™»Ê @]‡8páÀE§N:téÀ¯È;žò•À>wü‹ €@'ZV³‡}@]Ò¥J•*TÀE§N:téÀ¯€×c3MÈÀ=úWé $±€@&ëÈU?$©@^Š(ýr€@#ŽØÂ†EÂ@_ãǏ~ÐúßÀ7dÏ×2€@~<¿g§ï@akׯ^œ{ÀE§N:téÀ«`c³g¢€À7 Å/ã߀@ÅÍFré@a‘£F4ÀE§N:téÀ«*VXª–À6·¢Ò™€@/Š0:@a·nÝ»víÀE§N:téÀªõyd +À6bÛ·‡Š€@e£<ÓX@aÝ:téÓ§ÀE§N:téÀªÀ‘Òx ˜À6SJK€@œ€n3^@b 0`ÀE§N:téÀªŒÓ*º=ŽÀ5¿räTÆ®€@gð‹‡­@b(Ñ£FÀE§N:téÀªYÑ€A¯­À5p0R1ûӀ@|*øéZý@bN:téÓÀE§N:téÀª'‰yP\¡À5" šrp€@✳’‘@bthÑ£FÀE§N:téÀ©õöû]EüÀ4Ö]]Õ)€@M’'ÎGØ@bš4hÑ£FÀE§N:téÀ©Å’~ëÄÀ4‹¹Qʋ€@Œâ'¯Mv@bÀÀE§N:téÀ©”äŒØ’À4B’mn€@0e5Þ!ÀDÝ»víÛ·Àź{ï!d€€@lãEÊñk?òå˗.\¹ÀDÝ»víÛ·ÀÅžƒgxÂîÀ:5,#€@lÚÖ©ˆø@å˗.\¹ÀDÝ»víÛ·ÀŲ×V[CÀ)S±^9Îm€@lÃJjña@ X±bŋÀDÝ»víÛ·ÀÅ©²2,&ÏÀ2fMŠÏâr€@laõ³Õ@å˗.\¹ÀDÝ»víÛ·ÀŝŠòÀ8,úïýü €@lj^HÒoâ@Ÿ>|ùóçÀDÝ»víÛ·Àō:—à>À=”ig¢âÁ€@l*3ÆmGî@X±bŋÀDÝ»víÛ·ÀÅzAËÀAR«ŠÒhS€@kÞjÁƒÙš@ ‰$H‘"ÀDÝ»víÛ·ÀÅdL˜‡>ÀCÕ.—¢e€@k‡ã©>ι@"å˗.\¹ÀDÝ»víÛ·ÀÅK|mìšhÀF/i|‰Þ€@k'_©¹r3@%B… +(PÀDÝ»víÛ·ÀÅ/è=_5UÀH}“ƒq€@jœ·4<,@'Ÿ>|ùóçÀDÝ»víÛ·ÀÅ·o°4ÒÀJ𠛣Úk€@jKî;*R@)û÷ïß¿~ÀDÝ»víÛ·ÀÄñÅ£œÀLŠÛ®ž–æ€@iÒðr„#@,X±bŋÀDÝ»víÛ·ÀÄ΄h›ÀN—›¬¬4€@iSRP/Š@.µjÕ«V­ÀDÝ»víÛ·ÀÄšä{Ÿ.–ÀP,×hg€@hÍßé׍@0‰$H‘"ÀDÝ»víÛ·Àā±ãa–êÀQ«]eƉ€@hCr%Šá@1·nÝ»víÀDÝ»víÛ·ÀÄX•ÜªŠ«ÀQʘf§€@gŽÇYFB@2å˗.\¹ÀDÝ»víÛ·ÀÄ-ŽB”ÀR'G8Np€@g" Ø@ S@4(P¡B…ÀDÝ»víÛ·ÀÄ8ýCÏ_ÀS$Óh®ûî€@fº§mäÃ@5B… +(PÀDÝ»víÛ·ÀÃÓM^1ÀSº]¡­‹€@eöÏ)G#@6páÇÀDÝ»víÛ·ÀÀìà»1ÀTCW^€†€@e^Ÿ“Ôø@7Ÿ>|ùóçÀDÝ»víÛ·ÀÃsX±<2ÀTŸ?ə2µ€@dÅ׋Ét*@8͛6lÙ³ÀDÝ»víÛ·ÀÃB§“ÃIÀU*‹²Ð§€@d- +â°ëB@9û÷ïß¿~ÀDÝ»víÛ·ÀéamatÀUˆL ¶°?€@c”žjϧ‚@;*T©R¥JÀDÝ»víÛ·ÀÂÜlŒÙô_ÀUÖLàŠ \€@býR"‰Uƒ@µjÕ«V­ÀDÝ»víÛ·ÀÂ?u)úNÄÀV`£©ïuž€@aA\=Ë›@?ãǏ|ùóçÀDÝ»víÛ·ÀŸÐ¥qŠÀT×gb¢c>€@UOhK=Ž@H6lÙ³fÍÀDÝ»víÛ·ÀŸnÇ ôøÀT–/Lì€@T]e\Õ@H͛6lÙ³ÀDÝ»víÛ·ÀŸ%l<ÙpÀTQ“(5©m€@S£K~™5Z@Idɓ&L™ÀDÝ»víÛ·Àœ®Î‹äšòÀT +FSè €@Rïän• ß@Iû÷ïß¿~ÀDÝ»víÛ·ÀœPͰ)¢aÀSÀá7a",€@RC Ŋ>V@J“&L™2dÀDÝ»víÛ·ÀŒô+|- \ÀSu趞@e€@Qœ¡>ˆx @K*T©R¥JÀDÝ»víÛ·ÀŒ˜îPqõÀS)Íð)}‹€@Püu­{’ß@KÁƒ 0ÀDÝ»víÛ·ÀŒ?œgÀ‡ÀRÜòIŽã£€@Pb_ËØ\;@LX±bŋÀDÝ»víÛ·À»æ³$ÜîoÀR©Ne5€€@OœeÃüAu@Lïß¿~ýûÀDÝ»víÛ·À»¹@î¿fÀRB:ŒÒ[€@N‚Š8a@M‡8páÀDÝ»víÛ·À»:,ÿ,bÆÀQôÿMÉšQ€@Mm¹úÀœõ@Nš°¶@Rš4hÑ£FÀDÝ»víÛ·À·W 5>2`ÀM:¿F*è€@BË›ëT@Rå˗.\¹ÀDÝ»víÛ·À·„©ºBÀLÊS«7S(€@B.Y…Ä~O@S1bŋ,ÀDÝ»víÛ·À¶Ïú§‹rÀL\±Ûyn€@A—³³ÆÞŒ@S|ùóçϟÀDÝ»víÛ·À¶ŒŠ.î··ÀKì–z™a€@Aé9O„'@Sȑ"D‰ÀDÝ»víÛ·À¶KcÜSÀKz+€ôc'€@@{­Ô¹–Õ@T(P¡B…ÀDÝ»víÛ·À¶ +»©ä1ñÀK|ë 6€@?ënÁVž@T_¿~ýû÷ÀDÝ»víÛ·ÀµËr²xÆÀJ`JF€@>é~ބ³ˆ@T«V­ZµjÀDÝ»víÛ·ÀµBõËg5ÀJt9».€@=ñF1'@TöíÛ·nÝÀDÝ»víÛ·ÀµP.ÙôT£ÀIŸjÚô€@=%ŒÀÞ@UB… +(PÀDÝ»víÛ·Àµ7ÏH >ÀI&( s€@<—æhí§@UŽ8páÃÀDÝ»víÛ·ÀŽÙ^n׳ÀH­4ÝvIž€@;;žØ¢ñŠ@UÙ³f͛6ÀDÝ»víÛ·Àޟ¢“Ðo‰ÀH4vJç,€@:d†¶mÇ @V%J•*T©ÀDÝ»víÛ·ÀŽgrž^GÀGŒ-Âæ[À@9”žD6Ü@VpáÇÀDÝ»víÛ·ÀŽ/­¥Æ>ÀGD•©œyœ€@8Ë¡2ýrn@VŒxñãǏÀDÝ»víÛ·À³ùgÛ(5ÀFÍ࢐€@8 7]Ô1[@W @ÀDÝ»víÛ·À³ÃÂUlÀŽÀFX8œÕl€@7M ìŒÆÖ@WS§N:tÀDÝ»víÛ·À³ƒÊ¯DkÀEãÄêõ|€@6–ÒÑ«c @WŸ>|ùóçÀDÝ»víÛ·À³\VÉz3`ÀEp¥K_1€@5æ>\£-@WêÕ«V­ZÀDÝ»víÛ·À³*8 ŒyÀDþõ?ߎ[€@5;÷àrà@X6lÙ³fÍÀDÝ»víÛ·À²ù$5uÀDŽÌª‰€@4”ïꋶó@X‚ @ÀDÝ»víÛ·À²É.ešÀD = ^³€@3óŽà +–@X͛6lÙ³ÀDÝ»víÛ·À²š |M1ÀC³XWvA€@3WåíB(@Y2dɓ&ÀDÝ»víÛ·À²eç1ýãÀCþ_.v³<€@1Ýh!pÜ@Ydɓ&L™ÀDÝ»víÛ·À²71ÕÀC€CtÖV|€@1Q¬ƒ»öµ@Y°`Áƒ ÀDÝ»víÛ·À² ì{øˆÀCLywfà€@0˔5sA +@Yû÷ïß¿~ÀDÝ»víÛ·À±Ûê­¡ÖûÀBõÒú%ۀ@0JFñs@ZGŒ±ê€@'¬d…ɍo@]‡8páÀDÝ»víÛ·À¯ìéÜ]ÛSÀ>äó=•š€@'ešës@]Ò¥J•*TÀDÝ»víÛ·À¯€ˆ³Ú.\À>cŒhiz€@&gnçyä@^ÈÞl‰€@õ<‰êN³@`útéÓ§NÀDÝ»víÛ·À¬ Mm‰GÀ8[œu\g!€@.,4ñv@a @ÀDÝ»víÛ·À«ç2)ŽòºÀ7þ;®°€@ma6¹“«@aF 0`ÁÀDÝ»víÛ·À«®ñx(!9À7¢¬Ÿ"ˀ@²žåŒK@akׯ^œ{ÀDÝ»víÛ·À«w†Í5ÒVÀ7IC‡“ €@ý©²­a§@a‘£F4ÀDÝ»víÛ·À«@íº‘ãÀ6ñ6)¯Ë€@NJð&h£@a·nÝ»víÀDÝ»víÛ·À« !ì'÷pÀ6›6¬uîê€@€L̎ˆ@aÝ:téÓ§ÀDÝ»víÛ·ÀªÖ,>-À6FúT·5ʀ@ÿ|'Ÿ¿‘@b 0`ÀDÝ»víÛ·Àª¡á]䫅À5ôu÷Y…W€@_šeòh@b(Ñ£FÀDÝ»víÛ·Àªnd~±RÀ5£ž³ØžÞ€@ġރV@bN:téÓÀDÝ»víÛ·Àª;€¥”ú>À5TiòÇÛQ€@.<2Ðş@bthÑ£FÀDÝ»víÛ·Àª ž0—UÀ5ÍdK€@œL—H1@bš4hÑ£FÀDÝ»víÛ·À©ØLÜ.qÀ4ºŸþ‰P<€@©º'MÏ@bÀÀDÝ»víÛ·À©§­’œpAÀ4p4üŒs€@…,,<šBÀD(P¡B…ÀÆvyZà& €€@nӝ}Ì7?òå˗.\¹ÀD(P¡B…ÀÆtI¢sŒÎÀ*MõZ97€@nÊƧ @å˗.\¹ÀD(P¡B…ÀÆnGé¶ìÀ, —Սº €@n°Pà{fÿ@ X±bŋÀD(P¡B…ÀÆcâ„[ÂcÀ4\1B·¯ž€@n†D/žRE@å˗.\¹ÀD(P¡B…ÀÆUôdDöÀ:Œm£6Þæ€@nL«kç–@Ÿ>|ùóçÀD(P¡B…ÀÆDg.néÀ@Xo®,€+€@nDæä¬@X±bŋÀD(P¡B…ÀÆ/sE×¢ÀC&"PWy€@m®›åµÄÙ@ ‰$H‘"ÀD(P¡B…ÀÆ:YB°ÀEÝeä8R€@mL¶¿¢³@"å˗.\¹ÀD(P¡B…ÀÅûãk (oÀHm±b” €@lßx?±T«@%B… +(PÀD(P¡B…ÀÅ݋—åÀJíU‘66ý€@lgÚžÁ>Õ@'Ÿ>|ùóçÀD(P¡B…ÀÅŒ_ŽPO§ÀM4ž ËN€@kçð“€u@)û÷ïß¿~ÀD(P¡B…Àؒk«Ä7ÀOhYå1Ë2€@k^!"jéº@,X±bŋÀD(P¡B…ÀÅr;ÿ&?§ÀPœ—›€@jÎÄ(Sa@.µjÕ«V­ÀD(P¡B…ÀÅIú?©þÀQ­•€j¿€@j7ŸtH»?@0‰$H‘"ÀD(P¡B…ÀÅÁtI—ÀRŽÔ%»]ǀ@iœOºTó @1·nÝ»víÀD(P¡B…ÀÄñî»Oó!ÀSa{»¹)`€@hü¢ Š9H@2å˗.\¹ÀD(P¡B…ÀÄÃ@Ë<ÀT ¥óç‚N€@hY–nóÁñ@4(P¡B…ÀD(P¡B…ÀĒè{Ž=€ÀTÊd}€II€@gŽ#š¿@5B… +(PÀD(P¡B…ÀÄaûžÌÀUc×Yy€@g ·x‘šh@6páÇÀD(P¡B…ÀÄ-åët.TÀUî«G b€@fd”VÑžŠ@7Ÿ>|ùóçÀD(P¡B…ÀÃù‚w¯»†ÀVi­žøÒ^€@eŒZf6@8͛6lÙ³ÀD(P¡B…ÀÃÄxq0ÀVԖt4V߀@e¬¢œÎ@9û÷ïß¿~ÀD(P¡B…ÀͲ²…¿ÀW/°Šš«€@dnҀÉn@;*T©R¥JÀD(P¡B…ÀÃV’*ÀWyÈÿg#€@cÉ›·^Ý@µjÕ«V­ÀD(P¡B…À®:ø¡”ÀWñ~MR‰€@açݙÜ–@?ãǏ|ùóçÀD(P¡B…À¿W"šÛå—ÀU㳔ÿÄr€@UŽw¢Â3²@H6lÙ³fÍÀD(P¡B…ÀŸðgď>)ÀU˜ä*hÛ"€@TÄ-á·€@H͛6lÙ³ÀD(P¡B…ÀŸ‹>ÓÍØÀUJÚ5Õ.@€@T\Id1@Idɓ&L™ÀD(P¡B…ÀŸ';–ƒPÀTúO¡Ïû±€@SEÛ»®¶Œ@Iû÷ïß¿~ÀD(P¡B…ÀœÄ╈ÀT§ã¡QV”€@R‘¥ú$j@J“&L™2dÀD(P¡B…Àœdeö{ÍÀTTtrO€@Qä¿a>@K*T©R¥JÀD(P¡B…Àœλ0ð‰ÀSÿt›ó€Ç€@Q=‚€ÂÚá@KÁƒ 0ÀD(P¡B…ÀŒ§aÅBºÀSªIšØÚ€@PyWÙՏ@LX±bŋÀD(P¡B…ÀŒJþî3ÒÀSTòJ2C€@Pϵ¥Õ@Lïß¿~ýûÀD(P¡B…À»ðs‚iË|ÀRÿ¶ÝMÈ€@Nà÷p;@M‡8páÀD(P¡B…À»—yöçzÀRªñžĀ@MňÎ!³K@NŽ ãa@S1bŋ,ÀD(P¡B…À·È‰¯ ÀM"¶ˆãÀ€@A¥­n>@S|ùóçϟÀD(P¡B…À¶ÂI  ÀL«kDú«ƒ€@A{˜÷@Sȑ"D‰ÀD(P¡B…À¶NPý¢ÀL1§›0X€@@„Ä!ñ]@T(P¡B…ÀD(P¡B…À¶=r!ýÀK¶Zð!W€@?ùó ûÏ@T_¿~ýû÷ÀD(P¡B…Àµü5X«¡HÀK9üH횀@>ô²1ïÀ@T«V­ZµjÀD(P¡B…ÀµŒ{€GÛzÀJ»#e]O܀@=ù6ùHx@TöíÛ·nÝÀD(P¡B…Àµ}ëï#Ò:ÀJ<DŽr'€@=ýÊ~¿@UB… +(PÀD(P¡B…Àµ@†êŠp,ÀIŸMÀ^2h€@<‰ŽšåC@UŽ8páÃÀD(P¡B…ÀµL{s}ÄÀI@D:ôˆ€@;|ùóçÀD(P¡B…À³~ÓÊ È ÀEå ŽÒO€@5Û®‘Ôm@WêÕ«V­ZÀD(P¡B…À³KŠPÑ/ŸÀEoҋJp€@5/YúJà@X6lÙ³fÍÀD(P¡B…À³‹»$,ÀDüNïo—€@4ˆ8)Œ=@X‚ @ÀD(P¡B…À²èðì†øÀDŠ€@0üHôok@Y°`Áƒ ÀD(P¡B…À²#KWª*ÌÀC£„·µå‹€@0w<Ôs@Yû÷ïß¿~ÀD(P¡B…À±õQ‹ôùÀCIêü +x€@/îKŸõ™@ZGËUÏZžy€@%Ü5ÿ’@^I&v +Tù€@%D7PŸ{@^iÓ§N:ÀD(P¡B…À¯8L° +|êÀ=Éß ÜøÂ€@$±µ‚Dù@^µjÕ«V­ÀD(P¡B…À®ò‚W”4±À=Mnš§|ã€@$$FYÙkä@_ ÀD(P¡B…À®­Ú¬› +À<ÓÁGB0€@#›Ž Pî¡@_L™2dɒÀD(P¡B…À®jO<Ž{îÀ<\řfK€@#ËX™Ë@_˜0`ÁƒÀD(P¡B…À®'Ù¿AŽ”À;èjü=€@"˜[]?`¹@_ãǏu +À5ÕóÐWÊʀ@…äiK@bN:téÓÀD(P¡B…ÀªO/€yºÀ5…=ÿ_z&€@t æ°É @bthÑ£FÀD(P¡B…Àª·>^ÄÙÀ56.`1È̀@ån°pµ@bš4hÑ£FÀD(P¡B…À©ê÷Ö;JÐÀ4èºQ©+·€@[ s(h@bÀÀD(P¡B…À©¹íˆ<`À4œ×~9h<€@Ôœyâ¡ÀCJ•*T©RÀÇ?),ÃS›€€@pu㢌¢?òå˗.\¹ÀCJ•*T©RÀÇ<œöÄ +åÀ šoøÈí€@ppý²z@å˗.\¹ÀCJ•*T©RÀÇ5Íp·ÿäÀ/õ>.ꏀ@pbömҙ@ X±bŋÀCJ•*T©RÀÇ*•CBÅ)À6‘Î×׳é€@pKf„\£<@å˗.\¹ÀCJ•*T©RÀÇ$Ϫf¹À=¢ þea€@p*À‡èÎ@Ÿ>|ùóçÀCJ•*T©RÀDZĊ`ÀB¡ q¯€@p”-ŠWS@X±bŋÀCJ•*T©RÀÆð}@t +ÀE*+FŒºÎ€@o¡ˆš…×@ ‰$H‘"ÀCJ•*T©RÀÆÕ±—ÎyVÀH,Éö ßɀ@o1ÕHa>Ø@"å˗.\¹ÀCJ•*T©RÀÆ·{lUųÀJúŸMœÞû€@nµ! Ã+¹@%B… +(PÀCJ•*T©RÀƕýóvü!ÀM²¬ªšàö€@n,ŠîiÍ@'Ÿ>|ùóçÀCJ•*T©RÀÆqo?iÎÀPOÊ}€@m™h}1Šž@)û÷ïß¿~ÀCJ•*T©RÀÆJ¿ûrRÀQD°Üڟ€@lý.µ×@,X±bŋÀCJ•*T©RÀÆ鲿J{ÀR`§ܜӀ@lYÑdœ@.µjÕ«V­ÀCJ•*T©RÀÅóM %íŒÀS_ûô—\€@k®,Pš%@0‰$H‘"ÀCJ•*T©RÀÅÄmtˆ¡ÀTMÀCzL‡€@jþ€ '|ùóçÀCJ•*T©RÀąbäšAãÀX@’õÞW¿€@fªê•¿i@8͛6lÙ³ÀCJ•*T©RÀÄK›Ÿ‹\ÀXš>ÓRþh€@eò&!T>@9û÷ïß¿~ÀCJ•*T©RÀÄòÅm|èÀXþÄÓm»u€@e;Iââm@;*T©R¥JÀCJ•*T©RÀÃՒáuéÀYC&˜Ÿ¢€@d†Ñô0!à@µjÕ«V­ÀCJ•*T©RÀà äÔ³|(ÀY¢y\"›€@b|H®{.N@?ãǏ €@Zq>Ïž-Ï@EB… +(PÀCJ•*T©RÀÀÎۏN®ÀX(ð—Œó“€@Yr L¥ñ@EÙ³f͛6ÀCJ•*T©RÀÀ–E1ÅÀWã/àT(€@X|ö·–áÞ@FpáÇÀCJ•*T©RÀÀ^¥sNÀWšÃÞÖ\ö€@WÜg€ +@G @ÀCJ•*T©RÀÀ&š¢¡ˆÀWN=ÁŽŸX€@V«ã[šèb@GŸ>|ùóçÀCJ•*T©RÀ¿ßר¡« +ÀVüÈè\7¥€@UÐ0£z™š@H6lÙ³fÍÀCJ•*T©RÀ¿tüM‡ÀV§ng?c7€@TüÞÌH@H͛6lÙ³ÀCJ•*T©RÀ¿ ÎÏRЈÀVO]/Tç€@T1¿YsUd@Idɓ&L™ÀCJ•*T©RÀŸ¡>»Ë ÀUôo 1߀@Sn¢º5ï€@Iû÷ïß¿~ÀCJ•*T©RÀŸ:^ßäEÀU˜0Š[ò!€@R³R tÁ-@J“&L™2dÀCJ•*T©RÀœÕ5YåjÀU:߇ŀ@Qÿ”(ŸÊÈ@K*T©R¥JÀCJ•*T©RÀœqƵoÍ÷ÀTÜò3€/ €@QS-ãÐ.@KÁƒ 0ÀCJ•*T©RÀœ@8«ûÀT~Îÿói€@P­âÔùè@LX±bŋÀCJ•*T©RÀŒ°^аžÀT ËL±[€@Pv…–@Lïß¿~ýûÀCJ•*T©RÀŒQã͍‚ÀSÃ.–û€@NïTÓ™œ@M‡8páÀCJ•*T©RÀ»õaÿEzÌÀSfT‘‹"€@M̆ŠØ®@NÀKä-»9Ÿ€@>›*1gmL@T«V­ZµjÀCJ•*T©RÀµëw9}"ÀK_àÄ=€€@=ŸhfíÖà@TöíÛ·nÝÀCJ•*T©RÀµ«i›…ÀJÛq—FQ€@<­»0}@UB… +(PÀCJ•*T©RÀµl”¥ŽÕŠÀJW7m'"é€@;ÃǯãC@UŽ8páÃÀCJ•*T©RÀµ.÷~3ŸÀIÓ~’Eý€@:âéŽä“@UÙ³f͛6ÀCJ•*T©RÀŽò£(üÀIP‰ ì€@: +kœ@V%J•*T©ÀCJ•*T©RÀŽ·^} ÀHΒ&h§N€@98ËëjŠ3@VpáÇÀCJ•*T©RÀŽ}]t›ÓÀHMÊWip€@8nž2©Ð@VŒxñãǏÀCJ•*T©RÀŽDŠ¥ýµ`ÀGÎ]›ü‰€@7«sz5ó@W @ÀCJ•*T©RÀŽ ãŽk×êÀGPo?¯žN€@6¯$Î@WS§N:tÀCJ•*T©RÀ³Öd MH…ÀFÔïˆkà€@67ðCð<@WŸ>|ùóçÀCJ•*T©RÀ³¡)o¯ÀFY‰n¿—€@5‡ +_'Ù@WêÕ«V­ZÀCJ•*T©RÀ³lËŒíGSÀEàÁ”Xÿ€@4Û§jæ 4@X6lÙ³fÍÀCJ•*T©RÀ³9ªkxkÀEiÚJGè@€@45}…^@X‚ @ÀCJ•*T©RÀ³Ÿ²ÒGÀDôáý3F,€@3”JUsS@X͛6lÙ³ÀCJ•*T©RÀ²ÖŠõ#lÀDäė€@2÷Í6Êߎ@Y2dɓ&ÀCJ•*T©RÀ²{O•ÀDºö +kµ€@1*jyYÜ@Ydɓ&L™ÀCJ•*T©RÀ²l“è ÜiÀDY¿7è߀@0 °µ©›G@Y°`Áƒ ÀCJ•*T©RÀ²<òڈžÙÀCúQuC³É€@0jT“Œl@Yû÷ïß¿~ÀCJ•*T©RÀ²0äøVÀCXî4€@/:®v.¯@ZGŠ•ÐÀCB‹Ï…Xž€@.Ftîÿõ%@Z“&L™2dÀCJ•*T©RÀ±³0p"BsÀBéݱrÇ€@-[Ž6º@ã@ZÞœzõë×ÀCJ•*T©RÀ±†é§§ŒÀB“B* +€€@,yþ-.n¥@[*T©R¥JÀCJ•*T©RÀ±[m`;ºÀB>¬ØI8€@+ ê'¶Ñ@[uëׯ^œÀCJ•*T©RÀ±0µêŒ\‚ÀAìm,îÆ€@*ЫQ­@[Áƒ 0ÀCJ•*T©RÀ±¿ "ëÀA›c³Š¿Ÿ€@*,›”õ@\ 4hÑ£ÀCJ•*T©RÀ°Ý…®š7óÀAL——hß̀@)E¯Î/8Ž@\X±bŋÀCJ•*T©RÀ°µ²ÁšÞÀ@ÿ¡*·Õ€@(‹q$ðp@\€H‘"DˆÀCJ•*T©RÀ°5dÜ:ŠÀ@Žt«UÉ€@'Øý¿Ìi@\ïß¿~ýûÀCJ•*T©RÀ°f˜õ\oÀ@k†¢…-€@'+E& ”@];víÛ·nÀCJ•*T©RÀ°?£>$ï9À@#K]‡â€@&„Á6þÔ\@]‡8páÀCJ•*T©RÀ°×^!“vÀ?ºp {K^€@%ä@c3mI@]Ò¥J•*TÀCJ•*T©RÀ¯é^9}náÀ?1ƒ'>Հ@%I€Fæ/ó@^«ºvFƒâ€@$ŽAº†¡ª@^iÓ§N:ÀCJ•*T©RÀ¯Xu AôÀ>)*M貀@$$H§—3Â@^µjÕ«V­ÀCJ•*T©RÀ¯ÍæQëiÀ=©B²Ð&Š€@#™[ßߙ@_ ÀCJ•*T©RÀ®ÌQ=ÛûÀ=,k²+€@#Dö­™@_L™2dɒÀCJ•*T©RÀ®‡÷ÏjZÀ<²fšÊh€@"‘Ð7ýƒ@_˜0`ÁƒÀCJ•*T©RÀ®D»‚ëÀ<;"Wþ]z€@"Ëõšu£@_ãǏŸ»GñÀ7aÖôl'€@¯þƒµE@a·nÝ»víÀCJ•*T©RÀ«5låÊø«À7^Bé Ð€@ „hŠQ@aÝ:téÓ§ÀCJ•*T©RÀªÿlFµRTÀ6°ÊX>€@p2­]E@b 0`ÀCJ•*T©RÀªÊ8Y Z(À6[ ŠS싀@×}%Ñÿc@b(Ñ£FÀCJ•*T©RÀª•ÌÐÁfÀ6Ü~ ­€@Cšªi/@bN:téÓÀCJ•*T©RÀªb%~MQéÀ5Žëã9=€@Ž",Wœ@bthÑ£FÀCJ•*T©RÀª/>MÞoŒÀ5dn̅øQ€@)£°@bš4hÑ£FÀCJ•*T©RÀ©ýFŒ'ËÀ5šœ;¯ã€@¢NX@bÀÀCJ•*T©RÀ©Ë ‰7âÀ4Èd®Îüò€@) ¯}–ÀB ÀÈ–%Šj€€@q˜$áîkå?òå˗.\¹ÀB ÀÈêó` žÀ"W]•îk€@q’áùŸk@å˗.\¹ÀB ÀÈ AùŒˆÀ1(ŒÍ‚ƒ€@qƒ‹/.¹@ X±bŋÀB ÀÇþÎ5-~ÆÀ9Ù©æ¹*€@qhü#ÈâL@å˗.\¹ÀB ÀÇíŠyá¯À@wâ<ùN€@qC¯W‡l@Ÿ>|ùóçÀB ÀÇØê“hœÀD w‚}N—€@q{Ò­@X±bŋÀB ÀÇŸ?Á|€1ÀG8Xómç€@pÜdö—@ ‰$H‘"ÀB ÀÇ =rx¹ÀJÒŽƒu÷€@pœ .3@"å˗.\¹ÀB ÀÇ’Ê®ÀMçW¿Ÿ%®€@pTWºW4@%B… +(PÀB ÀÇYç0ëðÀPp'$ê€@p±%èƒì@'Ÿ>|ùóçÀB ÀÇ1u «æÁÀQÈ«éŽÄ€@oaæÚ.Ó@)û÷ïß¿~ÀB ÀÇï=SG¥ÀSNam€@n®k™žŠ@,X±bŋÀB ÀÆ×|˜®m‘ÀT?zKo#€@mñ·ì܉Í@.µjÕ«V­ÀB ÀÆŠb>ó@ÀUNF–\$€@m.^œZ.@0‰$H‘"ÀB ÀÆrãn”wÀVHck^L€@lf ¡€PF@1·nÝ»víÀB ÀÆ=,„6ÏÀW.,hó€@k™­øµc@2å˗.\¹ÀB ÀÆs–Œ²…ÀWû"“0•€@jÊšEŸà@4(P¡B…ÀB ÀÅËõ€ž_ÀX­Õš Z€@iúJŒ@5B… +(PÀB ÀŐì9ÐLÿÀYJßð‹Hì€@i(òٌ³@6páÇÀB ÀÅT‡@¶ÀYÔ|ùóçÀB ÀÅõŠOŒÀZI¡Ý{šÞ€@g‰©2CM·@8͛6lÙ³ÀB ÀÄØdãb&SÀZ«‹( ˆÐ€@fœžÀ[6¯Qˀ@e-í.ÌfÏ@µjÕ«V­ÀB ÀÖåA¹ÑÀ[v³œ T€@bõp±ì^º@?ãǏ|ùóçÀB ÀÀ4·ú®ŸÀX" z}®Ô€@Uӄvn¡@H6lÙ³fÍÀB À¿øP&ipÀWÁŠœÁ€@T÷×f—p@H͛6lÙ³ÀB À¿‰=|jôÀW]ayI.n€@T%0*·ç#@Idɓ&L™ÀB À¿Ž<õZÐÀV÷º·Ë~¡€@S[N±_#@Iû÷ïß¿~ÀB ÀŸ¯þÆÂpÀVÈm3‚Á€@R™ïssÍ~@J“&L™2dÀB ÀŸFWžvBåÀV)gñãt€@Qà̧Ü@K*T©R¥JÀB ÀœÞ›_ÀUÁ&Xº™€@Q/Ÿ8µ @KÁƒ 0ÀB ÀœxɅ>€”ÀUYUe1€@P†“é#Ö@LX±bŋÀB ÀœáoLVªÀTñúå]iœ€@OÈ ŠOb~@Lïß¿~ýûÀB ÀŒ²ß÷*@ÀT‹_£gH¬€@N’ôÆéø@M‡8páÀB ÀŒRÀôzyÕÀT%ÞB[ï€@Miؑehî@N»,)@OL™2dɒÀB À»=HBžµQÀSìš+œ€@J=HóÄ1@OãǏâàJþŠÀQ +èÜò9—€@EVa +¿Š@Q·nÝ»víÀB Àžïñ¡ŽÀPŸt‹`?€@DJjdT]ß@R 0`ÀB Àž ­>ވrÀPs>†zæ*€@C”™mQ@RN:téÓÀB ÀžS¢°KlÀP*G°’Ý>€@Bæ„|MiJ@Rš4hÑ£FÀB Àžì†éüwÀOÆCe?éa€@B?Ә»³â@Rå˗.\¹ÀB À·œ‚XtŠÀO;v­4ý€@A 1Ôb‹ä@S1bŋ,ÀB À·t\ S׆ÀN³ùôsÀ€@ANÝïhš@S|ùóçϟÀB À·,tçåÙÀN,jÿuˆÿ€@@t؊Š7l@Sȑ"D‰ÀB À¶åÏôJNëÀM£K2O€@?Ðäadæ@T(P¡B…ÀB À¶ p;F1ÀM$£ŸT€@>ÅêÓÞ@T_¿~ýû÷ÀB À¶\W–žÀŸÀLŽpQŠQë€@=Àë€Üƒ¥@T«V­ZµjÀB À¶†ÔŸVòÀL™E̲=€@<ÈzÞíÎ@TöíÛ·nÝÀB Àµ×ýÖ ¬dÀKxü¿hˆÈ€@;ٟéÛf³@UB… +(PÀB Àµ—»¯Þ pÀJîìMÄl׀@:óМ&ÀÏ@UŽ8páÃÀB ÀµXŸ¿ t;ÀJe®ï&¥e€@:‹…Ù|@UÙ³f͛6ÀB ÀµÈØÇÀI݂1ýxǀ@9AW>%gå@V%J•*T©ÀB ÀŽÞ‹2}rÀIV›8[vL€@8sÁݔu@VpáÇÀB ÀŽ£NAáˆëÀHÑ' ³…â€@7­`€té¿@VŒxñãǏÀB ÀŽiJÜgSÀHMNV‘ëπ@6íÎÑ0Ü@W @ÀB ÀŽ0|儚ÀGË0MÈ[Ԁ@64®šBÙ@WS§N:tÀB À³øà$ˆjOÀGJé*C ™€@5§^wKe@WŸ>|ùóçÀB À³Âp%'ƒ/ÀF̏ւçw€@4Ôeú¡O@WêÕ«V­ZÀB À³(BþíÀFP7 +„Ý¡€@4,œGŸö@X6lÙ³fÍÀB À³Y®øÀEÕíį˜^€@3ŠÌQh@X‚ @ÀB À³%ý|—}—ÀE]¿¶6¡¯€@2ìNjšô@X͛6lÙ³ÀB À²ô©"ΓÀDçµ€Œ¬,…€@0ÆV"n›™@Ydɓ&L™ÀB À²†v<Ÿ€ÀD³ ±í}€@0=ëŠI.È@Y°`Áƒ ÀB À²V*}LjÀDPL=šæË€@/vÜ$ßÅ@Yû÷ïß¿~ÀB À²&|HÀΗÀCïíŽ͓€@.z§Ü:Â,@ZG†\Ÿîc¢€@#T ‹zs@^µjÕ«V­ÀB À¯09··íÀ>VTÃE€@#.¹^K@_ ÀB À®éëå÷ÈjÀ=ƒZCœå|€@"„Õud@_L™2dɒÀB À®€ÈÚVÉsÀ=SE.р@"à¯A@_˜0`ÁƒÀB À®`ÉŒ +йÀ<Œ,£º\~€@!‹¶|“Ÿ@_ãǏè|þÀ9…^§I€@…€'Sº@a @ÀB À¬, +ސ#À8µ÷§¬e£€@ÑI/'0%@aF 0`ÁÀB À«ò9¯TÀ8T˜ÝšȀ@"ÞSú/Ñ@akׯ^œ{ÀB À«¹Á“BÀ7õY¬ïVd€@zÅ*}(@a‘£F4ÀB À«€ìûrºÀ7˜+w‹à›€@֊ßLô@a·nÝ»víÀB À«Iœï‰]7À7=Xè̀@85„ªlQ@aÝ:téÓ§ÀB À«!íŠÜÆÀ6ãÉ®=±€@žÓþÿÎ@b 0`ÀB ÀªÝwf®«À6Œzý¢ÿ€@ +5݉w@b(Ñ£FÀB Àªš˜è:4HÀ67ÌÁ“€@z,Øø˜Ú@bN:téÓÀB Àªt‚!ŠtÀ5ãa:;Z}€@P3@bthÑ£FÀB ÀªA.ÜڐØÀ5‘}fqfï€@g+1zš@bš4hÑ£FÀB Àª›5ÌÀ5AO·Ãåü€@ãßÛññ@bÀÀB À©Ü‘Aî÷À4ò̵21€@d„ ¶/ÅÀA·nÝ»vîÀÈúëôEO€€@rÖ+ n?òå˗.\¹ÀA·nÝ»vîÀÈ÷û¥ÏÉÀ$#Rp€@rÐzÉuÉ0@å˗.\¹ÀA·nÝ»vîÀÈï‡ñ}0À2ø"ÚF€@r¿s¹¶Á@ X±bŋÀA·nÝ»vîÀÈá®ù¯RjÀ;ò[aÍû€@r¡Sñd/F@å˗.\¹ÀA·nÝ»vîÀÈΎ[Û ÙÀB^Œ$ €@rvU€S#@Ÿ>|ùóçÀA·nÝ»vîÀȶo®Ê£ÏÀFsÍü3âˀ@r?¿L9±=@X±bŋÀA·nÝ»vîÀș¯ŠîPbÀJ5 6†žc€@qþœìtˆ@ ‰$H‘"ÀA·nÝ»vîÀÈxˆÙdn“ÀMâWŸ7ô;€@qŽ.i\ŠR@"å˗.\¹ÀA·nÝ»vîÀÈS9!ÿHÀP€‰žsò€@q`ßEžaƒ@%B… +(PÀA·nÝ»vîÀÈ)ôYÚÀRF ˆ)ë€@q™þc¶ˆ@'Ÿ>|ùóçÀA·nÝ»vîÀÇýBjçTÀSŸµ!;ªÝ€@p£J¶ç•@)û÷ïß¿~ÀA·nÝ»vîÀÇ̹ïyû“ÀU ”¯%Ð€@p; mfs@,X±bŋÀA·nÝ»vîÀǙKÔ ±ÀVeˆ±v|—€@oœžö«šT@.µjÕ«V­ÀA·nÝ»vîÀÇc…y»ÀW„ÉŠ¶-1€@n»ë«É@0‰$H‘"ÀA·nÝ»vîÀÇ*@Ô­+§ÀXŠ£÷%ÑZ€@mÖ7~ y@1·nÝ»víÀA·nÝ»vîÀÆï)ãýÀYx] )ƒ€@lìâ0ˆž@2å˗.\¹ÀA·nÝ»vîÀƲ7`ÑšÀZIëíEžÒ€@l²ŠZŒ4@4(P¡B…ÀA·nÝ»vîÀÆsâ‚LÀZþNÖQ-5€@kÍò›¿@5B… +(PÀA·nÝ»vîÀÆ2Žt˜{À[™µt¬ƒ€€@j*ii ÒL@6páÇÀA·nÝ»vîÀÅðºžv;xÀ\Lîõ8>€@i@ޚ@= @7Ÿ>|ùóçÀA·nÝ»vîÀÅ­ÈeðñÅÀ\Œ{]”‰j€@hZGП”–@8͛6lÙ³ÀA·nÝ»vîÀÅiëOmËëÀ\åFè €@gw}³Yµ7@9û÷ïß¿~ÀA·nÝ»vîÀÅ%TlkËþÀ]* $I:р@f™$øP +@;*T©R¥JÀA·nÝ»vîÀÄà2×ÈvDÀ]Z>N‡ü€@e¿»7?ç)@À]s÷áTÕ€@d뚬g?@=‡8páÀA·nÝ»vîÀÄUëž3À]zãYÔ¶«€@d:VUŒ@>µjÕ«V­ÀA·nÝ»vîÀÄ€ÏÓ„À]qmÞ£ý€@cT¢¥ÒQ¢@?ãǏžù @C|ùóçϟÀA·nÝ»vîÀÂ2t[žÀ[óÞ¹’‡€@]PñÉ<°@D(P¡B…ÀA·nÝ»vîÀÁðÐý@öÀ[ð=8Æu€@[Ð›ŠŸ@D«V­ZµjÀA·nÝ»vîÀÁ¯þ8îÀ[CŠÏ_ë5€@Z«ÆÝ b@EB… +(PÀA·nÝ»vîÀÁpúÃáAÀZåç9ÙT€@Y’–˜L@EÙ³f͛6ÀA·nÝ»vîÀÁ0ìƒÞöÀZ…uOˆF΀@X„ÄòæüY@FpáÇÀA·nÝ»vîÀÀòºgfH°ÀZ"ø€‰™€@W‚Ÿy@G @ÀA·nÝ»vîÀÀµt}@ßÖÀYŒîäj€@VŠÙ) Ý@GŸ>|ùóçÀA·nÝ»vîÀÀy%ØñkÀYRwpÓv€@UœŠhQSª@H6lÙ³fÍÀA·nÝ»vîÀÀ=ÕBØEcÀXä²ZX3ý€@T¹DûAá¹@H͛6lÙ³ÀA·nÝ»vîÀÀ‹Žê üÀXtÿ×é€@Sßê~8=^@Idɓ&L™ÀA·nÝ»vîÀ¿”šdêûÀXÜ1s_V€@S*Ë:@Iû÷ïß¿~ÀA·nÝ»vîÀ¿$8@²ãÀWB®¯JŠ€@RI°6€ ¡@J“&L™2dÀA·nÝ»vîÀŸµôØÆ>"ÀWSñ|\=€@QŒ(ƒ\ZÃ@K*T©R¥JÀA·nÝ»vîÀŸIÐD{ÀVª‰35 €@P×>U…q@KÁƒ 0ÀA·nÝ»vîÀœßÈãàÀV8GÖs®j€@P*œI,Ì6@LX±bŋÀA·nÝ»vîÀœwÛ]Þ&aÀUÆäI*n€@O ߯Æw²@Lïß¿~ýûÀA·nÝ»vîÀœòy_œÀUV€¡Fš€@MÑÍj†€@C‰«ºÁmš@R 0`ÀA·nÝ»vîÀžÝ2ø“ÀPéBË~€@BÖ7ñŸ@RN:téÓÀA·nÝ»vîÀžŽö{ÐoÀPšýRoU€@B*³–mWÀ@Rš4hÑ£FÀA·nÝ»vîÀž@Hè£C ÀPNøÈ³>8€@A†ÀžÛ9=@Rå˗.\¹ÀA·nÝ»vîÀ·óìN-N?ÀPíKèU€@@êˆɌ@S1bŋ,ÀA·nÝ»vîÀ·šéÜ@ÇÀOy,)Sڀ@@T"²¬t@S|ùóçϟÀA·nÝ»vîÀ·_9Q ­ +ÀNéŒÕtuh€@?‰’;ôEú@Sȑ"D‰ÀA·nÝ»vîÀ·ß‡ïÀNXˆ-‰#j€@>w'#Fƒ¢@T(P¡B…ÀA·nÝ»vîÀ¶ÏÜ•]vÀMÆó…2€@=pG1äó@T_¿~ýû÷ÀA·nÝ»vîÀ¶Š1,‚ÀM5?Y“U€@ã[…ÀKƒ{8œ[€@9»uá,n@UŽ8páÃÀA·nÝ»vîÀµ€æòM‘„ÀJôMËUf€@8ä}RÄrû@UÙ³f͛6ÀA·nÝ»vîÀµAâL¿Õ‹ÀJfê–ä*Ž€@8ä›Ñ 5@V%J•*T©ÀA·nÝ»vîÀµ)ÓFe‰ÀIÛùˆ4i€@7NÝ]•à@VpáÇÀA·nÝ»vîÀŽÇ¹¡íó©ÀIPþá_Š€@6Žù0»Út@VŒxñãǏÀA·nÝ»vîÀތ{ÃýƒÀHÈÁÚ€è€@5Õеþ¢×@W @ÀA·nÝ»vîÀŽR ÙügÀHB}µݶ€@5#v@WS§N:tÀA·nÝ»vîÀŽîóûöÑÀGŸJpxÈY€@4v5§ñ.@WŸ>|ùóçÀA·nÝ»vîÀ³ârÓ CÀG<;@~€¢€@3ÏTJ©Ö@WêÕ«V­ZÀA·nÝ»vîÀ³¬'Vš5ÀFŒ_šDt€@3-LdÔÔ@X6lÙ³fÍÀA·nÝ»vîÀ³w:ȖœÀF>Ãdcåð€@2– @õ˜@X‚ @ÀA·nÝ»vîÀ³C %Ü×¥ÀEÃoms$€@1øªäaå@X͛6lÙ³ÀA·nÝ»vîÀ³3¬ÿ~ÀEJiǟ,¢€@1eF¢[Ӟ@Y2dɓ&ÀA·nÝ»vîÀ²ÑêÓ *ÀEtK8|€@0Zèÿ›.L@Ydɓ&L™ÀA·nÝ»vîÀ²Ÿž ggŠÀE ˜Ë³q€@/šFχö’@Y°`Áƒ ÀA·nÝ»vîÀ²nzÐø¢ÅÀD¥AŽÇä€@.¥Ìžäê%@Yû÷ïß¿~ÀA·nÝ»vîÀ²>,Qè;ÏÀDAwV"Bµ€@-­ÚzbÏ@ZG$Á12@]‡8páÀA·nÝ»vîÀ°;X‰ŠµïÀ@DMíe础@$£ÎÃï@]Ò¥J•*TÀA·nÝ»vîÀ°A5‘ À?ø9Úï2€@$%Û7rä@^áÂÉÌÑï€@"õÜ¢tc­@^µjÕ«V­ÀA·nÝ»vîÀ¯MŒRÀ>[|987€@"pÅbN@_ ÀA·nÝ»vîÀ¯¡²…ÛÀ=ØcÈÇÂç€@!ðkwTš¥@_L™2dɒÀA·nÝ»vîÀ®À¹QɏÀ=Xbm hՀ@!t››!x@_˜0`ÁƒÀA·nÝ»vîÀ®{ûÀ».QÀ<ÛaĐá€@ ý#šqT@_ãǏ|ùóçÀ@íÛ·nݺÀÉ€xçßcÊÀI(lR뚀@s‘Yôê’@X±bŋÀ@íÛ·nݺÀɄBý­kÀM[&ÿ;ÏO€@sE ðRA@ ‰$H‘"À@íÛ·nݺÀÉ_#ã v£ÀP¹¬e‹‘€@ríÉìlÕß@"å˗.\¹À@íÛ·nݺÀÉ5döŒDŸÀRœ–V«~é€@rŒh²âÑ£@%B… +(PÀ@íÛ·nݺÀÉEJOnÀTiîiDm+€@r!áÿ&Íw@'Ÿ>|ùóçÀ@íÛ·nݺÀÈÕ—D€FÀVž{wn~€@q¯\[)£m@)û÷ïß¿~À@íÛ·nݺÀȟI·,ŠdÀW…Ór uÁ€@q6U6ËHð@,X±bŋÀ@íÛ·nݺÀÈfŽwaQÀXàïÞæA:€@pž\^‹Pÿ@.µjÕ«V­À@íÛ·nݺÀÈ)ãL°Ô3ÀZGÇ/€@p6ÔöÙ@0‰$H‘"À@íÛ·nݺÀÇë –KÀ[ „p¡Ì €@oeÝ 2"@1·nÝ»víÀ@íÛ·nݺÀÇ©ÑQŽ™8À\+:8„A€@n[juu/Æ@2å˗.\¹À@íÛ·nݺÀÇf} »IÀ\çÂdý1€@mP M»DE@4(P¡B…À@íÛ·nݺÀÇ![tfbœÀ]šätªí׀@lE1„¯OŒ@5B… +(PÀ@íÛ·nݺÀÆÚµ¶ŒMÀ^1>#/X_€@k<5Bü¡k@6páÇÀ@íÛ·nݺÀƒÌ]ÉÀ^­?ÿH#ÿ€@j6›©Aè@7Ÿ>|ùóçÀ@íÛ·nݺÀÆIۂGŽ7À_+§†¡¿€@i5™îXº@8͛6lÙ³À@íÛ·nݺÀÆ:VÈÎÀ_[²ñëà€@h:Œh@9û÷ïß¿~À@íÛ·nݺÀŵÆS›kŒÀ_‘¶Ve±€@gD³áP?ú@;*T©R¥JÀ@íÛ·nݺÀÅkaÀ_±Óv×73€@fUí9û#@µjÕ«V­À@íÛ·nݺÀĊp¡íÀ_•¶,Ø€@c³üzB V@?ãǏ|ùóçÀ@íÛ·nݺÀÀŒÒñޘÀZ‘ýiÿ•€@U\xŽ;áO@H6lÙ³fÍÀ@íÛ·nݺÀÀ~«~ìÕÀZ»R*€@Tq¶»¯‘@H͛6lÙ³À@íÛ·nݺÀÀA«Ž‚ÀY“ýx¡Ç€@S‘ÌGìàž@Idɓ&L™À@íÛ·nݺÀÀÔùÕ[ÀY&Ò¿é€@RŒX€Hö@Iû÷ïß¿~À@íÛ·nݺÀ¿–V[+OÀX•åß ‹”€@QðörN–a@J“&L™2dÀ@íÛ·nݺÀ¿#[ÏŽ:‘ÀXÊèÊŽ€@Q/AÇ 5@K*T©R¥JÀ@íÛ·nݺÀŸ²žxŠ—ÑÀW˜NYš€I€@PvÖìNäè@KÁƒ 0À@íÛ·nݺÀŸDhv6šžÀWÓž€@Oާ…ü¬¢@LX±bŋÀ@íÛ·nݺÀœØf<Œ¹ÀVž­ÌH#€@N@°M@Lïß¿~ýûÀ@íÛ·nݺÀœnªàbxFÀV$ÿF€@M €mª@M‡8páÀ@íÛ·nݺÀœ.C©ÜâÀU«!Åø€@KÕú3ð@Nžl8X·ÀTÅ[ìî€@I€±óOˆÛ@OL™2dɒÀ@íÛ·nݺÀ»Ý›œ-ÌÀTWè皍€@H Èï­@OãǏY2ÀQ ÁÀMõ^€@AgkŒ.ւ@Rš4hÑ£FÀ@íÛ·nݺÀžvN] [ÀPžÅ=z旀@@Æ¢?He@Rå˗.\¹À@íÛ·nݺÀž( /ÔÿƒÀPiÿDân€@@--ŒìLÕ@S1bŋ,À@íÛ·nݺÀ·Û3ÇI†ÀPQžœÌ€@?5aXžù±@S|ùóçϟÀ@íÛ·nݺÀ·ÅP¡ÉWÀO¢31«¡€@>›ËÓŽU@Sȑ"D‰À@íÛ·nݺÀ·EÀXzôZÀO 5ô={3€@=;`ŠÂ@T(P¡B…À@íÛ·nݺÀ¶ý$ò0Ï!ÀNp$Û+&€@<zÍ|7n@T_¿~ýû÷À@íÛ·nݺÀ¶µòÍhá!ÀM×iXŽç€@;¡7Êæ@T«V­ZµjÀ@íÛ·nݺÀ¶p(°öNøÀM?_=Í0 €@:3uN£n@TöíÛ·nÝÀ@íÛ·nݺÀ¶+ę{VÀLšV"‘{€@9Qþ—aàë@UB… +(PÀ@íÛ·nݺÀµèÃÔȧaÀL’ž%}÷€@8yÿïú¬4@UŽ8páÃÀ@íÛ·nݺÀµ§#þfŸÀK~OýŠnf€@7ªzû wÿ@UÙ³f͛6À@íÛ·nݺÀµfޟQ,ºÀJëÀS®r4€@6âí‰H\Z@V%J•*T©À@íÛ·nݺÀµ'ò-*­oÀJ[r/Ì€@6"Þ_j@VpáÇÀ@íÛ·nݺÀŽêY/>ÀIÌ^C=žŽ€@5iÛ²1*@VŒxñãǏÀ@íÛ·nݺÀŽ®í„ÀI?ͧýz,€@4·zžvÆÁ@W @À@íÛ·nݺÀŽs Èx5ÀHµu(‡z€@4 Y”é§@WS§N:tÀ@íÛ·nݺÀŽ9Pç`ö©ÀH-hŽ·¡°€@3ebÄ_@WŸ>|ùóçÀ@íÛ·nݺÀŽÒ uŒÀG§·vŠ8s€@2ÄipE@WêÕ«V­ZÀ@íÛ·nݺÀ³ÉS\ßrÀG$mÇÆ‘€€@2(òž.ɬ@X6lÙ³fÍÀ@íÛ·nݺÀ³“{G)ŽÀF£”'c„€@1’kf±mä@X‚ @À@íÛ·nݺÀ³^–³‡ênÀF%0ZP)õ€@1Œyªñã@X͛6lÙ³À@íÛ·nݺÀ³*ÙÃ6h‡ÀE©E¢#ˆ²€@0sk§ˆ˜@Y2dɓ&À@íÛ·nݺÀ²ëR-°CÀEϬ4ž}€@/ÐAƒpí@Ydɓ&L™À@íÛ·nݺÀ²žMhL*žÀEbëÁÇÐw€@.ƀîŒs†@Y°`Áƒ À@íÛ·nݺÀ²†F3_›ÀDøù·T§1€@-È T(¬@Yû÷ïß¿~À@íÛ·nݺÀ²U6ñ`‚ÀD‘›™£;€@,Ô$‘ȉO@ZGùl@[Áƒ 0À@íÛ·nݺÀ±AÀi×iWÀB[°õõÀM€@'ßÞ9ÏK@\ 4hÑ£À@íÛ·nݺÀ±ÈñG–ÕÀBœYˆ€@''烜ó}@\X±bŋÀ@íÛ·nݺÀ°ìšZÉ}uÀA±™~@(‹€@&wâUÀïÜ@\€H‘"DˆÀ@íÛ·nݺÀ°Ã/™õLãÀA_Ÿs3X|€@%ΣLôx®@\ïß¿~ýûÀ@íÛ·nݺÀ°šƒÈÕlÀAî¿Aû€@%+ÙëŒ@];víÛ·nÀ@íÛ·nݺÀ°r’#ÁšÀ@Â@¹ƒ€@$9úoÄi@]‡8páÀ@íÛ·nݺÀ°KV d.éÀ@v4‘1€Ê€@#ø{IšÚ@]Ò¥J•*TÀ@íÛ·nݺÀ°$Ëk޲À@,,Álí€@#gYt¹z@^±†l„%ã€@!Ó)™Ëí9@_ À@íÛ·nݺÀ¯"isŽÀ>+[Ûš œ€@!VÇ_÷@_L™2dɒÀ@íÛ·nݺÀ®ÛÀ:‡1À=šjq÷6>€@ Ýv§Ì{@_˜0`ÁƒÀ@íÛ·nݺÀ®–Hôc˜0À=(š™1]€@ i "Ȃ@_ãǏ§À;» €kJ€@Gñe„Æ@`cF4hÀ@íÛ·nݺÀ­‹ÍV;À;FÛS·ˀ@}÷ŸÔ @`‰$H‘"À@íÛ·nݺÀ­KäôÎøRÀ:Õ`x­æ €@ºßސÐê@`®Ý»víÛÀ@íÛ·nݺÀ­ † èÀ:f…0` Š€@þ_Úç€@`Ô©R¥J•À@íÛ·nݺÀ¬Ï(îÓç>À9ú6:~ €@H2»Ý@`útéÓ§NÀ@íÛ·nݺÀ¬’I@Gœ8À9`ôŒ=€@˜ȏ*`@a @À@íÛ·nݺÀ¬V`¶ôl°À9(óT)Yç€@íÉɍ§@aF 0`ÁÀ@íÛ·nݺÀ¬i¹MšÀ8ÃÛãwQò€@I†n+Ì@akׯ^œ{À@íÛ·nݺÀ«á^Ö[£qÀ8a »­d2€@©¶€#ŠF@a‘£F4À@íÛ·nݺÀ«š:Än“óÀ8l€Ïqš€@xj@a·nÝ»víÀ@íÛ·nݺÀ«oø_Ü-À7¡ô]*Óq€@z8çUŠ9@aÝ:téÓ§À@íÛ·nݺÀ«8’©Ç!^À7E‘ü÷‚]€@é±@8›î@b 0`À@íÛ·nݺÀ«Æò<ÅÀ6ë6Š ª€@]¹Øö@b(Ñ£FÀ@íÛ·nݺÀªÌIþ›”TÀ6’Ó§Ý2{€@Ö#Cž—@bN:téÓÀ@íÛ·nݺÀª—]¹bÍ5À6<[o!Ûs€@RĈÔnz@bthÑ£FÀ@íÛ·nݺÀªc;€8AåÀ5çÀj3¥¯€@Ósµ2xª@bš4hÑ£FÀ@íÛ·nݺÀª/ÞûUiÀ5”õ‘dà€@X k;ÚÒ@bÀÀ@íÛ·nݺÀ©ýCñ>!œÀ5CîEÀ„B€@à`0MÀ@$H‘"DˆÀÊùþ0q4E€€@uޅ¯Ñ.©?òå˗.\¹À@$H‘"DˆÀÊöl9:›ïÀ(RÝ~ú[€@u×É­F@å˗.\¹À@$H‘"DˆÀÊì£QõÀ7`Ê@Xk€@uÀlÕÅuý@ X±bŋÀ@$H‘"DˆÀÊÚœrÌ®ÀA•C’ƒ¡€@u—™o+@å˗.\¹À@$H‘"DˆÀÊ€cÙ)âÀG)w·ÊÇ>€@u[Íàq¬@Ÿ>|ùóçÀ@$H‘"DˆÀÊ€5UÀÀLV +ÒŽB{€@uŠø¯@X±bŋÀ@$H‘"DˆÀÊîh1ôÀP‡9Uœ';€@t¶Ë#ƒñ@ ‰$H‘"À@$H‘"DˆÀÊV%RlKpÀRѶ—npЀ@tOæá\@"å˗.\¹À@$H‘"DˆÀÊ'2qèdÀTëog¯ùY€@sÝ:¿î@%B… +(PÀ@$H‘"DˆÀÉóc·kª;ÀVéÐÝðáb€@s`&­#“@'Ÿ>|ùóçÀ@$H‘"DˆÀÉ» `ݝÀX¯ŒjÞ<‡€@rÚ(°e @)û÷ïß¿~À@$H‘"DˆÀÉ~ßJéç¡ÀZN‚ÂªŠ€@rM=X†m@,X±bŋÀ@$H‘"DˆÀÉ>ý8œ€À[¿Å€¥§€@qºò’ž<ù@.µjÕ«V­À@$H‘"DˆÀÈûï¢Ö–À\þ?oB€€@q%VvmÖ@0‰$H‘"À@$H‘"DˆÀȶ ‡ƒÃ—À^-„rπ@pŽñ8˜œ@1·nÝ»víÀ@$H‘"DˆÀÈmâö`À_ ˜u« €@oêš ƒšO@2å˗.\¹À@$H‘"DˆÀÈ#ˆDÉ×QÀ_ߗ>Ì%€€@nºv¥%r@4(P¡B…À@$H‘"DˆÀÇ×i[˜ß‹À`Fžï‡Ø†€@mŒv*]†@5B… +(PÀ@$H‘"DˆÀlj׌Ž2|À`%Ÿa€@lb&Ôdà@6páÇÀ@$H‘"DˆÀÇ;VBïÀ`ÄxeTz€@k=.]±ýW@7Ÿ>|ùóçÀ@$H‘"DˆÀÆë~ÂX+À`íÖÿ3˜€@jÓ:Ž\k@8͛6lÙ³À@$H‘"DˆÀƛ;€FkÀa +}/Aр@iý•ïf@9û÷ïß¿~À@$H‘"DˆÀÆJŒpmÊüÀa­åš‹€@gùMff‰!@;*T©R¥JÀ@$H‘"DˆÀÅùŠxO £Àa!p狭æ€@fó+?|j@µjÕ«V­À@$H‘"DˆÀÅÀf‡‹uÀ`óK?5‚€@dâ–Êc@?ãǏ|ùóçÀ@$H‘"DˆÀÀÿ°6ÓsÀ[Ó@­ÆÊŠ€@U’ñB¹@H6lÙ³fÍÀ@$H‘"DˆÀÀŸ™¥¥À[H ;Z>õ€@T&okן‚@H͛6lÙ³À@$H‘"DˆÀÀ~Í­y_ÀZ»z9 ÷h€@S@7îM@Idɓ&L™À@$H‘"DˆÀÀ@N–2/„ÀZ.d]Ô]€@ReJŋÿ@Iû÷ïß¿~À@$H‘"DˆÀÀ¶5/òÀY¡uÿ€@Q”ü­;“@J“&L™2dÀ@$H‘"DˆÀ¿ŽnWÀYÀY;©ì‡©€@PÏKÈÏ@K*T©R¥JÀ@$H‘"DˆÀ¿6'€­ÀXŠ.p{ËT€@P“ÚW‘€@KÁƒ 0À@$H‘"DˆÀŸŠŒc­2QÀX®2?ހ@NÂʰ ¢ì@LX±bŋÀ@$H‘"DˆÀŸ6g›éÐ/ÀWy «é€@Mp¥>\܌@Lïß¿~ýûÀ@$H‘"DˆÀœÈŸÉ {ÀVó~ªIAd€@L/ã\(Ç~@M‡8páÀ@$H‘"DˆÀœ]‡k1b²ÀVpf ˆ€@Jÿ·ŸÿL@Nãçÿ  +@S1bŋ,À@$H‘"DˆÀž 5×û–ÀP{ˆZ&·9€@=ÅSÅ®æ7@S|ùóçϟÀ@$H‘"DˆÀ·Ÿ(Ž|#ÀP+ÔÆr€@<ŽØ}¬>@Sȑ"D‰À@$H‘"DˆÀ·rpåXÓÑÀOµ 9ÐX̀@;°ÔÆJÝ@T(P¡B…À@$H‘"DˆÀ·(H‹ +wßÀOxÁ5ƒþ€@:žz”©u@T_¿~ýû÷À@$H‘"DˆÀ¶ßš¡ ²µÀNt²Úњ€@9Ë +Pn3æ@T«V­ZµjÀ@$H‘"DˆÀ¶˜dÚ]ØšÀMֳ͌€@8çÑÅXø@TöíÛ·nÝÀ@$H‘"DˆÀ¶R€2ž%ÀM8Õ$À™»€@8+)¶*i@UB… +(PÀ@$H‘"DˆÀ¶U‹ .ÀLBºØM€@7=|9ŸéŠ@UŽ8páÃÀ@$H‘"DˆÀµËs3ý~gÀLŒ!ËŒŒ€@6u5\bx@UÙ³f͛6À@$H‘"DˆÀµ‰ú^ŸÀKkݎ$„Ö€@5ŽÐÞnƒ@V%J•*T©À@$H‘"DˆÀµIäÅA,˜ÀJÖ[ý¡I€@4ûÒ7×ýâ@VpáÇÀ@$H‘"DˆÀµ -莩÷ÀJC&ïù˜€@4IÅ`ÄÔ#@VŒxñãǏÀ@$H‘"DˆÀŽÍÏú¶ oÀI²T äen€@3ž>2ŠÖZ@W @À@$H‘"DˆÀŽ‘Å|ùóçÀ@$H‘"DˆÀŽ‘ˆXWÔÀHíŸUc4€@1Ÿû‡Œ!?@WêÕ«V­ZÀ@$H‘"DˆÀ³å\hÛ4DÀGˆLù‡€@1)Û×"ș@X6lÙ³fÍÀ@$H‘"DˆÀ³®b4ÝDŽÀGL‹Ýp]€@0™ˆ”CÍ·@X‚ @À@$H‘"DˆÀ³xœ²—ðkÀF‚ðWN!€@0 º4Gb:@X͛6lÙ³À@$H‘"DˆÀ³D€àSaÀF8?iº…€@/ [©S)³@Y2dɓ&À@$H‘"DˆÀ³ÿkNçGÀF)VfTvá€@.Û6ý@Ydɓ&L™À@$H‘"DˆÀ²Ð+\éðuÀEžÊOv &€@-Öb,uŸÝ@Y°`Áƒ À@$H‘"DˆÀ²]›ñŒÏÀEK=|”Ñ€@,Ü¥gcí@Yû÷ïß¿~À@$H‘"DˆÀ²k.GÊŽÀDà™µŒ«®€@+íoÃ!»á@ZG"kèî€@'«°í@\ 4hÑ£À@$H‘"DˆÀ±(¶Éœ©»ÀB@²•UÈ€@&`ÃOš£@\X±bŋÀ@$H‘"DˆÀ°ýÿ?Ü<ÀAêo‡šÞ¢€@%ŽýÂêܹ@\€H‘"DˆÀ@$H‘"DˆÀ°ÔØLfhÀA–c:#œá€@%ë5Ò@\ïß¿~ýûÀ@$H‘"DˆÀ°ªæoz˜ÀAD|sN>Œ€@$q:\ r @];víÛ·nÀ@$H‘"DˆÀ°‚{ lâÀ@ôª}—ä€@#؞ Ž®@]‡8páÀ@$H‘"DˆÀ°ZÉÂë±ÆÀ@ŠÝ%®†D€@#EώµT•@]Ò¥J•*TÀ@$H‘"DˆÀ°3ÍöøuOÀ@[ž§ÖQ€@"žˆ˜;Ó®@^|bo•΀@ µÞdy'ï@_L™2dɒÀ@$H‘"DˆÀ®õÔ®uœÀ=öAf:¯;€@ @ŽyvÍI@_˜0`ÁƒÀ@$H‘"DˆÀ®¯šÒÇÜmÀ=s­À;Ÿå€@Ÿz/@_ãǏµjÕ«V¬ÀÌ ++%ý2€€@w¿@"vøê?òå˗.\¹À>µjÕ«V¬ÀÌÌgßhÀ*ö^Œß­€@w¶LÁj'á@å˗.\¹À>µjÕ«V¬ÀÌ x®ƒXÀ:6o9׀@wš±Lºùó@ X±bŋÀ>µjÕ«V¬ÀËõìOÞ*]ÀCç%SÕ9¯€@whò§ƒÄ®@å˗.\¹À>µjÕ«V¬ÀËÚ¢·|>ˆÀJ<ÚÓî·A€@w" 8SW<@Ÿ>|ùóçÀ>µjÕ«V¬ÀËž'rDnÀP šèžˆ9€@vÈBlø@X±bŋÀ>µjÕ«V¬Àˏ ­’ÐQÀRžöÈü}€@v]Jgç(@ ‰$H‘"À>µjÕ«V¬ÀË_œÆ|ŒÙÀULE.÷n¹€@uâØ¿2«@"å˗.\¹À>µjÕ«V¬ÀË*€5Tô+ÀW€Þiú䳀@uZÂ÷0Vö@%B… +(PÀ>µjÕ«V¬ÀÊð#x‚kÀYÙÿYCO²€@tÆõ]ÏPœ@'Ÿ>|ùóçÀ>µjÕ«V¬ÀʰžŠðÁÀ[ÌL;ô€P€@t)mjÊmÚ@)û÷ïß¿~À>µjÕ«V¬ÀÊlô^V"kÀ]‹–t‚¹g€@s„wQøw²@,X±bŋÀ>µjÕ«V¬ÀÊ%OGyÀ_}†›€€@rÚi¬@Á×@.µjÕ«V­À>µjÕ«V¬ÀÉÚS-œM§À`/Êù-€@r-8oŽïÚ@0‰$H‘"À>µjÕ«V¬ÀɌÄïÃÀ`ŸÄ#Sü(€@q~“0N»”@1·nÝ»víÀ>µjÕ«V¬ÀÉ<:À:NÀa9bO[u\€@pÐTØ;¡@2å˗.\¹À>µjÕ«V¬ÀÈéâšæ` ÀaŸb¡€@p"ÉoÓ`@4(P¡B…À>µjÕ«V¬ÀȕÚ"‚ùvÀað€­=c©€@n›…L@5B… +(PÀ>µjÕ«V¬ÀÈ@}3.WÀb/nÀ °5€@mŸª ó@6páÇÀ>µjÕ«V¬ÀÇê Æ^0ŠÀb]cñ•€@lW”‹¶Kö@7Ÿ>|ùóçÀ>µjÕ«V¬ÀǓ©¿ìÄÀb{]¶P€@k™gñ§÷@8͛6lÙ³À>µjÕ«V¬ÀÇ;”ÁBÀb‹ôÀs“€@iã–VÜMÉ@9û÷ïß¿~À>µjÕ«V¬ÀÆãæõ>[Àbu«õìë€@h¹Dy„Ø@;*T©R¥JÀ>µjÕ«V¬Àƌ=±høaÀb‰-޳ý}€@g™nÐÚŸø@µjÕ«V¬ÀÆ4ԈgmíÀbvŒ\S€@f„¹™W X@=‡8páÀ>µjÕ«V¬ÀÅÝÞsÈÀbX프š€@ezôR†È@>µjÕ«V­À>µjÕ«V¬À҅ wKÀb3ž‡6À€@d{þÎ>ˆ@?ãǏµjÕ«V¬ÀÅ1ìß"òÀb‚‹øV€@c‡§+kØÀ@@‰$H‘"À>µjÕ«V¬ÀÄÝ2»æmkÀaÕÎfÆûw€@b¬ø@A @À>µjÕ«V¬Àĉn–>ô2ÀaŸ‡Ý¯w€@aœÂªT÷Û@A·nÝ»víÀ>µjÕ«V¬ÀÄ6µ|‡äÞÀad—î©a€@`çŸèLA,@BN:téÓÀ>µjÕ«V¬ÀÃå„„Àa$çp[€@` Öç2Ž@Bå˗.\¹À>µjÕ«V¬ÀÔœ;!DBÀ`áŸÇ7C(€@^¯£Úúoÿ@C|ùóçϟÀ>µjÕ«V¬ÀÃE ‘£ÌÀ`›œø»&€€@];OðÆSL@D(P¡B…À>µjÕ«V¬ÀÂ÷Ó£î~À`S›îüt€@[؊%fßê@D«V­ZµjÀ>µjÕ«V¬À«^]KvÀ` +:.þÌ:€@Z†³ó#Å'@EB… +(PÀ>µjÕ«V¬ÀÂ`FTHÒÀ_õœï@€@YE)žyÜ@EÙ³f͛6À>µjÕ«V¬À›KÂÀ^ꕘw碀@XE†P@FpáÇÀ>µjÕ«V¬ÀÁÎ6e®3kÀ^U 歀@VðaNI¡‹@G @À>µjÕ«V¬ÀÁ‡AðcçðÀ]œžž˜+Ÿ€@UÛÞ êß@GŸ>|ùóçÀ>µjÕ«V¬ÀÁA¶Çž9hÀ]#Z=Ò²€@TÕ1ñŸ@H6lÙ³fÍÀ>µjÕ«V¬ÀÀýš5 ò·À\‡lM?¶Î€@SÛԊ¬øÚ@H͛6lÙ³À>µjÕ«V¬ÀÀºîòtæ±À[êÑ+ÖM€@Rï< êÓà@Idɓ&L™À>µjÕ«V¬ÀÀyµšÁÖ4À[NYgÀ·œ€@RÞÿ²nÍ@Iû÷ïß¿~À>µjÕ«V¬ÀÀ9í àdÀZ²°§»‡Ý€@Q:4Û|@J“&L™2dÀ>µjÕ«V¬À¿÷%Q'…QÀZcxŽ£ˆ€@Pp·Ï~Æ@K*T©R¥JÀ>µjÕ«V¬À¿}EqZaÀYä/8=^€@OcÊEþŠ&@KÁƒ 0À>µjÕ«V¬À¿1 +™VÀXéЖ3€@Mú{€Ü±@LX±bŋÀ>µjÕ«V¬ÀŸ‘Ý.y~®ÀXU­Š±§ò€@L€ì‹Tï@Lïß¿~ýûÀ>µjÕ«V¬ÀŸ =¹!8ÀWÄy¡u_ڀ@Ka Å-€@M‡8páÀ>µjÕ«V¬Àœ±E_6±ÀW6G¶Ú€@J/)ƒ +I@NµjÕ«V¬ÀœDâ@ËÀV¬íÔM$€@I Ù%. @NµjÕ«V­À>µjÕ«V¬ÀŒÚûÀn- ÀV(¬Y€JԀ@Gü€¥­a@OL™2dɒÀ>µjÕ«V¬ÀŒszž5§ºÀU©9©ͪ€@FøçÎÓ5g@OãǏµjÕ«V¬ÀŒHÅE(ÀU.QéÍ4x€@Fdûøù@P=zõëׯÀ>µjÕ«V¬À»«QTœ€0ÀT·¶txvI€@E¶ðÚM@P‰$H‘"À>µjÕ«V¬À»J€‰®Œ-ÀTE-Qä8q€@D>h't@PÔ©R¥J•À>µjÕ«V¬ÀºëÚžóÀSրÈN¬U€@Cl͌Œ8©@Q @À>µjÕ«V¬Àº膯¡ÀSk~õýmI€@BŠ-ÓÏxt@Qkׯ^œ{À>µjÕ«V¬Àº4?g,›ŠÀSùw,„í€@A阮Â@Q·nÝ»víÀ>µjÕ«V¬À¹ÛW‡æOÀRŸÅå¢ë€@A6y†‡à@R 0`À>µjÕ«V¬À¹„@¢WE“ÀR>¹‚}øœ€@@ŒEùŠý @RN:téÓÀ>µjÕ«V¬À¹.í€qŠaÀQొå̀@?ÔùŒô(.@Rš4hÑ£FÀ>µjÕ«V¬ÀžÛOÆÊŽŽÀQ…ˆ$3á€@>¡J¬¬ŒŒ@Rå˗.\¹À>µjÕ«V¬Àž‰Z/x­ÈÀQ-ì[=€@=|Ý»I9@S1bŋ,À>µjÕ«V¬Àž9 ("÷ÀP×N‰ÖV{€@µjÕ«V¬À·ê8,ÄSÀP‚€t€@;]ÉÉs–@Sȑ"D‰À>µjÕ«V¬À·, è—ÀP-ùcužÑ€@:`_P;ãq@T(P¡B…À>µjÕ«V¬À·QV¯Š ÀO³Ùùâ<ñ€@9ofù¯1@T_¿~ýû÷À>µjÕ«V¬À·8‹µ€ÀO ̅rs€@8‰RKü›@T«V­ZµjÀ>µjÕ«V¬À¶Ÿ¢Œ]TÍÀNgÕæƒŒ1€@7­iý-_8@TöíÛ·nÝÀ>µjÕ«V¬À¶w‘ŽÝÐÀMÄtòÔ¬û€@6Ûã ß@UB… +(PÀ>µjÕ«V¬À¶1þ÷ù˜ÀM#àÁ°²€@6öŸœ»@UŽ8páÃÀ>µjÕ«V¬Àµíç\ùÀL„æi²€@5PQk^›€@UÙ³f͛6À>µjÕ«V¬Àµ«DÕÂâDÀKçGþ‡ €@4–éÕòÚ@V%J•*T©À>µjÕ«V¬Àµj¢ ÀKMìâsø€@3äÌeèÌ @VpáÇÀ>µjÕ«V¬Àµ*GÀnlÏÀJµ_²ºŒÄ€@39ƒ,²‚ê@VŒxñãǏÀ>µjÕ«V¬ÀŽëàúµoHÀJ b"Wj€@2” s‚€@W @À>µjÕ«V¬ÀŽ®Öñˆ3ÐÀIŽeiõŸ€@1õŸë@WS§N:tÀ>µjÕ«V¬ÀŽs#&ž¬]ÀHþŸ˜ÀÎ6€@1\}2@WŸ>|ùóçÀ>µjÕ«V¬ÀŽ8¿Ÿ2ÀHqíI‘º€@0ȄÖÉí@WêÕ«V­ZÀ>µjÕ«V¬À³ÿ£ì™kÑÀGè åŠy!€@09‚Î3Ð#@X6lÙ³fÍÀ>µjÕ«V¬À³ÇË1³³ØÀG`ý$6ø€@/^T;#@X‚ @À>µjÕ«V¬À³‘.*cÄÀFÜÀ[_å€@.Re€9ßg@X͛6lÙ³À>µjÕ«V¬À³[Æ0vWÀF[RÒX³m€@-NŽ@r¯@Y2dɓ&À>µjÕ«V¬À³åäÁ›lÀFLfàŸD€@-×,ý(€R@Ydɓ&L™À>µjÕ«V¬À²çF‘Îì¬ÀF ö_aʀ@,×Õ †v@Y°`Áƒ À>µjÕ«V¬À²³¶CžÀÀE›ÑËÿ&€@+㇩jžV@Yû÷ïß¿~À>µjÕ«V¬À²-sÈÀE-Å-ÓaÀ€@*ù²)K~1@ZGµjÕ«V¬À²O¥=¢ÓxÀDž%Ýò(€@*Ês¹¡<@Z“&L™2dÀ>µjÕ«V¬À²ZùÞæÀDZ’ÑP%à€@)CNв²T@ZÞœzõë×À>µjÕ«V¬À±ïz$dE/ÀCõ=ñ†]Հ@(uÄ1eñ@[*T©R¥JÀ>µjÕ«V¬À±ÀÊÖD–ÀC’¢íE¯õ€@'°·Ù€N@[uëׯ^œÀ>µjÕ«V¬À±’ÿÃᙟÀC2«Ñ¯Ÿ€@&óŒ„lQ@[Áƒ 0À>µjÕ«V¬À±f ,fÆÀBÕCR3.t€@&>lt–št@\ 4hÑ£À>µjÕ«V¬À±:1é;QÀBzTǰRŽ€@%fÊ­Ô@\X±bŋÀ>µjÕ«V¬À±Ç7R -ÀB!Ì.ݳH€@$éPjïŸt@\€H‘"DˆÀ>µjÕ«V¬À°äX'šŒÀA˖&#j²€@$HÓ[ëÖ¹@\ïß¿~ýûÀ>µjÕ«V¬À°º²ý6†ÔÀAwŸêù¡é€@#®žt›ò~@];víÛ·nÀ>µjÕ«V¬À°‘ÑãhÀA%×Væ€@#eéœö@]‡8páÀ>µjÕ«V¬À°i­ëŒQÀ@Ö*Ü+¯µ€@"‹ÞÅ6Ó¬@]Ò¥J•*TÀ>µjÕ«V¬À°BD‚–ÙÀ@ˆ‰‚=©ˆ€@"Ç%›©@^µjÕ«V¬À°Ž[À@<ââ9\€@!~Ý}ˆo@^iÓ§N:À>µjÕ«V¬À¯ëTÒdÊÀ?æNCÿ߄€@ ÿ䚂{¿@^µjÕ«V­À>µjÕ«V¬À¯ hY³õ¹À?Vä¬u€@ …¢”²r&@_ À>µjÕ«V¬À¯Wªnà’À>Êgžƒ€@ àœK@_L™2dɒÀ>µjÕ«V¬À¯íèWÀ>Aœ9Ì€@<ՑMõ¢@_˜0`ÁƒÀ>µjÕ«V¬À®Èõ÷LJÀ=ŒtƛXҀ@bݺð@_ãǏµjÕ«V¬À®‚n÷ŠÿzÀ=:s«Ë€@Bçm@`¯^œzöÀ>µjÕ«V¬À®=úN«¢À<»žoýÀ@ÃæSúxy@`=zõëׯÀ>µjÕ«V¬À­ú­–[\ ÀµjÕ«V¬À­ž§œÀ;ÇËmv›€@Bc`߬²@`‰$H‘"À>µjÕ«V¬À­woŽkÊ·À;Q:±4 +€@‹¢én¬@`®Ý»víÛÀ>µjÕ«V¬À­7pH +žÀ:Þ*X+TӀ@Û*Š©éÑ@`Ô©R¥J•À>µjÕ«V¬À¬ø~$rÃCÀ:mÓ.-“€@0žæ3,=@`útéÓ§NÀ>µjÕ«V¬À¬º‘ôÅæjÀ: _€Œ€@Œ b0êd@a @À>µjÕ«V¬À¬}¥Ûs[AÀ9”ýО^-€@ìç}åÁ¯@aF 0`ÁÀ>µjÕ«V¬À¬A³áZÏAÀ9,X°Ó€@SŒ9dk@akׯ^œ{À>µjÕ«V¬À¬¶µjÕ«V¬À«Ì§N)ˆÀ8b8ÅŒ·b€@.kŸœ^y@a·nÝ»víÀ>µjÕ«V¬À«“¢'„À8›–9ìñ€@£8$[&@aÝ:téÓ§À>µjÕ«V¬À«[?ì:¥‹À7¡3þš׀@‚¥é€å@b 0`À>µjÕ«V¬À«#Ý;èÀ7Cñ­~œq€@š…ü“@b(Ñ£FÀ>µjÕ«V¬ÀªíSö§£=À6èÄß%|€@Ý),C@bN:téÓÀ>µjÕ«V¬Àª·ŸÝöƋÀ6žX-Û÷€@¡˜|ô›@bthÑ£FÀ>µjÕ«V¬Àª‚Œ&™­À68o`Œ²Ê€@+&jê$@bš4hÑ£FÀ>µjÕ«V¬ÀªN£Ý™¢ÌÀ5ã)Ÿ°Uˀ@žb’«ÁQ@bÀÀ>µjÕ«V¬ÀªRí»ÈðÀ5¿²ÑÇõ€@I(b$=‚À="D‰$HÀÍQ°DP#€€@yò6” +æ?òå˗.\¹À="D‰$HÀÍM9ÄìZ\À.8$úÝ Œ€@yæâVÙ @å˗.\¹À="D‰$HÀÍ@DŽ–ðÀ=Ž1â–Œõ€@yÄDJÎ<ì@ X±bŋÀ="D‰$HÀÍ)Ù-YÌ}ÀF³õC®ˆ7€@y‡WžXz@å˗.\¹À="D‰$HÀÍ +·æ…GŸÀMîø×àʀ@y1oÞ¢@2@Ÿ>|ùóçÀ="D‰$HÀÌãa†ÝbÀRP"Ð|}â€@xÅ$^UœZ@X±bŋÀ="D‰$HÀÌŽ€i~D]ÀUYxe“Üŀ@xD_Y¥hª@ ‰$H‘"À="D‰$HÀÌ~”û U…ÀXAÕÉ&ò݀@w±SůÏ@"å˗.\¹À="D‰$HÀÌB&ä8²šÀZâ5Úäж€@wžˆX@%B… +(PÀ="D‰$HÀËÿµ¥yAëÀ]SfcÈـ@v^€óúkœ@'Ÿ>|ùóçÀ="D‰$HÀ˷ۄ¶5»À_tÇY‡ÈR€@u€q@0Ö_@)û÷ïß¿~À="D‰$HÀËkI¢Íà—À`ª{)î®·€@tâºÃ@,X±bŋÀ="D‰$HÀ˘%/Àax®Nbò¿€@t;‡ÍúŒ@.µjÕ«V­À="D‰$HÀÊÆkÙ>ôÀb#ÄåV„æ€@sS1XÖoK@0‰$H‘"À="D‰$HÀÊo\ þTÀb³J Æß4€@r‰ Vï|@1·nÝ»víÀ="D‰$HÀÊà^/`ÑÀc*6›ë€@qÁ;d~*œ@2å˗.\¹À="D‰$HÀɺkï$L‘Àc‰+ùµŽ€@pû ¿!x(@4(P¡B…À="D‰$HÀÉ]lvéú6ÀcÑŸDŠ’€@p9Ì HØ@5B… +(PÀ="D‰$HÀÈÿH‚<”]Àd+ +‰]ـ@nù1ŸBjS@6páÇÀ="D‰$HÀÈ _Õ0ãàÀd%p»%›–€@m‰Áºòe@7Ÿ>|ùóçÀ="D‰$HÀÈA!HvÀd4ú  +ø€@l&|šÅ$Æ@8͛6lÙ³À="D‰$HÀÇáˆ)|e±Àd5ü¥a¶j€@jÐ!ĉ±Ú@9û÷ïß¿~À="D‰$HÀǂ"ÓœDÀd*p‹`¶\€@i‡ÁôF@;*T©R¥JÀ="D‰$HÀÇ#ÐÇö_Àdìt1“[€@hKŠt@€@gtð\uÖ@=‡8páÀ="D‰$HÀÆfÀ~ÿ†ÀcÂz.ˆÐ6€@eü¥â(ã@>µjÕ«V­À="D‰$HÀÆ àŽlR™Àc|€á&õ€@dèÙÕtSH@?ãǏ^ÍÀb6ín§ ˜€@`;–tHï@Bå˗.\¹À="D‰$HÀÃõ¥Îy¿Àa捰É4€@^ÛGš[®®@C|ùóçϟÀ="D‰$HÀáÖ3‚©Àa“ç„ε•€@]SáíÊûF@D(P¡B…À="D‰$HÀÃO‘)!åÀa?œA»'€@[à'žÒÜz@D«V­ZµjÀ="D‰$HÀÂþÜ·íïÀ`ê¯ñ’µ€@ZBÎ õ@EB… +(PÀ="D‰$HÀ¯¹æî‹DÀ`•@Ã†W€@Y0]¢£ÅB@EÙ³f͛6À="D‰$HÀÂb+aRùOÀ`?Þƒä€@Wò¥ŒY.@FpáÇÀ="D‰$HÀÂ/qüÃÀ_ÕÀ nÕ€@VÅL^”qU@G @À="D‰$HÀÁËŀ +cÁÀ_*ŸÌðæ€@U§ŽúÚb@GŸ>|ùóçÀ="D‰$HÀÁ‚ò<ˆcËÀ^}¥q€ÇY€@T˜Î͹GL@H6lÙ³fÍÀ="D‰$HÀÁ;¹ RTÄÀ]Ïšÿò܀@S˜g»³{M@H͛6lÙ³À="D‰$HÀÀöª|,À]!Å7܀@R¥Œmüa@Idɓ&L™À="D‰$HÀÀ²ÚtéÀ\tÈ8a' +€@QÀ/XÓÐE@Iû÷ïß¿~À="D‰$HÀÀo©‹]vÀ[ÉZ’šEž€@Pç%ž³ªt@J“&L™2dÀ="D‰$HÀÀ.ÎϬŒÀ[ 46€@Pƒ¯¥ì@K*T©R¥JÀ="D‰$HÀ¿ß_¯“ÀZy1™~€€@N°Š3  3@KÁƒ 0À="D‰$HÀ¿ct¥!+ìÀYÕ9_æÜ6€@MB›3¢C@LX±bŋÀ="D‰$HÀŸêæNÛœÌÀY4^”N$ý€@Ké3硬@Lïß¿~ýûÀ="D‰$HÀŸuH&»-›ÀX–Ôr›Ú²€@J£M:•€@M‡8páÀ="D‰$HÀŸŠ "ÄôÀWüëÙ±Lp€@Ioë°IZ@N“ÀP׏ÄK—L€@:)hR*8@Sȑ"D‰À="D‰$HÀ·Å–ãs³ÀP“â틀@93¿­:W@T(P¡B…À="D‰$HÀ·xwWT–ŸÀP'8Í3k…€@8IµfÿË2@T_¿~ýû÷À="D‰$HÀ·,ô‚À±~ÀO ¥Üd7~€@7j“žy(@T«V­ZµjÀ="D‰$HÀ¶ã ØP@FÀNôçH_%G€@6•cZÊ¡™@TöíÛ·nÝÀ="D‰$HÀ¶š²<„íÀNKlñRßS€@5ɳ1ÆQ@UB… +(PÀ="D‰$HÀ¶Sè¹ù~ÀM€dÕåk€@5ϧ(©â@UŽ8páÃÀ="D‰$HÀ¶¥ƒMÂîÀLÿôñ¡–¢€@4L"¹ška@UÙ³f͛6À="D‰$HÀµÊä)jTýÀL^|ùóçÀ="D‰$HÀŽR|Ç}ØfÀHÐù ÙÜj€@/Ý@5úw²@WêÕ«V­ZÀ="D‰$HÀŽ„ïÊPÀHCîZè©€@.ÊNßõJ»@X6lÙ³fÍÀ="D‰$HÀ³ßÖ²»’¹ÀG¹çŸ¹}}€@-Àn'k;¶@X‚ @À="D‰$HÀ³šjõ1ܖÀG2áGª N€@,¿žéåy@X͛6lÙ³À="D‰$HÀ³r:§ &ÀF®Õ ô2â€@+Å«o]š@Y2dɓ&À="D‰$HÀ³2ùÓIHÀF×J_/˜D€@,Ãæ\µJ@Ydɓ&L™À="D‰$HÀ²ý“ŠxtÀF_0KÆb€@+ÊÓ!…@Y°`Áƒ À="D‰$HÀ²ÉE\ÓقÀEêz7µÊ׀@*ܲe!S„@Yû÷ïß¿~À="D‰$HÀ²–ßêšÀEy ûÑÚŀ@)øïnþ¡@ZGÇL¢Áª€@e=â¥Ý@_ãǏ@å˗.\¹À;|ùóçÀ;|ùóçÀ;|ùóçÀ;µjÕ«V­À;|ùóçÀ;Ó&€@Tlí€~ÇA@H6lÙ³fÍÀ;À_ |Ó)O€@Se³Wkª<@H͛6lÙ³À;[À[ué÷e]x€@N 'ÇÐ@KÁƒ 0À;\€@N¯øj‹Ú@RN:téÓÀ;|ùóçÀ;™Ôˆ@X6lÙ³fÍÀ; RÙDŽÀ>Ðû¥.äî€@“\ÏØ9@_˜0`ÁƒÀ;F}¶Ö®€@ȇwâ{$@_ãǏ‚xےž€@ä“4™@`‰$H‘"À;ÀÀ9#Ù¥LD€@¹®/qŽ@a‘£F4À;!À8ŒàŒ­yԀ@4Œléh@a·nÝ»víÀ;|ùóçÀ9û÷ïß¿|ÀϏÖ× ‰uÀXg/Jð‰€@}Õ(D}Öv@X±bŋÀ9û÷ïß¿|ÀÏQhšYÖYÀ\gŽâŽMx€@}|8Z@ ‰$H‘"À9û÷ïß¿|ÀÏ Ëž-wÀ`ý, 2÷€@|:뜗ö@"å˗.\¹À9û÷ïß¿|ÀιßÊM!dÀaºØT×ó€@{JÉN|c@%B… +(PÀ9û÷ïß¿|ÀÎbˆìÛJqÀc5?îúú߀@zLúšKŠ@'Ÿ>|ùóçÀ9û÷ïß¿|ÀÎÀy +?Àdt‚ñŒŸ†€@yCã‚ê@)û÷ïß¿~À9û÷ïß¿|ÀÍ¡D"âÀe?bw%€@x3á%•úA@,X±bŋÀ9û÷ïß¿|ÀÍ9Ûk«ÐÀf]@ŸŸ“Y€@w"Ú l@.µjÕ«V­À9û÷ïß¿|ÀÌΈtºíEÀg +\”I+€@vbwtþ +@0‰$H‘"À9û÷ïß¿|ÀÌ`eð̍»Àg»ìâf€@u@)|@1·nÝ»víÀ9û÷ïß¿|ÀËð%0øs²ÀgðˆU§€@s÷pØ®ï@2å˗.\¹À9û÷ïß¿|ÀË~d Œ{UÀh2ƒãÏÂæ€@rôê0G@4(P¡B…À9û÷ïß¿|ÀË ­“MŒÀhYK9æ€@qúòKÒ2õ@5B… +(PÀ9û÷ïß¿|Àʘ~6³-Àheè$…î8€@q +`'—B÷@6páÇÀ9û÷ïß¿|ÀÊ%H‚У+Àh\PÊxE—€@p#Ìï›S@7Ÿ>|ùóçÀ9û÷ïß¿|Àɲm“¿ZñÀh@% ††€@n œd‹M@8͛6lÙ³À9û÷ïß¿|ÀÉ@=Ný’ ÀhžõPڀ@lë=­IKÚ@9û÷ïß¿~À9û÷ïß¿|ÀÈÎù‰™6dÀgÜjc0Hڀ@k[þ’ÃÔ@;*T©R¥JÀ9û÷ïß¿|ÀÈ^ÙãÜjiÀg˜œ9ËZY€@ià꧔ +•@µjÕ«V­À9û÷ïß¿|ÀÇKR.¶ÝÀf”¬„£ª€@e㊑-X @?ãǏ|ùóçÀ9û÷ïß¿|À·b©MoÀ`§Óy/€@TVž¥ Ÿ@H6lÙ³fÍÀ9û÷ïß¿|ÀÁ¶h€¥À`<Т÷„ª€@SHð‚ùŠ>@H͛6lÙ³À9û÷ïß¿|ÀÁjHÙ°” À_¥i)Û€@RJ¹ÁF$œ@Idɓ&L™À9û÷ïß¿|ÀÁ 'Wg À^Ô8µÍ˜€@Q[Qò¹Z@Iû÷ïß¿~À9û÷ïß¿|ÀÀØ #­À^6Øù¬€@PyøsJš@J“&L™2dÀ9û÷ïß¿|ÀÀ’£WÇQDÀ]<Pߓk€@OKðRfi@KÁƒ 0À9û÷ïß¿|ÀÀ )ä¯âÀ[Ždºúí€@LF‡tg]@LX±bŋÀ9û÷ïß¿|À¿—0™;‚ÀZ÷rÑâ¶í€@Jæ†`Ž@Lïß¿~ýûÀ9û÷ïß¿|À¿~ÔïîÀZ?FíÉž€@Iœ\fê—@M‡8páÀ9û÷ïß¿|ÀŸŸ'œ[ÀYŒ(Ú~iû€@HeÛÎTµ“@Ns72e@P=zõëׯÀ9û÷ïß¿|ÀŒia ôý†ÀVw¡µ D€@CZ¬ n›F@P‰$H‘"À9û÷ïß¿|ÀŒ‰”دÀUì'îvÆý€@Bƒü‘ìV@PÔ©R¥J•À9û÷ïß¿|À»š4ÔTÄÀUf­ø +Š¥€@A¹zÇáM@@Q @À9û÷ïß¿|À»6JwîQ*ÀTæRG©©€@@úOÑ,ؒ@Qkׯ^œ{À9û÷ïß¿|ÀºÔ³XªîÀTjʛ€Ö$€@@E¶$…?@Q·nÝ»víÀ9û÷ïß¿|ÀºuXHTáÀSóÒT`žˆ€@?5ïÙCV4@R 0`À9û÷ïß¿|Àº%VŸÅÀS)én&g€@=òÛ8)ör@RN:téÓÀ9û÷ïß¿|À¹œ[4ÃÀS–rïMC€@<Àù;!§¥@Rš4hÑ£FÀ9û÷ïß¿|À¹c菇ˆÀR§á>Çœ€@;Ÿ,\Ó@Rå˗.\¹À9û÷ïß¿|À¹ º'RîÀR@×pßé]€@:Œl£w6Š@S1bŋ,À9û÷ïß¿|Àž·j>éŸTÀQÝCiûªû€@9‡ÅÅåØë@S|ùóçϟÀ9û÷ïß¿|Àžcën£ÀQ{—A’ €@8NA1/!@Sȑ"D‰À9û÷ïß¿|Àž7^¢k±ÀQ'cŒ€@7¥~Ý@T(P¡B…À9û÷ïß¿|À·ÂH:lÀP»ßEŽž€@6ÅQÇQs™@T_¿~ýû÷À9û÷ïß¿|À·tœÎ]<ÀP^xVǀ@5ð& +ñ'Ä@T«V­ZµjÀ9û÷ïß¿|À·'ž§¢hÀPîëD €@5$܇zÀ}@TöíÛ·nÝÀ9û÷ïß¿|À¶ÜÖRµfÀON6ïF€@4bǓù·4@UB… +(PÀ9û÷ïß¿|À¶“¶i‰ýÀN›Ö¯!f€@3©Fz`n@UŽ8páÃÀ9û÷ïß¿|À¶L7ÆUL!ÀMìõÌéê€@2÷ēÄ`õ@UÙ³f͛6À9û÷ïß¿|À¶R7”"ÀMA~>í|î€@2Mž„l @V%J•*T©À9û÷ïß¿|ÀµÁýŸ›MÀL™{:-ž€@1ª¡\-Í@VpáÇÀ9û÷ïß¿|Àµ1Ë­¶~ÀKôôæeŒ€@1 +7ûD±@VŒxñãǏÀ9û÷ïß¿|Àµ=æ}@ddÀKSì0«Ý¥€@0w„ü•ß6@W @À9û÷ïß¿|ÀŽþp=LîÀJ¶c°@7]€@/ÍWæîÏ@WS§N:tÀ9û÷ïß¿|ÀŽ¿°dg5IÀJWºô*ÿ€@.¶Ak0ˆ&@WŸ>|ùóçÀ9û÷ïß¿|ÀŽ‚µ#5óÀI…Âýh;°€@-©=˜Pt@WêÕ«V­ZÀ9û÷ïß¿|ÀŽG…JžaÀHòž9ûT€@,¥6‡¢ a@X6lÙ³fÍÀ9û÷ïß¿|ÀŽ Õw%€”ÀHbßxú׳€@+ªôª@X‚ @À9û÷ïß¿|À³Óàý€*ÀGÖ|‘Š$!€@*¶þÌp`1@X͛6lÙ³À9û÷ïß¿|À³œ46˜Ž3ÀGMi'šÔŽ€@)˘*hoW@Y2dɓ&À9û÷ïß¿|À³^xܘpÀÀG|Dú8ª€@*oÆÏ?–@Ydɓ&L™À9û÷ïß¿|À³'–Tã›ÀFüÄ#ð{¶€@)…«§öÐ@Y°`Áƒ À9û÷ïß¿|À²ñÛÔtB"ÀFqTÉù€@(Š+q@Yû÷ïß¿~À9û÷ïß¿|À²œA‰ËŽÀF D€9Š€@'гH]4ì@ZGÊËW€@'ŽdcÀê@Z“&L™2dÀ9û÷ïß¿|À²WJñ»’ÀE#\9*‘Ý€@&A«“/@ZÞœzõë×À9û÷ïß¿|À²%ޑâdŸÀDµ…>zêü€@%‡jJvì@[*T©R¥JÀ9û÷ïß¿|À±õqæ•ÀDJ؂õ_¬€@$ԖQšã0@[uëׯ^œÀ9û÷ïß¿|À±Åý–páŸÀCã9[þ俀@$)©ÔSøÖ@[Áƒ 0À9û÷ïß¿|À±—zzQ¥ÀC~Œ7t{`€@#…ò1ÆÉ™@\ 4hÑ£À9û÷ïß¿|À±iáõ–4\ÀC¶’»Ÿy€@"éã_¹F@\X±bŋÀ9û÷ïß¿|À±=-6fóÀBœžñ±\π@"R©.fƚ@\€H‘"DˆÀ9û÷ïß¿|À±Uó;§ÀBa,Վ'€@!ÂiŸX"8@\ïß¿~ýûÀ9û÷ïß¿|À°æV#×ÀBH³ÐŒí€@!8FÌZÁ@];víÛ·nÀ9û÷ïß¿|À°Œ'„Y$ÀA¯Ûí=w€@ ³(,d`ò@]‡8páÀ9û÷ïß¿|À°’ıQF*ÀAZÐÅÉ0€@ 3•4+Ã4@]Ò¥J•*TÀ9û÷ïß¿|À°j(ÕÌaÀAWþ¿>€@rq݆@^‡pÛHc«€@lÍ @_ãǏOä *@b 0`À9û÷ïß¿|À«Q@ŸUúªÀ7Œ}êzl€@Ëóâ«r@b(Ñ£FÀ9û÷ïß¿|À«Ÿ`—²À7]vÆ1À€@]LiH¶è@bN:téÓÀ9û÷ïß¿|ÀªâÜ +§ýÀ7œä>ÁR€@ò3_™¡@bthÑ£FÀ9û÷ïß¿|Àª¬ñ~û§DÀ6¥ß¹,ÂD€@Š„~á»<@bš4hÑ£FÀ9û÷ïß¿|ÀªwÚá&lÀ6M/Q*HŸ€@&8MŒû@bÀÀ9û÷ïß¿|ÀªC“gpëÀ5ö|I×ìD€@‰¹9ÎNZÀ8hÑ£FÀÐäMœŸ}¬€€@«nÕ¯Rj?òå˗.\¹À8hÑ£FÀÐàÍz»åWÀ7™û€Ñ›€@žÄ±è›>@å˗.\¹À8hÑ£FÀÐÖ\‚‹3ùÀG¢Õît³€@xdù•OÇ@ X±bŋÀ8hÑ£FÀÐÄÅԉ=uÀQÞç°!e€@:é7ü¢á@å˗.\¹À8hÑ£FÀЬVžÖ)ŸÀWn¥h;³€@€è$ÊQ€@Ÿ>|ùóçÀ8hÑ£FÀЍ4 À\€¹è€@€ôÉñ“@X±bŋÀ8hÑ£FÀÐiWŒÀ`™(ʵŽ€@€ ù7R¡w@ ‰$H‘"À8hÑ£FÀÐ?PR—ØÙÀbŒÜv÷?€@Ì&3š@"å˗.\¹À8hÑ£FÀÐÒEý¹oÀd–£=çÔq€@}àÿR)@%B… +(PÀ8hÑ£FÀÏŒv‚²ÐÁÀf2Î:Äc3€@|ª&Aõ‰ó@'Ÿ>|ùóçÀ8hÑ£FÀÏPYyNžÀg…ítÔ±€@{jÊ[fÏ@)û÷ïß¿~À8hÑ£FÀÎޑBb¥jÀh›U®qʜ€@z' âB@,X±bŋÀ8hÑ£FÀÎh2áGvŸÀiwS«GÀ"€@xã3Q@.µjÕ«V­À8hÑ£FÀÍîEÆêj®Àj»P{]ñ€@w¢ Žûˆ@0‰$H‘"À8hÑ£FÀÍq» |æßÀj“µ(ÄÎY€@vfqÞI$Ï@1·nÝ»víÀ8hÑ£FÀÌói}cTÀjߏÐËž€@u3‰[÷y @2å˗.\¹À8hÑ£FÀÌt€,ÕÀk€9hŽ€@t q—òŒX@4(P¡B…À8hÑ£FÀËô2=©ŸÀk˜f~Ҁ@rïr—î +Î@5B… +(PÀ8hÑ£FÀËtnq،›ÀkJg'ŽE€@qàA³Z…Ø@6páÇÀ8hÑ£FÀÊõ5¶Î„ÀjשWS?A€@pÞ3^ñ +@7Ÿ>|ùóçÀ8hÑ£FÀÊvêÒóÐqÀjœK‡9‹P€@oҝŽŠY@8͛6lÙ³À8hÑ£FÀÉùÝ ÄÉÀjQó+ƒÙ“€@nÆO(«U@9û÷ïß¿~À8hÑ£FÀÉ~KÛ!ÌÊÀiû¶Fáéà€@lL8 O<@;*T©R¥JÀ8hÑ£FÀÉj׫çƒÀišËZ쁀@j®‡‚õø@µjÕ«V­À8hÑ£FÀÇ¢µÉNîßÀhDï&£ãõ€@f]¥€Ú1`@?ãǏlã€@bÆÚI{ØÓ@A·nÝ»víÀ8hÑ£FÀÅꘝªÀfJà“Ëþր@ažõË“@BN:téÓÀ8hÑ£FÀłz;hÐ,ÀeȪƒ +,€@`»pÛºô@Bå˗.\¹À8hÑ£FÀÅÄZq¿EÀeF „Šv€@_šqሠ+ê@C|ùóçϟÀ8hÑ£FÀĹvÝ MÀdÃÙI!Óð€@]ښç”n@D(P¡B…À8hÑ£FÀÄXŽ'ÿÍXÀdBºøT`8€@\5}ê­Nt@D«V­ZµjÀ8hÑ£FÀÃúàm²9ÀcÃ3ëù‡J€@Z©\£áž&@EB… +(PÀ8hÑ£FÀÝχ:ÓÀcEª2š«€@Y4—€Üç@EÙ³f͛6À8hÑ£FÀÃCæõVúkÀbÊkÍ‡4€@WÕ«ÂI­e@FpáÇÀ8hÑ£FÀÂì>ÃÃÀbQ²ÍŸ7р@V‹1›š}H@G @À8hÑ£FÀ–ÌöÙGŠÀaÚ:ŠÍy•€@USéþ0Ę@GŸ>|ùóçÀ8hÑ£FÀÂCŽÅÒÞFÀac7¢Ý׀@T.à[ó3â@H6lÙ³fÍÀ8hÑ£FÀÁò€”Žš À`í@m˜ò€@S1ç`Ž*@H͛6lÙ³À8hÑ£FÀÁ£œGÄYdÀ`xÍÔ¥Bh€@R0҉’@Idɓ&L™À8hÑ£FÀÁVÙȋG"À`?]„@Q$rµùÑA@Iû÷ïß¿~À8hÑ£FÀÁ /tfh<À_+Œ]åTc€@P?·ý芜@J“&L™2dÀ8hÑ£FÀÀÒw걡À^OĘÒ݀@NÒ Žæ>.@K*T©R¥JÀ8hÑ£FÀÀ|÷_ešÀ]xêw¬~ö€@M?8Å\P@KÁƒ 0À8hÑ£FÀÀ8Pûþ€zÀ\§i$p  €@KłlŸQ‹@LX±bŋÀ8hÑ£FÀ¿ë&’kDÀ[Ûh%oé€@Jc‰-²@Lïß¿~ýûÀ8hÑ£FÀ¿iaÉwM–À[þå÷<œ€@IýŒÊfX@M‡8páÀ8hÑ£FÀŸë8ôãEŒÀZToÃcª5€@Gá Ô2ø@N\vß@Qkׯ^œ{À8hÑ£FÀ»H øëÀTÞܟWO€@?©\ü¯Î9@Q·nÝ»víÀ8hÑ£FÀº¥Ø&Ød%ÀTaˆF}¶œ€@>Z æïç_@R 0`À8hÑ£FÀºF­ŸžÀSèñÏÁd€@=*ö‰î„@RN:téÓÀ8hÑ£FÀ¹é°þ©ÉOÀStÕ®Û4€@;ñr¹kZU@Rš4hÑ£FÀ8hÑ£FÀ¹ŽÏc`€ƒÀSõQtø€@:Õ¹˜BØ@Rå˗.\¹À8hÑ£FÀ¹5ôÇÞÈôÀR™²¹£€@9Èì|‘…ª@S1bŋ,À8hÑ£FÀžßÀeª[ÀR0ý¿k(Œ€@8Ê ² ”ˆ@S|ùóçϟÀ8hÑ£FÀžŠpäæ¹ÀQËm“#ö€@7Ø-õ±ªk@Sȑ"D‰À8hÑ£FÀž6ìzþWÀQf¯‘žø€@6òcfS³=@T(P¡B…À8hÑ£FÀ·å r +#ÙÀQÖÃùý€@6Ô +Ef˜@T_¿~ýû÷À8hÑ£FÀ·–#ñiYÚÀP¢¥xƒˆ£€@5G¶’ ¬@T«V­ZµjÀ8hÑ£FÀ·HnŠuúúÀPC/öiŒB€@4Q-Ür@TöíÛ·nÝÀ8hÑ£FÀ¶üwí@qšÀOË5ŸP€@3Ã÷m@UB… +(PÀ8hÑ£FÀ¶²7ªËëÀOV÷§ú€@3 +å‹G„@UŽ8páÃÀ8hÑ£FÀ¶i€ŒÅžÏÀN_[‡Ýz4€@2aø`­f@UÙ³f͛6À8hÑ£FÀ¶"¶¢/’ÀÀM¯!Ÿz…q€@1Œ7ÌQ&^@V%J•*T©À8hÑ£FÀµÝd[±|ùóçÀ8hÑ£FÀŽ™²Úñ¥æÀIÜÃûĉ%€@,ށÌ=„'@WêÕ«V­ZÀ8hÑ£FÀŽ]MAÁ,/ÀIFš+qÇœ€@+·œñž*Ã@X6lÙ³fÍÀ8hÑ£FÀŽ"Eÿ)ŒÀHŽ#Ð €@*Ã:&ù™@X‚ @À8hÑ£FÀ³è”©‘ÀH%&o>'ð€@)ÖÏƱ.@X͛6lÙ³À8hÑ£FÀ³°0ú< 'ÀG™£{ w€@(ñÛ)=Ž@Y2dɓ&À8hÑ£FÀ³r̔u"§ÀGʱ5"ºÌ€@)/1…œ +$@Ydɓ&L™À8hÑ£FÀ³;4Ä5{ÀGG–œ—Dâ€@(MÏ5È @Y°`Áƒ À8hÑ£FÀ³ÍågžÚÀFÈ{(€oπ@'vÊ¢,¬E@Yû÷ïß¿~À8hÑ£FÀ²Ï޵G¢ÀFM8v±å[€@&©Ž‰nP@ZGü‡ê@[*T©R¥JÀ8hÑ£FÀ²qHŽªÀDƒáæD¬_€@#Ì üÕš@[uëׯ^œÀ8hÑ£FÀ±Õy"ŸS•ÀDÔTsᲀ@#(*ÉöZl@[Áƒ 0À8hÑ£FÀ±ŠwÛg8œÀC²Ùgþ1€@"‹NØjž@\ 4hÑ£À8hÑ£FÀ±xfQÃdÁÀCNԛâQ€@!õÒ @\X±bŋÀ8hÑ£FÀ±K=§ŸÔÀBíª•t7Y€@!eXBài@\€H‘"DˆÀ8hÑ£FÀ±÷=š@}ÀBA‚ð;€@ ÛªqY¢@\ïß¿~ýûÀ8hÑ£FÀ°óŒ±¶ý”ÀB3~ûŸ5ڀ@ V¿DÖd¬@];víÛ·nÀ8hÑ£FÀ°È÷Ûž?œÀAÚLeÌ~€@¯‚Ÿ $@]‡8páÀ8hÑ£FÀ°Ÿ2ËRýYÀAƒ‘XJ%ÿ€@»¶_32÷@]Ò¥J•*TÀ8hÑ£FÀ°v7ÅÈå™ÀA/8y#ž€@њ+ž@^¥À>Åz6w™É€@…ãÙ\@_ãǏ9Öc9Z€@UP¡ˆaN@`¯^œzöÀ8hÑ£FÀ®°`JÜÀ=±Î #Ÿ€@©ªŽhš@`=zõëׯÀ8hÑ£FÀ®K(”dºzÀ=-BÀ“P%€@GBVœ@`cF4hÀ8hÑ£FÀ®ÕßÝæñÀ<¬Íù€i€@dÝÆLÐÒ@`‰$H‘"À8hÑ£FÀ­Ã°lÇ?ÉÀ<.-ú@Jº€@Ë* ~@`®Ý»víÛÀ8hÑ£FÀ­°ŠœÑÀ;³mÇÙå€@6ëÑË|@`Ô©R¥J•À8hÑ£FÀ­@Ï8XRŽÀ;;¹óúË̀@§ã]}*Â@`útéÓ§NÀ8hÑ£FÀ­ŒhÖÀ:Æû}røv€@ؚ4߯@a @À8hÑ£FÀ¬ÂK9@wâÀ:U†šö΀@˜““¿ãe@aF 0`ÁÀ8hÑ£FÀ¬„›#2«@À9åüÔ(ŠC€@ßä?} @akׯ^œ{À8hÑ£FÀ¬GîUŽtÛÀ9y E €@›‹Òóœ@a‘£F4À8hÑ£FÀ¬ >“®‹âÀ9º²FgЀ@#h'Uô@a·nÝ»víÀ8hÑ£FÀ«Ñ…ÑۓãÀ8šk â7€@¯H ¿µ@aÝ:téÓ§À8hÑ£FÀ«—Ÿ4è_ˆÀ8CŒFŠë€@?æ]¥@b 0`À8hÑ£FÀ«^âšÎÀ7á (K€@Òj1WN»@b(Ñ£FÀ8hÑ£FÀ«&ëá`NŒÀ7€Õ+ÝÙ€@i]h~fS@bN:téÓÀ8hÑ£FÀªïÖTÀ7"Ø¢7V»€@µäDn@bthÑ£FÀ8hÑ£FÀª¹œ;%i•À6Çc[Ž€@B¡ƒ<@bš4hÑ£FÀ8hÑ£FÀª„8‘uiÈÀ6mGî&LJ€@„äŒ@bÀÀ8hÑ£FÀªOŠxMÒÀ6“\M(€@ ˔¥9ÜÊÀ6Õ«V­ZŽÀÑÐJvN+G€€@ƒÛß5lÊ­?òå˗.\¹À6Õ«V­ZŽÀÑÌc=ØÀÌÀL| @àdƀ@ƒ˜Ý*kŽ*@ X±bŋÀ6Õ«V­ZŽÀѪm•Ù‚êÀUJ[­¶…š€@ƒIlDà¯ù@å˗.\¹À6Õ«V­ZŽÀэ`·²‡ÁÀ[ÎÎKÌ:‚€@‚ßeHz@Ÿ>|ùóçÀ6Õ«V­ZŽÀÑhù«÷ãÁÀ`橎mè€@‚]mö‡³@X±bŋÀ6Õ«V­ZŽÀÑ=ÒßMÁÀc—cíæ€@ÆÏ$Ò¯@ ‰$H‘"À6Õ«V­ZŽÀÑ —àôŒ‹Àfî²€@'Žkªù@"å˗.\¹À6Õ«V­ZŽÀÐÖ €S³mÀh‘÷z\ €@€gX¯“]@%B… +(PÀ6Õ«V­ZŽÀЛv‹ ÀiÕ‡kY€@Röyº‘2@'Ÿ>|ùóçÀ6Õ«V­ZŽÀÐ\I»µ«ŽÀk7>Þ<߀@}Ïgš†"@)û÷ïß¿~À6Õ«V­ZŽÀК£5±YÀlNµTÿҀ@|Jg ŽÀÄ@,X±bŋÀ6Õ«V­ZŽÀÏ­‹øìžÈÀm!#Nù4ž€@zÈû’FÉ@.µjÕ«V­À6Õ«V­ZŽÀÏ"t·MöÀm·>”«ê˜€@yN®ŸtÊ\@0‰$H‘"À6Õ«V­ZŽÀΕÏ6àÚÀnû2›œ€@wސŸ]@1·nÝ»víÀ6Õ«V­ZŽÀÎŽTš,Àn?SŠªØá€@v{ë×öñ@2å˗.\¹À6Õ«V­ZŽÀÍw‘ŒšúOÀnB—×Fï€@u(ÖL’€¿@4(P¡B…À6Õ«V­ZŽÀÌèÔ×VftÀn%q«ÎG̀@sæm©è‰ @5B… +(PÀ6Õ«V­ZŽÀÌZåþÎuÀmì# +Eµ€@rµ~ø+@6páÇÀ6Õ«V­ZŽÀËÎ>)ú~Àm›imÍa€@q”Ö§.¿;@7Ÿ>|ùóçÀ6Õ«V­ZŽÀËC?åú‰Àm8ø \ ô€@p…>Eíj±@8͛6lÙ³À6Õ«V­ZŽÀʺ5”«=kÀlȋÒL΀@o ”K`\|@9û÷ïß¿~À6Õ«V­ZŽÀÊ3XQú»ÄÀlMŠ0vÿg€@m+€óYê«@;*T©R¥JÀ6Õ«V­ZŽÀÉ®ÔåˆÊ}ÀkɕÌV¬Œ€@ki@œT\@.’Àk<×Wä,ž€@iÂöÞ$o@=‡8páÀ6Õ«V­ZŽÀÈ­!­ÇÀjª$€!€@h7Sz¥ @>µjÕ«V­À6Õ«V­ZŽÀÈ0é÷5ª­Àj«\XSŒ€@fÄÜ9>ÓG@?ãǏ|ùóçÀ6Õ«V­ZŽÀ‚»m¿64Àb"Žñ-š}€@SéBC'/Œ@H6lÙ³fÍÀ6Õ«V­ZŽÀÂ.@ì 8.Àa û‰bÄ{€@RЭ¡ìþc@H͛6lÙ³À6Õ«V­ZŽÀÁÜ%žáŽòÀa!ˆÓEõ€@QɈˆ6c\@Idɓ&L™À6Õ«V­ZŽÀÁŒ^í&#³À`€Ž<á~€@PÒᜟ^@Iû÷ïß¿~À6Õ«V­ZŽÀÁ>à/W²À`*Qß-€@OםÄPLK@J“&L™2dÀ6Õ«V­ZŽÀÀóœýA¡À_f æ%W€€@N&æþe“@K*T©R¥JÀ6Õ«V­ZŽÀÀª„[bàgÀ^}•††Tœ€@L‘õ–™ìé@KÁƒ 0À6Õ«V­ZŽÀÀcŠNeáóÀ]›u3Mö€@K6õx@LX±bŋÀ6Õ«V­ZŽÀÀžÓÇèÿÀ\¿ÃìŠà€@Iµ)Q Éo@Lïß¿~ýûÀ6Õ«V­ZŽÀ¿·eQç֒À[êŒÊߣ•€@HjdÆ¡ö^@M‡8páÀ6Õ«V­ZŽÀ¿5lße+œÀ[ Å +9€@G5Ov#f@No‘}@Q·nÝ»víÀ6Õ«V­ZŽÀºÔÔù;TvÀTÌÙ »Ù€@=Hó¶W@R 0`À6Õ«V­ZŽÀºsœmæíÀTNY#¶i€@<?¥*®­@RN:téÓÀ6Õ«V­ZŽÀºïc§ÀSÔœoi5Ѐ@:ð“ù‡éŽ@Rš4hÑ£FÀ6Õ«V­ZŽÀ¹žT`ØóÀS_ŸÑŽÑP€@9ܺp§g:@Rå˗.\¹À6Õ«V­ZŽÀ¹]ØlÝéÝÀRïÛKI€@8ז&Àã@S1bŋ,À6Õ«V­ZŽÀ¹f¡òqÀR‚f"ky€@7à!=ªCí@S|ùóçϟÀ6Õ«V­ZŽÀž®îÓÿ#ÀR‹V«=€@6õgæäÒÁ@Sȑ"D‰À6Õ«V­ZŽÀžZgË,n{ÀQ°GâÎmš€@6‚.ó±c@T(P¡B…À6Õ«V­ZŽÀžÉ2>žÀQIÜ5ɀ@5B˜û˜ãÄ@T_¿~ýû÷À6Õ«V­ZŽÀ·· ¢ó(«ÀPåY]ÜÆˆ€@4xåÍ~w@T«V­ZµjÀ6Õ«V­ZŽÀ·h }l€ƒÀP‚ÌÔÕú€@3ž±dš ò@TöíÛ·nÝÀ6Õ«V­ZŽÀ·7ïsëÀP"@J&<€@3R‚#'”@UB… +(PÀ6Õ«V­ZŽÀ¶Ï«OõµÀO‡sn/E#€@2R,Ċä‰@UŽ8páÃÀ6Õ«V­ZŽÀ¶† *ÚÐÀNÎzÑ©Ðð€@1ª¯^@UÙ³f͛6À6Õ«V­ZŽÀ¶>!ºŸ€ÀN™P*¶)€@1 +U[HoU@V%J•*T©À6Õ«V­ZŽÀµ÷ԋ­µÅÀMhÍÎŒeŽ€@0p¢LÙâG@VpáÇÀ6Õ«V­ZŽÀµ³(Æ•ÖÆÀLŒÀNª€@/ºGáÛ«@VŒxñãǏÀ6Õ«V­ZŽÀµp>ŠÁÀLcŠ"9ǀ@.žàxCâ@W @À6Õ«V­ZŽÀµ.tÒEgÀKn³€ºs:€@-ŽIØöô@WS§N:tÀ6Õ«V­ZŽÀŽîs:"ªÀJÍ÷3$Á€@,‡Í×iÄ}@WŸ>|ùóçÀ6Õ«V­ZŽÀŽ¯ÛŠÆ +ÞÀJ1 Ùȏô€@+ŠÃ}O{@WêÕ«V­ZÀ6Õ«V­ZŽÀŽr²8ø«õÀI˜!Àr€@*–Ž}œŽ@X6lÙ³fÍÀ6Õ«V­ZŽÀŽ6íœpÀÀIçXBsy€@)ª›Ô¿L@X‚ @À6Õ«V­ZŽÀ³ü…]Õê™ÀHqb —ÆÐ€@(Æe¹àš@X͛6lÙ³À6Õ«V­ZŽÀ³ÃppCaÈÀGã~؂q#€@'én%)2@Y2dɓ&À6Õ«V­ZŽÀ³†9ÀHF¿o€@'ßèw¹®@Ydɓ&L™À6Õ«V­ZŽÀ³MØ}~iÀGhên0€@'À%,@Y°`Áƒ À6Õ«V­ZŽÀ³Ëè·¡ŽÀG þžh¡€@&:SŠ—ß@Yû÷ïß¿~À6Õ«V­ZŽÀ²àî™RV¯ÀFŽ,%—ó€@%v ×I‚Ò@ZGrŸçjñè€@ð{ R@`¯^œzöÀ6Õ«V­ZŽÀ®¢ÉœýN•À=è>ˆ€@McyF'@`=zõëׯÀ6Õ«V­ZŽÀ®\ÂúЇÁÀ=a÷hŸ€@°«Â8?©@`cF4hÀ6Õ«V­ZŽÀ®ö &×RÀ<ÞäkÏí€@š@‚ñü@`‰$H‘"À6Õ«V­ZŽÀ­ÔZœâßËÀ<_*Iûàl€@ˆ;ˆ@`®Ý»víÛÀ6Õ«V­ZŽÀ­‘éR0æQÀ;⬜šz"€@û¹œ +`¿@`Ô©R¥J•À6Õ«V­ZŽÀ­PšA)-{À;iP‹®†Õ€@tTßÐHê@`útéÓ§NÀ6Õ«V­ZŽÀ­fDîóÀ:òûs¿„€@ñ°Ðl™@a @À6Õ«V­ZŽÀ¬ÑFT.”|À:”£á²Ÿ€@s˜ZÙ­C@aF 0`ÁÀ6Õ«V­ZŽÀ¬“3ŸÅ0ÏÀ:«Ë]º€@ùÙ0†gg@akׯ^œ{À6Õ«V­ZŽÀ¬V'r±ªÀ9¡1r“ ñ€@„C˜ûg±@a‘£F4À6Õ«V­ZŽÀ¬IJ”åÀ96«kKM€@ªL@ÿè@a·nÝ»víÀ6Õ«V­ZŽÀ«ß +šbDÀ8ÍpåÜ€@€âM–›E@aÝ:téÓ§À6Õ«V­ZŽÀ«€ìr$ˆ}À8gX€R7ª€@:ÂÉÆ%@b 0`À6Õ«V­ZŽÀ«kœ"¿¢ºÀ8ªŠM š€@šIæ{Žæ@b(Ñ£FÀ6Õ«V­ZŽÀ«3v‚ À7¢T@"¯€@áÇ×r?@bN:téÓÀ6Õ«V­ZŽÀªüÕÔ!À7CBîVÞî€@!¹? L@bthÑ£FÀ6Õ«V­ZŽÀªÅ®„űÀ6æeãöç€@ gÛdZ©S@bš4hÑ£FÀ6Õ«V­ZŽÀªá}ŽÀ6‹©o€¥’€@ ³îÁ#ãæ@bÀÀ6Õ«V­ZŽÀª[[ |`À62ÿÓñ €@ ¶ÛJN©À5B… +(PÀÒڍÝöP€€@†~™M¹§7?òå˗.\¹À5B… +(PÀÒÕb’¹ŽÀAm-$NrF€@†hrÓãü@å˗.\¹À5B… +(PÀÒÅîœrëÉÀQgš’vªŒ€@†&j÷Œo@ X±bŋÀ5B… +(PÀÒ¬kÌRÀYž„í•ñ܀@…ŸOœþ@å˗.\¹À5B… +(PÀ҉hœpZÀ`žT߉ڀ@…4±è³Su@Ÿ>|ùóçÀ5B… +(PÀÒ]ºV5ÑÀd=o\’Óé€@„ŒÌd€ç@X±bŋÀ5B… +(PÀÒ*"óLÀg`kåqçk€@ƒË +Z®1@ ‰$H‘"À5B… +(PÀÑïŒiš':Àj&jpÕ=€@‚õ“]î{@"å˗.\¹À5B… +(PÀÑ®ýåQQÀlo?Lûـ@‚tPä6·@%B… +(PÀ5B… +(PÀÑiýÂç£ÀnGóY™*ó€@'åK3H@'Ÿ>|ùóçÀ5B… +(PÀÑ F›—;ÕÀo±Šß l€@€:â,ö)5@)û÷ïß¿~À5B… +(PÀÐÔÂ޲Àp_ +k­|€@~ž¿ÀG@,X±bŋÀ5B… +(PÀЅՆþ`Àpœ%@ÿ_€@|ÑW#·¡@.µjÕ«V­À5B… +(PÀÐ6'Ÿ–ºœÀpøiÿþ€@{Üš<ƒ@0‰$H‘"À5B… +(PÀÏË`ѐªÀq‘¯=}€@yc"Îì@1·nÝ»víÀ5B… +(PÀÏ*€¡²Àqsr]€@wÉ}¯Œ õ@2å˗.\¹À5B… +(PÀΉ,Æ(Àpø}ÞΜҀ@vEcxó@4(P¡B…À5B… +(PÀÍé‡ —lLÀpÏ€úª=€@t×á¹ÊÏ}@5B… +(PÀ5B… +(PÀÍK§„lÞèÀp˜Ý:V™€@s€à0~$¶@6páÇÀ5B… +(PÀ̰ 3ÎSÀpWaȵڀ@r?ÀÿaU„@7Ÿ>|ùóçÀ5B… +(PÀÌö"âM†Àp BÑ͋÷€@q£×• €@8͛6lÙ³À5B… +(PÀˀ¿qñmÀo{3/hñU€@o÷ é©à@9û÷ïß¿~À5B… +(PÀÊíŒí_4”ÀnÓÇÝxýC€@mìÁœˆÎ@;*T©R¥JÀ5B… +(PÀÊ]H@ŠÀn%£€¿n€@lÈ9èý @Àl¹bí€6€@h™®±8·š@>µjÕ«V­À5B… +(PÀÈÁU’É+Àl²™Œ€@g>–&@?ãǏÂYœÀkFïӛT€@e¡.§®HŸ@@‰$H‘"À5B… +(PÀÇ¿–Î +Àj;ãwá€@dKNýÞ0w@A @À5B… +(PÀÇCÉà®Û2ÀiÙåýX¯€@c ž£8&ê@A·nÝ»víÀ5B… +(PÀÆËOúzæÐÀi&Èϱâÿ€@aãtö<*æ@BN:téÓÀ5B… +(PÀÆV™>Ò¿Àhu͝Çvo€@`Î:c@Bå˗.\¹À5B… +(PÀÅä,Ÿ~å0ÀgǺäÈFŠ€@_–é:Ò8Ü@C|ùóçϟÀ5B… +(PÀÅuh>4MÀg$U¡€@]³\õÒµ@D(P¡B…À5B… +(PÀÅ À“Ï/Àfvv!?P!€@[ïà°FÐä@D«V­ZµjÀ5B… +(PÀÄ¡"Œ¿ÀeÓûµŒáY€@ZI•壵l@EB… +(PÀ5B… +(PÀÄ;yÌóÖÞÀe5è~D<€@XŸŽ÷!­š@EÙ³f͛6À5B… +(PÀÃØ±9b¿€ÀdœYò›†%€@WLÙ4?êŽ@FpáÇÀ5B… +(PÀÃx³K`»±Àd`“*k€@Uò©™[ߟ@G @À5B… +(PÀÃmZõ³ÀcuO¹uMy€@T®i2æô@GŸ>|ùóçÀ5B… +(PÀÂÀÔèfÁöÀbå<e€@S~՚|#Õ@H6lÙ³fÍÀ5B… +(PÀÂhàÞ&žÀbW3Ó^žŽ€@RbÆBec{@H͛6lÙ³À5B… +(PÀ„] ª+ÀaÌ%üzd€@QY üM@Idɓ&L™À5B… +(PÀÁÀ±€æ»êÀaD7{&X–€@P`̒sd@Iû÷ïß¿~À5B… +(PÀÁpY;ŸíŠÀ`¿§Z_[€@Nñ›©iD@J“&L™2dÀ5B… +(PÀÁ"jÑx͞À`>žökæ€@M@ViÅÚI@K*T©R¥JÀ5B… +(PÀÀÖÕry…À_‚sß>€@K«ö +.5ö@KÁƒ 0À5B… +(PÀÀ‡ÄšŒ€À^ˆëÁ»€@J2ÀÈ-‡@LX±bŋÀ5B… +(PÀÀFp=l@¯À]£OtJ–€@HÓТé@Lïß¿~ýûÀ5B… +(PÀÀ}GjxÀ\Ÿ™³ ñ·€@G‹leze·@M‡8páÀ5B… +(PÀ¿}:€”À[ážÌŒòñ€@FZQÒS¢»@N1ÎÿðÆ@NµjÕ«V­À5B… +(PÀŸ}{uë7•ÀZG`”ˆ+؀@D5IW–‚@OL™2dɒÀ5B… +(PÀŸb +®xÀY‰BH‘h€@C=ùìÏè‹@OãǏ…¹‹Eöb@Qkׯ^œ{À5B… +(PÀ»gh…q£ïÀU¿®î€@=5²ÌåÎÙ@Q·nÝ»víÀ5B… +(PÀ»óÌ·9'ÀU5 Ç†›€@;ùR<ª°y@R 0`À5B… +(PÀºžýž¹ž.ÀT°®id…€@:Ïj¶Ä@RN:téÓÀ5B… +(PÀº>lÖ²ÀT1€z•-_€@9µ©qŒÓ +@Rš4hÑ£FÀ5B… +(PÀ¹à&ž°ßÀS·œ”Ò‹v€@8«ÃÎÆzL@Rå˗.\¹À5B… +(PÀ¹„šákZÀSBL7ˆ•€@7°CpšŒ@S1bŋ,À5B… +(PÀ¹*&;mŸÀRÑhÇÀši€@6Â+)§z@S|ùóçϟÀ5B… +(PÀžÒC”MÐIÀRcZŸ,õ€@5àWNó.§@Sȑ"D‰À5B… +(PÀž|dŒ5hÀQ÷XGݵ€@5 +6#_@T(P¡B…À5B… +(PÀž(}áuØfÀQq·ÉøK€@4>s姅2@T_¿~ýû÷À5B… +(PÀ·Ö†îŒï-ÀQ%³–M׀@3|¿Ö:4G@T«V­ZµjÀ5B… +(PÀ·†tôԖÒÀPÀ$Έë€@2Ä@—’Z.@TöíÛ·nÝÀ5B… +(PÀ·8=C÷ÝÀP\Ë÷Nڀ@2P‘… €@UB… +(PÀ5B… +(PÀ¶ëÖGß§ŠÀO÷Uþ`[׀@1lVàåŸ@UŽ8páÃÀ5B… +(PÀ¶¡4¢ÞŸ÷ÀO9ƒÛèÀ’€@0ËÆK]@UÙ³f͛6À5B… +(PÀ¶XN)8ÂlÀN€‹”ø€@02MÜâ @V%J•*T©À5B… +(PÀ¶q‚]ÀMË™€í|€@/=ÀzŸÁÃ@VpáÇÀ5B… +(PÀµË‰'vq”ÀMs¥Òр@.#DòÇŒ@VŒxñãǏÀ5B… +(PÀµ‡–ZˆÈÀLnÓjj€@-÷e˜Þ@W @À5B… +(PÀµE5ZJºÀKÅü318D€@,ž‡@WS§N:tÀ5B… +(PÀµ\MFuÀK" &e€@+÷ ÿ³@WŸ>|ùóçÀ5B… +(PÀŽÅàø…ÀJ‚9zà%ƒ€@*!îŽV—¡@WêÕ«V­ZÀ5B… +(PÀއ8õ«YÀIændf„€@)8g+Ÿœ@X6lÙ³fÍÀ5B… +(PÀŽJ¡æ@µÌÀIN–ËÛ{™€@(VÓ‹”*@X‚ @À5B… +(PÀމªŽ\"ÀHºžÃ#€@'|¯:Ò-@X͛6lÙ³À5B… +(PÀ³ÕÊyy •ÀH*pœ2ÑÆ€@&©ÁGQÀ@Y2dɓ&À5B… +(PÀ³˜aÜîžÉÀH]øþè€@&‚C»úèö@Ydɓ&L™À5B… +(PÀ³_vc…ŸíÀGÓõËŽŽ·€@%ŽÖÒŠ@Y°`Áƒ À5B… +(PÀ³'˱”î0ÀGNX®«Ð€@$ñL°L)@Yû÷ïß¿~À5B… +(PÀ²ñW›`ÿÀFÌóa—ç8€@$6ˆ<³q@ZGhrœ$ë@]Ò¥J•*TÀ5B… +(PÀ°Œ_N$Å-ÀAwÙL·5p€@l0­È@^šOÞ2€@‚rÇ[) @`¯^œzöÀ5B… +(PÀ®³Íou£•À>4|=€@éH‰ek@`=zõëׯÀ5B… +(PÀ®mNïdQMÀ=“Äتõ„€@Uiõšî@`cF4hÀ5B… +(PÀ®( +HÀ=âTN×0€@Ç$÷Ú@`‰$H‘"À5B… +(PÀ­äæ•þÝÀ<m³­7œ€@> +!DO@`®Ý»víÛÀ5B… +(PÀ­¡')X‘kÀ<Iy/)€@¹ÝžU÷T@`Ô©R¥J•À5B… +(PÀ­_p–ÝørÀ;”Y\à +€@:g9 ãã@`útéÓ§NÀ5B… +(PÀ­ØŸ(+VÀ;‚6dls€@¿q%øu@a @À5B… +(PÀ¬ßXm _èÀ:§©úёĀ@HÈӀ§@aF 0`ÁÀ5B… +(PÀ¬ è­©¶À:5·Šÿ€@Ö>CŒµ­@akׯ^œ{À5B… +(PÀ¬c‚Ä ³ƒÀ9Ɠ3拀@g£ô¥@a‘£F4À5B… +(PÀ¬' +Û +–À9Z%ŽæZp€@ù{zôŠ@a·nÝ»víÀ5B… +(PÀ«ëº–):À8ðXŽ +œR€@++V£ ±@aÝ:téÓ§À5B… +(PÀ«±Kçc©µÀ8‰áÎö3€@c£¹õÆJ@b 0`À5B… +(PÀ«wÎ5Pù=À8$LPº%€@ ¢ŒÌÆëÌ@b(Ñ£FÀ5B… +(PÀ«?;Å+GÈÀ7Áäa˜€@ è0dDà—@bN:téÓÀ5B… +(PÀ« ÏMPÀ7aÌç¿Úº€@ 3»Í!w@bthÑ£FÀ5B… +(PÀªÐ¢-2À7óh²§€@ …˜&|n@bš4hÑ£FÀ5B… +(PÀªšÑVŸùQÀ6šFWUvc€@ +ÜkÂ@bÀÀ5B… +(PÀªe¶ŽêÙÀ6NŽÒŽu€@ +8Ön2ÉÀ3¯^œzõìÀÔ dÁ…€€@‰µm™ÕU¯?òå˗.\¹À3¯^œzõìÀÔáåoÀE®ÛññÉu€@‰—î|Q_€@å˗.\¹À3¯^œzõìÀÓïà›/ʳÀUŒ1]Lˀ@‰@Å2ŠY@ X±bŋÀ3¯^œzõìÀÓÐyÚí*¡À_Œjõîð5€@ˆ·f…da@å˗.\¹À3¯^œzõìÀÓ¥¥—;\ÀdgaÚ €@ˆ˜t©Š@Ÿ>|ùóçÀ3¯^œzõìÀÓpzûÜ©KÀh’s +F»8€@‡$ 1©Q@X±bŋÀ3¯^œzõìÀÓ2qpŽëÀl: +ž·q€@†)4Æ9¡â@ ‰$H‘"À3¯^œzõìÀÒë„`^Ào[D¯lòԀ@…ΣjÑ}@"å˗.\¹À3¯^œzõìÀҞb牮 Àpìºy{8“€@ƒù&,êöb@%B… +(PÀ3¯^œzõìÀÒL ‡èX8Àqâú'/{/€@‚Ôœ–9m6@'Ÿ>|ùóçÀ3¯^œzõìÀÑõËéÉÇKÀr•{¯Øžp€@°Sñžˆ@)û÷ïß¿~À3¯^œzõìÀќο’ÙÚÀs ©øFA€@€Õбí‡@,X±bŋÀ3¯^œzõìÀÑBgè1ÀsUb;2.€@~õ0~Ò@.µjÕ«V­À3¯^œzõìÀÐæ_›— ÀsusOzÂ+€@|áðÖ&Šê@0‰$H‘"À3¯^œzõìÀЊo}8µ–ÀsoÿõJg6€@zéæº+H@1·nÝ»víÀ3¯^œzõìÀÐ.æ*QÏHÀsK¶°⾀@y±Œ°5Î@2å˗.\¹À3¯^œzõìÀÏšx+áÑÀs°Z‘Ž™€@wTf6'@4(P¡B…À3¯^œzõìÀÎõ·D#ÀrÅáç¡Þ€@u¶³h¡Ø­@5B… +(PÀ3¯^œzõìÀÎEöõ2œ6ÀrlrEþ™È€@t6Šóz@6páÇÀ3¯^œzõìÀ͙«%ðxXÀr +i†•ú€@r҂¥Ì#ž@7Ÿ>|ùóçÀ3¯^œzõìÀÌñá„>EÀq¢˜i +„€@qˆ°cµÌÝ@8͛6lÙ³À3¯^œzõìÀÌLÊE^Àq5‘·ڀ@pW‰Š“ž’@9û÷ïß¿~À3¯^œzõìÀË«îù±›ÀpÆãà`3e€@nzÏ4ë@;*T©R¥JÀ3¯^œzõìÀËxéºEíÀpV»†*³‰€@lqHúÆiS@'޵@>µjÕ«V­À3¯^œzõìÀÉS!n¯âÀn‘¿ß¥ã€@g238ÜãŒ@?ãǏ|ùóçÀ3¯^œzõìÀÂýYûm:MÀc© 0ir€@RæÕ^ž@H6lÙ³fÍÀ3¯^œzõìÀ¡ä4O3’ÀcƘùÏV€@QÉHûü?Ï@H͛6lÙ³À3¯^œzõìÀÂIA©µˆÜÀbw‚»Mì€@P¿nàmµ@Idɓ&L™À3¯^œzõìÀÁó`·©7ÜÀaäät­€@OŽ2ZzZÔ@Iû÷ïß¿~À3¯^œzõìÀÁ .šfØpÀaTÇMèøÔ€@MÀ4Ñӝ%@J“&L™2dÀ3¯^œzõìÀÁO—̜ĩÀ`É¢CÜ!‹€@LH{]@K*T©R¥JÀ3¯^œzõìÀÁˆTEÛõÀ`B¿Ÿj…6€@JÚ<É»Ð@KÁƒ 0À3¯^œzõìÀÀµëþ¬ñÀ_€KC=_÷€@I ’PH¢È@LX±bŋÀ3¯^œzõìÀÀl®’ÀÀ^ƒ¢—¯)_€@G³zɐ@Lïß¿~ýûÀ3¯^œzõìÀÀ%»÷rښÀ]p™¿RŽ€@FqêŸz@M‡8páÀ3¯^œzõìÀ¿Â{ìf8À\£ßYØ£ë€@EGPË€Ëc@N2E'àÀZ&SŬ€@B?û& +åw@OãǏ(ŒU÷Hj@Q @À3¯^œzõìÀ»þ<-–Q9ÀVÀUŠ0w€@<Ñ}[±F@Qkׯ^œ{À3¯^œzõìÀ»”&”þŸ2ÀV)ƒba…Ü€@;Ca.T@Q·nÝ»víÀ3¯^œzõìÀ»,ÉÙîD/ÀU™@ç|ó€@:`^l'Z@R 0`À3¯^œzõìÀºÈþµ‹ÀU"öà€@9CEâï @RN:téÓÀ3¯^œzõìÀºeÄèŸ2ÀTŠÇŸõ݀@86•H–²œ@Rš4hÑ£FÀ3¯^œzõìÀºæ8ªk‚ÀT ÔÚ®—V€@79[ËYã@Rå˗.\¹À3¯^œzõìÀ¹šS(¯×ÀS‘÷»ØiŠ€@6Io¶Î@S1bŋ,À3¯^œzõìÀ¹LôiãK-ÀSݲI†*€@5fÁ §çÓ@S|ùóçϟÀ3¯^œzõìÀžó¶‘]’ÀRªçÏKº€@4ÌƶÞ@Sȑ"D‰À3¯^œzõìÀžœ»ö\ÀR;E×€@3ÄYh @T(P¡B…À3¯^œzõìÀžGnªû&'ÀQÎl÷äø€@3ñç¢Æ@T_¿~ýû÷À3¯^œzõìÀ·ôN'uôÐÀQc$h]ýz€@2Kõ!7‰@T«V­ZµjÀ3¯^œzõìÀ·£ ÕÓé:ÀPú¯\እ€@1œW˜ÿ@TöíÛ·nÝÀ3¯^œzõìÀ·SÛPe‰9ÀP”€3w£€@0õ6[ӄ%@UB… +(PÀ3¯^œzõìÀ·r3ža(ÀP1çIJ0€@0Vfÿïé@UŽ8páÃÀ3¯^œzõìÀ¶ºÚ(‡®çÀOŸ„o A€@/{äsS<@UÙ³f͛6À3¯^œzõìÀ¶qíŸõ<ÀNáÅ@è‰Û€@.Xð¯jŸ"@V%J•*T©À3¯^œzõìÀ¶(ð^ZíÀN(¶™“V€@-BIϚ!|@VpáÇÀ3¯^œzõìÀµâˆygþ¿ÀMtH]ÆÉ]€@,7ÐßRC@VŒxñãǏÀ3¯^œzõìÀµÅeøô²ÀLÄhq—än€@+6’ðǟ}@W @À3¯^œzõìÀµZœx õÙÀLšuú€@*@\\ör@WS§N:tÀ3¯^œzõìÀµ3ö æÀKr”(^€@)Rºü(k„@WŸ>|ùóçÀ3¯^œzõìÀŽØïQ5ì…ÀJÏRmPNۀ@(nZtå!@WêÕ«V­ZÀ3¯^œzõìÀޚVŒ¬zrÀJ0ÚÏJ@X͛6lÙ³À3¯^œzõìÀ³çVT ÀHmÔN lA€@%'zïù@Y2dɓ&À3¯^œzõìÀ³©ŒÖîbòÀH¢?P6:€@%²:€]t@Ydɓ&L™À3¯^œzõìÀ³pù~:ÀHù˜Žž€@$T}×®Y@Y°`Áƒ À3¯^œzõìÀ³7Ã_7£ÀGŒIG€@#›’sÿ“@Yû÷ïß¿~À3¯^œzõìÀ³ÀkïÀGÿ»nŠ€@"ëdØ1§@ZGÚÄàŠ²€@ Ї ö{@`¯^œzöÀ3¯^œzõìÀ®Ãµ®¢ªkÀ>LÇdìЀ@},àŽ +š@`=zõëׯÀ3¯^œzõìÀ®|ƒS«6À=Œü}惀@òâ'w(©@`cF4hÀ3¯^œzõìÀ®7«ePuÀ=;ôWvÕȀ@m°Óú-—@`‰$H‘"À3¯^œzõìÀ­òŠ‹‚0ZÀ<žÝŒý%€@í\䞟ò@`®Ý»víÛÀ3¯^œzõìÀ­¯eÁžÀ<9* š“€@q­€¥F@`Ô©R¥J•À3¯^œzõìÀ­mMUf©À;ŒŒ~÷’Ü€@úmu$ ¬@`útéÓ§NÀ3¯^œzõìÀ­,WÇOÀ;CxÓ­Ž¢€@‡iœôÌ@a @À3¯^œzõìÀ¬ì} +ωèÀ:ÍDþ©.€@rgL-@aF 0`ÁÀ3¯^œzõìÀ¬­¶9;JÀ:ZhŒöy€@Z²Ð0DÊ@akׯ^œ{À3¯^œzõìÀ¬oû×±|¶À9é ÿ!«¢€@‹èü$Œl@a‘£F4À3¯^œzõìÀ¬3GÜÉ`™À9|Ó€@ Ä4ýñWZ@a·nÝ»víÀ3¯^œzõìÀ«÷“Š2µÀ9¥‚Éh€@ IZòč@aÝ:téÓ§À3¯^œzõìÀ«ŒØúìÀ8š¶àÇÝՀ@ H܎ +ã@b 0`À3¯^œzõìÀ«ƒÓ]šÁÀ8Bß  +þ€@ ”šËeÔþ@b(Ñ£FÀ3¯^œzõìÀ«J8Z÷òÀ7ßv›üáä€@ +ækÈx‚!@bN:téÓÀ3¯^œzõìÀ«Fê±^žÀ7~hVAÓ€@ +=懇àK@bthÑ£FÀ3¯^œzõìÀªÛ8 €ÆÀ7¢Sh€@ šÝ&Ôv‚@bš4hÑ£FÀ3¯^œzõìÀª¥gٛKÀ6ÃÂÛ Ù€@ý²ìc[@bÀÀ3¯^œzõìÀªo¬âÞ9À6hŠ~¢¿€@d\ûû8À28páÄÀÕea{š€€@¥›äÐç[?òå˗.\¹À28páÄÀÕ]BÁKÑ ÀKa1X9qi€@}òäª>'@å˗.\¹À28páÄÀÕEÔýö³À[é;[KŽ€@ D‘žfk@ X±bŋÀ28páÄÀÕÕ_>>MÀc«¡¢"H€@ŒPb|udû@å˗.\¹À28páÄÀÔ蔈¬ŸÀiM‚y¢€@‹[uœ~Ò~@Ÿ>|ùóçÀ28páÄÀÔŠÝ;ëÀÀnEžmœœ€@Š5ú·äž+@X±bŋÀ28páÄÀÔZ/o¯|¶ÀqBt¢»…Ì€@ˆëöÕ.²(@ ‰$H‘"À28páÄÀÔ\dT ÀsÊÃò•-€@‡Š×ÃȆ@"å˗.\¹À28páÄÀÓ§JžêQèÀtU%;·G€@†‹h÷6Û@%B… +(PÀ28páÄÀÓDÎÌ +ö!ÀuMJžyèe€@„°,Â`ˆÖ@'Ÿ>|ùóçÀ28páÄÀÒރ‚hÆÀuóÝ}„°§€@ƒF;ÑÞ[Š@)û÷ïß¿~À28páÄÀÒuØ,²ÀvP2‰Öçʀ@æ­¶Ÿf}@,X±bŋÀ28páÄÀÒ OâcÀvq"Ý"ýk€@€—t è!@.µjÕ«V­À28páÄÀÑ¢H ;€ÀvdÏ2Ưõ€@~·šîü=@0‰$H‘"À28páÄÀÑ8Ž_€pÀv/þwÇ2€@|j>ž°&@@1·nÝ»víÀ28páÄÀÐО¹ŽzÛÀuۂ5Û²=€@zGj¥Åƒ@2å˗.\¹À28páÄÀÐjO’ª^µÀuq +$­€@xN=CëC@4(P¡B…À28páÄÀÐÁì4BÀt÷Á F«•€@v|÷d”@5B… +(PÀ28páÄÀÏH‚sÅÀtsñ§~×q€@tÑZxÆ@6páÇÀ28páÄÀΉËͱREÀséXMøFö€@sHân—΃@7Ÿ>|ùóçÀ28páÄÀÍÐFT«6Às[݋"€@qံ@8͛6lÙ³À28páÄÀÍËˀÀr˕ãÒã€@p—8ìIÿ@9û÷ïß¿~À28páÄÀÌm(µ[ÀrµjÕ«V­À28páÄÀÉå6ËØfÀpݟœÆQ€@g*tÅGGŒ@?ãǏ|ùóçÀ28páÄÀÃ7¶Ê.…ÀdnÆÃ‘€æ€@R#©ÁŒÀ@H6lÙ³fÍÀ28páÄÀÂØŸJÞ$‹ÀcÆ<‡ðD·€@QNš@H͛6lÙ³À28páÄÀÂ|גö&Àc"-&y€@Oû« +?c@Idɓ&L™À28páÄÀÂ#ìå-Àb‚ҜǬï€@N/F1@Iû÷ïß¿~À28páÄÀÁÍç§ÐˆyÀaèQ±øjŽ€@LHÚSä(-@J“&L™2dÀ28páÄÀÁz°Œ¥EéÀaRœ†õ’€@J¡÷yúGÀ@K*T©R¥JÀ28páÄÀÁ*0ÍçkåÀ`šhF€@IŽîŽV.@KÁƒ 0À28páÄÀÀÜP†”£éÀ`6f}ʬ €@G­û§í(k@LX±bŋÀ28páÄÀÀøÀŠÔ–À__ í>ŀ@F\Ú ×Äê@Lïß¿~ýûÀ28páÄÀÀHšáEçÀ^[qJ€@E$„JRIZ@M‡8páÀ28páÄÀÀ‡Ä +ìLÀ]`žé¬ã€@DK1DP@Nòè~Np@P‰$H‘"À28páÄÀœ 2bžðõÀX~¶‘¶ ƒ€@=}Žpx@PÔ©R¥J•À28páÄÀŒ™ƒ‡ÎÀWÑzžäó;€@ŸÀN€¯>婢@+@5R1@VpáÇÀ28páÄÀµ÷å„HšËÀMȪçžnƀ@*ÅåÊ;÷@VŒxñãǏÀ28páÄÀµ²_=ïÀMsŒ@Åò€@)`òG¿@W @À28páÄÀµnzrˆÖŽÀLfñ$²aՀ@(1`4ÁÆ€@WS§N:tÀ28páÄÀµ,,•6÷ÀKœ ³LäL€@'TÖ{9N@WŸ>|ùóçÀ28páÄÀŽëj°À]?ÀK£CL%€@&@WêÕ«V­ZÀ28páÄÀެ*NÌÚkÀJv£.k‡/€@%± +^ @X6lÙ³fÍÀ28páÄÀŽna%Û¥KÀIÙî{Ù¶€@$ë,>ò¬@X‚ @À28páÄÀŽ21ÇÉÀIAj1»€@$+mZ›0M@X͛6lÙ³À28páÄÀ³÷ «iÀH¬úšór©€@#qàš5Žî@Y2dɓ&À28páÄÀ³¹”:Ð)ªÀHâ’ã#€ê€@#» ¿ÁÛ@Ydɓ&L™À28páÄÀ³w!)Ÿ:ÀHR/:=P€@"çæ€Ò@Y°`Áƒ À28páÄÀ³F©l±… ÀGƒ8ÊÐ3€@":1Ó?9¢@Yû÷ïß¿~À28páÄÀ³ SÀG?ˆÑšt€@!•Ÿš¶@ZGiY¢Lb€@ aáû*I@ZÞœzõë×À28páÄÀ²o¶šÁmšÀEÃù“ÍE€@¥«œ€§¬@[*T©R¥JÀ28páÄÀ²<ÙA^KŠÀEMe¯•?t€@”—ÃS˜ù@[uëׯ^œÀ28páÄÀ² ›Ð÷ÀDڅp©Np€@ÊÀì@@[Áƒ 0À28páÄÀ±ÚQ*‹ÀDk2H“¢€@–”ISKƒ@\ 4hÑ£À28páÄÀ±ª•€·PÀCÿH–†)€@šOÆO,@\X±bŋÀ28páÄÀ±{Ô팎€ÀC–€ÝÛ~΀@ÄcŒû…²@\€H‘"DˆÀ28páÄÀ±NËsÅÀC1&\ßՀ@ê@ëm@\ïß¿~ýûÀ28páÄÀ±!&Ñ:HvÀBݞ_á€@^íòÄš@];víÛ·nÀ28páÄÀ°õ*þøÛØÀBoš¿_ü€@QB˜ÕT«@]‡8páÀ28páÄÀ°Ê ™ºŒÀBVé¢í€@‘uW2\:@]Ò¥J•*TÀ28páÄÀ°ŸÈ(s'ÀAžA"°ß`€@وÛGñ%@^z ˜¿\€@ + kTV¯@`=zõëׯÀ28páÄÀ®‹$QhÀ=î3eAi(€@‰|6UñU@`cF4hÀ28páÄÀ®E7ûïÁÀ=eÿ‚=£â€@ ¯®­ Ç@`‰$H‘"À28páÄÀ®=šRgÕÀ<á`[œnš€@–oL˜ä‰@`®Ý»víÛÀ28páÄÀ­Œž(·²êÀ<`6T±ht€@#…«2ÉG@`Ô©R¥J•À28páÄÀ­z+ÜXõ®À;âc%éëd€@i€©=E˜@`útéÓ§NÀ28páÄÀ­8Þôf#ìÀ;gÉÍ5©q€@“ß$M®@a @À28páÄÀ¬ø¯ó%OgÀ:ðN}pd€€@ ÅÌ~=|@aF 0`ÁÀ28páÄÀ¬¹—›*‘ýÀ:{֏sJô€@ þóAŠYÃ@akׯ^œ{À28páÄÀ¬{Žì²ŽµÀ: +HsÙj€@ ?}oÝ+@a‘£F4À28páÄÀ¬>#££À9›‹¥‹„2€@ …­Ë%‹@a·nÝ»víÀ28páÄÀ¬‘²ŠäÀ9/ˆQ€@ +Ò«’•û@aÝ:téÓ§À28páÄÀ«ÇE‹ À8Æ(ÄWn€@ +%·Ÿ1ËŸ@b 0`À28páÄÀ«„ºü?êÀ8_Vk¯Î€@ ~N@b(Ñ£FÀ28páÄÀ«Ti#îlYÀ7úüŸŠù€@Ü÷äÚH³@bN:téÓÀ28páÄÀ«7Á­—cÀ7™ºú€@@²·$ûê@bthÑ£FÀ28páÄÀªäëÞ^fÀ79d"yÁ€@©ˆðNm”@bš4hÑ£FÀ28páÄÀª®}†±ŽØÀ6Ûÿy†ö¶€@E=Tð@bÀÀ28páÄÀªxê,ÌEÀ6€Çø²ëõ€@‰Žæ,ÙCÀ0‰$H‘ ÀÖø?qx €€@‘<öUæœ^?òå˗.\¹À0‰$H‘ ÀÖí×ϟâ^ÀQ“.*×l‰€@‘!ï?·‰@å˗.\¹À0‰$H‘ ÀÖÎð²2©éÀa8;KŸË§€@Òtî7Í@ X±bŋÀ0‰$H‘ À֜üËóK,ÀhùÖän€@Rò gm9@å˗.\¹À0‰$H‘ ÀÖYN–¥LÀoç"YfЀ@WŽQ«çs@Ÿ>|ùóçÀ0‰$H‘ ÀÖ_")“ÀrìNÙPñ€@ÌkÞŒ—Ð@X±bŋÀ0‰$H‘ ÀÕ§±³ö_üÀu^mÿ²€@ŒåAã@ ‰$H‘"À0‰$H‘ ÀÕ>a®3+ÀwDönCÄh€@ŠNwy`@"å˗.\¹À0‰$H‘ ÀÔ̭ٛ #Àx¢E@'Š€@ˆ}ãJ+ž@%B… +(PÀ0‰$H‘ ÀÔUùIˁ ÀyŒžúüG€@†²ÿQX@'Ÿ>|ùóçÀ0‰$H‘ ÀÓÛÜÎjÀzü}Ãùu€@„ôÍÓâU}@)û÷ïß¿~À0‰$H‘ ÀÓ`{ðÀzAŒ‹È"ƒ€@ƒIõ#+@,X±bŋÀ0‰$H‘ ÀÒä4“;†5Àz%qnì,Ā@¹áIž@.µjÕ«V­À0‰$H‘ ÀÒiW–?.÷Àyք³Ÿ¥Æ€@€EO‰§†‚@0‰$H‘"À0‰$H‘ ÀÑðSoÁäÀy^F1#(€@}Þ±\T@1·nÝ»víÀ0‰$H‘ ÀÑyÓ8xÉÀxÈ8û‘€@{lÅRi»/@2å˗.\¹À0‰$H‘ ÀÑHxžãÝÀx%³t8y€@y1àÝ(; +@4(P¡B…À0‰$H‘ ÀЕøš +ïÀwkËïYE€@w*gYÎ4A@5B… +(PÀ0‰$H‘ ÀÐ) ˆÉ€Àv°Ý•À€@uRlgtªÞ@6páÇÀ0‰$H‘ ÀÏ+Ï<§Àuô'œ™’€@s¥Üa1C @7Ÿ>|ùóçÀ0‰$H‘ Àγ5§ +ëòÀu7ùpÔՀ@r Õ É~Ž@8͛6lÙ³À0‰$H‘ ÀÍî']öΑÀt~d…§ué€@p¿»þrô@9û÷ïß¿~À0‰$H‘ ÀÍ/àƒ‡0—ÀsÈÕŠl€@nþ{?ì\:@;*T©R¥JÀ0‰$H‘ ÀÌx5Ûÿ¬rÀsgñŽ],€@lži “@µjÕ«V­À0‰$H‘ ÀÊvºpýaSÀq+‹Xuæ]€@g‹ýHŠ@?ãǏô@BN:téÓÀ0‰$H‘ ÀǎgÜëR‘Àlڛù×£H€@`5ŝ{n@Bå˗.\¹À0‰$H‘ ÀÇmZSÀkÞ|ïg÷d€@]÷®wyl@C|ùóçϟÀ0‰$H‘ ÀƇ>ÿFsÀjëUìh—5€@[ë§ÁT÷Ç@D(P¡B…À0‰$H‘ ÀÆ +bx–Àj1݀@ZŸ]UV@D«V­ZµjÀ0‰$H‘ Àő@„èUMÀi Ж£õ€@XCw7ÔÊ`@EB… +(PÀ0‰$H‘ Àŏ­ËDtÀhGžcüõ†€@V¢y’ @EÙ³f͛6À0‰$H‘ ÀīȎ%ôÃÀgw×ìË"Q€@U Œœx.Ï@FpáÇÀ0‰$H‘ ÀÄ>Ãxn|éÀf°p ے"€@S»œ|ùóçÀ0‰$H‘ ÀÃoy‚9î0Àe3æ:mþ€@Q@©,B›@H6lÙ³fÍÀ0‰$H‘ Àà à »Àd|+—€€@P&ž°¿Ø@H͛6lÙ³À0‰$H‘ À­á˜ë_ÀcÊŸµÉú€@ND'!×Ãn@Idɓ&L™À0‰$H‘ ÀÂQø6yÀcûèwû€@Lb<Û @:@Iû÷ïß¿~À0‰$H‘ ÀÁù,Œ–t3Àbx÷CÂZ0€@J€¹/[–@J“&L™2dÀ0‰$H‘ ÀÁ£d`HÀaزIK³ä€@Ià€‚Ñû@K*T©R¥JÀ0‰$H‘ ÀÁP‚õðšÀa>"‡—èW€@GŒ0jù0¬@KÁƒ 0À0‰$H‘ ÀÁnÉ9YœÀ`©3ËYœ_€@F,Xæû·@LX±bŋÀ0‰$H‘ ÀÀ³ $êÀ`Êiªx€@Dç9ã .m@Lïß¿~ýûÀ0‰$H‘ ÀÀhD=ú( À_Š.Ç€@CºÞ—Å¥¬@M‡8páÀ0‰$H‘ ÀÀúÝïcZÀ^S<«[%€@B¥y Ùåõ@N ­ðž*Ú@P=zõëׯÀ0‰$H‘ Àœ¯…·5EäÀY²çÌý΀@<¥€ÃUdš@P‰$H‘"À0‰$H‘ Àœ7åÙÖ3ÀXó¥€2ìð€@;D_¶‹Ã@PÔ©R¥J•À0‰$H‘ ÀŒÃ³Æu>ÀX>³ýÍ£/€@9ûY󈚸@Q @À0‰$H‘ ÀŒRÁðÐBÀW’–ôu÷€@8Çú›5FŽ@Qkׯ^œ{À0‰$H‘ À»äêcKÑ6ÀVî­ˆ¥€@7š)IÙ@Q·nÝ»víÀ0‰$H‘ À»zô[±ôÀVRh[e€@6›3-°\Õ@R 0`À0‰$H‘ À»ôǓŠ)ÀUœBÚµ™Â€@5ž‹òÅz›@RN:téÓÀ0‰$H‘ Àº¬“UؑÀU.ÄŸ”Ð€@4±#S0=c@Rš4hÑ£FÀ0‰$H‘ ÀºIÄ:>–ÆÀTŠWžc€@3ÑŽ[œ52@Rå˗.\¹À0‰$H‘ À¹ékZaëÀT$ {{€@2ÿ×"‰y@S1bŋ,À0‰$H‘ À¹‹m‡ÃâÀS§ ŒÞj€@28AU/â)@S|ùóçϟÀ0‰$H‘ À¹/³û4*TÀS-Ãì2­€@1|@M™µû@Sȑ"D‰À0‰$H‘ ÀžÖ1°0ÀR·\Oóæ`€@0ÊB®dÅ@T(P¡B…À0‰$H‘ Àž~ÖÄñgLÀRCÒO;@€@0!‰j.@T_¿~ýû÷À0‰$H‘ Àž)—”ÈÖ²ÀQÓ"Š7S€@/Ç#ÌpD@T«V­ZµjÀ0‰$H‘ À·ÖfO ÀQeGš»N€@-Ò`Žo`Ÿ@TöíÛ·nÝÀ0‰$H‘ À·…4ïRŽ€ÀPú9¥”j؀@,°µjÅå@UB… +(PÀ0‰$H‘ À·5÷@,ÀP‘ï<þ®ˆ€@+œ².•@UŽ8páÃÀ0‰$H‘ À¶èŸ`yŽÀP,]«¶JR€@*•[Q¯Ý@UÙ³f͛6À0‰$H‘ À¶!>Œ=ÀO’ò).¯€@)™Çô©™@V%J•*T©À0‰$H‘ À¶SpÚDùÀNÒiùÓ€@(©!s +Œ_@VpáÇÀ0‰$H‘ À¶ ©ƒÀNTe#€@'¡ÚÝh°@VŒxñãǏÀ0‰$H‘ ÀµÅCèm»ÀM`ª’/€@&å’u» ü@W @À0‰$H‘ Àµ€±íÀL¯;ÈÌ"a€@&J‰‚›@WS§N:tÀ0‰$H‘ Àµ=»®{³àÀL›fÚéÀ@%E.1€@WŸ>|ùóçÀ0‰$H‘ ÀŽüX€¡uŸÀKZ«n@ç4€@$€­QÝ»@WêÕ«V­ZÀ0‰$H‘ ÀŽŒ|šŸÀJ·M±³œ{€@#ÃB¢ZŠ@X6lÙ³fÍÀ0‰$H‘ ÀŽ~Ss­¹ÀJcùôj€@# rÎ3£ƒ@X‚ @À0‰$H‘ ÀŽA0J(@ÀI}Ð"ܰç€@"[˧MqÆ@X͛6lÙ³À0‰$H‘ ÀŽ«d=!–ÀHçt@¹Gn€@!°ãjØ)Q@Y2dɓ&À0‰$H‘ À³Èm¹?hÀI«ûÕ`K€@"ýÆ~%ß@Ydɓ&L™À0‰$H‘ À³Æ,1ÀH‹VÝ3T€@!n¡¬–a@Y°`Áƒ À0‰$H‘ À³TtÀþLbÀGüö.Á€@ ͎šóж@Yû÷ïß¿~À0‰$H‘ À³nd­–ÀGsS­g‚&€@ 4BÖ<Õ/@ZGˆ9¥ÀFmwç]\€@.P8SÑ@ZÞœzõë×À0‰$H‘ À²{®¯ôçÀEðÞ5=Pe€@%VåGœ@[*T©R¥JÀ0‰$H‘ À²Hi¹InõÀEx@=Å׀@'äûn˜@[uëׯ^œÀ0‰$H‘ À²=$FŒLÀEs‘£éj€@6A¯ÍÖ@[Áƒ 0À0‰$H‘ À±å [œÊÀD’OãŠÜހ@OrŒ×ôb@\ 4hÑ£À0‰$H‘ À±µ +)Ɲ¡ÀD$®éq Á€@rÜ é-@\X±bŋÀ0‰$H‘ À±…ó,ôöÈÀCºl> h€@ŸíxÓÂ@\€H‘"DˆÀ0‰$H‘ À±WÓZ*ÝœÀCSeFŒ=Ÿ€@ÖY'œ9@\ïß¿~ýûÀ0‰$H‘ À±*£5gÜÀBïy]›€@ñ5órÜ@];víÛ·nÀ0‰$H‘ À°þ[އMŠÀBŽˆ_âzž€@[ïj ƒ@]‡8páÀ0‰$H‘ À°Òõ}˜K–ÀB0uK.þ€@ªªBç,@]Ò¥J•*TÀ0‰$H‘ À°šj_i»üÀAÕ#sÁS€@ºÝjv@^€?L­€@-¢ÎFÜ@`=zõëׯÀ0‰$H‘ À®˜bð%QŸÀ>êÆ€@§'ÍÙÈ@`cF4hÀ0‰$H‘ À®Qó'Öh­À=ŒêEh€@OWq˜@`‰$H‘"À0‰$H‘ À® ÄNÀՊÀ=Ýù”º€@sO.ép¯@`®Ý»víÛÀ0‰$H‘ À­ÈÍìUÝïÀ<„W†f‡2€@ ŸåÇ K@`Ô©R¥J•À0‰$H‘ À­†Ó[+¡À<7¹C+€@ Ó|,1>¥@`útéÓ§NÀ0‰$H‘ À­Dj²v'À;‰`³õ +Ҁ@ »ø¹U@a @À0‰$H‘ À­í.J¡!À;µÛCNn€@ Pø‡ù‚Ë@aF 0`ÁÀ0‰$H‘ À¬Ä‰€7ðÀ:›Æà>ñ€@ +™äóÀŸô@akׯ^œ{À0‰$H‘ À¬†8añëÀ:(x2A#-€@ é5ڗŒ@a‘£F4À0‰$H‘ À¬Hò…¯$xÀ9ž±îPl€@ >¡bU”@a·nÝ»víÀ0‰$H‘ À¬ ±gîäÀ9K°Óì™,€@™æo P6@aÝ:téÓ§À0‰$H‘ À«Ñn™ TÀ8á]·''œ€@úÄXu-@b 0`À0‰$H‘ À«—#ß ýÀ8y¢[7Ž:€@`ýrӟŸ@b(Ñ£FÀ0‰$H‘ À«]Ë3Z!þÀ8igCn€@ÌXáÕûm@bN:téÓÀ0‰$H‘ À«%^ÀáÃÀ7±žZÀ`܀@<ŸŽhe€@bthÑ£FÀ0‰$H‘ ÀªíØáø|ÓÀ7Q-„õpQ€@±hfZ[@bš4hÑ£FÀ0‰$H‘ Àª·4–À6óù§2I€@+ ô‘²u@bÀÀ0‰$H‘ Àªk*o)À6—ˆÐð€@šû&_jôÀ-ëׯ^œxÀØÎæH‰…Ä€€@”:œƒ“™O?òå˗.\¹À-ëׯ^œxÀØÁ[',%ÕÀVÿ-B­Óœ€@”*Òe¡ +@å˗.\¹À-ëׯ^œxÀؙŸ’pÀf^GªR‘€@“€ø…øíÛ@ X±bŋÀ-ëׯ^œxÀØXHg þ×Àp*Ôöå€@’òNâmh‚@å˗.\¹À-ëׯ^œxÀØtŽc%•Àtw‡kO€Ô€@’ ûŽîñ@Ÿ>|ùóçÀ-ëׯ^œxÀט({Xu°ÀxÀ +‰[X€@üi7jŸù@X±bŋÀ-ëׯ^œxÀ×ä&Pv^ÀzÅ2ŒœH€@²Áò$@ ‰$H‘"À-ëׯ^œxÀ֜ii©"MÀ|Ãë¯ ý«€@] @6úá@"å˗.\¹À-ëׯ^œxÀÖ.úÖøÀ~"Jû +L€@‹ éAf³Ø@%B… +(PÀ-ëׯ^œxÀՁ'‚%!À~Ñ·#V!܀@ˆÍa9.@'Ÿ>|ùóçÀ-ëׯ^œxÀÔîªÖÄáyÀ© Ÿèê€@†ª!Ïu«¯@)û÷ïß¿~À-ëׯ^œxÀÔ[Ì/®xßÀ~ûðr"­º€@„š›\ëøÂ@,X±bŋÀ-ëׯ^œxÀÓÊjVáŸÀ~„Žàtå_€@‚΀ߗÁn@.µjÕ«V­À-ëׯ^œxÀÓ;ĉùUÀ}×î$’Í€@ž Œ(-@0‰$H‘"À-ëׯ^œxÀÒ°°DÔÀ}‹Î˜.X€@*^éÎÙM@1·nÝ»víÀ-ëׯ^œxÀÒ)ÅÎt=áÀ|Àóñeŀ@|eîp5Q@2å˗.\¹À-ëׯ^œxÀѧ[ôu:À{>Ә{{€@yèQ`1Ø@4(P¡B…À-ëׯ^œxÀÑ)š[~i Àz­' 2€@w«—¶@5B… +(PÀ-ëׯ^œxÀа‰„ ‚Ày!œ_;²Ë€@u©¬ æÃÊ@6páÇÀ-ëׯ^œxÀÐ<º@ÜÃÀx(@ò}€@sÜO­ïœ}@7Ÿ>|ùóçÀ-ëׯ^œxÀϘzš•¢ýÀw5o=q(€@r=Փòå@8͛6lÙ³À-ëׯ^œxÀÎÁŠý§<”ÀvJšîTA€@pÉ3—ÛF@9û÷ïß¿~À-ëׯ^œxÀÍóÿiÀuhËÿÜòc€@nóäUEˆ@;*T©R¥JÀ-ëׯ^œxÀÍ,ÕÁí§Àt‘$ì a€@l—âK­ÌL@µjÕ«V­À-ëׯ^œxÀËâÔmˆÀrK£.¬du€@fŒZ,o@?ãǏ×Ҍ§/€@[Vc™?Y@D(P¡B…À-ëׯ^œxÀÆZäÌÆ7Àk;wŸJŸJ€@YFœ ± @D«V­ZµjÀ-ëׯ^œxÀÅÜ)ÅÑåôÀjB»TÉ\€@WIæöF@EB… +(PÀ-ëׯ^œxÀÅbPÝIë¡ÀiTjèˆB€@U€òtÔ/ƒ@EÙ³f͛6À-ëׯ^œxÀÄìÇ@6ÿÀho–ßnf€@T!=±ÄT,@FpáÇÀ-ëׯ^œxÀÄ{]€ +íxÀg”Ò®é2€@RŒ°@ŒÙc@G @À-ëׯ^œxÀÄ ê*&)þÀfÁÉá¡ë€@Qu,sšŠ@GŸ>|ùóçÀ-ëׯ^œxÀÀLÏ#wÀeõÊÊ_ø€@PHGTçÀ$@H6lÙ³fÍÀ-ëׯ^œxÀÃ>fí'KzÀe/—Gå*€@Ngiw)À@H͛6lÙ³À-ëׯ^œxÀÂÜø²šÀdo¯Ï•$—€@Lj¿jÉV;@Idɓ&L™À-ëׯ^œxÀÂ}BûŽqiÀc·øŽ^i€@J–Ê0!mÞ@Iû÷ïß¿~À-ëׯ^œxÀÂ!ÄõØ*€ÀcQšCj€@Hè0š‹@J“&L™2dÀ-ëׯ^œxÀÁÉ'@TÀbZ0š  €@G[œž”%£@K*T©R¥JÀ-ëׯ^œxÀÁtRIä)¹Àaµü%ª@€@Eîuþ~òó@KÁƒ 0À-ëׯ^œxÀÁ"ŒúÀasÑ;€@Dž÷º¢@LX±bŋÀ-ëׯ^œxÀÀÒÉ¥(võÀ`ƒSÁ¡j€@Ch,çÏZŒ@Lïß¿~ýûÀ-ëׯ^œxÀÀ†3¶€À_ۃ „ë€@@P æ“ʃ@NµjÕ«V­À-ëׯ^œxÀ¿_yÕTxåÀ\Âicvµ€@>àÜ%98@OL™2dɒÀ-ëׯ^œxÀŸÙʄɑÀ[מ,R.€@=Bڞ@Þ@OãǏ|ùóçÀ-ëׯ^œxÀµ ¹ü¢ÜÀK˜5ÆyÙ¬€@"™pûp‡@WêÕ«V­ZÀ-ëׯ^œxÀŽËOUÃè2ÀJòНK®±€@!ëÍsäÆà@X6lÙ³fÍÀ-ëׯ^œxÀތfNŠAÀJQ²3þý€@!Dž‘ñŸ@X‚ @À-ëׯ^œxÀŽNô-ï-ËÀIµ6äF€@ £ó=Fþ@X͛6lÙ³À-ëׯ^œxÀŽîˆY‹ÀI¡ÇE£€@ äяH¿@Y2dɓ&À-ëׯ^œxÀ³Ö‰î0yÀIVF> N€@ †1Ć8@Ydɓ&L™À-ëׯ^œxÀ³šçí¥j:ÀHÀ0¥–²€@ÔÏŽÐ@Y°`Áƒ À-ëׯ^œxÀ³a¿ +ºÇÀH/;„ù€@¬¡àó9@Yû÷ïß¿~À-ëׯ^œxÀ³(¢'€€ÑÀG£,b—™€@’âñçf@ZGÉá0%v€@ú}î.j@^µjÕ«V­À-ëׯ^œxÀ°4áUÔëkÀ@ê$sƒc,€@qHc‚òè@_ À-ëׯ^œxÀ° N?eFÀ@—çÞ·¬š€@튢U[@_L™2dɒÀ-ëׯ^œxÀ¯ÌõS’ÉùÀ@Gýh©%€@nþ®æ^@_˜0`ÁƒÀ-ëׯ^œxÀ¯€Âi—œÀ?ô›‡ˆ€@êÆo}û@_ãǏ@`¯^œzöÀ-ëׯ^œxÀ®ìPAzŸÀ>ʞe²€@ ,Yu¹@`=zõëׯÀ-ëׯ^œxÀ®€}Šò€À>;°ZEÝz€@ G±ŽtÝ@`cF4hÀ-ëׯ^œxÀ®]·Òi À=°œ™!G€@ wmÆ7ñ|@`‰$H‘"À-ëׯ^œxÀ®67ixAÀ=)?¶·=€@ ®ãþ·>@`®Ý»víÛÀ-ëׯ^œxÀ­Ó𭆕À<¥x/Ÿ£±€@ +ížvN@`Ô©R¥J•À-ëׯ^œxÀ­Ý.\ïXÀ<%%õßHˀ@ +3”jª®f@`útéÓ§NÀ-ëׯ^œxÀ­Nõh±ŽúÀ;š*]¯™t€@ €%â™Ð&@a @À-ëׯ^œxÀ­1 +ÈNVÀ;.h ja €@Óbù.@aF 0`ÁÀ-ëׯ^œxÀ¬Îˆ˜›r^À:·Ââƒ'Ā@,7 Wq!@akׯ^œ{À-ëׯ^œxÀ¬ô×#õsÀ:Dõ}±ó€@‹)C‘@a‘£F4À-ëׯ^œxÀ¬Rnɟš5À9ÓevÑv€@ï²¢O_@a·nÝ»víÀ-ëׯ^œxÀ¬ï®ùýÀ9ezªš.5€@Y•‡‚Žg@aÝ:téÓ§À-ëׯ^œxÀ«ÚpÿU„À8úGÙq5؀@Ȗþ”p×@b 0`À-ëׯ^œxÀ«ŸìiŽA„À8‘¶C/t¹€@<J±(@b(Ñ£FÀ-ëׯ^œxÀ«f[ÑœÁÀ8+°‚š€@µÚï.@bN:téÓÀ-ëׯ^œxÀ«-¹Mœ·dÀ7È VXÊ$€@21Ú9"@bthÑ£FÀ-ëׯ^œxÀªõÿ$}ÁÀ7fòí?”·€@³šJpp…@bš4hÑ£FÀ-ëׯ^œxÀª¿'Ê)ß,À7…KÛÀ€@9$Œ[B÷@bÀÀ-ëׯ^œxÀª‰-âÀ6«r…€@Â¥ÿµyÀ*ŋ,X°ÀÚú+=GŽŠ€€@˜}Þnž?òå˗.\¹À*ŋ,X°ÀÚè6è.<À^Ä <}å€@—Ξ@×x}@å˗.\¹À*ŋ,X°ÀÚ²€˜{ÿ²Àm»<{z"€@—+ÜÚÊø @ X±bŋÀ*ŋ,X°ÀÚ\ž°§ŸÀuaÃ䱀@–)‘Á΢•@å˗.\¹À*ŋ,X°ÀÙêuJLµIÀzÀŠëÄF(€@”ß\×9CM@Ÿ>|ùóçÀ*ŋ,X°ÀÙaÐ)arÀ~ë=†iQ€@“hÓY}jÒ@X±bŋÀ*ŋ,X°ÀØÈ+­ºn +À€÷~Y$H€@‘ßLkb û@ ‰$H‘"À*ŋ,X°ÀØ"áE#ÀÀðÂÇOd€@Uêšó²@"å˗.\¹À*ŋ,X°À×vƒøßBÑÀ‚zØœ_ €@²|ÔÔ,V@%B… +(PÀ*ŋ,X°ÀÖÆËãžm@À‚«•É€@Šã«TP‚@'Ÿ>|ùóçÀ*ŋ,X°ÀÖ…P.&À‚šÓè#¯®€@ˆHÉQi@)û÷ïß¿~À*ŋ,X°ÀÕhÙºsAÀ‚JmwŸ€‚€@…æ$Âj;@,X±bŋÀ*ŋ,X°ÀÔœi‡EõÀÍ•ŽxaZ€@ƒœXí~B@.µjÕ«V­À*ŋ,X°ÀÔèQÅÁŠÀ7Y +U€@Íåý‚@0‰$H‘"À*ŋ,X°ÀÓxCGOîÀ€‘BÛZh4€@€£éb€@1·nÝ»víÀ*ŋ,X°ÀÒÞêÔEá„ÀÆ©¶ÇA€@}9¿.b<@2å˗.\¹À*ŋ,X°ÀÒLÃPsÀ~gԉ#݀@zRçá°#@4(P¡B…À*ŋ,X°ÀÑ¿–Äï +ZÀ}gÉ3€@wåÜî†q}@5B… +(PÀ*ŋ,X°ÀÑ9nŵÉ[À{¿u\G€@uÀú¿‰q*@6páÇÀ*ŋ,X°ÀйT÷(§©Àz~ 6ò¯€@sÚ< |±õ@7Ÿ>|ùóçÀ*ŋ,X°ÀÐ?Õk ¿ÀyKù + €@r)¥œÑ J@8͛6lÙ³À*ŋ,X°ÀϔlÞeˆëÀx)FçûÔô€@pše¶åÞ@9û÷ïß¿~À*ŋ,X°ÀεBS krÀwáZžÝý€@n¡PÊD…®@;*T©R¥JÀ*ŋ,X°ÀÍßö\Þ­ŽÀv¢á2€@l:pˆÖ¹q@µjÕ«V­À*ŋ,X°À˔ä=aÙ«Àso¯„?b€@fPÅ¥¢ˆ@?ãǏz@D«V­ZµjÀ*ŋ,X°ÀÆ#Þ'š>åÀkgöz;?€@V6+‚ß<Ä@EB… +(PÀ*ŋ,X°ÀÅ€Óœ6ìÅÀjaË% ýJ€@TiñŠ9@EÙ³f͛6À*ŋ,X°ÀÅ*„Ã÷|Àig9¢¶€@S 2™ÿåµ@FpáÇÀ*ŋ,X°ÀÄŽŒ¬¹Àhwið€S€@Qª|4ª@G @À*ŋ,X°ÀÄCH<—2ýÀg‘!¡Lø/€@PhºÐoã@GŸ>|ùóçÀ*ŋ,X°ÀÃÖ;ÐÿùÀf²ü4®Ç€@N„?£ðL@H6lÙ³fÍÀ*ŋ,X°ÀÃlÀ^g4CÀeÝ +Îè+±€@Lk„²÷ˆ}@H͛6lÙ³À*ŋ,X°ÀÃ^êIŠÀeFкc—€@J ›â»@Idɓ&L™À*ŋ,X°ÀÂ¥¶rÒ1ÔÀdI–Ÿ\ºf€@HÀin4ü@Iû÷ïß¿~À*ŋ,X°ÀÂG¡')àSÀc‹Ó=©,“€@G%ŸŽˆ÷r@J“&L™2dÀ*ŋ,X°ÀÁìú 1yÀbÕÊ€<–h€@E­!ã‚M@K*T©R¥JÀ*ŋ,X°ÀÁ•.~>Àb'CÛx¶2€@DSÀ²Ö¯®@KÁƒ 0À*ŋ,X°ÀÁAgn"Àa€ªçÆl€@CŸ÷þÍY@LX±bŋÀ*ŋ,X°ÀÀð7SٜÀ`ß¿‡]˜œ€@Aó.Û'@Lïß¿~ýûÀ*ŋ,X°ÀÀ¡ìJNÈ»À`F<ÿ&7€@@çAû\É@M‡8páÀ*ŋ,X°ÀÀVfýZ[À_fÇÿo©)€@?à¥-ùK@Nśý®@NµjÕ«V­À*ŋ,X°À¿Ž7ý À]M+L™Œ€@|ùóçÀ*ŋ,X°Àµµ&M,®ÀKÐvš,w€@ êŒûÍ Ö@WêÕ«V­ZÀ*ŋ,X°ÀŽØÈ Ãv¯ÀK(áT¯€@ L†Eô/@X6lÙ³fÍÀ*ŋ,X°ÀŽ™a)ÀJ† +Ò74?€@h7âYÒ@X‚ @À*ŋ,X°ÀŽ[uxÅ€0ÀIçÏb³•Ÿ€@AÑuß×:@X͛6lÙ³À*ŋ,X°ÀŽúA`YÀIN î™s.€@%ÎFpÏ@Y2dɓ&À*ŋ,X°À³âp~‘9%ÀI‰ qßþ€@ÒM4ð6%@Ydɓ&L™À*ŋ,X°À³ŠÓËWFÀHð€P?€@·KRÚz@Y°`Áƒ À*ŋ,X°À³l™U’>ÀH])ˆ0:ª€@ªe©¥ßI@Yû÷ïß¿~À*ŋ,X°À³3ŽéÈøÀGÎއ‡Iq€@ª¹quÒI@ZGÀBÖžÜÐ"€@3“B\@^í•>';€@ @É[ö@`=zõëׯÀ*ŋ,X°À®¯ožmÂ[À>]UŸ“šý€@ +QOtè@`cF4hÀ*ŋ,X°À®h[ê|À=Ñ$ñـ@ •NK¯{@`‰$H‘"À*ŋ,X°À®":_— À=Hp{^Œ€@à€Èð„ù@`®Ý»víÛÀ*ŋ,X°À­ÞÍ>,nÀ<ÄeJV$€@2_– +@`Ô©R¥J•À*ŋ,X°À­šš1›ŒÀ@`útéÓ§NÀ*ŋ,X°À­X}AÚï§À;ÄÿÏ̀@èçŠešñ@a @À*ŋ,X°À­x ÷4 À;IT9_!€@MÒr#r@aF 0`ÁÀ*ŋ,X°À¬×‘7\2À:Ñ»ô™@ø€@¶£!\7Ÿ@akׯ^œ{À*ŋ,X°À¬˜Á/ðYLÀ:]0š#o߀@%Š Œ@a‘£F4À*ŋ,X°À¬[õC£—À9ë—ô“êj€@™Šb·U2@a·nÝ»víÀ*ŋ,X°À¬I®àõ!À9|ؕ?區@[*¿vL@aÝ:téÓ§À*ŋ,X°À«â”ŸÃŸÐÀ9ÚR'›€@ÌXœ–ù@b 0`À*ŋ,X°À«§ÛŸìr|À8§…ò®T €@«+ÞTâ@b(Ñ£FÀ*ŋ,X°À«nZ À8@Å1æg€@—Ç€šUP@bN:téÓÀ*ŋ,X°À«5EºJÀ7܂®É5€@!óšm 5@bthÑ£FÀ*ŋ,X°Àªý[}éÞ§À7z©èýZ‡€@°DRt@bš4hÑ£FÀ*ŋ,X°ÀªÆVU€¬À7'.h2€@AÐ!УÎ@bÀÀ*ŋ,X°Àª0 |åÀ6œç•1õ€@×0dúaÀ'Ÿ>|ùóèÀݔ® MAø€€@,ãv‰w?òå˗.\¹À'Ÿ>|ùóèÀÝ|O5MžÀe;§>|·]€@œÖ£·Ð2@å˗.\¹À'Ÿ>|ùóèÀÝ2„ñn Àt_(¢R<ó€@›Û>œŠüè@ X±bŋÀ'Ÿ>|ùóèÀÜœ(­äÈ~À} )^P€@šUI`7‰'@å˗.\¹À'Ÿ>|ùóèÀÜ#sìnœÀßFxPíü€@˜omøÔô‘@Ÿ>|ùóçÀ'Ÿ>|ùóèÀÛn9’žÀ„Eçñö€@–VSlC”¢@X±bŋÀ'Ÿ>|ùóèÀÚ§)N‹òÀ…Ìs—Æ@€@”3…ɑè@ ‰$H‘"À'Ÿ>|ùóèÀÙÔî-UlßÀ†˜è¿^pà€@’#p0Œë@"å˗.\¹À'Ÿ>|ùóèÀØýÔÀ_— À†Ùzž;€@6~mµª@%B… +(PÀ'Ÿ>|ùóèÀØ&o[ww@À†¯õª4C€@Œçž`2M6@'Ÿ>|ùóçÀ'Ÿ>|ùóèÀ×Qï.£ÿÄÀ†@ŠqÚ#5€@‰Ÿ€ÝW$@)û÷ïß¿~À'Ÿ>|ùóèÀւÀÚÊ©À…íÄC§C€@†î’² Ö¡@,X±bŋÀ'Ÿ>|ùóèÀÕºß~YBjÀ„¹8ÇÂn'€@„qöjôß@.µjÕ«V­À'Ÿ>|ùóèÀÔûYñ "ÀƒÏÑGÅ€@‚AH›Ÿ$@0‰$H‘"À'Ÿ>|ùóèÀÔD¥rd À‚Ü0éàj€@€T âzÿ÷@1·nÝ»víÀ'Ÿ>|ùóèÀӖðÓµÙÀèߊaB€@}HŸnÜ@2å˗.\¹À'Ÿ>|ùóèÀÒòɲ_À€ûNuh߀@zT°?Œ€Ê@4(P¡B…À'Ÿ>|ùóèÀÒUé{‚»óÀ€w^gö€@wÁOÀ;”‘@5B… +(PÀ'Ÿ>|ùóèÀÑÁæ?Ñ À~ÁŠe£€@uÛÃ8œô@6páÇÀ'Ÿ>|ùóèÀÑ5–§ôïÀ|ë[%3:€@s‰ñŸIlH@7Ÿ>|ùóçÀ'Ÿ>|ùóèÀа{ékÇüÀ{qâšç{ž€@qÏ +0æWH@8͛6lÙ³À'Ÿ>|ùóèÀÐ2/iXÀzEÕ÷¿Ç€@pHn”ÛkQ@9û÷ïß¿~À'Ÿ>|ùóèÀÏtIÍÙÜÀxÇú>Ë \€@mݺžÅZ@;*T©R¥JÀ'Ÿ>|ùóèÀΏ EՖƒÀw—O¬;€@kx IŒ÷ª@|ùóèÀ͵òz{RzÀv€6L¿žq€@iR¡ª¹=&@=‡8páÀ'Ÿ>|ùóèÀÌæ# .ÏÀuÔçvã€@gaý™P² @>µjÕ«V­À'Ÿ>|ùóèÀÌkV`*šÀt“µAéŀ@eœþ›Ü&þ@?ãǏ|ùóèÀËaœî£iÀs·„Æõz€@cüY‘ၷ@@‰$H‘"À'Ÿ>|ùóèÀʪ¡=KÔÀrë2\ø?B€@bz3åZŒ&@A @À'Ÿ>|ùóèÀÉûnøŠEÀr,l+Æ(“€@aՎù‘ @A·nÝ»víÀ'Ÿ>|ùóèÀÉS‹Àqv¹QD‘_€@_€ Ln>@BN:téÓÀ'Ÿ>|ùóèÀȱUßV<œÀpÆÆ ¬æ€@]Ë€5n@Bå˗.\¹À'Ÿ>|ùóèÀÈøK1A²ÀpUM€@Zœès·ñ@C|ùóçϟÀ'Ÿ>|ùóèÀǀÀ0áw•Ànó€€2{€@Xž°•4ò<@D(P¡B…À'Ÿ>|ùóèÀÆñoÒÅ÷Àmº«íÑ׀@V©þZx9@D«V­ZµjÀ'Ÿ>|ùóèÀÆgÈéDŒžÀlâ +_¥ý€@TÞZNƒ@EB… +(PÀ'Ÿ>|ùóèÀÅ㍏&k·Àkn¬#€@S:Eñ”@EÙ³f͛6À'Ÿ>|ùóèÀÅd€áæÏ¥Àj\4;{¬g€@Q»^· óì@FpáÇÀ'Ÿ>|ùóèÀÄêg}Bš€ÀiVµBՀ@P`SË}šŽ@G @À'Ÿ>|ùóèÀÄu #iÀh[@zø¡š€@NMdàܗ@GŸ>|ùóçÀ'Ÿ>|ùóèÀÄ3„˜èÀgjªä]@6€@L,"†ŸS@H6lÙ³fÍÀ'Ÿ>|ùóèÀ×¶ž—@¬Àf„1Bžڀ@JjÒøHÕ@H͛6lÙ³À'Ÿ>|ùóèÀÃ/c*á:~Àe§•rJÙ?€@HBVÌZw@Idɓ&L™À'Ÿ>|ùóèÀÂË 7(ÖMÀdԍ‘.©ï€@FšÔ†&¿c@Iû÷ïß¿~À'Ÿ>|ùóèÀÂj‚B| ûÀd +ǯ4Y@€@E`Ô¿å @J“&L™2dÀ'Ÿ>|ùóèÀ \CnÀcIì­u}‘€@C¹ý±]n–@K*T©R¥JÀ'Ÿ>|ùóèÀÁŽ37;oÎÀb‘¢xò,›€@By!š`_N@KÁƒ 0À'Ÿ>|ùóèÀÁ^30‹ÀaáÂ†Bg€@AS©-V¹@LX±bŋÀ'Ÿ>|ùóèÀÁ 2_’ +»Àa9SOa™€@@FÊ©õÒ@Lïß¿~ýûÀ'Ÿ>|ùóèÀÀ»Qx  À`˜˜÷Å+€@> 2mŠê@M‡8páÀ'Ÿ>|ùóèÀÀnVÆíðÀ_þkÍÁ^O€@<Úiãè#@N|ùóèÀÀ$ùRÙuÀ^ÝŽgå@;8§Ûs@NµjÕ«V­À'Ÿ>|ùóèÀ¿žëÞÀpüÀ]ζÌ> €@9·_d †!@OL™2dɒÀ'Ÿ>|ùóèÀ¿.y6@»5À\ÏîÕ|Ý­€@8S5í§@OãǏ|ùóèÀŸš–œçv¥À[à_&h€@7 +I 9çâ@P=zõëׯÀ'Ÿ>|ùóèÀŸ'Á™IÿÀZý¿<§F€@5ÙG²µAž@P‰$H‘"À'Ÿ>|ùóèÀœ©yÁ-K’ÀZ(ÔÑـ@4ŸF”»$L@PÔ©R¥J•À'Ÿ>|ùóèÀœ/țéµ|ÀY]óòžÏ€@3·J0šr­@Q @À'Ÿ>|ùóèÀŒ¹ž»(’ãÀXž‰ 1„¿€@2‰®}Z@Qkׯ^œ{À'Ÿ>|ùóèÀŒG„ÿ³{ÀWéá”€@1Þi73Å @Q·nÝ»víÀ'Ÿ>|ùóèÀ»×œümÀW<ŠcõҀ@1 uëlØ@R 0`À'Ÿ>|ùóèÀ»k|j'¯ÅÀV˜Ë"cÙå€@0B]·Z\@RN:téÓÀ'Ÿ>|ùóèÀ».°yÀUüÖÝN;‰€@/âKoî@Rš4hÑ£FÀ'Ÿ>|ùóèÀº›®óçšúÀUh=9³Ì€@-²;˜ËÞC@Rå˗.\¹À'Ÿ>|ùóèÀº7Ýõ¡ÀTÚ~BgՀ@,iÒFŠ+@S1bŋ,À'Ÿ>|ùóèÀ¹ÖšpÁèÀTSÝUÛ,€@+4ßÐö¬W@S|ùóçϟÀ'Ÿ>|ùóèÀ¹wË;¶ÀSÐLÞ÷Z€@*ÌÓÝ®@Sȑ"D‰À'Ÿ>|ùóèÀ¹]†à3ÀSQŠ#:L€@(ÿ,äaï@T(P¡B…À'Ÿ>|ùóèÀžÁ@tD5ÀRÕgû͎æ€@'ûµ8äoC@T_¿~ýû÷À'Ÿ>|ùóèÀžicv÷8ØÀR]2Îޫɀ@'8—áû@T«V­ZµjÀ'Ÿ>|ùóèÀž¶T@vÀQècr·Ìž€@&  9@TöíÛ·nÝÀ'Ÿ>|ùóèÀ·À)-ZßÀQvæp¶rà€@%@îv¶@UB… +(PÀ'Ÿ>|ùóèÀ·n¬CÕÐÀQ§šPÏ®€@$o8œRv @UŽ8páÃÀ'Ÿ>|ùóèÀ·1(u}üÀP’‚R¿€@#§šr<ý@UÙ³f͛6À'Ÿ>|ùóèÀ¶Ñši#±ÞÀP5’nF(€@"éxÞ§º7@V%J•*T©À'Ÿ>|ùóèÀ¶†ä“òÍÀO¡"ÜR]º€@"3ô»]c…@VpáÇÀ'Ÿ>|ùóèÀ¶<5¡…úÁÀNÜöÐt€@!†uuSè<@VŒxñãǏÀ'Ÿ>|ùóèÀµô0 +òñÀNvË?€@ àaÍ _@W @À'Ÿ>|ùóèÀµ­åë'?ÀMewW÷8€@ A,³Ì‘Z@WS§N:tÀ'Ÿ>|ùóèÀµiJs~ÃàÀL±Ð™—ÉB€@Pš‡0î@WŸ>|ùóçÀ'Ÿ>|ùóèÀµ&Q2|³8ÀLZZæY€@*Áœˆ|(@WêÕ«V­ZÀ'Ÿ>|ùóèÀŽäî¢A÷ÀKYëgÍP€@Ȫ@X6lÙ³fÍÀ'Ÿ>|ùóèÀŽ¥kÞþIÀJµ]œaK܀@þðHµSƒ@X‚ @À'Ÿ>|ùóèÀŽf»ÙŽgxÀJŠHsí€@÷~­Ñ€"@X͛6lÙ³À'Ÿ>|ùóèÀŽ)Öa8€LÀIzKî²'.€@øÅíJœ›@Y2dɓ&À'Ÿ>|ùóèÀ³íˆqï¯ÀI¶ý@Ã`’€@ƒ…ú»»’@Ydɓ&L™À'Ÿ>|ùóèÀ³±ÎÁ’}ÀI GðF €@†já€\°@Y°`Áƒ À'Ÿ>|ùóèÀ³v㋎ƒÀH†ŒG°ð€@–¥ÄŒ,@Yû÷ïß¿~À'Ÿ>|ùóèÀ³=ŸKñˆ–ÀGö9ȕò~€@±¡È3ª@ZG|ùóèÀ³ª‘ñòEÀGjÙ[Qš€@ØDÕٟ@Z“&L™2dÀ'Ÿ>|ùóèÀ²Îùvÿ†ðÀFä0ª"­ž€@ r{O@ZÞœzõë×À'Ÿ>|ùóèÀ²™(ÙYÀFb +“£X€@DxŸest@[*T©R¥JÀ'Ÿ>|ùóèÀ²e7ÅŒÀEä3Ÿ5Ì €@ˆ¿™@[uëׯ^œÀ'Ÿ>|ùóèÀ²2K›„ÀEj{yû‚Œ€@պȍDã@[Áƒ 0À'Ÿ>|ùóèÀ²j}éÁÀDôŽm2±€@*èšÍž@\ 4hÑ£À'Ÿ>|ùóèÀ±Ï CK)’ÀD‚³6`’!€@‡Îr»µ?@\X±bŋÀ'Ÿ>|ùóèÀ±ŸT”$ÀDNÞܪ€@ëýM- l@\€H‘"DˆÀ'Ÿ>|ùóèÀ±p-žx6ÀC©`–ݝ€@W É"ïÒ@\ïß¿~ýûÀ'Ÿ>|ùóèÀ±B6ÄnØIÀCAÓ2ž&€@Ș+ŽçÅ@];víÛ·nÀ'Ÿ>|ùóèÀ±0ã¹/íÀBÝTím4N€@@GGÛÆA@]‡8páÀ'Ÿ>|ùóèÀ°éŸŸŸÀB{ó†Eç6€@{‡õ0F@]Ò¥J•*TÀ'Ÿ>|ùóèÀ°œÚè9ÌÀBêC€€@}eéÚÝ@^|ùóèÀ°“|ön>gÀAÁÜ7U‰N€@ ‘Ú i…œ@^iÓ§N:À'Ÿ>|ùóèÀ°iôFÆ`ÀAhìŽÄþ€@ ¬‰-pT@^µjÕ«V­À'Ÿ>|ùóèÀ°A:˜8í|ÀA”T[@ù€@ Ï€Z…«ë@_ À'Ÿ>|ùóèÀ°Iç¶¢yÀ@Ÿ»l’“)€@ +ü P,@_L™2dɒÀ'Ÿ>|ùóèÀ¯ä8ÚÛä‚À@mHÖüL€@ +0ñé'‹e@_˜0`ÁƒÀ'Ÿ>|ùóèÀ¯—Y3ïcŠÀ@%ED#Š€@ mÎö|NS@_ãǏ|ùóèÀ¯Kê'V„À?¢uB†€@²FéŽÁ/@`¯^œzöÀ'Ÿ>|ùóèÀ¯áh2õyÀ? æÃ&mþ€@ýúwïÑè@`=zõëׯÀ'Ÿ>|ùóèÀ®¹5 WMèÀ>{wdM…Ž€@Põ£€Y@`cF4hÀ'Ÿ>|ùóèÀ®qۂÈ+À=î^V₀@©²ó)ìc@`‰$H‘"À'Ÿ>|ùóèÀ®+˙|‡$À=d\瓁l€@ á+§€@`®Ý»víÛÀ'Ÿ>|ùóèÀ­æüoY âÀ<ÞiÝÓI€@ngŒ8œ@`Ô©R¥J•À'Ÿ>|ùóèÀ­£eubÏÙÀ<\¯µR€@ÙgŸèÇ$@`útéÓ§NÀ'Ÿ>|ùóèÀ­`þj%ÞŽÀ;ÝG^ŠÐ€@IÑõž.@a @À'Ÿ>|ùóèÀ­¿VLŸÎÀ;ajö‚L€@¿d±ÇùÙ@aF 0`ÁÀ'Ÿ>|ùóèÀ¬ß ‰dÙðÀ:èøcÌ«€@9æáM@akׯ^œ{À'Ÿ>|ùóèÀ¬ š–ÏžÙÀ:sœxX՗€@¹?—ÊL@a‘£F4À'Ÿ>|ùóèÀ¬bŠRÚ1À:|ùóèÀ¬%ŒÏû[ÐÀ9‘Ÿ,®} €@Äßäíòó@aÝ:téÓ§À'Ÿ>|ùóèÀ«é×\6ZÀ9% _" €@QƺÁ7@b 0`À'Ÿ>|ùóèÀ«®ï~Ÿ ŠÀ8»BkÁ€@ᶈt[@b(Ñ£FÀ'Ÿ>|ùóèÀ«tþôûròÀ8Sž*)F€@tù°~šé@bN:téÓÀ'Ÿ>|ùóèÀ«;ÿ±†ó À7î»W97€@ så…à@bthÑ£FÀ'Ÿ>|ùóèÀ«ëØÎ±"À7ŒHì%8à€?ÿNÉ2ÇK@bš4hÑ£FÀ'Ÿ>|ùóèÀªÌœ¿©µÀ7,2â9Ãڀ?þ‹NnI«@bÀÀ'Ÿ>|ùóèÀª–oéIãÇÀ6Îeÿ86³€?ýÎ0`E{À$xñãǏÀàe]&õ€€@¢E܇lÁW?òå˗.\¹À$xñãǏÀàSwHÝdÅÀn¢eˆ"¥ù€@¡ûQ¥·‰Z@å˗.\¹À$xñãǏÀàœ¶ŸDÀ}$Ð9$G€@¡,@rËÛð@ X±bŋÀ$xñãǏÀߗ’mÝÞÀ„RYB&ú€@Ÿî"ŽŒ™@å˗.\¹À$xñãǏÀÞÂnùmєÀˆŠÞ}‹œ—€@š@ @Ÿ>|ùóçÀ$xñãǏÀÝÌÒ9÷ÀEÀ‹0ñ/ÅÐ €@™æC)§Ë¬@X±bŋÀ$xñãǏÀÜÄÉ ìBÀŒj퓙œ€@–ÕÈúáƒL@ ‰$H‘"À$xñãǏÀÛµ}4;‹ÀŒËkP2€@”\DFkâ@"å˗.\¹À$xñãǏÀÚŠ²ÞúŸ)ÀŒn_MÁB­€@‘zmúc-[@%B… +(PÀ$xñãǏÀٝ˜”DÙ(À‹ é(m€@Ž¥.8¡¡@'Ÿ>|ùóçÀ$xñãǏÀ؝T$fVŽÀŠ•-Û£€@ŠÁ,>—·G@)û÷ïß¿~À$xñãǏÀ×§áŒt{ÍÀ‰XpOYK~€@‡~û‚<Å@,X±bŋÀ$xñãǏÀÖŸ£êŸ^ÀˆW£§Ž€@„°nfIÇ@.µjÕ«V­À$xñãǏÀÕâ ВÎàÀ†­‰>{šÜ€@‚F\˜~ä@0‰$H‘"À$xñãǏÀÕíöAÀ…[µùÇ +€@€1fcÕ@1·nÝ»víÀ$xñãǏÀÔNVc§uÀ„}ç|&g€@|̊þ¡ 8@2å˗.\¹À$xñãǏÀӖqeÊHÀ‚Ü.2 €@y¶;3@84@4(P¡B…À$xñãǏÀÒ鳫He£À·j,%÷B€@wŠŠáŒ}@5B… +(PÀ$xñãǏÀÒG]Yh#ÊÀ€©Ü@…p€@tŊµ"B@6páÇÀ$xñãǏÀÑ®–£õ†:Àaìî‡z/€@r˚µŸ€@7Ÿ>|ùóçÀ$xñãǏÀÑ–¥9œáÀ}š9Šår8€@qÌYÏv2@8͛6lÙ³À$xñãǏÀЖ§÷ú†±À{öû©KpC€@o'S2€\b@9û÷ïß¿~À$xñãǏÀÐ&êõ’6Àzt€Mëýš€@l…Y uŒó@;*T©R¥JÀ$xñãǏÀÏ8ôÕØM^ÀyØt£ŸŽ€@j3!QÈëœ@µjÕ«V­À$xñãǏÀÌ€uŒ”ÊÒÀu¯{õšŠÝ€@dÀ}YfÍ@?ãǏºÇfÀs¬yÇxê€@`+ ãñ@A·nÝ»víÀ$xñãǏÀɳ§`ÁF·ÀrNü€òX&€@]À§a¬^@BN:téÓÀ$xñãǏÀÉ +0žægäÀq£ÅÍôU€@[Sœ”³Ž@Bå˗.\¹À$xñãǏÀÈg€5¬qÀpØi'ØFÀ€@Yô:ß»°@C|ùóçϟÀ$xñãǏÀÇËÅ| ÑHÀp&ÌQÿ/€@VøÊ¡@(‘@D(P¡B…À$xñãǏÀÇ6TŠ'pÀnø,^ËQ€@U +ýñÿe@D«V­ZµjÀ$xñãǏÀƧxOGxÀm°ÍF 0€@SFœÙÿè@EB… +(PÀ$xñãǏÀƳÉ6ueÀlw}÷'Ž[€@QªÕ/`X–@EÙ³f͛6À$xñãǏÀřÿc/›ïÀkL‡ó€@P5É%²F%@FpáÇÀ$xñãǏÀűFƒ Àj.DD/ëî€@MË⃚6L@G @À$xñãǏÀĢРO܀Ài®êÁv/€@Kr%˜GŽ@GŸ>|ùóçÀ$xñãǏÀÄ.L8Ä;`ÀhãXR¿ÿ€@IVœ×ÀÂ]@H6lÙ³fÍÀ$xñãǏÀߌ†šÀÀg"dP«øþ€@Gr;㫉&@H͛6lÙ³À$xñãǏÀÃS¢ ýÞUÀf6®Éž:õ€@EŸ"ûË\‰@Idɓ&L™À$xñãǏÀÂìÆYšÀeV=Ê€@D4ÇÁ‰Ï@Iû÷ïß¿~À$xñãǏÀ‰õnò Àd€‰hâîo€@BÑ3×ÃSì@J“&L™2dÀ$xñãǏÀÂ*ýűgÇÀcµ^w⠀@A ¥æÁT@K*T©R¥JÀ$xñãǏÀÁϰDø4ÀbóM„ !=€@@jƒ¹Tì@KÁƒ 0À$xñãǏÀÁwà(›\‘Àb:Æ%"€@>Àt4¶Èe@LX±bŋÀ$xñãǏÀÁ#búO=ÖÀaŠÿónŸˆ€@<Úy[¡n@Lïß¿~ýûÀ$xñãǏÀÀÒr°lÀ`ã†p€æÏ€@;àŸ.@Ó@M‡8páÀ$xñãǏÀÀƒÂIT¯;À`DôL;€@9†l¿Î @N|ùóçÀ$xñãǏÀµ1oàÚÆRÀL0ˆa4âB€@Ræ_I¹@WêÕ«V­ZÀ$xñãǏÀŽï€çHÑÀK…r¬Šb€@Yyœ3l@X6lÙ³fÍÀ$xñãǏÀޝf\˜ï€ÀJß\šŒã¿€@i@šó@X‚ @À$xñãǏÀŽp«IxËÀJ>n`ÍU€@€ß;ÅÏè@X͛6lÙ³À$xñãǏÀŽ3gŒ%8ÀI¡{Rà€@ pßÖüY@Y2dɓ&À$xñãǏÀ³÷N‹K.ÀIߣïºl$€@"x"@Ydɓ&L™À$xñãǏÀ³ºêµEXRÀIB£HÙrB€@D 04õ`@Y°`Áƒ À$xñãǏÀ³óðdeÀH«3‹ÚÓހ@qKÙÇO@Yû÷ïß¿~À$xñãǏÀ³FZïhÊ%ÀH 2€@©2*1ß@ZG@Z“&L™2dÀ$xñãǏÀ²×mFTºÀGŸ3f7k€@5 Ö³@ZÞœzõë×À$xñãǏÀ²¡WK¿q[ÀF€¢‚€@‰¹P@[*T©R¥JÀ$xñãǏÀ²lÇçÀFÙûš*¯€@äÊckû@@[uëׯ^œÀ$xñãǏÀ²9`îJiÀE…Îùñ}Ҁ@H$ïWŒu@[Áƒ 0À$xñãǏÀ²%ª{ÀEÈÏ€L7€@²µœÇS,@\ 4hÑ£À$xñãǏÀ±ÕàÊ1vÀD›šÖ]h€@$†šE±@\X±bŋÀ$xñãǏÀ±¥·"rŽÀD,Ïígÿ€@7©å”Ò@\€H‘"DˆÀ$xñãǏÀ±v«ªy&ÀCÀ Á=’€@3Cÿ—Är@\ïß¿~ýûÀ$xñãǏÀ±HeA°+{ÀCW†Êèû1€@ :FÓJ%'@];víÛ·nÀ$xñãǏÀ±-«$þÀBò)~õb€@ L[Q§d@]‡8páÀ$xñãǏÀ°îà«ÆßnÀBåp¥ €@ h +!ÆZ@]Ò¥J•*TÀ$xñãǏÀ°ÃxցyËÀB0›·Êá€@ +§œP—@^N€@z¹ã®@_L™2dɒÀ$xñãǏÀ¯íÝaˆPœÀ@|ÑóNmc€@Éví@Ž¡@_˜0`ÁƒÀ$xñãǏÀ¯ µÈþy§À@-FK*€@61À»}@_ãǏ²€@Þ[î*‘f@`=zõëׯÀ$xñãǏÀ®ÁÊZ$ˆÀ>–hÁe€@Gªnþ@`cF4hÀ$xñãǏÀ®z3>Ü1À>Šªü§€@µŸpˆ!ü@`‰$H‘"À$xñãǏÀ®3çö^uÀ=|ópüݟ€@)•QÉ@`®Ý»víÛÀ$xñãǏÀ­îßËÜ?À<ö °Û8€@¢¿a[¡1@`Ô©R¥J•À$xñãǏÀ­«éŽØÕÀn"€€@§šàÞüï?òå˗.\¹À!R¥J•*PÀâUIÆôLÀwsºžaTœ€@§«€3Ó[@å˗.\¹À!R¥J•*PÀâFê}òÀ…ØË’UÅ€@¥°ÿÐÒ.(@ X±bŋÀ!R¥J•*PÀኟ?.}À€+š¹ål€@£© R…+@å˗.\¹À!R¥J•*PÀàñe{šµÀ‘gŠŸ!ç€@¡SՓò•@Ÿ>|ùóçÀ!R¥J•*PÀàEÕ\"å™À’±îCÍF_€@ëÐR±³@X±bŋÀ!R¥J•*PÀß&‘GJAÀ’ô¹íêâe€@™‰ÖìFV@ ‰$H‘"À!R¥J•*PÀÝÕÉëëÚÀ’ˆ":^Ü3€@•²ÅšÔÀS@"å˗.\¹À!R¥J•*PÀÜlRœGKÀ‘ÁoŠg €@’u¢ª¬ô@%B… +(PÀ!R¥J•*PÀÛ% þbCÀÏ‚^'·i€@ˆª~¥Ëä@'Ÿ>|ùóçÀ!R¥J•*PÀÙñEŽ ÿ”À 0ì۷À@‹\º[±å@)û÷ïß¿~À!R¥J•*PÀØÐjG'VÀ™÷‘ÿœô€@‡gDÜ3ì@,X±bŋÀ!R¥J•*PÀ×Áµ‹Â‘°À‹ º@Úǖ€@„Uáo»@.µjÕ«V­À!R¥J•*PÀÖŖƒÂÅÀ‰ÁÀ»N€@ÁÃTnO·@0‰$H‘"À!R¥J•*PÀÕÚª‹zÀ‡üœ‰Aµ€@'šÈnŠ5@1·nÝ»víÀ!R¥J•*PÀÔÿÿCr':À†Qß;։h€@{w‘…óK•@2å˗.\¹À!R¥J•*PÀÔ4r/‡À„Æ?ëD 9€@xXÈæ ¢›@4(P¡B…À!R¥J•*PÀÓwƒ'ï +ÀƒY’Iq €@uŽä< è@5B… +(PÀ!R¥J•*PÀÒÆf°çÀ‚6áe²€@swûKÅu-@6páÇÀ!R¥J•*PÀÒ!X^™;À€åÈÎÐï_€@qŽè»fÌþ@7Ÿ>|ùóçÀ!R¥J•*PÀц¹•ÛkÊÀ±æè8ˆU€@oÔr í£ñ@8͛6lÙ³À!R¥J•*PÀÐõ޶•סÀ}Ê(“©W€@lû‡Ë›Î~@9û÷ïß¿~À!R¥J•*PÀÐlú}4L–À|3mšU{€@jÃhCÅx@;*T©R¥JÀ!R¥J•*PÀÏØgfíoÀz}ÛÄzÀP€@hQ4úÃåÊ@µjÕ«V­À!R¥J•*PÀÍ!y{š.yÀvœ™¶— €@cŒ1;Z„@?ãǏ|ùóçÀ!R¥J•*PÀÄS˵o>÷ÀhœÎ ®k7€@F6Ô6È@H6lÙ³fÍÀ!R¥J•*PÀÃá^ÓÔ(ÞÀgµ9úþ»€@Dyø®\~ó@H͛6lÙ³À!R¥J•*PÀÃs²ÚÍ|Àfºƒ9bA€@Bí?Xµ±@Idɓ&L™À!R¥J•*PÀà +ˆ*ˆ•ÀeÌßÐÀÀ€@A‰—ßXæŸ@Iû÷ïß¿~À!R¥J•*PÀÂ¥¢ÇçgÀdëŽöë5_€@@JDû#3…@J“&L™2dÀ!R¥J•*PÀÂDÊ0 ³ÀdÙwÍ=3€@>Ucèjk5@K*T©R¥JÀ!R¥J•*PÀÁçÉ*Ë×LÀcKÎÁs€@|ùóçÀ!R¥J•*PÀµ;x¡ÍÀLWà.ˆ°€@FF<¥ŸU@WêÕ«V­ZÀ!R¥J•*PÀŽøÜ ª`ÀK«Y6æmj€@qI€[ +@X6lÙ³fÍÀ!R¥J•*PÀŽžFwNÙÿÀKìºyû€@£úÈÃ}@X‚ @À!R¥J•*PÀŽy6ˆ mÀJao蘥<€@ÝɐI@X͛6lÙ³À!R¥J•*PÀŽ; Ž~d¿ÀIù,4o³€@2П +š@Y2dɓ&À!R¥J•*PÀ³ÿŒì#ŒªÀJá[›€@¯ôÊ+o@Ydɓ&L™À!R¥J•*PÀ³Ãÿrš±ÀId³ö€@ò.sÎ7^@Y°`Áƒ À!R¥J•*PÀ³‡ÃoÆÎÀHÊ󟜕÷€@>ŠâZ@Yû÷ïß¿~À!R¥J•*PÀ³Mâ"ø%ÍÀH7=ÖҍD€@“§y¢@ZGþŠ@Z“&L™2dÀ!R¥J•*PÀ²ÞþŒ+ìÀG œ W:€@UÁja @ZÞœzõë×À!R¥J•*PÀ²šéÒK3ÀFš4ti€€@…êŽ@[*T©R¥JÀ!R¥J•*PÀ²sLxqŒÀF¡Õ?±€@l£ûF4/@[uëׯ^œÀ!R¥J•*PÀ²?«“dxqÀEp*z€@ aX5vh +@[Áƒ 0À!R¥J•*PÀ² +—˜øÀE%T 9ô€@ bj(7 @\ 4hÑ£À!R¥J•*PÀ±ÛÁµ˜X ÀD±!V»*р@ o ¶Õ÷é@\X±bŋÀ!R¥J•*PÀ±«fZMÀD@ªïÓü–€@ +†Ïã0Û@\€H‘"DˆÀ!R¥J•*PÀ±|f>ÂÀCÓÈgŸª€@ š×µt!@\ïß¿~ýûÀ!R¥J•*PÀ±M·”9÷ŸÀCjRêa•€@Ô£?ã›Ï@];víÛ·nÀ!R¥J•*PÀ± SøLƒ˜ÀC%µ®Îi€@ §žÔö’@]‡8páÀ!R¥J•*PÀ°óÝûßÝÀB¡ñÏð€@Gc«FÏ@]Ò¥J•*TÀ!R¥J•*PÀ°ÈNUCZÀBA•t>·€@^:\Ñ@^æšÀA1ùTãœã€@Œ€)-<×@_ À!R¥J•*PÀ°"‡¬YÁŽÀ@Üݎ¡º€@ïR$óoŸ@_L™2dɒÀ!R¥J•*PÀ¯ö(áÊàÀ@Š8j@œ +€@XqÕ·B-@_˜0`ÁƒÀ!R¥J•*PÀ¯šÃ<@ +ŒÀ@9ñœ+â€@ǍÁéÑ@_ãǏ¬â§ÑNH€@5ä“×t@`cF4hÀ!R¥J•*PÀ®_wôAÀ>Ž~Ã=€@º å€M•@`‰$H‘"À!R¥J•*PÀ®:áV](À=’$~[kù€@CïQ7à@`®Ý»víÛÀ!R¥J•*PÀ­õš šB£À= +€;Àh€?ÿ ¶~{)F@`Ô©R¥J•À!R¥J•*PÀ­±ªØŽÊ’À<†~ðp«b€?þÃ×:Œaö@`útéÓ§NÀ!R¥J•*PÀ­náMÂDÀ<ÿf‡œÅ€?ýï ŠäÅ¿@a @À!R¥J•*PÀ­-CG×"[À;ˆáâñB€?ý!îõ‰Rx@aF 0`ÁÀ!R¥J•*PÀ¬ìÈíR">À;Uê*€?ü\-˜#Xy@akׯ^œ{À!R¥J•*PÀ¬­jª€›ÄÀ:˜TêúJӀ?ûpèÈr @a‘£F4À!R¥J•*PÀ¬o!/F#ÍÀ:$¬µ/Su€?úåhÙAúÛ@a·nÝ»víÀ!R¥J•*PÀ¬1åjÐׯÀ9³ôފÚù€?ú3ÉšH*G@aÝ:téÓ§À!R¥J•*PÀ«õ°ŠCÚþÀ9FøÚG˜€?ùˆKœ2 Q@b 0`À!R¥J•*PÀ«º{õi»ƒÀ8Úñšf€A€?øâªÂ§HF@b(Ñ£FÀ!R¥J•*PÀ«€AL`˜À8rv–qd€?øBŠŽëMs@bN:téÓÀ!R¥J•*PÀ«FúeB 4À8 Œbl‹€?÷š`b¿Æ@bthÑ£FÀ!R¥J•*PÀ«¡IèÿÀ7©™(È8€?÷ƒÒüͳ@bš4hÑ£FÀ!R¥J•*PÀª×05Ó¬<À7H¥Í‡€?öô 1‘µ@bÀÀ!R¥J•*PÀª ¡” &-À6é`ȧJ¯€?õöËM¬ÉÀX±bŋÀå$vßõ怀@¯Íö<ç?òå˗.\¹ÀX±bŋÀäõ'AºIµÀƒmÞµ«KŽ€@®ŸÌ~ù]'@å˗.\¹ÀX±bŋÀärïÃ*]À‘p֒ÜC€@¬¢èÏ Y@ X±bŋÀX±bŋÀã³ópbŽÀ–Â€ä‰¥Ž€@š^N]Qiá@å˗.\¹ÀX±bŋÀâÌÿ‰Gµ÷À™Š-„€@€r•&Kº)@Ÿ>|ùóçÀX±bŋÀá×A¬ð²`Àš&E{€@ ×sQ @X±bŋÀX±bŋÀàã#Ê!ðsÀ™Z“€së€@›™†3À¯@ ‰$H‘"ÀX±bŋÀßôæšE„ŒÀ—Ó±Ff‹«€@–¬ fLÔ@"å˗.\¹ÀX±bŋÀÞC--Ì1PÀ–Ü@ڙ €@’À(²©P@%B… +(PÀX±bŋÀ݆ܳÀ”LeraZ€@Il¹T…›@'Ÿ>|ùóçÀX±bŋÀÛCX¯Lx'À’¢£:øX“€@Š[>`ë4Ø@)û÷ïß¿~ÀX±bŋÀÙñي5=±À‘O0R€@†k)×-¥2@,X±bŋÀX±bŋÀØ»äÔ|Þ¶ÀpõŒž£€@ƒ9ÞžÈ b@.µjÕ«V­ÀX±bŋÀמÐÕFÀŒñˆe5ó!€@€™ŽŽÆ z@0‰$H‘"ÀX±bŋÀ֘I,)(µÀŠšã PX€@|Öûm¹…@1·nÝ»víÀX±bŋÀÕŠvq×G)Àˆ§ÉÁÈC€@y6EpLÇ@2å˗.\¹ÀX±bŋÀÔNj +t*äÀ†§yӆ»€@v2ÄVø%@4(P¡B…ÀX±bŋÀÓùÊOæ®kÀ„íMF¥€€@s±yuš[@5B… +(PÀX±bŋÀÓ;zÞŸÁÀƒcš`ø +’€@q›ÚF¹@6páÇÀX±bŋÀҊÑÿ%èÀ‚ö‡kÏC€@o³SSüÝ@7Ÿ>|ùóçÀX±bŋÀÑæC;ÈšÀ€ÒÈw«šD€@l³ŠÔ»@8͛6lÙ³ÀX±bŋÀÑL[ÝіÀ|p#2Ür€@juâ`ã*@9û÷ïß¿~ÀX±bŋÀÐŒiº\ñ‰À}‹…Ûêʀ@gÔuŽôr­@;*T©R¥JÀX±bŋÀÐ5ºÖ¶…À{ÏkVJ€@eЪ6ÉÊ@µjÕ«V­ÀX±bŋÀ͒ízfïÀwº ðxî€@`áuÕ~c}@?ãǏ|ùóçÀX±bŋÀÄt(,8oÀiPü £€@Bß© +흃@H6lÙ³fÍÀX±bŋÀÃÿ*œä Àh8f÷_Œò€@A\˜Qw>@H͛6lÙ³ÀX±bŋÀÏ4®…°¡Àg/Ž»Dµ€@@P(ë)P@Idɓ&L™ÀX±bŋÀÃ#ýÚ +IŽÀf5ÖòuÜj€@="gˆÍ@Iû÷ïß¿~ÀX±bŋÀœBhÓa“ÀeIÒÐB€@;s–ö$ª‘@J“&L™2dÀX±bŋÀÂZÊÓÀdjŒl&4º€@9‚†j +@K*T©R¥JÀX±bŋÀÁüDå”G`Àc—ŒáeX€@7ÁŠ7ÝŠ@KÁƒ 0ÀX±bŋÀÁ¡‡0 ÀbÐ ùqü€@6,Q‚óÆ@LX±bŋÀX±bŋÀÁJr`'ÀÀbè·#Ž€@4ŒÍG±(@Lïß¿~ýûÀX±bŋÀÀöº ãŸGÀa_«•%ŸX€@3n~Þˆ@M‡8páÀX±bŋÀÀŠ:wy‡žÀ`µÝ E†–€@2=^PUý‹@N¶ËÀZ/—Ñ€nì€@(r%’ï‰Õ@Q @ÀX±bŋÀœ˗ ·ÀYarUZª €@'7ü҄2@Qkׯ^œ{ÀX±bŋÀŒ‹³­ FÀXž†ûŠÐՀ@&Š$,@Q·nÝ»víÀX±bŋÀŒÔ“AÀWåùŒ˜š^€@%åÇD²@R 0`ÀX±bŋÀ»©ÔïˆfÂÀW7QН€@$Úld6)@RN:téÓÀX±bŋÀ»=³cÃ҃ÀVîìcµ•€@#˗‰vM@Rš4hÑ£FÀX±bŋÀºÔŽÃªk-ÀUó6vށ€@"=+й›@Rå˗.\¹ÀX±bŋÀºnA‡"3§ÀU\鐜‰«€@!n‘–UÐÖ@S1bŋ,ÀX±bŋÀº +šÊº•ÌÀTÍÔÙ ë€@ ¬º}³rx@S|ùóçϟÀX±bŋÀ¹©ŠÉŽ QÀTCîZ€@í óÝûA@Sȑ"D‰ÀX±bŋÀ¹K&_{[‹ÀSŸ4O:K€@–š·­y@T(P¡B…ÀX±bŋÀžï%VQTÀS<†GŽ˜Í€@R˜ùH,@T_¿~ýû÷ÀX±bŋÀž•]:iSÀRŸÉM„€@!>ðpº @T«V­ZµjÀX±bŋÀž=ïC~d«ÀRDßéý€@È,`¡@TöíÛ·nÝÀX±bŋÀ·èžnšCÝÀQήOWu€@ï¢ú@UB… +(PÀX±bŋÀ·•§owÅ\ÀQ\»Øfˀ@ë°Ò¹ýš@UŽ8páÃÀX±bŋÀ·D«€¶ZÀPìÿø¯TU€@õKWÊ@UÙ³f͛6ÀX±bŋÀ¶õŽ^ûmÜÀPK/(zœ€@ +Ù:µ4ç@V%J•*T©ÀX±bŋÀ¶š²Mƒ.ÀPÝý>îJ€@+l”df@VpáÇÀX±bŋÀ¶]– Q&ÌÀOg; ü`7€@V)ÕaŒU@VŒxñãǏÀX±bŋÀ¶P؆^dÀN¢ß§Üç€@ŠG°OH1@W @ÀX±bŋÀµÌÔn­’ÀMät‚³Ó’€@Ç 8UV÷@WS§N:tÀX±bŋÀµ‡ýb\ÀM+É~Ó?€@ Ðw®úù@WŸ>|ùóçÀX±bŋÀµBÿ+Œ»)ÀLx­:ª‘°€@Wõ'M'?@WêÕ«V­ZÀX±bŋÀµŒÃbfÀKÊðZo/€@ªë„Z \@X6lÙ³fÍÀX±bŋÀŽ¿­2dBDÀK"dßî‚y€@/@»Ac@X‚ @ÀX±bŋÀŽ€VlOÀJ~Þ/Øw=€@cF‹úAõ@X͛6lÙ³ÀX±bŋÀŽB|U9žŸÀIà1l„€@‚d„Šå@Y2dɓ&ÀX±bŋÀŽÍ"ØÏRÀJ ‡7“ž€@/•»y-õ@Ydɓ&L™ÀX±bŋÀ³ÉÓÿpÀI€2¬ÃÛr€@%ñ»^ n@Y°`Áƒ ÀX±bŋÀ³ŽNŽ1ÀHåŠkŽw#€@ üÁ×+öŠ@Yû÷ïß¿~ÀX±bŋÀ³T/îYÌ6ÀHPšüàØ€@ âì1\@ZG5CK€@ Öi2“Çê@Z“&L™2dÀX±bŋÀ²ãóÍ;ZÆÀG5þ·ð€@ +×h +}@ZÞœzõë×ÀX±bŋÀ²­ŸÖqîÀF¯òOª‘€@ äŒÞÃû@[*T©R¥JÀX±bŋÀ²xÁíüÀF.qJÞ6b€@ý£ìZ&#@[uëׯ^œÀX±bŋÀ²Dð"v†µÀE±G Éï€@!lx+š˜@[Áƒ 0ÀX±bŋÀ²AäYyÀE8Aš¡[à€@OoÆÉ]Õ@\ 4hÑ£ÀX±bŋÀ±à¬ÐHMœÀDÃ2S@‡èt±Ž@\X±bŋÀX±bŋÀ±°'À$óšÀDQìOЀ@ÇÉè4Ð@\€H‘"DˆÀX±bŋÀ±€©óžõvÀCäEŸeô$€@ ጩ×@\ïß¿~ýûÀX±bŋÀ±R+ +ª2ÀCzA'œ|€@ba'­¿ÿ@];víÛ·nÀX±bŋÀ±$£O9\ÀC;d7(€@»Sˆ N³@]‡8páÀX±bŋÀ°ø +%ÓȜÀB¯Ž±Ÿ€@x™µnS@]Ò¥J•*TÀX±bŋÀ°ÌY0?ÊÀBNîï*Á€@‚l%3Æ@^(äôµè€@ܓ&@_ ÀX±bŋÀ°&z»²À@DºKހ?þÐM©_nä@_ãǏãÀ?ì*[+€?ýëq' Æ@`¯^œzöÀX±bŋÀ¯§ü-‚¹À?T30¶€€?ý}¿¹œö@`=zõëׯÀX±bŋÀ®ÏVpºotÀ>À j«Õ¯€?ü<€?Öä@`cF4hÀX±bŋÀ®‡]þŸÜŒÀ>/ýswœÂ€?ûp™¥@Oè@`‰$H‘"ÀX±bŋÀ®@µ$Dõ¯À=£â€\€?ú¬Üœ'(Ú@`®Ý»víÛÀX±bŋÀ­ûR¹fŸ6À=•¢,ck€?ùðo£™s²@`Ô©R¥J•ÀX±bŋÀ­·-êþ =À<–ó™{@þ€?ù:úiz @`útéÓ§NÀX±bŋÀ­t>7a >À<ÚŸð €?øŒ*¿©Ï@a @ÀX±bŋÀ­2{j˜èÀÀ;˜*í"nŽ€?÷ã°sk£˜@aF 0`ÁÀX±bŋÀ¬ñݚêŒ[À;ÅkփK€?÷AC}áe@akׯ^œ{ÀX±bŋÀ¬²]%ÍéÀ:ŠŒÜ!€?ö€YòBˆ@a‘£F4ÀX±bŋÀ¬sò«Ÿ=fÀ:2e(N·€?ö {øq\ @a·nÝ»víÀX±bŋÀ¬6—æÀ9Á3nz58€?õ{ ÖJïŠ@aÝ:téÓ§ÀX±bŋÀ«úCp[ÄÀ9RÝôƝä€?ôîÐÇC†*@b 0`ÀX±bŋÀ«Ÿñ*µ[À8çL'€?ôfÓ¿šÈ@b(Ñ£FÀX±bŋÀ«„™ÑœïçÀ8~f>r³d€?óãt¢·; @bN:téÓÀX±bŋÀ«K72,ØÉÀ8ÉXÛǀ?ódŠ}/@bthÑ£FÀX±bŋÀ«ÃJДiÀ7ŽE é{€?òéÉOGr{@bš4hÑ£FÀX±bŋÀªÛ8MEö™À7Rß2ÖDý€?òsûFiš@bÀÀX±bŋÀª€›I‘À6óÐQbÞÁ€?òZ ü+ÿÀ 0`Á€ÀèßÈ{OZ€€@¶Ÿ2[Á?òå˗.\¹À 0`Á€À舞ÂÍ­zÀ‘ŽÈ”%ØL€@µ{‚DŸd@å˗.\¹À 0`Á€Àç¡t€'kdÀžA±ã6Í8€@²œ-͝»@ X±bŋÀ 0`Á€Àæ_¥è»XšÀ¢zИa€@®ý;A+«@å˗.\¹À 0`Á€Àä÷ ú¬ÌÀ£A>/Vk€@§{éA. @Ÿ>|ùóçÀ 0`Á€À㒠“m À¢cóW„±è€@¢!üs^ªi@X±bŋÀ 0`Á€ÀâDÈw@–ºÀ ÒÛFñ)€@œ*øãTY×@ ‰$H‘"À 0`Á€Àá!’ìÀž4«@ €€@–-*ÊŘ²@"å˗.\¹À 0`Á€Àà UøÇ-ÉÀšõ4ëÔœ€@‘¿ÌWv­@%B… +(PÀ 0`Á€ÀÞ0÷EŒãÀ˜QÃÊv·€@ŒàõÍGÉå@'Ÿ>|ùóçÀ 0`Á€À܁í^IÜ@À•—yfN€€@‡×ÜPÀ@)û÷ïß¿~À 0`Á€ÀÚþšLœ¿­À“s ˆ»K€@ƒòùèAþÏ@,X±bŋÀ 0`Á€ÀÙ ð1˜ÌÀ‘›šýaU߀@€âÿŸ°QÑ@.µjÕ«V­À 0`Á€ÀØc—#K¢ÀËm@tр@|â‡"‹¢@0‰$H‘"À 0`Á€À×B’áOŸÝÀ2 +»(€@xîD"±Jq@1·nÝ»víÀ 0`Á€ÀÖ:ïŸ6¥kÀЧˆ§Ž€@u¶7€v™@2å˗.\¹À 0`Á€ÀÕJþŠçŸÀˆaãÚ¡€€@sÓß U§@4(P¡B…À 0`Á€ÀÔmgãÚÝÀ†ZŒˆã¢€@pæ*žG.@5B… +(PÀ 0`Á€ÀÓ¢Ä,ÞÀ„”ªüíݲ€@n/„t•œ@6páÇÀ 0`Á€ÀÒ眛­Ð(Àƒ +šN-Y€@k#5—rðT@7Ÿ>|ùóçÀ 0`Á€ÀÒ:SÚ¥Ž^À±vk» þ€@h…ä嫒D@8͛6lÙ³À 0`Á€Àјâ!ìä‡À€€Ï[߄€@f>•Ö5 @9û÷ïß¿~À 0`Á€ÀÑ +ltšÀ~ä]'í%€@d:„S*@;*T©R¥JÀ 0`Á€ÀÐtŸp¢ÅÀ}·ÓO^€@bkÚQ :@µjÕ«V­À 0`Á€ÀÍôu»!ï<Àx£eÿgV€@[íǹ‚ ­@?ãǏI_ >€@BÏ¥·Ž@G @À 0`Á€ÀÅ ÃRHP'Àkø¿È8ɀ@@•D4%¹f@GŸ>|ùóçÀ 0`Á€Àď»t%ÀiÍ '~¥€@>iÊ·nÐ7@H6lÙ³fÍÀ 0`Á€ÀÄÚç-™Àh§2«C€@;óO:G¢@H͛6lÙ³À 0`Á€ÀÃ¥õÉnýÀg’Ëòã|€@9ŸO™D`r@Idɓ&L™À 0`Á€ÀÃ9š<)öÀfŽš*¢XY€@7Ã%TšC(@Iû÷ïß¿~À 0`Á€ÀÂкV²ÖÀe™›èÖœ€€@5û!kœœ@J“&L™2dÀ 0`Á€ÀÂlÕA 5ˆÀd²”žÊŸc€@4`j1q@K*T©R¥JÀ 0`Á€À {>j™Àcؖn«ðN€@2íÞBÄ%@KÁƒ 0À 0`Á€ÀÁ±<šÏ™Àc +¹7¥2&€@1žûÏÒ`\@LX±bŋÀ 0`Á€ÀÁYPè/ÀbH'ӝÏ:€@0oË{F@°@Lïß¿~ýûÀ 0`Á€ÀÁnp¬@Àaö<Ýd€@.¹œ“”Tœ@M‡8páÀ 0`Á€ÀÀ³Âã4À`âu<üœ€@,ÅÜcÃàŸ@NáUzbœ€@$'?`§Ük@PÔ©R¥J•À 0`Á€ÀœŒ¹¶ŠÔëÀZ`…wë N€@#x¶ž@Q @À 0`Á€ÀœY³_ÀYŽ»3gv^€@"!”΋,í@Qkׯ^œ{À 0`Á€ÀŒ›*g(‚lÀXȄË4·G€@!;âBœ†@Q·nÝ»víÀ 0`Á€ÀŒ'ÓRë+ÝÀX ý ó&€@ g;uY@R 0`À 0`Á€À»·Ú—RóºÀW[T‘7ªþ€@Bš€l„K@RN:téÓÀ 0`Á€À»KTajÀV²ÏGÐu>€@ҌŠg7—@Rš4hÑ£FÀ 0`Á€ÀºáS÷}—øÀVÂeĶm€@{ÿŸ˜@Rå˗.\¹À 0`Á€Àºzuò%±ÁÀUz’‡&Ÿy€@9žeùû!@S1bŋ,À 0`Á€ÀºUr̝ùÀT髐4y€@ *9÷-Q@S|ùóçϟÀ 0`Á€À¹ŽÓãýéæÀT^:r€@ðÙ +ۉ@Sȑ"D‰À 0`Á€À¹UÛsVúÀSÖÛ}I„«€@åêÁ’Œ@T(P¡B…À 0`Á€ÀžùXf¯ÀSSÆPÆï€€@êI莬û@T_¿~ýû÷À 0`Á€ÀžŸ6pTûŽÀRÔŸà.§#€@üšŒÿ¢@T«V­ZµjÀ 0`Á€ÀžGc‘(9ëÀRYАP›€@èń< +@TöíÛ·nÝÀ 0`Á€À·ñÍ4S[nÀQâ]¯ºO€@Gá:ƒ,@UB… +(PÀ 0`Á€À·ža 6nÎÀQnÅ8íÞ%€@} /š @UŽ8páÃÀ 0`Á€À·Mª_†{ÀPþÀ” S€@œ& ˆŠ@UÙ³f͛6À 0`Á€À¶ýƵ0/žÀP’1®M€€@†Ùñ@V%J•*T©À 0`Á€À¶°v­(pÀP(ûAtÍ€@Xt’öà @VpáÇÀ 0`Á€À¶eçЁÀO†Â}Ò©€@²Gôfš”@VŒxñãǏÀ 0`Á€À¶ƒ¶ý–_ÀNÀPº‰_€@dŠ\u"@W @À 0`Á€ÀµÓÃ8‡«tÀNªžëèd€@öv——r¯@WS§N:tÀ 0`Á€ÀµÀºnŠÀMF܉ ÿˆ€@ ґÓº @WŸ>|ùóçÀ 0`Á€ÀµImûR°,ÀL’³z“ €@ º(Ší@WêÕ«V­ZÀ 0`Á€ÀµŸý;ÀKãþcŽ•€@ ¬[€ª¶ö@X6lÙ³fÍÀ 0`Á€ÀŽÅ¥”¹pÀK:¢dôŀ@ +š_ª8ŸÙ@X‚ @À 0`Á€ÀކÏ?ßmÀJ–3 X®€@ ­w…í@X͛6lÙ³À 0`Á€ÀŽHpl ÀIöÂ0L•߀@ºõ…,8å@Y2dɓ&À 0`Á€ÀŽ z°ÀJ8o|ÊЀ@ F©47@Ydɓ&L™À 0`Á€À³ÏIãí9%ÀI–Þ”×è€@QY(£‡[@Y°`Áƒ À 0`Á€À³“kïµ,ÀHû*œ5у€@hŠ.…ÕÄ@Yû÷ïß¿~À 0`Á€À³Y@‘ØÀHe +Ök׀@‹œA¯„œ@ZG€@­R“ +ˆ@[uëׯ^œÀ 0`Á€À²I*µ¡“±ÀEÁ?:—ƒü€@Óq6ZWÉ@[Áƒ 0À 0`Á€À²WŸÔ÷tÀEG|ӝë3€@/;ùþtF@\ 4hÑ£À 0`Á€À±äŸe9KÍÀDÑ»YÞ §€@’”PvŒÁ@\X±bŋÀ 0`Á€À±³øÆ‚î»ÀD_Ͱrjà€@ý ŸxoË@\€H‘"DˆÀ 0`Á€À±„Zëü2¢ÀCñ‰8ý̰€@n3CRU%@\ïß¿~ýûÀ 0`Á€À±Uœ_ÔãÀC†Å©띀?ÿË_Ê‘B@];víÛ·nÀ 0`Á€À±(Í#ÀC\âü²°€?þÆIÁÉ!@]‡8páÀ 0`Á€À°ûc%F†ÄÀB»*Ñp]̀?ýÌ{vI&X@]Ò¥J•*TÀ 0`Á€À°Ï—F•íÀBZ F*Ø!€?üÝWDVɗ@^ã>€?øŸ»M”·â@_˜0`ÁƒÀ 0`Á€À¯Žä*€£À@Mcð‚>F€?øž–ýŒ @_ãǏÏnð'ð©€?öD˜ï±@`cF4hÀ 0`Á€À®Œ,_±ôÀ>>Ëg}W€?õbáèÆ@`‰$H‘"À 0`Á€À®Ea6GCÀ=²"xސ€?ôÉ»õä_y@`®Ý»víÛÀ 0`Á€À­ÿÝÄÀ”À=)Mx–õC€?ô6¯²Ê[@`Ô©R¥J•À 0`Á€À­»™(€±ÁÀ<€*ˆþƒ€?ó©+F#¿@`útéÓ§NÀ 0`Á€À­xŠÑ·žÀ<"•äa€?ó ®^ áª@a @À 0`Á€À­6ª~Br$À;€p`%ÿj€?ò:ò¿{]@aF 0`ÁÀ 0`Á€À¬õð7ŽgŸÀ;)š„I‹3€?òüÅ@akׯ^œ{À 0`Á€À¬¶TN¢áÀ:±ö§þ€?ñ€LŸ>’@@a‘£F4À 0`Á€À¬wÏXÃnPÀ:=hk‡€?ñ.gzª4µ@a·nÝ»víÀ 0`Á€À¬:Z-*GÀ9ËÔ¯Gð€?ðŒ¡>VÙ¶@aÝ:téÓ§À 0`Á€À«ýíá|À#À9]!zŽ¡€?ðNËŽÇ–@b 0`À 0`Á€À«ÂƒÇyÌ­À8ñ5ð›§V€?ïÉuûµxK@b(Ñ£FÀ 0`Á€À«ˆjZzÀ8‡úA?Iï€?îü‹ …d,@bN:téÓÀ 0`Á€À«NœŒhÍIÀ8!W›Ba €?î6‡Ë{Í€@bthÑ£FÀ 0`Á€À«$ŽYÀ7œ8˜̀?íw!pÌøe@bš4hÑ£FÀ 0`Á€ÀªÞs\äÉ¢À7[†ÕP*l€?쟎ÚP@bÀÀ 0`Á€Àª§·'ÀöÀ6ü/ž=À€?ì 4 7•À~ýû÷ïàÀîrRÎF€€@ÁԏL?Ч?òå˗.\¹À~ýû÷ïàÀí·œãÿŸåÀ¢Ÿ÷¯ú”·€@À&ÌòmœÁ@å˗.\¹À~ýû÷ïàÀëàlœ}?ÇÀ­pªV_º^€@ž¢FF na@ X±bŋÀ~ýû÷ïàÀéš ‰rÍñÀ¯8ŽßŸ}€@±pS·Œ[ù@å˗.\¹À~ýû÷ïàÀç],ÏK;‡À­„®]úÀ€@še –_r@Ÿ>|ùóçÀ~ýû÷ïàÀåYÓv,ƒÀ©n±"¶­€@¡+™ÉŸ@X±bŋÀ~ýû÷ïàÀ㜀ƒ3ñÀ¥ÌMžö €@™¬€&ñùç@ ‰$H‘"À~ýû÷ïàÀâþSä‹À¢ŸG Ѐ@“F/¶XÁ|@"å˗.\¹À~ýû÷ïàÀàØÙÕêŸcÀŸü(Ïúô€@ÉΑ±³ã@%B… +(PÀ~ýû÷ïàÀß~kIQÀ›È90H˜@€@‡¢@í€ßJ@'Ÿ>|ùóçÀ~ýû÷ïàÀݒ€ó²À˜a?aÕØ€@ƒ&ˆÂÁû@)û÷ïß¿~À~ýû÷ïàÀÛáûèjÀ•—Ñ0=ls€@“ÅeҖ@,X±bŋÀ~ýû÷ïàÀÚ_ŒôͲÅÀ“GËJ§µæ€@zq£àâ(@.µjÕ«V­À~ýû÷ïàÀÙGî}À‘V æ‰þ“€@vwdj Lã@0‰$H‘"À~ýû÷ïàÀ×ί7^öÀPAµ¿^€@sVÀ¥žÅ@1·nÝ»víÀ~ýû÷ïàÀÖµmÈëÀŒ_P‘s›~€@pÕþs +Á¥@2å˗.\¹À~ýû÷ïàÀÕµ_ÀÆoÑÀ‰Ëb€÷Qƒ€@m˜;JL@4(P¡B…À~ýû÷ïàÀÔ̔̅$À‡‡V°ŽS€@j6ÊۚB@5B… +(PÀ~ýû÷ïàÀÓ÷ŸûO*À…’“ö'P€@g]ö’?µŠ@6páÇÀ~ýû÷ïàÀÓ4 ŸŸV?Àƒä@]=0€@dïDøÒ‚ô@7Ÿ>|ùóçÀ~ýû÷ïàÀÒ$ɒqéÀ‚nÂ2< +€@bÔG Œö@8͛6lÙ³À~ýû÷ïàÀÑ×$U=ÅjÀ'ŽÐօƀ@`ü òä1•@9û÷ïß¿~À~ýû÷ïàÀÑ:{1ÆÀ€ +MV6}€@^³E±ë#@;*T©R¥JÀ~ýû÷ïàÀЧ×ÏŠIïÀ~!!4=ý€@[Æ«ÔÕ¢y@µjÕ«V­À~ýû÷ïàÀÎAD8Ží"Àyj!LW«€@Tš'K“Ô@?ãǏ@FpáÇÀ~ýû÷ïàÀŧQEÍëÀlË c˜Ӏ@:µŠ]1Ëž@G @À~ýû÷ïàÀÅ"‚•›XÀkqoØT'3€@8xæAž@GŸ>|ùóçÀ~ýû÷ïàÀÄ£à¡qEÀj-§Sm€@6tÜWhœ@H6lÙ³fÍÀ~ýû÷ïàÀÄ+,H‰UÀhþݬˆp€@4£}¶µ¶@H͛6lÙ³À~ýû÷ïàÀ÷ÆLõáÀgàón Ì`€@2ÿv˜vÆ@Idɓ&L™À~ýû÷ïàÀÃILî•#€ÀfÕYÚ8e€@1„rÀm@Iû÷ïß¿~À~ýû÷ïàÀÂßÇwö5/ÀeÙ(42€@0-3,Ñ:@J“&L™2dÀ~ýû÷ïàÀÂzÄýÈbÀdë÷KˆƒÁ€@-îE[ÌÝ@K*T©R¥JÀ~ýû÷ïàÀÂaGDÀd t$Ò§€@+œ?l¿-‚@KÁƒ 0À~ýû÷ïàÀÁœA^iO$Àc9£ðËK€@)Á…ÜO<Ä@LX±bŋÀ~ýû÷ïàÀÁdH#{ªÀbr óJN€@'õì9ÐfÍÔ@Qkׯ^œ{À~ýû÷ïàÀŒŠÒwv ¿ÀXè㺀@¯ã-ä…u@Q·nÝ»víÀ~ýû÷ïàÀŒ2ëÍòVIÀX*=‹‰›€@|˜w:ÿ@R 0`À~ýû÷ïàÀ»ÂmßÝÎAÀWv€ò?q€@` +¶x€@RN:téÓÀ~ýû÷ïàÀ»U*µq/©ÀVÌwø¹ï€@WŒÕâ@Rš4hÑ£FÀ~ýû÷ïàÀºê÷ÃGÀV*hyF[€@a—Ö­(@Rå˗.\¹À~ýû÷ïàÀºƒ­™dµÀU»_²•€@{šI•›ì@S1bŋ,À~ýû÷ïàÀº'bèêÀTþR­+@€@€1þRY@S|ùóçϟÀ~ýû÷ïàÀ¹œF¥ºÀTqºçS‚€@ÙœèÃς@Sȑ"D‰À~ýû÷ïàÀ¹]ô œ‘ªÀSé[?Œçw€@2ìrÊ@T(P¡B…À~ýû÷ïàÀ¹Ú$<âÀSe?2_?Œ€@g™Ÿ¬6š@T_¿~ýû÷À~ýû÷ïàÀžŠ©ååÐÀÀRåEšqW€@|Ä»ûv@T«V­ZµjÀ~ýû÷ïàÀžN‹ã*qÀRiMÇÍÑ.€@;Õ‚_@TöíÛ·nÝÀ~ýû÷ïàÀ·ø¬¢XYÀQñ7®z…€@ ›@UB… +(PÀ~ýû÷ïàÀ·€ü©Ê{PÀQ|ᝁ}€@ ì$ÚgšZ@UŽ8páÃÀ~ýû÷ïàÀ·Si¬zqWÀQ -ô-{ü€@ +Ú¶>í(@UÙ³f͛6À~ýû÷ïàÀ·âÊu‡OÀPžý^sÝـ@ ֓™¶Óƒ@V%J•*T©À~ýû÷ïàÀ¶¶W³YHƒÀP51Õ02π@Þ²ò(Õ@VpáÇÀ~ýû÷ïàÀ¶jž¢Ü„¡ÀO\$0+‘€@òYùøò@VŒxñãǏÀ~ýû÷ïàÀ¶ ö],·‰ÀNÖ«/ +€@ø8 ™}@W @À~ýû÷ïàÀµÙ+,€²ÀNkŸ·’€@7oL·#.@WS§N:tÀ~ýû÷ïàÀµ’Í֞ëEÀM[qè7¶€@gÇŒd‚@WŸ>|ùóçÀ~ýû÷ïàÀµNKŠE™ ÀLŠ~+E¬À€@ QbD§@WêÕ«V­ZÀ~ýû÷ïàÀµ nYþB„ÀK÷$€@ànÈwÍ@X6lÙ³fÍÀ~ýû÷ïàÀŽÊ)&àߟÀKLð9qù…€@'‰“‰©Ý@X‚ @À~ýû÷ïàÀފo³f•yÀJ§õ9Ÿ$€@up§í@X͛6lÙ³À~ýû÷ïàÀŽL6œ£±ÀJïß¹›€@È©°V:c@Y2dɓ&À~ýû÷ïàÀŽ¿iXXpÀJJzGZ=€@mٍ_`@Ydɓ&L™À~ýû÷ïàÀ³ÓeÆ\¢,ÀI§ù&]р@kJ5[ÓÃ@Y°`Áƒ À~ýû÷ïàÀ³—„ñ§PïÀI eç9•ì€@Äl8€€?ð4)ƒ˜÷Œ@`=zõëׯÀ~ýû÷ïàÀ®×ÿ²ÝŠÀ>Ûˎfª€?ï{ÝoœœÕ@`cF4hÀ~ýû÷ïàÀ®ÈÅè¥À>Iî‰v|ó€?î˜sëJ&Ý@`‰$H‘"À~ýû÷ïàÀ®HãÍRkØÀ=ŒÙÖ¢Î̀?휊Þc8¢@`®Ý»víÛÀ~ýû÷ïàÀ®G„ÓnÀ=3Ÿ +Këŀ?ìë .°6†@`Ô©R¥J•À~ýû÷ïàÀ­Ÿêü2ÛÀ<®8Ÿ}€?ì CÐä\œ@`útéÓ§NÀ~ýû÷ïàÀ­{Śæ`À<,)ž€?ë\í_«À}@a @À~ýû÷ïàÀ­9ÏØÙ9À;­ªëZgE€?ê ±ºÈ9!@aF 0`ÁÀ~ýû÷ïàÀ¬øÿc LxÀ;2€oè1ö€?éë=­¥Š(@akׯ^œ{À~ýû÷ïàÀ¬¹NÕRë{À:º‹ÂŸ^w€?é|ùóçÀå˗.\ ÀæéÕÙxšÀ°}>Íþŀ@šë“X @X±bŋÀå˗.\ ÀäžF¹!*ÀªŠ üÇ$€@’ ¥;$Ð}@ ‰$H‘"Àå˗.\ ÀâðdYÛ‰À¥Ñ,äŽÞ,€@ŠéSÈ,c-@"å˗.\¹Àå˗.\ ÀáxlÞOÎÀ¢'Xñ]h€@„4næ>¹"@%B… +(PÀå˗.\ Àà<àÿDXÀžÐ{U©ì׀@aqŒ *Ú@'Ÿ>|ùóçÀå˗.\ ÀÞ\NíËbÆÀ𕐭Žo€@y£E€ty@)û÷ïß¿~Àå˗.\ ÀÜ†Žšz³À—=a×JŸž€@tˆYžÍ@,X±bŋÀå˗.\ ÀÚééÈrYÀ”ŠN|û€@q"Ÿ7ÒÔ@.µjÕ«V­Àå˗.\ ÀÙ{cÓüÖÀ’QÞcXWž€@mŸÒþ­`@0‰$H‘"Àå˗.\ ÀØ3jQßz‚ÀpE#æÎf€@hÚ^xs«™@1·nÝ»víÀå˗.\ À× qÑå·ÇÀ£BÍÀ‡€@e}ù +àK@2å˗.\¹Àå˗.\ ÀÖõúc&çÀŠÕ’>ú€@bŸMFL9^@4(P¡B…Àå˗.\ ÀÕ1khÓJÀˆei¹²±Ž€@`y‚÷Rµ@5B… +(PÀå˗.\ ÀÔ3тŒ”÷À†NoÆ&<€@]&<ÄÐû@6páÇÀå˗.\ ÀÓi³jº À„…-]€OO€@Yﲍ`+ñ@7Ÿ>|ùóçÀå˗.\ ÀÒ¯Dø#ŒÀ‚ú<ïÚÁ€@W/@8͛6lÙ³Àå˗.\ ÀÒs-€žÀ¡í Ï€@TίÖÇi@9û÷ïß¿~Àå˗.\ ÀÑa‹‘cÎaÀ€s:\Xøs€@R¹L˜a •@;*T©R¥JÀå˗.\ ÀÐË"©f2?À~ҐËz“€@PãRzù|Œ@µjÕ«V­Àå˗.\ ÀÎu©ÚžÞzÀyõ=®Fí€@IÁ_š’Q@?ãǏ@A @Àå˗.\ ÀËÉû»Ç€Àvsf~Ìš€@B¶Þ÷¿þÜ@A·nÝ»víÀå˗.\ ÀÊú˜¬<;eÀurîÂÍ€×€@A҈©!@BN:téÓÀå˗.\ ÀÊ4”Û†ºªÀtwèÝØXõ€@>îœÈ“[S@Bå˗.\¹Àå˗.\ ÀÉw³û)œŽÀs„eÆÉ€@<%^×̰‡@C|ùóçϟÀå˗.\ ÀÈëßMŽÙÀr˜±eº€@9 ìKÚÛ®@D(P¡B…Àå˗.\ ÀÈ)Høí<Àq¶pBžŒø€@7ZÜýòŠÓ@D«V­ZµjÀå˗.\ ÀÇtӃ‹-ÀpÝÂ~ FӀ@5MEÃ2eb@EB… +(PÀå˗.\ ÀÆÙOHËÀpÔS]q‹€@3rÍC>ÿA@EÙ³f͛6Àå˗.\ ÀÆE?ۆ7_Àn“GAÝCš€@1ƟŽî>*@FpáÇÀå˗.\ ÀÅžJRà:ÓÀmþ ìs€@0Dbî|1Ù@G @Àå˗.\ ÀÅ2.á[ŽÀkºzªýj€@-Ð#èð@GŸ>|ùóçÀå˗.\ ÀIJ&a]4Àjo‰&=€@+[u^‹ë@H6lÙ³fÍÀå˗.\ ÀÄ8&ǝ„Ài9{Tç€@)#›ûÍö@H͛6lÙ³Àå˗.\ ÀÃð|ï-Àh°Pª€@'"P¬§Äæ@Idɓ&L™Àå˗.\ ÀÃTn&ʖšÀg®Š5O®€@%R¢f„@Iû÷ïß¿~Àå˗.\ ÀÂêFžü@ÀfÝé{ý€@#­Õ/Šœ‚@J“&L™2dÀå˗.\ À„F%°ÇžÀeÀâ†Õû€@"1^¢³í@K*T©R¥JÀå˗.\ ÀÂ"Иŭ°Àd0{œ­j¡€@ ØÂIJÈH@KÁƒ 0Àå˗.\ ÀÁÅm„ø…šÀcZFÔ-+î€@A¥x^=@LX±bŋÀå˗.\ ÀÁkáp|ÉâÀb2¯×ݍ€@ R^ •@Lïß¿~ýûÀå˗.\ ÀÁõå€ÀaÑc®­€@ +©ÛÖRB@M‡8páÀå˗.\ ÀÀÃu±[§Àa<$.D€@:0Do§ò@N< Ižð€@ úšŽÄÓs@R 0`Àå˗.\ À»ÉqŒÑ™ÀW‰ô4Ài€@ +Ÿ54M@RN:téÓÀå˗.\ À»[ÚOïëÀVÝN :8ª€@ ]DŒ°ª@Rš4hÑ£FÀå˗.\ ÀºñY©e†ÀV:eá*“€@2šõ_²Ž@Rå˗.\¹Àå˗.\ Àº‰Åì µŸÀUŸ§ví{€@¶ ïG+@S1bŋ,Àå˗.\ Àº$ûÁ;×OÀU uvK/›€@tPfc®@S|ùóçϟÀå˗.\ À¹ÂÚÕ_§6ÀT~Ò§Ó€@&þ€5ÖU@Sȑ"D‰Àå˗.\ À¹cLvýœŸÀSõŠ%¶N€@CÜ?õ?Ž@T(P¡B…Àå˗.\ À¹;ÝÐÑÀSpÎí³?R€@nœÌ¯© @T_¿~ýû÷Àå˗.\ Àž«”å‘ùÞÀRð)åóà€@Šq’y±Ì@T«V­ZµjÀå˗.\ ÀžSD ‹,µÀRs”ËÝêɀ@éà¶&A@TöíÛ·nÝÀå˗.\ À·ý6q~ˆQÀQúííœ ˜€@8 Œtñã@UB… +(PÀå˗.\ À·©YÑCèÀQ†0ê*«€@ LmõŠ@UŽ8páÃÀå˗.\ À·Wœ‚CÀQç+×3d€?ÿâQŒÚ@UÙ³f͛6Àå˗.\ À·ít·çkÀP§G3Ìoñ€?þޚžZfv@V%J•*T©Àå˗.\ À¶º<*.MªÀP=iŽA€?ý–3$¶6z@VpáÇÀå˗.\ À¶nxµPœ ÀO¬gƒîõ΀?ü…¡3F—Ì@VŒxñãǏÀå˗.\ À¶$“ŽzŽÀNå +éöô€?ûŒ»÷O@W @Àå˗.\ ÀµÜ~Mæ0$ÀN#Ùèg>€?ú‰t |@WS§N:tÀå˗.\ Àµ–*+°*ÀMh  Ãâ)€?ù›ÐTnt…@WŸ>|ùóçÀå˗.\ ÀµQ‰wÖLÀL³'_±¥€?ø·í‹hŽ@WêÕ«V­ZÀå˗.\ ÀµŽÕ Œ ÀLò[‰cß@X͛6lÙ³Àå˗.\ ÀŽOœÆÿêÀJå«ßŸ3€?õzŽŒÎ7@Y2dɓ&Àå˗.\ ÀŽš /­ÀJVŽÒ€h™€?õÅcōH @Ydɓ&L™Àå˗.\ À³Ö$°¡„_ÀI³me),€?ôñH咘f@Y°`Áƒ Àå˗.\ À³š)ÊrGÀICUښ€?ô($š«í@Yû÷ïß¿~Àå˗.\ À³_šÒë4ÀH~ÅߕD»€?ói>eÔ²Ñ@ZGåúÀD56ÛÛ>€?ìC¥ÀÄã@\ïß¿~ýûÀå˗.\ À±Z7Í;ÀC–µ‡Gm€?ëX3,©k@];víÛ·nÀå˗.\ À±,mš×uœÀC.›(¡ €?êwwÉõ@]‡8páÀå˗.\ À°ÿ•}eÀBÉÁ1(r€?韌Ց@]Ò¥J•*TÀå˗.\ À°Ó§æ»RPÀBhêmý*€?èћ +A0Y@^â¿B]®€?âèÝÄ¢M{@`cF4hÀå˗.\ À®’1Ït ÀÀ>Q_pQ#€?â`-—âƒ|@`‰$H‘"Àå˗.\ À®K;˜–1kÀ=Äòz螀?áÜ«Œ)„ý@`®Ý»víÛÀå˗.\ À®޶˜†À=:ƒÃDòî€?á^áÇò@`Ô©R¥J•Àå˗.\ À­Á"3†"À<ŽœÄ7ހ?àä9]û&y@`útéÓ§NÀå˗.\ À­}ílƯÀ<2Ž}ì…€?ànÔò’0Ð@a @Àå˗.\ À­;èMã™À;³Õ¢³€?ßûmrv@aF 0`ÁÀå˗.\ À¬û +»ŠÀ;8q÷„ä€?ß!X p@akׯ^œ{Àå˗.\ À¬»KŒs‰>À:ÀG@³˜Ï€?ÞO Q©Šþ@a‘£F4Àå˗.\ À¬|¥n-!À:K8/ýހ?݄02b&3@a·nÝ»víÀå˗.\ À¬?V( ÓÀ9Ù)Oºå¹€?ÜÀo!4™÷@aÝ:téÓ§Àå˗.\ À¬…D ÿÀ9jZltð€?Üy÷ƒ@b 0`Àå˗.\ À«ÆýŒÀ8ý€*yÒ +€?ÛMT7(@b(Ñ£FÀå˗.\ À«Œqä/]2À8“ü«a]ý€?ڜÂiHìƒ@bN:téÓÀå˗.\ À«RÝ"€ˆÀ8,òËÊë€?Ùòuô—G,@bthÑ£FÀå˗.\ À«8Åþ‚ÜÀ7Èppj*€?ÙMÜãM@bš4hÑ£FÀå˗.\ Àªâ~íáC À7f`gŽÑ›€?Ø®·€A_)@bÀÀå˗.\ Àª«©êøl£À7®^?xM€?ØÎUOj¿é2dɓ&Áá?ò¥N€€€@óŸÑW•>í?òå˗.\¹¿é2dɓ&Àù*ä–\mÀÝÀ6†¡”€@ͬNpê߁@å˗.\¹¿é2dɓ&ÀóŸh†j_ÀÌÅJ5€@°¶1{"â@ X±bŋ¿é2dɓ&Àï€ -ý;÷ÀÁØIáî̃€@a~‡ü‡–@å˗.\¹¿é2dɓ&Àë IijÀ¹)A@$oŀ@B|â5³@Ÿ>|ùóç¿é2dɓ&ÀçÛòœ§áÀ³ +Èòš#€@„Ë>íˆ5®@X±bŋ¿é2dɓ&Àå^G­ØÀ­Ô Ê3ì€@| ìã3»@ ‰$H‘"¿é2dɓ&Àãf8ÿäÅáÀ§Ô ÝÈŒ€@sj+’ûiÉ@"å˗.\¹¿é2dɓ&ÀáÏ€Z:ŠäÀ£q Ó r€@lE_ЧX@%B… +(P¿é2dɓ&Ààµè7ûîÀ Eje[å€@eË0iΉv@'Ÿ>|ùóç¿é2dɓ&ÀÞÇ]dàÀ›ÊÅ›÷–€@anlÏ”@)û÷ïß¿~¿é2dɓ&ÀÜÞEŸBs6À˜f"­²…€@\|œ§)Ç|@,X±bŋ¿é2dɓ&ÀÛ3„éñ®À•4g]p!Ā@W®Ôƙy©@.µjÕ«V­¿é2dɓ&ÀÙ¹\‘'ÇçÀ’×Þs“âî€@Súšj3¡"@0‰$H‘"¿é2dɓ&ÀØhR–ÝÕÀÜ%é©Uf€@Q(íN3@1·nÝ»ví¿é2dɓ&À×:U€ŸÅ«ÀŽSt͒y1€@MrCB”ts@2å˗.\¹¿é2dɓ&ÀÖ)îôJŒÀ‹gb6+u~€@IŸNàÂÌ@4(P¡B…¿é2dɓ&ÀÕ3|ùóç¿é2dɓ&ÀÒǞ4÷0ÀƒF™Lpc€@>û¿ï_$@8͛6lÙ³¿é2dɓ&ÀÒ2EÜÀãqÃ2è€@;’¶s W@9û÷ïß¿~¿é2dɓ&ÀÑu ?x+SÀ€¬7Õ:§ €@8™ØÑúì×@;*T©R¥J¿é2dɓ&ÀÐÜšlgfÀ5Ɩsz‚€@5þÿÊ:ÍÙ@µjÕ«V­¿é2dɓ&ÀΏ‚¡Nœ£Àz:3Ó ‚8€@/Ö5_™8ý@?ãǏ'kÀx邚j—Ä€@,µñ @@‰$H‘"¿é2dɓ&ÀÌžTžóÛÀw¹sìȰ€@)ôßÄé“à@A @¿é2dɓ&ÀËÝVÿ 8cÀv¥)Æ è€@'‡ë· Dœ@A·nÝ»ví¿é2dɓ&ÀË 6 œGPÀuŸºïO)€@%evZ–@BN:téÓ¿é2dɓ&ÀÊDŸù}õšÀt V ó]ـ@#©ýŠ‘^@Bå˗.\¹¿é2dɓ&ÀɆSúTÖÀsšš¬D›ñ€@!ԄŒPj@C|ùóçϟ¿é2dɓ&ÀÈÑݵŒ[Àr¹­\W¥C€@ U±tŽŽ@D(P¡B…¿é2dɓ&ÀÈ$XÎIL;ÀqÔCYLä€@ý }æ^@D«V­Zµj¿é2dɓ&ÀÇö⿌LÀpøŸdä€@’µ—h@EB… +(P¿é2dɓ&ÀÆãÊf…ÅÀp'B),:&€@bÑŒÕé@EÙ³f͛6¿é2dɓ&ÀÆN”ÆŠá)Àn¿‰C5~€@e=RÊt©@FpáÇ¿é2dɓ&ÀÅÀØ•ÀwÀmD5ŸKk·€@“ †+±Ð@G @¿é2dɓ&ÀÅ9ér‡ ƒÀkÞÚC¥x¿€@æÁäJ\/@GŸ>|ùóç¿é2dɓ&ÀĹ[éeZ'ÀjˆgîA€@](5Ìxþ@H6lÙ³fÍ¿é2dɓ&ÀÄ>Ç Í[ÙÀiWl‡çـ@ó© KP@H͛6lÙ³¿é2dɓ&ÀÃÉÊ¢äíÙÀh1ݬ§ €@OÄ{Fí@Idɓ&L™¿é2dɓ&ÀÃZ ì™øùÀgYµbw»€@ ïB *Bq@Iû÷ïß¿~¿é2dɓ&ÀÂï>øÿÞÁÀf€¥Ùïy€@ +Á¿‘Uá{@J“&L™2d¿é2dɓ&À‰ րÀe(Uÿ€@ìö*W@K*T©R¥J¿é2dɓ&ÀÂ'AtÿÀdBéœËpS€@ñ9åo@KÁƒ 0¿é2dɓ&ÀÁɋvÄÉÀcjüH˜â«€@GKl¡84@LX±bŋ¿é2dɓ&ÀÁo³ÁÎMxÀbŸV*‘€@Ã>×9w~@Lïß¿~ýû¿é2dɓ&ÀÁƒ[ЧÊÀaßh’€@b:BaÀ~@M‡8pá¿é2dɓ&ÀÀÆÅÀl„Àa) e"ԓ€@!Ÿ¢þŒ†@NeÀSûŒ£W €?êé6l¥b4@T(P¡B…¿é2dɓ&À¹Ë)’råÀSv‡ŒºüĀ?éÈ* Y_@T_¿~ýû÷¿é2dɓ&Àž® ö«PþÀRõŒá˟‡€?è¹+rnkä@T«V­Zµj¿é2dɓ&ÀžU g%ÀRx©,€ŽŸ€?级Ò9@TöíÛ·nÝ¿é2dɓ&À·ÿ{y1“ÀQÿºH&ÝŒ€?æË/û-Ôâ@UB… +(P¿é2dɓ&À·«ˆËšÊÛÀQŠž‰Ž‘Ì€?åéuþ;—@UŽ8páÿé2dɓ&À·Y¶šœlãÀQ5×í€?åE_í@UÙ³f͛6¿é2dɓ&À· óŒZ’bÀP«]Ž\™€?äJŠŸÒ w@V%J•*T©¿é2dɓ&À¶Œ/œH¿žÀP@øæ=€?ã‹E ýnU@VpáÇ¿é2dɓ&À¶pZ8&­wÀO³Ñ(¥ÖK€?âՎnߎ@VŒxñãǏ¿é2dɓ&À¶&dD#¶ÀNìÚ[š€?â(9¯œ@W @¿é2dɓ&ÀµÞ>ZjRÐÀN*Ÿb:Xǀ?Ⴧk„@WS§N:t¿é2dɓ&Àµ—ڏ›5²ÀMoså¹×€?àåÃΟžy@WŸ>|ùóç¿é2dɓ&ÀµS*ÕÏé}ÀL¹b¡†Ìr€?àN£]·{@WêÕ«V­Z¿é2dɓ&Àµ!ÄŒ†–ÀL ;ÇMä€?ß{ qŸîÈ@X6lÙ³fÍ¿é2dɓ&ÀŽÎ²lžØÔÀK^tnžñ€?ÞddҟÜ@X‚ @¿é2dɓ&ÀŽŽÐRTŽÀJžÞ~•_3€?ÝVþý èð@X͛6lÙ³¿é2dɓ&ÀŽPoj2ÌqÀJJ{*¶B€?ÜS&9ª_@Y2dɓ&¿é2dɓ&ÀŽ +_ÀJ\MNG€?Ýujk@Ydɓ&L™¿é2dɓ&À³×„¥¡ùÀI¹+Ñ„€?Ûó(€ž@Y°`Áƒ ¿é2dɓ&À³›|A/!ÀIµÂ, €?Úæƒ¿èTË@Yû÷ïß¿~¿é2dɓ&À³`áhÀHƒñ¡ä®Ÿ€?ÙçßÇ Ü@ZG³U "?@_L™2dɒ¿é2dɓ&À°‚ZÉ#ÝÀ@¬¯ ÚŠ€?Ì_ø¯ÅS£@_˜0`Áƒ¿é2dɓ&À¯œ\H‹À@Zrž µÔ€?ˊ&íŽ@_ãǏ枖xÿx€?É9ʌjQb@`cF4h¿é2dɓ&À®“fŽ†#À>U"ùžd€?ȃa;}@`‰$H‘"¿é2dɓ&À®Lg¶°œšÀ=ǘ¬è”M€?ÇÓâŒmòÍ@`®Ý»víÛ¿é2dɓ&À®²… ÀlÀ==÷:ÔI5€?Ç*ú=•!Á@`Ô©R¥J•¿é2dɓ&À­Â>øPÀ<ž•%Qà€?ƈXpž»@`útéÓ§N¿é2dɓ&À­‡ŒÚÀ<5Â- z›€?Åë²,í‹@a @¿é2dɓ&À­<ôŒ í_À;¶ëúh€?ÅTÀ`Í4@aF 0`Á¿é2dɓ&À¬ü˜üÒÀ;;kšû›Á€?ÄÃ@-Z +@akׯ^œ{¿é2dɓ&À¬ŒJZwȜÀ:Ã%Ó¿$€?Ä6òY£s–@a‘£F4¿é2dɓ&À¬}…šlÀ:Mü癀?ï›#Æ)@a·nÝ»ví¿é2dɓ&À¬@ÞÿžÃÀ9ÛÕQ £€?Ã-ҍ@aÝ:téÓ§¿é2dɓ&À¬pnvªžÀ9l”º±] €?®ñŠíÚ@b 0`¿é2dɓ&À«ÇâvBíQÀ9!ð3À?Â57 +MŽz@b(Ñ£F¿é2dɓ&À«Qs… qÀ8–dÏd4?€?Á¿¢Íª`@bN:téÓ¿é2dɓ&À«S·*¡À8/F:”£€?ÁN¶°D@bthÑ£F¿é2dɓ&À« W•?¿À7ʰŸq€?Àà8’Oo@bš4hÑ£F¿é2dɓ&ÀªãNFgmœÀ7hŒþO X€?ÀvºH@bÀ¿é2dɓ&Àª¬t6cQÜÀ7ÈŒîPy€?ÀaÍry¢?é2dɓ&€Áá?ò¥Nx€€ÀóŸÑW•>¡?òå˗.\¹?é2dɓ&€Àù*ä–\m ÀÝÀ6†¡D€ÀͬNpêßÖ@å˗.\¹?é2dɓ&€ÀóŸh†j_ÀÌÅJ5ÿ€À°¶1{#,@ X±bŋ?é2dɓ&€Àï€ -ý;÷ÀÁØIáîÌ}€Àa~‡üˆ@å˗.\¹?é2dɓ&€Àë IijÀ¹)A@$o€ÀB|â6@Ÿ>|ùóç?é2dɓ&€ÀçÛòœ§áÀ³ +Èòš€À„Ë>íˆ6@X±bŋ?é2dɓ&€Àå^G­ØÀ­Ô Ê3í€À| ìã4I@ ‰$H‘"?é2dɓ&€Àãf8ÿäÅáÀ§Ô ÝÈŸ€Àsj+’ûj-@"å˗.\¹?é2dɓ&€ÀáÏ€Z:ŠäÀ£q Ó q€ÀlE_Чì@%B… +(P?é2dɓ&€Ààµè7ûîÀ Eje[å€ÀeË0iΉê@'Ÿ>|ùóç?é2dɓ&€ÀÞÇ]dàÀ›ÊÅ›÷“€ÀanlÏð@)û÷ïß¿~?é2dɓ&€ÀÜÞEŸBs6À˜f"­²ƒ€À\|œ§)È +@,X±bŋ?é2dɓ&€ÀÛ3„éñ®À•4g]p!ĀÀW®Ôƙz@.µjÕ«V­?é2dɓ&€ÀÙ¹\‘'ÇçÀ’×Þs“âî€ÀSúšj3¡@0‰$H‘"?é2dɓ&€ÀØhR–ÝÕÀÜ%é©Ue€ÀQ(íN3h@1·nÝ»ví?é2dɓ&€À×:U€ŸÅ«ÀŽSt͒y/€ÀMrCB”u@2å˗.\¹?é2dɓ&€ÀÖ)îôJŒÀ‹gb6+u|€ÀIŸNàÃO@4(P¡B…?é2dɓ&€ÀÕ3|ùóç?é2dɓ&€ÀÒǞ4÷0ÀƒF™Lpc€À>û¿ï_$œ@8͛6lÙ³?é2dɓ&€ÀÒ2EÜÀãqÃ2è€À;’¶s á@9û÷ïß¿~?é2dɓ&€ÀÑu ?x+SÀ€¬7Õ:§!€À8™ØÑúíO@;*T©R¥J?é2dɓ&€ÀÐÜšlgfÀ5Ɩsz„€À5þÿÊ:ÎC@µjÕ«V­?é2dɓ&€ÀΏ‚¡Nœ£Àz:3Ó ‚8€À/Ö5_™9¡@?ãǏ'kÀx邚j—Ä€À,µñ °@@‰$H‘"?é2dɓ&€ÀÌžTžóÛÀw¹sìȰ€À)ôßÄé”m@A @?é2dɓ&€ÀËÝVÿ 8cÀv¥)Æ è€À'‡ë· E@@A·nÝ»ví?é2dɓ&€ÀË 6 œGPÀuŸºïO)€À%evZ–{@BN:téÓ?é2dɓ&€ÀÊDŸù}õšÀt V ó]ـÀ#©ýŠ‘Ë@Bå˗.\¹?é2dɓ&€ÀɆSúTÖÀsšš¬D›ñ€À!ԄŒPjf@C|ùóçϟ?é2dɓ&€ÀÈÑݵŒ[Àr¹­\W¥D€À U±tµ@D(P¡B…?é2dɓ&€ÀÈ$XÎIL;ÀqÔCYLå€Àý }æ^Œ@D«V­Zµj?é2dɓ&€ÀÇö⿌LÀpøŸdä€À’µ—þ@EB… +(P?é2dɓ&€ÀÆãÊf…ÅÀp'B),:'€ÀbÑŒÖr@EÙ³f͛6?é2dɓ&€ÀÆN”ÆŠá)Àn¿‰C5~€Àe=RÊu$@FpáÇ?é2dɓ&€ÀÅÀØ•ÀwÀmD5ŸKk·€À“ †+²?@G @?é2dɓ&€ÀÅ9ér‡ ƒÀkÞÚC¥xÀ€ÀæÁäJ\•@GŸ>|ùóç?é2dɓ&€ÀĹ[éeZ'ÀjˆgîB€À](5ÌyZ@H6lÙ³fÍ?é2dɓ&€ÀÄ>Ç Í[ÙÀiWl‡çۀÀó© K¥@H͛6lÙ³?é2dɓ&€ÀÃÉÊ¢äíÙÀh1ݬ§ €ÀOÄ{Fî8@Idɓ&L™?é2dɓ&€ÀÃZ ì™øùÀgYµbwŸ€À ïB *Bþ@Iû÷ïß¿~?é2dɓ&€ÀÂï>øÿÞÁÀf€¥Ùï~€À +Á¿‘Uáù@J“&L™2d?é2dɓ&€À‰ րÀe(U€Àìö*Í@K*T©R¥J?é2dɓ&€ÀÂ'AtÿÀdBéœËpV€Àñ9åot@KÁƒ 0?é2dɓ&€ÀÁɋvÄÉÀcjüH˜â­€ÀGKl¡8—@LX±bŋ?é2dɓ&€ÀÁo³ÁÎMxÀbŸV*’€ÀÃ>×9wÚ@Lïß¿~ýû?é2dɓ&€ÀÁƒ[ЧÊÀaßh’€Àb:BaÀÔ@M‡8pá?é2dɓ&€ÀÀÆÅÀl„Àa) e"ԓ€À!Ÿ¢þŒÔ@NeÀSûŒ£W +€¿êé6l¥b¥@T(P¡B…?é2dɓ&€À¹Ë)’råÀSv‡ŒºüĀ¿éÈ* YÊ@T_¿~ýû÷?é2dɓ&€Àž® ö«PþÀRõŒá˟‡€¿è¹+rnlL@T«V­Zµj?é2dɓ&€ÀžU g%ÀRx©,€Ž €¿çº§Ò9x@TöíÛ·nÝ?é2dɓ&€À·ÿ{y1“ÀQÿºH&ÝŒ€¿æË/û-ÕA@UB… +(P?é2dɓ&€À·«ˆËšÊÛÀQŠž‰Ž‘Í€¿åéuþ;õ@UŽ8páÃ?é2dɓ&€À·Y¶šœlãÀQ5×åE`D@UÙ³f͛6?é2dɓ&€À· óŒZ’bÀP«]Ž\š€¿äJŠŸÒ Í@V%J•*T©?é2dɓ&€À¶Œ/œH¿žÀP@øæ>€¿ã‹E ýnª@VpáÇ?é2dɓ&€À¶pZ8&­wÀO³Ñ(¥ÖM€¿âՎnà@VŒxñãǏ?é2dɓ&€À¶&dD#¶ÀNìÚ[œ€¿â(9° @W @?é2dɓ&€ÀµÞ>ZjRÐÀN*Ÿb:Xɀ¿áƒ‡k„O@WS§N:t?é2dɓ&€Àµ—ڏ›5²ÀMoså¹Ù€¿àåÃΟžÅ@WŸ>|ùóç?é2dɓ&€ÀµS*ÕÏé}ÀL¹b¡†Ìt€¿àN£]·Â@WêÕ«V­Z?é2dɓ&€Àµ!ÄŒ†–ÀL ;ÇM倿ß{ qŸïT@X6lÙ³fÍ?é2dɓ&€ÀŽÎ²lžØÔÀK^tnžñ€¿ÞddÒ f@X‚ @?é2dɓ&€ÀŽŽÐRTŽÀJžÞ~•_5€¿ÝVþý év@X͛6lÙ³?é2dɓ&€ÀŽPoj2ÌqÀJJ{*¶C€¿ÜS&9ªà@Y2dɓ&?é2dɓ&€ÀŽ +_ÀJ\MNG€¿Ýujÿ@Ydɓ&L™?é2dɓ&€À³×„¥¡ùÀI¹+Ñ„€¿Ûó(E@Y°`Áƒ ?é2dɓ&€À³›|A/!ÀIµÂ, €¿Ú惿èUT@Yû÷ïß¿~?é2dɓ&€À³`áhÀHƒñ¡ä®Ÿ€¿ÙçßÇ +_@ZG³U "Õ@_L™2dɒ?é2dɓ&€À°‚ZÉ#ÝÀ@¬¯ ÚŠ€¿Ì_ø¯ÅT3@_˜0`Áƒ?é2dɓ&€À¯œ\H‹À@Zrž µÔ€¿ËŠ&í @_ãǏ枖xÿx€¿É9ʌjQâ@`cF4h?é2dɓ&€À®“fŽ†#À>U"ùžd€¿Èƒa;û@`‰$H‘"?é2dɓ&€À®Lg¶°œšÀ=ǘ¬è”M€¿ÇÓâŒmóE@`®Ý»víÛ?é2dɓ&€À®²… ÀlÀ==÷:ÔI5€¿Ç*ú=•"5@`Ô©R¥J•?é2dɓ&€À­Â>øPÀ<ž•%Qà€¿ÆˆXpŸ.@`útéÓ§N?é2dɓ&€À­‡ŒÚÀ<5Â- z›€¿Åë²,틀@a @?é2dɓ&€À­<ôŒ í_À;¶ëúh€¿ÅTÀ`Í4‰@aF 0`Á?é2dɓ&€À¬ü˜üÒÀ;;kšû›Á€¿ÄÃ@-Z +x@akׯ^œ{?é2dɓ&€À¬ŒJZwȜÀ:Ã%Ó¿$€¿Ä6òY£sþ@a‘£F4?é2dɓ&€À¬}…šlÀ:Mü癀¿Ã¯›#Æ)|@a·nÝ»ví?é2dɓ&€À¬@ÞÿžÃÀ9ÛÕQ £€¿Ã-ҍe@aÝ:téÓ§?é2dɓ&€À¬pnvªžÀ9l”º±] €¿Â®ñŠí9@b 0`?é2dɓ&€À«ÇâvBíQÀ9!ð3À¿Â57 +MŽ×@b(Ñ£F?é2dɓ&€À«Qs… qÀ8–dÏd4?€¿Á¿¢Íª»@bN:téÓ?é2dɓ&€À«S·*¡À8/F:”£€¿ÁN¶°œ@bthÑ£F?é2dɓ&€À« W•?¿À7ʰŸq€¿Àà8’OÅ@bš4hÑ£F?é2dɓ&€ÀªãNFgmœÀ7hŒþO X€¿ÀvºHâ@bÀ?é2dɓ&€Àª¬t6cQÜÀ7ÈŒîPy€¿ÀaÍryô@å˗.\ÀÀô7\ù! €€ÀҌÌèüž?òå˗.\¹@å˗.\ÀÀòô&”`þÀœw±œ€Àʋ‚Ö×ÐB@å˗.\¹@å˗.\ÀÀðœg¶î§ÆÀ¿5ΏaK€ÀŒµ„Hs@ X±bŋ@å˗.\ÀÀí[·0&À¹˜«^_&ƀÀ°Vj?]é„@å˗.\¹@å˗.\ÀÀé¡`[2.ðÀŽ~8¢{å݀À€]HxÿǓ@Ÿ>|ùóç@å˗.\ÀÀæéÕÙxšÀ°}>ÍþÀ€Àšë“X &@X±bŋ@å˗.\ÀÀäžF¹!*ÀªŠ üÇ€À’ ¥;$Й@ ‰$H‘"@å˗.\ÀÀâðdYÛ‰À¥Ñ,äŽÞ'€ÀŠéSÈ,cY@"å˗.\¹@å˗.\ÀÀáxlÞOÎÀ¢'Xñ]d€À„4næ>¹C@%B… +(P@å˗.\ÀÀà<àÿDXÀžÐ{U©ìӀÀaqŒ +@'Ÿ>|ùóç@å˗.\ÀÀÞ\NíËbÆÀ𕐭Žj€Ày£E€t¢@)û÷ïß¿~@å˗.\ÀÀÜ†Žšz³À—=a×JŸ¶€ÀtˆYžÍ)@,X±bŋ@å˗.\ÀÀÚééÈrYÀ”ŠN|û€Àq"Ÿ7Òï@.µjÕ«V­@å˗.\ÀÀÙ{cÓüÖÀ’QÞcXW·€ÀmŸÒþ­‘@0‰$H‘"@å˗.\ÀÀØ3jQßz‚ÀpE#æÎe€ÀhÚ^xs«Ã@1·nÝ»ví@å˗.\ÀÀ× qÑå·ÇÀ£BÍÀ‡€Àe}ù +ào@2å˗.\¹@å˗.\ÀÀÖõúc&çÀŠÕ’>úœ€ÀbŸMFL9}@4(P¡B…@å˗.\ÀÀÕ1khÓJÀˆei¹²±³€À`y‚÷RÐ@5B… +(P@å˗.\ÀÀÔ3тŒ”÷À†NoÆ&<€À]&<ÄÐû2@6páÇ@å˗.\ÀÀÓi³jº À„…-]€OQ€ÀYﲍ`,@7Ÿ>|ùóç@å˗.\ÀÀÒ¯Dø#ŒÀ‚ú<ïÚ€ÀW/E@8͛6lÙ³@å˗.\ÀÀÒs-€žÀ¡í Ï€ÀTίÖǍ@9û÷ïß¿~@å˗.\ÀÀÑa‹‘cÎaÀ€s:\Xøs€ÀR¹L˜a ·@;*T©R¥J@å˗.\ÀÀÐË"©f2?À~ҐËz“€ÀPãRzù|Û@µjÕ«V­@å˗.\ÀÀÎu©ÚžÞzÀyõ=®Fí€ÀIÁ_š’@?ãǏîœÈ“[ˆ@Bå˗.\¹@å˗.\ÀÀÉw³û)œŽÀs„eÆÉ€À<%^×̰ž@C|ùóçϟ@å˗.\ÀÀÈëßMŽÙÀr˜±eº€À9 ìKÚÛÚ@D(P¡B…@å˗.\ÀÀÈ)Høí<Àq¶pBžŒø€À7ZÜýòŠû@D«V­Zµj@å˗.\ÀÀÇtӃ‹-ÀpÝÂ~ FӀÀ5MEÃ2eˆ@EB… +(P@å˗.\ÀÀÆÙOHËÀpÔS]q‹€À3rÍC>ÿb@EÙ³f͛6@å˗.\ÀÀÆE?ۆ7_Àn“GAÝC©€À1ƟŽî>J@FpáÇ@å˗.\ÀÀÅžJRà:ÓÀmþ ìt€À0Dbî|1õ@G @@å˗.\ÀÀÅ2.á[ŽÀkºzªýk€À-Ð#èðI@GŸ>|ùóç@å˗.\ÀÀIJ&a]4Àjo‰&=€À+[u^‹@H6lÙ³fÍ@å˗.\ÀÀÄ8&ǝ„Ài9{Tê€À)#›ûÍö1@H͛6lÙ³@å˗.\ÀÀÃð|ï-Àh°Pª€À'"P¬§Å@Idɓ&L™@å˗.\ÀÀÃTn&ʖšÀg®Š5O°€À%R¢fª@Iû÷ïß¿~@å˗.\ÀÀÂêFžü@ÀfÝé{þ€À#­Õ/Šœ£@J“&L™2d@å˗.\ÀÀ„F%°ÇžÀeÀâ†Õü€À"1^¢³í @K*T©R¥J@å˗.\ÀÀÂ"Иŭ°Àd0{œ­j£€À ØÂIJÈf@KÁƒ 0@å˗.\ÀÀÁÅm„ø…šÀcZFÔ-+ï€ÀA¥x^t@LX±bŋ@å˗.\ÀÀÁkáp|ÉâÀb2¯×ݐ€À R^ ÂÇ@Lïß¿~ýû@å˗.\ÀÀÁõå€ÀaÑc®­€À +©ÛÖRs@M‡8pá@å˗.\ÀÀÀÃu±[§Àa<$.D€À:0Doš@N< Ižð€À úšŽÄÓ¥@R 0`@å˗.\ÀÀ»ÉqŒÑ™ÀW‰ô4Ài€À +Ÿ54ML@RN:téÓ@å˗.\ÀÀ»[ÚOïëÀVÝN :8ª€À ]DŒ°×@Rš4hÑ£F@å˗.\ÀÀºñY©e†ÀV:eá*“€À2šõ_²·@Rå˗.\¹@å˗.\ÀÀº‰Åì µŸÀUŸ§ví{€À¶ ïGS@S1bŋ,@å˗.\ÀÀº$ûÁ;×OÀU uvK/›€ÀtPfc×@S|ùóçϟ@å˗.\ÀÀ¹ÂÚÕ_§6ÀT~Ò§Ó€À&þ€5Öx@Sȑ"D‰@å˗.\ÀÀ¹cLvýœŸÀSõŠ%¶O€ÀCÜ?õ?²@T(P¡B…@å˗.\ÀÀ¹;ÝÐÑÀSpÎí³?S€ÀnœÌ¯©C@T_¿~ýû÷@å˗.\ÀÀž«”å‘ùÞÀRð)åóá€ÀŠq’y±í@T«V­Zµj@å˗.\ÀÀžSD ‹,µÀRs”ËÝêʀÀéà¶&`@TöíÛ·nÝ@å˗.\ÀÀ·ý6q~ˆQÀQúííœ ™€À8 Œtò@UB… +(P@å˗.\ÀÀ·©YÑCèÀQ†0ê*¬€À Lmõ§@UŽ8páÃ@å˗.\ÀÀ·Wœ‚CÀQç+×3e€¿ÿâQŒÚÇ@UÙ³f͛6@å˗.\ÀÀ·ít·çkÀP§G3Ìoò€¿þޚžZfª@V%J•*T©@å˗.\ÀÀ¶º<*.MªÀP=iŽA€¿ý–3$¶6¯@VpáÇ@å˗.\ÀÀ¶nxµPœ ÀO¬gƒîõЀ¿ü…¡3F˜@VŒxñãǏ@å˗.\ÀÀ¶$“ŽzŽÀNå +éöö€¿ûŒ»÷~@W @@å˗.\ÀÀµÜ~Mæ0$ÀN#Ùèg>€¿ú‰t |H@WS§N:t@å˗.\ÀÀµ–*+°*ÀMh  Ãâ)€¿ù›ÐTnt°@WŸ>|ùóç@å˗.\ÀÀµQ‰wÖLÀL³'_±¥‘€¿ø·í‹hÜ@WêÕ«V­Z@å˗.\ÀÀµŽÕ Œ ÀLò[‰d@X͛6lÙ³@å˗.\ÀÀŽOœÆÿêÀJå«ßŸ3€¿õzŽŒÎ7B@Y2dɓ&@å˗.\ÀÀŽš /­ÀJVŽÒ€h™€¿õÅcōHE@Ydɓ&L™@å˗.\ÀÀ³Ö$°¡„_ÀI³me),€¿ôñH咘‰@Y°`Áƒ @å˗.\ÀÀ³š)ÊrGÀICUښ€¿ô($š«€@Yû÷ïß¿~@å˗.\ÀÀ³_šÒë4ÀH~ÅߕD»€¿ói>eÔ²ò@ZGåúÀD56ÛÛ>€¿ìC¥ÀÅ@\ïß¿~ýû@å˗.\ÀÀ±Z7Í;ÀC–µ‡Gm€¿ëX3,©™@];víÛ·n@å˗.\ÀÀ±,mš×uœÀC.›(¡ €¿êwwÉ"@]‡8pá@å˗.\ÀÀ°ÿ•}eÀBÉÁ1(r€¿éŸŒÕŒ@]Ò¥J•*T@å˗.\ÀÀ°Ó§æ»RPÀBhêmý*€¿èћ +A0„@^â¿B]®€¿âèÝÄ¢Mœ@`cF4h@å˗.\ÀÀ®’1Ït ÀÀ>Q_pQ#€¿â`-—⃛@`‰$H‘"@å˗.\ÀÀ®K;˜–1kÀ=Äòz螀¿áÜ«Œ)…@`®Ý»víÛ@å˗.\ÀÀ®޶˜†À=:ƒÃDòá^áÈ@`Ô©R¥J•@å˗.\ÀÀ­Á"3†"À<ŽœÄ7ހ¿àä9]û&–@`útéÓ§N@å˗.\ÀÀ­}ílƯÀ<2Ž}ì…€¿ànÔò’0í@a @@å˗.\ÀÀ­;èMã™À;³Õ¢³€¿ßûmr¬@aF 0`Á@å˗.\ÀÀ¬û +»ŠÀ;8q÷„䀿ß!X pD@akׯ^œ{@å˗.\ÀÀ¬»KŒs‰>À:ÀG@³˜Ï€¿ÞO Q©‹2@a‘£F4@å˗.\ÀÀ¬|¥n-!À:K8/ýހ¿Ý„02b&e@a·nÝ»ví@å˗.\ÀÀ¬?V( ÓÀ9Ù)Oºå¹€¿ÜÀo!4š)@aÝ:téÓ§@å˗.\ÀÀ¬…D ÿÀ9jZltð€¿Üy÷³@b 0`@å˗.\ÀÀ«ÆýŒÀ8ý€*yÒ +€¿ÛMT7V@b(Ñ£F@å˗.\ÀÀ«Œqä/]2À8“ü«a]ý€¿ÚœÂiHì±@bN:téÓ@å˗.\ÀÀ«RÝ"€ˆÀ8,òËÊ뀿Ùòuô—GX@bthÑ£F@å˗.\ÀÀ«8Åþ‚ÜÀ7Èppj*€¿ÙMÜãME@bš4hÑ£F@å˗.\ÀÀªâ~íáC À7f`gŽÑ›€¿Ø®·€A_R@bÀ@å˗.\ÀÀª«©êøl£À7®^?xM€¿ØÎUO”@~ýû÷ïàÀîrRÎF€€ÀÁԏL?Њ?òå˗.\¹@~ýû÷ïàÀí·œãÿŸåÀ¢Ÿ÷¯ú”·€ÀÀ&ÌòmœÁ@å˗.\¹@~ýû÷ïàÀëàlœ}?ÇÀ­pªV_º[€Àž¢FF na@ X±bŋ@~ýû÷ïàÀéš ‰rÍñÀ¯8ŽßŸ}€À±pS·Œ[ù@å˗.\¹@~ýû÷ïàÀç],ÏK;‡À­„®]ú¿€Àše –_q@Ÿ>|ùóç@~ýû÷ïàÀåYÓv,ƒÀ©n±"¶°€À¡+™ÉŸ@X±bŋ@~ýû÷ïàÀ㜀ƒ3ñÀ¥ÌMžö €À™¬€&ñùæ@ ‰$H‘"@~ýû÷ïàÀâþSä‹À¢ŸG ЀÀ“F/¶XÁz@"å˗.\¹@~ýû÷ïàÀàØÙÕêŸcÀŸü(Ïúô€ÀÉΑ±³ã@%B… +(P@~ýû÷ïàÀß~kIQÀ›È90H˜@€À‡¢@í€ßJ@'Ÿ>|ùóç@~ýû÷ïàÀݒ€ó²À˜a?aÕØ€Àƒ&ˆÂÁû@)û÷ïß¿~@~ýû÷ïàÀÛáûèjÀ•—Ñ0=ls€À“ÅeҖ@,X±bŋ@~ýû÷ïàÀÚ_ŒôͲÅÀ“GËJ§µæ€Àzq£àâ(@.µjÕ«V­@~ýû÷ïàÀÙGî}À‘V æ‰þ“€Àvwdj Lã@0‰$H‘"@~ýû÷ïàÀ×ί7^öÀPAµ¿^€ÀsVÀ¥žÅ@1·nÝ»ví@~ýû÷ïàÀÖµmÈëÀŒ_P‘s›~€ÀpÕþs +Á¥@2å˗.\¹@~ýû÷ïàÀÕµ_ÀÆoÑÀ‰Ëb€÷Qƒ€Àm˜;JL@4(P¡B…@~ýû÷ïàÀÔ̔̅$À‡‡V°ŽS€Àj6ÊۚB@5B… +(P@~ýû÷ïàÀÓ÷ŸûO*À…’“ö'P€Àg]ö’?µŠ@6páÇ@~ýû÷ïàÀÓ4 ŸŸV?Àƒä@]=0€ÀdïDøÒ‚ô@7Ÿ>|ùóç@~ýû÷ïàÀÒ$ɒqéÀ‚nÂ2< +€ÀbÔG Œö@8͛6lÙ³@~ýû÷ïàÀÑ×$U=ÅjÀ'ŽÐօƀÀ`ü òä1•@9û÷ïß¿~@~ýû÷ïàÀÑ:{1ÆÀ€ +MV6}€À^³E±ë#@;*T©R¥J@~ýû÷ïàÀЧ×ÏŠIïÀ~!!4=ý€À[Æ«ÔÕ¢y@µjÕ«V­@~ýû÷ïàÀÎAD8Ží"Àyj!LW«€ÀTš'K“Ô@?ãǏ@FpáÇ@~ýû÷ïàÀŧQEÍëÀlË c˜рÀ:µŠ]1Ë·@G @@~ýû÷ïàÀÅ"‚•›XÀkqoØT'2€À8xæAž@GŸ>|ùóç@~ýû÷ïàÀÄ£à¡qEÀj-§Sm‚€À6tÜWhœ@H6lÙ³fÍ@~ýû÷ïàÀÄ+,H‰UÀhþݬˆp€À4£}¶µµ@H͛6lÙ³@~ýû÷ïàÀ÷ÆLõáÀgàón Ìc€À2ÿv˜vÆ@Idɓ&L™@~ýû÷ïàÀÃILî•#€ÀfÕYÚ8f€À1„rÀl@Iû÷ïß¿~@~ýû÷ïàÀÂßÇwö5/ÀeÙ(43€À0-3,Ñ:@J“&L™2d@~ýû÷ïàÀÂzÄýÈbÀdë÷KˆƒÂ€À-îE[ÌÝ@K*T©R¥J@~ýû÷ïàÀÂaGDÀd t$Ò§€À+œ?l¿-‚@KÁƒ 0@~ýû÷ïàÀÁœA^iO$Àc9£ðËL€À)Á…ÜO<Ã@LX±bŋ@~ýû÷ïàÀÁdH#{ªÀbr óJO€À'õì9ÐfÍÔ@Qkׯ^œ{@~ýû÷ïàÀŒŠÒwv ¿ÀXè㺀À¯ã-ä…u@Q·nÝ»ví@~ýû÷ïàÀŒ2ëÍòVIÀX*=‹‰›€À|˜w:ÿ@R 0`@~ýû÷ïàÀ»ÂmßÝÎAÀWv€ò?p€À` +¶x€@RN:téÓ@~ýû÷ïàÀ»U*µq/©ÀVÌwø¹ì€ÀWŒÕâ@Rš4hÑ£F@~ýû÷ïàÀºê÷ÃGÀV*hyF[€Àa—Ö­(@Rå˗.\¹@~ýû÷ïàÀºƒ­™dµÀU»_²•€À{šI•›ì@S1bŋ,@~ýû÷ïàÀº'bèêÀTþR­+?€À€1þRY@S|ùóçϟ@~ýû÷ïàÀ¹œF¥ºÀTqºçS‚€ÀÙœèÃς@Sȑ"D‰@~ýû÷ïàÀ¹]ô œ‘ªÀSé[?Œçw€À2ìrÊ@T(P¡B…@~ýû÷ïàÀ¹Ú$<âÀSe?2_?Œ€Àg™Ÿ¬6š@T_¿~ýû÷@~ýû÷ïàÀžŠ©ååÐÀÀRåEšqW~€À|Ä»ûv@T«V­Zµj@~ýû÷ïàÀžN‹ã*qÀRiMÇÍÑ.€À;Õ‚]@TöíÛ·nÝ@~ýû÷ïàÀ·ø¬¢XYÀQñ7®z…€À ›@UB… +(P@~ýû÷ïàÀ·€ü©Ê{PÀQ|ᝁ}€À ì$ÚgšX@UŽ8páÃ@~ýû÷ïàÀ·Si¬zqWÀQ -ô-{ü€À +Ú¶>í'@UÙ³f͛6@~ýû÷ïàÀ·âÊu‡OÀPžý^sÝـÀ ֓™¶Ó@V%J•*T©@~ýû÷ïàÀ¶¶W³YHƒÀP51Õ02΀ÀÞ²ò(Õ@VpáÇ@~ýû÷ïàÀ¶jž¢Ü„¡ÀO\$0+‘€ÀòYùøò@VŒxñãǏ@~ýû÷ïàÀ¶ ö],·‰ÀNÖ«/ *€Àø8 ™|@W @@~ýû÷ïàÀµÙ+,€²ÀNkŸ·€À7oL·#-@WS§N:t@~ýû÷ïàÀµ’Í֞ëEÀM[qè7¶€ÀgÇŒd€@WŸ>|ùóç@~ýû÷ïàÀµNKŠE™ ÀLŠ~+E¬À€À QbDŠ@WêÕ«V­Z@~ýû÷ïàÀµ nYþB„ÀK÷$€ÀànÈwÊ@X6lÙ³fÍ@~ýû÷ïàÀŽÊ)&àߟÀKLð9qù„€À'‰“‰©Ü@X‚ @@~ýû÷ïàÀފo³f•yÀJ§õ9Ÿ$€Àup§í@X͛6lÙ³@~ýû÷ïàÀŽL6œ£±ÀJïß¹›€ÀÈ©°V:c@Y2dɓ&@~ýû÷ïàÀŽ¿iXXpÀJJzGZ=€Àmٍ_`@Ydɓ&L™@~ýû÷ïàÀ³ÓeÆ\¢,ÀI§ù&]рÀkJ5[ÓÃ@Y°`Áƒ @~ýû÷ïàÀ³—„ñ§PïÀI eç9•ì€ÀÄl8€€¿ð4)ƒ˜÷Œ@`=zõëׯ@~ýû÷ïàÀ®×ÿ²ÝŠÀ>Ûˎfª€¿ï{ÝoœœÕ@`cF4h@~ýû÷ïàÀ®ÈÅè¥À>Iî‰v|ó€¿î˜sëJ&Ý@`‰$H‘"@~ýû÷ïàÀ®HãÍRkØÀ=ŒÙÖ¢Î̀¿íœŠÞc8¢@`®Ý»víÛ@~ýû÷ïàÀ®G„ÓnÀ=3Ÿ +Këŀ¿ìë .°6†@`Ô©R¥J•@~ýû÷ïàÀ­Ÿêü2ÛÀ<®8Ÿ}€¿ì CÐä\œ@`útéÓ§N@~ýû÷ïàÀ­{Śæ`À<,)ž€¿ë\í_«À}@a @@~ýû÷ïàÀ­9ÏØÙ9À;­ªëZgE€¿ê ±ºÈ9!@aF 0`Á@~ýû÷ïàÀ¬øÿc LxÀ;2€oè1ö€¿éë=­¥Š(@akׯ^œ{@~ýû÷ïàÀ¬¹NÕRë{À:º‹ÂŸ^w€¿é/Vk€À§{éA.!@Ÿ>|ùóç@ 0`ÁÀ㒠“m À¢cóW„±Ü€À¢!üs^ªk@X±bŋ@ 0`ÁÀâDÈw@–±À ÒÛFñ)p€Àœ*øãTYÑ@ ‰$H‘"@ 0`ÁÀá!’ìÀž4«@ ÷€À–-*ÊŘ»@"å˗.\¹@ 0`ÁÀà UøÇ-ÉÀšõ4ëÔ”€À‘¿ÌWvµ@%B… +(P@ 0`ÁÀÞ0÷EŒÔÀ˜QÃÊv¥€ÀŒàõÍGÉã@'Ÿ>|ùóç@ 0`ÁÀ܁í^IÜ@À•—yfN{€À‡×ÜPË@)û÷ïß¿~@ 0`ÁÀÚþšLœ¿­À“s ˆ»G€ÀƒòùèAþÙ@,X±bŋ@ 0`ÁÀÙ ð1˜ÀÀ‘›šýaUӀÀ€âÿŸ°QÑ@.µjÕ«V­@ 0`ÁÀØc—#K¢ÀËm@tЀÀ|â‡"‹±@0‰$H‘"@ 0`ÁÀ×B’áOŸÝÀ2 +»(z€ÀxîD"±J}@1·nÝ»ví@ 0`ÁÀÖ:ïŸ6¥kÀЧˆ§¯€Àu¶7€v¥@2å˗.\¹@ 0`ÁÀÕJþŠç³ÀˆaãÚ¡ó€ÀsÓß U§@4(P¡B…@ 0`ÁÀÔmgãÚÝÀ†ZŒˆã €Àpæ*žG.@5B… +(P@ 0`ÁÀÓ¢Ä,ÞÀ„”ªüíݱ€Àn/„t•Ï@6páÇ@ 0`ÁÀÒ眛­Ð(Àƒ +šN-Y€Àk#5—rðb@7Ÿ>|ùóç@ 0`ÁÀÒ:SÚ¥Ž^À±vk» ý€Àh…ä嫒T@8͛6lÙ³@ 0`ÁÀјâ!ìä‡À€€Ï[߃€Àf>•Ö5@9û÷ïß¿~@ 0`ÁÀÑ +ltšÀ~ä]'í$€Àd:„S7@;*T©R¥J@ 0`ÁÀÐtŸp¢ÅÀ}·ÓO^€ÀbkÚQ D@µjÕ«V­@ 0`ÁÀÍôu»!ï<Àx£eÿgU€À[íǹ‚ œ@?ãǏI_ <€ÀBÏ¥·š@G @@ 0`ÁÀÅ ÃRHP'Àkø¿È8ɀÀ@•D4%¹q@GŸ>|ùóç@ 0`ÁÀď»t%ÀiÍ '~€€À>iÊ·nÐJ@H6lÙ³fÍ@ 0`ÁÀÄÚç-™Àh§2«C€À;óO:G²@H͛6lÙ³@ 0`ÁÀÃ¥õÉnýÀg’Ëòã|€À9ŸO™D`@Idɓ&L™@ 0`ÁÀÃ9š<)öÀfŽš*¢XW€À7Ã%TšC6@Iû÷ïß¿~@ 0`ÁÀÂкV²ÖÀe™›èÖœ €À5û!kœ©@J“&L™2d@ 0`ÁÀÂlÕA 5ˆÀd²”žÊŸa€À4`j1|@K*T©R¥J@ 0`ÁÀ {>j™Àcؖn«ðM€À2íÞBÄ0@KÁƒ 0@ 0`ÁÀÁ±<šÏ™Àc +¹7¥2%€À1žûÏÒ`f@LX±bŋ@ 0`ÁÀÁYPè/ÀbH'ӝÏ:€À0oË{F@»@Lïß¿~ýû@ 0`ÁÀÁnp¬@Àaö<Ýd€À.¹œ“”TÏ@M‡8pá@ 0`ÁÀÀ³Âã4À`âu<ü›€À,ÅÜcÃàÎ@NáUzbÁ€À$'?`§Üw@PÔ©R¥J•@ 0`ÁÀœŒ¹¶ŠÔëÀZ`…wë R€À#x¶Æ@Q @@ 0`ÁÀœY³_ÀYŽ»3gva€À"!”΋,ù@Qkׯ^œ{@ 0`ÁÀŒ›*g(‚lÀXȄË4·I€À!;âBœ‘@Q·nÝ»ví@ 0`ÁÀŒ'ÓRë+ÝÀX ý ó&€À g;uY@R 0`@ 0`ÁÀ»·Ú—RóºÀW[T‘7ªÿ€ÀBš€l„_@RN:téÓ@ 0`ÁÀ»KTajÀV²ÏGÐu@€ÀҌŠg7©@Rš4hÑ£F@ 0`ÁÀºáS÷}—øÀVÂeĶm€À{ÿŸ«@Rå˗.\¹@ 0`ÁÀºzuò%±ÁÀUz’‡&Ÿy€À9žeùû1@S1bŋ,@ 0`ÁÀºUr̝ùÀT髐4x€À *9÷-b@S|ùóçϟ@ 0`ÁÀ¹ŽÓãýéæÀT^:r€ÀðÙ +ۘ@Sȑ"D‰@ 0`ÁÀ¹UÛsVúÀSÖÛ}I„©€ÀåêÁ’Ê@T(P¡B…@ 0`ÁÀžùXf¯ÀSSÆPÆï£€ÀêI莭 +@T_¿~ýû÷@ 0`ÁÀžŸ6pTûŽÀRÔŸà.§"€ÀüšŒÿ±@T«V­Zµj@ 0`ÁÀžGc‘(9ëÀRYАP™€Àèń<@TöíÛ·nÝ@ 0`ÁÀ·ñÍ4S[nÀQâ]¯ºN€ÀGá:ƒ8@UB… +(P@ 0`ÁÀ·ža 6nÂÀQnÅ8íÞ€À} /š @UŽ8páÃ@ 0`ÁÀ·Mª_†{ÀPþÀ” Rÿ€Àœ& ˆŠ™@UÙ³f͛6@ 0`ÁÀ¶ýƵ0/žÀP’1®M¢€À†Ùñ™@V%J•*T©@ 0`ÁÀ¶°v­(pÀP(ûAtÍ€ÀXt’öÃ@VpáÇ@ 0`ÁÀ¶eçЁÀO†Â}Ò§€À²Gôfš @VŒxñãǏ@ 0`ÁÀ¶ƒ¶ý–_ÀNÀPº‰^þ€ÀdŠ\u.@W @@ 0`ÁÀµÓÃ8‡«tÀNªžëèb€Àöv——rÄ@WS§N:t@ 0`ÁÀµÀºnŠÀMF܉ ÿ…€À ґÓº²@WŸ>|ùóç@ 0`ÁÀµImûR°,ÀL’³z“Ÿ€À º(Ší”@WêÕ«V­Z@ 0`ÁÀµŸý;ÀKãþcŽ“€À ¬[€ª·@X6lÙ³fÍ@ 0`ÁÀŽÅ¥”¹pÀK:¢dôĀÀ +š_ª8Ÿë@X‚ @@ 0`ÁÀކÏ?ßmÀJ–3 X®€À ­w…í@X͛6lÙ³@ 0`ÁÀŽHplªÀIöÂ0L•ì€Àºõ…,9@Y2dɓ&@ 0`ÁÀŽ z°ÀJ8o|ÊЀÀ F©47#@Ydɓ&L™@ 0`ÁÀ³ÏIãí9%ÀI–Þ”×è€ÀQY(£‡k@Y°`Áƒ @ 0`ÁÀ³“kïµ,ÀHû*œ5у€ÀhŠ.…ÕÓ@Yû÷ïß¿~@ 0`ÁÀ³Y@‘ØÀHe +Ök׀À‹œA¯„Î@ZG€À­R“ +•@[uëׯ^œ@ 0`ÁÀ²I*µ¡“±ÀEÁ?:—ƒü€ÀÓq6ZWÖ@[Áƒ 0@ 0`ÁÀ²WŸÔ÷tÀEG|ӝë3€À/;ùþtR@\ 4hÑ£@ 0`ÁÀ±äŸe9KÍÀDÑ»YÞ §€À’”PvŒÎ@\X±bŋ@ 0`ÁÀ±³øÆ‚î»ÀD_Ͱrjà€Àý ŸxoÖ@\€H‘"Dˆ@ 0`ÁÀ±„Zëü2¢ÀCñ‰8ý̰€Àn3CRU0@\ïß¿~ýû@ 0`ÁÀ±Uœ_ÔãÀC†Å©띀¿ÿË_Ê‘X@];víÛ·n@ 0`ÁÀ±(Í#ÀC\âü²°€¿þÆIÁÉ7@]‡8pá@ 0`ÁÀ°ûc%F†ÄÀB»*Ñp]̀¿ýÌ{vI&k@]Ò¥J•*T@ 0`ÁÀ°Ï—F•íÀBZ F*Ø!€¿üÝWDVÉ«@^ã>€¿øŸ»M”·ó@_˜0`Áƒ@ 0`ÁÀ¯Žä*€£À@Mcð‚>F€¿øž–ýŒ@_ãǏÏnð'𩀿öD˜ï±@`cF4h@ 0`ÁÀ®Œ,_±ôÀ>>Ëg}W€¿õbáèÖ@`‰$H‘"@ 0`ÁÀ®Ea6GCÀ=²"xސ€¿ôÉ»õä_‡@`®Ý»víÛ@ 0`ÁÀ­ÿÝÄÀ”À=)Mx–õC€¿ô6¯²Ê[@`Ô©R¥J•@ 0`ÁÀ­»™(€±ÁÀ<€*ˆþƒ€¿ó©+F#Í@`útéÓ§N@ 0`ÁÀ­xŠÑ·žÀ<"•äa€¿ó ®^ á¶@a @@ 0`ÁÀ­6ª~Br$À;€p`%ÿj€¿ò:ò¿{i@aF 0`Á@ 0`ÁÀ¬õð7ŽgŸÀ;)š„I‹3€¿òüÅ@akׯ^œ{@ 0`ÁÀ¬¶TN¢áÀ:±ö§þ€¿ñ€LŸ>’K@a‘£F4@ 0`ÁÀ¬wÏXÃnPÀ:=hk‡€¿ñ.gzª4Á@a·nÝ»ví@ 0`ÁÀ¬:Z-*GÀ9ËÔ¯Gð€¿ðŒ¡>VÙÁ@aÝ:téÓ§@ 0`ÁÀ«ýíá|À#À9]!zŽ¡€¿ðNËŽÇ¡@b 0`@ 0`ÁÀ«ÂƒÇyÌ­À8ñ5ð›§V€¿ïÉuûµx`@b(Ñ£F@ 0`ÁÀ«ˆjZzÀ8‡úA?Iîü‹ …dB@bN:téÓ@ 0`ÁÀ«NœŒhÍIÀ8!W›Ba €¿î6‡Ë{Íž@bthÑ£F@ 0`ÁÀ«$ŽYÀ7œ8˜̀¿íw!pÌøz@bš4hÑ£F@ 0`ÁÀªÞs\äÉ¢À7[†ÕP*l€¿ìŸŽÚb@bÀ@ 0`ÁÀª§·'ÀöÀ6ü/ž=À€¿ì 4 7š@X±bŋ Àå$vßõ怀À¯Íö<Ú?òå˗.\¹@X±bŋ Àäõ'AºIµÀƒmÞµ«K€€À®ŸÌ~ù]@å˗.\¹@X±bŋ ÀärïÃ*] À‘p֒Ü%€À¬¢èÏ B@ X±bŋ@X±bŋ Àã³ópbŽÀ–€䉥€Àš^N]QiÜ@å˗.\¹@X±bŋ ÀâÌÿ‰GµíÀ™Š-ƒò€À€r•&Kº@Ÿ>|ùóç@X±bŋ Àá×A¬ð²`Àš&Ei€À ×sQ @X±bŋ@X±bŋ Ààã#Ê!ðjÀ™Z“€sÓ€À›™†3À®ù@ ‰$H‘"@X±bŋ ÀßôæšE„ŒÀ—Ó±Ff‹¡€À–¬ fL×@"å˗.\¹@X±bŋ ÀÞC--Ì1@À–Ü@ڙŒ€À’À(²©I@%B… +(P@X±bŋ À݆ܳÀ”LeraS€ÀIl¹T…¢@'Ÿ>|ùóç@X±bŋ ÀÛCX¯Lx'À’¢£:øXŽ€ÀŠ[>`ë4á@)û÷ïß¿~@X±bŋ ÀÙñي5=¥À‘O0Rƒ€À†k)×-¥/@,X±bŋ@X±bŋ ÀØ»äÔ|Þ¶ÀpõŒž™€Àƒ9ÞžÈ h@.µjÕ«V­@X±bŋ ÀמÐÕFÀŒñˆe5ó €À€™ŽŽÆ x@0‰$H‘"@X±bŋ À֘I,)(µÀŠšã PT€À|Öûm¹…Š@1·nÝ»ví@X±bŋ ÀÕŠvq×G)Àˆ§ÉÁÈA€Ày6EpLÑ@2å˗.\¹@X±bŋ ÀÔNj +t*äÀ†§yӆ»€Àv2ÄVø,@4(P¡B…@X±bŋ ÀÓùÊOæ®kÀ„íMF¥|€Às±yušc@5B… +(P@X±bŋ ÀÓ;zÞŸÁÀƒcš`ø +Œ€Àq›ÚF¿@6páÇ@X±bŋ ÀҊÑÿ%èÀ‚ö‡kÏ@€Ào³SSüë@7Ÿ>|ùóç@X±bŋ ÀÑæC;ÈšÀ€ÒÈw«š@€Àl³ŠÔ»+@8͛6lÙ³@X±bŋ ÀÑL[ÝіÀ|p#2Ün€Àjuâ`ã4@9û÷ïß¿~@X±bŋ ÀÐŒiº\ñ‰À}‹…ÛêǀÀgÔuŽôr·@;*T©R¥J@X±bŋ ÀÐ5ºÖ¶…À{ÏkVF€ÀeЪ6ÉÒ@µjÕ«V­@X±bŋ À͒ízfïÀwº ðxê€À`áuÕ~c†@?ãǏ|ùóç@X±bŋ ÀÄt(,8oÀiPü ¢€ÀBß© +흋@H6lÙ³fÍ@X±bŋ ÀÃÿ*œä Àh8f÷_Œð€ÀA\˜QwE@H͛6lÙ³@X±bŋ ÀÏ4®…°¡Àg/Ž»DŽ€À@P(ë)X@Idɓ&L™@X±bŋ ÀÃ#ýÚ +IŽÀf5ÖòuÜi€À="gˆÛ@Iû÷ïß¿~@X±bŋ ÀœBhÓa“ÀeIÒÐ@€À;s–ö$ªŸ@J“&L™2d@X±bŋ ÀÂZÊÓÀdjŒl&4¹€À9‚†j@K*T©R¥J@X±bŋ ÀÁüDå”G`Àc—ŒáeX€À7ÁŠ7Ý—@KÁƒ 0@X±bŋ ÀÁ¡‡0 ÀbÐ ùqü€À6,Q‚óÒ@LX±bŋ@X±bŋ ÀÁJr`'ÀÀbè·#Ž€À4ŒÍG±4@Lïß¿~ýû@X±bŋ ÀÀöº ãŸGÀa_«•%ŸX€À3n~Þ”@M‡8pá@X±bŋ ÀÀŠ:wy‡žÀ`µÝ E†–€À2=^PUý•@N¶ËÀZ/—Ñ€nì€À(r%’ï‰ä@Q @@X±bŋ Àœ˗ ·ÀYarUZª €À'7ü҄A@Qkׯ^œ{@X±bŋ ÀŒ‹³­ FÀXž†ûŠÐՀÀ&Š$8@Q·nÝ»ví@X±bŋ ÀŒÔ“AÀWåùŒ˜š^€À%åÇDÀ@R 0`@X±bŋ À»©ÔïˆfÂÀW7QН€À$Úld66@RN:téÓ@X±bŋ À»=³cÃ҃ÀVîìcµ•€À#˗‰vY@Rš4hÑ£F@X±bŋ ÀºÔŽÃªk-ÀUó6vށ€À"=+йŠ@Rå˗.\¹@X±bŋ ÀºnA‡"3§ÀU\鐜‰«€À!n‘–UÐá@S1bŋ,@X±bŋ Àº +šÊº•ÌÀTÍÔÙ ë€À ¬º}³r‚@S|ùóçϟ@X±bŋ À¹©ŠÉŽ QÀTCîZ€Àí óÝûU@Sȑ"D‰@X±bŋ À¹K&_{[‹ÀSŸ4O:K€À–š·­Œ@T(P¡B…@X±bŋ Àžï%VQTÀS<†GŽ˜Î€ÀR˜ùH?@T_¿~ýû÷@X±bŋ Àž•]:iSÀRŸÉM„€À!>ðpº@T«V­Zµj@X±bŋ Àž=ïC~d«ÀRDßéý€ÀÈ,`±@TöíÛ·nÝ@X±bŋ À·èžnšCÝÀQήOWu€Àï¢úž@UB… +(P@X±bŋ À·•§owÅ\ÀQ\»Øf̀Àë°Ò¹ýµ@UŽ8páÃ@X±bŋ À·D«€¶ZÀPìÿø¯TV€ÀõKWÚ@UÙ³f͛6@X±bŋ À¶õŽ^ûmÜÀPK/(zŸ€À +Ù:µ4õ@V%J•*T©@X±bŋ À¶š²Mƒ.ÀPÝý>îK€À+l”df@VpáÇ@X±bŋ À¶]– Q&ÌÀOg; ü`:€ÀV)ÕaŒa@VŒxñãǏ@X±bŋ À¶P؆^dÀN¢ß§Üè€ÀŠG°OH=@W @@X±bŋ ÀµÌÔn­’ÀMät‚³Ó”€ÀÇ 8UW@WS§N:t@X±bŋ Àµ‡ýb\ÀM+É~Ó@€À Ðw®û@WŸ>|ùóç@X±bŋ ÀµBÿ+Œ»)ÀLx­:ª‘±€ÀWõ'M'J@WêÕ«V­Z@X±bŋ ÀµŒÃbfÀKÊðZo0€Àªë„Z f@X6lÙ³fÍ@X±bŋ ÀŽ¿­2dBDÀK"dßî‚z€À/@»Am@X‚ @@X±bŋ ÀŽ€VlOÀJ~Þ/Øw=€ÀcF‹úAý@X͛6lÙ³@X±bŋ ÀŽB|U9ž•ÀIà1lw€À‚d„Šé@Y2dɓ&@X±bŋ ÀŽÍ"ØÏRÀJ ‡7“¶€À/•»y-þ@Ydɓ&L™@X±bŋ À³ÉÓÿoÀI€2¬ÃÛo€À%ñ»^ ~@Y°`Áƒ @X±bŋ À³ŽNŽ1ÀHåŠkŽw"€À üÁ×+öµ@Yû÷ïß¿~@X±bŋ À³T/îYÌ4ÀHPšüàØ€À âì1\@ZG5CH€À Öi2“Çö@Z“&L™2d@X±bŋ À²ãóÍ;ZÆÀG5þ·ð€À +×h +‹@ZÞœzõë×@X±bŋ À²­ŸÖqîÀF¯òOª€À äŒÞÄ@[*T©R¥J@X±bŋ À²xÁíûÀF.qJÞ6a€Àý£ìZ&/@[uëׯ^œ@X±bŋ À²Dð"v†µÀE±G Éî€À!lx+š€@[Áƒ 0@X±bŋ À²AäYxÀE8Aš¡[߀ÀOoÆÉ]â@\ 4hÑ£@X±bŋ À±à¬ÐHMœÀDÃ2SÀ‡èt±š@\X±bŋ@X±bŋ À±°'À$óšÀDQìOÐ~€ÀÇÉè4Ü@\€H‘"Dˆ@X±bŋ À±€©óžõvÀCäEŸeô$€À ጩä@\ïß¿~ýû@X±bŋ À±R+ +ª2ÀCzA'œ{€Àba'­À @];víÛ·n@X±bŋ À±$£O9\ÀC;d7(€À»Sˆ NŸ@]‡8pá@X±bŋ À°ø +%ÓȜÀB¯Ž±Ÿ€Àx™µn[@]Ò¥J•*T@X±bŋ À°ÌY0?ÊÀBNîï*À€À‚l%3Ï@^(äôµè€Àܓ&'@_ @X±bŋ À°&z»²À@DºKހ¿þÐM©_nõ@_ãǏãÀ?ì*[+€¿ýëq' ×@`¯^œzö@X±bŋ À¯§ü-‚¹À?T30¶€€¿ý}¿¹Ÿ@`=zõëׯ@X±bŋ À®ÏVpºotÀ>À j«Õ¯€¿ü<€?Öö@`cF4h@X±bŋ À®‡]þŸÜŒÀ>/ýswœÂ€¿ûp™¥@Oø@`‰$H‘"@X±bŋ À®@µ$Dõ¯À=£â€\€¿ú¬Üœ'(ê@`®Ý»víÛ@X±bŋ À­ûR¹fŸ6À=•¢,ck€¿ùðo£™sÁ@`Ô©R¥J•@X±bŋ À­·-êþ =À<–ó™{@þ€¿ù:úiz &@`útéÓ§N@X±bŋ À­t>7a >À<ڟ𠀿øŒ*¿©Ü@a @@X±bŋ À­2{j˜èÀÀ;˜*í"nŽ€¿÷ã°sk£Š@aF 0`Á@X±bŋ À¬ñݚêŒ[À;ÅkփK€¿÷AC}ár@akׯ^œ{@X±bŋ À¬²]%ÍéÀ:ŠŒÜ!€¿ö€YòB–@a‘£F4@X±bŋ À¬sò«Ÿ=fÀ:2e(N·€¿ö {øq\+@a·nÝ»ví@X±bŋ À¬6—æÀ9Á3nz58€¿õ{ ÖJï—@aÝ:téÓ§@X±bŋ À«úCp[ÄÀ9RÝôƝ䀿ôîÐÇC†6@b 0`@X±bŋ À«Ÿñ*µ[À8çL'€¿ôfÓ¿šÔ@b(Ñ£F@X±bŋ À«„™ÑœïçÀ8~f>r³d€¿óãt¢·;«@bN:téÓ@X±bŋ À«K72,ØÉÀ8ÉXÛǀ¿ódŠ};@bthÑ£F@X±bŋ À«ÃJДiÀ7ŽE é{€¿òéÉOGr‡@bš4hÑ£F@X±bŋ ÀªÛ8MEö™À7Rß2ÖDý€¿òsûFi€@bÀ@X±bŋ Àª€›I‘À6óÐQbÞÁ€¿òZ ü, +@!R¥J•*XÀâqK»>n€€À§šàÞüîû?òå˗.\¹@!R¥J•*XÀâUIÆôLÀwsºžaT€À§«€3ÓG@å˗.\¹@!R¥J•*XÀâFê}éÀ…ØË’UÄõ€À¥°ÿÐÒ.@ X±bŋ@!R¥J•*XÀኟ?.tÀ€+š¹åF€À£© R…@å˗.\¹@!R¥J•*XÀàñe{š­À‘gŠŸ!ԀÀ¡SՓò‹@Ÿ>|ùóç@!R¥J•*XÀàEÕ\"å‘À’±îCÍFL€ÀëÐR±¡@X±bŋ@!R¥J•*XÀß&‘GJ1À’ô¹íêâS€À™‰ÖìFH@ ‰$H‘"@!R¥J•*XÀÝÕÉëëËÀ’ˆ":^Ü"€À•²ÅšÔÀG@"å˗.\¹@!R¥J•*XÀÜlRœGKÀ‘ÁoŠg€À’u¢ª¬ô@%B… +(P@!R¥J•*XÀÛ% þbCÀÏ‚^'·b€Àˆª~¥Ëè@'Ÿ>|ùóç@!R¥J•*XÀÙñEŽ ÿ”À 0ìÛ·º€À‹\º[±é@)û÷ïß¿~@!R¥J•*XÀØÐjG'JÀ™÷‘ÿœÛ€À‡gDÜ3æ@,X±bŋ@!R¥J•*XÀ×Áµ‹Â‘€À‹ º@Úǀ€À„Uáo·@.µjÕ«V­@!R¥J•*XÀÖŖƒÂÅÀ‰ÁÀ»J€ÀÁÃTnO»@0‰$H‘"@!R¥J•*XÀÕÚª‹zÀ‡üœ‰A¯€À'šÈnŠ?@1·nÝ»ví@!R¥J•*XÀÔÿÿCr':À†Qß;։c€À{w‘…óK@2å˗.\¹@!R¥J•*XÀÔ4r/‡À„Æ?ëD 7€ÀxXÈæ ¢ @4(P¡B…@!R¥J•*XÀÓwƒ'ïÀƒY’Iq€ÀuŽä< ä@5B… +(P@!R¥J•*XÀÒÆf°çÀ‚6áe²€ÀswûKÅu3@6páÇ@!R¥J•*XÀÒ!X^™;À€åÈÎÐï]€ÀqŽè»fÍ@7Ÿ>|ùóç@!R¥J•*XÀц¹•ÛkÂÀ±æè8ˆE€ÀoÔr í£ì@8͛6lÙ³@!R¥J•*XÀÐõ޶•סÀ}Ê(“©T€Àlû‡Ë›Î…@9û÷ïß¿~@!R¥J•*XÀÐlú}4L–À|3mšUz€ÀjÃhCÅ@;*T©R¥J@!R¥J•*XÀÏØgfíoÀz}ÛÄzÀP€ÀhQ4úÃåÒ@µjÕ«V­@!R¥J•*XÀÍ!y{š.jÀvœ™¶–þ€ÀcŒ1;Z~@?ãǏ|ùóç@!R¥J•*XÀÄS˵o>÷ÀhœÎ ®k7€ÀF6Ô6Ò@H6lÙ³fÍ@!R¥J•*XÀÃá^ÓÔ(ÞÀgµ9úþ¹€ÀDyø®\~ü@H͛6lÙ³@!R¥J•*XÀÃs²ÚÍ|Àfºƒ9bA€ÀBí?Xµž@Idɓ&L™@!R¥J•*XÀà +ˆ*ˆ•ÀeÌßÐÀŸ€ÀA‰—ßXæÅ@Iû÷ïß¿~@!R¥J•*XÀÂ¥¢ÇçgÀdëŽöë5]€À@JDû#3Œ@J“&L™2d@!R¥J•*XÀÂDÊ0 ³ÀdÙwÍ=1€À>UcèjkB@K*T©R¥J@!R¥J•*XÀÁçÉ*Ë×LÀcKÎÁq€À€À*6ø 3@UÙ³f͛6@!R¥J•*XÀ¶ëšüž•ÀPlË Õü€À )/âC@V%J•*T©@!R¥J•*XÀ¶Ÿb”r“ÀPŸIùðë€ÀùoÌÌò*@VpáÇ@!R¥J•*XÀ¶TI¢gúÅÀO@‡QŒ×c€ÀóãûQcØ@VŒxñãǏ@!R¥J•*XÀ¶ ]áŽw`ÀN}ÒG¡`©€Àù›ÔUrH@W @@!R¥J•*XÀµÄ7'þ¹ +ÀMÀîæDTM€À À81ž¶@WS§N:t@!R¥J•*XÀµ~Çï,vXÀM ­ªk€À#‹#ª]U@WŸ>|ùóç@!R¥J•*XÀµ;x¡ÍÀLWà.ˆ®€ÀFF<¥Ÿ_@WêÕ«V­Z@!R¥J•*XÀŽøÜ ª`ÀK«Y6æmi€ÀqI€[ +›@X6lÙ³fÍ@!R¥J•*XÀŽžFwNÙÿÀKìºyú€À£úÈÈ@X‚ @@!R¥J•*XÀŽy6ˆ mÀJao蘥<€ÀÝɐR@X͛6lÙ³@!R¥J•*XÀŽ; Ž~d¿ÀIù,4o³€À2П +²@Y2dɓ&@!R¥J•*XÀ³ÿŒì#ŒªÀJá[›€À¯ôÊ+w@Ydɓ&L™@!R¥J•*XÀ³Ãÿrš±ÀId³ö€Àò.sÎ7g@Y°`Áƒ @!R¥J•*XÀ³‡ÃoÆÎÀHÊ󟜕÷€À>Šâb@Yû÷ïß¿~@!R¥J•*XÀ³Mâ"ø%ÍÀH7=ÖҍD€À“§y©@ZGþ‘@Z“&L™2d@!R¥J•*XÀ²ÞþŒ+ìÀG œ W:€ÀUÁja@ZÞœzõë×@!R¥J•*XÀ²šéÒK3ÀFš4ti€€À…êŽ@[*T©R¥J@!R¥J•*XÀ²sLxqŒÀF¡Õ?±€Àl£ûF4;@[uëׯ^œ@!R¥J•*XÀ²?«“dxqÀEp*z€À aX5vh@[Áƒ 0@!R¥J•*XÀ² +—˜øÀE%T 9ô€À bj(7 @\ 4hÑ£@!R¥J•*XÀ±ÛÁµ˜X ÀD±!V»*рÀ o ¶Õ÷õ@\X±bŋ@!R¥J•*XÀ±«fZMÀD@ªïÓü–€À +†Ïã0æ@\€H‘"Dˆ@!R¥J•*XÀ±|f>ÂÀCÓÈgŸª€À š×µt,@\ïß¿~ýû@!R¥J•*XÀ±M·”9÷ŸÀCjRêa•€ÀÔ£?ã›Û@];víÛ·n@!R¥J•*XÀ± SøLƒ˜ÀC%µ®Îi€À §žÔöœ@]‡8pá@!R¥J•*XÀ°óÝûßÝÀB¡ñÏð€ÀGc«FÏ'@]Ò¥J•*T@!R¥J•*XÀ°ÈNUCZÀBA•t>·€À^:\Û@^æšÀA1ùTãœã€ÀŒ€)-<à@_ @!R¥J•*XÀ°"‡¬YÁŽÀ@Üݎ¡º€ÀïR$óoŠ@_L™2dɒ@!R¥J•*XÀ¯ö(áÊàÀ@Š8j@œ +€ÀXqÕ·B6@_˜0`Áƒ@!R¥J•*XÀ¯šÃ<@ +ŒÀ@9ñœ+â€ÀǍÁéÙ@_ãǏ¬â§ÑNH€À5ä“×z@`cF4h@!R¥J•*XÀ®_wôAÀ>Ž~Ã=€Àº å€Mœ@`‰$H‘"@!R¥J•*XÀ®:áV](À=’$~[kù€ÀCïQ7ç@`®Ý»víÛ@!R¥J•*XÀ­õš šB£À= +€;Àh€¿ÿ ¶~{)R@`Ô©R¥J•@!R¥J•*XÀ­±ªØŽÊ’À<†~ðp«b€¿þÃ×:Œb@`útéÓ§N@!R¥J•*XÀ­náMÂDÀ<ÿf‡œÅ€¿ýï ŠäÅË@a @@!R¥J•*XÀ­-CG×"[À;ˆáâñB€¿ý!îõ‰R„@aF 0`Á@!R¥J•*XÀ¬ìÈíR">À;Uê*€¿ü\-˜#X…@akׯ^œ{@!R¥J•*XÀ¬­jª€›ÄÀ:˜TêúJӀ¿ûpèÈr@a‘£F4@!R¥J•*XÀ¬o!/F#ÍÀ:$¬µ/Su€¿úåhÙAúæ@a·nÝ»ví@!R¥J•*XÀ¬1åjÐׯÀ9³ôފÚù€¿ú3ÉšH*R@aÝ:téÓ§@!R¥J•*XÀ«õ°ŠCÚþÀ9FøÚG˜€¿ùˆKœ2 [@b 0`@!R¥J•*XÀ«º{õi»ƒÀ8Úñšf€A€¿øâªÂ§HQ@b(Ñ£F@!R¥J•*XÀ«€AL`˜À8rv–qd€¿øBŠŽëM}@bN:téÓ@!R¥J•*XÀ«FúeB 4À8 Œbl‹€¿÷š`b¿Ð@bthÑ£F@!R¥J•*XÀ«¡IèÿÀ7©™(È8€¿÷ƒÒüÍŸ@bš4hÑ£F@!R¥J•*XÀª×05Ó¬<À7H¥Í‡€¿öô 1‘Ÿ@bÀ@!R¥J•*XÀª ¡” &-À6é`ȧJ¯€¿õöËM¬Ó@$xñãǏ Ààe]&퀀À¢E܇lÁG?òå˗.\¹@$xñãǏ ÀàSwHÝdŒÀn¢eˆ"¥Þ€À¡ûQ¥·‰K@å˗.\¹@$xñãǏ Ààœ¶Ÿ<À}$Ð9$/€À¡,@rËÛá@ X±bŋ@$xñãǏ Àߗ’mÝÏÀ„RYB&á€ÀŸî"ŽŒ~@å˗.\¹@$xñãǏ ÀÞÂnùmфÀˆŠÞ}‹œw€Àš@ @Ÿ>|ùóç@$xñãǏ ÀÝÌÒ9÷ÀEÀ‹0ñ/ÅÏú€À™æC)§Ë§@X±bŋ@$xñãǏ ÀÜÄÉ ìBÀŒj퓙€À–ÕÈúáƒG@ ‰$H‘"@$xñãǏ ÀÛµ}4;‹ÀŒËkP&€À”\DFká@"å˗.\¹@$xñãǏ ÀÚŠ²ÞúŸ)ÀŒn_MÁB €À‘zmúc-Z@%B… +(P@$xñãǏ Àٝ˜”DÙ(À‹ é(c€ÀŽ¥.8¡¡@'Ÿ>|ùóç@$xñãǏ À؝T$fVŠÀŠ•-Û£ð€ÀŠÁ,>—·:@)û÷ïß¿~@$xñãǏ À×§áŒt{ÁÀ‰XpOYKl€À‡~û‚<»@,X±bŋ@$xñãǏ ÀÖŸ£êŸ^ÀˆW£§Š€À„°nfIÉ@.µjÕ«V­@$xñãǏ ÀÕâ ВÎàÀ†­‰>{šÙ€À‚F\˜~ä!@0‰$H‘"@$xñãǏ ÀÕíöAÀ…[µùÇ +€À€1fcÕ@1·nÝ»ví@$xñãǏ ÀÔNVc§uÀ„}ç|&g€À|̊þ¡ <@2å˗.\¹@$xñãǏ ÀӖqeÊ>À‚Ü.2€Ày¶;3@8+@4(P¡B…@$xñãǏ ÀÒ鳫He£À·j,%÷?€ÀwŠŠáŒ€@5B… +(P@$xñãǏ ÀÒG]Yh#ÊÀ€©Ü@…n€ÀtŊµ"D@6páÇ@$xñãǏ ÀÑ®–£õ†:Àaìî‡z,€Àr˚µŸ©@7Ÿ>|ùóç@$xñãǏ ÀÑ–¥9œáÀ}š9Šår5€ÀqÌYÏv5@8͛6lÙ³@$xñãǏ ÀЖ§÷ú†±À{öû©Kp@€Ào'S2€\g@9û÷ïß¿~@$xñãǏ ÀÐ&êõ’6Àzt€MëýŠ€Àl…Y uŒõ@;*T©R¥J@$xñãǏ ÀÏ8ôÕØM^ÀyØt£ŸŽ€Àj3!QÈëŸ@µjÕ«V­@$xñãǏ ÀÌ€uŒ”ÊÒÀu¯{õšŠÝ€ÀdÀ}YfÏ@?ãǏºÇfÀs¬yÇxé€À`+ ãô@A·nÝ»ví@$xñãǏ Àɳ§`ÁF·ÀrNü€òX%€À]À§a¬`@BN:téÓ@$xñãǏ ÀÉ +0žægäÀq£ÅÍôS€À[Sœ”³‘@Bå˗.\¹@$xñãǏ ÀÈg€5¬qÀpØi'ØFŸ€ÀYô:ß»µ@C|ùóçϟ@$xñãǏ ÀÇËÅ| ÑHÀp&ÌQÿ/€ÀVøÊ¡@(•@D(P¡B…@$xñãǏ ÀÇ6TŠ'pÀnø,^ËQ€ÀU +ýñÿj@D«V­Zµj@$xñãǏ ÀƧxOGxÀm°ÍF 0€ÀSFœÙÿí@EB… +(P@$xñãǏ ÀƳÉ6ueÀlw}÷'ŽX€ÀQªÕ/`Xš@EÙ³f͛6@$xñãǏ Àřÿc/›ïÀkL‡ð€ÀP5É%²F)@FpáÇ@$xñãǏ ÀűFƒ Àj.DD/ëì€ÀMË⃚6T@G @@$xñãǏ ÀĢРO܀Ài®êÁv-€ÀKr%˜GŒ@GŸ>|ùóç@$xñãǏ ÀÄ.L8Ä;`ÀhãXR¿ý€ÀIVœ×ÀÂe@H6lÙ³fÍ@$xñãǏ Àߌ†šÀÀg"dP«øþ€ÀGr;㫉/@H͛6lÙ³@$xñãǏ ÀÃS¢ ýÞUÀf6®Éž:ó€ÀEŸ"ûË\@Idɓ&L™@$xñãǏ ÀÂìÆYšÀeV=Ê€ÀD4ÇÁ‰×@Iû÷ïß¿~@$xñãǏ À‰õnò Àd€‰hâîo€ÀBÑ3×ÃSó@J“&L™2d@$xñãǏ ÀÂ*ýűgÇÀcµ^w⠀ÀA ¥æÁ[@K*T©R¥J@$xñãǏ ÀÁϰDø4ÀbóM„ !=€À@jƒ¹Tñ@KÁƒ 0@$xñãǏ ÀÁwà(›\‘Àb:Æ%"€À>Àt4¶Èo@LX±bŋ@$xñãǏ ÀÁ#búO=ÖÀaŠÿónŸˆ€À<Úy[¡x@Lïß¿~ýû@$xñãǏ ÀÀÒr°lÀ`ã†p€æÏ€À;àŸ.@Û@M‡8pá@$xñãǏ ÀÀƒÂIT¯;À`DôL;€À9†l¿Î'@N|ùóç@$xñãǏ Àµ1oàÚÆRÀL0ˆa4â@€ÀRæ_I¹"@WêÕ«V­Z@$xñãǏ ÀŽï€çHÑÀK…r¬Šb€ÀYyœ3l@X6lÙ³fÍ@$xñãǏ Àޝf\˜ï€ÀJß\šŒã¿€Ài@šù@X‚ @@$xñãǏ ÀŽp«IxËÀJ>n`ÍU€À€ß;ÅÏï@X͛6lÙ³@$xñãǏ ÀŽ3gŒ%8ÀI¡{Rà€À pßÖü_@Y2dɓ&@$xñãǏ À³÷N‹K.ÀIߣïºl$€À"x"@Ydɓ&L™@$xñãǏ À³ºêµEXRÀIB£HÙrB€ÀD 04õi@Y°`Áƒ @$xñãǏ À³óðdeÀH«3‹ÚÓހÀqKÙÇW@Yû÷ïß¿~@$xñãǏ À³FZïhÊ%ÀH 2€À©2*1ç@ZG @Z“&L™2d@$xñãǏ À²×mFTºÀGŸ3f7k€À5 Ö³&@ZÞœzõë×@$xñãǏ À²¡WK¿q[ÀF€¢‚€À‰¹P!@[*T©R¥J@$xñãǏ À²lÇçÀFÙûš*¯€ÀäÊckûH@[uëׯ^œ@$xñãǏ À²9`îJiÀE…Îùñ}ҀÀH$ïWŒ|@[Áƒ 0@$xñãǏ À²%ª{ÀEÈÏ€L7€À²µœÇS3@\ 4hÑ£@$xñãǏ À±ÕàÊ1vÀD›šÖ]h€À$†šE·@\X±bŋ@$xñãǏ À±¥·"rŽÀD,Ïígÿ€À7©å”Þ@\€H‘"Dˆ@$xñãǏ À±v«ªy&ÀCÀ Á=’€À3Cÿ—Ä}@\ïß¿~ýû@$xñãǏ À±HeA°+{ÀCW†Êèû1€À :FÓJ%3@];víÛ·n@$xñãǏ À±-«$þÀBò)~õb€À L[Q§p@]‡8pá@$xñãǏ À°îà«ÆßnÀBåp¥ €À h +!ÆZ˜@]Ò¥J•*T@$xñãǏ À°ÃxցyËÀB0›·Êá€À +§œP¡@^@_ @$xñãǏ À°Až15cÀ@ÎèÕë>N€Àz¹ãž@_L™2dɒ@$xñãǏ À¯íÝaˆPœÀ@|ÑóNmc€ÀÉví@Ž«@_˜0`Áƒ@$xñãǏ À¯ µÈþy§À@-FK*€À61À»†@_ãǏ²€ÀÞ[î*‘o@`=zõëׯ@$xñãǏ À®ÁÊZ$ˆÀ>–hÁe€ÀGªn@`cF4h@$xñãǏ À®z3>Ü1À>Šªü§€ÀµŸpˆ"@`‰$H‘"@$xñãǏ À®3çö^uÀ=|ópüݟ€À)•QÑ@`®Ý»víÛ@$xñãǏ À­îßËÜ?À<ö °Û8€À¢¿a[¡9@`Ô©R¥J•@$xñãǏ À­«éŽØÕÀ|ùóèÀݔ® MAø€€À,ãv‰w?òå˗.\¹@'Ÿ>|ùóèÀÝ|O5MžÀe;§>|·]€ÀœÖ£·Ð2@å˗.\¹@'Ÿ>|ùóèÀÝ2„ñn Àt_(¢R<ç€À›Û>œŠüê@ X±bŋ@'Ÿ>|ùóèÀÜœ(­äÈ~À} )^P€ÀšUI`7‰'@å˗.\¹@'Ÿ>|ùóèÀÜ#sìnœÀßFxPíý€À˜omøÔô‘@Ÿ>|ùóç@'Ÿ>|ùóèÀÛn9’žÀ„Eçñö“€À–VSlC”¢@X±bŋ@'Ÿ>|ùóèÀÚ§)N‹òÀ…Ìs—Æ@€À”3…ɑè@ ‰$H‘"@'Ÿ>|ùóèÀÙÔî-UlßÀ†˜è¿^pà€À’#p0Œë@"å˗.\¹@'Ÿ>|ùóèÀØýÔÀ_— À†Ùzž9€À6~mµª@%B… +(P@'Ÿ>|ùóèÀØ&o[ww@À†¯õª4A€ÀŒçž`2M6@'Ÿ>|ùóç@'Ÿ>|ùóèÀ×Qï.£ÿÄÀ†@ŠqÚ#7€À‰Ÿ€ÝW$@)û÷ïß¿~@'Ÿ>|ùóèÀւÀÚÊ©À…íÄC§H€À†î’² Ö¡@,X±bŋ@'Ÿ>|ùóèÀÕºß~YBjÀ„¹8ÇÂn)€À„qöjôß@.µjÕ«V­@'Ÿ>|ùóèÀÔûYñ "ÀƒÏÑGÅ€À‚AH›Ÿ$@0‰$H‘"@'Ÿ>|ùóèÀÔD¥rd À‚Ü0éàj€À€T âzÿ÷@1·nÝ»ví@'Ÿ>|ùóèÀӖðÓµÙÀèߊaC€À}HŸnÜ@2å˗.\¹@'Ÿ>|ùóèÀÒòɲ_À€ûNuh߀ÀzT°?Œ€Ì@4(P¡B…@'Ÿ>|ùóèÀÒUé{‚»óÀ€w^gö€ÀwÁOÀ;”’@5B… +(P@'Ÿ>|ùóèÀÑÁæ?Ñ À~ÁŠe£€ÀuÛÃ8œö@6páÇ@'Ÿ>|ùóèÀÑ5–§ôïÀ|ë[%3:€Às‰ñŸIlI@7Ÿ>|ùóç@'Ÿ>|ùóèÀа{ékÇüÀ{qâšç{ž€ÀqÏ +0æWI@8͛6lÙ³@'Ÿ>|ùóèÀÐ2/iXÀzEÕ÷¿Ç€ÀpHn”ÛkQ@9û÷ïß¿~@'Ÿ>|ùóèÀÏtIÍÙÜÀxÇú>Ë \€ÀmݺžÅZ@;*T©R¥J@'Ÿ>|ùóèÀΏ EՖƒÀw—O¬<€Àkx IŒ÷š@|ùóèÀ͵òz{RzÀv€6L¿žs€ÀiR¡ª¹=&@=‡8pá@'Ÿ>|ùóèÀÌæ# .ÏÀuÔçvã!€Àgaý™P² @>µjÕ«V­@'Ÿ>|ùóèÀÌkV`*šÀt“µAéȀÀeœþ›Ü'@?ãǏ|ùóèÀËaœî£iÀs·„Æõz€ÀcüY‘ၞ@@‰$H‘"@'Ÿ>|ùóèÀʪ¡=KÔÀrë2\ø?C€Àbz3åZŒ(@A @@'Ÿ>|ùóèÀÉûnøŠEÀr,l+Æ(”€ÀaՎù‘ @A·nÝ»ví@'Ÿ>|ùóèÀÉS‹Àqv¹QD‘`€À_€ Ln>‘@BN:téÓ@'Ÿ>|ùóèÀȱUßV<œÀpÆÆ ¬è€À]Ë€5p@Bå˗.\¹@'Ÿ>|ùóèÀÈøK1A²ÀpUMŽ€ÀZœès·ò@C|ùóçϟ@'Ÿ>|ùóèÀǀÀ0áw•Ànó€€2~€ÀXž°•4ò<@D(P¡B…@'Ÿ>|ùóèÀÆñoÒÅ÷Àmº«íÑۀÀV©þZx:@D«V­Zµj@'Ÿ>|ùóèÀÆgÈéDŒžÀlâ +_Š€ÀTÞZNƒ@EB… +(P@'Ÿ>|ùóèÀÅ㍏&k·Àkn¬#€ÀS:Eñ”@EÙ³f͛6@'Ÿ>|ùóèÀÅd€áæÏ¥Àj\4;{¬h€ÀQ»^· óì@FpáÇ@'Ÿ>|ùóèÀÄêg}Bš€ÀiVµBՀÀP`SË}šŽ@G @@'Ÿ>|ùóèÀÄu #iÀh[@zø¡š€ÀNMdàܗ@GŸ>|ùóç@'Ÿ>|ùóèÀÄ3„˜èÀgjªä]@6€ÀL,"†ŸS@H6lÙ³fÍ@'Ÿ>|ùóèÀ×¶ž—@¬Àf„1BžڀÀJjÒøHÕ@H͛6lÙ³@'Ÿ>|ùóèÀÃ/c*á:~Àe§•rJÙ?€ÀHBVÌZw@Idɓ&L™@'Ÿ>|ùóèÀÂË 7(ÖMÀdԍ‘.©ï€ÀFšÔ†&¿c@Iû÷ïß¿~@'Ÿ>|ùóèÀÂj‚B| ûÀd +ǯ4Y@€ÀE`Ô¿å @J“&L™2d@'Ÿ>|ùóèÀ \CnÀcIì­u}‘€ÀC¹ý±]n–@K*T©R¥J@'Ÿ>|ùóèÀÁŽ37;oÎÀb‘¢xò,›€ÀBy!š`_N@KÁƒ 0@'Ÿ>|ùóèÀÁ^30‹ÀaáÂ†Bg€ÀAS©-V¹@LX±bŋ@'Ÿ>|ùóèÀÁ 2_’ +»Àa9SOa™€À@FÊ©õÒ@Lïß¿~ýû@'Ÿ>|ùóèÀÀ»Qx  À`˜˜÷Å+€À> 2mŠê@M‡8pá@'Ÿ>|ùóèÀÀnVÆíðÀ_þkÍÁ^O€À<Úiãè#@N|ùóèÀÀ$ùRÙuÀ^ÝŽgåÀ;8§Ûr@NµjÕ«V­@'Ÿ>|ùóèÀ¿žëÞÀpüÀ]ζÌ> €À9·_d †!@OL™2dɒ@'Ÿ>|ùóèÀ¿.y6@»5À\ÏîÕ|ݯ€À8S5í§@OãǏ|ùóèÀŸš–œçv¥À[à_&k€À7 +I 9çâ@P=zõëׯ@'Ÿ>|ùóèÀŸ'Á™IÿÀZý¿<§G€À5ÙG²µAž@P‰$H‘"@'Ÿ>|ùóèÀœ©yÁ-K’ÀZ(ÔÑۀÀ4ŸF”»$L@PÔ©R¥J•@'Ÿ>|ùóèÀœ/țéµ|ÀY]óòžÏ€À3·J0šr­@Q @@'Ÿ>|ùóèÀŒ¹ž»(’ãÀXž‰ 1„À€À2‰®}Z@Qkׯ^œ{@'Ÿ>|ùóèÀŒG„ÿ³{ÀWéá”€À1Þi73Å @Q·nÝ»ví@'Ÿ>|ùóèÀ»×œümÀW<ŠcõӀÀ1 uëlØ@R 0`@'Ÿ>|ùóèÀ»k|j'¯ÅÀV˜Ë"cÙå€À0B]·Z\@RN:téÓ@'Ÿ>|ùóèÀ».°yÀUüÖÝN;‰€À/âKoî@Rš4hÑ£F@'Ÿ>|ùóèÀº›®óçšúÀUh=9³Ì€À-²;˜ËÞC@Rå˗.\¹@'Ÿ>|ùóèÀº7Ýõ¡ÀTÚ~Bg׀À,iÒFŠ+@S1bŋ,@'Ÿ>|ùóèÀ¹ÖšpÁèÀTSÝUÛ,€À+4ßÐö¬W@S|ùóçϟ@'Ÿ>|ùóèÀ¹wË;¶ÀSÐLÞ÷Z€À*ÌÓÝ®@Sȑ"D‰@'Ÿ>|ùóèÀ¹]†à3ÀSQŠ#:L€À(ÿ,äaï@T(P¡B…@'Ÿ>|ùóèÀžÁ@tD5ÀRÕgû͎æ€À'ûµ8äoC@T_¿~ýû÷@'Ÿ>|ùóèÀžicv÷8ØÀR]2ÎޫɀÀ'8—áû@T«V­Zµj@'Ÿ>|ùóèÀž¶T@vÀQècr·Ìž€À&  9@TöíÛ·nÝ@'Ÿ>|ùóèÀ·À)-ZßÀQvæp¶rá€À%@îv¶@UB… +(P@'Ÿ>|ùóèÀ·n¬CÕÐÀQ§šPÏ®€À$o8œRv @UŽ8páÃ@'Ÿ>|ùóèÀ·1(u}üÀP’‚R¿€À#§šr<ý@UÙ³f͛6@'Ÿ>|ùóèÀ¶Ñši#±ÞÀP5’nF(€À"éxÞ§º7@V%J•*T©@'Ÿ>|ùóèÀ¶†ä“òÍÀO¡"ÜR]º€À"3ô»]c…@VpáÇ@'Ÿ>|ùóèÀ¶<5¡…úÁÀNÜöÐt€À!†uuSè<@VŒxñãǏ@'Ÿ>|ùóèÀµô0 +òñÀNvË?€À àaÍ _@W @@'Ÿ>|ùóèÀµ­åë'?ÀMewW÷:€À A,³Ì‘[@WS§N:t@'Ÿ>|ùóèÀµiJs~ÃàÀL±Ð™—ÉD€ÀPš‡0î@WŸ>|ùóç@'Ÿ>|ùóèÀµ&Q2|³8ÀLZZæY€À*Áœˆ|(@WêÕ«V­Z@'Ÿ>|ùóèÀŽäî¢A÷ÀKYëgÍQ€ÀȪ@X6lÙ³fÍ@'Ÿ>|ùóèÀŽ¥kÞþIÀJµ]œaK܀ÀþðHµSƒ@X‚ @@'Ÿ>|ùóèÀŽf»ÙŽgxÀJŠHsí€À÷~­Ñ€"@X͛6lÙ³@'Ÿ>|ùóèÀŽ)Öa8€LÀIzKî²'/€ÀøÅíJœ›@Y2dɓ&@'Ÿ>|ùóèÀ³íˆqï¯ÀI¶ý@Ã`’€Àƒ…ú»»’@Ydɓ&L™@'Ÿ>|ùóèÀ³±ÎÁ’}ÀI GðF €À†já€\°@Y°`Áƒ @'Ÿ>|ùóèÀ³v㋎ƒÀH†ŒG°ð€À–¥ÄŒ,@Yû÷ïß¿~@'Ÿ>|ùóèÀ³=ŸKñˆ–ÀGö9ȕò~€À±¡È3ª@ZG|ùóèÀ³ª‘ñòEÀGjÙ[Qš€ÀØDÕٟ@Z“&L™2d@'Ÿ>|ùóèÀ²Îùvÿ†ðÀFä0ª"­ž€À r{O@ZÞœzõë×@'Ÿ>|ùóèÀ²™(ÙYÀFb +“£X€ÀDxŸest@[*T©R¥J@'Ÿ>|ùóèÀ²e7ÅŒÀEä3Ÿ5Ì €Àˆ¿™@[uëׯ^œ@'Ÿ>|ùóèÀ²2K›„ÀEj{yû‚Œ€ÀպȍDã@[Áƒ 0@'Ÿ>|ùóèÀ²j}éÁÀDôŽm2±€À*èšÍž@\ 4hÑ£@'Ÿ>|ùóèÀ±Ï CK)’ÀD‚³6`’!€À‡Îr»µ?@\X±bŋ@'Ÿ>|ùóèÀ±ŸT”$ÀDNÞܪ€ÀëýM- l@\€H‘"Dˆ@'Ÿ>|ùóèÀ±p-žx6ÀC©`–ݝ€ÀW É"ïÒ@\ïß¿~ýû@'Ÿ>|ùóèÀ±B6ÄnØIÀCAÓ2ž&€ÀȘ+ŽçÅ@];víÛ·n@'Ÿ>|ùóèÀ±0ã¹/íÀBÝTím4N€À@GGÛÆA@]‡8pá@'Ÿ>|ùóèÀ°éŸŸŸÀB{ó†Eç6€À{‡õ0F@]Ò¥J•*T@'Ÿ>|ùóèÀ°œÚè9ÌÀBêC€€À}eéÚÝ@^|ùóèÀ°“|ön>gÀAÁÜ7U‰N€À ‘Ú i…œ@^iÓ§N:@'Ÿ>|ùóèÀ°iôFÆ`ÀAhìŽÄþ€À ¬‰-pT@^µjÕ«V­@'Ÿ>|ùóèÀ°A:˜8í|ÀA”T[@ù€À Ï€Z…«ë@_ @'Ÿ>|ùóèÀ°Iç¶¢yÀ@Ÿ»l’“)€À +ü P,@_L™2dɒ@'Ÿ>|ùóèÀ¯ä8ÚÛä‚À@mHÖüL€À +0ñé'‹e@_˜0`Áƒ@'Ÿ>|ùóèÀ¯—Y3ïcŠÀ@%ED#Š€À mÎö|NS@_ãǏ|ùóèÀ¯Kê'V„À?¢uB†€À²FéŽÁ/@`¯^œzö@'Ÿ>|ùóèÀ¯áh2õyÀ? æÃ&mþ€ÀýúwïÑè@`=zõëׯ@'Ÿ>|ùóèÀ®¹5 WMèÀ>{wdM…Ž€ÀPõ£€Y@`cF4h@'Ÿ>|ùóèÀ®qۂÈ+À=î^V₀À©²ó)ìc@`‰$H‘"@'Ÿ>|ùóèÀ®+˙|‡$À=d\瓁l€À á+§€@`®Ý»víÛ@'Ÿ>|ùóèÀ­æüoY âÀ<ÞiÝÓI€ÀngŒ8œ@`Ô©R¥J•@'Ÿ>|ùóèÀ­£eubÏÙÀ<\¯µR€ÀÙgŸèÇ$@`útéÓ§N@'Ÿ>|ùóèÀ­`þj%ÞŽÀ;ÝG^ŠÐ€ÀIÑõž.@a @@'Ÿ>|ùóèÀ­¿VLŸÎÀ;ajö‚L€À¿d±ÇùÙ@aF 0`Á@'Ÿ>|ùóèÀ¬ß ‰dÙðÀ:èøcÌ«€À9æáM@akׯ^œ{@'Ÿ>|ùóèÀ¬ š–ÏžÙÀ:sœxX՗€À¹?—ÊL@a‘£F4@'Ÿ>|ùóèÀ¬bŠRÚ1À:|ùóèÀ¬%ŒÏû[ÐÀ9‘Ÿ,®} €ÀÄßäíòó@aÝ:téÓ§@'Ÿ>|ùóèÀ«é×\6ZÀ9% _" €ÀQƺÁ7@b 0`@'Ÿ>|ùóèÀ«®ï~Ÿ ŠÀ8»BkÁ€Àᶈt[@b(Ñ£F@'Ÿ>|ùóèÀ«tþôûròÀ8Sž*)F€Àtù°~šé@bN:téÓ@'Ÿ>|ùóèÀ«;ÿ±†ó À7î»W97€À så…à@bthÑ£F@'Ÿ>|ùóèÀ«ëØÎ±"À7ŒHì%8à€¿ÿNÉ2ÇK@bš4hÑ£F@'Ÿ>|ùóèÀªÌœ¿©µÀ7,2â9Ãڀ¿þ‹NnI«@bÀ@'Ÿ>|ùóèÀª–oéIãÇÀ6Îeÿ86³€¿ýÎ0`E{@*ŋ,XžÀÚú+=GŽŠ€€À˜}Þn˜?òå˗.\¹@*ŋ,XžÀÚè6è.<À^Ä <~€À—Ξ@×xv@å˗.\¹@*ŋ,XžÀÚ²€˜{ÿ²Àm»<{z÷€À—+ÜÚÊø@ X±bŋ@*ŋ,XžÀÚ\ž°§°ÀuaÃ䜀À–)‘Á΢ƒ@å˗.\¹@*ŋ,XžÀÙêuJLµIÀzÀŠëÄF€À”ß\×9CI@Ÿ>|ùóç@*ŋ,XžÀÙaÐ)afÀ~ë=†i3€À“hÓY}jÇ@X±bŋ@*ŋ,XžÀØÈ+­ºn +À€÷~Y$?€À‘ßLkb ø@ ‰$H‘"@*ŋ,XžÀØ"áE#ÀÀðÂÇO\€ÀUêšó¯@"å˗.\¹@*ŋ,XžÀ×vƒøßBÑÀ‚zØœ_€À²|ÔÔ,Q@%B… +(P@*ŋ,XžÀÖÆËãžm@À‚«•Ä€ÀŠã«TP~@'Ÿ>|ùóç@*ŋ,XžÀÖ…P.&À‚šÓè#¯§€ÀˆHÉQg@)û÷ïß¿~@*ŋ,XžÀÕhÙºsAÀ‚JmwŸ€z€À…æ$Âj9@,X±bŋ@*ŋ,XžÀÔœi‡EõÀÍ•ŽxaU€ÀƒœXí~C@.µjÕ«V­@*ŋ,XžÀÔèQÅÁœÀ7Y +Uø€ÀÍåýz@0‰$H‘"@*ŋ,XžÀÓxCGOîÀ€‘BÛZh4€À€£éb€@1·nÝ»ví@*ŋ,XžÀÒÞêÔEá„ÀÆ©¶ÇA€À}9¿.b=@2å˗.\¹@*ŋ,XžÀÒLÃPsÀ~gԉ#ۀÀzRçá°!@4(P¡B…@*ŋ,XžÀÑ¿–Äï +QÀ}gÉ€ÀwåÜî†qq@5B… +(P@*ŋ,XžÀÑ9nŵÉ[À{¿u\G˜€ÀuÀú¿‰q*@6páÇ@*ŋ,XžÀйT÷(§©Àz~ 6òª€ÀsÚ< |±õ@7Ÿ>|ùóç@*ŋ,XžÀÐ?Õk ¿ÀyKù + €Àr)¥œÑ I@8͛6lÙ³@*ŋ,XžÀϔlÞeˆëÀx)FçûÔñ€Àpše¶åÞ@9û÷ïß¿~@*ŋ,XžÀεBS krÀwáZžÝù€Àn¡PÊD…¯@;*T©R¥J@*ŋ,XžÀÍßö\Þ­ŽÀv¢á4€Àl:pˆÖ¹q@µjÕ«V­@*ŋ,XžÀ˔ä=aÙ«Àso¯„?e€ÀfPÅ¥¢ˆ@?ãǏz€@D«V­Zµj@*ŋ,XžÀÆ#Þ'š>åÀkgöz;>€ÀV6+‚ß<Ä@EB… +(P@*ŋ,XžÀÅ€Óœ6ìÅÀjaË% ýH€ÀTiñŠ9@EÙ³f͛6@*ŋ,XžÀÅ*„Ã÷|Àig9¢¶€ÀS 2™ÿåž@FpáÇ@*ŋ,XžÀÄŽŒ¬¹Àhwið€S€ÀQª|4«@G @@*ŋ,XžÀÄCH<—2óÀg‘!¡Lø$€ÀPhºÐoÝ@GŸ>|ùóç@*ŋ,XžÀÃÖ;ÐÿùÀf²ü4®Æ€ÀN„?£ðL@H6lÙ³fÍ@*ŋ,XžÀÃlÀ^g4CÀeÝ +Îè+°€ÀLk„²÷ˆ€@H͛6lÙ³@*ŋ,XžÀÃ^êIŠÀeFкc—€ÀJ ›âŸ@Idɓ&L™@*ŋ,XžÀÂ¥¶rÒ1ÔÀdI–Ÿ\ºe€ÀHÀin4@Iû÷ïß¿~@*ŋ,XžÀÂG¡')àSÀc‹Ó=©,’€ÀG%ŸŽˆ÷u@J“&L™2d@*ŋ,XžÀÁìú 1yÀbÕÊ€<–]€ÀE­!ã‚M…@K*T©R¥J@*ŋ,XžÀÁ•.~>Àb'CÛx¶2€ÀDSÀ²Ö¯¯@KÁƒ 0@*ŋ,XžÀÁAgn"Àa€ªçÆl€ÀCŸ÷þÍ\@LX±bŋ@*ŋ,XžÀÀð7SٜÀ`ß¿‡]˜œ€ÀAó.Û(@Lïß¿~ýû@*ŋ,XžÀÀ¡ìJNÈ»À`F<ÿ&6€À@çAû\Ì@M‡8pá@*ŋ,XžÀÀVfýZ[À_fÇÿo©'€À?à¥-ùK@Nśý®@NµjÕ«V­@*ŋ,XžÀ¿Ž7ý À]M+L™Œ€À|ùóç@*ŋ,XžÀµµ&M,®ÀKÐvš,w€À êŒûÍ Ø@WêÕ«V­Z@*ŋ,XžÀŽØÈ Ãv¯ÀK(áT¯€À L†Eô1@X6lÙ³fÍ@*ŋ,XžÀŽ™a)ÀJ† +Ò74?€Àh7âYÕ@X‚ @@*ŋ,XžÀŽ[uxÅ€0ÀIçÏb³•Ÿ€ÀAÑuß×?@X͛6lÙ³@*ŋ,XžÀŽúA`YÀIN î™s.€À%ÎFpÒ@Y2dɓ&@*ŋ,XžÀ³âp~‘9%ÀI‰ qßþ€ÀÒM4ð6.@Ydɓ&L™@*ŋ,XžÀ³ŠÓËWF ÀHð€P<€À·KRÚ€@Y°`Áƒ @*ŋ,XžÀ³l™U’=ÀH])ˆ0:§€Àªe©¥ßP@Yû÷ïß¿~@*ŋ,XžÀ³3ŽéÈøÀGÎއ‡Iq€Àª¹quÒP@ZGÀBÖžÜÐ!€À3“Ba@^@_L™2dɒ@*ŋ,XžÀ¯Ù?S DáÀ@[šR\BՀÀ Œbº©@_˜0`Áƒ@*ŋ,XžÀ¯Œ±Nä{ À@ 5([Ó€À ²Š] @_ãǏí•>';€À @É[ý@`=zõëׯ@*ŋ,XžÀ®¯ožmÂ[À>]UŸ“šý€À +QOtð@`cF4h@*ŋ,XžÀ®h[ê|À=Ñ$ñـÀ •NK¯ƒ@`‰$H‘"@*ŋ,XžÀ®":_— À=Hp{^Œ€Àà€Èð…@`®Ý»víÛ@*ŋ,XžÀ­ÞÍ>,nÀ<ÄeJV$€À2_–@`Ô©R¥J•@*ŋ,XžÀ­šš1›ŒÀ|ùóç@-ëׯ^œ€Àט({Xu°ÀxÀ +‰[X€Àüi7jŸö@X±bŋ@-ëׯ^œ€À×ä&PvSÀzÅ2Œœ9€À²Áò@ ‰$H‘"@-ëׯ^œ€À֜ii©"MÀ|Ãë¯ ý€€À] @6úÜ@"å˗.\¹@-ëׯ^œ€ÀÖ.úÖíÀ~"Jû +0€À‹ éAf³Æ@%B… +(P@-ëׯ^œ€ÀՁ'‚%!À~Ñ·#V!πÀˆÍa9.@'Ÿ>|ùóç@-ëׯ^œ€ÀÔîªÖÄáyÀ© Ÿèá€À†ª!Ïu«¯@)û÷ïß¿~@-ëׯ^œ€ÀÔ[Ì/®xÕÀ~ûðr"­¢€À„š›\ëøµ@,X±bŋ@-ëׯ^œ€ÀÓÊjVá•À~„ŽàtåH€À‚΀ߗÁc@.µjÕ«V­@-ëׯ^œ€ÀÓ;ĉùLÀ}×î$’¶€Àž Œ(#@0‰$H‘"@-ëׯ^œ€ÀÒ°°DÔÀ}‹Î˜.V€À*^éÎÙK@1·nÝ»ví@-ëׯ^œ€ÀÒ)ÅÎt=áÀ|ÀóñeŀÀ|eîp5P@2å˗.\¹@-ëׯ^œ€Àѧ[ôu:À{>Ә{v€ÀyèQ`1×@4(P¡B…@-ëׯ^œ€ÀÑ)š[~i˜Àz­' !€Àw«—¶@5B… +(P@-ëׯ^œ€Àа‰„ zÀy!œ_;²»€Àu©¬ æÃ¿@6páÇ@-ëׯ^œ€ÀÐ<º@ÜÃÀx(@ò}{€ÀsÜO­ïœ}@7Ÿ>|ùóç@-ëׯ^œ€ÀϘzš•¢ýÀw5o=q$€Àr=Փòä@8͛6lÙ³@-ëׯ^œ€ÀÎÁŠý§<”ÀvJšîT>€ÀpÉ3—ÛF@9û÷ïß¿~@-ëׯ^œ€ÀÍóÿiÀuhËÿÜò`€ÀnóäUE‡@;*T©R¥J@-ëׯ^œ€ÀÍ,ÕÁí˜Àt‘$ì S€Àl—âK­Ì=@µjÕ«V­@-ëׯ^œ€ÀËâÔmˆÀrK£.¬dt€ÀfŒZ,o@?ãǏ×Ҍ§-€À[Vc™?[@D(P¡B…@-ëׯ^œ€ÀÆZäÌÆ7Àk;wŸJŸG€ÀYFœ ±@D«V­Zµj@-ëׯ^œ€ÀÅÜ)ÅÑåôÀjB»TÉY€ÀWIæöF@EB… +(P@-ëׯ^œ€ÀÅbPÝIë–ÀiTjèˆ2€ÀU€òtÔ/z@EÙ³f͛6@-ëׯ^œ€ÀÄìÇ@6ÿÀho–ßnc€ÀT!=±ÄT-@FpáÇ@-ëׯ^œ€ÀÄ{]€ +íxÀg”Ò®é1€ÀRŒ°@ŒÙd@G @@-ëׯ^œ€ÀÄ ê*&)þÀfÁÉá¡è€ÀQu,sš§@GŸ>|ùóç@-ëׯ^œ€ÀÀLÏ#wÀeõÊÊ_õ€ÀPHGTçÀ%@H6lÙ³fÍ@-ëׯ^œ€ÀÃ>fí'KzÀe/—Gå'€ÀNgiw)Ã@H͛6lÙ³@-ëׯ^œ€ÀÂÜø²šÀdo¯Ï•$•€ÀLj¿jÉV=@Idɓ&L™@-ëׯ^œ€ÀÂ}BûŽqiÀc·øŽ^f€ÀJ–Ê0!má@Iû÷ïß¿~@-ëׯ^œ€ÀÂ!ÄõØ*€ÀcQšCh€ÀHè0š‹“@J“&L™2d@-ëׯ^œ€ÀÁÉ'@TÀbZ0š  +€ÀG[œž”%§@K*T©R¥J@-ëׯ^œ€ÀÁtRIä)¹Àaµü%ª?€ÀEîuþ~ò÷@KÁƒ 0@-ëׯ^œ€ÀÁ"ŒúÀasÑ:€ÀDž÷º¢@LX±bŋ@-ëׯ^œ€ÀÀÒÉ¥(võÀ`ƒSÁ¡i€ÀCh,çÏZ@Lïß¿~ýû@-ëׯ^œ€ÀÀ†3¶€À_ۃ „ë€À@P æ“ʆ@NµjÕ«V­@-ëׯ^œ€À¿_yÕTxåÀ\Âicv¶€À>àÜ%9<@OL™2dɒ@-ëׯ^œ€ÀŸÙʄɑÀ[מ,R.€À=Bڞ@å@OãǏ|ùóç@-ëׯ^œ€Àµ ¹ü¢ÜÀK˜5ÆyÙ¬€À"™pûp‹@WêÕ«V­Z@-ëׯ^œ€ÀŽËOUÃè2ÀJòНK®±€À!ëÍsäÆã@X6lÙ³fÍ@-ëׯ^œ€ÀތfNŠAÀJQ²3þý€À!Dž‘ñŸ@X‚ @@-ëׯ^œ€ÀŽNô-ï-ËÀIµ6äF€À £ó=G@X͛6lÙ³@-ëׯ^œ€ÀŽîˆY‹ÀI¡ÇE£€À äяHÁ@Y2dɓ&@-ëׯ^œ€À³Ö‰î0yÀIVF> N€À †1Ć=@Ydɓ&L™@-ëׯ^œ€À³šçí¥j:ÀHÀ0¥–²€ÀÔÏŽÐ@Y°`Áƒ @-ëׯ^œ€À³a¿ +ºÇÀH/;„ù€À¬¡àóA@Yû÷ïß¿~@-ëׯ^œ€À³(¢'€€ÑÀG£,b—™€À’âñçfŠ@ZGÉá0%v€Àú}î.n@^µjÕ«V­@-ëׯ^œ€À°4áUÔëjÀ@ê$sƒc*€ÀqHc‚òê@_ @-ëׯ^œ€À° N?eFÀ@—çÞ·¬š€À튢U[@_L™2dɒ@-ëׯ^œ€À¯ÌõS’ÉùÀ@Gýh©%€Ànþ®æc@_˜0`Áƒ@-ëׯ^œ€À¯€Âi—œÀ?ô›‡ˆ€ÀêÆo~@_ãǏʞe²€À ,YuÂ@`=zõëׯ@-ëׯ^œ€À®€}Šò€À>;°ZEÝz€À G±Žtæ@`cF4h@-ëׯ^œ€À®]·Òi À=°œ™!G€À wmÆ7ñ†@`‰$H‘"@-ëׯ^œ€À®67ixAÀ=)?¶·=€À ®ãþ·D@`®Ý»víÛ@-ëׯ^œ€À­Ó𭆕À<¥x/Ÿ£±€À +ížvN@`Ô©R¥J•@-ëׯ^œ€À­Ý.\ïXÀ<%%õßHˀÀ +3”jª®m@`útéÓ§N@-ëׯ^œ€À­Nõh±ŽúÀ;š*]¯™t€À €%â™Ð.@a @@-ëׯ^œ€À­1 +ÈNVÀ;.h ja €ÀÓbù5@aF 0`Á@-ëׯ^œ€À¬Îˆ˜›r^À:·Ââƒ'ĀÀ,7 Wq)@akׯ^œ{@-ëׯ^œ€À¬ô×#õsÀ:Dõ}±ó€À‹)C‘#@a‘£F4@-ëׯ^œ€À¬Rnɟš5À9ÓevÑv€Àï²¢Oe@a·nÝ»ví@-ëׯ^œ€À¬ï®ùýÀ9ezªš.5€ÀY•‡‚Žn@aÝ:téÓ§@-ëׯ^œ€À«ÚpÿU„À8úGÙq5؀ÀȖþ”pÝ@b 0`@-ëׯ^œ€À«ŸìiŽA„À8‘¶C/t¹€À<J±-@b(Ñ£F@-ëׯ^œ€À«f[ÑœÁÀ8+°‚š€ÀµÚï4@bN:téÓ@-ëׯ^œ€À«-¹Mœ·dÀ7È VXÊ$€À21Ú9"@bthÑ£F@-ëׯ^œ€Àªõÿ$}ÁÀ7fòí?”·€À³šJpp‰@bš4hÑ£F@-ëׯ^œ€Àª¿'Ê)ß,À7…KÛÀ€À9$Œ[Bü@bÀ@-ëׯ^œ€Àª‰-âÀ6«r…€ÀÂ¥ÿµ~@0‰$H‘$ÀÖø?qx €€À‘<öUæœ\?òå˗.\¹@0‰$H‘$ÀÖí×ϟâ^ÀQ“.*×l>€À‘!ï?·†@å˗.\¹@0‰$H‘$ÀÖÎð²2©éÀa8;KŸËš€ÀÒtî7Ë@ X±bŋ@0‰$H‘$À֜üËóK!ÀhùÖäS€ÀRò gm/@å˗.\¹@0‰$H‘$ÀÖYN–¥AÀoç"Yf¯€ÀWŽQ«ç_@Ÿ>|ùóç@0‰$H‘$ÀÖ_")ˆÀrìNÙPá€ÀÌkÞŒ—Œ@X±bŋ@0‰$H‘$ÀÕ§±³ö_üÀu^mÿ²€ÀŒåAà@ ‰$H‘"@0‰$H‘$ÀÕ>a®3+ÀwDönCÄ^€ÀŠNwy^@"å˗.\¹@0‰$H‘$ÀÔ̭ٛ #Àx¢E@'…€Àˆ}ãJ+ž@%B… +(P@0‰$H‘$ÀÔUùIˁÀyŒžúü5€À†²ÿQL@'Ÿ>|ùóç@0‰$H‘$ÀÓÛÜÎjÀzü}Ãùj€À„ôÍÓâU}@)û÷ïß¿~@0‰$H‘$ÀÓ`{ðÀzAŒ‹È"u€ÀƒIõ#+@,X±bŋ@0‰$H‘$ÀÒä4“;†5Àz%qnì,ž€À¹áIž@.µjÕ«V­@0‰$H‘$ÀÒiW–?.÷Àyք³Ÿ¥Œ€À€EO‰§†„@0‰$H‘"@0‰$H‘$ÀÑðSoÁäÀy^F1# €À}Þ±\T@1·nÝ»ví@0‰$H‘$ÀÑyÓ8xÉÀxÈ8û‹€À{lÅRi»/@2å˗.\¹@0‰$H‘$ÀÑHxžãÝÀx%³t8r€Ày1àÝ(; +@4(P¡B…@0‰$H‘$ÀЕøš +ïÀwkËïY@€Àw*gYÎ4B@5B… +(P@0‰$H‘$ÀÐ) ˆÉ€Àv°Ý•»€ÀuRlgtªß@6páÇ@0‰$H‘$ÀÏ+Ï<§Àuô'œ™€Às¥Üa1C @7Ÿ>|ùóç@0‰$H‘$Àγ5§ +ëáÀu7ùpÔȀÀr Õ É~…@8͛6lÙ³@0‰$H‘$ÀÍî']öΑÀt~d…§uä€Àp¿»þrõ@9û÷ïß¿~@0‰$H‘$ÀÍ/àƒ‡0ˆÀsÈÕŠ]€Ànþ{?ì\-@;*T©R¥J@0‰$H‘$ÀÌx5Ûÿ¬rÀsgñŽ](€Àlži “@µjÕ«V­@0‰$H‘$ÀÊvºpýaSÀq+‹XuæY€Àg‹ýH@?ãǏ÷@BN:téÓ@0‰$H‘$ÀǎgÜëR‘Àlڛù×£E€À`5ŝ{q@Bå˗.\¹@0‰$H‘$ÀÇmZSÀkÞ|ïg÷b€À]÷®wyn@C|ùóçϟ@0‰$H‘$ÀƇ>ÿFsÀjëUìh—4€À[ë§ÁT÷Ê@D(P¡B…@0‰$H‘$ÀÆ +bx‹Àj1΀ÀZŸ]UK@D«V­Zµj@0‰$H‘$Àő@„èUMÀi Ж£ó€ÀXCw7ÔÊc@EB… +(P@0‰$H‘$Àŏ­ËDtÀhGžcüõ„€ÀV¢y’ @EÙ³f͛6@0‰$H‘$ÀīȎ%ôÃÀgw×ìË"P€ÀU Œœx.Ô@FpáÇ@0‰$H‘$ÀÄ>Ãxn|àÀf°p ے€ÀS»œ|ùóç@0‰$H‘$ÀÃoy‚9î0Àe3æ:mü€ÀQ@©,Bž@H6lÙ³fÍ@0‰$H‘$Àà à »Àd|+—¡€ÀP&ž°¿Ü@H͛6lÙ³@0‰$H‘$À­á˜ë_ÀcÊŸµÉù€ÀND'!×Ãs@Idɓ&L™@0‰$H‘$ÀÂQø6yÀcûèwù€ÀLb<Û @@@Iû÷ïß¿~@0‰$H‘$ÀÁù,Œ–t3Àbx÷CÂZ/€ÀJ€¹/[š@J“&L™2d@0‰$H‘$ÀÁ£d`HÀaزIK³á€ÀIà€‚Ñÿ@K*T©R¥J@0‰$H‘$ÀÁP‚õðšÀa>"‡—èV€ÀGŒ0jù0²@KÁƒ 0@0‰$H‘$ÀÁnÉ9YœÀ`©3ËYœ^€ÀF,XæûŒ@LX±bŋ@0‰$H‘$ÀÀ³ $êÀ`Êiªw€ÀDç9ã .t@Lïß¿~ýû@0‰$H‘$ÀÀhD=ú( À_Š.Ç€ÀCºÞ—Å¥²@M‡8pá@0‰$H‘$ÀÀúÝïcZÀ^S<«[$€ÀB¥y Ùåø@N ­ðž*á@P=zõëׯ@0‰$H‘$Àœ¯…·5EäÀY²çÌýˀÀ<¥€ÃUd£@P‰$H‘"@0‰$H‘$Àœ7åÙÖ3ÀXó¥€2ìð€À;D_¶‹Ê@PÔ©R¥J•@0‰$H‘$ÀŒÃ³Æu>ÀX>³ýÍ£/€À9ûYóˆšÿ@Q @@0‰$H‘$ÀŒRÁðÐBÀW’–ôuõ€À8Çú›5F”@Qkׯ^œ{@0‰$H‘$À»äêcKÑ6ÀVî­ˆ¥€À7š)Iá@Q·nÝ»ví@0‰$H‘$À»zô[±ôÀVRh[d€À6›3-°\Û@R 0`@0‰$H‘$À»ôǓŠ)ÀUœBÚµ™Á€À5ž‹òÅz¡@RN:téÓ@0‰$H‘$Àº¬“UؑÀU.ÄŸ”Ð€À4±#S0=i@Rš4hÑ£F@0‰$H‘$ÀºIÄ:>–ÆÀTŠWžc€À3ÑŽ[œ57@Rå˗.\¹@0‰$H‘$À¹ékZaëÀT$ {{€À2ÿ×"‰~@S1bŋ,@0‰$H‘$À¹‹m‡ÃâÀS§ ŒÞj€À28AU/â.@S|ùóçϟ@0‰$H‘$À¹/³û4*TÀS-Ãì2­€À1|@M™µÿ@Sȑ"D‰@0‰$H‘$ÀžÖ1°0ÀR·\Oóæ`€À0ÊB®dË@T(P¡B…@0‰$H‘$Àž~ÖÄñgLÀRCÒO;@€À0!‰j1@T_¿~ýû÷@0‰$H‘$Àž)—”ÈÖ²ÀQÓ"Š7S€À/Ç#ÌpM@T«V­Zµj@0‰$H‘$À·ÖfO ÀQeGš»N€À-Ò`Žo`Æ@TöíÛ·nÝ@0‰$H‘$À·…4ïRŽ€ÀPú9¥”j؀À,°µjÅî@UB… +(P@0‰$H‘$À·5÷@,ÀP‘ï<þ®ˆ€À+œ².@UŽ8páÃ@0‰$H‘$À¶èŸ`yŽÀP,]«¶JR€À*•[Q¯æ@UÙ³f͛6@0‰$H‘$À¶!>Œ=ÀO’ò).¯€À)™Çô© @V%J•*T©@0‰$H‘$À¶SpÚDùÀNÒiùÓ€À(©!s +Œf@VpáÇ@0‰$H‘$À¶ ©ƒÀNTe#€À'¡ÚÝh¶@VŒxñãǏ@0‰$H‘$ÀµÅCèm»ÀM`ª’/€À&å’u» @W @@0‰$H‘$Àµ€±íÀL¯;ÈÌ"a€À&J‰‚›“@WS§N:t@0‰$H‘$Àµ=»®{³àÀL›fÚéÀÀ%E.1€@WŸ>|ùóç@0‰$H‘$ÀŽüX€¡uŸÀKZ«n@ç4€À$€­QÝ»@WêÕ«V­Z@0‰$H‘$ÀŽŒ|šŸÀJ·M±³œ{€À#ÃB¢Z@X6lÙ³fÍ@0‰$H‘$ÀŽ~Ss­¹ÀJcùôj€À# rÎ3£ˆ@X‚ @@0‰$H‘$ÀŽA0J(@ÀI}Ð"ܰç€À"[˧MqË@X͛6lÙ³@0‰$H‘$ÀŽ«d=!–ÀHçt@¹Gn€À!°ãjØ)U@Y2dɓ&@0‰$H‘$À³Èm¹?hÀI«ûÕ`K€À"ýÆ~%ã@Ydɓ&L™@0‰$H‘$À³Æ,1ÀH‹VÝ3T€À!n¡¬–a@Y°`Áƒ @0‰$H‘$À³TtÀþLbÀGüö.Á€À ͎šóŠº@Yû÷ïß¿~@0‰$H‘$À³nd­–ÀGsS­g‚&€À 4BÖ<Õ3@ZGˆ9¥ÀFmwç]\€À.P8SÙ@ZÞœzõë×@0‰$H‘$À²{®¯ôçÀEðÞ5=Pe€À%VåG£@[*T©R¥J@0‰$H‘$À²Hi¹InõÀEx@=Å׀À'äûn˜•@[uëׯ^œ@0‰$H‘$À²=$FŒLÀEs‘£éj€À6A¯ÍÝ@[Áƒ 0@0‰$H‘$À±å [œÊÀD’OãŠÜހÀOrŒ×ôg@\ 4hÑ£@0‰$H‘$À±µ +)Ɲ¡ÀD$®éq Á€ÀrÜ é1@\X±bŋ@0‰$H‘$À±…ó,ôöÈÀCºl> h€ÀŸíxÓÇ@\€H‘"Dˆ@0‰$H‘$À±WÓZ*ÝœÀCSeFŒ=Ÿ€ÀÖY'œ>@\ïß¿~ýû@0‰$H‘$À±*£5gÜÀBïy]›€Àñ5órà@];víÛ·n@0‰$H‘$À°þ[އMŠÀBŽˆ_âzž€À[ïj ‰@]‡8pá@0‰$H‘$À°Òõ}˜K–ÀB0uK.þ€ÀªªBç,@]Ò¥J•*T@0‰$H‘$À°šj_i»üÀAÕ#sÁS€ÀºÝj{@^€?L­€À-¢ÎFß@`=zõëׯ@0‰$H‘$À®˜bð%QŸÀ>êÆ€À§'ÍÙÍ@`cF4h@0‰$H‘$À®Qó'Öh­À=ŒêEh€ÀOWq @`‰$H‘"@0‰$H‘$À® ÄNÀՊÀ=Ýù”º€ÀsO.épž@`®Ý»víÛ@0‰$H‘$À­ÈÍìUÝïÀ<„W†f‡2€À ŸåÇ R@`Ô©R¥J•@0‰$H‘$À­†Ó[+¡À<7¹C+€À Ó|,1>¬@`útéÓ§N@0‰$H‘$À­Dj²v'À;‰`³õ +ҀÀ »ø¹^@a @@0‰$H‘$À­í.J¡!À;µÛCNn€À Pø‡ù‚Ñ@aF 0`Á@0‰$H‘$À¬Ä‰€7ðÀ:›Æà>ñ€À +™äóÀŸû@akׯ^œ{@0‰$H‘$À¬†8añëÀ:(x2A#-€À é5ڗ’@a‘£F4@0‰$H‘$À¬Hò…¯$xÀ9ž±îPl€À >¡bU”@a·nÝ»ví@0‰$H‘$À¬ ±gîäÀ9K°Óì™,€À™æo P=@aÝ:téÓ§@0‰$H‘$À«Ñn™ TÀ8á]·''œ€ÀúÄXu2@b 0`@0‰$H‘$À«—#ß ýÀ8y¢[7Ž:€À`ýrӟ¥@b(Ñ£F@0‰$H‘$À«]Ë3Z!þÀ8igCn€ÀÌXáÕûs@bN:téÓ@0‰$H‘$À«%^ÀáÃÀ7±žZÀ`܀À<ŸŽhe©@bthÑ£F@0‰$H‘$ÀªíØáø|ÓÀ7Q-„õpQ€À±hfZ`@bš4hÑ£F@0‰$H‘$Àª·4–À6óù§2I€À+ ô‘²z@bÀ@0‰$H‘$Àªk*o)À6—ˆÐð€Àšû&_jù@28páÈÀÕea{š€€À¥›äÐçT?òå˗.\¹@28páÈÀÕ]BÁKÑÀKa1X9qv€À}òäª>@å˗.\¹@28páÈÀÕEÔýö§À[é;[KĀÀ D‘žfX@ X±bŋ@28páÈÀÕÕ_>>BÀc«¡¢"5€ÀŒPb|udé@å˗.\¹@28páÈÀÔ蔈¬•ÀiM‚yŒ€À‹[uœ~Òk@Ÿ>|ùóç@28páÈÀÔŠÝ;ëÀÀnEžmœÀ€ÀŠ5ú·äž'@X±bŋ@28páÈÀÔZ/o¯|¬ÀqBt¢»…Ž€ÀˆëöÕ.²@ ‰$H‘"@28páÈÀÔ\dT ÀsÊÃò• €À‡Š×ÃȆ@"å˗.\¹@28páÈÀÓ§JžêQèÀtU%;·G €À†‹h÷6Ù@%B… +(P@28páÈÀÓDÎÌ +öÀuMJžyèU€À„°,Â`ˆË@'Ÿ>|ùóç@28páÈÀÒރ‚hÆÀuóÝ}„°£€ÀƒF;ÑÞ[Š@)û÷ïß¿~@28páÈÀÒuØ,²ÀvP2‰Ö猀Àæ­¶Ÿfs@,X±bŋ@28páÈÀÒ OâZÀvq"Ý"ý\€À€—t è@.µjÕ«V­@28páÈÀÑ¢H ;€ÀvdÏ2Ưò€À~·šîü>@0‰$H‘"@28páÈÀÑ8Ž_€pÀv/þwÇ1ù€À|j>ž°&@@1·nÝ»ví@28páÈÀÐО¹ŽzÓÀuۂ5Û²*€ÀzGj¥Åƒ@2å˗.\¹@28páÈÀÐjO’ª^µÀuq +$­–€ÀxN=CëC@4(P¡B…@28páÈÀÐÁì4BÀt÷Á F«€Àv|÷d—@5B… +(P@28páÈÀÏH‚sÅÀtsñ§~×m€ÀtÑZxÆ@6páÇ@28páÈÀΉËͱREÀséXMøFñ€ÀsHân—Î…@7Ÿ>|ùóç@28páÈÀÍÐFT«6Às[݋"€Àqသ@8͛6lÙ³@28páÈÀÍËˀÀr˕ãÒá€Àp—8ìJ@9û÷ïß¿~@28páÈÀÌm(µ[ÀrµjÕ«V­@28páÈÀÉå6ËØYÀpݟœÆG€Àg*tÅGGµ@?ãǏ|ùóç@28páÈÀÃ7¶Ê.…ÀdnÆÃ‘€ã€ÀR#©ÁŒÃ@H6lÙ³fÍ@28páÈÀÂØŸJÞ$‹ÀcÆ<‡ðDµ€ÀQNš@H͛6lÙ³@28páÈÀÂ|גö&Àc"-&v€ÀOû« +?i@Idɓ&L™@28páÈÀÂ#ìå-Àb‚ҜǬê€ÀN/F6@Iû÷ïß¿~@28páÈÀÁÍç§ÐˆyÀaèQ±øj¯€ÀLHÚSä(2@J“&L™2d@28páÈÀÁz°Œ¥EéÀaRœ†õŽ€ÀJ¡÷yúGÇ@K*T©R¥J@28páÈÀÁ*0ÍçkåÀ`šhF€ÀIŽîŽV2@KÁƒ 0@28páÈÀÀÜP†”£éÀ`6f}ʬ€ÀG­û§í(s@LX±bŋ@28páÈÀÀøÀŠÔ–À__ í>€ÀF\Ú ×Äð@Lïß¿~ýû@28páÈÀÀHšáEçÀ^[qF€ÀE$„JRI`@M‡8pá@28páÈÀÀ‡Ä +ìLÀ]`žé¬ã€ÀDK1DU@Nòè~Nz@P‰$H‘"@28páÈÀœ 2bžðõÀX~¶‘¶ €€À=}Žp‚@PÔ©R¥J•@28páÈÀŒ™ƒ‡ÎÀWÑzžäó8€ÀŸÀN€¯>燎À+@5R8@VpáÇ@28páÈÀµ÷å„HšËÀMȪçžnŀÀ*ÅåÊ;þ@VŒxñãǏ@28páÈÀµ²_=ïÀMsŒ@Åò€À)`òGÈ@W @@28páÈÀµnzrˆÖŽÀLfñ$²aՀÀ(1`4ÁÆ«@WS§N:t@28páÈÀµ,,•6÷ÀKœ ³LäL€À'TÖ{9U@WŸ>|ùóç@28páÈÀŽëj°À]?ÀK£CL%€À& @WêÕ«V­Z@28páÈÀެ*NÌÚkÀJv£.k‡/€À%± +^ @X6lÙ³fÍ@28páÈÀŽna%Û¥KÀIÙî{Ù¶€À$ë,>ò²@X‚ @@28páÈÀŽ21ÇÉÀIAj1»€À$+mZ›0R@X͛6lÙ³@28páÈÀ³÷ «iÀH¬úšór©€À#qàš5Žô@Y2dɓ&@28páÈÀ³¹”:Ð)©ÀHâ’ã#€è€À#» ¿Áß@Ydɓ&L™@28páÈÀ³w!)Ÿ:ÀHR/:=O€À"çæ€Ö@Y°`Áƒ @28páÈÀ³F©l±… ÀGƒ8ÊÐ1€À":1Ó?9§@Yû÷ïß¿~@28páÈÀ³ SÀG?ˆÑšq€À!•Ÿšž@ZGiY¢La€À aáû*Iƒ@ZÞœzõë×@28páÈÀ²o¶šÁm§ÀEÃù“ÍC€À¥«œ€§®@[*T©R¥J@28páÈÀ²<ÙA^K€ÀEMe¯•?r€À”—ÃS˜ý@[uëׯ^œ@28páÈÀ² ›Ð÷ÀDڅp©Nn€ÀÊÀì@ @[Áƒ 0@28páÈÀ±ÚQ*‹ÀDk2H“¢€À–”ISK‰@\ 4hÑ£@28páÈÀ±ª•€·PÀCÿH–†) €ÀšOÆO2@\X±bŋ@28páÈÀ±{Ô팎€ÀC–€ÝÛ~̀ÀÄcŒû…¶@\€H‘"Dˆ@28páÈÀ±NËsÅÀC1&\ßŎ€Àê@ëp@\ïß¿~ýû@28páÈÀ±!&Ñ:HvÀBݞ_à€À^íòĬ@];víÛ·n@28páÈÀ°õ*þøÛØÀBoš¿_û€ÀQB˜ÕT­@]‡8pá@28páÈÀ°Ê ™ºŒÀBVé¢í€À‘uW2\@@]Ò¥J•*T@28páÈÀ°ŸÈ(s'‹ÀAžA"°ß_€ÀوÛGñ*@^z ˜¿\€À + kTVŽ@`=zõëׯ@28páÈÀ®‹$QhÀ=î3eAi(€À‰|6UñY@`cF4h@28páÈÀ®E7ûïÁÀ=eÿ‚=£â€À ¯®­ Ë@`‰$H‘"@28páÈÀ®=šRgÕÀ<á`[œnš€À–oL˜äŽ@`®Ý»víÛ@28páÈÀ­Œž(·²êÀ<`6T±ht€À#…«2ÉK@`Ô©R¥J•@28páÈÀ­z+ÜXõ®À;âc%éëd€Ài€©=Ež@`útéÓ§N@28páÈÀ­8Þôf#ìÀ;gÉÍ5©q€À“ß$M¶@a @@28páÈÀ¬ø¯ó%OgÀ:ðN}pd€€À ÅÌ~=ƒ@aF 0`Á@28páÈÀ¬¹—›*‘ýÀ:{֏sJô€À þóAŠYÊ@akׯ^œ{@28páÈÀ¬{Žì²ŽµÀ: +HsÙj€À ?}oÝ2@a‘£F4@28páÈÀ¬>#££À9›‹¥‹„2€À …­Ë%’@a·nÝ»ví@28páÈÀ¬‘²ŠäÀ9/ˆQ€À +Ò«’•@aÝ:téÓ§@28páÈÀ«ÇE‹ À8Æ(ÄWn€À +%·Ÿ1ËÃ@b 0`@28páÈÀ«„ºü?êÀ8_Vk¯Î€À ~N@b(Ñ£F@28páÈÀ«Ti#îlYÀ7úüŸŠù€ÀÜ÷äÚH¹@bN:téÓ@28páÈÀ«7Á­—cÀ7™ºú€À@²·$ûñ@bthÑ£F@28páÈÀªäëÞ^fÀ79d"yÁ€À©ˆðNm™@bš4hÑ£F@28páÈÀª®}†±ŽØÀ6Ûÿy†ö¶€ÀE=Tô@bÀ@28páÈÀªxê,ÌEÀ6€Çø²ëõ€À‰Žæ,ÙI@3¯^œzõìÀÔ dÁ…€€À‰µm™ÕU¯?òå˗.\¹@3¯^œzõìÀÔáåoÀE®ÛññÊ€À‰—î|Q_€@å˗.\¹@3¯^œzõìÀÓïà›/ʳÀUŒ1]LـÀ‰@Å2ŠY@ X±bŋ@3¯^œzõìÀÓÐyÚí*¡À_Œjõîð5€Àˆ·f…da@å˗.\¹@3¯^œzõìÀÓ¥¥—;\ÀdgaÚ$€Àˆ˜t©Š@Ÿ>|ùóç@3¯^œzõìÀÓpzûÜ©KÀh’s +F»3€À‡$ 1©Q@X±bŋ@3¯^œzõìÀÓ2qpŽëÀl: +ž·~€À†)4Æ9¡ã@ ‰$H‘"@3¯^œzõìÀÒë„`^Ào[D¯lòۀÀ…ΣjÑ}@"å˗.\¹@3¯^œzõìÀҞb牮 Àpìºy{8€Àƒù&,êöb@%B… +(P@3¯^œzõìÀÒL ‡èX8Àqâú'/{.€À‚Ôœ–9m6@'Ÿ>|ùóç@3¯^œzõìÀÑõËéÉÇKÀr•{¯Øžp€À°Sñžˆ@)û÷ïß¿~@3¯^œzõìÀќο’ÙÚÀs ©øFA€À€Õбí‡@,X±bŋ@3¯^œzõìÀÑBgè1ÀsUb;2.€À~õ0~Ò@.µjÕ«V­@3¯^œzõìÀÐæ_›— ÀsusOzÂ+€À|áðÖ&Šê@0‰$H‘"@3¯^œzõìÀЊo}8µ–ÀsoÿõJg6€Àzéæº+I@1·nÝ»ví@3¯^œzõìÀÐ.æ*QÏHÀsK¶°⾀Ày±Œ°5Ï@2å˗.\¹@3¯^œzõìÀÏšx+áÑÀs°Z‘Ž™€ÀwTf6'@4(P¡B…@3¯^œzõìÀÎõ·D#ÀrÅáç¡Þ€Àu¶³h¡Ø®@5B… +(P@3¯^œzõìÀÎEöõ2œ6ÀrlrEþ™É€Àt6Šóz@6páÇ@3¯^œzõìÀ͙«%ðxXÀr +i†•ú€Àr҂¥Ì#ž@7Ÿ>|ùóç@3¯^œzõìÀÌñá„>EÀq¢˜i +„€Àqˆ°cµÌÝ@8͛6lÙ³@3¯^œzõìÀÌLÊE^Àq5‘·ڀÀpW‰Š“ž’@9û÷ïß¿~@3¯^œzõìÀË«îù±›ÀpÆãà`3e€ÀnzÏ4ë@;*T©R¥J@3¯^œzõìÀËxéºEíÀpV»†*³Š€ÀlqHúÆiS@'޶@>µjÕ«V­@3¯^œzõìÀÉS!n¯âÀn‘¿ß¥ã€Àg238ÜãŒ@?ãǏ|ùóç@3¯^œzõìÀÂýYûm:CÀc© 0ii€ÀRæÕ^ž”@H6lÙ³fÍ@3¯^œzõìÀ¡ä4O3’ÀcƘùÏV€ÀQÉHûü?Ï@H͛6lÙ³@3¯^œzõìÀÂIA©µˆÜÀbw‚»Mì€ÀP¿nàmµ@Idɓ&L™@3¯^œzõìÀÁó`·©7ÜÀaäät­€ÀOŽ2ZzZÔ@Iû÷ïß¿~@3¯^œzõìÀÁ .šfØpÀaTÇMèøÕ€ÀMÀ4Ñӝ%@J“&L™2d@3¯^œzõìÀÁO—̜ĩÀ`É¢CÜ!Œ€ÀLH{]@K*T©R¥J@3¯^œzõìÀÁˆTEÛõÀ`B¿Ÿj…7€ÀJÚ<É»Ð@KÁƒ 0@3¯^œzõìÀÀµëþ¬ñÀ_€KC=_ø€ÀI ’PH¢È@LX±bŋ@3¯^œzõìÀÀl®’ÀÀ^ƒ¢—¯)_€ÀG³zɏ@Lïß¿~ýû@3¯^œzõìÀÀ%»÷rښÀ]p™¿RŽ€ÀFqêŸz@M‡8pá@3¯^œzõìÀ¿Â{ìf8À\£ßYØ£ë€ÀEGPË€Ëc@N2E'àÀZ&SÅ«€ÀB?û& +åw@OãǏ(ŒU÷Hh@Q @@3¯^œzõìÀ»þ<-–Q9ÀVÀUŠ0v€À<Ñ}[±F@Qkׯ^œ{@3¯^œzõìÀ»”&”þŸ2ÀV)ƒba…Û€À;Ca.S@Q·nÝ»ví@3¯^œzõìÀ»,ÉÙîD/ÀU™@ç|ò€À:`^l'Y@R 0`@3¯^œzõìÀºÈþµ‹ÀU"öà€À9CEâï @RN:téÓ@3¯^œzõìÀºeÄèŸ2ÀTŠÇŸõ݀À86•H–²œ@Rš4hÑ£F@3¯^œzõìÀºæ8ªk‚ÀT ÔÚ®—U€À79[ËYã@Rå˗.\¹@3¯^œzõìÀ¹šS(¯×ÀS‘÷»ØiŠ€À6Io¶Î@S1bŋ,@3¯^œzõìÀ¹LôiãK-ÀSݲI†*€À5fÁ §çÓ@S|ùóçϟ@3¯^œzõìÀžó¶‘]’ÀRªçÏKº€À4ÌƶÞ@Sȑ"D‰@3¯^œzõìÀžœ»ö\ÀR;E×€À3ÄYh @T(P¡B…@3¯^œzõìÀžGnªû&'ÀQÎl÷äø€À3ñç¢Æ@T_¿~ýû÷@3¯^œzõìÀ·ôN'uôÐÀQc$h]ýz€À2Kõ!7‰@T«V­Zµj@3¯^œzõìÀ·£ ÕÓé:ÀPú¯\እ€À1œW™@TöíÛ·nÝ@3¯^œzõìÀ·SÛPe‰9ÀP”€3w£€À0õ6[ӄ&@UB… +(P@3¯^œzõìÀ·r3ža(ÀP1çIJ0€À0Vfÿïé@UŽ8páÃ@3¯^œzõìÀ¶ºÚ(‡®çÀOŸ„o A€À/{äsS=@UÙ³f͛6@3¯^œzõìÀ¶qíŸõ<ÀNáÅ@è‰Û€À.Xð¯jŸ#@V%J•*T©@3¯^œzõìÀ¶(ð^ZíÀN(¶™“V€À-BIϚ!}@VpáÇ@3¯^œzõìÀµâˆygþ¿ÀMtH]ÆÉ]€À,7ÐßRC@VŒxñãǏ@3¯^œzõìÀµÅeøô²ÀLÄhq—än€À+6’ðǟ~@W @@3¯^œzõìÀµZœx õÙÀLšuú€À*@\\ös@WS§N:t@3¯^œzõìÀµ3ö æÀKr”(^€À)Rºü(k…@WŸ>|ùóç@3¯^œzõìÀŽØïQ5ì…ÀJÏRmPNۀÀ(nZtå"@WêÕ«V­Z@3¯^œzõìÀޚVŒ¬zrÀJ0ÚÏK@X͛6lÙ³@3¯^œzõìÀ³çVT ÀHmÔN lA€À%'zïù@Y2dɓ&@3¯^œzõìÀ³©ŒÖîbòÀH¢?P6:€À%²:€]t@Ydɓ&L™@3¯^œzõìÀ³pù~:ÀHù˜Žž€À$T}×®Y@Y°`Áƒ @3¯^œzõìÀ³7Ã_7£ÀGŒIG€À#›’sÿ“@Yû÷ïß¿~@3¯^œzõìÀ³ÀkïÀGÿ»nŠ€À"ëdØ1§@ZGÚÄàŠ²€À Ї ö{@`¯^œzö@3¯^œzõìÀ®Ãµ®¢ªkÀ>LÇdìЀÀ},àŽ +š@`=zõëׯ@3¯^œzõìÀ®|ƒS«6À=Œü}惀Àòâ'w(©@`cF4h@3¯^œzõìÀ®7«ePuÀ=;ôWvÕȀÀm°Óú-—@`‰$H‘"@3¯^œzõìÀ­òŠ‹‚0ZÀ<žÝŒý%€Àí\䞟ò@`®Ý»víÛ@3¯^œzõìÀ­¯eÁžÀ<9* š“€Àq­€¥F@`Ô©R¥J•@3¯^œzõìÀ­mMUf©À;ŒŒ~÷’Ü€Àúmu$ ¬@`útéÓ§N@3¯^œzõìÀ­,WÇOÀ;CxÓ­Ž¢€À‡iœôÌ@a @@3¯^œzõìÀ¬ì} +ωèÀ:ÍDþ©.€ÀrgL-@aF 0`Á@3¯^œzõìÀ¬­¶9;JÀ:ZhŒöy€ÀZ²Ð0DÊ@akׯ^œ{@3¯^œzõìÀ¬oû×±|¶À9é ÿ!«¢€À‹èü$Œl@a‘£F4@3¯^œzõìÀ¬3GÜÉ`™À9|Ó€À Ä4ýñWZ@a·nÝ»ví@3¯^œzõìÀ«÷“Š2µÀ9¥‚Éh€À IZòč@aÝ:téÓ§@3¯^œzõìÀ«ŒØúìÀ8š¶àÇÝՀÀ H܎ +ã@b 0`@3¯^œzõìÀ«ƒÓ]šÁÀ8Bß  +þ€À ”šËeÔþ@b(Ñ£F@3¯^œzõìÀ«J8Z÷òÀ7ßv›üáä€À +ækÈx‚!@bN:téÓ@3¯^œzõìÀ«Fê±^žÀ7~hVAÓ€À +=懇àK@bthÑ£F@3¯^œzõìÀªÛ8 €ÆÀ7¢Sh€À šÝ&Ôv‚@bš4hÑ£F@3¯^œzõìÀª¥gٛKÀ6ÃÂÛ Ù€Àý²ìc[@bÀ@3¯^œzõìÀªo¬âÞ9À6hŠ~¢¿€Àd\ûû8@5B… +(TÀÒڍÝöP€€À†~™M¹§3?òå˗.\¹@5B… +(TÀÒÕb’¹ŽÀAm-$Nrº€À†hrÓãü@å˗.\¹@5B… +(TÀÒÅîœrëÉÀQgš’vªÅ€À†&j÷Œh@ X±bŋ@5B… +(TÀÒ¬kÌRÀYž„í•ñՀÀ…ŸOœþ@å˗.\¹@5B… +(TÀ҉hœpZÀ`žT߉ـÀ…4±è³So@Ÿ>|ùóç@5B… +(TÀÒ]ºV5ÑÀd=o\’Óõ€À„ŒÌd€â@X±bŋ@5B… +(TÀÒ*"óCÀg`kåqçW€ÀƒË +Z®#@ ‰$H‘"@5B… +(TÀÑïŒiš':Àj&jpÕ5€À‚õ“]îw@"å˗.\¹@5B… +(TÀÑ®ýåQQÀlo?LûրÀ‚tPä6µ@%B… +(P@5B… +(TÀÑiýÂç£ÀnGóY™*ó€À'åK3E@'Ÿ>|ùóç@5B… +(TÀÑ F›—;ÕÀo±Šß l€À€:â,ö)2@)û÷ïß¿~@5B… +(TÀÐÔÂ޲Àp_ +k­|€À~ž¿ÀB@,X±bŋ@5B… +(TÀЅՆþ`Àpœ%@ÿ^€À|ÑW#·¡ @.µjÕ«V­@5B… +(TÀÐ6'Ÿ–ºœÀpøiÿû€À{Üš<@0‰$H‘"@5B… +(TÀÏË`ѐªÀq‘¯=z€Àyc"Îé@1·nÝ»ví@5B… +(TÀÏ*€¡²Àqsr]Ž€ÀwÉ}¯Œ ñ@2å˗.\¹@5B… +(TÀΉ,Æ(Àpø}ÞΜπÀvEcxó@4(P¡B…@5B… +(TÀÍé‡ —lLÀpÏ€úª=€Àt×á¹ÊÏ{@5B… +(P@5B… +(TÀÍK§„lÞèÀp˜Ý:V™|€Às€à0~$Ž@6páÇ@5B… +(TÀ̰ 3ÎSÀpWaȵ؀Àr?ÀÿaU‚@7Ÿ>|ùóç@5B… +(TÀÌö"âM†Àp BÑ͋ô€Àq£×• ¢@8͛6lÙ³@5B… +(TÀˀ¿qñmÀo{3/hñT€Ào÷ é©à@9û÷ïß¿~@5B… +(TÀÊíŒí_4”ÀnÓÇÝxý@€ÀmìÁœˆË@;*T©R¥J@5B… +(TÀÊ]H@ŠÀn%£€¿m€ÀlÈ9èý @Àl¹bí€6€Àh™®±8·Š@>µjÕ«V­@5B… +(TÀÈÁU’É+Àl²™Œ€Àg>–&~@?ãǏÂYœÀkFïӛT€Àe¡.§®Hœ@@‰$H‘"@5B… +(TÀÇ¿–Î +Àj;ãwá!€ÀdKNýÞ0v@A @@5B… +(TÀÇCÉà®Û2ÀiÙåýX¯€Àc ž£8&é@A·nÝ»ví@5B… +(TÀÆËOúzæÐÀi&Èϱâü€Àaãtö<*ä@BN:téÓ@5B… +(TÀÆV™>Ò¿Àhu͝Çvk€À`Î:b@Bå˗.\¹@5B… +(TÀÅä,Ÿ~å0ÀgǺäÈF¢€À_–é:Ò8Ø@C|ùóçϟ@5B… +(TÀÅuh>4MÀg$U¡€À]³\õÒŽ@D(P¡B…@5B… +(TÀÅ À“Ï/Àfvv!?P€À[ïà°FÐá@D«V­Zµj@5B… +(TÀÄ¡"Œ¿ÀeÓûµŒáU€ÀZI•壵k@EB… +(P@5B… +(TÀÄ;yÌóÖÞÀe5è~D;€ÀXŸŽ÷!­—@EÙ³f͛6@5B… +(TÀÃØ±9b¿vÀdœYò›†€ÀWLÙ4?ê¥@FpáÇ@5B… +(TÀÃx³K`»±Àd`“*j€ÀUò©™[ߌ@G @@5B… +(TÀÃmZõ³ÀcuO¹uMy€ÀT®i2æô@GŸ>|ùóç@5B… +(TÀÂÀÔèfÁöÀbå<e€ÀS~՚|#Ô@H6lÙ³fÍ@5B… +(TÀÂhàÞ&žÀbW3Ó^ž€ÀRbÆBecz@H͛6lÙ³@5B… +(TÀ„] ª+ÀaÌ%üzf€ÀQY üL@Idɓ&L™@5B… +(TÀÁÀ±€æ»êÀaD7{&X—€ÀP`̒sb@Iû÷ïß¿~@5B… +(TÀÁpY;ŸíŠÀ`¿§Z_]€ÀNñ›©iB@J“&L™2d@5B… +(TÀÁ"jÑx͞À`>žökç€ÀM@ViÅÚE@K*T©R¥J@5B… +(TÀÀÖÕry…À_‚sß>€ÀK«ö +.5ö@KÁƒ 0@5B… +(TÀÀ‡ÄšŒ€À^ˆëÁœ€ÀJ2ÀÈ-…@LX±bŋ@5B… +(TÀÀFp=l@¯À]£OtJ–€ÀHÓТè@Lïß¿~ýû@5B… +(TÀÀ}GjxÀ\Ÿ™³ ñ·€ÀG‹leze¶@M‡8pá@5B… +(TÀ¿}:€”À[ážÌŒòñ€ÀFZQÒS¢ž@N1ÎÿðÅ@NµjÕ«V­@5B… +(TÀŸ}{uë7•ÀZG`”ˆ+րÀD5IW–@OL™2dɒ@5B… +(TÀŸb +®iÀY‰BH‘\€ÀC=ùìÏè€@OãǏ…¹‹Eö`@Qkׯ^œ{@5B… +(TÀ»gh…q£ïÀU¿®î€À=5²ÌåÎØ@Q·nÝ»ví@5B… +(TÀ»óÌ·9'ÀU5 Ç†›€À;ùR<ª°y@R 0`@5B… +(TÀºžýž¹ž!ÀT°®idz€À:Ïj¶µ@RN:téÓ@5B… +(TÀº>lÖ²ÀT1€z•-_€À9µ©qŒÓ +@Rš4hÑ£F@5B… +(TÀ¹à&ž°ßÀS·œ”Ò‹v€À8«ÃÎÆzK@Rå˗.\¹@5B… +(TÀ¹„šákZÀSBL7ˆ•€À7°Cpšœ@S1bŋ,@5B… +(TÀ¹*&;mŸÀRÑhÇÀši€À6Â+)§z@S|ùóçϟ@5B… +(TÀžÒC”MÐIÀRcZŸ,õ€À5àWNó.š@Sȑ"D‰@5B… +(TÀž|dŒ5hÀQ÷XGݵ€À5 +6#_@T(P¡B…@5B… +(TÀž(}áuØfÀQq·ÉøK€À4>s姅2@T_¿~ýû÷@5B… +(TÀ·Ö†îŒï-ÀQ%³–MրÀ3|¿Ö:4H@T«V­Zµj@5B… +(TÀ·†tôԖÒÀPÀ$Έë€À2Ä@—’Z.@TöíÛ·nÝ@5B… +(TÀ·8=C÷ÝÀP\Ë÷NڀÀ2P‘… €@UB… +(P@5B… +(TÀ¶ëÖGß§ŠÀO÷Uþ`[׀À1lVàåŸ@UŽ8páÃ@5B… +(TÀ¶¡4¢ÞŸ÷ÀO9ƒÛèÀ‘€À0ËÆK]@UÙ³f͛6@5B… +(TÀ¶XN)8ÂlÀN€‹”ø€À02MÜâ @V%J•*T©@5B… +(TÀ¶q‚]ÀMË™€í|€À/=ÀzŸÁÁ@VpáÇ@5B… +(TÀµË‰'vq”ÀMs¥ÒрÀ.#DòÇŒ@VŒxñãǏ@5B… +(TÀµ‡–ZˆÈÀLnÓjj€À-÷e˜Þ@W @@5B… +(TÀµE5ZJºÀKÅü318D€À,ž…@WS§N:t@5B… +(TÀµ\MFuÀK" &e€À+÷ ÿ±@WŸ>|ùóç@5B… +(TÀŽÅàø…ÀJ‚9zà%ƒ€À*!îŽV—¡@WêÕ«V­Z@5B… +(TÀއ8õ«YÀIændf„€À)8g+Ÿœ@X6lÙ³fÍ@5B… +(TÀŽJ¡æ@µÌÀIN–ËÛ{˜€À(VÓ‹”(@X‚ @@5B… +(TÀމªŽ\"ÀHºžÃ#€À'|¯:Ò,@X͛6lÙ³@5B… +(TÀ³ÕÊyy •ÀH*pœ2ÑŀÀ&©ÁGQŸ@Y2dɓ&@5B… +(TÀ³˜aÜîžÉÀH]øþæ€À&‚C»úèø@Ydɓ&L™@5B… +(TÀ³_vc…ŸìÀGÓõËŽŽµ€À%ŽÖÒŠ@Y°`Áƒ @5B… +(TÀ³'˱”î/ÀGNX®«Í€À$ñL°L*@Yû÷ïß¿~@5B… +(TÀ²ñW›`ÿÀFÌóa—ç7€À$6ˆ<³t@ZGhrœ$ð@]Ò¥J•*T@5B… +(TÀ°Œ_N$Å-ÀAwÙL·5o€Àl0­È @^šOÞ2€À‚rÇ[) @`¯^œzö@5B… +(TÀ®³Íou£•À>4|=€ÀéH‰eo@`=zõëׯ@5B… +(TÀ®mNïdQMÀ=“Äتõ„€ÀUiõšî"@`cF4h@5B… +(TÀ®( +HÀ=âTN×0€ÀÇ$÷Ú@`‰$H‘"@5B… +(TÀ­äæ•þÝÀ<m³­7œ€À> +!DO@`®Ý»víÛ@5B… +(TÀ­¡')X‘kÀ<Iy/)€À¹ÝžU÷X@`Ô©R¥J•@5B… +(TÀ­_p–ÝørÀ;”Y\à +€À:g9 ãå@`útéÓ§N@5B… +(TÀ­ØŸ(+VÀ;‚6dls€À¿q%øw@a @@5B… +(TÀ¬ßXm _èÀ:§©úёĀÀHÈӀ§@aF 0`Á@5B… +(TÀ¬ è­©¶À:5·Šÿ€ÀÖ>CŒµ¯@akׯ^œ{@5B… +(TÀ¬c‚Ä ³ƒÀ9Ɠ3拀Àg£ô¥@a‘£F4@5B… +(TÀ¬' +Û +–À9Z%ŽæZp€Àù{zô@a·nÝ»ví@5B… +(TÀ«ëº–):À8ðXŽ +œR€À++V£ ž@aÝ:téÓ§@5B… +(TÀ«±Kçc©µÀ8‰áÎö3€Àc£¹õÆR@b 0`@5B… +(TÀ«wÎ5Pù=À8$LPº%€À ¢ŒÌÆëÑ@b(Ñ£F@5B… +(TÀ«?;Å+GÈÀ7Áäa˜€À è0dDà›@bN:téÓ@5B… +(TÀ« ÏMPÀ7aÌç¿Úº€À 3»Í!}@bthÑ£F@5B… +(TÀªÐ¢-2À7óh²§€À …˜&|s@bš4hÑ£F@5B… +(TÀªšÑVŸùQÀ6šFWUvc€À +ÜkÂ@bÀ@5B… +(TÀªe¶ŽêÙÀ6NŽÒŽu€À +8Ön2Í@6Õ«V­ZžÀÑÐJvN+G€€ÀƒÛß5lÊ©?òå˗.\¹@6Õ«V­ZžÀÑÌc=ØÀÌÀL| @àdZ€Àƒ˜Ý*kŽ(@ X±bŋ@6Õ«V­ZžÀѪm•Ù‚êÀUJ[­¶…“€ÀƒIlDà¯õ@å˗.\¹@6Õ«V­ZžÀэ`·²‡ÁÀ[ÎÎKÌ:z€À‚ßeHz@Ÿ>|ùóç@6Õ«V­ZžÀÑhù«÷ãÁÀ`橎mã€À‚]mö‡°@X±bŋ@6Õ«V­ZžÀÑ=ÒßMÁÀc—cíç€ÀÆÏ$Ò¬@ ‰$H‘"@6Õ«V­ZžÀÑ —àôŒ‹Àfî²€À'Žkªö@"å˗.\¹@6Õ«V­ZžÀÐÖ €S³mÀh‘÷z\€À€gX¯“[@%B… +(P@6Õ«V­ZžÀЛv‹ ÀiÕ‡kN€ÀRöyº‘/@'Ÿ>|ùóç@6Õ«V­ZžÀÐ\I»µ«ŽÀk7>Þ<ۀÀ}Ïgš†@)û÷ïß¿~@6Õ«V­ZžÀК£5±YÀlNµTÿ̀À|Jg ŽÀÁ@,X±bŋ@6Õ«V­ZžÀÏ­‹øìžÈÀm!#Nù4Ž€ÀzÈû’FÆ@.µjÕ«V­@6Õ«V­ZžÀÏ"t·MöÀm·>”«ê”€ÀyN®ŸtÊY@0‰$H‘"@6Õ«V­ZžÀΕÏ6àÚÀnû2›¹€ÀwސŸ\@1·nÝ»ví@6Õ«V­ZžÀÎŽTš,Àn?SŠªØà€Àv{ë×öí@2å˗.\¹@6Õ«V­ZžÀÍw‘Œšú@ÀnB—×F݀Àu(ÖL’€Ž@4(P¡B…@6Õ«V­ZžÀÌèÔ×VftÀn%q«ÎGǀÀsæm©è‰ @5B… +(P@6Õ«V­ZžÀÌZåþÎuÀmì# +E±€Àrµ~ø(@6páÇ@6Õ«V­ZžÀËÎ>)ú~Àm›imÍZ€Àq”Ö§.¿8@7Ÿ>|ùóç@6Õ«V­ZžÀËC?åú‰Àm8ø \ ï€Àp…>Eíj¯@8͛6lÙ³@6Õ«V­ZžÀʺ5”«=kÀlȋÒLɀÀo ”K`\x@9û÷ïß¿~@6Õ«V­ZžÀÊ3XQú»ÄÀlMŠ0vÿe€Àm+€óYê©@;*T©R¥J@6Õ«V­ZžÀÉ®ÔåˆÊ}ÀkɕÌV¬‹€Àki@œTY@.’Àk<×Wä,·€ÀiÂöÞ$l@=‡8pá@6Õ«V­ZžÀÈ­!­ÇÀjª$€!€Àh7Sz¥@>µjÕ«V­@6Õ«V­ZžÀÈ0é÷5ª­Àj«\XSŒ€ÀfÄÜ9>ÓF@?ãǏ|ùóç@6Õ«V­ZžÀ‚»m¿64Àb"Žñ-š|€ÀSéBC'/‹@H6lÙ³fÍ@6Õ«V­ZžÀÂ.@ì 8.Àa û‰bÄy€ÀRЭ¡ìþb@H͛6lÙ³@6Õ«V­ZžÀÁÜ%žáŽòÀa!ˆÓEó€ÀQɈˆ6c\@Idɓ&L™@6Õ«V­ZžÀÁŒ^í&#³À`€Ž<á~€ÀPÒᜟ^@Iû÷ïß¿~@6Õ«V­ZžÀÁ>à/W²À`*Qß-€ÀOםÄPLI@J“&L™2d@6Õ«V­ZžÀÀóœýA¡À_f æ%W¡€ÀN&æþe“@K*T©R¥J@6Õ«V­ZžÀÀª„[bàgÀ^}•††Tž€ÀL‘õ–™ìé@KÁƒ 0@6Õ«V­ZžÀÀcŠNeáóÀ]›u3MöŒ€ÀK6õx@LX±bŋ@6Õ«V­ZžÀÀžÓÇèÿÀ\¿ÃìŠà €ÀIµ)Q Éo@Lïß¿~ýû@6Õ«V­ZžÀ¿·eQç֒À[êŒÊߣ“€ÀHjdÆ¡ö_@M‡8pá@6Õ«V­ZžÀ¿5lße+œÀ[ Å +7€ÀG5Ov#f@No‘@Q·nÝ»ví@6Õ«V­ZžÀºÔÔù;TvÀTÌÙ »Û€À=Hó¶W@R 0`@6Õ«V­ZžÀºsœmæíÀTNY#¶k€À<?¥*®¬@RN:téÓ@6Õ«V­ZžÀºïc§ÀSÔœoi5ЀÀ:ð“ù‡é@Rš4hÑ£F@6Õ«V­ZžÀ¹žT`ØóÀS_ŸÑŽÑP€À9ܺp§g;@Rå˗.\¹@6Õ«V­ZžÀ¹]ØlÝéÝÀRïÛKI€À8ז&Àâ@S1bŋ,@6Õ«V­ZžÀ¹f¡òqÀR‚f"ky€À7à!=ªCí@S|ùóçϟ@6Õ«V­ZžÀž®îÓÿ#ÀR‹V«=€À6õgæäÒÀ@Sȑ"D‰@6Õ«V­ZžÀžZgË,n{ÀQ°GâÎm˜€À6‚.ó±a@T(P¡B…@6Õ«V­ZžÀžÉ2>žÀQIÜ5ǀÀ5B˜û˜ãÄ@T_¿~ýû÷@6Õ«V­ZžÀ·· ¢ó(«ÀPåY]ÜÆˆ€À4xåÍ~x@T«V­Zµj@6Õ«V­ZžÀ·h }l€ƒÀP‚ÌÔÕù€À3ž±dš ò@TöíÛ·nÝ@6Õ«V­ZžÀ·7ïsëÀP"@J&;€À3R‚#'”@UB… +(P@6Õ«V­ZžÀ¶Ï«OõµÀO‡sn/E€À2R,Ċä‰@UŽ8páÃ@6Õ«V­ZžÀ¶† *ÚÐÀNÎzÑ©Ðì€À1ª¯^@UÙ³f͛6@6Õ«V­ZžÀ¶>!ºŸ€ÀN™P*¶&€À1 +U[HoS@V%J•*T©@6Õ«V­ZžÀµ÷ԋ­µÅÀMhÍÎŒeŽ€À0p¢LÙâG@VpáÇ@6Õ«V­ZžÀµ³(Æ•ÖÆÀLŒÀNŠ€À/ºGáÛ«@VŒxñãǏ@6Õ«V­ZžÀµp>ŠÁÀLcŠ"9ŀÀ.žàxCâ@W @@6Õ«V­ZžÀµ.tÒEgÀKn³€ºs8€À-ŽIØöô@WS§N:t@6Õ«V­ZžÀŽîs:"ªÀJÍ÷3$Ÿ€À,‡Í×iÄ}@WŸ>|ùóç@6Õ«V­ZžÀŽ¯ÛŠÆ +èÀJ1 ÙȐ€À+ŠÃ}O{*@WêÕ«V­Z@6Õ«V­ZžÀŽr²8ø«õÀI˜!Àq€À*–Ž}œŽ@X6lÙ³fÍ@6Õ«V­ZžÀŽ6íœpÀÀIçXBsx€À)ª›Ô¿L@X‚ @@6Õ«V­ZžÀ³ü…]Õê™ÀHqb —ÆÏ€À(Æe¹àš@X͛6lÙ³@6Õ«V­ZžÀ³ÃppCaÈÀGã~؂q#€À'én%)3@Y2dɓ&@6Õ«V­ZžÀ³†8ÀHF¿m€À'ßèw¹¯@Ydɓ&L™@6Õ«V­ZžÀ³MØ}~iÀGhên/€À'À%-@Y°`Áƒ @6Õ«V­ZžÀ³Ëè·¡ŽÀG þžh¡€À&:SŠ—ã@Yû÷ïß¿~@6Õ«V­ZžÀ²àî™RV­ÀFŽ,%—ð€À%v ×I‚Ó@ZGrŸçjñè€Àð{ R"@`¯^œzö@6Õ«V­ZžÀ®¢ÉœýN•À=è>ˆ€ÀMcyF*@`=zõëׯ@6Õ«V­ZžÀ®\ÂúЇÁÀ=a÷hŸ€À°«Â8?¬@`cF4h@6Õ«V­ZžÀ®ö &×RÀ<ÞäkÏí€Àš@‚ñÿ@`‰$H‘"@6Õ«V­ZžÀ­ÔZœâßËÀ<_*Iûàl€Àˆ;‹@`®Ý»víÛ@6Õ«V­ZžÀ­‘éR0æQÀ;⬜šz"€Àû¹œ +`Â@`Ô©R¥J•@6Õ«V­ZžÀ­PšA)-{À;iP‹®†Õ€ÀtTßÐHí@`útéÓ§N@6Õ«V­ZžÀ­fDîóÀ:òûs¿„€Àñ°Ðlž@a @@6Õ«V­ZžÀ¬ÑFT.”|À:”£á²Ÿ€Às˜ZÙ­F@aF 0`Á@6Õ«V­ZžÀ¬“3ŸÅ0ÏÀ:«Ë]º€ÀùÙ0†gi@akׯ^œ{@6Õ«V­ZžÀ¬V'r±ªÀ9¡1r“ ñ€À„C˜ûgµ@a‘£F4@6Õ«V­ZžÀ¬IJ”åÀ96«kKM€ÀªL@ÿì@a·nÝ»ví@6Õ«V­ZžÀ«ß +šbDÀ8ÍpåÜ€À€âM–›H@aÝ:téÓ§@6Õ«V­ZžÀ«€ìr$ˆ}À8gX€R7ª€À:ÂÉÆ(@b 0`@6Õ«V­ZžÀ«kœ"¿¢ºÀ8ªŠM §€ÀšIæ{Ží@b(Ñ£F@6Õ«V­ZžÀ«3v‚ À7¢T@"¯€ÀáÇ×rE@bN:téÓ@6Õ«V­ZžÀªüÕÔ!À7CBîVÞî€À!¹? Q@bthÑ£F@6Õ«V­ZžÀªÅ®„űÀ6æeãöç€À gÛdZ©W@bš4hÑ£F@6Õ«V­ZžÀªá}ŽÀ6‹©o€¥’€À ³îÁ#ãë@bÀ@6Õ«V­ZžÀª[[ |`À62ÿÓñ €À ¶ÛJN®@8hÑ£FÀÐäMœŸ}¬€€À«nÕ¯Rh?òå˗.\¹@8hÑ£FÀÐàÍz»åWÀ7™û€Ñ•€ÀžÄ±è›;@å˗.\¹@8hÑ£FÀÐÖ\‚‹3ñÀG¢Õîtv€Àxdù•Oœ@ X±bŋ@8hÑ£FÀÐÄÅԉ=uÀQÞç°!^€À:é7ü¢Þ@å˗.\¹@8hÑ£FÀЬVžÖ)ŸÀWn¥h;³€À€è$ÊQ~@Ÿ>|ùóç@8hÑ£FÀЍ4 À\€¹Ž€À€ôÉñ“‹@X±bŋ@8hÑ£FÀÐiWŒÀ`™(ʵ„€À€ ù7R¡u@ ‰$H‘"@8hÑ£FÀÐ?PR—ØÑÀbŒÜv÷,€ÀÌ&3š@"å˗.\¹@8hÑ£FÀÐÒEý¹gÀd–£=çÔ]€À}àÿR@%B… +(P@8hÑ£FÀÏŒv‚²Ð²Àf2Î:Äc €À|ª&Aõ‰ã@'Ÿ>|ùóç@8hÑ£FÀÏPYyNžÀg…ítԛ€À{jÊ[fŸ@)û÷ïß¿~@8hÑ£FÀÎޑBb¥jÀh›U®qʑ€Àz' âB@,X±bŋ@8hÑ£FÀÎh2áGvŸÀiwS«GÀ€Àxã3Q@.µjÕ«V­@8hÑ£FÀÍîEÆêj®Àj»P{]ã€Àw¢ Žû†@0‰$H‘"@8hÑ£FÀÍq» |æÐÀj“µ(ÄÎ=€ÀvfqÞI$Â@1·nÝ»ví@8hÑ£FÀÌói}cTÀjߏÐË«€Àu3‰[÷y¡@2å˗.\¹@8hÑ£FÀÌt€,ÕÀk€9hª€Àt q—òŒX@4(P¡B…@8hÑ£FÀËô2=©ŸÀk˜f~̀Àrïr—î +Î@5B… +(P@8hÑ£FÀËtnq،›ÀkJg'Ž@€ÀqàA³Z…Ù@6páÇ@8hÑ£FÀÊõ5¶Î„ÀjשWS?<€ÀpÞ3^ñ +@7Ÿ>|ùóç@8hÑ£FÀÊvêÒóÐqÀjœK‡9‹K€ÀoҝŽŠY@8͛6lÙ³@8hÑ£FÀÉùÝ Ä»ÀjQó+ƒÙ€ÀnÆO(«H@9û÷ïß¿~@8hÑ£FÀÉ~KÛ!ÌÊÀiû¶Fá騀ÀlL8 O?@;*T©R¥J@8hÑ£FÀÉj׫çƒÀišËZìy€Àj®‡‚õú@µjÕ«V­@8hÑ£FÀÇ¢µÉNîßÀhDï&£ãñ€Àf]¥€Ú1`@?ãǏl߀ÀbÆÚI{ØÖ@A·nÝ»ví@8hÑ£FÀÅꘝª ÀfJà“ËþȀÀažõË“@BN:téÓ@8hÑ£FÀłz;hÐ,ÀeȪƒ +*€À`»pÛº÷@Bå˗.\¹@8hÑ£FÀÅÄZq¿EÀeF „Šv€À_šqሠ+î@C|ùóçϟ@8hÑ£FÀĹvÝ BÀdÃÙI!Óå€À]ښç”d@D(P¡B…@8hÑ£FÀÄXŽ'ÿÍXÀdBºøT`6€À\5}ê­Nv@D«V­Zµj@8hÑ£FÀÃúàm²9ÀcÃ3ëù‡I€ÀZ©\£áž)@EB… +(P@8hÑ£FÀÝχ:ÓÀcEª2šª€ÀY4—€Üç@EÙ³f͛6@8hÑ£FÀÃCæõVúkÀbÊkÍ‡3€ÀWÕ«ÂI­h@FpáÇ@8hÑ£FÀÂì>ÃÃÀbQ²ÍŸ7πÀV‹1›š}K@G @@8hÑ£FÀ–ÌöÙGŠÀaÚ:ŠÍy”€ÀUSéþ0Ĝ@GŸ>|ùóç@8hÑ£FÀÂCŽÅÒÞFÀac7¢ÝրÀT.à[ó3æ@H6lÙ³fÍ@8hÑ£FÀÁò€”Žš À`í@m˜ñ€ÀS1ç`Ž-@H͛6lÙ³@8hÑ£FÀÁ£œGÄYdÀ`xÍÔ¥Bg€ÀR0҉”@Idɓ&L™@8hÑ£FÀÁVÙȋG"À`?]„ÀQ$rµùÑD@Iû÷ïß¿~@8hÑ£FÀÁ /tfh<À_+Œ]åT`€ÀP?·ýèŠÀ@J“&L™2d@8hÑ£FÀÀÒw걡À^OĘÒۀÀNÒ Žæ>4@K*T©R¥J@8hÑ£FÀÀ|÷_ešÀ]xêw¬~ò€ÀM?8Å\V@KÁƒ 0@8hÑ£FÀÀ8Pûþ€zÀ\§i$p Ÿ€ÀKłlŸQ@LX±bŋ@8hÑ£FÀ¿ë&’kDÀ[Ûh%oæ€ÀJc‰-·@Lïß¿~ýû@8hÑ£FÀ¿iaÉwM–À[þå÷<º€ÀIýŒÊf_@M‡8pá@8hÑ£FÀŸë8ôãEŒÀZToÃcª3€ÀGá Ô2ü@N\vä@Qkׯ^œ{@8hÑ£FÀ»H øëÀTÞܟWM€À?©\ü¯ÎB@Q·nÝ»ví@8hÑ£FÀº¥Ø&ØdÀTaˆF}¶€À>Z æïçV@R 0`@8hÑ£FÀºF­ŸžÀSèñÏÁb€À=*ö‰îŠ@RN:téÓ@8hÑ£FÀ¹é°þ©ÉOÀStÕ®Û3€À;ñr¹kZ[@Rš4hÑ£F@8hÑ£FÀ¹ŽÏc`€ƒÀSõQtø€À:Õ¹˜BÜ@Rå˗.\¹@8hÑ£FÀ¹5ôÇÞÈôÀR™²¹¢€À9Èì|‘…¬@S1bŋ,@8hÑ£FÀžßÀeª[ÀR0ý¿k(Œ€À8Ê ² ”Œ@S|ùóçϟ@8hÑ£FÀžŠpäæ¹ÀQËm“#ö€À7Ø-õ±ªo@Sȑ"D‰@8hÑ£FÀž6ìzþWÀQf¯‘žø€À6òcfS³B@T(P¡B…@8hÑ£FÀ·å r +#ÙÀQÖÃùý€À6Ô +Ef›@T_¿~ýû÷@8hÑ£FÀ·–#ñiYÚÀP¢¥xƒˆ£€À5G¶’ ²@T«V­Zµj@8hÑ£FÀ·HnŠuúúÀPC/öiŒB€À4Q-Üu@TöíÛ·nÝ@8hÑ£FÀ¶üwí@qšÀOË5ŸP€À3Ã÷m@UB… +(P@8hÑ£FÀ¶²7ªËëÀOV÷§ú€À3 +å‹G‡@UŽ8páÃ@8hÑ£FÀ¶i€ŒÅžÏÀN_[‡Ýz4€À2aø`­k@UÙ³f͛6@8hÑ£FÀ¶"¶¢/’ÀÀM¯!Ÿz…q€À1Œ7ÌQ&b@V%J•*T©@8hÑ£FÀµÝd[±|ùóç@8hÑ£FÀŽ™²Úñ¥æÀIÜÃûĉ%€À,ށÌ=„0@WêÕ«V­Z@8hÑ£FÀŽ]MAÁ,/ÀIFš+qÇœ€À+·œñž*Ë@X6lÙ³fÍ@8hÑ£FÀŽ"Eÿ)ŒÀHŽ#Ð €À*Ã:&ù™$@X‚ @@8hÑ£FÀ³è”©‘ÀH%&o>'ð€À)ÖÏƱ4@X͛6lÙ³@8hÑ£FÀ³°0ú< 'ÀG™£{ w€À(ñÛ)=•@Y2dɓ&@8hÑ£FÀ³r̔u"§ÀGʱ5"ºË€À)/1…œ +'@Ydɓ&L™@8hÑ£FÀ³;4Ä5zÀGG–œ—Dá€À(MÏ5È @Y°`Áƒ @8hÑ£FÀ³ÍågžÙÀFÈ{(€o΀À'vÊ¢,¬G@Yû÷ïß¿~@8hÑ£FÀ²Ï޵G¡ÀFM8v±åY€À&©Ž‰nS@ZGü‡ì@[*T©R¥J@8hÑ£FÀ²qHŽªÀDƒáæD¬^€À#Ì üÕ¬@[uëׯ^œ@8hÑ£FÀ±Õy"ŸS”ÀDÔTs᱀À#(*ÉöZn@[Áƒ 0@8hÑ£FÀ±ŠwÛg8œÀC²Ùgþ1€À"‹NØjž @\ 4hÑ£@8hÑ£FÀ±xfQÃdÁÀCNԛâQ€À!õÒ @\X±bŋ@8hÑ£FÀ±K=§ŸÔÀBíª•t7X€À!eXBàj@\€H‘"Dˆ@8hÑ£FÀ±÷=š@{ÀBA‚ð8€À ÛªqY¢@\ïß¿~ýû@8hÑ£FÀ°óŒ±¶ý”ÀB3~ûŸ5׀À V¿DÖd®@];víÛ·n@8hÑ£FÀ°È÷Ûž?œÀAÚLeÌ~€À¯‚Ÿ *@]‡8pá@8hÑ£FÀ°Ÿ2ËRýYÀAƒ‘XJ%ÿ€À»¶_32û@]Ò¥J•*T@8hÑ£FÀ°v7ÅÈå™ÀA/8y#ž€Àњ+Œ@^¥À>Åz6w™É€À…ãÙ\@_ãǏ9Öc9Z€ÀUP¡ˆaT@`¯^œzö@8hÑ£FÀ®°`JÜÀ=±Î #Ÿ€À©ªŽhš“@`=zõëׯ@8hÑ£FÀ®K(”dºzÀ=-BÀ“P%€ÀGBVÀ@`cF4h@8hÑ£FÀ®ÕßÝæñÀ<¬Íù€i€ÀdÝÆLÐ×@`‰$H‘"@8hÑ£FÀ­Ã°lÇ?ÉÀ<.-ú@Jº€ÀË* ~!@`®Ý»víÛ@8hÑ£FÀ­°ŠœÑÀ;³mÇÙå€À6ëÑˀ@`Ô©R¥J•@8hÑ£FÀ­@Ï8XRŽÀ;;¹óúË̀À§ã]}*Å@`útéÓ§N@8hÑ£FÀ­ŒhÖÀ:Æû}røv€Àؚ4ßÉ@a @@8hÑ£FÀ¬ÂK9@wâÀ:U†šö΀À˜““¿ãh@aF 0`Á@8hÑ£FÀ¬„›#2«@À9åüÔ(ŠC€Àßä?}#@akׯ^œ{@8hÑ£FÀ¬GîUŽtÛÀ9y E €À›‹Òó¿@a‘£F4@8hÑ£FÀ¬ >“®‹âÀ9º²FgЀÀ#h'Uô"@a·nÝ»ví@8hÑ£FÀ«Ñ…ÑۓãÀ8šk â7€À¯H ¿ž@aÝ:téÓ§@8hÑ£FÀ«—Ÿ4è_ˆÀ8CŒFŠë€À?æ]š@b 0`@8hÑ£FÀ«^âšÎÀ7á (K€ÀÒj1WNœ@b(Ñ£F@8hÑ£FÀ«&ëá`NŒÀ7€Õ+ÝÙ€Ài]h~fU@bN:téÓ@8hÑ£FÀªïÖTÀ7"Ø¢7V»€ÀµäDn’@bthÑ£F@8hÑ£FÀª¹œ;%i•À6Çc[Ž€ÀB¡ƒ@@bš4hÑ£F@8hÑ£FÀª„8‘uiÈÀ6mGî&LJ€À„äŒ@bÀ@8hÑ£FÀªOŠxMÒÀ6“\M(€À ˔¥9ÜÐ@9û÷ïß¿€ÀК Õo5€€À©DªÆðŒ?òå˗.\¹@9û÷ïß¿€ÀÐ¥Äm1/À3ñJhÿ@D€À–ÝÄL@å˗.\¹@9û÷ïß¿€ÀÐÜXûErÀCéïïQ£€À[“–ÿþ@ X±bŋ@9û÷ïß¿€ÀÏíç};pÏÀNbŠøì†G€À~ûpŒ~VQ@å˗.\¹@9û÷ïß¿€ÀÏÄK™ÞþÀSúÈRx€À~xG*·M(@Ÿ>|ùóç@9û÷ïß¿€ÀϏÖ× ‰uÀXg/Jðd€À}Õ(D}Öu@X±bŋ@9û÷ïß¿€ÀÏQhšYÖHÀ\gŽâŽMX€À}|8Zl@ ‰$H‘"@9û÷ïß¿€ÀÏ Ëž-wÀ`ý, 2ï€À|:뜗õ@"å˗.\¹@9û÷ïß¿€ÀιßÊM!dÀaºØT×è€À{JÉN|a@%B… +(P@9û÷ïß¿€ÀÎbˆìÛJqÀc5?îúúրÀzLúšK‡@'Ÿ>|ùóç@9û÷ïß¿€ÀÎÀy +?Àdt‚ñŒŸ€€ÀyCã‚ê@)û÷ïß¿~@9û÷ïß¿€ÀÍ¡D"ÓÀe?bw€Àx3á%•ú4@,X±bŋ@9û÷ïß¿€ÀÍ9Ûk«ÁÀf]@ŸŸ“G€Àw"Ú _@.µjÕ«V­@9û÷ïß¿€ÀÌΈtºíEÀg +\”I#€Àvbwtþ @0‰$H‘"@9û÷ïß¿€ÀÌ`eð̍¬Àg»ìâU€Àu@)s@1·nÝ»ví@9û÷ïß¿€ÀËð%0øs²ÀgðˆUž€Às÷pØ®ï@2å˗.\¹@9û÷ïß¿€ÀË~d Œ{HÀh2ƒãÏÂӀÀrôê0=@4(P¡B…@9û÷ïß¿€ÀË ­“MŒÀhYK9åû€ÀqúòKÒ2õ@5B… +(P@9û÷ïß¿€Àʘ~6³-Àheè$…î0€Àq +`'—Bø@6páÇ@9û÷ïß¿€ÀÊ%H‚У+Àh\PÊxE€Àp#Ìï›R@7Ÿ>|ùóç@9û÷ïß¿€Àɲm“¿ZñÀh@% †€Àn œd‹L@8͛6lÙ³@9û÷ïß¿€ÀÉ@=Ný’’ÀhžõPƀÀlë=­IKÌ@9û÷ïß¿~@9û÷ïß¿€ÀÈÎù‰™6XÀgÜjc0HʀÀk[þ’ÃÉ@;*T©R¥J@9û÷ïß¿€ÀÈ^ÙãÜj]Àg˜œ9ËZG€Àià꧔ +‹@µjÕ«V­@9û÷ïß¿€ÀÇKR.¶ÑÀf”¬„£ª~€Àe㊑-X@?ãǏ|ùóç@9û÷ïß¿€À·b©MoÀ`§Óy,€ÀTVž¥ À@H6lÙ³fÍ@9û÷ïß¿€ÀÁ¶h€¥À`<Т÷„š€ÀSHð‚ùŠA@H͛6lÙ³@9û÷ïß¿€ÀÁjHÙ°” À_¥i)Û €ÀRJ¹ÁF$ @Idɓ&L™@9û÷ïß¿€ÀÁ 'Wg À^Ô8µÍ’€ÀQ[Qò¹^@Iû÷ïß¿~@9û÷ïß¿€ÀÀØ #­À^6Øù§€ÀPyøsJš@J“&L™2d@9û÷ïß¿€ÀÀ’£WÇQ<À]<PߓW€ÀOKðRfi@KÁƒ 0@9û÷ïß¿€ÀÀ )ä¯âÀ[Ždºúé€ÀLF‡tga@LX±bŋ@9û÷ïß¿€À¿—0™;‚ÀZ÷rÑâ¶ê€ÀJæ†`”@Lïß¿~ýû@9û÷ïß¿€À¿~ÔïíøÀZ?FíÉš€ÀIœ\fê@M‡8pá@9û÷ïß¿€ÀŸŸ'œ[ÀYŒ(Ú~iù€ÀHeÛÎTµš@Ns72e’@P=zõëׯ@9û÷ïß¿€ÀŒia ôý†ÀVw¡µ B€ÀCZ¬ n›J@P‰$H‘"@9û÷ïß¿€ÀŒ‰”دÀUì'îvÆú€ÀBƒü‘ìV„@PÔ©R¥J•@9û÷ïß¿€À»š4ÔTÄÀUf­ø +Š¢€ÀA¹zÇáMD@Q @@9û÷ïß¿€À»6JwîQ*ÀTæRG©š€À@úOÑ,ؖ@Qkׯ^œ{@9û÷ïß¿€ÀºÔ³XªîÀTjʛ€Ö"€À@E¶$…C@Q·nÝ»ví@9û÷ïß¿€ÀºuXHTáÀSóÒT`ž…€À?5ïÙCV=@R 0`@9û÷ïß¿€Àº%VŸÅÀS)én&f€À=òÛ8)öy@RN:téÓ@9û÷ïß¿€À¹œ[4·ÀS–rïM8€À<Àù;!§ @Rš4hÑ£F@9û÷ïß¿€À¹c菇ˆÀR§á>Ç»€À;Ÿ,\Ó@Rå˗.\¹@9û÷ïß¿€À¹ º'RîÀR@×pßé\€À:Œl£w6‘@S1bŋ,@9û÷ïß¿€Àž·j>éŸTÀQÝCiûªù€À9‡ÅÅåØñ@S|ùóçϟ@9û÷ïß¿€Àžcën£ÀQ{—A’ €À8NA1/'@Sȑ"D‰@9û÷ïß¿€Àž7^¢k±ÀQ'cŒ€À7¥~ã@T(P¡B…@9û÷ïß¿€À·ÂH:lÀP»ßEŽ€À6ÅQÇQsŸ@T_¿~ýû÷@9û÷ïß¿€À·tœÎ]<ÀP^xVȀÀ5ð& +ñ'Ê@T«V­Zµj@9û÷ïß¿€À·'ž§¢hÀPîëD €À5$܇zÀ‚@TöíÛ·nÝ@9û÷ïß¿€À¶ÜÖRµfÀON6ïF€À4bǓù·:@UB… +(P@9û÷ïß¿€À¶“¶i‰ýÀN›Ö¯!g€À3©Fz`r@UŽ8páÃ@9û÷ïß¿€À¶L7ÆUL!ÀMìõÌéë€À2÷ēÄ`ù@UÙ³f͛6@9û÷ïß¿€À¶R7”"ÀMA~>í|ï€À2Mž„l@V%J•*T©@9û÷ïß¿€ÀµÁýŸ›MÀL™{:- €À1ª¡\-Ñ@VpáÇ@9û÷ïß¿€Àµ1Ë­¶~ÀKôôæeŒ€À1 +7ûDµ@VŒxñãǏ@9û÷ïß¿€Àµ=æ}@dZÀKSì0«Ý™€À0w„ü•ß3@W @@9û÷ïß¿€ÀŽþp=LäÀJ¶c°@7P€À/ÍWæîÆ@WS§N:t@9û÷ïß¿€ÀŽ¿°dg5IÀJWºô+€À.¶Ak0ˆ.@WŸ>|ùóç@9û÷ïß¿€ÀŽ‚µ#5óÀI…Âýh;¯€À-©=˜P{@WêÕ«V­Z@9û÷ïß¿€ÀŽG…JžaÀHòž9ûU€À,¥6‡¢ e@X6lÙ³fÍ@9û÷ïß¿€ÀŽ Õw%€”ÀHbßxú׳€À+ªô°@X‚ @@9û÷ïß¿€À³Óàý€*ÀGÖ|‘Š$ €À*¶þÌp`7@X͛6lÙ³@9û÷ïß¿€À³œ46˜Ž3ÀGMi'šÔ³€À)˘*ho\@Y2dɓ&@9û÷ïß¿€À³^xܘp¿ÀG|Dú8Š€À*oÆÏ?–Ÿ@Ydɓ&L™@9û÷ïß¿€À³'–Tã›ÀFüÄ#ð{µ€À)…«§öÒ@Y°`Áƒ @9û÷ïß¿€À²ñÛÔtB"ÀFqTÉ÷€À(Š+s@Yû÷ïß¿~@9û÷ïß¿€À²œA‰˳ÀF D€9€€À'гH]4ð@ZGÊËT€À'ŽdcÀë@Z“&L™2d@9û÷ïß¿€À²WJñ»’ÀE#\9*‘Ü€À&A«“0@ZÞœzõë×@9û÷ïß¿€À²%ޑâdŸÀDµ…>zêü€À%‡jJvï@[*T©R¥J@9û÷ïß¿€À±õqæ•ÀDJ؂õ_«€À$ԖQšã0@[uëׯ^œ@9û÷ïß¿€À±Åý–páŸÀCã9[þ俀À$)©ÔSøÙ@[Áƒ 0@9û÷ïß¿€À±—zzQ¥ÀC~Œ7t{^€À#…ò1ÆÉš@\ 4hÑ£@9û÷ïß¿€À±iáõ–4ZÀC¶’»Ÿw€À"éã_¹G@\X±bŋ@9û÷ïß¿€À±=-6fóÀBœžñ±\΀À"R©.fƛ@\€H‘"Dˆ@9û÷ïß¿€À±Uó;ŠÀBa,Վ'€À!ÂiŸX"9@\ïß¿~ýû@9û÷ïß¿€À°æV#×ÀBH³ÐŒí€À!8FÌZÂ@];víÛ·n@9û÷ïß¿€À°Œ'„Y$ÀA¯Ûí=w€À ³(,d`ô@]‡8pá@9û÷ïß¿€À°’ıQF*ÀAZÐÅÉ0€À 3•4+Ã6@]Ò¥J•*T@9û÷ïß¿€À°j(ÕÌ`ÀAWþ¿=€Àrq݆@^‡pÛHc«€ÀlÍ  +@_ãǏOä * @b 0`@9û÷ïß¿€À«Q@ŸUúªÀ7Œ}êzl€ÀËóâ«u@b(Ñ£F@9û÷ïß¿€À«Ÿ`—²À7]vÆ1À€À]LiH¶ë@bN:téÓ@9û÷ïß¿€ÀªâÜ +§ýÀ7œä>ÁR€Àò3_™¡@bthÑ£F@9û÷ïß¿€Àª¬ñ~û§DÀ6¥ß¹,ÂD€ÀŠ„~á»>@bš4hÑ£F@9û÷ïß¿€ÀªwÚá&lÀ6M/Q*HŸ€À&8MŒþ@bÀ@9û÷ïß¿€ÀªC“gpëÀ5ö|I×ìD€À‰¹9ÎN_@;@å˗.\¹@;|ùóç@;|ùóç@;|ùóç@;µjÕ«V­@;|ùóç@;Ó'€ÀTlí€~ÇA@H6lÙ³fÍ@;À_ |Ó)Q€ÀSe³Wkª<@H͛6lÙ³@;[À[ué÷e]z€ÀN 'ÇÐ@KÁƒ 0@;\€@N¯øj‹Ú@RN:téÓ@;|ùóç@;™Ôˆ@X6lÙ³fÍ@; RÙDŽÀ>Ðû¥.äî€À“\ÏØ9@_˜0`Áƒ@;F}¶Ö®€Àȇwâ{$@_ãǏ‚xےž€Àä“4™@`‰$H‘"@;ÀÀ9#Ù¥LD€À¹®/qŽ@a‘£F4@;!À8ŒàŒ­yԀÀ4Œléh@a·nÝ»ví@;|ùóç@="D‰$HÀ˷ۄ¶5»À_tÇY‡ÈS€Àu€q@0Ö`@)û÷ïß¿~@="D‰$HÀËkI¢Íà—À`ª{)î®·€ÀtâºÃ@,X±bŋ@="D‰$HÀ˘%/Àax®NbòÀ€Àt;‡ÍúŒ@.µjÕ«V­@="D‰$HÀÊÆkÙ>ôÀb#ÄåV„ç€ÀsS1XÖoL@0‰$H‘"@="D‰$HÀÊo\ þTÀb³J Æß5€Àr‰ Vï}@1·nÝ»ví@="D‰$HÀÊà^/`ÑÀc*6›ì€ÀqÁ;d~*@2å˗.\¹@="D‰$HÀɺkï$L‘Àc‰+ùµ€Àpû ¿!x(@4(P¡B…@="D‰$HÀÉ]lvéú6ÀcÑŸDŠ’€Àp9Ì HØ@5B… +(P@="D‰$HÀÈÿH‚<”]Àd+ +‰]ـÀnù1ŸBjS@6páÇ@="D‰$HÀÈ _Õ0ãàÀd%p»%›•€Àm‰Áºòe@7Ÿ>|ùóç@="D‰$HÀÈA!HvÀd4ú  +ø€Àl&|šÅ$Æ@8͛6lÙ³@="D‰$HÀÇáˆ)|e±Àd5ü¥a¶j€ÀjÐ!ĉ±Ú@9û÷ïß¿~@="D‰$HÀǂ"ÓœDÀd*p‹`¶\€Ài‡ÁôF@;*T©R¥J@="D‰$HÀÇ#ÐÇö_Àdìt1“\€ÀhKŠt@µjÕ«V­@="D‰$HÀÆ àŽlR™Àc|€á&ù€ÀdèÙÕtSH@?ãǏ^ÍÀb6ín§ —€À`;–tHï@Bå˗.\¹@="D‰$HÀÃõ¥Îy¿Àa捰É3€À^ÛGš[®®@C|ùóçϟ@="D‰$HÀáÖ3‚©Àa“ç„ε•€À]SáíÊûD@D(P¡B…@="D‰$HÀÃO‘)!åÀa?œA»'€À[à'žÒÜz@D«V­Zµj@="D‰$HÀÂþÜ·íïÀ`ê¯ñ’Ž€ÀZBÎ õ@EB… +(P@="D‰$HÀ¯¹æî‹DÀ`•@Ã†W€ÀY0]¢£ÅB@EÙ³f͛6@="D‰$HÀÂb+aRùOÀ`?Þƒä€ÀWò¥ŒY.@FpáÇ@="D‰$HÀÂ/qüÃÀ_ÕÀ nÕ€ÀVÅL^”qU@G @@="D‰$HÀÁËŀ +cÁÀ_*ŸÌðæ€ÀU§ŽúÚb@GŸ>|ùóç@="D‰$HÀÁ‚ò<ˆcËÀ^}¥q€ÇZ€ÀT˜Î͹GL@H6lÙ³fÍ@="D‰$HÀÁ;¹ RTÄÀ]ÏšÿòڀÀS˜g»³{M@H͛6lÙ³@="D‰$HÀÀöª|,À]!Å7ـÀR¥Œmüa@Idɓ&L™@="D‰$HÀÀ²ÚtéÀ\tÈ8a'€ÀQÀ/XÓÐE@Iû÷ïß¿~@="D‰$HÀÀo©‹]vÀ[ÉZ’šE·€ÀPç%ž³ªt@J“&L™2d@="D‰$HÀÀ.ÎϬŒÀ[ 46€ÀPƒ¯¥ì@K*T©R¥J@="D‰$HÀ¿ß_¯“ÀZy1™~¢€ÀN°Š3  3@KÁƒ 0@="D‰$HÀ¿ct¥!+ìÀYÕ9_æÜ5€ÀMB›3¢A@LX±bŋ@="D‰$HÀŸêæNÛœÌÀY4^”N$ü€ÀKé3硪@Lïß¿~ýû@="D‰$HÀŸuH&»-›ÀX–Ôr›Ú±€ÀJ£M:•~@M‡8pá@="D‰$HÀŸŠ "ÄôÀWüëÙ±Lp€ÀIoë°IY@N“ÀP׏ÄK—M€À:)hR*:@Sȑ"D‰@="D‰$HÀ·Å–ãs³ÀP“â팀À93¿­:X@T(P¡B…@="D‰$HÀ·xwWT–ŸÀP'8Í3k‡€À8IµfÿË3@T_¿~ýû÷@="D‰$HÀ·,ô‚À±~ÀO ¥Üd7€€À7j“žy(@T«V­Zµj@="D‰$HÀ¶ã ØP@FÀNôçH_%K€À6•cZÊ¡š@TöíÛ·nÝ@="D‰$HÀ¶š²<„íÀNKlñRßV€À5ɳ1ÆQ@UB… +(P@="D‰$HÀ¶Sè¹ù~ÀM€dÕån€À5ϧ(©â@UŽ8páÃ@="D‰$HÀ¶¥ƒMÂîÀLÿôñ¡–€€À4L"¹škb@UÙ³f͛6@="D‰$HÀµÊä)jTýÀL^|ùóç@="D‰$HÀŽR|Ç}ØfÀHÐù ÙÜm€À/Ý@5úw²@WêÕ«V­Z@="D‰$HÀŽ„ïÊPÀHCîZè©€À.ÊNßõJ»@X6lÙ³fÍ@="D‰$HÀ³ßÖ²»’¹ÀG¹çŸ¹}~€À-Àn'k;¶@X‚ @@="D‰$HÀ³šjõ1ܖÀG2áGª N€À,¿žéåz@X͛6lÙ³@="D‰$HÀ³r:§ &ÀF®Õ ô2â€À+Å«o]š@Y2dɓ&@="D‰$HÀ³2ùÓIHÀF×J_/˜D€À,Ãæ\µJ@Ydɓ&L™@="D‰$HÀ²ý“ŠxtÀF_0KÆb€À+ÊÓ!…@Y°`Áƒ @="D‰$HÀ²ÉE\ÓقÀEêz7µÊ׀À*ܲe!S„@Yû÷ïß¿~@="D‰$HÀ²–ßêšÀEy ûÑÚŀÀ)øïnþ¡@ZGÇL¢Áª€Àe=â¥Ý@_ãǏµjÕ«V°ÀÌ ++%ý2€€Àw¿@"vøç?òå˗.\¹@>µjÕ«V°ÀÌÌgßhÀ*ö^ŒÝ¢€Àw¶LÁj'ß@å˗.\¹@>µjÕ«V°ÀÌ x®ƒIÀ:6o9o€Àwš±Lºùã@ X±bŋ@>µjÕ«V°ÀËõìOÞ*]ÀCç%SÕ9ª€Àwhò§ƒÄ­@å˗.\¹@>µjÕ«V°ÀËÚ¢·|>yÀJ<ÚÓî·F€Àw" 8SW,@Ÿ>|ùóç@>µjÕ«V°ÀËž'rDnÀP šèžˆ/€ÀvÈBl÷@X±bŋ@>µjÕ«V°Àˏ ­’ÐQÀRžöÈü}€Àv]Jgç%@ ‰$H‘"@>µjÕ«V°ÀË_œÆ|ŒÙÀULE.÷n·€ÀuâØ¿2ª@"å˗.\¹@>µjÕ«V°ÀË*€5Tô+ÀW€Þiú䧀ÀuZÂ÷0Vó@%B… +(P@>µjÕ«V°ÀÊð#x‚kÀYÙÿYCOš€ÀtÆõ]ÏP@'Ÿ>|ùóç@>µjÕ«V°ÀʰžŠðŽÀ[ÌL;ô€B€Àt)mjÊmÏ@)û÷ïß¿~@>µjÕ«V°ÀÊlô^V"kÀ]‹–t‚¹n€Às„wQøw±@,X±bŋ@>µjÕ«V°ÀÊ%OGyqÀ_}†›—€ÀrÚi¬@ÁÌ@.µjÕ«V­@>µjÕ«V°ÀÉÚS-œM™À`/Êù"€Àr-8oŽïÏ@0‰$H‘"@>µjÕ«V°ÀɌÄïÃÀ`ŸÄ#Sü"€Àq~“0N»“@1·nÝ»ví@>µjÕ«V°ÀÉ<:À:NÀa9bO[uW€ÀpÐTØ; @2å˗.\¹@>µjÕ«V°ÀÈéâšæ` ÀaŸb¡™€Àp"ÉoÓ_@4(P¡B…@>µjÕ«V°ÀȕÚ"‚ùjÀað€­=cž€Àn›…=@5B… +(P@>µjÕ«V°ÀÈ@}3.WÀb/nÀ °3€ÀmŸª ó@6páÇ@>µjÕ«V°ÀÇê Æ^0ŠÀb]cñ“€ÀlW”‹¶Kõ@7Ÿ>|ùóç@>µjÕ«V°ÀǓ©¿ìÄÀb{]¶P€Àk™gñ§ø@8͛6lÙ³@>µjÕ«V°ÀÇ;”ÁBÀb‹ôÀs‘€Àiã–VÜMÉ@9û÷ïß¿~@>µjÕ«V°ÀÆãæõ>[Àbu«õìè€Àh¹Dy„×@;*T©R¥J@>µjÕ«V°Àƌ=±høaÀb‰-޳ý|€Àg™nÐÚŸø@µjÕ«V°ÀÆ4ԈgmíÀbvŒ\Q€Àf„¹™W Y@=‡8pá@>µjÕ«V°ÀÅÝÞsÈÀbX프Š€ÀezôR†Ç@>µjÕ«V­@>µjÕ«V°À҅ wKÀb3ž‡6Ÿ€Àd{þÎ>ˆ@?ãǏµjÕ«V°ÀÅ1ìß"òÀb‚‹øV€Àc‡§+kØÀ@@‰$H‘"@>µjÕ«V°ÀÄÝ2»æmkÀaÕÎfÆûv€Àb¬ø@A @@>µjÕ«V°Àĉn–>ô2ÀaŸ‡Ý¯v€ÀaœÂªT÷Û@A·nÝ»ví@>µjÕ«V°ÀÄ6µ|‡äÔÀad—î©Y€À`çŸèLA$@BN:téÓ@>µjÕ«V°ÀÃå„„Àa$çp[€À` Öç2Ž@Bå˗.\¹@>µjÕ«V°ÀÔœ;!DBÀ`áŸÇ7C(€À^¯£Úúoþ@C|ùóçϟ@>µjÕ«V°ÀÃE ‘£ÌÀ`›œø»&€€À];OðÆSM@D(P¡B…@>µjÕ«V°ÀÂ÷Ó£î~À`S›îüt€À[؊%fßë@D«V­Zµj@>µjÕ«V°À«^]KvÀ` +:.þÌ:€ÀZ†³ó#Å(@EB… +(P@>µjÕ«V°ÀÂ`FTHÒÀ_õœï>€ÀYE)žyÜ@EÙ³f͛6@>µjÕ«V°À›KÂÀ^ꕘw瞀ÀXE†Q@FpáÇ@>µjÕ«V°ÀÁÎ6e®3kÀ^U æ¬ý€ÀVðaNI¡‹@G @@>µjÕ«V°ÀÁ‡AðcçðÀ]œžž˜+œ€ÀUÛÞ êà@GŸ>|ùóç@>µjÕ«V°ÀÁA¶Çž9hÀ]#Z=Ò²€ÀTÕ1ñŸ@H6lÙ³fÍ@>µjÕ«V°ÀÀýš5 ò·À\‡lM?¶Ë€ÀSÛԊ¬øÚ@H͛6lÙ³@>µjÕ«V°ÀÀºîòtæ±À[êÑ+ÖL€ÀRï< êÓà@Idɓ&L™@>µjÕ«V°ÀÀyµšÁÖ4À[NYgÀ·œ€ÀRÞÿ²nÎ@Iû÷ïß¿~@>µjÕ«V°ÀÀ9í àdÀZ²°§»‡Ý€ÀQ:4Û|@J“&L™2d@>µjÕ«V°À¿÷%Q'…QÀZcxŽ£‡€ÀPp·Ï~Æ@K*T©R¥J@>µjÕ«V°À¿}EqZaÀYä/8=]€ÀOcÊEþŠ&@KÁƒ 0@>µjÕ«V°À¿1 +™VÀXéЖ0€ÀMú{€Ü±@LX±bŋ@>µjÕ«V°ÀŸ‘Ý.y~®ÀXU­Š±§ñ€ÀL€ì‹Tò@Lïß¿~ýû@>µjÕ«V°ÀŸ =¹!8ÀWÄy¡u_ـÀKa Å-‚@M‡8pá@>µjÕ«V°Àœ±E_6±ÀW6G¶Ú€ÀJ/)ƒ +M@NµjÕ«V°ÀœDâ@ËÀV¬íÔM#€ÀI Ù%. @NµjÕ«V­@>µjÕ«V°ÀŒÚûÀn- ÀV(¬Y€JЀÀGü€¥­c@OL™2dɒ@>µjÕ«V°ÀŒszž5§ºÀU©9©Íš€ÀFøçÎÓ5j@OãǏµjÕ«V°ÀŒHÅE(ÀU.QéÍ4t€ÀFdûøû@P=zõëׯ@>µjÕ«V°À»«QTœ€"ÀT·¶txv=€ÀE¶ðÚC@P‰$H‘"@>µjÕ«V°À»J€‰®Œ-ÀTE-Qä8o€ÀD>h'v@PÔ©R¥J•@>µjÕ«V°ÀºëÚžóÀSրÈN¬S€ÀCl͌Œ8«@Q @@>µjÕ«V°Àº膯¡ÀSk~õýmG€ÀBŠ-ÓÏxu@Qkׯ^œ{@>µjÕ«V°Àº4?g,›ŠÀSùw,„ê€ÀA阮Â@Q·nÝ»ví@>µjÕ«V°À¹ÛW‡æOÀRŸÅå¢è€ÀA6y†‡à@R 0`@>µjÕ«V°À¹„@¢WE“ÀR>¹‚}øš€À@ŒEùŠý!@RN:téÓ@>µjÕ«V°À¹.í€qŠaÀQొåʀÀ?ÔùŒô(1@Rš4hÑ£F@>µjÕ«V°ÀžÛOÆÊŽŽÀQ…ˆ$3ހÀ>¡J¬¬Œ¿@Rå˗.\¹@>µjÕ«V°Àž‰Z/x­ÈÀQ-ì[=€À=|Ý»I;@S1bŋ,@>µjÕ«V°Àž9 ("÷ÀP×N‰ÖVz€ÀµjÕ«V°À·ê8,ÄSÀP‚€t€À;]ÉÉs—@Sȑ"D‰@>µjÕ«V°À·, è—ÀP-ùcužÐ€À:`_P;ãr@T(P¡B…@>µjÕ«V°À·QV¯Š ÀO³Ùùâ<ï€À9ofù¯1@T_¿~ýû÷@>µjÕ«V°À·8‹µ€ÀO ̅rp€À8‰RKüž@T«V­Zµj@>µjÕ«V°À¶Ÿ¢Œ]TÍÀNgÕæƒŒ0€À7­iý-_8@TöíÛ·nÝ@>µjÕ«V°À¶w‘ŽÝÐÀMÄtòÔ¬ù€À6Ûã ß@UB… +(P@>µjÕ«V°À¶1þ÷ù˜ÀM#àÁ°°€À6öŸœŒ@UŽ8páÃ@>µjÕ«V°Àµíç\ùÀL„æi°€À5PQk^›¥@UÙ³f͛6@>µjÕ«V°Àµ«DÕÂâDÀKçGþ‡ €À4–éÕòÚ@V%J•*T©@>µjÕ«V°Àµj¢ ÀKMìâs÷€À3äÌeèÌ @VpáÇ@>µjÕ«V°Àµ*GÀnlÏÀJµ_²ºŒÄ€À39ƒ,²‚ì@VŒxñãǏ@>µjÕ«V°ÀŽëàúµoHÀJ b"Wi€À2” s‚¥@W @@>µjÕ«V°ÀŽ®Öñˆ3ÐÀIŽeiõ€À1õŸí@WS§N:t@>µjÕ«V°ÀŽs#&ž¬]ÀHþŸ˜ÀÎ6€À1\}2@WŸ>|ùóç@>µjÕ«V°ÀŽ8¿Ÿ2ÀHqíI‘¹€À0ȄÖÉï@WêÕ«V­Z@>µjÕ«V°À³ÿ£ì™kÑÀGè åŠy!€À09‚Î3Ð$@X6lÙ³fÍ@>µjÕ«V°À³ÇË1³³ØÀG`ý$6÷€À/^T;'@X‚ @@>µjÕ«V°À³‘.*cÄÀFÜÀ[_å€À.Re€9ßi@X͛6lÙ³@>µjÕ«V°À³[Æ0vWÀF[RÒX³m€À-NŽ@rŽ@Y2dɓ&@>µjÕ«V°À³åäÁ›lÀFLfàŸD€À-×,ý(€W@Ydɓ&L™@>µjÕ«V°À²çF‘Îì¬ÀF ö_aʀÀ,×Õ †y@Y°`Áƒ @>µjÕ«V°À²³¶CžÀÀE›ÑËÿ&€À+㇩jžY@Yû÷ïß¿~@>µjÕ«V°À²-sÈÀE-Å-ÓaÀ€À*ù²)K~5@ZGµjÕ«V°À²O¥=¢ÓxÀDž%Ýò(€À*Ês¹¡?@Z“&L™2d@>µjÕ«V°À²ZùÞæÀDZ’ÑP%à€À)CNв²X@ZÞœzõë×@>µjÕ«V°À±ïz$dE/ÀCõ=ñ†]ՀÀ(uÄ1eõ@[*T©R¥J@>µjÕ«V°À±ÀÊÖD–ÀC’¢íE¯õ€À'°·Ù€R@[uëׯ^œ@>µjÕ«V°À±’ÿÃᙟÀC2«Ñ¯Ÿ€À&óŒ„lT@[Áƒ 0@>µjÕ«V°À±f ,fÆÀBÕCR3.t€À&>lt–šv@\ 4hÑ£@>µjÕ«V°À±:1é;QÀBzTǰRŽ€À%fÊ­×@\X±bŋ@>µjÕ«V°À±Ç7R -ÀB!Ì.ݳH€À$éPjïŸv@\€H‘"Dˆ@>µjÕ«V°À°äX'šŒÀA˖&#j²€À$HÓ[ëÖœ@\ïß¿~ýû@>µjÕ«V°À°º²ý6†ÔÀAwŸêù¡é€À#®žt›ò€@];víÛ·n@>µjÕ«V°À°‘ÑãhÀA%×Væ€À#e霸@]‡8pá@>µjÕ«V°À°i­ëŒQÀ@Ö*Ü+¯µ€À"‹ÞÅ6Ó­@]Ò¥J•*T@>µjÕ«V°À°BD‚–ÙÀ@ˆ‰‚=©ˆ€À"Ç%›©@^µjÕ«V°À°Ž[À@<ââ9\€À!~Ý}ˆr@^iÓ§N:@>µjÕ«V°À¯ëTÒdÊÀ?æNCÿ߄€À ÿ䚂{Á@^µjÕ«V­@>µjÕ«V°À¯ hY³õ¹À?Vä¬u€À …¢”²r(@_ @>µjÕ«V°À¯WªnàÀ>Êgž€À àœK@_L™2dɒ@>µjÕ«V°À¯íèUÀ>Aœ9Ì€À<ՑMõ¢@_˜0`Áƒ@>µjÕ«V°À®Èõ÷LJÀ=ŒtƛXрÀbݺò@_ãǏµjÕ«V°À®‚n÷ŠÿyÀ=:s«È€ÀBçmŸ@`¯^œzö@>µjÕ«V°À®=úN«¢À<»žoýÀ€ÀÃæSúx{@`=zõëׯ@>µjÕ«V°À­ú­–[\ÀµjÕ«V°À­ž§»À;ÇËmv–€ÀBc`߬²@`‰$H‘"@>µjÕ«V°À­woŽkÊ·À;Q:±4€À‹¢én®@`®Ý»víÛ@>µjÕ«V°À­7pH +žÀ:Þ*X+TрÀÛ*Š©éÒ@`Ô©R¥J•@>µjÕ«V°À¬ø~$rÃ@À:mÓ.-“€À0žæ3,;@`útéÓ§N@>µjÕ«V°À¬º‘ôÅæjÀ: _€Œ€ÀŒ b0êf@a @@>µjÕ«V°À¬}¥Ûs[AÀ9”ýО^*€Àìç}åÁ°@aF 0`Á@>µjÕ«V°À¬A³áZÏAÀ9,X°Òþ€ÀSŒ9dl@akׯ^œ{@>µjÕ«V°À¬¶µjÕ«V°À«Ì§N)ˆÀ8b8ÅŒ·a€À.kŸœ^z@a·nÝ»ví@>µjÕ«V°À«“¢'„À8›–9ìð€À£8$['@aÝ:téÓ§@>µjÕ«V°À«[?ì:¥‹À7¡3þšրÀ‚¥é€è@b 0`@>µjÕ«V°À«#Ý;èÀ7Cñ­~œp€Àš…ü•@b(Ñ£F@>µjÕ«V°ÀªíSö§£=À6èÄß%{ÿ€ÀÝ),E@bN:téÓ@>µjÕ«V°Àª·ŸÝöƋÀ6žX-Ûö€À¡˜|ô@bthÑ£F@>µjÕ«V°Àª‚Œ&™«À68o`Œ²É€À+&jê&@bš4hÑ£F@>µjÕ«V°ÀªN£Ý™¢ËÀ5ã)Ÿ°UɀÀžb’«ÁQ@bÀ@>µjÕ«V°ÀªRí»ÈíÀ5¿²ÑÇò€ÀI(b$=‚@@$H‘"DŒÀÊùþ0q4E€€Àuޅ¯Ñ.€?òå˗.\¹@@$H‘"DŒÀÊöl9:›áÀ(RÝ~ú[€Àu×É­Eï@å˗.\¹@@$H‘"DŒÀÊì£QõÀ7`Ê@X€ÀuÀlÕÅuø@ X±bŋ@@$H‘"DŒÀÊÚœrÌ®ÀA•C’ƒš€Àu—™o'@å˗.\¹@@$H‘"DŒÀÊ€cÙ)âÀG)w·ÊÇF€Àu[Íàq§@Ÿ>|ùóç@@$H‘"DŒÀÊ€5UÀÀLV +ÒŽB,€ÀuŠøª@X±bŋ@@$H‘"DŒÀÊîh1ôÀP‡9Uœ'-€Àt¶Ë#ƒì@ ‰$H‘"@@$H‘"DŒÀÊV%RlKpÀRѶ—npƀÀtOæá\@"å˗.\¹@@$H‘"DŒÀÊ'2qèXÀTëog¯ùF€ÀsÝ:¿î@%B… +(P@@$H‘"DŒÀÉóc·kª;ÀVéÐÝðáW€Às`&­#@'Ÿ>|ùóç@@$H‘"DŒÀÉ» `ݝÀX¯ŒjÞý8œ€À[¿Å€¥”€Àqºò’ž<ö@.µjÕ«V­@@$H‘"DŒÀÈûï¢Ö–À\þ?oBt€Àq%VvmÒ@0‰$H‘"@@$H‘"DŒÀȶ ‡ƒÃ—À^-„rƀÀpŽñ8˜™@1·nÝ»ví@@$H‘"DŒÀÈmâö`À_ ˜u«€Àoêš ƒšK@2å˗.\¹@@$H‘"DŒÀÈ#ˆDÉ×QÀ_ߗ>Ì%œ€Ànºv¥%n@4(P¡B…@@$H‘"DŒÀÇ×i[˜ß‹À`Fžï‡Ø€ÀmŒv*]„@5B… +(P@@$H‘"DŒÀlj׌Ž2|À`%Ÿa€Àlb&ÔdÞ@6páÇ@@$H‘"DŒÀÇ;VBïÀ`ÄxeTz€Àk=.]±ýT@7Ÿ>|ùóç@@$H‘"DŒÀÆë~ÂX+À`íÖÿ3“€ÀjÓ:Ž\i@8͛6lÙ³@@$H‘"DŒÀƛ;€F`Àa +}/AÐò€Àiý•ïf @9û÷ïß¿~@@$H‘"DŒÀÆJŒpmÊñÀa­åš~€ÀgùMff‰@;*T©R¥J@@$H‘"DŒÀÅùŠxO ™Àa!p狭րÀfó+?|]@µjÕ«V­@@$H‘"DŒÀÅÀf‡‹jÀ`óK?5r€Àdâ–ÊY@?ãǏ€ÀVø€6®Å@GŸ>|ùóç@@$H‘"DŒÀÀÿ°6ÓsÀ[Ó@­ÆÊ„€ÀU’ñBŒ@H6lÙ³fÍ@@$H‘"DŒÀÀŸ™¥¥À[H ;Z>ï€ÀT&okן„@H͛6lÙ³@@$H‘"DŒÀÀ~Í­y_ÀZ»z9 ÷a€ÀS@7îP@Idɓ&L™@@$H‘"DŒÀÀ@N–2/„ÀZ.d]ÔY€ÀReJŌ@Iû÷ïß¿~@@$H‘"DŒÀÀ¶5/òÀY¡uÿ€ÀQ”ü­;–@J“&L™2d@@$H‘"DŒÀ¿ŽnWÀJÀY;©ì‡™€ÀPÏKÈÉ@K*T©R¥J@@$H‘"DŒÀ¿6'€­ÀXŠ.p{ËO€ÀP“ÚW‘Š@KÁƒ 0@@$H‘"DŒÀŸŠŒc­2QÀX®2?ۀÀNÂʰ ¢ï@LX±bŋ@@$H‘"DŒÀŸ6g›éÐ/ÀWy «é|€ÀMp¥>\ܐ@Lïß¿~ýû@@$H‘"DŒÀœÈŸÉ {ÀVó~ªIA_€ÀL/ã\(ǁ@M‡8pá@@$H‘"DŒÀœ]‡k1b²ÀVpf ˆ€ÀJÿ·ŸÿL"@Nãçÿ @S1bŋ,@@$H‘"DŒÀž 5×û–ÀP{ˆZ&·8€À=ÅSÅ®æ?@S|ùóçϟ@@$H‘"DŒÀ·Ÿ(Ž|#ÀP+ÔÆq€À<ŽØ}¬F@Sȑ"D‰@@$H‘"DŒÀ·rpåXÓÑÀOµ 9ÐXˀÀ;°ÔÆJå@T(P¡B…@@$H‘"DŒÀ·(H‹ +wßÀOxÁ5ƒü€À:žz”©u @T_¿~ýû÷@@$H‘"DŒÀ¶ßš¡ ²ªÀNt²Úщ€À9Ë +Pn3â@T«V­Zµj@@$H‘"DŒÀ¶˜dÚ]ØšÀMֳͺ€À8çÑÅY@TöíÛ·nÝ@@$H‘"DŒÀ¶R€2ž%ÀM8Õ$À™¹€À8+)¶*q@UB… +(P@@$H‘"DŒÀ¶U‹ .ÀLBºØK€À7=|9Ÿé®@UŽ8páÃ@@$H‘"DŒÀµËs3ý~gÀLŒ!ËŒŠ€À6u5\bx@UÙ³f͛6@@$H‘"DŒÀµ‰ú^ŸÀKkݎ$„Ô€À5ŽÐÞnƒ +@V%J•*T©@@$H‘"DŒÀµIäÅA,˜ÀJÖ[ý¡E€À4ûÒ7×ýè@VpáÇ@@$H‘"DŒÀµ -莩÷ÀJC&ïù•€À4IÅ`ÄÔ'@VŒxñãǏ@@$H‘"DŒÀŽÍÏú¶ oÀI²T äen€À3ž>2ŠÖ_@W @@@$H‘"DŒÀŽ‘Å|ùóç@@$H‘"DŒÀŽ‘ˆXWÔÀHíŸUc1€À1Ÿû‡Œ!D@WêÕ«V­Z@@$H‘"DŒÀ³å\hÛ4DÀGˆLù„€À1)Û×"Ȟ@X6lÙ³fÍ@@$H‘"DŒÀ³®b4ÝDŽÀGL‹Ýp\€À0™ˆ”CÍŒ@X‚ @@@$H‘"DŒÀ³xœ²—ðkÀF‚ðWN €À0 º4Gb>@X͛6lÙ³@@$H‘"DŒÀ³D€àSaÀF8?iº„€À/ [©S)»@Y2dɓ&@@$H‘"DŒÀ³ÿkNçGÀF)VfTvà€À.Û6ý@Ydɓ&L™@@$H‘"DŒÀ²Ð+\éðuÀEžÊOv &€À-Öb,uŸä@Y°`Áƒ @@$H‘"DŒÀ²]›ñŒÏÀEK=|”Ï€À,Ü¥gcñ@Yû÷ïß¿~@@$H‘"DŒÀ²k.GÊŽÀDà™µŒ«­€À+íoÃ!»æ@ZG"kèî€À'«°í”@\ 4hÑ£@@$H‘"DŒÀ±(¶Éœ©ºÀB@²•UÈ¿€À&`ÃOš¥@\X±bŋ@@$H‘"DŒÀ°ýÿ?Ü;ÀAêo‡šÞ €À%ŽýÂêÜŒ@\€H‘"Dˆ@@$H‘"DŒÀ°ÔØLfgÀA–c:#œà€À%ë5Ò@\ïß¿~ýû@@$H‘"DŒÀ°ªæoz˜ÀAD|sN>Œ€À$q:\ r%@];víÛ·n@@$H‘"DŒÀ°‚{ lâÀ@ôª}—â€À#؞ ޲@]‡8pá@@$H‘"DŒÀ°ZÉÂë±ÅÀ@ŠÝ%®†C€À#EώµT—@]Ò¥J•*T@@$H‘"DŒÀ°3ÍöøuNÀ@[ž§ÖO€À"žˆ˜;Ó±@^|bo•΀À µÞdy'ó@_L™2dɒ@@$H‘"DŒÀ®õÔ®uœÀ=öAf:¯8€À @ŽyvÍL@_˜0`Áƒ@@$H‘"DŒÀ®¯šÒÇÜmÀ=s­À;Ÿå€ÀŸz/ @_ãǏ|ùóç@@íÛ·nÝŒÀÉ€xçßcÊÀI(lR룀Às‘Yôê@X±bŋ@@íÛ·nÝŒÀɄBý­^ÀM[&ÿ;Ï€ÀsE ðR5@ ‰$H‘"@@íÛ·nÝŒÀÉ_#ã v—ÀP¹¬e‹z€ÀríÉìlÕÔ@"å˗.\¹@@íÛ·nÝŒÀÉ5döŒDŸÀRœ–V«~è€ÀrŒh²âÑ¢@%B… +(P@@íÛ·nÝŒÀÉEJOnÀTiîiDm+€Àr!áÿ&Íu@'Ÿ>|ùóç@@íÛ·nÝŒÀÈÕ—D€FÀVž{wn{€Àq¯\[)£l@)û÷ïß¿~@@íÛ·nÝŒÀȟI·,ŠdÀW…Ór u€Àq6U6ËHî@,X±bŋ@@íÛ·nÝŒÀÈfŽwaEÀXàïÞæA-€Àpž\^‹Pö@.µjÕ«V­@@íÛ·nÝŒÀÈ)ãL°Ô3ÀZGÇ/—€Àp6ÔöÙ@0‰$H‘"@@íÛ·nÝŒÀÇë –KƒÀ[ „p¡Ë÷€ÀoeÝ 2@1·nÝ»ví@@íÛ·nÝŒÀÇ©ÑQŽ™,À\+:8„*€Àn[juu/µ@2å˗.\¹@@íÛ·nÝŒÀÇf} »IÀ\çÂdý+€ÀmP M»DD@4(P¡B…@@íÛ·nÝŒÀÇ![tfbœÀ]šätªíπÀlE1„¯O»@5B… +(P@@íÛ·nÝŒÀÆÚµ¶ŒBÀ^1>#/XF€Àk<5Bü¡^@6páÇ@@íÛ·nÝŒÀƒÌ]ÉÀ^­?ÿH#÷€Àj6›©Aè@7Ÿ>|ùóç@@íÛ·nÝŒÀÆIۂGŽ7À_+§†¡·€Ài5™îXºŽ@8͛6lÙ³@@íÛ·nÝŒÀÆ:VÈÎÀ_[²ñëØ€Àh:Œh@9û÷ïß¿~@@íÛ·nÝŒÀŵÆS›kŒÀ_‘¶Ve¬€ÀgD³áP?ú@;*T©R¥J@@íÛ·nÝŒÀÅkaÀ_±Óv×7/€ÀfUí9û!@µjÕ«V­@@íÛ·nÝŒÀĊp¡íÀ_•¶,Ø€Àc³üzB U@?ãǏ|ùóç@@íÛ·nÝŒÀÀŒÒñޘÀZ‘ýiÿ‡€ÀU\xŽ;áD@H6lÙ³fÍ@@íÛ·nÝŒÀÀ~«~ìÕÀZ»R*Œ€ÀTq¶»¯“@H͛6lÙ³@@íÛ·nÝŒÀÀA«Ž‚ÀY“ýx¡º€ÀS‘ÌGìà¯@Idɓ&L™@@íÛ·nÝŒÀÀÔùÕ[ÀY&Ò¿é€ÀRŒX€Hö@Iû÷ïß¿~@@íÛ·nÝŒÀ¿–V[+OÀX•åß ‹•€ÀQðörN–b@J“&L™2d@@íÛ·nÝŒÀ¿#[ÏŽ:‘ÀXÊèÊŽ€ÀQ/AÇ 5@K*T©R¥J@@íÛ·nÝŒÀŸ²žxŠ—ÑÀW˜NYš€J€ÀPvÖìNäé@KÁƒ 0@@íÛ·nÝŒÀŸDhv6šžÀWÓž€ÀOާ…ü¬¥@LX±bŋ@@íÛ·nÝŒÀœØf<Œ¹ÀVž­ÌH#€ÀN@°M@Lïß¿~ýû@@íÛ·nÝŒÀœnªàbxFÀV$ÿF€ÀM €m®@M‡8pá@@íÛ·nÝŒÀœ.C©ÜâÀU«!Åø€ÀKÕú3ò@Nžl8X·ÀTÅ[ìî€ÀI€±óOˆÜ@OL™2dɒ@@íÛ·nÝŒÀ»Ý›œ-ÌÀTWè繊€ÀH Èï®@OãǏY2ÀQ ÁÀMõ\€ÀAgkŒ.ֆ@Rš4hÑ£F@@íÛ·nÝŒÀžvN] [ÀPžÅ=z敀À@Æ¢?Hh@Rå˗.\¹@@íÛ·nÝŒÀž( /ÔÿƒÀPiÿDâm€À@--ŒìLØ@S1bŋ,@@íÛ·nÝŒÀ·Û3ÇI†ÀPQžœË€À?5aXžùŽ@S|ùóçϟ@@íÛ·nÝŒÀ·ÅP¡ÉWÀO¢31«¢€À>›ËÓŽX@Sȑ"D‰@@íÛ·nÝŒÀ·EÀXzôZÀO 5ô={6€À=;`ŠÇ@T(P¡B…@@íÛ·nÝŒÀ¶ý$ò0Ï!ÀNp$Û+&€À<zÍ|7u@T_¿~ýû÷@@íÛ·nÝŒÀ¶µòÍhá!ÀM×iXŽê€À;¡7Êé@T«V­Zµj@@íÛ·nÝŒÀ¶p(°öNøÀM?_=Í0 €À:3uN£q@TöíÛ·nÝ@@íÛ·nÝŒÀ¶+ę{VÀLšV"‘€€À9Qþ—aàñ@UB… +(P@@íÛ·nÝŒÀµèÃÔȧaÀL’ž%}û€À8yÿïú¬7@UŽ8páÃ@@íÛ·nÝŒÀµ§#þfŸÀK~OýŠni€À7ªzû x@UÙ³f͛6@@íÛ·nÝŒÀµfޟQ,ºÀJëÀS®r:€À6âí‰H\]@V%J•*T©@@íÛ·nÝŒÀµ'ò-*­oÀJ[r/Ì€À6"Þ_j@VpáÇ@@íÛ·nÝŒÀŽêY/>ÀIÌ^C=ž“€À5iÛ²1,@VŒxñãǏ@@íÛ·nÝŒÀŽ®í„ÀI?ͧýz0€À4·zžvÆÄ@W @@@íÛ·nÝŒÀŽs Èx5ÀHµu(‡}€À4 Y”éª@WS§N:t@@íÛ·nÝŒÀŽ9Pç`ö©ÀH-hŽ·¡²€À3ebÄ_@WŸ>|ùóç@@íÛ·nÝŒÀŽÒ uŒÀG§·vŠ8u€À2ÄipI@WêÕ«V­Z@@íÛ·nÝŒÀ³ÉS\ßrÀG$mÇÆ‘¥€À2(òž.É®@X6lÙ³fÍ@@íÛ·nÝŒÀ³“{G)ŽÀF£”'c…€À1’kf±må@X‚ @@@íÛ·nÝŒÀ³^–³‡ênÀF%0ZP)ö€À1Œyªñä@X͛6lÙ³@@íÛ·nÝŒÀ³*ÙÃ6h‡ÀE©E¢#ˆ²€À0sk§ˆš@Y2dɓ&@@íÛ·nÝŒÀ²ëR-°CÀEϬ4ž{€À/ÐAƒpì@Ydɓ&L™@@íÛ·nÝŒÀ²žMhL*ÀEbëÁÇÐt€À.ƀîŒs…@Y°`Áƒ @@íÛ·nÝŒÀ²†F3_›ÀDøù·T§.€À-È T(«@Yû÷ïß¿~@@íÛ·nÝŒÀ²U6ñ`‚ÀD‘›™£:€À,Ô$‘ȉP@ZGùl@[Áƒ 0@@íÛ·nÝŒÀ±AÀi×iWÀB[°õõÀK€À'ßÞ9ÏM@\ 4hÑ£@@íÛ·nÝŒÀ±ÈñG–ÕÀBœY…€À''烜ó}@\X±bŋ@@íÛ·nÝŒÀ°ìšZÉ}uÀA±™~@(‹€À&wâUÀïÞ@\€H‘"Dˆ@@íÛ·nÝŒÀ°Ã/™õLâÀA_Ÿs3X{€À%ΣLôx®@\ïß¿~ýû@@íÛ·nÝŒÀ°šƒÈÕlÀAî¿Aú€À%+ÙëŒ@];víÛ·n@@íÛ·nÝŒÀ°r’#ÁšÀ@Â@¹€À$9úoÄj@]‡8pá@@íÛ·nÝŒÀ°KV d.éÀ@v4‘1€É€À#ø{IšÚ"@]Ò¥J•*T@@íÛ·nÝŒÀ°$Ëk޲À@,,Álí€À#gYt¹}@^±†l„%ã€À!Ó)™Ëí;@_ @@íÛ·nÝŒÀ¯"isŽÀ>+[Ûš œ€À!VÇ_÷@_L™2dɒ@@íÛ·nÝŒÀ®ÛÀ:‡1À=šjq÷6>€À Ýv§Ì~@_˜0`Áƒ@@íÛ·nÝŒÀ®–Hôc˜0À=(š™1]€À i "Ȃ@_ãǏ§À;» €kJ€ÀGñe„Ë@`cF4h@@íÛ·nÝŒÀ­‹ÍV;À;FÛS·ˀÀ}÷ŸÔ#@`‰$H‘"@@íÛ·nÝŒÀ­KäôÎøRÀ:Õ`x­æ €ÀºßސÐì@`®Ý»víÛ@@íÛ·nÝŒÀ­ † èÀ:f…0` Š€Àþ_Úç…@`Ô©R¥J•@@íÛ·nÝŒÀ¬Ï(îÓç>À9ú6:~ €ÀH2»Ý#@`útéÓ§N@@íÛ·nÝŒÀ¬’I@Gœ8À9`ôŒ=€À˜ȏ*b@a @@@íÛ·nÝŒÀ¬V`¶ôl°À9(óT)Yç€ÀíÉɍ«@aF 0`Á@@íÛ·nÝŒÀ¬i¹MšÀ8ÃÛãwQò€ÀI†n+Ð@akׯ^œ{@@íÛ·nÝŒÀ«á^Ö[£qÀ8a »­d2€À©¶€#ŠI@a‘£F4@@íÛ·nÝŒÀ«š:Än“óÀ8l€Ïqš€Àxj@a·nÝ»ví@@íÛ·nÝŒÀ«oø_Ü-À7¡ô]*Óq€Àz8çUŠ;@aÝ:téÓ§@@íÛ·nÝŒÀ«8’©Ç!^À7E‘ü÷‚]€Àé±@8›ð@b 0`@@íÛ·nÝŒÀ«Æò<ÅÀ6ë6Š ª€À]¹Øù@b(Ñ£F@@íÛ·nÝŒÀªÌIþ›”TÀ6’Ó§Ý2{€ÀÖ#Cžš@bN:téÓ@@íÛ·nÝŒÀª—]¹bÍ5À6<[o!Ûs€ÀRĈÔn|@bthÑ£F@@íÛ·nÝŒÀªc;€8AåÀ5çÀj3¥¯€ÀÓsµ2x­@bš4hÑ£F@@íÛ·nÝŒÀª/ÞûUiÀ5”õ‘dà€ÀX k;ÚÕ@bÀ@@íÛ·nÝŒÀ©ýCñ>!œÀ5CîEÀ„B€Àà`0M@A·nÝ»vðÀÈúëôEO€€ÀrÖ+ n?òå˗.\¹@A·nÝ»vðÀÈ÷û¥ÏÉÀ$#Rk€ÀrÐzÉuÉ/@å˗.\¹@A·nÝ»vðÀÈï‡ñ}0À2ø"ÚF=€Àr¿s¹¶Á@ X±bŋ@A·nÝ»vðÀÈá®ù¯RjÀ;ò[aÍû €Àr¡Sñd/F@å˗.\¹@A·nÝ»vðÀÈΎ[Û ÙÀB^Œ$ €ÀrvU€S"@Ÿ>|ùóç@A·nÝ»vðÀȶo®Ê£ÏÀFsÍü3âÀ€Àr?¿L9±<@X±bŋ@A·nÝ»vðÀș¯ŠîPVÀJ5 6†žZ€Àqþœìtˆ @ ‰$H‘"@A·nÝ»vðÀÈxˆÙdn‡ÀMâWŸ7ô,€ÀqŽ.i\ŠH@"å˗.\¹@A·nÝ»vðÀÈS9!ÿHÀP€‰žsë€Àq`ßEžaƒ@%B… +(P@A·nÝ»vðÀÈ)ôYÚÀRF ˆ)æ€Àq™þc¶‡@'Ÿ>|ùóç@A·nÝ»vðÀÇýBjçTÀSŸµ!;ªÔ€Àp£J¶ç”@)û÷ïß¿~@A·nÝ»vðÀÇ̹ïyû“ÀU ”¯%Â€Àp; mft@,X±bŋ@A·nÝ»vðÀǙKÔ ±ÀVeˆ±v|‹€Àoœžö«šS@.µjÕ«V­@A·nÝ»vðÀÇc…y»ÀW„ÉŠ¶-,€Àn»ë«É@0‰$H‘"@A·nÝ»vðÀÇ*@Ô­+§ÀXŠ£÷%ÑU€ÀmÖ7~ y@1·nÝ»ví@A·nÝ»vðÀÆï)ãýÀYx] )€Àlìâ0ˆž@2å˗.\¹@A·nÝ»vðÀƲ7`ÑšÀZIëíEžÎ€Àl²ŠZŒ3@4(P¡B…@A·nÝ»vðÀÆsâ‚AÀZþNÖQ-#€ÀkÍò›²@5B… +(P@A·nÝ»vðÀÆ2Žt˜{À[™µt¬ƒ¡€Àj*ii ÒL@6páÇ@A·nÝ»vðÀÅðºžv;xÀ\Lîõ8;€Ài@ޚ@= @7Ÿ>|ùóç@A·nÝ»vðÀÅ­ÈeðñÅÀ\Œ{]”‰h€ÀhZGП”•@8͛6lÙ³@A·nÝ»vðÀÅiëOmËëÀ\åFè€Àgw}³Yµ8@9û÷ïß¿~@A·nÝ»vðÀÅ%TlkËóÀ]* $I:¿€Àf™$øOÿ@;*T©R¥J@A·nÝ»vðÀÄà2×ÈvDÀ]Z>N‡ù€Àe¿»7?ç'@À]s÷áTÕ€Àd뚬gA@=‡8pá@A·nÝ»vðÀÄUëž3À]zãYÔ¶«€Àd:VUŒ@>µjÕ«V­@A·nÝ»vðÀÄ€ÏÓ„À]qmÞ£ý€ÀcT¢¥ÒQ£@?ãǏžù @C|ùóçϟ@A·nÝ»vðÀÂ2t[žÀ[óÞ¹’‚€À]PñÉ<²@D(P¡B…@A·nÝ»vðÀÁðÐý@öÀ[ð=8Æq€À[Ð›ŠŸ@D«V­Zµj@A·nÝ»vðÀÁ¯þ8îÀ[CŠÏ_ë0€ÀZ«ÆÝ b@EB… +(P@A·nÝ»vðÀÁpúÃáAÀZåç9ÙQ€ÀY’–˜L +@EÙ³f͛6@A·nÝ»vðÀÁ0ìƒÞöÀZ…uOˆF̀ÀX„ÄòæüZ@FpáÇ@A·nÝ»vðÀÀòºgfH°ÀZ"ø€‰—€ÀW‚Ÿy@G @@A·nÝ»vðÀÀµt}@ßÎÀYŒîäj €ÀVŠÙ) Õ@GŸ>|ùóç@A·nÝ»vðÀÀy%ØñkÀYRwpÓv€ÀUœŠhQS¬@H6lÙ³fÍ@A·nÝ»vðÀÀ=ÕBØEcÀXä²ZX3þ€ÀT¹DûAáº@H͛6lÙ³@A·nÝ»vðÀÀ‹Žê üÀXtÿ×ê€ÀSßê~8=`@Idɓ&L™@A·nÝ»vðÀ¿”šdêûÀXÜ1s_W€ÀS*Ë<@Iû÷ïß¿~@A·nÝ»vðÀ¿$8@²ãÀWB®¯JŠ€ÀRI°6€ ¢@J“&L™2d@A·nÝ»vðÀŸµôØÆ>"ÀWSñ|\=€ÀQŒ(ƒ\ZÅ@K*T©R¥J@A·nÝ»vðÀŸIÐD{ÀVª‰35€ÀP×>U…t@KÁƒ 0@A·nÝ»vðÀœßÈãàÀV8GÖs®j€ÀP*œI,Ì7@LX±bŋ@A·nÝ»vðÀœwÛ]Þ&aÀUÆäI*n€ÀO ߯Æw¹@Lïß¿~ýû@A·nÝ»vðÀœòy_œÀUV€¡Fš€ÀMÑÍj†€ÀC‰«ºÁmž@R 0`@A·nÝ»vðÀžÝ2ø“ÀPéBË~€ÀBÖ7ñŸ@RN:téÓ@A·nÝ»vðÀžŽö{ÐcÀPšýRoL€ÀB*³–mW»@Rš4hÑ£F@A·nÝ»vðÀž@Hè£C ÀPNøÈ³>6€ÀA†ÀžÛ9@@Rå˗.\¹@A·nÝ»vðÀ·óìN-N?ÀPíKèT€À@êˆɎ@S1bŋ,@A·nÝ»vðÀ·šéÜ@ÇÀOy,)SـÀ@T"²¬w@S|ùóçϟ@A·nÝ»vðÀ·_9Q ­ +ÀNéŒÕtuf€À?‰’;ôF@Sȑ"D‰@A·nÝ»vðÀ·ß‡ïÀNXˆ-‰#j€À>w'#FƒŠ@T(P¡B…@A·nÝ»vðÀ¶ÏÜ•]vÀMÆó…2€À=pG1äú@T_¿~ýû÷@A·nÝ»vðÀ¶Š1,‚ÀM5?Y“U€Àã[…ÀKƒ{8œZ€À9»uá,r@UŽ8páÃ@A·nÝ»vðÀµ€æòM‘„ÀJôMËUf€À8ä}RÄrÿ@UÙ³f͛6@A·nÝ»vðÀµAâL¿Õ‹ÀJfê–ä*Ž€À8ä›Ñ 8@V%J•*T©@A·nÝ»vðÀµ)ÓFe‰ÀIÛùˆ4i€À7NÝ]•â@VpáÇ@A·nÝ»vðÀŽÇ¹¡íó©ÀIPþá_Š€À6Žù0»Úx@VŒxñãǏ@A·nÝ»vðÀތ{ÃýƒÀHÈÁÚ€é€À5Õеþ¢Û@W @@A·nÝ»vðÀŽR ÙügÀHB}µݷ€À5#v@WS§N:t@A·nÝ»vðÀŽîóûöÑÀGŸJpxÈY€À4v5§ñ1@WŸ>|ùóç@A·nÝ»vðÀ³ârÓ CÀG<;@~€£€À3ÏTJ©Ú@WêÕ«V­Z@A·nÝ»vðÀ³¬'Vš5ÀFŒ_šDt€À3-LdÔØ@X6lÙ³fÍ@A·nÝ»vðÀ³w:ȖœÀF>Ãdcåñ€À2– @õœ@X‚ @@A·nÝ»vðÀ³C %Ü×¥ÀEÃoms%€À1øªäaè@X͛6lÙ³@A·nÝ»vðÀ³3¬ÿ~ÀEJiǟ,£€À1eF¢[Ó¡@Y2dɓ&@A·nÝ»vðÀ²ÑêÓ *ÀEtK8{€À0Zèÿ›.M@Ydɓ&L™@A·nÝ»vðÀ²Ÿž gg¥ÀE ˜Ë³n€À/šFχö“@Y°`Áƒ @A·nÝ»vðÀ²nzÐø¢ÄÀD¥AŽÇâ€À.¥Ìžäê&@Yû÷ïß¿~@A·nÝ»vðÀ²>,Qè;ÏÀDAwV"B³€À-­ÚzbÐ@ZG$Á13@]‡8pá@A·nÝ»vðÀ°;X‰ŠµïÀ@DMíe础À$£ÎÃï @]Ò¥J•*T@A·nÝ»vðÀ°A5‘ À?ø9Úï2€À$%Û7ræ@^áÂÉÌÑï€À"õÜ¢tc¯@^µjÕ«V­@A·nÝ»vðÀ¯MŒRÀ>[|987€À"pÅbN@_ @A·nÝ»vðÀ¯¡²…ÛÀ=ØcÈÇÂç€À!ðkwTš§@_L™2dɒ@A·nÝ»vðÀ®À¹QɏÀ=Xbm hՀÀ!t››!y@_˜0`Áƒ@A·nÝ»vðÀ®{ûÀ».QÀ<ÛaĐá€À ý#šqW@_ãǏ|ùóç@B ÀÇØê“hœÀD w‚}N…€Àq{Ò­@X±bŋ@B ÀÇŸ?Á|€1ÀG8Xóm׀ÀpÜdö—@ ‰$H‘"@B ÀÇ =rx¹ÀJÒŽƒuï€Àpœ .3@"å˗.\¹@B ÀÇ’Ê®ÀMçW¿Ÿ%°€ÀpTWºW4@%B… +(P@B ÀÇYç0ëðÀPp'$ê€Àp±%èƒì@'Ÿ>|ùóç@B ÀÇ1u «æÁÀQÈ«éŽÅ€ÀoaæÚ.Ñ@)û÷ïß¿~@B ÀÇï=SG¥ÀSNam€Àn®k™žˆ@,X±bŋ@B ÀÆ×|˜®m‘ÀT?zKo'€Àmñ·ì܉Ê@.µjÕ«V­@B ÀÆŠb>ó@ÀUNF–\$€Àm.^œZ-@0‰$H‘"@B ÀÆrãn”wÀVHck^Ký€Àlf ¡€PD@1·nÝ»ví@B ÀÆ=,„6ÏÀW.,hó€Àk™­øµa@2å˗.\¹@B ÀÆs–Œ²…ÀWû"“0•€ÀjÊšEŸà@4(P¡B…@B ÀÅËõ€ž_ÀX­Õš Z€ÀiúJŒ@5B… +(P@B ÀŐì9ÐLÿÀYJßð‹Hí€Ài(òٌ³@6páÇ@B ÀÅT‡@¶ÀYÔ|ùóç@B ÀÅõŠOŒÀZI¡Ý{šÞ€Àg‰©2CM·@8͛6lÙ³@B ÀÄØdãb&SÀZ«‹( ˆÐ€Àfœ@9û÷ïß¿~@B Àę‘59…ÀZúäÁ­xã€ÀeóÓSæäó@;*T©R¥J@B ÀÄXýY>žÀ[6¯QǀÀe-í.ÌfÏ@µjÕ«V­@B ÀÖåA¹ÑÀ[v³œ T€Àbõp±ì^¹@?ãǏ|ùóç@B ÀÀ4·ú®ŸÀX" z}®Ô€ÀUӄvn¡@H6lÙ³fÍ@B À¿øP&ipÀWÁŠœÁ€ÀT÷×f—p@H͛6lÙ³@B À¿‰=|jôÀW]ayI.n€ÀT%0*·ç#@Idɓ&L™@B À¿Ž<õZÐÀV÷º·Ë~¡€ÀS[N±_#@Iû÷ïß¿~@B ÀŸ¯þÆÂpÀVÈm3‚Á€ÀR™ïssÍ~@J“&L™2d@B ÀŸFWžvBåÀV)gñãt€ÀQà̧Ü@K*T©R¥J@B ÀœÞ›_ÀUÁ&Xº™€ÀQ/Ÿ8µ @KÁƒ 0@B ÀœxɅ>€”ÀUYUe1€ÀP†“é#Ö@LX±bŋ@B ÀœáoLVªÀTñúå]iœ€ÀOÈ ŠOb~@Lïß¿~ýû@B ÀŒ²ß÷*@ÀT‹_£gH¬€ÀN’ôÆéø@M‡8pá@B ÀŒRÀôzyÕÀT%ÞB[ï€ÀMiؑehî@N»,)@OL™2dɒ@B À»=HBžµQÀSìš+œ€ÀJ=HóÄ1@OãǏâàJþŠÀQ +èÜò9—€ÀEVa +¿Š@Q·nÝ»ví@B Àžïñ¡ŽÀPŸt‹`?€ÀDJjdT]ß@R 0`@B Àž ­>ވrÀPs>†zæ*€ÀC”™mQ@RN:téÓ@B ÀžS¢°KlÀP*G°’Ý>€ÀBæ„|MiJ@Rš4hÑ£F@B Àžì†éüwÀOÆCe?éa€ÀB?Ә»³â@Rå˗.\¹@B À·œ‚XtŠÀO;v­4ý€ÀA 1Ôb‹ä@S1bŋ,@B À·t\ S׆ÀN³ùôsÀ€ÀANÝïhš@S|ùóçϟ@B À·,tçåÙÀN,jÿuˆÿ€À@t؊Š7l@Sȑ"D‰@B À¶åÏôJNëÀM£K2O€À?Ðäadæ@T(P¡B…@B À¶ p;F1ÀM$£ŸT€À>ÅêÓÞ@T_¿~ýû÷@B À¶\W–žÀŸÀLŽpQŠQë€À=Àë€Üƒ¥@T«V­Zµj@B À¶†ÔŸVòÀL™E̲=€À<ÈzÞíÎ@TöíÛ·nÝ@B Àµ×ýÖ ¬dÀKxü¿hˆÈ€À;ٟéÛf³@UB… +(P@B Àµ—»¯Þ pÀJîìMÄl׀À:óМ&ÀÏ@UŽ8páÃ@B ÀµXŸ¿ t;ÀJe®ï&¥e€À:‹…Ù|@UÙ³f͛6@B ÀµÈØÇÀI݂1ýxǀÀ9AW>%gå@V%J•*T©@B ÀŽÞ‹2}rÀIV›8[vL€À8sÁݔu@VpáÇ@B ÀŽ£NAáˆëÀHÑ' ³…â€À7­`€té¿@VŒxñãǏ@B ÀŽiJÜgSÀHMNV‘ëπÀ6íÎÑ0Ü@W @@B ÀŽ0|儚ÀGË0MÈ[ԀÀ64®šBÙ@WS§N:t@B À³øà$ˆjOÀGJé*C ™€À5§^wKe@WŸ>|ùóç@B À³Âp%'ƒ/ÀF̏ւçw€À4Ôeú¡O@WêÕ«V­Z@B À³(BþíÀFP7 +„Ý¡€À4,œGŸö@X6lÙ³fÍ@B À³Y®øÀEÕíį˜^€À3ŠÌQh@X‚ @@B À³%ý|—}—ÀE]¿¶6¡¯€À2ìNjšô@X͛6lÙ³@B À²ô©"ΓÀDçµ€Œ¬,…€À0ÆV"n›™@Ydɓ&L™@B À²†v<Ÿ€ÀD³ ±í}€À0=ëŠI.È@Y°`Áƒ @B À²V*}LjÀDPL=šæË€À/vÜ$ßÅ@Yû÷ïß¿~@B À²&|HÀΗÀCïíŽ͓€À.z§Ü:Â,@ZG†\Ÿîc¢€À#T ‹zs@^µjÕ«V­@B À¯09··íÀ>VTÃE€À#.¹^K@_ @B À®éëå÷ÈjÀ=ƒZCœå|€À"„Õud@_L™2dɒ@B À®€ÈÚVÉsÀ=SE.рÀ"à¯A@_˜0`Áƒ@B À®`ÉŒ +йÀ<Œ,£º\~€À!‹¶|“Ÿ@_ãǏè|þÀ9…^§I€À…€'Sº@a @@B À¬, +ސ#À8µ÷§¬e£€ÀÑI/'0%@aF 0`Á@B À«ò9¯TÀ8T˜ÝšȀÀ"ÞSú/Ñ@akׯ^œ{@B À«¹Á“BÀ7õY¬ïVd€ÀzÅ*}(@a‘£F4@B À«€ìûrºÀ7˜+w‹à›€À֊ßLô@a·nÝ»ví@B À«Iœï‰]7À7=Xè̀À85„ªlQ@aÝ:téÓ§@B À«!íŠÜÆÀ6ãÉ®=±€ÀžÓþÿÎ@b 0`@B ÀªÝwf®«À6Œzý¢ÿ€À +5݉w@b(Ñ£F@B Àªš˜è:4HÀ67ÌÁ“€Àz,Øø˜Ú@bN:téÓ@B Àªt‚!ŠtÀ5ãa:;Z}€ÀP3@bthÑ£F@B ÀªA.ÜڐØÀ5‘}fqfï€Àg+1zš@bš4hÑ£F@B Àª›5ÌÀ5AO·Ãåü€ÀãßÛññ@bÀ@B À©Ü‘Aî÷À4ò̵21€Àd„ ¶/Å@CJ•*T©TÀÇ?),ÃS›€€Àpu㢌 ?òå˗.\¹@CJ•*T©TÀÇ<œöÄ +åÀ šoøÉÀÀppý²x@å˗.\¹@CJ•*T©TÀÇ5Íp·ÿäÀ/õ>.ê€ÀpbömҘ@ X±bŋ@CJ•*T©TÀÇ*•CBÅ)À6‘Î×׳æ€ÀpKf„\£:@å˗.\¹@CJ•*T©TÀÇ$Ϫf¹À=¢ þe4€Àp*À‡èÌ@Ÿ>|ùóç@CJ•*T©TÀDZĊTÀB¡ q¶€Àp”-ŠWJ@X±bŋ@CJ•*T©TÀÆð}@tŽÿÀE*+FŒº¿€Ào¡ˆš…Å@ ‰$H‘"@CJ•*T©TÀÆÕ±—ÎyVÀH,Éö ßÀÀo1ÕHa>Ø@"å˗.\¹@CJ•*T©TÀÆ·{lUųÀJúŸMœÞ÷€Ànµ! Ã+ž@%B… +(P@CJ•*T©TÀƕýóvüÀM²¬ªšàä€Àn,ŠîiÍ@'Ÿ>|ùóç@CJ•*T©TÀÆqo?iÎÀPOÊ}€Àm™h}1М@)û÷ïß¿~@CJ•*T©TÀÆJ¿ûrRÀQD°ÜÚ©€Àlý.µÖ@,X±bŋ@CJ•*T©TÀÆ鲿JpÀR`§ܜ΀ÀlYÑdœ€@.µjÕ«V­@CJ•*T©TÀÅóM %íŒÀS_ûô—V€Àk®,Pš#@0‰$H‘"@CJ•*T©TÀÅÄmtˆ¡ÀTMÀCzL€Àjþ€ '|ùóç@CJ•*T©TÀąbäšAãÀX@’õÞW¹€Àfªê•¿i@8͛6lÙ³@CJ•*T©TÀÄK›Ÿ‹\ÀXš>ÓRþb€Àeò&!T=@9û÷ïß¿~@CJ•*T©TÀÄòÅm|èÀXþÄÓm»p€Àe;Iââm@;*T©R¥J@CJ•*T©TÀÃՒáuéÀYC&˜Ÿž€Àd†Ñô0!à@µjÕ«V­@CJ•*T©TÀà äÔ³|(ÀY¢y\"™€Àb|H®{.M@?ãǏ €ÀZq>Ïž-Ð@EB… +(P@CJ•*T©TÀÀÎۏN®ÀX(ð—Œó‘€ÀYr L¥ñ@EÙ³f͛6@CJ•*T©TÀÀ–E1ÅÀWã/àT&€ÀX|ö·–áß@FpáÇ@CJ•*T©TÀÀ^¥sNÀWšÃÞÖ\õ€ÀWÜg€ +@G @@CJ•*T©TÀÀ&š¢¡ˆÀWN=ÁŽŸT€ÀV«ã[šèb@GŸ>|ùóç@CJ•*T©TÀ¿ßר¡« +ÀVüÈè\7€€ÀUÐ0£z™š@H6lÙ³fÍ@CJ•*T©TÀ¿tüM‡ÀV§ng?c5€ÀTüÞÌH@H͛6lÙ³@CJ•*T©TÀ¿ ÎÏRŠyÀVO]/T܀ÀT1¿YsUZ@Idɓ&L™@CJ•*T©TÀŸ¡>»Ë ÀUôo 1ހÀSn¢º5ï@Iû÷ïß¿~@CJ•*T©TÀŸ:^ßäEÀU˜0Š[ò!€ÀR³R tÁ.@J“&L™2d@CJ•*T©TÀœÕ5YåjÀU:߇ĀÀQÿ”(ŸÊÉ@K*T©R¥J@CJ•*T©TÀœqƵoÍ÷ÀTÜò3€/€ÀQS-ãÐ. @KÁƒ 0@CJ•*T©TÀœ@8«ûÀT~Îÿóg€ÀP­âÔùé@LX±bŋ@CJ•*T©TÀŒ°^аžÀT ËL±Z€ÀPv…—@Lïß¿~ýû@CJ•*T©TÀŒQã͍‚ÀSÃ.–û€ÀNïTÓ™Ÿ@M‡8pá@CJ•*T©TÀ»õaÿEzÌÀSfT‘‹!€ÀM̆Šس@N€ÀB)µX›À@S1bŋ,@CJ•*T©TÀ·=ïē)šÀMëpÛø>€ÀAz)Æ5•Á@S|ùóçϟ@CJ•*T©TÀ¶÷؄ õ[ÀMl`hۀÀ@æpW·g@Sȑ"D‰@CJ•*T©TÀ¶²ðˆñyuÀLê؁M€À@XŠ`ʱ)@T(P¡B…@CJ•*T©TÀ¶o;ðÄÝÀLgõ@òހÀ? ï]žG@T_¿~ýû÷@CJ•*T©TÀ¶,œ–ª>ÀKä-»9Ÿ€À>›*1gmN@T«V­Zµj@CJ•*T©TÀµëw9}"ÀK_àÄ=€€À=ŸhfíÖá@TöíÛ·nÝ@CJ•*T©TÀµ«i›…ÀJÛq—FQ€À<­»0~@UB… +(P@CJ•*T©TÀµl”¥ŽÕŠÀJW7m'"é€À;ÃǯãH@UŽ8páÃ@CJ•*T©TÀµ.÷~3ŸÀIÓ~’Eý€À:âéŽä”@UÙ³f͛6@CJ•*T©TÀŽò£(üÀIP‰ ì€À: +kœ@V%J•*T©@CJ•*T©TÀŽ·^} ÀHΒ&h§O€À98ËëjŠ7@VpáÇ@CJ•*T©TÀŽ}]t›ÓÀHMÊWip€À8nž2©Ó@VŒxñãǏ@CJ•*T©TÀŽDŠ¥ýµ`ÀGÎ]›üŠ€À7«sz5õ@W @@CJ•*T©TÀŽ ãŽk×êÀGPo?¯žN€À6¯$Ï@WS§N:t@CJ•*T©TÀ³Öd MH…ÀFÔïˆká€À67ðCð?@WŸ>|ùóç@CJ•*T©TÀ³¡)o¯ÀFY‰n¿—ƒ€À5‡ +_'Ó@WêÕ«V­Z@CJ•*T©TÀ³lËŒíGSÀEàÁ”Xÿ€À4Û§jæ 7@X6lÙ³fÍ@CJ•*T©TÀ³9ªkxkÀEiÚJGè@€À45}…a@X‚ @@CJ•*T©TÀ³Ÿ²ÒGÀDôáý3F,€À3”JUsU@X͛6lÙ³@CJ•*T©TÀ²ÖŠõ#lÀDäė€À2÷Í6Êßµ@Y2dɓ&@CJ•*T©TÀ²{O•ÀDºö +kŽ€À1*jyYÜ@Ydɓ&L™@CJ•*T©TÀ²l“è ÜiÀDY¿7è߀À0 °µ©›I@Y°`Áƒ @CJ•*T©TÀ²<òڈžØÀCúQuC³Æ€À0jT“Œl@Yû÷ïß¿~@CJ•*T©TÀ²0äøVÀCXî2€À/:®v.°@ZGŠ•ÐÀCB‹Ï…X¶€À.Ftîÿõ%@Z“&L™2d@CJ•*T©TÀ±³0p"BsÀBéݱrÇ€À-[Ž6º@å@ZÞœzõë×@CJ•*T©TÀ±†é§§ŒÀB“B* +€€À,yþ-.nš@[*T©R¥J@CJ•*T©TÀ±[m`;ºÀB>¬ØI8€À+ ê'¶Ö@[uëׯ^œ@CJ•*T©TÀ±0µêŒ\‚ÀAìm,îÆ€À*ЫQ¯@[Áƒ 0@CJ•*T©TÀ±¿ "ëÀA›c³Š¿Ÿ€À*,›”ù@\ 4hÑ£@CJ•*T©TÀ°Ý…®š7óÀAL——hßˀÀ)E¯Î/8·@\X±bŋ@CJ•*T©TÀ°µ²ÁšÞÀ@ÿ¡*·Õ€À(‹q$ðp@\€H‘"Dˆ@CJ•*T©TÀ°5dÜ:ŠÀ@Žt«UÉ€À'Øý¿Ìj@\ïß¿~ýû@CJ•*T©TÀ°f˜õ\oÀ@k†¢…-€À'+E& –@];víÛ·n@CJ•*T©TÀ°?£>$ï9À@#K]‡à€À&„Á6þÔ\@]‡8pá@CJ•*T©TÀ°×^!“uÀ?ºp {K\€À%ä@c3mI@]Ò¥J•*T@CJ•*T©TÀ¯é^9}náÀ?1ƒ'>ՀÀ%I€Fæ/õ@^«ºvFƒâ€À$ŽAº†¡¬@^iÓ§N:@CJ•*T©TÀ¯Xu AóÀ>)*M豀À$$H§—3Ä@^µjÕ«V­@CJ•*T©TÀ¯ÍæQëiÀ=©B²Ð&Š€À#™[ßߚ@_ @CJ•*T©TÀ®ÌQ=ÛøÀ=,k²+€À#Dö­™@_L™2dɒ@CJ•*T©TÀ®‡÷ÏjZÀ<²fšÊh€À"‘Ð7ýƒ@_˜0`Áƒ@CJ•*T©TÀ®D»‚ëÀ<;"Wþ]w€À"Ëõšu£@_ãǏŸ»GñÀ7aÖôl&€À¯þƒµE‘@a·nÝ»ví@CJ•*T©TÀ«5låÊø«À7^Bé Ï€À „hŠR@aÝ:téÓ§@CJ•*T©TÀªÿlFµRTÀ6°ÊX=€Àp2­]F@b 0`@CJ•*T©TÀªÊ8Y Z%À6[ ŠS숀À×}%Ñÿb@b(Ñ£F@CJ•*T©TÀª•ÌÐÁfÀ6Ü~ ª€ÀCšªi.@bN:téÓ@CJ•*T©TÀªb%~MQéÀ5Žëã9<€ÀŽ",W¿@bthÑ£F@CJ•*T©TÀª/>MÞoŒÀ5dn̅øP€À)£°@bš4hÑ£F@CJ•*T©TÀ©ýFŒ'ËÀ5šœ;¯â€À¢NX@bÀ@CJ•*T©TÀ©Ë ‰7àÀ4Èd®Îüð€À) ¯}–@D(P¡BˆÀÆvyZà& €€Ànӝ}Ì3?òå˗.\¹@D(P¡BˆÀÆtI¢sŒÃÀ*MõZ:=€ÀnÊƧ @å˗.\¹@D(P¡BˆÀÆnGé¶ìÀ, —Ս¹…€Àn°Pà{fû@ X±bŋ@D(P¡BˆÀÆcâ„[ÂcÀ4\1B·¯·€Àn†D/žRA@å˗.\¹@D(P¡BˆÀÆUôdDëÀ:Œm£6ÞȀÀnL«kç„@Ÿ>|ùóç@D(P¡BˆÀÆDg.néÀ@Xo®,€ €ÀnDæä«ý@X±bŋ@D(P¡BˆÀÆ/sE×¢ÀC&"PWˆ€Àm®›åµÄÕ@ ‰$H‘"@D(P¡BˆÀÆ:YB°ÀEÝeä8R €ÀmL¶¿¢¯@"å˗.\¹@D(P¡BˆÀÅûãk (oÀHm±b” €Àlßx?±Tš@%B… +(P@D(P¡BˆÀÅ݋—åÀJíU‘66í€ÀlgÚžÁ>Ó@'Ÿ>|ùóç@D(P¡BˆÀÅŒ_ŽPO§ÀM4ž ËC€Àkçð“€r@)û÷ïß¿~@D(P¡BˆÀؒk«Ä7ÀOhYå1Ë%€Àk^!"jé¹@,X±bŋ@D(P¡BˆÀÅr;ÿ&?§ÀPœ—šý€ÀjÎÄ(Sa@.µjÕ«V­@D(P¡BˆÀÅIú?©þÀQ­•€jœ€Àj7ŸtH»>@0‰$H‘"@D(P¡BˆÀÅÁtI—ÀRŽÔ%»]ŀÀiœOºTó +@1·nÝ»ví@D(P¡BˆÀÄñî»Oó!ÀSa{»¹)`€Àhü¢ Š9G@2å˗.\¹@D(P¡BˆÀÄÃ@Ë<ÀT ¥óç‚M€ÀhY–nóÁñ@4(P¡B…@D(P¡BˆÀĒè{Ž=€ÀTÊd}€IF€ÀgŽ#šÀ@5B… +(P@D(P¡BˆÀÄaûžÌÀUc×Yy€Àg ·x‘šg@6páÇ@D(P¡BˆÀÄ-åët.TÀUî«G Z€Àfd”VÑžŠ@7Ÿ>|ùóç@D(P¡BˆÀÃù‚w¯»†ÀVi­žøÒW€ÀeŒZf6@8͛6lÙ³@D(P¡BˆÀÃÄxq0ÀVԖt4V׀Àe¬¢œÍ@9û÷ïß¿~@D(P¡BˆÀͲ²…¿ÀW/°Šš§€ÀdnҀÉm@;*T©R¥J@D(P¡BˆÀÃV’*ÀWyÈÿg €ÀcÉ›·^Ü@µjÕ«V­@D(P¡BˆÀ®:ø¡”ÀWñ~MR‰‹€ÀaçݙÜ–@?ãǏ@EB… +(P@D(P¡BˆÀÀT0ð§èÀVäÀÔ,€ÀYxœWàô@EÙ³f͛6@D(P¡BˆÀÀI† AóÀV©„0Ë|x€ÀXBs‰di@FpáÇ@D(P¡BˆÀÀE³"ŸÔÀVl-(߂€ÀW9ìDAeÿ@G @@D(P¡BˆÀ¿¿5·L*ÀV*oeh€€ÀV`Y£Á»%@GŸ>|ùóç@D(P¡BˆÀ¿W"šÛå—ÀU㳔ÿÄp€ÀUŽw¢Â3²@H6lÙ³fÍ@D(P¡BˆÀŸðgď>)ÀU˜ä*hÛ€ÀTÄ-á·€@H͛6lÙ³@D(P¡BˆÀŸ‹>ÓÍØÀUJÚ5Õ.<€ÀT\Id1@Idɓ&L™@D(P¡BˆÀŸ';–ƒPÀTúO¡Ïû¯€ÀSEÛ»®¶Œ@Iû÷ïß¿~@D(P¡BˆÀœÄ╈ÀT§ã¡QV‘€ÀR‘¥ú$k@J“&L™2d@D(P¡BˆÀœdeö{ÍÀTTtrN€ÀQä¿a>@K*T©R¥J@D(P¡BˆÀœλ0ð‰ÀSÿt›ó€Æ€ÀQ=‚€ÂÚá@KÁƒ 0@D(P¡BˆÀŒ§aÅBºÀSªIšØÙ€ÀPyWÙՑ@LX±bŋ@D(P¡BˆÀŒJþî3ÒÀSTòJ2C€ÀPϵ¥Ö@Lïß¿~ýû@D(P¡BˆÀ»ðs‚iË|ÀRÿ¶ÝMÈ€ÀNà÷p;@M‡8pá@D(P¡BˆÀ»—yöçzÀRªñžĀÀMňÎ!³L@NŽ ãb@S1bŋ,@D(P¡BˆÀ·È‰¯ ÀM"¶ˆãÀŒ€ÀA¥­n>@S|ùóçϟ@D(P¡BˆÀ¶ÂI  ÀL«kDú«ƒ€ÀA{˜ø@Sȑ"D‰@D(P¡BˆÀ¶NPý¢ÀL1§›0X€À@„Ä!ñ^@T(P¡B…@D(P¡BˆÀ¶=r!ýÀK¶Zð!W€À?ùó ûÐ@T_¿~ýû÷@D(P¡BˆÀµü5X«¡HÀK9üH훀À>ô²1ïÁ@T«V­Zµj@D(P¡BˆÀµŒ{€GÛzÀJ»#e]OހÀ=ù6ùHy@TöíÛ·nÝ@D(P¡BˆÀµ}ëï#Ò:ÀJ<DŽr'€À=ýÊ~¿@UB… +(P@D(P¡BˆÀµ@†êŠp,ÀIŸMÀ^2h€À<‰ŽšåC@UŽ8páÃ@D(P¡BˆÀµL{s}ÄÀI@D:ô‹€À;|ùóç@D(P¡BˆÀ³~ÓÊ È ÀEå ŽÒO€À5Û®‘Ôn@WêÕ«V­Z@D(P¡BˆÀ³KŠPÑ/ŸÀEoҋJp€À5/YúJá@X6lÙ³fÍ@D(P¡BˆÀ³‹»$,ÀDüNïo—€À4ˆ8)Œ?@X‚ @@D(P¡BˆÀ²èðì†øÀDŠ€À0üHôom@Y°`Áƒ @D(P¡BˆÀ²#KWª*ËÀC£„·µåˆ€À0w<Ôs@Yû÷ïß¿~@D(P¡BˆÀ±õQ‹ôùÀCIêü +w€À/îKŸõ›@ZGËUÏZžy€À%Ü5ÿ”@^I&v +Tù€À%D7PŸ~@^iÓ§N:@D(P¡BˆÀ¯8L° +|êÀ=Éß ÜøÂ€À$±µ‚Dü@^µjÕ«V­@D(P¡BˆÀ®ò‚W”4±À=Mnš§|ã€À$$FYÙkæ@_ @D(P¡BˆÀ®­Ú¬› +À<ÓÁGB0€À#›Ž Pî¡@_L™2dɒ@D(P¡BˆÀ®jO<Ž{îÀ<\řfK€À#ËX™Ë@_˜0`Áƒ@D(P¡BˆÀ®'Ù¿AŽ”À;èjü<€À"˜[]?`º@_ãǏuÀ5ÕóÐWÊȀÀ…äiL@bN:téÓ@D(P¡BˆÀªO/€yºÀ5…=ÿ_z%€Àt æ°É@bthÑ£F@D(P¡BˆÀª·>^ÄÖÀ56.`1ÈɀÀån°p¶@bš4hÑ£F@D(P¡BˆÀ©ê÷Ö;JÍÀ4èºQ©+µ€À[ s(h@bÀ@D(P¡BˆÀ©¹íˆ<`À4œ×~9h;€ÀÔœyâ€@DÝ»víÛžÀź{ï!d€€ÀlãEÊñj?òå˗.\¹@DÝ»víÛžÀÅžƒgxÂîÀ:5,#Ÿ€ÀlÚÖ©ˆõ@å˗.\¹@DÝ»víÛžÀŲ×V[CÀ)S±^9Îo€ÀlÃJjñ_@ X±bŋ@DÝ»víÛžÀÅ©²2,&ÏÀ2fMŠÏâs€Àlaõ³Ó@å˗.\¹@DÝ»víÛžÀŝŠòÀ8,úïýûö€Àlj^HÒoá@Ÿ>|ùóç@DÝ»víÛžÀō:—à>À=”ig¢ââ€Àl*3ÆmGí@X±bŋ@DÝ»víÛžÀÅzAËÀAR«ŠÒh`€ÀkÞjÁƒÙ¥@ ‰$H‘"@DÝ»víÛžÀÅdL˜‡>ÀCÕ.—¢l€Àk‡ã©>η@"å˗.\¹@DÝ»víÛžÀÅK|mìšhÀF/i|‰Þ€Àk'_©¹r1@%B… +(P@DÝ»víÛžÀÅ/è=_5UÀH}“ƒq€Àjœ·4<+@'Ÿ>|ùóç@DÝ»víÛžÀÅ·o°4ÒÀJ𠛣Úk€ÀjKî;*P@)û÷ïß¿~@DÝ»víÛžÀÄñÅ£œÀLŠÛ®ž–æ€ÀiÒðr„"@,X±bŋ@DÝ»víÛžÀÄ΄h›ÀN—›¬¬4€ÀiSRP/ˆ@.µjÕ«V­@DÝ»víÛžÀÄšä{Ÿ.–ÀP,×hf€ÀhÍßé׋@0‰$H‘"@DÝ»víÛžÀā±ãa–êÀQ«]eƃ€ÀhCr%Šà@1·nÝ»ví@DÝ»víÛžÀÄX•ÜªŠ«ÀQʘf¡€ÀgŽÇYF@@2å˗.\¹@DÝ»víÛžÀÄ-ŽB”ÀR'G8Nm€Àg" Ø@ R@4(P¡B…@DÝ»víÛžÀÄ8ýCÏ_ÀS$Óh®ûî€Àfº§mäÂ@5B… +(P@DÝ»víÛžÀÃÓM^1ÀSº]¡­Œ€ÀeöÏ)G"@6páÇ@DÝ»víÛžÀÀìà»1ÀTCW^€†ž€Àe^Ÿ“Ôø@7Ÿ>|ùóç@DÝ»víÛžÀÃsX±<2ÀTŸ?ə2·€ÀdÅ׋Ét(@8͛6lÙ³@DÝ»víÛžÀÃB§“ÃIÀU*‹²Ð§€Àd- +â°ëA@9û÷ïß¿~@DÝ»víÛžÀéamatÀUˆL ¶°?€Àc”žjϧ@;*T©R¥J@DÝ»víÛžÀÂÜlŒÙô_ÀUÖLàŠ X€ÀbýR"‰U‚@µjÕ«V­@DÝ»víÛžÀÂ?u)úNÄÀV`£©ïuš€ÀaA\=Ëš@?ãǏ|ùóç@DÝ»víÛžÀŸÐ¥qŠÀT×gb¢c:€ÀUOhK=²@H6lÙ³fÍ@DÝ»víÛžÀŸnÇ ôøÀT–/Lê€ÀT]e\Õ@H͛6lÙ³@DÝ»víÛžÀŸ%l<ÙpÀTQ“(5©j€ÀS£K~™5X@Idɓ&L™@DÝ»víÛžÀœ®Î‹äšòÀT +FSè€ÀRïän• Ü@Iû÷ïß¿~@DÝ»víÛžÀœPͰ)¢aÀSÀá7a"(€ÀRC Ŋ>T@J“&L™2d@DÝ»víÛžÀŒô+|- \ÀSu趞@b€ÀQœ¡>ˆx +@K*T©R¥J@DÝ»víÛžÀŒ˜îPqõÀS)Íð)}‰€ÀPüu­{’Ü@KÁƒ 0@DÝ»víÛžÀŒ?œgÀ‡ÀRÜòIŽã¢€ÀPb_ËØ\9@LX±bŋ@DÝ»víÛžÀ»æ³$ÜîoÀR©Ne5}€ÀOœeÃüAq@Lïß¿~ýû@DÝ»víÛžÀ»¹@î¿fÀRB:ŒÒZ€ÀN‚Š8^@M‡8pá@DÝ»víÛžÀ»:,ÿ,bÆÀQôÿMÉšQ€ÀMm¹úÀœò@Nš°µ@Rš4hÑ£F@DÝ»víÛžÀ·W 5>2`ÀM:¿F*è€ÀBË›ëR@Rå˗.\¹@DÝ»víÛžÀ·„©ºBÀLÊS«7S(€ÀB.Y…Ä~M@S1bŋ,@DÝ»víÛžÀ¶Ïú§‹rÀL\±Ûyn€ÀA—³³ÆÞŠ@S|ùóçϟ@DÝ»víÛžÀ¶ŒŠ.î··ÀKì–z™a€ÀAé9O„%@Sȑ"D‰@DÝ»víÛžÀ¶KcÜSÀKz+€ôc(€À@{­Ô¹–Ò@T(P¡B…@DÝ»víÛžÀ¶ +»©ä1ñÀK|ë 8€À?ënÁV³@T_¿~ýû÷@DÝ»víÛžÀµËr²xÆÀJ`JF€À>é~ބ³†@T«V­Zµj@DÝ»víÛžÀµBõËg5ÀJt9».€À=ñF1"@TöíÛ·nÝ@DÝ»víÛžÀµP.ÙôT£ÀIŸjÚö€À=%ŒÀÙ@UB… +(P@DÝ»víÛžÀµ7ÏH >ÀI&( s€À<—æhí£@UŽ8páÃ@DÝ»víÛžÀŽÙ^n׳ÀH­4ÝvI¹€À;;žØ¢ñ¢@UÙ³f͛6@DÝ»víÛžÀޟ¢“Ðo‰ÀH4vJç,€À:d†¶mÇ @V%J•*T©@DÝ»víÛžÀŽgrž^GÀGŒ-Âæ[ŀÀ9”žD6Ù@VpáÇ@DÝ»víÛžÀŽ/­¥Æ>ÀGD•©œyœ€À8Ë¡2ýrj@VŒxñãǏ@DÝ»víÛžÀ³ùgÛ(5ÀFÍ࢑€À8 7]Ô1W@W @@DÝ»víÛžÀ³ÃÂUlÀŽÀFX8œÕl€À7M ìŒÆÔ@WS§N:t@DÝ»víÛžÀ³ƒÊ¯DkÀEãÄêõ|Á€À6–ÒÑ«cœ@WŸ>|ùóç@DÝ»víÛžÀ³\VÉz3`ÀEp¥K_3€À5æ>\£,ÿ@WêÕ«V­Z@DÝ»víÛžÀ³*8 ŒyÀDþõ?ߎ[€À5;÷àrÝ@X6lÙ³fÍ@DÝ»víÛžÀ²ù$5uÀDŽÌªˆ€À4”ïꋶð@X‚ @@DÝ»víÛžÀ²É.ešÀD = ^³€À3óŽà +”@X͛6lÙ³@DÝ»víÛžÀ²š |M1ÀC³XWvA€À3WåíB%@Y2dɓ&@DÝ»víÛžÀ²eç1ýâÀCþ_.v³;€À1Ýh!pÜ@Ydɓ&L™@DÝ»víÛžÀ²71ÕÀC€CtÖV|€À1Q¬ƒ»öµ@Y°`Áƒ @DÝ»víÛžÀ² ì{øˆÀCLywf߀À0˔5sA @Yû÷ïß¿~@DÝ»víÛžÀ±Ûê­¡ÖûÀBõÒú%ـÀ0JFñs@ZGŒ±ê€À'¬d…ɍq@]‡8pá@DÝ»víÛžÀ¯ìéÜ]ÛSÀ>äó=•š€À'ešët@]Ò¥J•*T@DÝ»víÛžÀ¯€ˆ³Ú.\À>cŒhiz€À&gnçyä@^ÈÞl‰€Àõ<‰êN¶@`útéÓ§N@DÝ»víÛžÀ¬ Mm‰GÀ8[œu\g!€À.,4ñw@a @@DÝ»víÛžÀ«ç2)ŽòºÀ7þ;®°€Àma6¹“¯@aF 0`Á@DÝ»víÛžÀ«®ñx(!9À7¢¬Ÿ"ˀÀ²žåŒL@akׯ^œ{@DÝ»víÛžÀ«w†Í5ÒVÀ7IC‡“ €Àý©²­a©@a‘£F4@DÝ»víÛžÀ«@íº‘ãÀ6ñ6)¯Ë€ÀNJð&h¥@a·nÝ»ví@DÝ»víÛžÀ« !ì'÷pÀ6›6¬uîê€À€L̎ˆ@aÝ:téÓ§@DÝ»víÛžÀªÖ,>-À6FúT·5ʀÀÿ|'Ÿ¿’@b 0`@DÝ»víÛžÀª¡á]䫅À5ôu÷Y…W€À_šeòi@b(Ñ£F@DÝ»víÛžÀªnd~±RÀ5£ž³ØžÞ€ÀġރV@bN:téÓ@DÝ»víÛžÀª;€¥”ú>À5TiòÇÛQ€À.<2ÐÅ @bthÑ£F@DÝ»víÛžÀª ž0—UÀ5ÍdK€ÀœL—H3@bš4hÑ£F@DÝ»víÛžÀ©ØLÜ.qÀ4ºŸþ‰P<€À©º'MÐ@bÀ@DÝ»víÛžÀ©§­’œpAÀ4p4üŒs€À…,,<šC@E§N:tìÀÅ +96¹Šy€€Àk?äй?òå˗.\¹@E§N:tìÀÅsËr“.À~ß ‡€Àkq`šØM@å˗.\¹@E§N:tìÀÅUˆÏ¥À&Öù>Áì#€Àjû՜3Í@ X±bŋ@E§N:tìÀÄûˆcÀ0¥ÐËDäY€ÀjÙÇ_I)X@å˗.\¹@E§N:tìÀÄï«q‡40À5å9u³5Œ€Àj«õz!q­@Ÿ>|ùóç@E§N:tìÀÄáIn +!ÀÀ:Ðp» †A€Àjr€Ñ[«r@X±bŋ@E§N:tìÀÄÐî!°À?sž+÷¿€Àj/ ˆ_8«@ ‰$H‘"@E§N:tìÀÄŒ!<ëBžÀBkžwø€Àiáì:2Ł@"å˗.\¹@E§N:tìÀÄ¥Žã0ŽMÀD1óèÔá €Ài‹ëòû˜z@%B… +(P@E§N:tìÀČnÒ²ÛÀFTM$¹€Ài-ɳ=X@'Ÿ>|ùóç@E§N:tìÀÄpâs&ÜòÀHJÅVú ‰€ÀhÈi$…@)û÷ïß¿~@E§N:tìÀÄSFo•ÀJ4.rBV€Àh\Œ(©îp@,X±bŋ@E§N:tìÀÄ3™ïÀL8ÖÅ[V€Àgê“3R`l@.µjÕ«V­@E§N:tìÀÄñœo±ÔÀMªÌ9ùæ#€Àgs q®í@0‰$H‘"@E§N:tìÀÃìù#èÂÀO<; +W³€Àföæc@1·nÝ»ví@E§N:tìÀÃÇ1re!“ÀP\B.€Xº€Àfv„Õ>$¶@2å˗.\¹@E§N:tìÀߺG rÀQ +€Àeò£SGåc@4(P¡B…@E§N:tìÀÃv» µù;ÀQšbS·€Àekè°IÕ +@5B… +(P@E§N:tìÀÃLXù3g3ÀR8Þ@„²7€ÀdâûkSÁ@6páÇ@E§N:tìÀà ¬ä $¡ÀRŸõñ”P€ÀdX„"†u@7Ÿ>|ùóç@E§N:tìÀÂóҔŠ&\ÀS8Q\î6€ÀcÍ¥Ï3í@8͛6lÙ³@E§N:tìÀÂÅè˜ßÐÀS€éÖÿÅó€ÀcAK))f@9û÷ïß¿~@E§N:tìÀ—wñ)ÀTæãÑ× €Àbµ„ì––.@;*T©R¥J@E§N:tìÀÂgd}òå³ÀTT\$³)€Àb*5ŠûȆ@µjÕ«V­@E§N:tìÀÁÔŷÀTìÿæ&+G€À`¯Á²ª@?ãǏøÞX@D«V­Zµj@E§N:tìÀÀtÆ-èÀT±Šê2‡ã€ÀX€M…hp @EB… +(P@E§N:tìÀ¿ËyiÈ2ÀTƒ*Q+)€ÀW©#%»ðœ@EÙ³f͛6@E§N:tìÀ¿jŒ‡ÝSÀTeôšL>ð€ÀVØ`OŠ­—@FpáÇ@E§N:tìÀ¿ +Àîú~ÀT;Twì«©€ÀV ü‰bŸw@G @@E§N:tìÀŸ«˜~ àxÀT O=ƀÀUIìÌ%'Õ@GŸ>|ùóç@E§N:tìÀŸMZ̶³ÄÀSØzôËŒ€ÀTŒ/,+m@H6lÙ³fÍ@E§N:tìÀœð#wn¥ÀSŸ£€pÇú€ÀSÔ»íBɳ@H͛6lÙ³@E§N:tìÀœ“ôÜ.Ú ÀSc§ò„Œ(€ÀS#ƒ”:ìÝ@Idɓ&L™@E§N:tìÀœ8íëHvýÀS$Õ:±9²€ÀRxoê­7'@Iû÷ïß¿~@E§N:tìÀŒßV‘çÆÀRãŸý€ÀQÓe AK+@J“&L™2d@E§N:tìÀŒ†t›·&ÀR ä˜~LR€ÀQ4EY|aÓ@K*T©R¥J@E§N:tìÀŒ/ǶÀÀR\Ž›ãހÀPšìž;9²@KÁƒ 0@E§N:tìÀ»ØöÉ|ÀRŒ˜Ž'€ÀP6 vk8@LX±bŋ@E§N:tìÀ»„!f ŒSÀQÑŸ·²ềÀNñùÃ[Ë@Lïß¿~ýû@E§N:tìÀ»0—ƒ«îúÀQ‹’YÂݟ€ÀMà/x›t@M‡8pá@E§N:tìÀºÞY€SцÀQE_‰°€ÀLØœ‹ªÓ@NÐ|Nãù€ÀCÏ(ÄOQ@RN:téÓ@E§N:tìÀ·_Ì"¥àòÀLÒfÙÊ}‡€ÀC)ÖÎÍV@Rš4hÑ£F@E§N:tìÀ·4пaKÀLh‹8g€ÀBŠÔ±¥íñ@Rå˗.\¹@E§N:tìÀ¶Ù–9'˅ÀKÿÍ17I=€ÀAñèJ±]@S1bŋ,@E§N:tìÀ¶—뀺ϊÀK™l.’z©€ÀA^՗Ò_÷@S|ùóçϟ@E§N:tìÀ¶W3âjþ ÀK1XfƒJ€À@Ñ`@_Ë@Sȑ"D‰@E§N:tìÀ¶u÷»DÈÀJÆx)³€À@I@¿­Qÿ@T(P¡B…@E§N:tìÀµØž×„`0ÀJXsـ.€À?Œcúp@T_¿~ýû÷@E§N:tìÀµ›övæÀIçþ€q7€À>ázÖߐ@T«V­Zµj@E§N:tìÀµ^UÏÀԕÀIvf€®›€À=œ€%H'@TöíÛ·nÝ@E§N:tìÀµ"·P6c?ÀI²gª‹€À<±É&¯Ê@UB… +(P@E§N:tìÀŽè(¯ÒˆÀHGB(€À;ÏLúïÌ@UŽ8páÃ@E§N:tìÀŽ®«2x°ãÀH~ðõt€À:ô¢+è(ƒ@UÙ³f͛6@E§N:tìÀŽv?TÀGoÀGšš€?a}€À:!dÿ^—@V%J•*T©@E§N:tìÀŽ>ä㗠ÆÀG5 +~ù‡€À9U7'Ú·+@VpáÇ@E§N:tìÀŽ›|ЇÀFÁàjƒQ{€À8¿xö@VŒxñãǏ@E§N:tìÀ³Ó`‰“Ï&ÀFOa<0s€À7Щœ× @W @@E§N:tìÀ³Ÿ3€¿²ZÀEÝ»7OZ€À7¥Ò2Ð4@WS§N:t@E§N:tìÀ³lÄþ·;ÀEmåç@€À6dhНT[@WŸ>|ùóç@E§N:tìÀ³9øË/*³ÀDý—L°`r€À5¶ª¹Ê#Ÿ@WêÕ«V­Z@E§N:tìÀ³å»eå²ÀDZi%€À5(€çuR@X6lÙ³fÍ@E§N:tìÀ²ØÕ{û±ÀD"z,bݚ€À4j¢åô@X‚ @@E§N:tìÀ²©Ä»n EÀC· ¡ˆ­€À3ËÚÞ¿;@X͛6lÙ³@E§N:tìÀ²{¯ù<³±ÀCM"(Îh€À31™¡*¡®@Y2dɓ&@E§N:tìÀ²I†MHÐÓÀCŸyޙ>8€À2+û¥Œž4@Ydɓ&L™@E§N:tìÀ²’]ØqÀCHõñKe€À1 +ä‚Ûl@Y°`Áƒ @E§N:tìÀ±îh° ×¥ÀBô8š1*€À1•Qqb€@Yû÷ïß¿~@E§N:tìÀ±ÂÛHÀB¡=Ñib€À0—ÿEk…1@ZGö¿Ü&€À*O¯º~žY@\€H‘"Dˆ@E§N:tìÀ°RÅNkÄ¢À@Æs±Š€À)••4””Ë@\ïß¿~ýû@E§N:tìÀ°-GQ þåÀ?{†ÒEå€À(âµYt@];víÛ·n@E§N:tìÀ°eî7ÅÀ>ø[ͅÙ€À(4­™»Ê"@]‡8pá@E§N:tìÀ¯È;žò•À>wü‹ €À'ZV³‡~@]Ò¥J•*T@E§N:tìÀ¯€×c3MÈÀ=úWé $±€À&ëÈU?$­@^Š(ýp€À#ŽØÂ†EÃ@_ãǏ~ÐúßÀ7dÏ×0€À~<¿g§ò@akׯ^œ{@E§N:tìÀ«`c³g¢£À7 Å/ãހÀÅÍFrê@a‘£F4@E§N:tìÀ«*VXª”À6·¢Ò™€À/Š0;@a·nÝ»ví@E§N:tìÀªõydÀ6bÛ·‡ˆ€Àe£<ÓZ@aÝ:téÓ§@E§N:tìÀªÀ‘Òx ˜À6SJKŽ€Àœ€n3d@b 0`@E§N:tìÀªŒÓ*º=ŽÀ5¿räTÆ®€Àgð‹‡±@b(Ñ£F@E§N:tìÀªYÑ€A¯­À5p0R1ûҀÀ|*øé[@bN:téÓ@E§N:tìÀª'‰yP\¡À5" šro€À✳’”@bthÑ£F@E§N:tìÀ©õöû]EüÀ4Ö]]Õ'€ÀM’'ÎGÚ@bš4hÑ£F@E§N:tìÀ©Å’~ëÄÀ4‹¹Qʉ€ÀŒâ'¯Mx@bÀ@E§N:tìÀ©”äŒØ’À4B’mm€À0e5Þ#@FpáÇÀÄdÌc‚ +Y€€ÀirQGi?òå˗.\¹@FpáÇÀÄc6?MnbÀö +/jìɀÀijÜRé0@å˗.\¹@FpáÇÀÄ^š{[µ™À$ WŠ2€ÀiW +žŸ @ X±bŋ@FpáÇÀÄW#ÌlÔ$À.$—ñÀÀi8¬@å˗.\¹@FpáÇÀÄLÎŒå3À3Ùc ÒN€ÀiÍ\^w@Ÿ>|ùóç@FpáÇÀÄ?Ã2inÀ8VJ—u€ÀhÛ6,%L·@X±bŋ@FpáÇÀÄ0!f°×`À<—Ó4•º€Àhžm§O#@ ‰$H‘"@FpáÇÀÄû)›ôÀ@jPߘž€ÀhY’z]@"å˗.\¹@FpáÇÀÄ j|ùóç@FpáÇÀÃÙLþùÀF9®çC‘œ€Àg\%•Kø@)û÷ïß¿~@FpáÇÀÜý÷|nåÀHŸ["€Àfû'Ώç¯@,X±bŋ@FpáÇÀàžnÏ<ÀÀIŽÇLé2D€Àf”„ž)r@.µjÕ«V­@FpáÇÀÁT%b7ÖÀK@ÙÄŒ·À€Àf(œÇϓ@0‰$H‘"@FpáÇÀÃ`Cnn2 ÀLºì>–€ÀežjøÚP£@1·nÝ»ví@FpáÇÀÃ=}ÅTçÀN#PÀ2€ÀeDbSöÒ@2å˗.\¹@FpáÇÀÃöT#šÀOos"Ù݀ÀdÌIEŒG@4(P¡B…@FpáÇÀÂóHu¬ÀPMü©îÕҀÀdQ¢Pš@5B… +(P@FpáÇÀÂÌá,ÀPÙ±LÎÊ€ÀcÔ²ÓoÔ@6páÇ@FpáÇÀ£ŽžÀQ\=Fùª*€ÀcVŽÍWO@7Ÿ>|ùóç@FpáÇÀÂz"rbæÀQÓ¶£\±Ž€ÀbÖGõ‰‘·@8͛6lÙ³@FpáÇÀÂO„«CŠÀR?1üš—°€ÀbUÐÌ@9û÷ïß¿~@FpáÇÀÂ#÷z"ÃÀRžiYrœf€ÀaÕC#d7@;*T©R¥J@FpáÇÀÁ÷˜™G”ÀRðBÔœ_­€ÀaTíRyH@µjÕ«V­@FpáÇÀÁnÜBåùïÀS“ã8³ á€À_° Ÿˆì@?ãǏ~ÀSÛ4A„î€À[é+Ãee8@BN:téÓ@FpáÇÀÀ…:+æFÀSÙÌÉöì€À[¬ÚþãV@Bå˗.\¹@FpáÇÀÀV`m âÀSÐ}ÊÂì'€ÀZ![ûŽ.@C|ùóçϟ@FpáÇÀÀ'¢iü8ÀSÂ.d1ð€ÀYEsÔÒ@D(P¡B…@FpáÇÀ¿òŒ]ÀS®Ç\IÐ^€ÀXo!dEW¥@D«V­Zµj@FpáÇÀ¿•P›öÀS–ÛñÜ«€ÀWž€ÖdØi@EB… +(P@FpáÇÀ¿9ÔhšŠÀSzð’&³D€ÀVÓ¡Ë“_@EÙ³f͛6@FpáÇÀŸÝEz«@ÀS[|_×;€ÀV‰Rñ#»@FpáÇ@FpáÇÀŸ‚">ò;ÆÀS8êY§ÝπÀUO5.öÉ¥@G @@FpáÇÀŸ'«®IÝôÀSïÔr=€ÀT•Ÿæ|5w@GŸ>|ùóç@FpáÇÀœÍùÚ±•éÀRåÈþùD€ÀSáÌá «@H6lÙ³fÍ@FpáÇÀœu#+žÀRµLŒ‰Á€ÀS3º ¶*@H͛6lÙ³@FpáÇÀœ:I?P.ÀR9•I€8€ÀR‹]bulŸ@Idɓ&L™@FpáÇÀŒÆN¢ÀŒ¬ÀRJ2Gý‡û€ÀQèŠfD»@Iû÷ïß¿~@FpáÇÀŒplÖÞËÀRÄaʀÀQKreƒx@J“&L™2d@FpáÇÀŒŸˆµ[ÀQÕiYýW€ÀP³ÎŎ€1@K*T©R¥J@FpáÇÀ»Çíw ûÀQ˜ŒØ>I€ÀP!wfâc@KÁƒ 0@FpáÇÀ»u^9Ý8’ÀQZ‰¿ºwõ€ÀO(³Ãÿ8O@LX±bŋ@FpáÇÀ»#ö +äÍeÀQ°ÅöOí€ÀN©Ý eµ@Lïß¿~ýû@FpáÇÀºÓž6ŽèœÀPÜFÅðÄрÀM‹ÚÀ£Õ@M‡8pá@FpáÇÀº„ŠÊÙáWÀPœ¢Pãt€ÀLœ`¡t@N€ÀDÅ{\Ž@R 0`@FpáÇÀ·f¡< ª5ÀLašt™gV€ÀCazˆéˆœ@RN:téÓ@FpáÇÀ·$ çSÙÀKý÷žOò€ÀBÁlšºž@Rš4hÑ£F@FpáÇÀ¶âbÂE³ÀK›Q£Ê‚€ÀB'a›ƒ‹Ú@Rå˗.\¹@FpáÇÀ¶¡ž€ŸÀK:ÌÑË%¥€ÀA“!‘¢èp@S1bŋ,@FpáÇÀ¶aŒ‡“†ÀJÛæ¯˜t€ÀAv„ŽÊÌ@S|ùóçϟ@FpáÇÀ¶"»ó§6­ÀJzö¥mWÀÀ@{'³š˜^@Sȑ"D‰@FpáÇÀµä€È.}°ÀJfFӓŒ€À?íå&÷Þ@T(P¡B…@FpáÇÀµ§~ˍæíÀI®Ýs8Ž€À>ï,á}œ@T_¿~ýû÷@FpáÇÀµkPMÞ¿~ÀIDñêӈñ€À=ùš€ÄQ)@T«V­Zµj@FpáÇÀµ0Qr*ÀHÙ)d'ª€À= åu˜HØ@TöíÛ·nÝ@FpáÇÀŽõì­ÄK„ÀHkù²!€|€À<(sì¡ÒŠ@UB… +(P@FpáÇÀŽŒŸ3㠙ÀGýÍ&\ÓB€À;KêÄ Yé@UŽ8páÃ@FpáÇÀŽ„”ÈƘrÀG÷1°H€À:væh•yp@UÙ³f͛6@FpáÇÀŽMqÇ»{ÀGçæ¿š²€À9©Ž ! @V%J•*T©@FpáÇÀŽTŒ`rÀF°Ë Òàd€À8á÷閠î@VpáÇ@FpáÇÀ³â>3Ø$ôÀFAêÊ˵P€À8!_Õ@VŒxñãǏ@FpáÇÀ³®-Á/ÀEÓ¶QVü€À7fðƒŸŒ@W @@FpáÇÀ³{ |ÐìÀEe»Z ÅW€À6²]ö{G4@WS§N:t@FpáÇÀ³Ibú„œÀDøÈò¿€À6`]*yA@WŸ>|ùóç@FpáÇÀ³ +ôØß[ÀDŒÎ@ê:€À5Y³Àuù°@WêÕ«V­Z@FpáÇÀ²çþTk²ÀD!ë-{Q€À4µŽu_s@X6lÙ³fÍ@FpáÇÀ²žìÉs;ÀCž<7^ÄЀÀ4O/1/S@X‚ @@FpáÇÀ²ŠÓ`£«þÀCOÙ5ÂK€À3z XÙ@X͛6lÙ³@FpáÇÀ²]®ñvNSÀBèÕÀ¬€À2ãTY‹M!@Y2dɓ&@FpáÇÀ²,®¢MmÀC@nÉ ‰€À2t, íǗ@Ydɓ&L™@FpáÇÀ±ÿ—ÕFÀBípL­‡·€À1è8ŽÀN—@Y°`Áƒ @FpáÇÀ±ÓA‚©æÀBœX^.H€À1aeUùÇ@Yû÷ïß¿~@FpáÇÀ±§ªG×èËÀBLVÒð€À0ß|SÜ/:@ZGk¯ÞZFÀ?‡%žnˆ€À*|)C5@\ïß¿~ýû@FpáÇÀ°{¿ÚdÀ?]ŒÚH€À)dPLlåÒ@];víÛ·n@FpáÇÀ¯êFþççâÀ>†ENĀÀ(µ[éšÜ€@]‡8pá@FpáÇÀ¯¢¿›£K~À> ÍýbGQ€À( _/EßÐ@]Ò¥J•*T@FpáÇÀ¯\[.œÇ‘À=éý“S[€À'i–?_@^@_ @FpáÇÀ®M°”Éø>À;Á ÜÅe¡€À%õ†-j,@_L™2dɒ@FpáÇÀ® £,' žÀ;S:$U€À$‡¢f +š@_˜0`Áƒ@FpáÇÀ­Ì–zMVÀ:ç­ËíW€À$ÈU¡:@_ãǏ@`útéÓ§N@FpáÇÀ«íé\-€À7ÖÇkéFá€À eœÇ«9Ø@a @@FpáÇÀ«¶CÜ 7À7}gHV€À ê¹x«@aF 0`Á@FpáÇÀ«~î*Ž/0À7%Òiœlò€ÀBK€‡w7@akׯ^œ{@FpáÇÀ«H¥àSw)À6ÏþðϬ€À†§ÞRf@a‘£F4@FpáÇÀ«&M}xàÀ6{ßõ+΀Àв†_9@a·nÝ»ví@FpáÇÀªÞksdDÀ6)m®«:‹€À 5lÈ@aÝ:téÓ§@FpáÇÀªªqkNUÀ5؝M¬Ûû€Àtþ;×»U@b 0`@FpáÇÀªw4elÛÀ5‰e ?a€ÀÎÜëaÿô@b(Ñ£F@FpáÇÀªD°š…’HÀ5;»b…Fà€À-¢æ‰@bN:téÓ@FpáÇÀªâ’õšÚÀ4ï–ûà…€À‘"M qŸ@bthÑ£F@FpáÇÀ©áƗ§ þÀ4€îÀ0\®€Àù1ooæ@bš4hÑ£F@FpáÇÀ©±Y? ÅÀ4[¹ÎšÕ€ÀeŠÊ€ù¿@bÀ@FpáÇÀ©—&„[ÑÀ4ïz»™€ÀÖZëÕVf@G:téÓ§PÀÃÉbãªLj€€ÀgêîñŒÈq?òå˗.\¹@G:téÓ§PÀÃÇøo…)À#ŸQpB€ÀgäE „w@å˗.\¹@G:téÓ§PÀÃÃÔ¢~^ÒÀ"yK­€ÀgÒŒ@ X±bŋ@G:téÓ§PÀÜÅbõ€À+I¥sPu€ÀgµÖªô"ç@å˗.\¹@G:téÓ§PÀóºžæÐ>À1ÿÚvÄ/ـÀgUÛ'@Ÿ>|ùóç@G:téÓ§PÀçä{è ÚÀ6Lò €ÀgaŒLMdí@X±bŋ@G:téÓ§PÀÙ¯ŒæÀ:W=›|·€Àg*vZ"$­@ ‰$H‘"@G:téÓ§PÀÉ(VdHûÀ=ë.A#ì!€Àfëª6Oÿ@"å˗.\¹@G:téÓ§PÀÃvf§r”ÔÀ@ÎÙÕÄHû€Àf¥¥R~ò—@%B… +(P@G:téÓ§PÀÃasš|_@ÀBŠàJNã€ÀfXþ~· +@'Ÿ>|ùóç@G:téÓ§PÀÃJfª•BŠÀD\&@–ᔀÀfm +2®;@)û÷ïß¿~@G:téÓ§PÀÃ1]HÉ~ÀF{¡®πÀe®wÊK‹¡@,X±bŋ@G:téÓ§PÀÃe¥úºÀGh¿€ÀeQFh1ݒ@.µjÕ«V­@G:téÓ§PÀÂù¢—í³ÀIŸTÕÅy€ÀdïDG(ž@0‰$H‘"@G:téÓ§PÀÂÛ5ÝÒ¹ÀJt\gÙ»€Àdˆó®šV@1·nÝ»ví@G:téÓ§PÀ»+¡ÅmÀKÈ׀ß5V€ÀdÉOê²D@2å˗.\¹@G:téÓ§PÀ™œË}¡3ÀM£×“ÀÀc±F°:z+@4(P¡B…@G:téÓ§PÀÂvª¿ºWÀN#sj6؀Àc@òk²× @5B… +(P@G:téÓ§PÀÂRqçŽÔ²ÀO0$0@Á€ÀbÎSì9>@6páÇ@G:téÓ§PÀÂ-€ºß'ÀP¯€¿F€ÀbYïmºØ@7Ÿ>|ùóç@G:téÓ§PÀÂwFZÑÀP‹„f^˜€ÀaäBø  @8͛6lÙ³@G:téÓ§PÀÁÞâŒþN ÀPõí†ЀÀamŒTª1$@9û÷ïß¿~@G:téÓ§PÀÁ¶a]> ÀQTA+²1â€À`öÁéÎæ@;*T©R¥J@G:téÓ§PÀÁ ÒÜ +ÀQŠŒ·+ŀÀ`±€Ýáû@µjÕ«V­@G:téÓ§PÀÁ WU\ÞÀRRØ#!µæ€À^<|ÝÅØ~@?ãǏ|ùóç@G:téÓ§PÀœSN‘ÕvÀR'H4r¥€ÀS&³òäb¬@H6lÙ³fÍ@G:téÓ§PÀŒþW%úGÎÀQ×á$ð€ÀRÊóäZ@H͛6lÙ³@G:téÓ§PÀŒªwK?ÖÀQª2bC5?€ÀQâ"fÛ@@Idɓ&L™@G:téÓ§PÀŒWr„]&qÀQzYÓl’h€ÀQG®= +$@Iû÷ïß¿~@G:téÓ§PÀŒUêùȘÀQGýšðL‹€ÀP²]NÈÐ@J“&L™2d@G:téÓ§PÀ»Ž,CSŠÿÀQ“lé4΀ÀP"\ȟ0@K*T©R¥J@G:téÓ§PÀ»cþJ ©ÀP݁ÀáЀÀO-™ÓŸ(b@KÁƒ 0@G:téÓ§PÀ»ÒúVÉÀPŠ!ͯfŸ€ÀN ³àØž@LX±bŋ@G:téÓ§PÀºÆ¯ÁjÀPmÁ>çSº€ÀMH÷¿k×@Lïß¿~ýû@G:téÓ§PÀºy˜ž³ŸÀP4£®[N1€ÀL#PpF@M‡8pá@G:téÓ§PÀº-Á.—JÀOö<(8Cî€ÀK1ï²<Ú@N"n°ju6@T_¿~ýû÷@G:téÓ§PÀµ<±V)—RÀHŠÓ0DPƀÀ=5 +º2I@T«V­Zµj@G:téÓ§PÀµî<`ÀH@šj¶J€À|ùóç@G:téÓ§PÀ²öÌiW„ÀDý£©Ÿ„€À4ّe²@D@WêÕ«V­Z@G:téÓ§PÀ²Ç¿UƒbžÀC·Vû-K÷€À49Á.DÅ@X6lÙ³fÍ@G:téÓ§PÀ²™¥áÆ»ÀCPœ“3‰ï€À3ž–ہ×@X‚ @@G:téÓ§PÀ²l}rx4ŠÀBëKB=Q?€À3ÛJqš=@X͛6lÙ³@G:téÓ§PÀ²@C3ÅŽ©ÀB‡l³+€À2uZ+ßx[@Y2dɓ&@G:téÓ§PÀ²jqM+ÀBáj «|q€À2µ×PÔÖ@Ydɓ&L™@G:téÓ§PÀ±ã/**žØÀB‘à$Å6?€À2)ÿ¹‹×·@Y°`Áƒ @G:téÓ§PÀ±·®ÑW{ÀBCÔLœ?€À1£-U[#Î@Yû÷ïß¿~@G:téÓ§PÀ±ŒãwaŸkÀA÷C ø2Š€À1!,³š+@ZGŽ;ù¬~6€À)ÞÎêÇ@];víÛ·n@G:téÓ§PÀ¯ÂýjŸŠÅÀ>/ ˆ®±€À).…‡ÖA×@]‡8pá@G:téÓ§PÀ¯|DʌÀ=šš1ðš€À(„)IF³@]Ò¥J•*T@G:téÓ§PÀ¯7Á°À=$qjŸ¹’€À'ß~ !žc@^ò€À"}\cFK°@`‰$H‘"@G:téÓ§PÀ¬~“? €À8£AKŸ#;€À" +‰ ,@`®Ý»víÛ@G:téÓ§PÀ¬DÎŒ@À8F’E:πÀ!›GŒ#ŠÊ@`Ô©R¥J•@G:téÓ§PÀ¬ á [©cÀ7ë¹@/¥ï€À!/uáRš†@`útéÓ§N@G:téÓ§PÀ«ÓÉ©ùåŸÀ7’¬ŽR«€À Æó¯-Œê@a @@G:téÓ§PÀ«œøÊžÀ7;a©ü¢€À a¡ã®¯@aF 0`Á@G:téÓ§PÀ«fr4³~À6å΀ží€ÀþÅm²ã@akׯ^œ{@G:téÓ§PÀ«0S28¿À6‘é1Ý0€À@38ԛ@a‘£F4@G:téÓ§PÀªûcïÕ9WÀ6?š랜€À‡VgsŸ @a·nÝ»ví@G:téÓ§PÀªÇ52Ӊ:À5ñˀÀÓú|Ê@c@aÝ:téÓ§@G:téÓ§PÀª“Ã75À5ŸìŽšŒb€À%íÆP@b 0`@G:téÓ§PÀªa +ŽÀ5R_й̀À|þ¥÷¬@b(Ñ£F@G:téÓ§PÀª/VÚplÀ5Rzàî€ÀØþÏÌ*@bN:téÓ@G:téÓ§PÀ©ýŽ’ÞGáÀ4»»Ûƒ:9€À9ÃuG‹@bthÑ£F@G:téÓ§PÀ©ÍNÓ-À4r“oéŸE€ÀŸ !ušÔ@bš4hÑ£F@G:téÓ§PÀ©1fö(À4*Ðæò¿€Àíò;×@bÀ@G:téÓ§PÀ©mÈøùÂmÀ3älÞÖрÀwl v–@H @„ÀÃ7;e<ûj€€Àf+Õèp?òå˗.\¹@H @„ÀÃ5ùIðÀqpŒÞ€§€Àf{.Ÿ@å˗.\¹@H @„ÀÃ2CæÎ.&À ÆŠª{n€Àfj3W‡Oã@ X±bŋ@H @„ÀÃ,0߬ OÀ(¯^‰ne€ÀfPd+Ah®@å˗.\¹@H @„ÀÃ#µ‹œ±uÀ0PŒÞ’b€Àf.œH?H@Ÿ>|ùóç@H @„ÀÃøþ·§ÊÀ4#…êˀÀf`Õ¥îé@X±bŋ@H @„Àà ýÜœÀ7ª“Ä{/€ÀeÑñ+\@ ‰$H‘"@H @„ÀÂý?×!À;E!‡Ð߀Àe—º“ò<@"å˗.\¹@H @„ÀÂëåôÎÕŽÀ>±eÍ)€ÀeWÆÝ£>¶@%B… +(P@H @„ÀÂØÀRC—©ÀA,ùNœ€Àe¿žõ»ý@'Ÿ>|ùóç@H @„ÀÂÀ_ÒÀB©|Üú‘€ÀdÆJyÒ•@)û÷ïß¿~@H @„À¬¬¡ÕÀD9[hq‹<€ÀduÜ»DӔ@,X±bŋ@H @„À“æn$› ÀEµUÔV"³€Àd ‰Â9J@.µjÕ«V­@H @„ÀÂyr.³oÀGá,B €ÀcÆÁ.qƒ@0‰$H‘"@H @„ÀÂ]mÁŠÝÀH_gl+̀Àchâf‡w@1·nÝ»ví@H @„ÀÂ?ä—7§IÀI¡žûK‹€ÀcY¢K\Z@2å˗.\¹@H @„À ëßÏVÀJ̹A9"º€Àb¢›]\w@4(P¡B…@H @„À  ÄÀKÞ #a¯ž€Àb;!À4Ýä@5B… +(P@H @„ÀÁßOÃk…ÀLßñ¶ E€ÀaÑg.—¿á@6páÇ@H @„ÀÁŒpslçìÀMÔõŒ˜:€Àaeäæõ@7Ÿ>|ùóç@H @„ÀÁ˜¬IŸÀNžÀIèE~€À`ù œšÒ}@8͛6lÙ³@H @„ÀÁsæM<ÀOˆûϚh‘€À`‹Bw­ ð@9û÷ïß¿~@H @„ÀÁN6"ÞCÃÀP"Pjx{ڀÀ`åÌGg¡@;*T©R¥J@H @„ÀÁ'µ|In%ÀPtè²ÉJI€À_\œ ›¶(@µjÕ«V­@H @„ÀÀ°cŸkìvÀQ'¡£W°€À\ÊTÝ•î@?ãǏ»ÀQ•WŠï€ÀY~H~óEä@BN:téÓ@H @„À¿Äž*°“šÀQžÔŠBó€ÀX³Þ•CÐé@Bå˗.\¹@H @„À¿qSp ~ÀQ¢ây°,¶€ÀWíŽ:@B³@C|ùóçϟ@H @„À¿p9ÀQ¡Á^üwâ€ÀW+”µ[ý©@D(P¡B…@H @„ÀŸÊÁ0ŽžNÀQ›ëÕ4ý¹€ÀVnk0äþ@D«V­Zµj@H @„ÀŸwŠ.E ÀQ‘Òçäu׀ÀUµN…Ý޵@EB… +(P@H @„ÀŸ$Ä#˜€×ÀQƒÞ«%êV€ÀU7+fÆÁ@EÙ³f͛6@H @„ÀœÒ,còþ³ÀQrnÊØ3»€ÀTQåVŒkð@FpáÇ@H @„Àœî‘XAåÀQ]ÚéF6€ÀS§]cò @G @@H @„Àœ.H{¡ŠÀQDèQ£³L€ÀS¡X ”@GŸ>|ùóç@H @„ÀŒÜÊB4StÀQ&Øñqˆ€ÀR`»&:΋@H6lÙ³fÍ@H @„ÀŒŒÏdDÀQo“†H€ÀQĝеíÍ@H͛6lÙ³@H @„ÀŒ<ã¶ÙaÀPÞUó¯¥|€ÀQ-|FJ ÿ@Idɓ&L™@H @„À»ì³‡Á©ÈÀPµ wÜž€ÀP›Z™¹@Iû÷ïß¿~@H @„À»ž+4 L™ÀP‰OG×îü€ÀP vCF{%@J“&L™2d@H @„À»Pw(Ñ͇ÀP[TVˆó¬€ÀO ºdo’@K*T©R¥J@H @„À» ¯ðucÀP+‘îL[b€ÀN[ž² \@KÁƒ 0@H @„Àº·¯a‚‰$ÀOôŒ™Ž`2€ÀM¶nË_Œ@LX±bŋ@H @„Àºl©WÈÖvÀO Ž—±Þ€ÀL æuÜ"b@Lïß¿~ýû@H @„Àº"“_¢ÍÀO) )Ý©€ÀK¶:8@M‡8pá@H @„À¹Ùq5ä ÀNÁó4•ŠR€ÀJ5í!dàü@NüïÀN[ÐËü$€ÀIXNYóô@NµjÕ«V­@H @„À¹J9ÜÀM÷Œ6áL€ÀH‚”È®•p@OL™2dɒ@H @„À¹¬OmÉÀM•• חj€ÀGŽ~«jQÄ@OãǏ²±Všœ@T(P¡B…@H @„ÀµHš$K$ÀHlÁՌˆ €À=1Únòûù@T_¿~ýû÷@H @„Àµdìb§©ÀHDj6ìg€À|ùóç@H @„À²ÖmÙ¬åUÀCŽ«fHG€À4>u¥Ý@WêÕ«V­Z@H @„À²šX:"ÆÈÀCP PÍŌ€À3€ž;+š@X6lÙ³fÍ@H @„À²{/æt~ÀBì}­ò]€À32BÀNø@X‚ @@H @„À²NðWi`ÀB‰ß4Óßw€À2|‹!çšI@X͛6lÙ³@H @„À²#˜ŸÔæÀB(\œˀÀ1îòrQoÓ@Y2dɓ&@H @„À±ñÃÖÝ·ÀB‚–ÿ¢<€À2ñ1Š2k@Ydɓ&L™@H @„À±Æd‹DÀB6lìêp€À2e±'«,#@Y°`Áƒ @H @„À±›·ŽÐSÀAëŸhKúª€À1ßöpù@Yû÷ïß¿~@H @„À±q¹«~š·ÀA¢,Kld€À1]8;AÕn@ZG@[uëׯ^œ@H @„À°©˜A€vÀ@FÏ$@Hœ€À.(Ëz°3»@[Áƒ 0@H @„À°ƒs :ÛÆÀ@6/ß@o€À-VhYŠÅ@\ 4hÑ£@H @„À°]çwwº£À?‰»øÿ¢Î€À,‹*JÐ5l@\X±bŋ@H @„À°8òW'ÔüÀ? ƒ»ñÔĀÀ+ÆÊa–Q@\€H‘"Dˆ@H @„À°ÚPm³À>º,·€À+ sîO@\ïß¿~ýû@H @„À¯á€OX¿À>TéÉ͕€À*Q—Œ/ô@];víÛ·n@H @„À¯šúâ9gñÀ=ŸJW¹ãà€À) DºóuŠ@]‡8pá@H @„À¯U‹ë³ÑÀ=* ñh7€À(ôÐqºì@]Ò¥J•*T@H @„À¯-þ– 1À<ž«€ì”€À(NÿùƒïÈ@^|ùóç@H͛6lÙŽÀ’ZgYkÀ22}dÓï`€ÀdŸ‹Pwèt@X±bŋ@H͛6lÙŽÀ† X‡!úÀ5ƒó•ùé%€ÀdE$P–Y@ ‰$H‘"@H͛6lÙŽÀÂxíàbÑÔÀ8׊ȟ[€Àd[*Só@"å˗.\¹@H͛6lÙŽÀÂiSñ‡ßÀ<1-¢ÒŽ€Àd £Å?‹‚@%B… +(P@H͛6lÙŽÀÂWÕsëï+À?6ŠÈº¹Ë€Àcà(OJ@'Ÿ>|ùóç@H͛6lÙŽÀÂDÔjmÀA—›à€Àcš€“±!‚@)û÷ïß¿~@H͛6lÙŽÀÂ/p JîÀBòó@3k€ÀcP‚NŠäP@,X±bŋ@H͛6lÙŽÀ­¯wÎÀCôŒñ>“>€ÀcÔÖ`¢ê@.µjÕ«V­@H͛6lÙŽÀÂXË¢×ëÀE9þðœ\€Àb®íƒT¬@0‰$H‘"@H͛6lÙŽÀÁ挐Fè—ÀFtwê®Wn€ÀbX3x;@1·nÝ»ví@H͛6lÙŽÀÁËR +TÉÊÀG€@1Þ,€Àaþ¿„“–@2å˗.\¹@H͛6lÙŽÀÁ®»±Œ)/ÀHÀfŸç*ˀÀa Ä¹UÆÉ@4(P¡B…@H͛6lÙŽÀÁã«£;/ÀIÄ«è-P#€Àa@é›™Ï@5B… +(P@H͛6lÙŽÀÁqáŽP÷ÀJ»ö5g;€À`Þßä%šy@6páÇ@H͛6lÙŽÀÁQÁ ®åÀKš¶Z,úT€À`{Ëa‘*@7Ÿ>|ùóç@H͛6lÙŽÀÁ0‘k«úPÀL†eÊǞ>€À`øD8gÊ@8͛6lÙ³@H͛6lÙŽÀÁf5”vEÀMRz/°í€À__À"ȰZ@9û÷ïß¿~@H͛6lÙŽÀÀëUöxþÀN Œ§ÌـÀ^’J1àBE@;*T©R¥J@H͛6lÙŽÀÀÇtÆSì„ÀN°z†Ã–€À]Ä3p +·@µjÕ«V­@H͛6lÙŽÀÀWö4m†¥ÀP8»`õ€À[]³­R­­@?ãǏ|ùóç@H͛6lÙŽÀŒk‡k·&ÀPYd8ï {€ÀQ”bAç@H6lÙ³fÍ@H͛6lÙŽÀŒŠìoÝWÀP=3á-23€ÀQÙ¿¿up@H͛6lÙ³@H͛6lÙŽÀ»ÒšØo’ÀPM9·9ô€ÀPqÌû©Ÿ@Idɓ&L™@H͛6lÙŽÀ»†L«Î~‰ÀOôz€ÀMŸrig¿@K*T©R¥J@H͛6lÙŽÀº§†œ)ÀO7]­Èå€ÀLÃe_h߈@KÁƒ 0@H͛6lÙŽÀº^1ÓÍgÀN®Q„Y[’€ÀKÐÃö‡â@LX±bŋ@H͛6lÙŽÀº!ƒ×ñÀNTÞVøÿ€ÀJæaüœ@Lïß¿~ýû@H͛6lÙŽÀ¹ÎçâÇ ÀMù^;¢Œ€ÀJ ºË@M‡8pá@H͛6lÙŽÀ¹ˆ†˜ +ÕTÀMœtUéY?€ÀI)•ºÊY×@NŸ%pÀK:ƒíA“’€ÀCù1·Ús%@Q @@H͛6lÙŽÀ·s ?˜J€ÀJèê#k€ÀCUiÚ +¶î@Qkׯ^œ{@H͛6lÙŽÀ·3×ÿ"UoÀJ˜)Å}ŸQ€ÀB·OnÑø@Q·nÝ»ví@H͛6lÙŽÀ¶õcÔ:Ù§ÀJHªsV‚€ÀB?çUžœ@R 0`@H͛6lÙŽÀ¶·ª/ï¬ÀIú;K‚9߀ÀAŒzðœýT@RN:téÓ@H͛6lÙŽÀ¶zššjœgÀI¬ÓŒ7À€À@ÿ4<”ž@Rš4hÑ£F@H͛6lÙŽÀ¶>\°QúwÀI`i»ñx€À@v€îö‰‚@Rå˗.\¹@H͛6lÙŽÀ¶Ä xå:ÀI÷ç7)6€À?æs”³3@S1bŋ,@H͛6lÙŽÀµÇܫەÀHÊfƒ‹×€À>é)Š ²w@S|ùóçϟ@H͛6lÙŽÀµ§Œ_•ÀH|æÚ/[€À=õÈW±@Sȑ"D‰@H͛6lÙŽÀµT/iTÂòÀH*ÔÝÙрÀ= ŒEøÏ@T(P¡B…@H͛6lÙŽÀµ}š‹ÀGÔãçA €À<&Ádô@T_¿~ýû÷@H͛6lÙŽÀŽãšÛŸk»@T«V­Zµj@H͛6lÙŽÀެþŒ3 +ÀGÙ²~œ€À:xC³$Š@TöíÛ·nÝ@H͛6lÙŽÀŽv\Çò¹ëÀFÁÐõ÷‹€À9¬gKS@UB… +(P@H͛6lÙŽÀŽA ΋è4ÀFb:°€À8梎ÝÈÔ@UŽ8páÃ@H͛6lÙŽÀŽ žŠ÷Ÿ<ÀFþìš[€À8'ÌŸŸ“@UÙ³f͛6@H͛6lÙŽÀ³Ùÿ·ÕÑÀEžöIY!]€À7o0ÉÑS)@V%J•*T©@H͛6lÙŽÀ³ŠyºZcÔÀE|ùóç@H͛6lÙŽÀ²·cfŸ_ÀCN7º7:€À3ŽóÛÒé«@WêÕ«V­Z@H͛6lÙŽÀ²‰í=fÀB쥬aԀÀ2úƒå˜Œ=@X6lÙ³fÍ@H͛6lÙŽÀ²]«¯€žÀB‹Ù)ÆE€À2jU-0Õ@X‚ @@H͛6lÙŽÀ²2MçEÀB+ï|ƒ¯€À1Þ5çÆ×@X͛6lÙ³@H͛6lÙŽÀ²ÑB:QÚÀAÍX¬^T€À1UöƉl<@Y2dɓ&@H͛6lÙŽÀ±ÓĘlk¥ÀB$u—=X€À3&q7(ŠC@Ydɓ&L™@H͛6lÙŽÀ±©@”ËntÀAÛ<Ñ|€À2›~©q&É@Y°`Áƒ @H͛6lÙŽÀ±g<ç‚íÀA“šïÀb€À2Uûáu4@Yû÷ïß¿~@H͛6lÙŽÀ±V5Š³ÈŒÀAM35‚b€À1“Èí~}W@ZGŠŸ’_T€À,3MéÍE@\€H‘"Dˆ@H͛6lÙŽÀ¯þ8{tÀ>FhŽ€À+t닔ð=@\ïß¿~ýû@H͛6lÙŽÀ¯·ž†;ÂÀ=ÙŒÿ}€À*ŒÍâô¯=@];víÛ·n@H͛6lÙŽÀ¯rJü ²JÀ=*Ɗþx€À* +·˜ÖÁe@]‡8pá@H͛6lÙŽÀ¯-ê·%XNÀ<¹Ýº)“€À)^n€€G@]Ò¥J•*T@H͛6lÙŽÀ®ê’¥s!„À„ÞSÀ;sÂG$r¹€À'zA•ÒÐ +@^µjÕ«V­@H͛6lÙŽÀ®&Š/÷©€À; &g0u€À&㎩+c@_ @H͛6lÙŽÀ­ç!âo5À:€‡"| €À&P·©—‡ý@_L™2dɒ@H͛6lÙŽÀ­š©¯\Í À:?ÛD)F”€À%Â÷{ÉP¹@_˜0`Áƒ@H͛6lÙŽÀ­kñ+ðÀ9Ý–yž¡€À%9ªn÷Ð@_ãǏF@`cF4h@H͛6lÙŽÀ¬}Åh +[žÀ8d‚xÃ7€À#=ɕõmí@`‰$H‘"@H͛6lÙŽÀ¬DèË•À8 +ÉÉðZº€À"ÈiDªKÓ@`®Ý»víÛ@H͛6lÙŽÀ¬ .S€ì]À7²Åælf¢€À"V˜ùtr@`Ô©R¥J•@H͛6lÙŽÀ«Ô™­ +±«À7\m''€À!è7ý:@`útéÓ§N@H͛6lÙŽÀ«Ïè|À7¶– ø€À!}&ç Ÿ@a @@H͛6lÙŽÀ«gÊŠ÷°/À6Ž™ê;És€À!G²l@aF 0`Á@H͛6lÙŽÀ«2ˆ·0ÃsÀ6c™««€À °}°éò@akׯ^œ{@H͛6lÙŽÀªþ•Z À6 O‚™€À N«rt~@a‘£F4@H͛6lÙŽÀªÊ=ªœõÀ5Ċۻ @€ÀßpTBˆ³@a·nÝ»ví@H͛6lÙŽÀª—-s׊°À5w‚-Œ€À' +8 @aÝ:téÓ§@H͛6lÙŽÀªdр +BRÀ5+êWrÝ׀Àt ¿£K—@b 0`@H͛6lÙŽÀª3&qF§À4ỏ"­˜€ÀÆ1ð”©@b(Ñ£F@H͛6lÙŽÀª(û»<À4˜î-ÿ¢‰€ÀO¬‚Ùn@bN:téÓ@H͛6lÙŽÀ©ÑÕå}ÔEÀ4Qz±qŒz€Ày=³­ÄÏ@bthÑ£F@H͛6lÙŽÀ©¢*8QÂÀ4 Y»‹ñ€ÀÙÑ3S$q@bš4hÑ£F@H͛6lÙŽÀ©s"FÐÎ À3Ƅûžž€À>áˆO@bÀ@H͛6lÙŽÀ©D»¡êÀ3‚òfäN€ÀšH«ù±4@I—.\¹rèÀÂ)°VK €€ÀdvZIs²?òå˗.\¹@I—.\¹rèÀÂ(±Oê©xÀ +ôáw_õ€Àds"r#Š@å˗.\¹@I—.\¹rèÀÂ%¶M'_Àäúª(€ÀdhæÐ!,@ X±bŋ@I—.\¹rèÀ Ááf¬$À$1|c¡€€ÀdWüÁÿø@å˗.\¹@I—.\¹rèÀÂÚbG`vÀ*§Z( 9õ€Àd@•ûjæš@Ÿ>|ùóç@I—.\¹rèÀÂ+€ À0Œçg ët€Àd"òkÐ}¥@X±bŋ@I—.\¹rèÀÂQ‚üíÀ3µ'_J|ˀÀcÿYO ‡@ ‰$H‘"@I—.\¹rèÀÁùÄmNaÀ6Ê;^LiS€ÀcÖü€­4@"å˗.\¹@I—.\¹rèÀÁëjҟ‹àÀ9ÊN˜ºyŸ€Àc§q'ÀRË@%B… +(P@I—.\¹rèÀÁÛRŒ*¹nÀ<³ÎÍf€Àcs°ŸX„W@'Ÿ>|ùóç@I—.\¹rèÀÁɉŒõ—À?…O%¥©y€Àc;(=®µ@)û÷ïß¿~@I—.\¹rèÀÁ¶ÝôNÀA¶äž…€ÀbýÛzªÌÈ@,X±bŋ@I—.\¹rèÀÁ¡p"ŽÀBm`X枀€ÀbŒ>VPÜÆ@.µjÕ«V­@I—.\¹rèÀÁŠ›V:ÀC­æHüö׀Àbvya†‡d@0‰$H‘"@I—.\¹rèÀÁrŠÂhëGÀD߃XV@K€Àb,Ì6Úr×@1·nÝ»ví@I—.\¹rèÀÁYR¹ÉêÀFpRF°ž€Àaß} Ë;Ë@2å˗.\¹@I—.\¹rèÀÁ>±åD|êÀGí¶×ՀÀaŽÚ‹ÓBB@4(P¡B…@I—.\¹rèÀÁ"ت#ÀHO=:8€Àa;E@5B… +(P@I—.\¹rèÀÁÙÚeÌÀI‹Åï#€À`åGqbD@6páÇ@I—.\¹rèÀÀçÌKE«gÀIÞ²ni¥€À`Œ–ƒ¡sn@7Ÿ>|ùóç@I—.\¹rèÀÀÈÄàÁlÀJ©c|ê€À`2bs(Çr@8͛6lÙ³@I—.\¹rèÀÀšÙ, +­^ÀKaFºß €À_­ªUFÊc@9û÷ïß¿~@I—.\¹rèÀÀˆ¡í÷aÀLh•†€À^ô¶í –@;*T©R¥J@I—.\¹rèÀÀfªUŠK4ÀL›égâ®=€À^:»Ê]̝@µjÕ«V­@I—.\¹rèÀ¿ýwn³©µÀMö509¹€À\ ㈭1@?ãǏƒt^ʶ@BN:téÓ@I—.\¹rèÀŸ“… ”f†ÀO!—Ž<€ÀX‘9AŠìp@Bå˗.\¹@I—.\¹rèÀŸIÛh:‹eÀO;nÏ_̀ÀWæõÑì†ú@C|ùóçϟ@I—.\¹rèÀœÿþ—ÓçÀOLŽÇº³u€ÀW?Ô|õō@D(P¡B…@I—.\¹rèÀœ¶|nã¬ÀOU1•h#m€ÀV›æŠˆž0@D«V­Zµj@I—.\¹rèÀœký]Ž:WÀOUûÒS÷ހÀUû6'‹@EB… +(P@I—.\¹rèÀœ!þzEVÀOOV'7ž€ÀU]Æé™Ø§@EÙ³f͛6@I—.\¹rèÀŒØ&ìOJÀOBF€°ôŠ€ÀTـýë@FpáÇ@I—.\¹rèÀŒŽTÿ/VDÀO.Áßß(¹€ÀT,«®dÚº@G @@I—.\¹rèÀŒDÈõä#”ÀO^%ÓS€ÀS˜ùåÀ @GŸ>|ùóç@I—.\¹rèÀ»ûvI)úÀNö{Wò€ÀSõJŽ”@H6lÙ³fÍ@I—.\¹rèÀ»²… œ°ÀNÒqí3oـÀR{9’¬2î@H͛6lÙ³@I—.\¹rèÀ»iå“uúÀN©“×õ€ÀQñ" ìdw@Idɓ&L™@I—.\¹rèÀ»!¬ ŸÒÀN|*êñ%ÿ€ÀQj7U«D¬@Iû÷ïß¿~@I—.\¹rèÀºÙâ×y[ÀNJF°s>€ÀPæt9¯$Å@J“&L™2d@I—.\¹rèÀº’“žWŽ»ÀNÚ÷¥/€ÀPeÖ3Ø@K*T©R¥J@I—.\¹rèÀºKÇÈm”ÀMÛzbˆ¢4€ÀOгƒ6ÄÜ@KÁƒ 0@I—.\¹rèÀº‡‰Z^žÀMž ª‡A€ÀNÛø"Ðm@LX±bŋ@I—.\¹rèÀ¹¿Úææ¹ÀM^‹#]zI€ÀMísc@Lïß¿~ýû@I—.\¹rèÀ¹zÉ5ö#:ÀMyeun€ÀM§€aä@M‡8pá@I—.\¹rèÀ¹6Y?Ã62ÀLÕ§Žàj€ÀL"ëÔsì™@Nž¥?@NµjÕ«V­@I—.\¹rèÀž¯vØKM‡ÀLB°«|¥€ÀJpÏ܆@OL™2dɒ@I—.\¹rèÀžm99§™ÀKõÿÚ:L€ÀI Êس @OãǏ$•Gäb@TöíÛ·nÝ@I—.\¹rèÀŽEE ÷kJÀFqÒÓš€À=;øÛy­P@UB… +(P@I—.\¹rèÀާÄb6¿ÀE¯éƍ÷€À<[D—KÃ@UŽ8páÃ@I—.\¹rèÀ³ÞÎî•×pÀE]#e-<}€À;‚8ÈoҎ@UÙ³f͛6@I—.\¹rèÀ³¬ž­UÁÀE -@‚·ž€À:°–L„þ@V%J•*T©@I—.\¹rèÀ³{cÆ>òÀDºVKŽD€À9æû=âš@VpáÇ@I—.\¹rèÀ³JËÞ†QÀDiä'wÀ«€À9"•Å ¿Í@VŒxñãǏ@I—.\¹rèÀ³ñïÀDŠÏèԀÀ8eŸÐ‰cs@W @@I—.\¹rèÀ²ëÐ4ÞæêÀCÌe³ +€À7¯_Šä}@WS§N:t@I—.\¹rèÀ²œg'ÀC&§ÌØc€À6ÿ>¿å™\@WŸ>|ùóç@I—.\¹rèÀ²³%ômSÀC2ñãóo€À6U$¥•Ež@WêÕ«V­Z@I—.\¹rèÀ²b²JõpÀBçÌ;ÇgF€À5°Úè Ó^@X6lÙ³fÍ@I—.\¹rèÀ²6a>[qÀBº!îT€À5,±«‰¬@X‚ @@I—.\¹rèÀ² +œçˆh)ÀBT¿ Ô ö€À4xæ°©ÀR@X͛6lÙ³@I—.\¹rèÀ±ßÅÊ{3xÀB Ýö +Œ+€À3ä×2.š@Y2dɓ&@I—.\¹rèÀ±µv)Ý)ªÀAÆš›8]€À3UÍ«k39@Ydɓ&L™@I—.\¹rèÀ±‹Ìe6ÀA€pWî‹€À2˛š¡@Y°`Áƒ @I—.\¹rèÀ±bÅÛa_wÀA;åÖPŒF€À2FÚžî@Yû÷ïß¿~@I—.\¹rèÀ±:_çòðÀ@øy+k©€À1Å +‹xcý@ZGÍa@[uëׯ^œ@I—.\¹rèÀ°yo5>®À?oõS30”€À.ûcÊhŠØ@[Áƒ 0@I—.\¹rèÀ°T•z§TÀ>öQñ,ÌB€À.(µ£.bT@\ 4hÑ£@I—.\¹rèÀ°0J˜ä3ªÀ>~Óe³€À-\ð|àŽ@\X±bŋ@I—.\¹rèÀ° ‹uŽqyÀ> sòÄž€À,—Ó­+%@\€H‘"Dˆ@I—.\¹rèÀ¯Ò«k9ÀÀ=–-rŒV€À+ÙPŽ v@\ïß¿~ýû@I—.\¹rèÀ¯MÅaŸ>À=$ù^ΠȀÀ+ •ìׂ£@];víÛ·n@I—.\¹rèÀ¯Hù¡ˆ!À<µÐà–uƒ€À*mÿ£dÓ^@]‡8pá@I—.\¹rèÀ¯š€ƒ?ùÀL„y,@`útéÓ§N@I—.\¹rèÀ«‚ó{E2À6Á ñНs€À!Ò+érŒ@a @@I—.\¹rèÀ«L ûB?ÿÀ6pSá €À!i;‹‡hà@aF 0`Á@I—.\¹rèÀ«þ¡—ûhÀ6 €N[]˜€À!_XÀ@akׯ^œ{@I—.\¹rèÀªäg²ûTÀ5ÒpJÂÉL€À  zóQ¥û@a‘£F4@I—.\¹rèÀª°äà˜‘ÞÀ5…ÏËÚ€À @uN¢@a·nÝ»ví@I—.\¹rèÀª~f°àkÀ5:—n»G–€ÀÆh=IÜ@aÝ:téÓ§@I—.\¹rèÀªL˜ŽsjTÀ4ð¿ëéy€À?Ž× (@b 0`@I—.\¹rèÀªw@M JÀ4šB7ñҀÀaAd“eT@b(Ñ£F@I—.\¹rèÀ©êÿž4bÀ4a刚<€À¶@Ò0ñY@bN:téÓ@I—.\¹rèÀ©».xKÄÀ47chVW€À€M§@bthÑ£F@I—.\¹rèÀ©ŒšycÀ3֜¿‰Ï&€ÀnNR=j@bš4hÑ£F@I—.\¹rèÀ©]t$MÀÀ3“@F#nž€ÀшÙ*¿@bÀ@I—.\¹rèÀ©/„æ¡°À3Qb2Ôô€À8ÞÒ@U€@J`Áƒ ÀÁ¬:×dg€€Àcb}ð r\?òå˗.\¹@J`Áƒ ÀÁ«PO%xÀÐgšu€Àc_wP]ë@å˗.\¹@J`Áƒ ÀÁš‘ªo&ÀÃ=¹·(µ€ÀcVk +Ú ×@ X±bŋ@J`Áƒ ÀÁ€»²ŸJÀ"‚Ž}é¹W€ÀcGoR. @å˗.\¹@J`Áƒ ÀÁ¥ ¬–xÀ(ü!1ßh€Àc2§0¹°è@Ÿ>|ùóç@J`Áƒ ÀÁ•‚‹ÆÀ.‡@ àE€Àc?óÉÙú@X±bŋ@J`Áƒ ÀÁ‹ uÄn²À20¿Ï¯€B€Àbøn(ÊsV@ ‰$H‘"@J`Áƒ ÀÁ€ @ö?[À5 I蟀рÀbÓj¥Wþ@"å˗.\¹@J`Áƒ ÀÁrÆÂÒˆÀ7׈‘XÌÆ€Àb©p³’L@%B… +(P@J`Áƒ ÀÁcäIX¶À:ÐÍÀÀbz¹Ï3s@'Ÿ>|ùóç@J`Áƒ ÀÁSnƒwŒÀ=.›‡ÎـÀbGTŸé@)û÷ïß¿~@J`Áƒ ÀÁAq@˜ÝÀ?žu< €Àbý.mWÍ@,X±bŋ@J`Áƒ ÀÁ-ûÆŸ²$ÀAüW1€ÀaÔk·1Lh@.µjÕ«V­@J`Áƒ ÀÁn6ÖÀB@áÎág€Àa•Ï:¶‚@0‰$H‘"@J`Áƒ ÀÁâÌãäoÀC_?>;#n€ÀaR k•“#@1·nÝ»ví@J`Áƒ ÀÀë_A‰JöÀDonì,\<€Àa Ã)§Öž@2å˗.\¹@J`Áƒ ÀÀÒ¢æôTvÀEp×ü€K€À`Âq35ۅ@4(P¡B…@J`Áƒ ÀÀž¿ë<ÎÀFbôà•€À`vf‚îÑe@5B… +(P@J`Áƒ ÀÀÇf ‡*ÀGEZ@)à€À`'÷y·º|@6páÇ@J`Áƒ ÀÀÍS^‰ÀHœ0vž€À_®ùžšXª@7Ÿ>|ùóç@J`Áƒ ÀÀdäMÝ=ÀHÙ÷fX#ö€À_ +£ûþNó@8͛6lÙ³@J`Áƒ ÀÀGk§ˆÀIŒ +pJ<€À^cŠ®&-@9û÷ïß¿~@J`Áƒ ÀÀ(‘®È ÀJ.Ë)S€À]º·äÆE†@;*T©R¥J@J`Áƒ ÀÀ MΟUœÀJÀ†FO£L€À]‡X59Ì@µjÕ«V­@J`Áƒ À¿Oâ~“¹ÀL™•i.€À[ Û€ž@?ãǏ69€ÀY¿s/ÅŠÄ@A @@J`Áƒ ÀŸ…+@Ÿ­ÀMö»'{€ÀYP26@A·nÝ»ví@J`Áƒ ÀŸ@]¬ 9ÀM:¢ÅZڀÀXu5~PõŠ@BN:téÓ@J`Áƒ ÀœûTŽ®ZÀMf*Wp€ÀWÓ]^\Yz@Bå˗.\¹@J`Áƒ Àœµ€ªÆñÀM‡Æò`þ€ÀW3õô÷!@C|ùóçϟ@J`Áƒ ÀœoŸä+fÀM ž¯ÂÝD€ÀV—!+Ü@D(P¡B…@J`Áƒ Àœ)ŽY—-RÀM±* íi€ÀUüù翝Ç@D«V­Zµj@J`Áƒ ÀŒã^öL—:ÀM¹ûRpÉš€ÀUe’ØäÖK@EB… +(P@J`Áƒ ÀŒ#Y,LìÀM»™s’ÍŠ€ÀTÐøÿ/#@EÙ³f͛6@J`Áƒ ÀŒVëðaŸƒÀM¶ ‚Å|€ÀT?5%7 +@FpáÇ@J`Áƒ ÀŒÈºZÀM«?ÄXo€ÀS°Lí4fÝ@G @@J`Áƒ À»ÊÆ`"PÀM™Ü +Ê%k€ÀS$CŸ˜U8@GŸ>|ùóç@J`Áƒ À»„ó?˜ÀMƒFBó+€ÀR›}A„@H6lÙ³fÍ@J`Áƒ À»?\ؓ!åÀMg&üC!‡€ÀRÕc0Q@H͛6lÙ³@J`Áƒ Àºú ú4;ÀMF\'Üv€ÀQ‘pžžM6@Idɓ&L™@J`Áƒ Àºµ5*†ÀM!ÁŒ€ÀQîRLÚ±@Iû÷ïß¿~@J`Áƒ Àºps_ ÅÀL÷b±o™>€ÀP“Mf5h«@J“&L™2d@J`Áƒ Àº,;ØËñÜÀLÉÀ“³aT€ÀP6%Pˆ@K*T©R¥J@J`Áƒ À¹èt˜Õª—ÀL˜^XYž#€ÀOAYf?Ü@KÁƒ 0@J`Áƒ À¹¥&/wÓÀLc{ÀÌè€ÀNWT×;jr@LX±bŋ@J`Áƒ À¹bX˜™émÀL+V¹èͅ€ÀMsé1cÉ@Lïß¿~ýû@J`Áƒ À¹ ?ÊÓØÀKð+™8¶¡€ÀL”p¡Ý}%@M‡8pá@J`Áƒ ÀžÞ]ËîÉÀK²5Ar䚀ÀK»…³ŽL @N(àìmt@TöíÛ·nÝ@J`Áƒ ÀŽ3ìM&qÀElz¡:š€À=CñG› @UB… +(P@J`Áƒ À³äõ?{1ÀEå±àµ€À|ùóç@J`Áƒ À²lk•\ñÀBÄ¡f O€À6wÞî³ @WêÕ«V­Z@J`Áƒ À²@kÑiYÖÀB|³æ0ÏĀÀ5ÕRC{‹@X6lÙ³fÍ@J`Áƒ À² +À'ÀB6>0öû€À582-Äن@X‚ @@J`Áƒ À±ê`·3MÀAð¿Aº€€À4 Mà÷çT@X͛6lÙ³@J`Áƒ À±ÀPÔ¬9ÀA¬:wD¯€À4 vÍQÕ@Y2dɓ&@J`Áƒ À±–á£8`¶ÀAh²%çè€À3žeQ@Ydɓ&L™@J`Áƒ À±nÉþÏÓÀA&)š“u€À2ö<Ÿ4Mb@Y°`Áƒ @J`Áƒ À±EÛëŸçÙÀ@ä¢#Ñ>C€À2qƒQF@@Yû÷ïß¿~@J`Áƒ À±@šÂ^À@€Ÿu#’€À1ñ*8eÃ@ZGÜ@[*T©R¥J@J`Áƒ À°…£÷ À?X:ÉÀîr€À0~‰«1@[uëׯ^œ@J`Áƒ À°`äüÄ¡qÀ>á@nHÍ£€À/W”ž‰®c@[Áƒ 0@J`Áƒ À°<±[ù‡]À>lCd֏€À.…4†§Ž@\ 4hÑ£@J`Áƒ À°œƒûÜÀ=ù?> ‚€À-¹›¯€Ë@\X±bŋ@J`Áƒ À¯ëŝ§:ZÀ=ˆ0¥¶qñ€À,ô‹³ x@\€H‘"Dˆ@J`Áƒ À¯Š†… dÀ=º-!|€À,5ÈVÇÞè@\ïß¿~ýû@J`Áƒ À¯bK¡ÉŒ:À<«àuÚÓʀÀ+}ŒJÅ@];víÛ·n@J`Áƒ À¯pÐüŠÀ<@”uàÒ³€À*ÊAhQn@]‡8pá@J`Áƒ À®ÜÐ|#µ—À;×)­¹F€À*ekø@]Ò¥J•*T@J`Áƒ À®›‡[–›‘À;o˜)–ÿ€À)uO¥õϰ@^ÔOÀ:¥í.„^€À(5[Ðmê,@^µjÕ«V­@J`Áƒ À­ÝIŒjûÀ:CÅÕŸßì€À'œÊ€,Ý@_ @J`Áƒ À­Ÿ±Œ À9ã_+ÜÝ]€À'ìœæï}@_L™2dɒ@J`Áƒ À­bùõŠz7À9„²;B„Õ€À&y˜‹  @_˜0`Áƒ@J`Áƒ À­' Š{m}À9'ž +s€À%4Ä@_ãǏ¡`ú€À#땪–äù@`‰$H‘"@J`Áƒ À¬Tí`SÀ7oW^È€À#tN©ƒ@`®Ý»víÛ@J`Áƒ À«Ñ\wžTÀ7ùtxð€À#Ž1‹@`Ô©R¥J•@J`Áƒ À«›&AÑÚHÀ6Êq†æ/€À"7ÕRŸÆ@`útéÓ§N@J`Áƒ À«e°õÐDÀ6y¹‰…m€À"#+–¬Ïì@a @@J`Áƒ À«0ö*?*À6*ÉœLb¡€À!¹Lۘeœ@aF 0`Á@J`Áƒ Àªüôû„£0À5ÝFˑ[€À!R¹†Ql@akׯ^œ{@J`Áƒ ÀªÉ©@i§<À5‘'ÄG˜€À î©UǶ±@a‘£F4@J`Áƒ Àª—ŽÁMÀ5Fgæœ €À ¯ÚÕË@a·nÝ»ví@J`Áƒ Àªe%$œdÀ4üÿÈ|h€À /zmüô>@aÝ:téÓ§@J`Áƒ Àª3ækۅÌÀ4ŽèÀðÜ€À§âJÒf"@b 0`@J`Áƒ ÀªPuþArÀ4n?و»€Àõùý#i1@b(Ñ£F@J`Áƒ À©Ó`=ëòÙÀ4(“ÊO/ €ÀIªvÏ@bN:téÓ@J`Áƒ À©€ÍŸD À3äHýyf,€À ø²ñèN@bthÑ£F@J`Áƒ À©ue>ˆNÀ3¡5Žçô݀ÀýŒÖb@bš4hÑ£F@J`Áƒ À©GTžÁÀ3_SM+Ú€À^¢M–î·@bÀ@J`Áƒ À©ÞpË&\À3œ S銀ÀÄ`b~Ä@K*T©R¥LÀÁ5KCeÒo€€Àbdj;o(?òå˗.\¹@K*T©R¥LÀÁ4rº€\…Àèڀ7†Ë€ÀbaM€hÍ@å˗.\¹@K*T©R¥LÀÁ1éêÐ0ÀÞ"·²Ñ€ÀbY:йZ@ X±bŋ@K*T©R¥LÀÁ-³0…ÈÛÀ!S+Õ{î€ÀbKØÇ@å˗.\¹@K*T©R¥LÀÁ'Òk(ޛÀ&³ýµÀ^€Àb9?%ešP@Ÿ>|ùóç@K*T©R¥LÀÁ LèaeÙÀ,:bìñ‘ƒ€Àb!‘þI#è@X±bŋ@K*T©R¥LÀÁ)IÕ.§À0ÔjG&ƒ€Àbøë™p@ ‰$H‘"@K*T©R¥LÀÁ ohæüÀ3|ŸÇÿ/ǀÀaã àÇcô@"å˗.\¹@K*T©R¥LÀÁ(;IÀ6VŒª&ˆ€Àaœ¹ðöß7@%B… +(P@K*T©R¥LÀÀò]žªÞáÀ8œ=¥‚B©€Àa“v+ +ÎI@'Ÿ>|ùóç@K*T©R¥LÀÀãÈYöÀ;ïÍ3Ö·€Àae auâ@)û÷ïß¿~@K*T©R¥LÀÀÒk-ÖvÀ=o€ÆP€Àa2§*º?@,X±bŋ@K*T©R¥LÀÀÀ[}áuÀ?žóF†€À`ü†ÿ†…†@.µjÕ«V­@K*T©R¥LÀÀ¬ù ç À@ôÿúqq€À`Âá ¶*+@0‰$H‘"@K*T©R¥LÀÀ˜Q÷° ?ÀB¹sÆ+…€À`…ðºýӀ@1·nÝ»ví@K*T©R¥LÀÀ‚tù_Ÿ ÀC­íI'¿€À`Eõvîƒ@2å˗.\¹@K*T©R¥LÀÀkquýLÑÀCô^µÉ€À`0µãž@4(P¡B…@K*T©R¥LÀÀSW[RlNÀDÙ\ßž{)€À_{ÔçGÞ@5B… +(P@K*T©R¥LÀÀ:7 +¶ÌÀE°TU*×ã€À^ìØµÅm:@6páÇ@K*T©R¥LÀÀ !<ˆßÀFyƒ3'€À^ZiSõ@7Ÿ>|ùóç@K*T©R¥LÀÀ&Ý"öÀG3Z“œ­€À]Ãú»E?e@8͛6lÙ³@K*T©R¥LÀ¿Ò±ÜˆÀGßJ6®d'€À]+RêVqü@9û÷ïß¿~@K*T©R¥LÀ¿™ºbúÀH|õžߊ€À\©æOò@;*T©R¥J@K*T©R¥LÀ¿_ ÇylÀI ˜®q‹€À[ô•„CÉ@µjÕ«V­@K*T©R¥LÀŸšiJƒ‹ÀJk7÷•€ÀZ/šU|#@?ãǏ(ü ú€ÀTFcÌ c@EÙ³f͛6@K*T©R¥LÀ»Øþ +H?ÀL@')·7}€ÀSŒªXñqØ@FpáÇ@K*T©R¥LÀ»–EùD|qÀL;ñÕØïì€ÀS5pí/w@G @@K*T©R¥LÀ»SžÓaóÀL1éš‘’€ÀR°À45iX@GŸ>|ùóç@K*T©R¥LÀ»çù‹ûÀL"hÑ¢ +ð€ÀR.žùQš>@H6lÙ³fÍ@K*T©R¥LÀºÎ·Ÿ…TŒÀL Ãq05€ÀQ¯wY€~@H͛6lÙ³@K*T©R¥LÀºŒÆœÀKôH`¶€=€ÀQ2¶_@Idɓ&L™@K*T©R¥LÀºJª¿ FÀKÖAù%×í€ÀP·ÆËÏ€Ÿ@Iû÷ïß¿~@K*T©R¥LÀº …¹íÀK³öÃXN€ÀP@ `?@J“&L™2d@K*T©R¥LÀ¹ÇËìÃéÆÀKª”÷!€ÀO•æ¢w @K*T©R¥J@K*T©R¥LÀ¹†æ®ýŸÀKcœ}Û:•€ÀN°ó‘€åž@KÁƒ 0@K*T©R¥LÀ¹Fh÷?ï^ÀK6 Xèêa€ÀMÑC< 0ò@LX±bŋ@K*T©R¥LÀ¹ZËG#.ÀK5ñÓE€ÀLöÔcöèõ@Lïß¿~ýû@K*T©R¥LÀžÆÃ Ök+ÀJÑSÐÍÇú€ÀL!€ôfÙ@M‡8pá@K*T©R¥LÀž‡ªf?«pÀJšžäZE2€ÀKQ±Z2ø@N#Ô»Ck@TöíÛ·nÝ@K*T©R¥LÀ³étx‰ÀDØwè·›€À=D!24Æ@UB… +(P@K*T©R¥LÀ³ž5­S¶ÀDŽÕŒÎ“€ÀU<€À9NÂ|ùóç@K*T©R¥LÀ²HòWjŸÀBVŽž €À6”I–a¡P@WêÕ«V­Z@K*T©R¥LÀ²ñyr™ÀBßæ–á€À5󚰍]@X6lÙ³fÍ@K*T©R¥LÀ±óŽöÜÁÀAÏ˔ÿR•€À5XC^9Éi@X‚ @@K*T©R¥LÀ±ÉÊQOÀA«‡©ôå€À4Áí 7f@X͛6lÙ³@K*T©R¥LÀ±  X_ÔÜÀALh—)Ž€À40zDp¿ @Y2dɓ&@K*T©R¥LÀ±xÃ{(sÀA  e€À3£Àœš@Ydɓ&L™@K*T©R¥LÀ±P:ÚäÙÀ@̅jÐûZ€À3—R&î +@Y°`Áƒ @K*T©R¥LÀ±(±¢5ö;À@ë±ï`"€À2—Ö îµ@Yû÷ïß¿~@K*T©R¥LÀ±ߨ2±À@P9ž}݀À2VKÉd@ZGû€À1%„ùŸµ@ZÞœzõë×@K*T©R¥LÀ°ÅӋ-yÀ?9: +/L€À0±ëùKrª@[*T©R¥J@K*T©R¥LÀ°l)œf²ýÀ>Å$üαP€À0BŒXðª@[uëׯ^œ@K*T©R¥LÀ°H­âøÀ>R㊏·¢€À/«\fåŸ8@[Áƒ 0@K*T©R¥LÀ°$‡}…DÀ=ât4…ev€À.ِÑòÒ@\ 4hÑ£@K*T©R¥LÀ°}ú3À=sÔÙ¢~]€À.hó(ª@\X±bŋ@K*T©R¥LÀ¯œèO‘ 5À=Éæ.-€À-I©$ìˆ@\€H‘"Dˆ@K*T©R¥LÀ¯yՁáõÀ<›úԞqì€À,‹Íú@\ïß¿~ýû@K*T©R¥LÀ¯6œnüÇÀ<2¹Uš‘Ì€À+Ò}X“…@];víÛ·n@K*T©R¥LÀ®ô›ê +œZÀ;Ë:Aœ¬€À+€$F<¥@]‡8pá@K*T©R¥LÀ®³lÐ5(QÀ;ey1Ô¬•€À*rXxE•Ï@]Ò¥J•*T@K*T©R¥LÀ®s, jÙÒÀ;qm²œŽ€À)Êhu‹€A@^y‹s—¿€À(‰ÜÜßü@^µjÕ«V­@K*T©R¥LÀ­·×f“ÔÛÀ9ß~ºÁýK€À'ðæKó¥6@_ @K*T©R¥LÀ­{'éÒõ‹À9‚'à’Š#€À'\•R…²n@_L™2dɒ@K*T©R¥LÀ­?RÿEWÿÀ9&o2S¢µ€À&ÌÀ6šþ@_˜0`Áƒ@K*T©R¥LÀ­Tۇ™¬À8ÌNÄGG€À&A?ïT¿£@_ãǏmîŠ@`cF4h@K*T©R¥LÀ¬ vn!]À7sEÕÈ?׀À$;œzy@`‰$H‘"@K*T©R¥LÀ«érç÷ø¡À7 ÂÐÈˀÀ#ÜUO‚@`®Ý»víÛ@K*T©R¥LÀ«³0áË$+À6ϲó¥Z¥€À#O/›³Œ@`Ô©R¥J•@K*T©R¥LÀ«}¬§‘þ²À6€÷\Š*€À"Þh»R@`útéÓ§N@K*T©R¥LÀ«HââÎnºÀ61ӓÕ߀À"p.„mi…@a @@K*T©R¥LÀ«ÐKÒ¹TÀ5ä÷† +P&€À"ƒu *@aF 0`Á@K*T©R¥LÀªáq©Ž/‡À5™u‘ÌÖ €À!åå ¯@akׯ^œ{@K*T©R¥LÀª®ÃÒ8U +À5OG„|z€À!9<„ˆ)}@a‘£F4@K*T©R¥LÀª|霺À5g4˜ýU€À ×m§°R_@a·nÝ»ví@K*T©R¥LÀªKn#{OÀ4ŸÎˆU—Ÿ€À x`èîùø@aÝ:téÓ§@K*T©R¥LÀªÀ?ŒŸÀ4xwr]ƒW€À þÊ2µf@b 0`@K*T©R¥LÀ©ê·{à:À43[õŸo€À„aWûC +@b(Ñ£F@K*T©R¥LÀ©»O­j0ŸÀ3ïv& +<{€ÀÕÁ‰˜<1@bN:téÓ@K*T©R¥LÀ©Œ‡G¿š¿À3¬À)£=€À+ô1–[Œ@bthÑ£F@K*T©R¥LÀ©^[ jÍÀ3k49qAՀÀ†Ðã¹T•@bš4hÑ£F@K*T©R¥LÀ©0È`¿MŒÀ3*Ì¢Lr»€Àæ0¯ÎÃ@bÀ@K*T©R¥LÀ©Ìz-«§À2ëƒÅóˀÀIîâ5@Kóçϟ>€ÀÀÄa^å`€€ÀaxŸ.Ö‘?òå˗.\¹@Kóçϟ>€ÀÀØŠ{šÄÀ4ÄԘ€Š€ÀavRÖáŠ@å˗.\¹@Kóçϟ>€ÀÀÁ@ ¶—À+¢E,W€Àao€¬’ý@ X±bŋ@Kóçϟ>€ÀÀœYAŸ20ÀªÆ¿ÞhԀÀac `‰©$@å˗.\¹@Kóçϟ>€ÀÀ·çŸt»¥À%“S»€ÀaRMçÂ^@Ÿ>|ùóç@Kóçϟ>€ÀÀ°ïŽ,,ŽÀ*(AŒ¶IG€Àa<÷k.@X±bŋ@Kóçϟ>€ÀÀšw>èrÛÀ/3ÜS +€Àa#'ŽžP@ ‰$H‘"@Kóçϟ>€ÀÀž…pLÙÀ2ñe&덀Àa}Œ4º@"å˗.\¹@Kóçϟ>€ÀÀ“!$/Ž©À4}ñ +ÄÛ݀À`â²µÇØß@%B… +(P@Kóçϟ>€ÀÀ†T38òÀ6Ù: +³âí€À`Œ`p”[@'Ÿ>|ùóç@Kóçϟ>€ÀÀx'û¯%€À9#[ÆÑl€À`’:ÎÏ¥@)û÷ïß¿~@Kóçϟ>€ÀÀh§œCÀ;ZþÇ»ž€À`doDßѶ@,X±bŋ@Kóçϟ>€ÀÀWܝà¥YÀ=~âˆqR[€À`31©ð9ˆ@.µjÕ«V­@Kóçϟ>€ÀÀEÔÅҗÔÀ?ÜµViþ€À_ýjœšÑ`@0‰$H‘"@Kóçϟ>€ÀÀ2œ)Û{[À@ÃmmQB€À_ŽbƄà@1·nÝ»ví@Kóçϟ>€ÀÀ@§ôÀAŽpÅg0ç€À_ŒY‰Êœ@2å˗.\¹@Kóçϟ>€ÀÀÎLùÐ8ÀB™‹²©€À^Ÿñþ~ù1@4(P¡B…@Kóçϟ>€À¿äªN#T9ÀCrahmfœ€À^!€õl~@5B… +(P@Kóçϟ>€À¿µÆ”¿œ®ÀD>©6§Io€À]žëŸ/æš@6páÇ@Kóçϟ>€À¿…V,&oÀDþ0R «€À]ž70ë3@7Ÿ>|ùóç@Kóçϟ>€À¿R¢×Fä2ÀE°Ûm;vù€À\nMñŠ@8͛6lÙ³@Kóçϟ>€À¿Ÿ€Íá‚ÀFV§…Eâ>€À\”€Íă@9û÷ïß¿~@Kóçϟ>€ÀŸé#¥ìLXÀFï©Éi塀À[u±†z2@;*T©R¥J@Kóçϟ>€ÀŸ²MLcÃÀG|š+Í€ÀZæEõ€¢#@€ÀŸz9ö?r'ÀGü³¥‰€ÀZUÍQŽá÷@=‡8pá@Kóçϟ>€ÀŸAuKô +ÀHpššB€ÀYÄ»{h`È@>µjÕ«V­@Kóçϟ>€ÀŸοX—ÀHØxßÊhš€ÀY3{™Ÿ3þ@?ãǏ€ÀœË­Ì‡Ø«ÀI5ŠHJÔõ€ÀX¢oB>ŠG@@‰$H‘"@Kóçϟ>€Àœœ€¿‚HÀIˆûÒጀÀXî¶m@A @@Kóçϟ>€ÀœS–†R€ÀIÐYüf/؀ÀW‚E["†ž@A·nÝ»ví@Kóçϟ>€ÀœЖYQãÀJã5 ?ð€ÀVóžÙ¿¢z@BN:téÓ@Kóçϟ>€ÀŒØÒ€ÏâÀJD:ـÀŒ™¿iÆ\kÀJpâ4f§ì€ÀUÚԙŠ@C|ùóçϟ@Kóçϟ>€ÀŒ[L®nÀJ•Vð«ã5€ÀUPØ-üö%@D(P¡B…@Kóçϟ>€ÀŒ.IDÀJ²ӇÐ!€ÀTȰN–‚o@D«V­Zµj@Kóçϟ>€À»ÝØÍ˜ÀJDžè: +€ÀTByϕÁ@EB… +(P@Kóçϟ>€À»¯a£OuÀJÖ ë€ÀSŸLh­=@EÙ³f͛6@Kóçϟ>€À»^?á:ï,ÀJÞFÆMM€ÀS<;‰5$ @FpáÇ@Kóçϟ>€À»Ä^šï„ÀJàZn§L³€ÀRŒWT8³@G @@Kóçϟ>€ÀºßJÇ=‹ÀJܵpÅ赀ÀR>¬êÑ@GŸ>|ùóç@Kóçϟ>€ÀºŸà9”šÀJÓ¬ŸOäi€ÀQÃEDdæ@H6lÙ³fÍ@Kóçϟ>€Àº`‘d?;ÀJŐN:ú¯€ÀQJ*÷A‰ú@H͛6lÙ³@Kóçϟ>€Àº!hòêá«ÀJ²«€§®„€ÀPÓdÌÿ| @Idɓ&L™@Kóçϟ>€À¹ârÔŸAÀJ›FYj¡2€ÀP^ø|<ÈV@Iû÷ïß¿~@Kóçϟ>€À¹£¹|pÂÀJ€– (€ÀOÙՎ–žt@J“&L™2d@Kóçϟ>€À¹eEBW–ˆÀJ`ŠÀ:š€ÀNú~ë 4X@K*T©R¥J@Kóçϟ>€À¹' Ÿ­w·ÀJ<­Ë{€ÀNòÑ7;8@KÁƒ 0@Kóçϟ>€ÀžéS¬ÃbZÀJÓš'Ü2€ÀMJ5QˆSà@LX±bŋ@Kóçϟ>€Àž«æiÕ oÀIë³k¿4ƀÀLyHÔ¯èš@Lïß¿~ýû@Kóçϟ>€ÀžnàO™îŽÀIŸ…9]ý€ÀK­.‚¹p@M‡8pá@Kóçϟ>€Àž2HSdŸ7ÀIŽ€ÝÆZ\€ÀJåäL0m@N€À·ö$êû€þÀI[ڌ²€ÀJ#hÐWîî@NµjÕ«V­@Kóçϟ>€À·º|F_ÏÀI&ÆHùœî€ÀIe·pÍoÇ@OL™2dɒ@Kóçϟ>€À·SDäoÀHïv<’›•€ÀH¬ÊE Ԓ@OãǏ€À·D¯•ŸJ˜ÀH¶ i\k€ÀGø™¹ez@P=zõëׯ@Kóçϟ>€À· +•ž£A{ÀHzã×Õ#&€ÀGI“GŒ@P‰$H‘"@Kóçϟ>€À¶Ñ ñ3ïÀH=þ&%ö؀ÀFžGÿ9?A@PÔ©R¥J•@Kóçϟ>€À¶˜(W yÀGÿ•æ©L€ÀEø›V§@Q @@Kóçϟ>€À¶_©Ì°ÏÊÀG¿ÕaJ[=€ÀEVe‹ž@Qkׯ^œ{@Kóçϟ>€À¶'Üwóg ÀG~å-ž'y€ÀD¹:‹§î<@Q·nÝ»ví@Kóçϟ>€Àµð©É9ì ÀG<ì*ĵӀÀD ~ xõÁ@R 0`@Kóçϟ>€ÀµºÜŽ1ÀFúw˶€ÀCŒHu·%@RN:téÓ@Kóçϟ>€Àµ„™·üÀF¶rnd€2€ÀBüaô0@Rš4hÑ£F@Kóçϟ>€ÀµNÆ®Å"$ÀFr6¢ÌM?€ÀBp(=Ÿ@Rå˗.\¹@Kóçϟ>€Àµ +}ßÀF-{àŒn€ÀAèi篭Á@S1bŋ,@Kóçϟ>€ÀŽæ8ÛdÀEè`/œ@€ÀAd· ÷šq@S|ùóçϟ@Kóçϟ>€À޲‘ýô¹ÀE¢ÿÓ¯:â€À@äú ìº@Sȑ"D‰@Kóçϟ>€ÀŽÇÙÉýÀE]uZe*Ž€À@i¿má@T(P¡B…@Kóçϟ>€ÀŽM¢šˆjÀEٜëS€À?â Ôgï6@T_¿~ýû÷@Kóçϟ>€ÀŽ ºÏ„ÀDÒCÍ5Ë€À>ù@¿7 ä@T«V­Zµj@Kóçϟ>€À³ëC€lþtÀDŒÉý·€À>§zäî€@TöíÛ·nÝ@Kóçϟ>€À³» +xýÀDG~ŸÄ s€À==xê@UB… +(P@Kóçϟ>€À³‹t°ÂÜÀDv u}&€À€À³\œ†õaÀCœÀyk+®€À;œ*š±—ô@UÙ³f͛6@Kóçϟ>€À³.0dµJ+ÀCym·©øé€À:Õ|­ºê‹@V%J•*T©@Kóçϟ>€À³€«®4ÀC5Œ!µ °€À:&֞W@VpáÇ@Kóçϟ>€À²Óo_–%ÀBò(Ï¡‚€À9Z¿Sp4Ý@VŒxñãǏ@Kóçϟ>€À²Šý~F= ÀB¯OŠD$€À8ŠS I‹ @W @@Kóçϟ>€À²{(žNÀBm g`|̀À7÷ œâzþ@WS§N:t@Kóçϟ>€À²OïÁi‚ÙÀB+eÁ«R€À7N{šø­ø@WŸ>|ùóç@Kóçϟ>€À²%Q)©îÀAêg`•Õÿ€À6ª·If@WêÕ«V­Z@Kóçϟ>€À±ûK*ìÂwÀAªûÂÎR€À6 )bÁX¬@X6lÙ³fÍ@Kóçϟ>€À±ÑÜQdÀAj~f…€À5rйH01@X‚ @@Kóçϟ>€À±©× ô’ÀA+ œOOA€À4Þ'U¥/@X͛6lÙ³@Kóçϟ>€À±€ŒúÇRœÀ@íƒÓ$k€À4N5}Ð@Y2dɓ&@Kóçϟ>€À±Yîÿ§ŒÀ@°,„Ò‡²€À3ÂÊh!`@Ydɓ&L™@Kóçϟ>€À±1äܘ4ŽÀ@sž~M‹€À3;áDlûˆ@Y°`Áƒ @Kóçϟ>€À± Nä<îëÀ@7ÜêÆ?7€À2¹>SÑDŸ@Yû÷ïß¿~@Kóçϟ>€À°åEÎÐWÀ?ùÔŸ`_€À2:œ&ú|@ZG€À°¿Å£¶ßŸÀ?…‘Ü¡Yœ€À1À:RPqß@Z“&L™2d@Kóçϟ>€À°šÎ€Z!À?ô>(íP€À1I“vwZ@ZÞœzõë×@Kóçϟ>€À°v]Â[ŸÀ>¡þbȏ€À0Ö§6c[ÿ@[*T©R¥J@Kóçϟ>€À°Rqtd¿À>2°ÁÑ€À0gU9«Êª@[uëׯ^œ@Kóçϟ>€À°/¡aªÀ=ŠΖšÖ€À/öüQȇ@[Áƒ 0@Kóçϟ>€À° R$GkÀ=Yˆ™ÿ€À/&Tc®â@\ 4hÑ£@Kóçϟ>€À¯Óg!ìZaÀ<î¿êŒ^ЀÀ.[ºšK>@\X±bŋ@Kóçϟ>€À¯ŠÒt1À<†Öîá'€À-—_®ooç@\€H‘"Dˆ@Kóçϟ>€À¯L£Ð€SVÀ<þžeí€À,Ù=:Ií@\ïß¿~ýû@Kóçϟ>€À¯ +®;Ÿ™TÀ;¹¬ñÜô€À, ô?:O«@];víÛ·n@Kóçϟ>€À®ÉŠ9ýÖ À;Ué¿Å6€À+nQiêi€@]‡8pá@Kóçϟ>€À®‰‡ø(5À:óÂú¿±z€À*Á#'x‰;@]Ò¥J•*T@Kóçϟ>€À®JO«#"À:“6/ñz €À*9™õ‰r@^€À® ù—¬À:4>ÔEЀÀ)vfŒª%@^iÓ§N:@Kóçϟ>€À­Îï€ð•À9ÖØÃD^€À(Ø}h6ÉÚ@^µjÕ«V­@Kóçϟ>€À­‘å~-]À9zÿšÉÀ€À(?S&¡@_ @Kóçϟ>€À­ViöÀ9 ®¿ËN €À'ªŸG[Nø@_L™2dɒ@Kóçϟ>€À­-GøGÝÀ8ÇáhMzJ€À'–ÃVm%@_˜0`Áƒ@Kóçϟ>€À¬á %óìÀ8p’ t€À&޶'í¹@_ãǏ€À¬§µ‚5ò3À8œPπÀ¬o(ç6âÀ7Æ\Càu_€À%ƒ5:žC@`=zõëׯ@Kóçϟ>€À¬7aëÛœÀ7sj++˜ +€À%NŸÛ$Ý@`cF4h@Kóçϟ>€À¬]3«ljÀ7!á£Ý )€À$‡!ÿ†A@`‰$H‘"@Kóçϟ>€À«Ênù¹ØÀ6Ñœ; ­Á€À$ŽÚçt€@`®Ý»víÛ@Kóçϟ>€À«”[¢7À6‚÷qœ'ˆ€À#™vYS³Ï@`Ô©R¥J•@Kóçϟ>€À«_»Â áŠÀ65Š¿Îø}€À#'º¢²LÈ@`útéÓ§N@Kóçϟ>€À«+Ÿ{¢{œÀ5éq˜|’À€À"¹>ôH@a @@Kóçϟ>€Àªø5l øÚÀ5žŠl\€À"Mç–Æ|_@aF 0`Á@Kóçϟ>€ÀªÅz…gÀ5U#«7€À!å™Ô¢‚ì@akׯ^œ{@Kóçϟ>€Àª“kÅPú÷À5 ãȲ満À!€;ð±ìÅ@a‘£F4@Kóçϟ>€Àªb8Æ'YÀ4Åá=G€€À!µo[@a·nÝ»ví@Kóçϟ>€Àª1FøIí{À4€‡ …‰€À œírR~›@aÝ:téÓ§@Kóçϟ>€Àª+)¶§¬À4;~-òÑ€À `ÍæðèR@b 0`@Kóçϟ>€À©Ñ¯ÿÇjÀ3øÃ[GÀÀ @G›ø@b(Ñ£F@Kóçϟ>€À©¢Ò¹þº‹À3µÎäf€Ÿ€À\^X÷£:@bN:téÓ@Kóçϟ>€À©t€ˆÝ$À3t­; ô€À± íëÞ@bthÑ£F@Kóçϟ>€À©FçúçÀ34š€À©ÓyڋzÀ2õ»wÖeò€Àh8ÖœÃ@bÀ@Kóçϟ>€ÀšíS;%NÀ2·àûŒäA€ÀÊlá—È­@Lœzõë×°ÀÀY †Gx À`žŸ}ô!0?òå˗.\¹@Lœzõë×°ÀÀXO—ŒÏèÀ¬5ùK¿€À`œŽé3$R@å˗.\¹@Lœzõë×°ÀÀV"`ø6;À€M²©³€À`–tTš@ X±bŋ@Lœzõë×°ÀÀRƒ¡@‡ÎÀbÌ.#­+€À`‹%ÜGÏd@å˗.\¹@Lœzõë×°ÀÀMv:îrÀ#…Qÿ-“€À`|ønì@Ÿ>|ùóç@Lœzõë×°ÀÀFþ&é­À(IP\ +Gñ€À`h¹1x@X±bŋ@Lœzõë×°ÀÀ? nƒâ™À,úÞo9Tk€À`QYߐ²@ ‰$H‘"@Lœzõë×°ÀÀ5ãýSÀ0Ë2\£ €À`6›Eð@"å˗.\¹@Lœzõë×°ÀÀ+M€ +À3 h…—æ€À`á Vt¥@%B… +(P@Lœzõë×°ÀÀfPk~ÙÀ5?¢)Ë?€À_è º7þ@'Ÿ>|ùóç@Lœzõë×°ÀÀ7]vÓ+À7cˆwv€Ä€À_›u y%]@)û÷ïß¿~@Lœzõë×°ÀÀɯþÁˆÀ9vÜVKˀÀ_Hç÷l@,X±bŋ@Lœzõë×°À¿èNÓҝãÀ;xuÎø(€À^î\ËDu@.µjÕ«V­@Lœzõë×°À¿Æ¶ªèÀ=gD)FÒ€À^ŽšìŽ@0‰$H‘"@Lœzõë×°À¿¢áª~š]À?BNè-Îo€À^)Zԝ‡@1·nÝ»ví@Lœzõë×°À¿|ç²äÜ5À@„[ÔЃŸ€À]ŸÜŸHÞZ@2å˗.\¹@Lœzõë×°À¿Tá—>Ê8ÀA\ÞZÙ|“€À]O™uTƒÕ@4(P¡B…@Lœzõë×°À¿*èýê§£ÀB*]÷ƒ¡k€À\Ü?Ÿ—@5B… +(P@Lœzõë×°ÀŸÿ8¡IþÀBìšàݖ+€À\d‡ì@6páÇ@Lœzõë×°ÀŸÑŠúâÀC£g՞Œ^€À[é QSTB@7Ÿ>|ùóç@Lœzõë×°ÀŸ¢YÉ3QcÀDN«q €À[kÃ/ˆåŒ@8͛6lÙ³@Lœzõë×°ÀŸq¢’[ÿ«ÀDî^X°â<€ÀZëe“‚{g@9û÷ïß¿~@Lœzõë×°ÀŸ?¹ø>ÀE‚m(ã€ÀZhû|‚?Ç@;*T©R¥J@Lœzõë×°ÀŸ Jq÷ìÀF ^”΋#€ÀYäõ–ŽŸÜ@µjÕ«V­@Lœzõë×°ÀœjÖhhí«ÀGcœ£€®€ÀXSVט@?ãǏ|ùóç@Lœzõë×°Àº1J¥‰T9ÀI–I²÷€ÀQYB»ð@H6lÙ³fÍ@Lœzõë×°À¹ôâÆ%ÀIÔ*j€ÀPæR®Lô–@H͛6lÙ³@Lœzõë×°À¹ž”&ÀI€ØwžŽ€ÀPuv¯eš@Idɓ&L™@Lœzõë×°À¹|iSˆ åÀIomEY{¿€ÀP¶ïºX@Iû÷ïß¿~@Lœzõë×°À¹@l·T@hÀIYÓ¬òŠ€ÀO44•ÍØ@J“&L™2d@Lœzõë×°À¹§ÓœÔÕÀI@JÙ €ÀN_LڎÄ'@K*T©R¥J@Lœzõë×°ÀžÉ#®xߟÀI# ‘Ø¢u€ÀMŽ¿ÞÞy6@KÁƒ 0@Lœzõë×°ÀžèÁüÓ~ÀITý…ö,€ÀLÂ”Ó Óõ@LX±bŋ@Lœzõë×°ÀžRÿU³ÀHÞ[M匏€ÀKúÑø)@Lïß¿~ýû@Lœzõë×°Àžmá‡vŸÀH·U­úŠq€ÀK7wÝÝŽ[@M‡8pá@Lœzõë×°À·ÞmÜOÀE}º{5€A€ÀAÌ]c{²Ê@S1bŋ,@Lœzõë×°Àޝ]tÒ9¬ÀE=ÌÊ “ú€ÀALS==x'@S|ùóçϟ@Lœzõë×°ÀŽ}|%ËÁ²ÀDý~dž΀À@Ðí_JO@Sȑ"D‰@Lœzõë×°ÀŽL3é*(ÀDŒé,8Á€À@W[}2¡@T(P¡B…@Lœzõë×°ÀŽ‚ÒpåOÀD|%r‹A€À?Ċú%µ@T_¿~ýû÷@Lœzõë×°À³ëk«2ÀD;J ™å€À>áZúŽ †ú@TöíÛ·nÝ@Lœzõë×°À³ý#Û×ÀC¹Ÿ²–z€À=/AT’)@UB… +(P@Lœzõë×°À³^œ%kF"ÀCx÷#B±€À<`³ù́@UŽ8páÃ@Lœzõë×°À³1 ÎæµûÀC8ƒ3'ò€À;—µU]@UÙ³f͛6@Lœzõë×°À³îi‹YjÀBøTŒ?‹{€À:ÔOh¬ÉŠ@V%J•*T©@Lœzõë×°À²×jAy~öÀBžy=9ç€À:ƒ˜2€@VpáÇ@Lœzõë×°À²«|Ý²ÅÀBxþkñ±¢€À9`‡åTz@VŒxñãǏ@Lœzõë×°À²€$7°lŽÀB9ðwW/€À8¯1:v;@W @@Lœzõë×°À²U`TTþÀAûZ¢^(L€À8Wöhe‚@WS§N:t@Lœzõë×°À²+/°³ÀAœFìþۀÀ7\Ò +ŠÑI@WŸ>|ùóç@Lœzõë×°À²‘ pÎ×ÀAŸ‡ŒM{€À6»våþÊ1@WêÕ«V­Z@Lœzõë×°À±ØƒwjÀABÉ­û¶L€À6¡â&@X6lÙ³fÍ@Lœzõë×°À±°hŒŒÙÀAo¿“Lê€À5‡¢öUe@X‚ @@Lœzõë×°À±ˆf‡À@Ê·L+ˆ%€À4ôÚřWù@X͛6lÙ³@Lœzõë×°À±`¯³À@Š! ȀÀ4f£%d®@Y2dɓ&@Lœzõë×°À±9Õ/@IÀ@UAU=&ï€À3ÜÖg’@Ydɓ&L™@Lœzõë×°À±„~šûÀ@U®Þ,€À3WPŸ3ùò@Y°`Áƒ @Lœzõë×°À°í»GÓ›À?ÅálF€À2ÕîÀ(@Yû÷ïß¿~@Lœzõë×°À°ÈwÜ]UxÀ?TŒÀ2^À€À2XŽ¡ˆ|µ@ZGår«.ÑE€À1ß@ä@Z“&L™2d@Lœzõë×°À°{‹‡Ø À>wÒ¥Zí€À1iOƒDYè@ZÞœzõë×@Lœzõë×°À°[¿*ðveÀ> ®G£­ö€À0÷0Ž9¹H@[*T©R¥J@Lœzõë×°À°8¡hÆÀ=¡ +Œ±ì€À0ˆ”Ðz»@[uëׯ^œ@Lœzõë×°À°Á(͎À=7ç3AـÀ0[ÓVC+@[Áƒ 0@Lœzõë×°À¯æ÷ò~НÀ<ÐFÏ@*K€À/jÖªñ%‰@\ 4hÑ£@Lœzõë×°À¯£`“ç¶ÓÀ€À+ ˜4¹ê@]Ò¥J•*T@Lœzõë×°À® ü6'¹À:%ùÔE`€À*aè˜Syú@^œ @a·nÝ»ví@Lœzõë×°ÀªŽå·$À4@ê4Úî€À!'CÝ'ã@aÝ:téÓ§@Lœzõë×°À©ç,AT+À3þ«ÏNƀÀ ¢døë0§@b 0`@Lœzõë×°À©ž@7¹ŽÀ3ŒQűöä€À G1×ŒÃ@b(Ñ£F@Lœzõë×°À©‰î)È ÞÀ3{®‹ÇÅ €ÀÜðn†Šö@bN:téÓ@Lœzõë×°À©\3ƒÛëÑÀ3< &ó4πÀ0IŽDw@bthÑ£F@Lœzõë×°À©/ œ³òÞÀ2ý¡ÉW:”€ÀˆFÌ4±@bš4hÑ£F@Lœzõë×°À©zZ\€‰À2À.¯}•$€ÀäÁÌ:‚@bÀ@Lœzõë×°ÀšÖvèBpÀ2ƒÂ!])݀ÀE—äO“ˆ@M‡8päÀ¿åº§u†€€À_š¡R!`§?òå˗.\¹@M‡8päÀ¿äaSƒfÀII»¥Û€À_€«›04R@å˗.\¹@M‡8päÀ¿àUÇtóÀB]Ýø„€À_˜Ï҃;€@ X±bŋ@M‡8päÀ¿Ù™ç[ÈSÀRSLƒ0‡€À_…åûÀu@å˗.\¹@M‡8päÀ¿Ð4’†À"&ê3×NO€À_i¯æ> 2@Ÿ>|ùóç@M‡8päÀ¿Ä,µàŠÀ&—3º«Ø€À_F©\¿ôœ@X±bŋ@M‡8päÀ¿µ‰H;ÏÀ*öë|ùóç@M‡8päÀ¿aåh~‡bÀ5ËŽ;D€À^,¢¶›/@)û÷ïß¿~@M‡8päÀ¿FÿŠÇݍÀ7œœ)æ-*€À]àœÖÆ@,X±bŋ@M‡8päÀ¿)×9WßÀ9Ÿšº”ƒn€À]Žœ- @.µjÕ«V­@M‡8päÀ¿ ++¶Ä•À;p·iIœQ€À]7X: +v@0‰$H‘"@M‡8päÀŸé JL<¯À=0Ø¥åЀÀ\ÚÆáJ;í@1·nÝ»ví@M‡8päÀŸÅ“쫯ÜÀ>Üފ €À€À\yeאo@2å˗.\¹@M‡8päÀŸ ,cÏLÀ@;9Jâ‡Æ€À\•mû@4(P¡B…@M‡8päÀŸxìœy9ŸÀ@þÃâ¶r€À[©žû_Í:@5B… +(P@M‡8päÀŸO졆FŒÀA¶ãvx€À[<60bÈØ@6páÇ@M‡8päÀŸ%D-OpÀBe_yÐ¥€ÀZËtBËÜŸ@7Ÿ>|ùóç@M‡8päÀœù ͗)ÀC y†nòù€ÀZWÚöá@8͛6lÙ³@M‡8päÀœË\Ã¥”ÀC£(?ãp€ÀYáы?˜@9û÷ïß¿~@M‡8päÀœœM£;a˜ÀD2r×ô€€ÀYiœŠÓ×G@;*T©R¥J@M‡8päÀœkøãážþÀD·pµ®€ÀXð9Ší@@>µjÕ«V­@M‡8päÀŒÔDƒe“@ÀF +JŒ5€ÀW|ƒ$ïv@?ãǏ|ùóç@M‡8päÀ¹ÅL"?©€ÀJÀåq0`™@M‡8pá@M‡8päÀ·‹ŠŠü“ÐÀG—$~÷E€ÀJ + ³ ¶Ð@NBO=a@NµjÕ«V­@M‡8päÀ·ÐÀ×¶|ÀGFûš‘ €ÀHšƒ–%ŸÉ@OL™2dɒ@M‡8päÀ¶æw{›ÀGeNga€ÀGýØq0p@OãǏ1\Ji€ÀC8A“J@RN:téÓ@M‡8päÀµ m[EݗÀEGA˧ـÀB°Â»Ü0@Rš4hÑ£F@M‡8päÀŽÛmíÆ:ÀE wŒ À€ÀB-#“ Ÿþ@Rå˗.\¹@M‡8päÀŽ©÷Ø|9ÀDÒý ÒZŽ€ÀA­(QFÆO@S1bŋ,@M‡8päÀŽy ™ÄœöÀD—ï–¢Òj€ÀA0ÀºäË@S|ùóçϟ@M‡8päÀŽH­oQf\ÀD\h@oóª€À@·Ûâ=î‰@Sȑ"D‰@M‡8päÀŽÛYŠ&«ÀD €É­Œ €À@BhFù¹`@T(P¡B…@M‡8päÀ³é—†ÅˆÀCäPìØåE€À? §Éxõ@T_¿~ýû÷@M‡8päÀ³ºáQVŸÀC§îúž,ɀÀ>Ú +øÃ@T«V­Zµj@M‡8päÀ³ŒºLeåÀCkoâ·dÿ€À=ëýv™^I@TöíÛ·nÝ@M‡8päÀ³_">(>–ÀC.ç|ùóç@M‡8päÀ±ÝºætÑÀAª绲€À6ÆÕ«QI@WêÕ«V­Z@M‡8päÀ±µ¡T»[6À@ÜîòxùE€À6,у³§@X6lÙ³fÍ@M‡8päÀ±ŽŠs£À@£µê€ l€À5—zEiG@X‚ @@M‡8päÀ±gJЧ(À@kôÖ)€À5«Æ)*ú@X͛6lÙ³@M‡8päÀ±@~K nÀ@2å^g9€À4zC ÉŒG@Y2dɓ&@M‡8päÀ±|33}À?ö³ƒù.š€À3ò"eçÌ@Ydɓ&L™@M‡8päÀ°ôü›:€À?ˆÐ,Ɂ€À3nQ¡-¢@Y°`Áƒ @M‡8päÀ°Ïþ9˜À?)j…¹ª€À2îôˆµ@Yû÷ïß¿~@M‡8päÀ°« ŽþÀ>°ÆÎSÞ]€À2qú)îº@ZGF®ÙéèÆ€À1ùœ… +r@Z“&L™2d@M‡8päÀ°cùèþRlÀ=Ýç)­€À1„â߄A@ZÞœzõë×@M‡8päÀ°@ðŠTvjÀ=vtß_A€À1¯‹9A­@[*T©R¥J@M‡8päÀ°`­%tÀ=Y•£)q€À0¥åºš ·@[uëׯ^œ@M‡8päÀ¯øÀ–ÎÀ<«š•ßÕ2€À0;ikþ-§@[Áƒ 0@M‡8päÀ¯µLÀÃûšÀÐzI0@\ 4hÑ£@M‡8päÀ¯rñã×:SÀ;æ7EÓ{e€À.ßڃ‹öy@\X±bŋ@M‡8päÀ¯1|êÕ¡xÀ;…•r +€C€À.r€œ#@\€H‘"Dˆ@M‡8päÀ®ðê•@kYÀ;&T-¯è€À-`ÕqƒS@\ïß¿~ýû@M‡8päÀ®±7¢_nPÀ:ÈsM€€À,©Ò«“>Ñ@];víÛ·n@M‡8päÀ®r`ÒpÕÆÀ:kò-•K€À+ø;’ˆ®Õ@]‡8pá@M‡8päÀ®4bç¿©À:Ï·ÚX“€À+KâÜû*@]Ò¥J•*T@M‡8päÀ­÷:§ž)ˆÀ9· +qÆ5€À*€œ±Á@^œÔ_d@^iÓ§N:@M‡8päÀ­^PåöÀ9ºÕt±€À)dŸÔWû@^µjÕ«V­@M‡8päÀ­D£ÛÞIÀ8±Õ¢Ï’¢€À(Ë—É l@_ @M‡8päÀ­ +²UïkwÀ8]o{6O€À(7â%—@_L™2dɒ@M‡8päÀ¬Ñ†Ÿ“ÝrÀ8 +ZGž€À'е²î‘y@_˜0`Áƒ@M‡8päÀ¬™ •{^À7ž’ÔWb‚€À'’Ru…Û@_ãǏÀ5è~Ì'ý€À$!º”Ò5@`Ô©R¥J•@M‡8päÀ«"Š¹fËÀ5Ÿo "ii€À#¯!IÖòž@`útéÓ§N@M‡8päÀªïÎÔ]XîÀ5W$³q€À#?¹ ¥Ö>@a @@M‡8päÀªœžƒê„ýÀ5Û/Œ€À"Óg‘£Õ@aF 0`Á@M‡8päÀªŒGÝñ¯À4ËL•v“·€À"j|Cœ@akׯ^œ{@M‡8päÀª[z-·Ž&À4†ßó@穀À"€LŒT@a‘£F4@M‡8päÀª+LÎ –DÀ4Cëæì€À! \—ð@a·nÝ»ví@M‡8päÀ©ûœ –QRÀ4[XpE€À!?×íb[@aÝ:téÓ§@M‡8päÀ©ÌȒ/ŠñÀ3À:'ÈA|€À àËŽº±@b 0`@M‡8päÀ©žlšýÊÀ3€)¥‹“€À … ¬w¬v@b(Ñ£F@M‡8päÀ©pй×Ï'À3A%6×R€À +Â5G›é@bN:téÓ@M‡8päÀ©Ct}cSJÀ3(~Wh±€À©¶ö¡9@bthÑ£F@M‡8päÀ©Ózè¢ÙÀ2Æ/#–Îð€ÀˆŽ¬T(@bš4hÑ£F@M‡8päÀšêÁRÖ£®À2Š4ÔLV€€À[Ô¯wš@bÀ@M‡8päÀš¿;¯É7À2O5Eƒç—€À»w6Éu1@NP¡B… +À¿"þHh›ÿ€€À^/ù^}e?òå˗.\¹@NP¡B… +À¿!ŒhüÞ(ÀHw<; €À^,_þ1BŽ@å˗.\¹@NP¡B… +À¿÷±žrïÀ- xŠ€À^!™B3÷Ê@ X±bŋ@NP¡B… +À¿²Õ\~ÀrŽcÉÔ>€À^²3Füz@å˗.\¹@NP¡B… +À¿òK×kÆÀ è쁚ҀÀ]öÀœ¬@Ÿ>|ùóç@NP¡B… +À¿ŒG¹¹æÀ% £öJ:_€À]Öâñ%%@X±bŋ@NP¡B… +ÀŸöš/€À)!¥Q|;ò€À]°>|-@ ‰$H‘"@NP¡B… +ÀŸæè/­VÀ-%FÔ: €À]ƒ êü@"å˗.\¹@NP¡B… +ÀŸÓ° ~øÊÀ0Š€1¢|M€À]OZ²ýìŒ@%B… +(P@NP¡B… +ÀŸ¿Š»ÐÀ2w6äùäd€À]‡TÛÒ.@'Ÿ>|ùóç@NP¡B… +ÀŸš:‘à©À4Wš0jð€À\ÕÄ ìZ¥@)û÷ïß¿~@NP¡B… +ÀŸŽú;Qí¿À6*È3‹@À€À\T6Ά@,X±bŋ@NP¡B… +ÀŸsŸÛhWJÀ7ïŸ,_JX€À\E~ÌŠŽŸ@.µjÕ«V­@NP¡B… +ÀŸVu‚?óÀ9¥J=M“·€À[õÁ7³@0‰$H‘"@NP¡B… +ÀŸ70–i7rÀ;Jü]6IŸ€À[ ÖWî4Ü@1·nÝ»ví@NP¡B… +ÀŸc7aºÀ<ßÿqøðž€À[G¥O«€O@2å˗.\¹@NP¡B… +Àœóüë‡åÀ>cµ‡=•y€ÀZêR\­:{@4(P¡B…@NP¡B… +ÀœÎA#yjýÀ?՚¬€¥€ÀZ‰5€€ž€@5B… +(P@NP¡B… +Àœ§Ö#ã·JÀ@š¡kž-B€ÀZ$©"^DŽ@6páÇ@NP¡B… +ÀœÖžo†ÀAA0–Q$¢€ÀYœñfª@7Ÿ>|ùóç@NP¡B… +ÀœVXè æIÀAÞa‡íí€ÀYR­…xݑ@8͛6lÙ³@NP¡B… +Àœ+rå‚Q7ÀBr'߅)€ÀXåôÔL`/@9û÷ïß¿~@NP¡B… +ÀŒÿ:îèu~ÀBü„µD®X€ÀXw7zNÎÁ@;*T©R¥J@NP¡B… +ÀŒÑÇ..ñšÀC}ˆïŸ–œ€ÀXÌã¿9À@ +ò@>µjÕ«V­@NP¡B… +ÀŒBßBäÀDÉÃ6 _ՀÀV®·=y:2@?ãǏ¹@GŸ>|ùóç@NP¡B… +À¹[ڕF@ÀGK_Öý(€ÀP‰ø/܍X@H6lÙ³fÍ@NP¡B… +À¹$Ñ92ZaÀGLÙ¢°‰E€ÀP"„Nî)Ž@H͛6lÙ³@NP¡B… +Àžíɶ|ŸZÀGIÙ^»¿G€ÀOyf͏Í6@Idɓ&L™@NP¡B… +Àž¶ÎY¡èÀGBšéYT–€ÀN±"•@Iû÷ïß¿~@NP¡B… +ÀžèâÉÀG7X„¬6€ÀMìN@ 8Ö@J“&L™2d@NP¡B… +ÀžI"Œ-ÜÀG(H‰cΑ€ÀM*üÆþM@K*T©R¥J@NP¡B… +Àž„§ÀG¢`;z €ÀLmÏʀÀHï`'™÷@NµjÕ«V­@NP¡B… +À¶ÏŸåGû&ÀFe`Œ` €ÀHGÁ#Ýh9@OL™2dɒ@NP¡B… +À¶›e£‹œÀF>üŽ. €ÀG£ß¯&€@OãǏrHÀEì"•Ž6€ÀFgP‘çã @P‰$H‘"@NP¡B… +Àµÿ(xH ÀE¿žÕÌì€ÀE̚H[@PÔ©R¥J•@NP¡B… +ÀµËíw©ÊÀE‘ÉE*Ï7€ÀE9™µÑŒÆ@Q @@NP¡B… +Àµ™1+»,¿ÀEbV7ÖaˀÀDšA/qÊR@Qkׯ^œ{@NP¡B… +Àµfç}x$ÀE1ƒÇÙW€ÀD‹ìÝ)@Q·nÝ»ví@NP¡B… +Àµ5ÎîmÓÀDÿqðªuã€ÀCn lÇè@R 0`@NP¡B… +ÀµŽ&r®ÀDÌCmj'g€ÀC ßæl€ú@RN:téÓ@NP¡B… +ÀŽÒЖã[ŸÀD˜ÖÊ@N€ÀB†Õ;]‡9@Rš4hÑ£F@NP¡B… +ÀŽ¢iTÚ!ÉÀDc +蚶€ÀBAž ]x@Rå˗.\¹@NP¡B… +ÀŽr€OïØ^ÀD-9¥ÎH¿€ÀA‹.€í@S1bŋ,@NP¡B… +ÀŽC6{ÀCöÀ³!;Œ€ÀAJ¥ýH@S|ùóçϟ@NP¡B… +ÀŽ/vqÃéÀC¿¹þ†n€À@œÊu5d@Sȑ"D‰@NP¡B… +À³åÊETVúÀCˆ:ûûò€À@*ˆVF@T(P¡B…@NP¡B… +À³·èž¢&#ÀCP]Œ@$í€À?vèöì›@T_¿~ýû÷@NP¡B… +À³Š‹IQ‚!ÀC7 Ž~x€À>žýGԞ@T«V­Zµj@NP¡B… +À³]²ÚdwcÀBßÛmМ€À=Í,ZZú@TöíÛ·nÝ@NP¡B… +À³1_·äM+ÀB§^$9R€À=TR»Š%@UB… +(P@NP¡B… +À³’É9ÀBnÑ=È€À<;RÙôÛ@UŽ8páÃ@NP¡B… +À²ÚJ̙…ÀB6Eš~wž€À;{BfY @UÙ³f͛6@NP¡B… +À²¯‡“$JUÀAýÊ÷d;0€À:ÀH¥å_q@V%J•*T©@NP¡B… +À²…JW%é6ÀAÅoù †%€À: +ú[4Y@VpáÇ@NP¡B… +À²[’ÑÀAB6p΀À9ZöT9cÚ@VŒxñãǏ@NP¡B… +À²2^+@1OÀAUNBZ?ô€À8°®Âš@W @@NP¡B… +À² ®*þ¯ÀAŸ¹Å®ü€À8 +DR/ÒŽ@WS§N:t@NP¡B… +À±áVDÈSÀ@æAKÆýu€À7iP¿ËäŒ@WŸ>|ùóç@NP¡B… +À±¹Öä>ãÀ@¯<Æh‰Œ€À6Í̲£@WêÕ«V­Z@NP¡B… +À±’­õ\OÀ@x›!tÊK€À65‰®”º@X6lÙ³fÍ@NP¡B… +À±l–«lÈÀ@Bd‰tƒ€À5¢sàl@X‚ @@NP¡B… +À±EÜÂ>u\À@  j~ۀÀ5¹fŸ@X͛6lÙ³@NP¡B… +À± 2aœœ(À?®ªõ”oG€À4‰;|‹Èµ@Y2dɓ&@NP¡B… +À°ûO?kÇÀ?EŠ £Z€À4ÚzqzÒ@Ydɓ&L™@NP¡B… +À°ÖTXÑ6À>܅d®3±€À3€vò¢ù¢@Y°`Áƒ @NP¡B… +À°²=mÏÀ>u +(#Q"€À3òdÿ>•@Yû÷ïß¿~@NP¡B… +À°ŽaŽJtŒÀ>ªZ&Ä­€À2‡.øg,@ZG¶@^µjÕ«V­@NP¡B… +À­fwçÀ8Meݹ€À) °&>@_ @NP¡B… +À¬ä^_Ö 'À7ûáò;—þ€À(uV™-ôó@_L™2dɒ@NP¡B… +À¬¬ÈÍ@zÀ7«—ã ÝڀÀ'å64'ń@_˜0`Áƒ@NP¡B… +À¬t‰k?xÙÀ7\„| h€À'Y,kr®@_ãǏ„í'@a @@NP¡B… +ÀªŸâ:ZâîÀ4ɉ/»¬€À#œ±Šë@aF 0`Á@NP¡B… +Àªo»{€ÄÀ4…î,7LÀÀ"ŠðÀe@akׯ^œ{@NP¡B… +Àª>몠á”À4Ce\Ðsô€À"@"ْŠ$@a‘£F4@NP¡B… +Àª[„Œf(À4êÝ]O€À!ÜõÎÀ&@a·nÝ»ví@NP¡B… +À©àdÏÒ)2À3ÁzÒÅý€À!zÅÓWŽV@aÝ:téÓ§@NP¡B… +À©² ±ÝÀ3‚[¥åø€À! +ño°ô@b 0`@NP¡B… +À©„9þº€ûÀ3Cª’\çV€À ¿Ö‰”×@b(Ñ£F@NP¡B… +À©Wsç-À3BŽßl_€À f‰DAÙ@bN:téÓ@NP¡B… +À©*X« À2ÉÕhl캀À ±‹ëêq@bthÑ£F@NP¡B… +Àšþ<ºfÜÀ2Ž_7i€Às5ªD>@bš4hÑ£F@NP¡B… +ÀšÒ¬«ä&ÐÀ2SÜ>Øô€ÀÍ|”€ép@bÀ@NP¡B… +Àš§¥¹æý À2H ŽQ€À,ÜIáÓ@O4hÑ£HÀŸi4—C#€€À\Ð€?òå˗.\¹@O4hÑ£HÀŸhZcl1¿ÿĝU”Øù€À\Íš†D®h@å˗.\¹@O4hÑ£HÀŸd„qEÃ>À¹Æ@ƀÀ\Ã×—@ X±bŋ@O4hÑ£HÀŸ^«@ò(UÀœÔ éOõ€À\³†vŒÍá@å˗.\¹@O4hÑ£HÀŸV€Á·ŠDÀŽ°Þ *å€À\œÉšš\·@Ÿ>|ùóç@O4hÑ£HÀŸL +v±z¶À#¥) w˜€À\º<4œ @X±bŋ@O4hÑ£HÀŸ?Ob 7nÀ'uÝ瀧€À\\xÚ1ç8@ ‰$H‘"@O4hÑ£HÀŸ0WúJ)>À+7¿¢Á€À\3,6ì,ö@"å˗.\¹@O4hÑ£HÀŸ.Ï;QÀ.ætC\•€À\䊁º@%B… +(P@O4hÑ£HÀŸ ÜÚN™À1@ñ>÷ȀÀ[Ï(ÑjP&@'Ÿ>|ùóç@O4hÑ£HÀœöp±-Î'À3§~æUـÀ[”ÚÙ+;Ž@)û÷ïß¿~@O4hÑ£HÀœÞ÷*8ưÀ4ºhô‡Âð€À[UR\9DÙ@,X±bŋ@O4hÑ£HÀœÅ~ë¹£YÀ6dSÎç+{€À[ÎÝ4»n@.µjÕ«V­@O4hÑ£HÀœª›‘Þ À8™5Ž÷C€ÀZǓ¡z)@0‰$H‘"@O4hÑ£HÀœŒÑÈP *À9Ž~'o9š€ÀZyçP¡`@1·nÝ»ví@O4hÑ£HÀœmŸÑ8xyÀ; \b$⩀ÀZ(Ž>Lã@2å˗.\¹@O4hÑ£HÀœLðÍ;ÑÀ<|£H‚Nä€ÀYÒd‰•Qû@4(P¡B…@O4hÑ£HÀœ*zpå-IÀ=ÛØ¹LVº€ÀYy(€PW@5B… +(P@O4hÑ£HÀœnó])·À?*™ÂŸKú€ÀY¯1 Cé@6páÇ@O4hÑ£HÀŒàáòžÀ@4MŽø³€ÀXœIC µî@7Ÿ>|ùóç@O4hÑ£HÀŒ¹çWÝgœÀ@ÊÔ¯aRȀÀX[G ï‹í@8͛6lÙ³@O4hÑ£HÀŒ‘“:jˆBÀAXÔdq×¶€ÀWöúÇO/@9û÷ïß¿~@O4hÑ£HÀŒgùŐdÀAÞJ`[|݀ÀW²ê{@;*T©R¥J@O4hÑ£HÀŒ=/‰OØÀB[?jÈà]€ÀW(»@÷*@µjÕ«V­@O4hÑ£HÀ»¶n»ôjÖÀC °‰‚Œ€ÀUé¢é2[@?ãǏ|ùóç@O4hÑ£HÀžôêúI_œÀF<šÿt«€ÀP$ú÷j—@H6lÙ³fÍ@O4hÑ£HÀžÀ\]XŠ`ÀFB+[ô¢€ÀO…Ž€G[n@H͛6lÙ³@O4hÑ£HÀž‹ÅèXßöÀFCVŽì燎ÀNÄWùr«`@Idɓ&L™@O4hÑ£HÀžW1›}mÀF@a“b!š€ÀN÷íå4)@Iû÷ïß¿~@O4hÑ£HÀž"šóëÀF9‚xۀÀMJª”/Ë€@J“&L™2d@O4hÑ£HÀ·î4ô8jnÀF.íx Sk€ÀL’ƒQ3“ª@K*T©R¥J@O4hÑ£HÀ·¹Þ#£JÏÀF Õ=ìÙw€ÀKݒò!"@KÁƒ 0@O4hÑ£HÀ·…¬“÷»‰ÀFjôåãå€ÀK+çÝz?J@LX±bŋ@O4hÑ£HÀ·Q§ä•€ÀEúÞY82€ÀJ}Žê@OãǏõ¹@Rš4hÑ£F@O4hÑ£HÀŽiàÇÀCœÐkIG‹€ÀAÞÚÓÈp@Rå˗.\¹@O4hÑ£HÀŽ;x/ÅjÛÀCŒ_斪 €ÀAfu”& d@S1bŋ,@O4hÑ£HÀŽ …òºÚÀCZ5&¥G€À@ñ7züAŸ@S|ùóçϟ@O4hÑ£HÀ³à +ú8=ŽÀC'ixCa€À@쯚è@Sȑ"D‰@O4hÑ£HÀ³³§.ÈQÀBôJ¶Ëz€À@ý­u‚øS©ö@T«V­Zµj@O4hÑ£HÀ³.à9.-iÀBW²Š6€À=šý6Ÿ[@TöíÛ·nÝ@O4hÑ£HÀ³Ê/Õ\£ÀB# àÜÄx€À<âœ3HÞ@UB… +(P@O4hÑ£HÀ²Ù0¹ î»ÀAî€À9üÂo$#†@VpáÇ@O4hÑ£HÀ²3«Ãâ¯ÀA͖9€À9PVGš@VŒxñãǏ@O4hÑ£HÀ² ‚UÔfíÀ@æ*Fìú €À8šÔøo;@W @@O4hÑ£HÀ±ãÕ +\}±À@±¶VO–€À8¹S¹È@WS§N:t@O4hÑ£HÀ±Œ£cŒKÆÀ@}|›&è"€À7hÌcÏ0@WŸ>|ùóç@O4hÑ£HÀ±•ìÌyšxÀ@I‡m",€À6Κïe(@WêÕ«V­Z@O4hÑ£HÀ±o°˜k –À@Þý í.€À69&ªÛ@X6lÙ³fÍ@O4hÑ£HÀ±Iî‘gÃÀ?Ń\ûù€À5šÎÉTè@X‚ @@O4hÑ£HÀ±$€BÑ +OÀ?_0=$Tƒ€À5Aa@@X͛6lÙ³@O4hÑ£HÀ°ÿÒg !À>ú> —}€À4“Ç ðé„@Y2dɓ&@O4hÑ£HÀ°Ûw•Ï…À>•ÆQ›€€À4C–r°‚@Ydɓ&L™@O4hÑ£HÀ°·’†æòsÀ>2^ +Ÿ|"€À3Ž˜‹Ei…@Y°`Áƒ @O4hÑ£HÀ°”"l͍À=ÏáÔÖ€À3ª F”@Yû÷ïß¿~@O4hÑ£HÀ°q&ØNxÀ=n[1éA€À2˜\‚=e@ZGŒ@\ïß¿~ýû@O4hÑ£HÀ®V:tÛÀÀ9ÙHΣi€À-<' …Ö@];víÛ·n@O4hÑ£HÀ®XÔvŸÀ9ƒ»]&O€À,io +'ÍT@]‡8pá@O4hÑ£HÀ­Ý®ù§Õ'À9/Sæ»D€À+Ÿ¥µÏzô@]Ò¥J•*T@O4hÑ£HÀ­¢“ 8þúÀ8Ü«ix¥€À+žFb[@^ÆÀ5M=Ø +nK€À$™Ý×òëŠ@`Ô©R¥J•@O4hÑ£HÀªãÍ ÿ0äÀ5vûj €À$&äBÑ¡@`útéÓ§N@O4hÑ£HÀª²n¢5¥À4ĺ–Ïa΀À#·çŸXU@a @@O4hÑ£HÀª®hÂÒèÀ4‚A&üW€À#J3}›`@aF 0`Á@O4hÑ£HÀªQІyËoÀ4@[æÜìb€À"àJªÁ>@akׯ^œ{@O4hÑ£HÀª"”,¶ÁÀ3ÿ²Gî,p€À"y8¬ŒÒ˜@a‘£F4@O4hÑ£HÀ©ó2“ý!À3À*²µ€À"æŠÀ¯@a·nÝ»ví@O4hÑ£HÀ©Ä± +H·À3Yò² ç€À!³>ïW8™@aÝ:téÓ§@O4hÑ£HÀ©–æËßæÒÀ3C€z?'p€À!T,“Ö8º@b 0`@O4hÑ£HÀ©i­0}À3ä=FY€À ÷›RÍŒ@b(Ñ£F@O4hÑ£HÀ©=÷tÀ2ËÆè¡£€À w–-@bN:téÓ@O4hÑ£HÀ©âë6"5À25Ÿâ€À E®mµà4@bthÑ£F@O4hÑ£HÀšåMÜwæœÀ2V@E¯ò€Àà[$X$@bš4hÑ£F@O4hÑ£HÀšº@€ª8«À22@‚4f€À9Æj*0@bÀ@O4hÑ£HÀš¹%|ŽëÀ1å |A€À—|¢é€+@OãǏ<|Àœ·Ã„ÚK€€À[‰<Оï??òå˗.\¹@OãǏ<|Àœ¶«áy–¿ý®+—:” +€À[†>™¿a"@å˗.\¹@OãǏ<|Àœ³b7«v¥À €‚ÙÉ݀À[}G= … @ X±bŋ@OãǏ<|Àœ­ëBñ†'À/W'¥ìJ€À[n`‡¯Ý.@å˗.\¹@OãǏ<|ÀœŠI°Q\À~Q%ƒ€À[Yš©y×@Ÿ>|ùóç@OãǏ<|Àœœ‚k’QŸÀ"\íV” ~€À[? ÿ 1Ê@X±bŋ@OãǏ<|Àœ›ž„À%ï‰È̀À[Ð˶šÙ@ ‰$H‘"@OãǏ<|Àœ‚'µ¹¿À)s_õ¶U%€ÀZù +ç}v9@"å˗.\¹@OãǏ<|Àœr‰J¶ÍÀ,絎ÙQK€ÀZÍáeÊ @%B… +(P@OãǏ<|Àœ`|Þ,hvÀ0%Í~ð{€ÀZ€8ÔÝ@'Ÿ>|ùóç@OãǏ<|ÀœLpGÓéWÀ1Ìdõ(º€ÀZhÒ¢Iz@)û÷ïß¿~@OãǏ<|Àœ6uö׿}À3hùgU_‘€ÀZ-Üʺ6å@,X±bŋ@OãǏ<|Àœ›_€À4úrþÂo€ÀY@.µjÕ«V­@OãǏ<|Àœí~þÀ6~œdmt€ÀY«ÓžÄA@0‰$H‘"@OãǏ<|ÀŒé|ßÿÑÍÀ7ö…P€€ÀYd€L @1·nÝ»ví@OãǏ<|ÀŒÌXDôÀ9`¿ÚIx€ÀYNŽ{iŠ@2å˗.\¹@OãǏ<|ÀŒ­Ãœ-ZÀ:Œç⌭ò€ÀXʂ©È¢~@4(P¡B…@OãǏ<|ÀŒ4¿Ñõ°À< +Œ$²€ÀXxa¬:&@5B… +(P@OãǏ<|ÀŒkXl†áÀ=IO«Åy€ÀX#2ˆQöj@6páÇ@OãǏ<|ÀŒH ˆþ¹øÀ>xêÀÀWË<Ò?3`@7Ÿ>|ùóç@OãǏ<|ÀŒ#cÌlÀ?™'©”0X€ÀWpÈ[¡ @8͛6lÙ³@OãǏ<|À»ýnd…ÕTÀ@Tô…œEπÀW¢¶Û|@9û÷ïß¿~@OãǏ<|À»Ö@¯úe^À@Ցc>%¬€ÀVµ€J;Œå@;*T©R¥J@OãǏ<|À»­ìU%êBÀANn;€É€€ÀVU8ŠÝC@µjÕ«V­@OãǏ<|À».œ³[¡ÀB‹-xEÐ*€ÀU,ñÞG…@?ãǏµ@€ÀRjðæ`H@D(P¡B…@OãǏ<|À¹¹ùO†ÒPÀD¡Ã|·ð€ÀRiådØ@D«V­Zµj@OãǏ<|À¹‰ ßÄÁFÀDÈz)où°€ÀQ£íe–Þ€@EB… +(P@OãǏ<|À¹W͇l`ÀDé± ËL{€ÀQAŽÄtŽå@EÙ³f͛6@OãǏ<|À¹&Dþ)Î1ÀE¢a.€ÀPà|‡Ï@FpáÇ@OãǏ<|Àžô€uƒsÀE‡)Þ$S€ÀP˜õŽž@G @@OãǏ<|ÀžÂ‹—†hŸÀE.—Â^®Ž€ÀP .:¬Më@GŸ>|ùóç@OãǏ<|ÀžqŠö”õÀE< Pù_€ÀOƒÐaå\,@H6lÙ³fÍ@OãǏ<|Àž^<õÂP©ÀEEœÎÈF€ÀNɯƒ)ª1@H͛6lÙ³@OãǏ<|Àž+øe–ÀEIñŠÖHŽ€ÀN¥Rù@Idɓ&L™@OãǏ<|À·ù¬W$¹€ÀEJÌTK1œkÀEŽÛ»ëä€ÀI[k›Ëú=@M‡8pá@OãǏ<|À¶›¬ûÜ19ÀDð„a‘©€ÀH» ŠD@NGÕÍ&@T«V­Zµj@OãǏ<|À³JƒŠ ÈÀAÒô?ª%æ€À=ßÿúéº@TöíÛ·nÝ@OãǏ<|À²ÖiœwԓÀA¡ìï-¯€À<œúéˆ%>@UB… +(P@OãǏ<|À²¬ýl;àÀAp¬Ü"E¶€À<Su(~¢@UŽ8páÃ@OãǏ<|À²„é 9íÀA?Dh,ÿˆ€À;IÎËÅ@UÙ³f͛6@OãǏ<|À²[šZÒÀA Â÷}jž€À:—N$9H@V%J•*T©@OãǏ<|À²3sL#*ÈÀ@Ü6ø§p€À9éžÈ€ié@VpáÇ@OãǏ<|À² Ú3%1À@ª­ìΐ¥€À9@ð&ó3;@VŒxñãǏ@OãǏ<|À±äµµ‚SÀ@y4pýñ€À8œØÉ'@W @@OãǏ<|À±Ÿ$DÃ%À@GÖB<Ê4€À7ýSŸNy@WS§N:t@OãǏ<|À±—Ëé?À@žOk)€À7bF§í@WŸ>|ùóç@OãǏ<|À±rôŸGÀ?Ë-qû?Y€À6˔=«ßæ@WêÕ«V­Z@OãǏ<|À±L°qðVÀ?i‘ŒÌó‚€À69 $^}@X6lÙ³fÍ@OãǏ<|À±'Ïê{ãŒÀ?zËý6πÀ5ªÎ9Űë@X‚ @@OãǏ<|À±a¹ÆçdÀ>§ø‹jg€À5 ‚€‚R@X͛6lÙ³@OãǏ<|À°ße&žà:À>H˜K‘€À4š!Û@Y2dɓ&@OãǏ<|À°»Ùh aƒÀ=èëOŸ«ì€À4³„Š@Ydɓ&L™@OãǏ<|À°˜œŠQê6À=Šyâæ³(€À3˜Žgü +@Y°`Áƒ @OãǏ<|À°vû~¹ÕÀ=,ÐdWԁ€À3r ÞÙš@Yû÷ïß¿~@OãǏ<|À°SÒu~œxÀ<ÏøÙÇ#€À2¥±{hy@ZGÚ@]‡8pá@OãǏ<|À­±Ö‡ÖæŸÀ8¿k ]‹Ä€À+ïx¹Oö@]Ò¥J•*T@OãǏ<|À­w¿ÉS²À8oQr#ߍ€À+Jt™œÜ@^e¬à+À8 D„•ý€À*ª +å]_’@^iÓ§N:@OãǏ<|À­܉e9À7ÒE €À*ÇÒý@^µjÕ«V­@OãǏ<|À¬ÍÙwxœÀ7…RêýрÀ)vxr€Ìº@_ @OãǏ<|À¬–£¶Œ’/À79m©Ù‰)€À(ã ?€@_L™2dɒ@OãǏ<|À¬` O +À6†àæ€À(S®ùyñ@_˜0`Áƒ@OãǏ<|À¬*KÞ NÀ6€Æˆäÿƒ€À'ÈD:Þab@_ãǏ(ގÀ3»×Ú£kr€À"®ó[ÂPó@a‘£F4@OãǏ<|À©ÖjzŸïÀ3}ù6ÖŠ›€À"Jo šj@a·nÝ»ví@OãǏ<|À©šŠÕ)’šÀ3A¢>§~€À!èãŸ¢5@aÝ:téÓ§@OãǏ<|À©{r„T^‚À3+gow€À!‰;yޕ­@b 0`@OãǏ<|À©NÊévPÀ2ÉåÔ𱪀À!,dZ̉@b(Ñ£F@OãǏ<|À©"­â¿9£À2­—”È4€À Ñô¬èV?@bN:téÓ@OãǏ<|Àš÷W«6sÀ2VWdZ€À yÚŒöL@bthÑ£F@OãǏ<|ÀšÌ 5‘ +'À2à$µ,™€À $jŒ¢@bš4hÑ£F@OãǏ<|Àš¡qpV0À1æD¿Rg^€À ÀÖе@bÀ@OãǏ<|Àšwz¡pÀ1¯‚\–ó€ÀýŒÍQë@PV­ZµjÖÀœ‘®ûŒ€€ÀZVÙ6Fâ?òå˗.\¹@PV­ZµjÖÀœ w#_¿ûÅ ÖSºd€ÀZTm¡A@å˗.\¹@PV­ZµjÖÀœ +X;ø2À ŒüJÞʀÀZKç‰k0‹@ X±bŋ@PV­ZµjÖÀœè4_ÁÀÂ÷Gm)€ÀZ>DO_@å˗.\¹@PV­ZµjÖÀŒýÃن³Àš˜\~öó€ÀZ+?çKÜ@Ÿ>|ùóç@PV­ZµjÖÀŒôœ°™ŽÜÀ!0žLÈW>€ÀZîpœ~µ@X±bŋ@PV­ZµjÖÀŒéxVmÀ$‰n(îj€ÀYõhsojŒ@ ‰$H‘"@PV­ZµjÖÀŒÜ]”+ȁÀ'ÕɄx9ۀÀYÒË¡³@"å˗.\¹@PV­ZµjÖÀŒÍTR-JÀ+Òs~T€ÀY«:]§r@%B… +(P@PV­ZµjÖÀŒŒeŽª·tÀ.AÁÊ]t€ÀY~ÛiúØv@'Ÿ>|ùóç@PV­ZµjÖÀŒ©›J©zšÀ0®ô£¥w€ÀYMÙ žcP@)û÷ïß¿~@PV­ZµjÖÀŒ•.äúÀ23ZÀÕ珀ÀYcša%á@,X±bŋ@PV­ZµjÖÀŒ~¡ +¯‡rÀ3­WÔÀÝπÀXÞ«^)§@@.µjÕ«V­@PV­ZµjÖÀŒf‰ !ÐÀ5?O+)€ÀX æ +û)@0‰$H‘"@PV­ZµjÖÀŒLÇŽ»’À6uqžî¥€ÀX_KyœÚ”@1·nÝ»ví@PV­ZµjÖÀŒ1ilÜ·À7Ömtþ>€ÀXéC¡\@2å˗.\¹@PV­ZµjÖÀŒ}ˆI­À9 ¬š-9G€ÀWс’ ]@4(P¡B…@PV­ZµjÖÀ»öMŸ-iÀ:]É_ ô€ÀW…ÌCY·H@5B… +(P@PV­ZµjÖÀ»Ö:v€À;l6<€ÀW74óà™%@6páÇ@PV­ZµjÖÀ»µ4/À<¯Nñ«H+€ÀVåûPU§§@7Ÿ>|ùóç@PV­ZµjÖÀ»’}Žš£À=Ã>6ŒkŸ€ÀV’_E™@8͛6lÙ³@PV­ZµjÖÀ»nºlÄ &À>ÉÄ¡õ€ÀV< ŠG–ë@9û÷ïß¿~@PV­ZµjÖÀ»IÊ_ßãÀ?ÀÊŒ«ỀÀUäþ)ž)Õ@;*T©R¥J@PV­ZµjÖÀ»#Ÿ g|ƒÀ@U+g ¬¢€ÀU‹¶Q =@µjÕ«V­@PV­ZµjÖÀº«–v%gjÀA‰ygâ<;€ÀTxMä É4@?ãǏî6àö€ÀRÿ]ß7Á@BN:téÓ@PV­ZµjÖÀ¹ÓGYL§ÀCðÝ]€ÀR €÷õq@Bå˗.\¹@PV­ZµjÖÀ¹¥Þ‡Ô<ÀC>ÞÁ¥­Î€ÀRBD¿M³@C|ùóçϟ@PV­ZµjÖÀ¹x*Mtï7ÀCr^Þr ó€ÀQãœS˜Ç'@D(P¡B…@PV­ZµjÖÀ¹J]žŽÀC Al<=€ÀQ…Ó—³Èû@D«V­Zµj@PV­ZµjÖÀ¹uíiÞûÀCȹEN—¬€ÀQ(oÄ0»]@EB… +(P@PV­ZµjÖÀžì^Ío–ÀCëùŠ«c€ÀPË­î|ž@EÙ³f͛6@PV­ZµjÖÀžœ[œ£ôÀD +5ÒùŀÀPo§Ž!ëb@FpáÇ@PV­ZµjÖÀžåóÞÀD# É%­ €ÀPtbvÏÃ@G @@PV­ZµjÖÀž^9ؕѷÀD8m¶ŒG€ÀOtR=Yxê@GŸ>|ùóç@PV­ZµjÖÀž.aàçàåÀDHÌc„€ÀNÁ²a[‰@H6lÙ³fÍ@PV­ZµjÖÀ·þhFõÆÀDTï܌}܀ÀN*îØHï@H͛6lÙ³@PV­ZµjÖÀ·ÎVϙ„öÀD]ŒéáՀÀMbÛޏ*{@Idɓ&L™@PV­ZµjÖÀ·ž6Ô3ÎÉÀDaB•šV€ÀL¶àà :«@Iû÷ïß¿~@PV­ZµjÖÀ·n<«£³ÀDaÏÚWQ€ÀL RøsŸú@J“&L™2d@PV­ZµjÖÀ·=î†öÑÀD^ÚšFU€ÀKfHqÈ£I@K*T©R¥J@PV­ZµjÖÀ· ÖÄÖÀDXµU/á€ÀJÁÕ Iî@KÁƒ 0@PV­ZµjÖÀ¶ÝÑ£ÃS +ÀDOÝI#Á€ÀJ +$¯î@LX±bŋ@PV­ZµjÖÀ¶­æmÓVÑÀDB¥…΀ÀI€öä‡g@Lïß¿~ýû@PV­ZµjÖÀ¶~ H&ÀD3X”Ã2€ÀHäšZa>E@M‡8pá@PV­ZµjÖÀ¶NxüøßèÀD![ìñ#€ÀHK)Vªw@N0Ìÿ€ÀDîËF-@PÔ©R¥J•@PV­ZµjÖÀµ+ぉ‰ÀCdyØ/YR€ÀDi ˆ¶>š@Q @@PV­ZµjÖÀŽÙ… RÖøÀCB Êı€ÀCçbé\Œ @Qkׯ^œ{@PV­ZµjÖÀެ1ⳞÀCOÝ%”€ÀCh¢ëð(@Q·nÝ»ví@PV­ZµjÖÀŽ3e âÀBù$€^Š€ÀBë¢û?ž@R 0`@PV­ZµjÖÀŽRŽô†êÓÀBÒœ֕€ÀBr'á¥É@RN:téÓ@PV­ZµjÖÀŽ&F—žÎ¢ÀB«3¿OɀÀAûi`|3«@Rš4hÑ£F@PV­ZµjÖÀ³ú\ۋQ1ÀB‚£-1 €ÀA‡ñў@Rå˗.\¹@PV­ZµjÖÀ³ÎԐƒ€ÀBY$#*÷€ÀA„LXõØ@S1bŋ,@PV­ZµjÖÀ³£®L› 1ÀB.Ï0H€À@š>#e[@S|ùóçϟ@PV­ZµjÖÀ³xímĔCÀB»)Zæs€À@<ŽXhِ@Sȑ"D‰@PV­ZµjÖÀ³N“Ó±MÀA×þ\õ^ý€À?§ºœ}rv@T(P¡B…@PV­ZµjÖÀ³$ Ïµ£üÀA«­¯‘ù€À>Û]uNñ@T_¿~ýû÷@PV­ZµjÖÀ²ûËðIªÀA~ݧŽI€À>;«ª)ž@T«V­Zµj@PV­ZµjÖÀ²Ñù) +¯ÀAQŸ˜Û©©€À=R>Ç·é@TöíÛ·nÝ@PV­ZµjÖÀ²©EÒêÈÒÀA$ åŽá€À<•O‡‹Û @UB… +(P@PV­ZµjÖÀ²€þŒ&ò#À@ö$µš€À;ÝUüšÌ@UŽ8páÃ@PV­ZµjÖÀ²Y#ðIñÀ@Ècì°8€À;*9€=Õ@UÙ³f͛6@PV­ZµjÖÀ²1¶vb3À@™ÁP7'¥€À:{ᇍ]ô@V%J•*T©@PV­ZµjÖÀ² +¶qf’lÀ@k]·€1€À9Ò4N ö@VpáÇ@PV­ZµjÖÀ±ä$ÍîÃÀ@<ê÷£¬„€À9-Wċã@VŒxñãǏ@PV­ZµjÖÀ±œÿxZÔÀ@u™ßœD€À8ŒsÑÞÑ.@W @@PV­ZµjÖÀ±˜H_Þ;À?À­Dr€À7ð,Êýæ@WS§N:t@PV­ZµjÖÀ±rÿ<%=vÀ?cb>J9M€À7X)E•n@WŸ>|ùóç@PV­ZµjÖÀ±N#?Ø2iÀ?îIÂOP€À6ÄOI %š@WêÕ«V­Z@PV­ZµjÖÀ±)ŽH¶<À>ªÉÂÜD.€À64„ñY§þ@X6lÙ³fÍ@PV­ZµjÖÀ±±îkò%À>O<É«ƒ€À5š°}PŒ@X‚ @@PV­ZµjÖÀ°âެê¢À=ó³úpÎe€À5 ž[ø8@X͛6lÙ³@PV­ZµjÖÀ°Ÿñ ¶G²À=˜áýû#€À4œƒ67ÈQ@Y2dɓ&@PV­ZµjÖÀ°œ1VÁ+>À=>ž1‰â€À4÷ýXӏ@Ydɓ&L™@PV­ZµjÖÀ°yÛãbQ‹À<äô÷C³€À3žýðTP@@Y°`Áƒ @PV­ZµjÖÀ°WïôØ+À<‹ò7­‹€À3%|Šœ¶@Yû÷ïß¿~@PV­ZµjÖÀ°6lÀGµ2À<3 lŸ{ՀÀ2¯\Zé!@ZG@ËÀ;…5Kå¿€À1ÌÞð +yÕ@ZÞœzõë×@PV­ZµjÖÀ¯šËŠ7À;/,„Nöh€À1`TWËÖ@[*T©R¥J@PV­ZµjÖÀ¯h˜ćÀ:Ùõê~€À0öÎr7’@[uëׯ^œ@PV­ZµjÖÀ¯)Á¹"-æÀ:…—óàê€À07^©îQ@[Áƒ 0@PV­ZµjÖÀ®ë~;ä‚À:2 N €À0,y²`H@\ 4hÑ£@PV­ZµjÖÀ®­þ³¿ºÀ9ßzbÚš€À/—øÂHÏ@\X±bŋ@PV­ZµjÖÀ®qA`z"ŒÀ9ÄMCó€À.Ún’…S@\€H‘"Dˆ@PV­ZµjÖÀ®5Cþ°|À9<ù oŽ€À.#A²Ùß@\ïß¿~ýû@PV­ZµjÖÀ­ú]0!ÅÀ8íµôÖê€À-pËiï@];víÛ·n@PV­ZµjÖÀ­¿€HÅTbÀ8ž.ìX©€À,Ãmœr¹@]‡8pá@PV­ZµjÖÀ­…µ…ö² À8P4Ë➞€À,ÕY=Ž@]Ò¥J•*T@PV­ZµjÖÀ­L¡Õ|þvÀ8/=¡€À+vß=p® @^$F2À55ßh>Ã[€À%ñœˆd.ƒ@`‰$H‘"@PV­ZµjÖÀ«r_¶zÐÀ4ód¢3Á¯€À%x}°97í@`®Ý»víÛ@PV­ZµjÖÀªÔB‰õ4¡À4±Ùg+ù€À%vš/^@`Ô©R¥J•@PV­ZµjÖÀª£¬oíáÀ4q;çÚò€À$žiЧ@`útéÓ§N@PV­ZµjÖÀªs­ß›dŒÀ41Š2§ÿ€À$°Ó™B@a @@PV­ZµjÖÀªDD±&ێÀ3òÂ4T,Ž€À#²Ã Ùë@aF 0`Á@PV­ZµjÖÀªnŸ‰šÀ3ŽáÀŽ©€À#H¯×Ýv6@akׯ^œ{@PV­ZµjÖÀ©ç)æ<»À3wæ)@ـÀ"áannBµ@a‘£F4@PV­ZµjÖÀ©¹tž#pÀ3;ÎE'¹z€À"|»‡4Ÿ@a·nÝ»ví@PV­ZµjÖÀ©ŒK"uÕ¹À3–nFbü€À"¿Q„‘@aÝ:téÓ§@PV­ZµjÖÀ©_­5SËÀ2Æ<ˆŠ7€À!»CfZŠ~@b 0`@PV­ZµjÖÀ©3—Ô·À2ŒŸ0ŒË€À!^;ÎãŽ*@b(Ñ£F@PV­ZµjÖÀ© fÞ îÀ2T;®Á€À!•ú7u@bN:téÓ@PV­ZµjÖÀšÜÿËÌq†À2HŠ·Ù€À «?í9@bthÑ£F@PV­ZµjÖÀš²y ûÂÀ1åL;|Èä€À U(=*@bš4hÑ£F@PV­ZµjÖÀšˆs:P¢1À1¯ ’qÅÆ€À >Tì‚@bÀ@PV­ZµjÖÀš^ìj,äðÀ1yÂÍ×îɀÀ^ânF@P»víÛ·pÀŒkÅQ›&h€€ÀY7õ&»*?òå˗.\¹@P»víÛ·pÀŒjÏ`Jü¿ú#°¯#Œ€ÀY5rPÖ{@å˗.\¹@P»víÛ·pÀŒgîéοÀ þbφ>â€ÀY-ìN†p¥@ X±bŋ@P»víÛ·pÀŒc#B×Àu‚W5w€ÀY!j—a‹Z@å˗.\¹@P»víÛ·pÀŒ\q€ËEÀ߆*ä€ÀYù‡áÀÔ@Ÿ>|ùóç@P»víÛ·pÀŒSÝ:çº3À U¥ý–€ÀXùªB1»@X±bŋ@P»víÛ·pÀŒIkƒ­ýÀ#AvÈ/B€ÀXޒ„gè@ ‰$H‘"@P»víÛ·pÀŒ=!;ËnÀ&ZdäÆ8Y€ÀXŸÌuǍu@"å˗.\¹@P»víÛ·pÀŒ/Ý_ŸJÀ)foåïŸ`€ÀXšvlÄ€ä@%B… +(P@P»víÛ·pÀŒ$ Q2üÀ,cûw/v>€ÀXq²¯9=ð@'Ÿ>|ùóç@P»víÛ·pÀŒ ÉÜ îÀ/Q 1š€ÀXD§.(Çh@)û÷ïß¿~@P»víÛ·pÀ»ú)ý=À1ÈÑÇHº€ÀX}=ÜK?@,X±bŋ@P»víÛ·pÀ»å'Zú3À2{kÃ󁷀ÀWÞaJΐÂ@.µjÕ«V­@P»víÛ·pÀ»Î…XrƒÀ3Ö Ô·,‡€ÀW¥‚‹zÁû@0‰$H‘"@P»víÛ·pÀ»¶P!J;œÀ5&|ý羀ÀWi®ßáš@1·nÝ»ví@P»víÛ·pÀ»œ”~ÅäÀ6kÅrÏf€ÀW)E‡qœ^@2å˗.\¹@P»víÛ·pÀ»_ʌO¥À7€ˆŸšU€€ÀVæP²@b%@4(P¡B…@P»víÛ·pÀ»d¿ÛƱ)À8ÒÝ5[ɀÀV k:\ +@5B… +(P@P»víÛ·pÀ»FÂôËÆ‚À9óh6Ñš7€ÀVWÍ8Ìà"@6páÇ@P»víÛ·pÀ»'w°˜H À;<šCàЀÀV ¯qçÛ@7Ÿ>|ùóç@P»víÛ·pÀ»ìðA¬ÜÀ<_€Û€õ{å€ÀTË«ÜA@µjÕ«V­@P»víÛ·pÀº,ÉI!’‡À@™ZwÚ{ˀÀSË`\ÐÙ5@?ãǏŽ@Bå˗.\¹@P»víÛ·pÀ¹4Œ# Ä€ÀBJ¯Cž¹Q€ÀQ¹‡šcŒ¢@C|ùóçϟ@P»víÛ·pÀ¹ ï*Ž6ÀB~éÍb€“€ÀQ`ä€Þ@D(P¡B…@P»víÛ·pÀžÝ.¢e¹ÆÀB­Ú|@ÃɀÀQ†Œõ¡‹@D«V­Zµj@P»víÛ·pÀž°Ü‡oÎÀB×­uÈùŽ€ÀP°‹Õæ÷<@EB… +(P@P»víÛ·pÀž„-5Ç«ÀBü“ /W€ÀPY­œ@EÙ³f͛6@P»víÛ·pÀžW-7IÀC® î€ÀP)Fir5@FpáÇ@P»víÛ·pÀž)æô ÀC866N«O€ÀOWã%@³º@G @@P»víÛ·pÀ·üc`ùm»ÀCOU?Rå€ÀN¬ùÀudH@GŸ>|ùóç@P»víÛ·pÀ·Î¯mÃsÀCb8f¶Ž€ÀNŒ¢Á0@H6lÙ³fÍ@P»víÛ·pÀ· Óþª¬ÀCq +ö!š:€ÀM\Nš©0¹@H͛6lÙ³@P»víÛ·pÀ·rؑ 1ÀC{ùݹY€ÀL¶ÏA¡±”@Idɓ&L™@P»víÛ·pÀ·Dȶ‹@³ÀCƒ/äZ•|€ÀL[+ßh}@Iû÷ïß¿~@P»víÛ·pÀ·¬åßÏÀC†×sP0ЀÀKr ¶,ù@J“&L™2d@P»víÛ·pÀ¶èŠÏ“­9ÀC‡*Å%g€ÀJÒù>ß%!@K*T©R¥J@P»víÛ·pÀ¶ºl̚UeÀC„ ÙÂe€ÀJ67;h;ö@KÁƒ 0@P»víÛ·pÀ¶ŒYz™2[ÀC~t†€ÀI›ØtŸæŒ@LX±bŋ@P»víÛ·pÀ¶^WðÌÄÈÀCunN€ÀIí Ȏ@Lïß¿~ýû@P»víÛ·pÀ¶0nëÏëÀCiWütŒs€ÀHnƒgŠ^ð@M‡8pá@P»víÛ·pÀ¶€Ì$çÀCZõ|©®(€ÀGÛ§‡DDÅ@N.¥›ÀAŸÒ‡Ž €À@€ÏËϪ@S|ùóçϟ@P»víÛ·pÀ³FúѱÛÀAxAsO•·€À@€ S@Sȑ"D‰@P»víÛ·pÀ³íxôœÀAOû5#+{€À?er>ì;@T(P¡B…@P»víÛ·pÀ²ô7úõ–ÀA'Ð„€À>žäiƒÛ@@T_¿~ýû÷@P»víÛ·pÀ²Ëä7S( +À@ýŸ^qÍà€À=ÝDúâU@T«V­Zµj@P»víÛ·pÀ²£òöÀ@Ó¯|ŸÝS€À= }å&™@TöíÛ·nÝ@P»víÛ·pÀ²|eEe•WÀ@©U戜9€ÀrÀ>ô®_9««€À7ÞôttÑÏ@WS§N:t@P»víÛ·pÀ±NF”ço-À>{!p1 €À7J iÖª@WŸ>|ùóç@P»víÛ·pÀ±*Q)UxÀ>Fc]?Hæ€À6¹\Ÿ%*@WêÕ«V­Z@P»víÛ·pÀ±®oPÀ=ïzL±H€À6+ûЏA÷@X6lÙ³fÍ@P»víÛ·pÀ°ãš\i4À=˜ÑHb†€À5¢²â8E@X‚ @@P»víÛ·pÀ°ÀØcōÖÀ=BxÜøš€À5&ñ}í@X͛6lÙ³@P»víÛ·pÀ°ž|/A¬ÙÀ<쀀1š€À4›#[°@Y2dɓ&@P»víÛ·pÀ°|…Dù +À<–öš°Ê܀À4®lœ§@Ydɓ&L™@P»víÛ·pÀ°Zóö=ÞÀõ€À3)øt {@Yû÷ïß¿~@P»víÛ·pÀ°ú— åÀ;™kG¶€À2µ‰ÅÚ@ZG³ ˜@å˗.\¹@Q @À»Áßë þþÀHœ%¶×€ÀX5‡f,@Ÿ>|ùóç@Q @À»¹Ò?î·LÀAgÒ â€ÀWñޜ1ù@X±bŋ@Q @À»°ŒWÆ$À"0݂Ay€ÀWØÌ«+iQ@ ‰$H‘"@Q @À»€yދ³À$ýÂI²÷:€ÀW»”ÏHQ@"å˗.\¹@Q @À»—;Z…QÀ'ÛµbÑR€ÀWš'ÞeȂ@%B… +(P@Q @À»ˆN>ûÀ*¬”D4w€ÀWt€44š@'Ÿ>|ùóç@Q @À»wŒ©/¢À-nüÂB.€ÀWK+wRQ-@)û÷ïß¿~@Q @À»eŽ!МÀ0Ï_ç (€ÀWâZya•@,X±bŋ@Q @À»QÌŽÔJÀ1a¢'n;û€ÀVìðZúk@.µjÕ«V­@Q @À»<‚Àí{À2©f3iåM€ÀVžve@0‰$H‘"@Q @À»%»GÝäÀ3痮Øáž€ÀV€»ê«]ª@1·nÝ»ví@Q @À» ß‘æÀ5¿ÊF΀ÀVEÓàî6@2å˗.\¹@Q @Àºó⢮|zÀ6EtCÚØè€ÀV÷#èŸ@4(P¡B…@Q @ÀºØê ÌB°À7dYp€Œ€ÀUÇVÈÌ£˜@5B… +(P@Q @ÀºŒ¥NE1 À8x ®¥â¥€ÀU„$ä][”@6páÇ@Q @ÀºŸ!sú]ÕÀ9€‰U~U€ÀU>”,/ @7Ÿ>|ùóç@Q @Àº€l ·äÀ:}`u»>`€ÀTö×§'€@8͛6lÙ³@Q @Àº`“À[bÀ;n€©ð_€ÀT­"SÊ9à@9û÷ïß¿~@Q @Àº?€.±uÀzŒ@Œ³%朊€ÀSv~`ÖFD@>µjÕ«V­@Q @À¹²&Qâ"À?rÊ[#es€ÀS%Ód«ô@?ãǏ|ùóç@Q @À·qKé+òªÀB‡ ÕIG±€ÀMJ +CÜx @H6lÙ³fÍ@Q @À·Eq}7 üÀB˜Æ +à΀ÀL«9Â#‰š@H͛6lÙ³@Q @À·sÁß7ÀBŠ/pfÌ¥€ÀL̑Ó@Idɓ&L™@Q @À¶íY^ëÀB°gåžê€ÀKr»7Ž +N@Iû÷ïß¿~@Q @À¶Á, ÂñªÀB¶kÒÀK‡€ÀJÙD/Ü-Ž@J“&L™2d@Q @À¶”óŒÆJÀB¹ŒŽ€ÀJAÈaMÑ{@K*T©R¥J@Q @À¶h·ÖAJÕÀB¹Š€MLn€ÀI¬]­¯°@KÁƒ 0@Q @À¶<xWaPÀB¶ŒÅ2‚š€ÀI~¯L@LX±bŋ@Q @À¶Q޲›õÀB°¶»$ƒ€ÀHˆ†B×@Lïß¿~ýû@Q @Àµä4§01šÀBš,%_d€ÀGù3;EÂ7@M‡8pá@Q @Àµž.üfƒÀBå»ÄZ€ÀGlŽÆnžŠ@N.á§ÀA|¹§ ±€ÀA•ŠENÀ@Rš4hÑ£F@Q @À³¥n·_ÀA[0cá…€ÀA)9›æâo@Rå˗.\¹@Q @À³d?†9ßéÀA8§ŸqºÐ€À@¿@)0&@S1bŋ,@Q @À³;»$\ÀA5ç;·€À@WŽ…-˜¶@S|ùóçϟ@Q @À³‹VŽùÄÀ@ðð)ªi9€À?å!&xFy@Sȑ"D‰@Q @À²ë²01çÕÀ@ËêíÝ 5€À?›"DÖJ@T(P¡B…@Q @À²Ä1K·§ŒÀ@Š9ºoƒ&€À>^ÈK{ÀŠ@T_¿~ýû÷@Q @À² +)KlÀ@ï6ΏC€À=¢˜Þ€^@T«V­Zµj@Q @À²v>åo7À@Y.&ë€À<êü!éàŒ@TöíÛ·nÝ@Q @À²OÎUx²äÀ@1Ԓi +€À<7à5þ@UB… +(P@Q @À²)»ÔëàUÀ@ +%€Ï­€À;‰3¥}—W@UŽ8páÃ@Q @À²| ^öÀ?Ä>Šâ¡Ø€À:Þâb/r@UÙ³f͛6@Q @À±Þ²z÷äÀ?s Àٕ€À:8Ù¬¹¬p@V%J•*T©@Q @À±¹Œ~bÂÀ?"îº€À9—ïa!ü@VpáÇ@Q @À±•&Ý¥-À>ÑrÒ³¥€À8ùOáî)F@VŒxñãǏ@Q @À±pðxžš£À>jR!}ã€À8_¥»R§–@W @@Q @À±MsœÚÀ>-й«˜n€À7ÉñqJýY@WS§N:t@Q @À±)§1Ó’À=۔P™·€À78ɘŽf@WŸ>|ùóç@Q @À±“Á³ãÀ=‰›fŸ ±€À6ªj ¹@WêÕ«V­Z@Q @À°ãáòÚõÀ=7³ЬÀ€À6ÂçhÓ¶@X6lÙ³fÍ@Q @À°Ái^¶À<åìücÄـÀ5™ÓŸ’@X‚ @@Q @À°Ÿœœ À<”Yäꣿ€À5éÇî˜@X͛6lÙ³@Q @À°~ M6?À2ßå€À/ÿ*-‚E@\€H‘"Dˆ@Q @À­ÕðÿDÀ8N"‚„€À.a,~hO@\ïß¿~ýû@Q @À­œÝêØkùÀ8¹›Â璀À-²"€L@];víÛ·n@Q @À­dwNâÛÀ7ŒMÅd΀À-ÀŒÔŒØ@]‡8pá@Q @À­,»^ý2À7t9œZN€À,aå– øß@]Ò¥J•*T@Q @À¬õšGµfÀ7-#\lž€À+Àp¯ÃŠŠ@^€À%ќøõ/œ@`®Ý»víÛ@Q @Àª‘…hÕõqÀ4äÛ šº€À%\+<¬B,@`Ô©R¥J•@Q @ÀªbX‹Ýõ À3ÚQQ•Š€À$é¿<鰒@`útéÓ§N@Q @Àª3¹ÓïcÁÀ3ž‹Iá]€À$zBÅh±F@a @@Q @Àª§\ð™¬À3c‘™›eš€À$  EcÆc@aF 0`Á@Q @À©ØE±ZèÀ3)bô{áá€À#£ÂËëŒ%@akׯ^œ{@Q @À©«°7z×À2ïýïC ©€À#<–3ÖË@a‘£F4@Q @À©~ŠÂ­ À2·a…q€À"Ø1ÚìN@a·nÝ»ví@Q @À©R²€UðÀ2ŠI•Þ€À"v-.AÓ@aÝ:téÓ§@Q @À©'A„\ùÀ2Hx׀*$€À"q_oȖ@b 0`@Q @ÀšüQ“tÀ2*M{)€À!¹G¿€¥@b(Ñ£F@Q @ÀšÑáLSØÀ1ܜd1ĵ€À!^qÌD¹Q@bN:téÓ@Q @Àš§îîÀ1§Í× Œ]€À!ތÎ\@bthÑ£F@Q @Àš~w +®ŒÄÀ1sŒmøHž€À ¯}ˆçvË@bš4hÑ£F@Q @ÀšUz®nÀ1@f⮀À [>Çi¡À@bÀ@Q @Àš,õ˜™À1 ÈÍÞ €À ÊQ^ª@Q… +(P¢À»;)ÈTကÀW.XèÙùü?òå˗.\¹@Q… +(P¢À»:PF9=¿öñìºAœ=€ÀW,9“Àå@å˗.\¹@Q… +(P¢À»7Æ6a>—À룯fY*€ÀW%݂öR@ X±bŋ@Q… +(P¢À»3ŒRO"À(ä8Œ×€ÀWJƒ»@å˗.\¹@Q… +(P¢À»-€çð#ÖÀқÿxs“€ÀW Š3V–?@Ÿ>|ùóç@Q… +(P¢À»&.±ÀoðàύÁ€ÀVù©ìÀ@X±bŋ@Q… +(P¢À»Û=5ûØÀ þöy—è;€ÀV⺊ö¬@ ‰$H‘"@Q… +(P¢À» +Œ[nÀ#ŒØlZXL€ÀVÇÐ×fZI@"å˗.\¹@Q… +(P¢À»`®²ŠÀ&p;Ñd/]€ÀV©JfÓ @%B… +(P@Q… +(P¢Àº÷ƒ×+èÌÀ)Í7+ |€ÀV†oïçY×@'Ÿ>|ùóç@Q… +(P¢ÀºçìÉ/i3À+²IÚNЀÀV`1¬eg¥@)û÷ïß¿~@Q… +(P¢ÀºÖÐK3‹ƒÀ.>'jqp€ÀV6j!EQR@,X±bŋ@Q… +(P¢ÀºÄ7ÛªîÀ0]« +å®Ç€ÀV |ùóç@Q… +(P¢À¹þº;H±!À9l² ĀÀT8^M*y,@8͛6lÙ³@Q… +(P¢À¹à œ ¹À9îÀFáDP€ÀSóås–Ž)@9û÷ïß¿~@Q… +(P¢À¹Á|fl,À:ËDVO8€ÀS­¶µjÕ«V­@Q… +(P¢À¹;3ðí‰À=П<ŠTƀÀR‡R “¥@?ãǏvû# èá€ÀR;%…FT@@‰$H‘"@Q… +(P¢ÀžóŒ•OÙÀ?¿±‰€ÀQîA'-îH@A @@Q… +(P¢ÀžÎ‚]÷•žÀ?€ ª«{€ÀQ Ë9ÖAÀ@A·nÝ»ví@Q… +(P¢ÀžšÒ”f÷À@’åV^º€ÀQRç·üƒ@BN:téÓ@Q… +(P¢Àž‚‰92óÀ@T üuɀÀQžÒ~“ã@Bå˗.\¹@Q… +(P¢Àž[² Œ‹zÀ@©‚=åb€ÀP¶^æü@C|ùóçϟ@Q… +(P¢Àž4Xú‘ÔÀ@ÂqUò˜»€ÀPgøvÁ@D(P¡B…@Q… +(P¢Àž ‡íé gÀ@ò‹K†‰‰€ÀP¢1µœ@D«V­Zµj@Q… +(P¢À·äK9ÀA…œ €ÀO–íå6äý@EB… +(P@Q… +(P¢À·»¬—†ÝQÀAE;:¯•Ï€ÀNû›‚+k@EÙ³f͛6@Q… +(P¢À·’¶œž;âÀAhobÎM€ÀN`:ú@FpáÇ@Q… +(P¢À·isx.ˆsÀA†Í»ü€ÀMÅÓ0ù)@G @@Q… +(P¢À·?ìd‡ÿIÀA¡…«|ùóç@Q… +(P¢À·*ÊÍIÀAž`–0ð€ÀL”­Lus@H6lÙ³fÍ@Q… +(P¢À¶ì7ŽæóÀA˄r²á€ÀKþݯÓÒ@H͛6lÙ³@Q… +(P¢À¶Âz ·ÐÀAÛ·{2€ÀKhˍ)©c@Idɓ&L™@Q… +(P¢À¶—Þ©þ+ÀAç5<Ø(ƒ€ÀJÕ!!Çíå@Iû÷ïß¿~@Q… +(P¢À¶m‰ãUXÀA𠏙¯É€ÀJC䝻-@J“&L™2d@Q… +(P¢À¶C"|ÞžÀAõŽÙˆÄ-€ÀI²ÜÂßg@K*T©R¥J@Q… +(P¢À¶²ñ$òÀAøYÓ2r€ÀI$pnôw@KÁƒ 0@Q… +(P¢Àµî>ÒªŽ ÀAø¶Lú€ÀH—íz|¢l@LX±bŋ@Q… +(P¢ÀµÃÏtcÿ„ÀAõ1â_€ÀH e{yUi@Lïß¿~ýû@Q… +(P¢Àµ™jQ’ .ÀAïv_ŒAW€ÀG„èJ±@M‡8pá@Q… +(P¢Àµoz+ó3ÀAçRÀŠF£€ÀFþƒBýǔ@N֖ÊŸ@T(P¡B…@Q… +(P¢À²”’1k‘À@)‹á€À>g‡ÁÚz@T_¿~ýû÷@Q… +(P¢À²n- x—À@Áá®Òû€À=d•ãÃk±@T«V­Zµj@Q… +(P¢À²HàBô±À?ÃÀ1?ä €À<²è\ñ\@TöíÛ·nÝ@Q… +(P¢À²#†·ƒµÀ?zù,µ”\€À<Ï{=K@UB… +(P@Q… +(P¢À±þƒª}Œ.À?1Mø¡rǀÀ;Y»¹n—@UŽ8páÃ@Q… +(P¢À±ÙØ—rLÀ>æÜUŽ€À:³ÇL޶@UÙ³f͛6@Q… +(P¢À±µ„ÚëjÀ>›À†Àl€À:àIw,×@V%J•*T©@Q… +(P¢À±‘Šªy) À>P`; +r€À9sõ³­÷£@VpáÇ@Q… +(P¢À±mê!ke0À>ôJPØž€À8Ùõ"¡1{@VŒxñãǏ@Q… +(P¢À±J£Œ¹¿À=·uMýn/€À8CÌ šsè@W @@Q… +(P¢À±'·ÝÀýßÀ=j¯a#0€À7±g•dJr@WS§N:t@Q… +(P¢À±&Ë×nâÀ=·#%,”€À7"ŽšJjÞ@WŸ>|ùóç@Q… +(P¢À°âðµÆüjÀ<Сƒ*s€À6—Ÿþ¡@WêÕ«V­Z@Q… +(P¢À°Á³E3À<ƒ/nD€À60bê @X6lÙ³fÍ@Q… +(P¢À°Ÿ•ÆZ‰À<6gîý +€À5ŒŲ +Œ@X‚ @@Q… +(P¢À°~pܺ4šÀ;éfeŽSR€À5 U>*-›@X͛6lÙ³@Q… +(P¢À°]ŠÑeõÀ;œŒ(5j5€À4÷ÐÓv@Y2dɓ&@Q… +(P¢À°=7lQ-ãÀ;OçŸP‡L€À4Õü (I@Ydɓ&L™@Q… +(P¢À°"fÅÑ À;†².‹€À3œÞƒ.ÿ@Y°`Áƒ @Q… +(P¢À¯úÎÒŽÅÀ:·uœC=€À3(ýƒ0÷¥@Yû÷ïß¿~@Q… +(P¢À¯Œ Ac‚À:kÀ)ëc݀À2žôú@ZGÃë€À/àÁE @\X±bŋ@Q… +(P¢À­Þ®Mj›YÀ8foîßp€À/*›‹‹p@\€H‘"Dˆ@Q… +(P¢À­Šš_±»À7ØuŽ…”€À.x8t HB@\ïß¿~ýû@Q… +(P¢À­nÒlÀ7’,?I:€À-Êý*à@];víÛ·n@Q… +(P¢À­6ª:ËËÀ7LŽMHP€À-"GSÁ'Ý@]‡8pá@Q… +(P¢À¬ÿòFðÞÀ7Ÿ¡å? €À,}ø (¢K@]Ò¥J•*T@Q… +(P¢À¬ÉÜV€¥À6ÃcŽ×¶€À+ÝðÐï”^@^ÿ†ªÀ4A6<ý—€À&p¹ßà²@`‰$H‘"@Q… +(P¢ÀªžÆÛz^µÀ4bŠ£Ï€À%ø©ÌÄ€j@`®Ý»víÛ@Q… +(P¢Àªo‘Æ9_À3ÉŸçóæ€À%ƒ§n*˔@`Ô©R¥J•@Q… +(P¢ÀªAIœC”*À3#:$Œt€À%œÍÔ»{@`útéÓ§N@Q… +(P¢ÀªZ6SÀ3UFŸ ž€À$¢t‘5Æf@a @@Q… +(P¢À©åòãÀ3(±‹f4€À$6õžfÆ@aF 0`Á@Q… +(P¢À©¹qÀ2ãÈ-Õj‚€À#ÌxÍ$œ@akׯ^œ{@Q… +(P¢À©Œ³Ò°ÄÀ2¬$+)fª€À#e}{|Ž @a‘£F4@Q… +(P¢À©`Ù%¯žàÀ2u;ƒ6]ò€À#òÆfŠ@a·nÝ»ví@Q… +(P¢À©5QS%ðÀ2? óËàá€À"Ÿ,°QÅ©@aÝ:téÓ§@Q… +(P¢À© +€Ÿ˜q°À2 —!Nh؀À"?²¹_1ñ@b 0`@Q… +(P¢ÀšàG]ÍvŽÀ1ÔØ™Fd€À!╗Ý*}@b(Ñ£F@Q… +(P¢Àš¶eÜÅ áÀ1 ÏÓDF€À!‡ÄW&ˆ@bN:téÓ@Q… +(P¢ÀšŒþqô”À1m{5q‡q€À!/.€Ãkb@bthÑ£F@Q… +(P¢ÀšdrõcÀ1:Ùí~–€À ØÄ.å5@bš4hÑ£F@Q… +(P¢Àš;—>ý¡À1ç³×ê€À „uœ£%v@bÀ@Q… +(P¢Àš”5¬üŽÀ0×¥LºY?€À 23ûíÅy@QéÓ§N<Àº¬™ …€€ÀV@Ü [ÿ?òå˗.\¹@QéÓ§N<Àº«JÿcXW¿õ–8‰ªGv€ÀV>ç?ír¯@å˗.\¹@QéÓ§N<ÀºšçgSÍÀ‹-C©”€ÀV9 +F@ X±bŋ@QéÓ§N<Àº€íyhäÀ%SÍ9€ÀV/KÏl@å˗.\¹@QéÓ§N<ÀºŸ_K€¯Àyîf¢ +t€ÀV!±sM÷Ž@Ÿ>|ùóç@QéÓ§N<Àº˜?ÃîßIÀÃW,îPï€ÀVIaÊ @X±bŋ@QéÓ§N<Àº’—× ÀþßæÎk׀ÀUû!øås@ ‰$H‘"@QéÓ§N<Àº…\AÆcÖÀ"”öÿ^þ~€ÀUâM_¢«@"å˗.\¹@QéÓ§N<Àºy¡ÿÕlÀ% þú ފ€ÀUÅງ€Ø@%B… +(P@QéÓ§N<ÀºliÉ8G8À'¢SH“–ù€ÀU¥óüEì@'Ÿ>|ùóç@QéÓ§N<Àº]ºGh®jÀ*Íð²h£€ÀU‚¡œ¿—¹@)û÷ïß¿~@QéÓ§N<ÀºMšÌԗþÀ,€Xò†Kœ€ÀU\ …ýU@,X±bŋ@QéÓ§N<Àº<K0À.ÚïlJ€ÀU2C<šqá@.µjÕ«V­@QéÓ§N<Àº),H`HãÀ0“O†gJ€ÀUwšuܙ@0‰$H‘"@QéÓ§N<ÀºîԟÙÀ1±D ¹_›€ÀTÕǁˆL@1·nÝ»ví@QéÓ§N<À¹ÿd}·ÝÀ2ÆïÝØ%€ÀT£Wv§û@2å˗.\¹@QéÓ§N<À¹è—C›YðÀ3Ó÷W_8€ÀTnMøŽ™]@4(P¡B…@QéÓ§N<À¹Ð‘ŒYªÀ4Ø ª“ç€ÀT6Ñù[@5B… +(P@QéÓ§N<À¹·^îeûÀ5Òà›<íҀÀSý ­/Í@6páÇ@QéÓ§N<À¹ìóO«À6Ä@ŸVñ}€ÀSÁ#Ì7M@7Ÿ>|ùóç@QéÓ§N<À¹š\¶ýþÀ7«øÓ’€ÀSƒCi8Î@8͛6lÙ³@QéÓ§N<À¹e äac±À8‰âÔ¿}ã€ÀSC“ŽQÞÛ@9û÷ïß¿~@QéÓ§N<À¹G§*V»ÌÀ9]â‰îÞo€ÀS=œ™™@;*T©R¥J@QéÓ§N<À¹)8ðBGLÀ:'åÍ?¬€ÀR¿j:¶ +à@µjÕ«V­@QéÓ§N<ÀžÈ©}3bŒÀŸýz§þ€ÀQ±Ž 3Ú@A·nÝ»ví@QéÓ§N<Àž=U~!äÍÀ>—ŸÆÚ=h€ÀPΜóÖ·°@BN:téÓ@QéÓ§N<Àžé|ŸòÀ?Z†Žü€ÀP…-úR?@Bå˗.\¹@QéÓ§N<À·óðñšà&À?…Á.ë³c€ÀP;‚Û›³@C|ùóçϟ@QéÓ§N<À·ÎvŸ)ØÀ?ï%Ÿ< «€ÀOãpySÍè@D(P¡B…@QéÓ§N<À·š…û.cÀ@'ßO¿aü€ÀOOÒVžu/@D«V­Zµj@QéÓ§N<À·‚&q/¿qÀ@Sâã R€ÀNŒ^@R“æ@EB… +(P@QéÓ§N<À·[dԄBÀ@{ºiÂπÀN)D}'@EÙ³f͛6@QéÓ§N<À·4Iè2¥ãÀ@Ÿ…¡ð­€ÀM–°p€8Û@FpáÇ@QéÓ§N<À· ßóošÀ@¿c«ì³D€ÀMÍB€^9@G @@QéÓ§N<À¶å-„‡Ê]À@Ût‚£ z€ÀLsÁeq@GŸ>|ùóç@QéÓ§N<À¶œ>³ä+À@óØz£zq€ÀKã°ùíŠ@H6lÙ³fÍ@QéÓ§N<À¶•™b ÀA°Ã4á€ÀKTœwõU@H͛6lÙ³@QéÓ§N<À¶lÇfœ(ÀAÿÂxƀÀJÇÇ nõ@Idɓ&L™@QéÓ§N<À¶DO·O!öÀA(<µ|¹€ÀJ:Šdô‰@Iû÷ïß¿~@QéÓ§N<À¶ºËÃnVÀA32š8EW€ÀI¯¹yŠ @J“&L™2d@QéÓ§N<Àµó™Þ¹ ÀA; +Zóa€ÀI&Vø+ô@K*T©R¥J@QéÓ§N<ÀµÊU+ ÀA@Ãg|€ÀHž”¹'í@KÁƒ 0@QéÓ§N<Àµ¡’=qžÀABT`mŸ€ÀH†•rÓ@LX±bŋ@QéÓ§N<ÀµxÍF +ðlÀAAÞžý€ÀG”>}€@Lïß¿~ýû@QéÓ§N<ÀµP oœWüÀA>ÚhPpp€ÀG̒ôA@M‡8pá@QéÓ§N<Àµ'U²;áÀA9g¹÷—+€ÀF‘??®A +@NŠœšs§å@T(P¡B…@QéÓ§N<À²e_Š20*À?_Óô3»€À=Õ²@T_¿~ýû÷@QéÓ§N<À²@xA§i À?—k€À=#‘ óÉ@T«V­Zµj@QéÓ§N<À²Þ}|{ŠÀ>ÛÜH’€À˜‹xš¡Œ€À;̜R+Á@UB… +(P@QéÓ§N<À±Ó˜Ì4À À>TBƒ}Ùü€À;'—„ n@UŽ8páÃ@QéÓ§N<À±¯ï*G(À>Áƒ€À:…sæXlC@UÙ³f͛6@QéÓ§N<À±Œ—Šd>¶À=É9š"©è€À9çš>)p{@V%J•*T©@QéÓ§N<À±i“»gžÀ=‚¯eYjƀÀ9M£÷:ú@VpáÇ@QéÓ§N<À±Fâ'a ÏÀ=;˜ éP€À8·TL TÙ@VŒxñãǏ@QéÓ§N<À±$…y×èžÀ<ô gËÛ$€À8$«}ÈÅí@W @@QéÓ§N<À±}ˆpÀ<¬‡jµª€À7•—ҁÎ@WS§N:t@QéÓ§N<À°àÊ·X£À|ùóç@QéÓ§N<À°¿mQÌ23À<}›+€À6ë:0ž@WêÕ«V­Z@QéÓ§N<À°žeŒ®åÁÀ;Òîׂ€À5ý/pþ›@X6lÙ³fÍ@QéÓ§N<À°}³‡>ÈÀ;ŠLÙø,†€À5{Ã.@ g@X‚ @@QéÓ§N<À°]WLyÃcÀ;Aª” }€À4ý”žw²Ä@X͛6lÙ³@QéÓ§N<À°=PÔ_žÀ:ùÌë| +€À4‚’M/?@Y2dɓ&@QéÓ§N<À° S†À:° êB€À4 +ª+S@Ydɓ&L™@QéÓ§N<À¯ü‰hJøÏÀ:hT,Æù€À3•Ê›šÌ@Y°`Áƒ @QéÓ§N<À¯Ÿ}NË=kÀ: ?»â3˜€À3#áûˆþ=@Yû÷ïß¿~@QéÓ§N<À¯,iŸÀ9Øn¬Bap€À2ŽÞÂãž@ZGjçé@];víÛ·n@QéÓ§N<À­·4%VÀ6Þ íâ§€À-7øž!dë@]‡8pá@QéÓ§N<À¬ÓžœA»À6œ ‡p„€À,•Pt‡,Ò@]Ò¥J•*T@QéÓ§N<À¬å™ýˆëÀ6Z—ña&€À+öѓÊXc@^lŸÈ@bthÑ£F@QéÓ§N<ÀšIhۇÈuÀ1óëQI€À ÿ¬/·@bš4hÑ£F@QéÓ§N<Àš!um ÍBÀ0ÑbZ"ÅրÀ «4?€40@bÀ@QéÓ§N<À§ùóóœ?AÀ0¡vêžÆV€À XíZýÖ@RN:téÔÀº"°mPR€€ÀUaKäq ì?òå˗.\¹@RN:téÔÀº!ðC¢ï ¿ôUy|ùóç@RN:téÔÀºÿAáXªÀ7Á_©¹ô€ÀU4pÿ-zÂ@X±bŋ@RN:téÔÀºÒ-3À'6ZʀÀU æ"çˆ4@ ‰$H‘"@RN:téÔÀ¹þ27êˆÀ!ƒ»6©Bf€ÀU ôG³€|@"å˗.\¹@RN:téÔÀ¹ó$ “uEÀ#ëPÿø`ÿ€ÀTï®<°/@%B… +(P@RN:téÔÀ¹æ­P•ìÀ&I1ûwr€ÀTÒ)V¡@'Ÿ>|ùóç@RN:téÔÀ¹ØÓÓ,ŽÀ(œRbr€ÀT±}Iæ)ý@)û÷ïß¿~@RN:téÔÀ¹ÉžMÙûOÀ*ã³°<€ÀTÄât@,X±bŋ@RN:téÔÀ¹¹÷¿ãýÀ-f +žã£€ÀTgoAԂ@.µjÕ«V­@RN:téÔÀ¹§<išýÀ/KŒ3›€ÀT=›bn[@0‰$H‘"@RN:téÔÀ¹” V +¶À0µ+ÆúŒ€ÀTiO™‡Ž@1·nÝ»ví@RN:téÔÀ¹ÇûH4>À1œâ³;í€ÀS※3p@2å˗.\¹@RN:téÔÀ¹j<œ¬Ð-À2ŒÿVhI€ÀS±mùã>Î@4(P¡B…@RN:téÔÀ¹S‡µÉÅ À3ŽÌQ¢\8€ÀS}ê UŽ(@5B… +(P@RN:téÔÀ¹;³/qÀ4€*m~~o€ÀSH|ùóç@RN:téÔÀ¹ÓL5R9À6h¿›OO-€ÀRÖö>Å÷§@8͛6lÙ³@RN:téÔÀžíݹOÀ7= åw±p€ÀR›§õ]9#@9û÷ïß¿~@RN:téÔÀžÑð€ZÅ­À8 gsИY€ÀR^áHû@@;*T©R¥J@RN:téÔÀžµÂÑúÀ8Ëÿ¶©€ÀR n8Ð@µjÕ«V­@RN:téÔÀžY{{£_ÿÀ:Ü^ƒ–Ê€ÀQ^/=q)˜@?ãǏ{]€ÀP +EÖÛ.a@Bå˗.\¹@RN:téÔÀ·(ŒÙ6À>TÕ6€ÀO‰î­É²B@C|ùóçϟ@RN:téÔÀ·kpç=9À>pöÝ7¥,€ÀNþó'h‹ð@D(P¡B…@RN:téÔÀ·GBÅòŽÀ>ыÍ5Ð €ÀNsÌ 7>@D«V­Zµj@RN:téÔÀ·"§[`Ç!À?*’¶؀ÀM詀kž@EB… +(P@RN:téÔÀ¶ýš|ùóç@RN:téÔÀ¶fwœÌ¹{À@9q1Vj€ÀK7\œa]@H6lÙ³fÍ@RN:téÔÀ¶@ |ÍÆ`À@O¹ª[,ÀÀJ¯pôY8à@H͛6lÙ³@RN:téÔÀ¶jzƒeÀ@bœš2Ÿµ€ÀJ(Ó«LŽ@Idɓ&L™@RN:téÔÀµò¢L&þ[À@ršƒ/cQ€ÀI£^~ºtò@Iû÷ïß¿~@RN:téÔÀµËžA9yqÀ@mæÐDê€ÀI+FŠ(@J“&L™2d@RN:téÔÀµ€³cŸŽ&À@‰U/ϷՀÀHœQÊñ@K*T©R¥J@RN:téÔÀµ}šwïn$À@mžøüž€ÀHçە.‘@KÁƒ 0@RN:téÔÀµVsýæ‘)À@”Ô:hþÁ€ÀG›fÎë•@LX±bŋ@RN:téÔÀµ/F1§À@–¥Ÿæ"€ÀG°ŽË²º@Lïß¿~ýû@RN:téÔÀµ UÕàÀ@•þ“,K€ÀF ŸŒ‰#@M‡8pá@RN:téÔÀŽàìÀ@U1­š¬^€ÀCi ’\;€@PÔ©R¥J•@RN:téÔÀ³Ðå9¯{kÀ@DŽmœt€ÀBû¿¯ˆPú@Q @@RN:téÔÀ³ªŒáœÀÀ@2]Ôþ}€ÀB×`ŸÇb@Qkׯ^œ{@RN:téÔÀ³„aB€'À@Ž­5°Â€ÀB%êÅ'ú@Q·nÝ»ví@RN:téÔÀ³^e·—ÜNÀ@ ¬{ôu€ÀAœûýb‡X@R 0`@RN:téÔÀ³8f€ªÀ?æ²ÖH*€ÀAX ;c@RN:téÓ@RN:téÔÀ³ Bڔ³À?·¡ì”ö&€À@ôÍZ|Ð@Rš4hÑ£F@RN:téÔÀ²í² »»À?†Q„.H€À@’*.ÿ†c@Rå˗.\¹@RN:téÔÀ²È”XËõùÀ?RçLn‘€À@26­ð=@S1bŋ,@RN:téÔÀ²£Ž‡- ŠÀ?‹WJµé€À?šzéÉÌ@S|ùóçϟ@RN:téÔÀ²Ï0›?À>æbiíΡ€À>ð{=QÁÈ@Sȑ"D‰@RN:téÔÀ²Z·;ôlrÀ>­kš3ž€À>s7éØjî€À=Œ4Å ‡`@T_¿~ýû÷@RN:téÔÀ²ÉáÂ9.À>7zaŸw€À<ßÝ> ‹@T«V­Zµj@RN:téÔÀ±ï=g[SôÀ=úvÙ͊€À<7V9’@TöíÛ·nÝ@RN:téÔÀ±Ëù­úÔïÀ=ŒLŽrÊ€€À;’”B€Í@UB… +(P@RN:téÔÀ±©‡àíÀ=}ècS€À:ñŒkLU­@UŽ8páÃ@RN:téÔÀ±†Qˆ%ÊÀ=<÷g¶ +€À:T2$ze¹@UÙ³f͛6@RN:téÔÀ±cïM³¢ñÀ<üÝ­¢ €À9ºxn÷ÿu@V%J•*T©@RN:téÔÀ±AÚ;OögÀ<ºTº2‹w€À9$Qµm @VpáÇ@RN:téÔÀ± ÆÂÀ|ùóç@RN:téÔÀ°œ…6soÀ;j4œ O€À6i0Ю@WêÕ«V­Z@RN:téÔÀ°{Տÿ•À;%ÿÿ° +Q€À5çF46ƒ @X6lÙ³fÍ@RN:téÔÀ°[íB^ +}À:᣷‚á€À5h„wy5Z@X‚ @@RN:téÔÀ°Ê^ž€À3þ“|²0»@Ydɓ&L™@RN:téÔÀ¯ŸèääX¶À9ÏܵsÊǀÀ3‹Óøi“F@Y°`Áƒ @RN:téÔÀ¯‚@iÂÛÀ9‹œP&œ€À3ìñíž@Yû÷ïß¿~@RN:téÔÀ¯F8õŠœÀ9GˆðÁ ;€À2®Í€žU@ZGc€À1w@[*T©R¥J@RN:téÔÀ®\Z’õþ +À89ÒÂ7U€À1àoÿ¥@[uëׯ^œ@RN:téÔÀ®#oT ÐÀ7÷9þôЀÀ0Ž»Q©@[Áƒ 0@RN:téÔÀ­ë ð{kŸÀ7µÉ&ˆÃ€À0Vÿ‚ Ña@\ 4hÑ£@RN:téÔÀ­³nnWcÀ7s@C™ŠÒ€À/÷=§‹3Ÿ@\X±bŋ@RN:téÔÀ­|VÄÙõÀ71ìþÞþü€À/EHH§í@\€H‘"Dˆ@RN:téÔÀ­EØÝH{)À6ñ­²ú€À.—e­f9@\ïß¿~ýû@RN:téÔÀ­ó’PÚâÀ6°·Ë.õ€À-îåÉV@];víÛ·n@RN:téÔÀ¬Ú¥²oNœÀ6pàc<³€À-IKþix@]‡8pá@RN:téÔÀ¬¥îÑôÀ61‘Wñõ€À,šй!‹@]Ò¥J•*T@RN:téÔÀ¬qË6gR'À5òÎÈßCǀÀ, ?žê:@^<Þî{À5ޜk¥Q³€À+rQړ¬W@^iÓ§N:@RN:téÔÀ¬ ? ›“¯À5vý’hë|€À*Ý:ŒÇò@^µjÕ«V­@RN:téÔÀ«ØÒö—ÜÀ59õ1cro€À*KÞÃÊ|µ@_ @RN:téÔÀ«ŠöW=¿À4ý…äW” €À)Ÿ&ÌcŒ@_L™2dɒ@RN:téÔÀ«u§Ã0fÀ4Á±óʂ‰€À)3ù³çåŽ@_˜0`Áƒ@RN:téÔÀ«DåÉ µ@À4†{ZZ¶€À(­?‡ŽG@_ãǏ}ÑðÜÀ3Ÿä6`·‰€À&³B]‚Ž@`‰$H‘"@RN:téÔÀªY%_mÀ3gԇ›“ÿ€À&<8˜?@`®Ý»víÛ@RN:téÔÀª+ì€BGÀ30hžý$€À%ÈW”Ñâ,@`Ô©R¥J•@RN:téÔÀ©þ|¢øUÁÀ2ù ÊÞ5€À%WQ { +û@`útéÓ§N@RN:téÔÀ©Ñéþ€û²À2Ã}-©j €À$éJ»ž.@a @@RN:téÔÀ©¥Ö{˜ÔÀ2ýÀž»J€À$}…/: +@aF 0`Á@RN:téÔÀ©z@–<óqÀ2Y"RÀ^¿€À$š Þ<@akׯ^œ{@RN:téÔÀ©O&Ë{” À2$ꏔ €À#®=·íl"@a‘£F4@RN:téÔÀ©$‡™C–À1ñUþ|ä9€À#J^€ž@a·nÝ»ví@RN:téÔÀšúa~ŸC²À1Ÿd +CÅu€À"èë+Նn@aÝ:téÓ§@RN:téÔÀšÐ²ü›ÕMÀ1Œþ…ðè€À"‰ÒûÍ)þ@b 0`@RN:téÔÀš§z•U§xÀ1Ze y­€À"-¢õ@b(Ñ£F@RN:téÔÀš~¶Ík8}À1)VL‘é€À!ÒsDÄê^@bN:téÓ@RN:téÔÀšVf+š9*À0øæÀ†Qÿ€À!z v¿®­@bthÑ£F@RN:téÔÀš.‡9ú*À0ÉU=g€À!#Â9Yiò@bš4hÑ£F@RN:téÔÀš¢{'À0™àä’ž€À υ÷Ń@bÀ@RN:téÔÀ§à“ç]›À0kH7r©€À }I„þÑÔ@R³f͛6nÀ¹ž¡õfó€€ÀTŽ–™If?òå˗.\¹@R³f͛6nÀ¹ëá8`<¿ó-÷Z€ÀTŒëÙo7@å˗.\¹@R³f͛6nÀ¹›Ìµ·À(vZ¥€ÀT‡éâÜÀ]@ X±bŋ@R³f͛6nÀ¹˜D+N'QÀ ±DÀër€ÀT—ÊÊ@å˗.\¹@R³f͛6nÀ¹“TköŠŽÀæ¬'|ӀÀTsù`œ7ý@Ÿ>|ùóç@R³f͛6nÀ¹Œÿ³#`ùÀÊþ|à€ÀTe)ϐ@X±bŋ@R³f͛6nÀ¹…I Á#ÁÀrÖKI€€ÀTSW#X@ ‰$H‘"@R³f͛6nÀ¹|4%ŒFdÀ ‡ü¶T€ÀT=ÉQI5 @"å˗.\¹@R³f͛6nÀ¹qÅP9ϳÀ"ÌÏ¢X €ÀT%vÛè²@%B… +(P@R³f͛6nÀ¹fuÛ£À% ÇŒ¶k€ÀT + ÿ\„h@'Ÿ>|ùóç@R³f͛6nÀ¹Xî‰ÐÞÀ'<ù]æÇÀ€ÀSëÜå/ˆœ@)û÷ïß¿~@R³f͛6nÀ¹J‘7j;À)e{öŠï€ÀSÊÁŽ8›@,X±bŋ@R³f͛6nÀ¹:ñoˆùÀ+‚tIá7€ÀSŠèi‹ëý@.µjÕ«V­@R³f͛6nÀ¹*Én…À-“N4Ÿï€ÀS€k®æÝ¹@0‰$H‘"@R³f͛6nÀ¹É*†À/– å{ڀÀSWg®ôUú@1·nÝ»ví@R³f͛6nÀ¹É\i­ØÀ0Æ4>öWt€ÀS+ùçÄþ@@2å˗.\¹@R³f͛6nÀžðhÔ<šÀ1¹æÇÖµc€ÀRþ@ûÜu³@4(P¡B…@R³f͛6nÀžÚìٜ4À2Š üÛËm€ÀRÎ\‚:þM@5B… +(P@R³f͛6nÀžÄ^e‡À3Š€hÄ€ÀRœlÕÒÒG@6páÇ@R³f͛6nÀž¬ÆµŠ²FÀ4g9Ê ,€ÀRh’äà&Ž@7Ÿ>|ùóç@R³f͛6nÀž”/B©P…À5;³õ‘_€ÀR2ð¡Óï@8͛6lÙ³@R³f͛6nÀžz¡·b VÀ6ìÙsŠ€ÀQû¥­õƒ¹@9û÷ïß¿~@R³f͛6nÀž`'æ'ŸÀ6ËÆÈ>ÞR€ÀQÂÕw\c@;*T©R¥J@R³f͛6nÀžDË¿>̘À7‡+üÒhâ€ÀQˆ Àè²¢@d€ÀQM(ž–ß÷@=‡8pá@R³f͛6nÀž ”•>߈À8äfïÑŽ€ÀQ­x%¶@>µjÕ«V­@R³f͛6nÀ·íͺcvŸÀ9†5ñ‡Š`€ÀPÒïð…8@?ãǏø@BN:téÓ@R³f͛6nÀ·NˁäŠÏÀ<0圔°V€ÀO'±®·Ëû@Bå˗.\¹@R³f͛6nÀ·-<Ÿ3Ÿ3À< Ìd¹Ç€ÀN¥5ŠÏ§õ@C|ùóçϟ@R³f͛6nÀ· .€¬©÷À=È +áʀÀN"Až·@D(P¡B…@R³f͛6nÀ¶èª HL€À=i`‰,€ÀMŸàÂÚê@D«V­Zµj@R³f͛6nÀ¶ÅžsfAÉÀ=ÁŸíÛ&;€ÀM«rµõX@EB… +(P@R³f͛6nÀ¶¢b”ž +RÀ>ÓćŽÙ€ÀL˜bÜI˜g@EÙ³f͛6@R³f͛6nÀ¶~±†ÄÀ>\Ëù:~€ÀLRÇ­,@FpáÇ@R³f͛6nÀ¶Z¬lò–SÀ>Ÿ¶º%%рÀK’¡ƒÑÑ@G @@R³f͛6nÀ¶6\È^mÀ>ÛÉ!Õ7րÀKs¥"ê@GŸ>|ùóç@R³f͛6nÀ¶Ê!žœzÀ?5Ĭø€ÀJŽé'âr @H6lÙ³fÍ@R³f͛6nÀµìü5)wÀ?@0ÿT&x€ÀJ#VãÁ@H͛6lÙ³@R³f͛6nÀµÇú8†õÀ?hïÎ{ h€ÀIŽ?㉈@Idɓ&L™@R³f͛6nÀµ¢Ì=î9 À?‹§—Wû€ÀIWE¡@Iû÷ïß¿~@R³f͛6nÀµ}xp3FÀ?šöN1€ÀH‘†Qj[ä@J“&L™2d@R³f͛6nÀµXÕ;ëÄÀ?¿Ø‘¿râ€ÀHâÁЫœ@K*T©R¥J@R³f͛6nÀµ2zíoaãÀ?ÑŒõ39*€ÀG™‚€4pÙ@KÁƒ 0@R³f͛6nÀµ ÝùÁaÀ?ÞplI™f€ÀGyi}g¥@LX±bŋ@R³f͛6nÀŽç4ü}sÈÀ?æ'åA +€ÀFŠÙªW¡Û@Lïß¿~ýû@R³f͛6nÀŽÁ…¹°1µÀ?éÔVŸœ€ÀF/³Ñ]Dy@M‡8pá@R³f͛6nÀޛշÅ!ÈÀ?çtIﷀÀEºàšà@NË@Q·nÝ»ví@R³f͛6nÀ³&栎-ÂÀ?ÃZâï€ÀArˆC3Ü@R 0`@R³f͛6nÀ³Y¯ \ÎÀ>Þ? +ýZ"€ÀA–L_£@RN:téÓ@R³f͛6nÀ²ÝüÚW‚ìÀ>ŽQ~øŽÜ€À@ŒnMJ»@Rš4hÑ£F@R³f͛6nÀ²¹Òä; À>ˆ!äógQ€À@]ûgHlÏ@Rå˗.\¹@R³f͛6nÀ²•Þ`èºÀ>YÖ6b*€À@4RÛ²@S1bŋ,@R³f͛6nÀ²r!žlÌ,À>)“5e€À?LŒµk$!@S|ùóçϟ@R³f͛6nÀ²NŸ()kÐÀ=÷|hÀÏ>€À>š`Ӑƒ@Sȑ"D‰@R³f͛6nÀ²+XÄ@ßÀ=ÃŽŠíº€À=ëßµè@T(P¡B…@R³f͛6nÀ²Py Ì¿À=Ž[hG·€À=Aa†Œ¹@T_¿~ýû÷@R³f͛6nÀ±åˆ ‹À=W’åä€À<™ÄЃ|s@T«V­Zµj@R³f͛6nÀ±Ãæ[8À=vØqÉb€À;öþgH%@TöíÛ·nÝ@R³f͛6nÀ± œ0Í ÖÀ<æ'zÿh€À;V+°%@UB… +(P@R³f͛6nÀ±~œšíh¢À<«ŸÏ|˜€À:¹g:•@UŽ8páÃ@R³f͛6nÀ±]™\qCÀu%€À: FӔì@UÙ³f͛6@R³f͛6nÀ±;Gû?ÀÀ<4$ª +þ€À9Š“þ4¹­@V%J•*T©@R³f͛6nÀ±d€Öô À;öü0ñ©€À8øB×¶ÂÍ@VpáÇ@R³f͛6nÀ°ù‘‚™ÅÀ;¹4îžiހÀ8iG,Ï@VŒxñãǏ@R³f͛6nÀ°ØçÔjgÀ;zÐÍaŸ€À7ݓá-«T@W @@R³f͛6nÀ°ž˜ÊéÀ;;å%ãø|€À7UZÖ@WS§N:t@R³f͛6nÀ°˜’öÐÙÀ:ü†C×€À6ÏÒšH @WŸ>|ùóç@R³f͛6nÀ°xØæ…pdÀ:ŒÇfŽçè€À6Mš`â@WêÕ«V­Z@R³f͛6nÀ°YjPQlÁÀ:|ºÑ†‡€À5Ώȳ‘@X6lÙ³fÍ@R³f͛6nÀ°:G…yÎÀ:À5TqžÜ€À*ðz䯒s@^µjÕ«V­@R³f͛6nÀ«¯XÑ¡0À4Úž^3cç€À*`„? qm@_ @R³f͛6nÀ«~Z]{æéÀ4 ¥¹Hßç€À)Ô7®ôq@_L™2dɒ@R³f͛6nÀ«MäröâŸÀ4gµ÷ú €À)K•3dz@_˜0`Áƒ@R³f͛6nÀ«õÅm­:À4.%§jf˜€À(ўGµ:@_ãǏ€À ŸS“ŠåT@S0`ÁƒÀ¹›&ŸŽ€€ÀSÇÄ$l;?òå˗.\¹@S0`ÁƒÀ¹ð ÁпòâÒoŸ€ÀSÆ8< Œ‚@å˗.\¹@S0`ÁƒÀ¹ï&l—À§ƒ’š©€ÀSÁ•ž•õ-@ X±bŋ@S0`ÁƒÀ¹™&i…ÀÀ l}–ó·€ÀS¹à9 +M€@å˗.\¹@S0`ÁƒÀ¹ïՇd€Àʹ…»e€ÀS¯¿2Œæ@Ÿ>|ùóç@S0`ÁƒÀ¹õJÝʌÀwƒá‘•€ÀS¡V¥ ¡r@X±bŋ@S0`ÁƒÀ¹¬JbEàÀÞßÈÝŠ€ÀS•ŽÁk@ ‰$H‘"@S0`ÁƒÀžÿ.šÀ9ç ±À€ÀS|çTÊÍÐ@"å˗.\¹@S0`ÁƒÀžõ<áÔ2À!ÃZì®­f€ÀSfZôU©R@%B… +(P@S0`ÁƒÀžêÞD`À#á»ÆŒd‹€ÀSMoËO@'Ÿ>|ùóç@S0`ÁƒÀžÝÃ#gEÀ%÷6¬l€ÀS0í±œÇ@)û÷ïß¿~@S0`ÁƒÀžÐ/2^ÿ•À(ô~ӊ”€ÀS4s÷c@,X±bŋ@S0`ÁƒÀžÁiÀuºÀ*-ÎÛôÀ€ÀRðìÈc@.µjÕ«V­@S0`ÁƒÀž±wf±iÀ+ú%b×õ€ÀRÍ,|ÝàÕ@0‰$H‘"@S0`ÁƒÀž `&ÚUìÀ-ä*žOšV€ÀR§ë8º@1·nÝ»ví@S0`ÁƒÀžŽ+‹\ø{À/Áœy’©€ÀR~­Úk=@2å˗.\¹@S0`ÁƒÀžzàÖš¿WÀ0ÈópbŒ€ÀRT$Ñ#@4(P¡B…@S0`ÁƒÀžf‡õu,À1ªBK ‡O€ÀR'8@5B… +(P@S0`ÁƒÀžQ)ÒS À2„‹Œ€ÀQù 5èû@6páÇ@S0`ÁƒÀž:ÌËkûÀ3WwZ&Ì̀ÀQȹvô‘@7Ÿ>|ùóç@S0`ÁƒÀž#{¬Æ"ŸÀ4"ýKO…€ÀQ–³N—\@8͛6lÙ³@S0`ÁƒÀž >€„9µÀ4æìJ°ë€ÀQc^s@9û÷ïß¿~@S0`ÁƒÀ·ò»ËéOÀ5£&9}5€ÀQ.·ÙŽ¿@;*T©R¥J@S0`ÁƒÀ·Ø%ӑÉÀ6W•›Ï€ÀP÷Ÿqр=@µjÕ«V­@S0`ÁƒÀ·…z ­ÃÀ8Eœ 9§ç€ÀPMƒê…!Ð@?ãǏ|ùóç@S0`ÁƒÀµ¿'\‚‰À=Áš*#z`€ÀIënñ¹@H6lÙ³fÍ@S0`ÁƒÀµ›ä¬U}À=ò•Y&鿀ÀIpÔë~•N@H͛6lÙ³@S0`ÁƒÀµxk©jýÀ>ž«S'Ž€ÀH÷LãšÊ­@Idɓ&L™@S0`ÁƒÀµTÃ=:J,À>Bæ[) €ÀH~šñ +Œ@Iû÷ïß¿~@S0`ÁƒÀµ0ò)ÅíÀ>b 4ü€ÀH×ڏ6g@J“&L™2d@S0`ÁƒÀµ þ­ž.­À>|óŒµìœ€ÀG£4,²@K*T©R¥J@S0`ÁƒÀŽèï=ruÀ>’ЩA(€ÀGxšk3@KÁƒ 0@S0`ÁƒÀŽÄÉÈYÁwÀ>¢C®Ÿ9p€ÀFŠjG¬Ç@LX±bŋ@S0`ÁƒÀŽ ”Šp"À>­žÐCMÁ€ÀF2Ó*\ãÈ@Lïß¿~ýû@S0`ÁƒÀŽ|Sœ„À>Ž\‘Üüb€ÀEÀòlKÑ@M‡8pá@S0`ÁƒÀŽX;|ßÀ>¶¬êîX€ÀEPrL³óÓ@NŽ¿Rq0рÀDá`‚› +@NµjÕ«V­@S0`ÁƒÀކõ}Ä>À>®Â²Ì H€ÀDsÉkG‚ú@OL™2dɒ@S0`ÁƒÀ³ëO"°ó†À>€åN®ЀÀDž§Ä³@OãǏ—TŽu4€ÀC6w’q@P=zõëׯ@S0`ÁƒÀ³£ Q+/À>†=®B”€ÀC4M$’ç‘@P‰$H‘"@S0`ÁƒÀ³ Š/Ê À>qÌ3wØí€ÀBÍŽâ;$@PÔ©R¥J•@S0`ÁƒÀ³[$T€&À>Z+]ÞCc€ÀBg`¥¥Œ²@Q @@S0`ÁƒÀ³7ZxdÀ>?…\ÿŒÆ€ÀBiní@Ä@Qkׯ^œ{@S0`ÁƒÀ³±^XÕ%À>"lþ_ €ÀA¡"o˜*@Q·nÝ»ví@S0`ÁƒÀ²ð-•ø{À>ÍÍ¡_€ÀA@ž†Ï@R 0`@S0`ÁƒÀ²ÌÐ`ݟãÀ=ß ºž2û€À@á³Käc{@RN:téÓ@S0`ÁƒÀ²©ž&$1ëÀ=¹ãdì€Ï€À@„zª‘Š@Rš4hÑ£F@S0`ÁƒÀ²†™&â×÷À=’yí4߀À@)%C_@@Rå˗.\¹@S0`ÁƒÀ²cÃ÷\ñÀ=hó^Š +å€À?žêfôÎ@S1bŋ,@S0`ÁƒÀ²A!åfXÀ==r¬/9'€À>îüÃÕ:e@S|ùóçϟ@S0`ÁƒÀ²²ˆ4•ÓÀ=¬žðX€À>B€Ò”éB@Sȑ"D‰@S0`ÁƒÀ±üz€°-À<á Žs€À=™shˆ¹@T(P¡B…@S0`ÁƒÀ±Ú{KÀÝùÀ<°`ñ=U€À<óÏ Îçd@T_¿~ýû÷@S0`ÁƒÀ±ž¶M%S¶À<~>…cã€ÀUé…qo@VŒxñãǏ@S0`ÁƒÀ°³q:0ÒmÀ:ÄõNsj^€À7¶Lã*8@W @@S0`ÁƒÀ°“õÓIk¹À:Š4£ˆ€À70ãÉ|å@WS§N:t@S0`ÁƒÀ°tÀ!ÕèÀ:Nî<&0»€À6®ž`è;„@WŸ>|ùóç@S0`ÁƒÀ°Uз° À:6ÓæC€À6/…Ÿ/ùú@WêÕ«V­Z@S0`ÁƒÀ°7(ºkÀ9×¶ i€À5³?B.‘à@X6lÙ³fÍ@S0`ÁƒÀ°Ə,ŒÒÀ9š¹ãéž-€À59Ø4H:@X‚ @@S0`ÁƒÀ¯õY– ÒÀ9^zê×ã€À4ÃCûÖl@X͛6lÙ³@S0`ÁƒÀ¯¹Ž|”ŸÀ9!AŒ=€À4Osðݜ@Y2dɓ&@S0`ÁƒÀ¯~ŸÛ«y3À8äMjÊ€À3ÞZºÌ&-@Ydɓ&L™@S0`ÁƒÀ¯D@ŠìgÀ8§EƒڀÀ3o쑊*w@Y°`Áƒ @S0`ÁƒÀ¯ +&Ú·}&À8j6°:KQ€À3Àà1@Yû÷ïß¿~@S0`ÁƒÀ®Ðªž¥ÙÀ8--áÛ+€À2šØÐZå@ZGÀG@_ @S0`ÁƒÀ«UžÕf?7À4D¡³B.€À)æ@P”@_L™2dɒ@S0`ÁƒÀ«%ÿž•ä|À4 XVŽŠ;€À)^@yÐ]@_˜0`Áƒ@S0`ÁƒÀªöâjùõÀ3֎U <€À(Ú&|$GŸ@_ãǏy7#KȀÀ#'Æ€9¶@aÝ:téÓ§@S0`ÁƒÀš•èûÖFàÀ15/\³÷€À"É,@  ·@b 0`@S0`ÁƒÀšmÔ×±üÀ0à}’×GȀÀ"lòÃR@b(Ñ£F@S0`ÁƒÀšF,ÉcãôÀ0²RTý€À"àŸŠ@bN:téÓ@S0`ÁƒÀšñö$ÅDÀ0„²þ¹€À!ºéb(Ÿà@bthÑ£F@S0`ÁƒÀ§ø"DôŸ¡À0W2CüW€À!dþgV£@bš4hÑ£F@S0`ÁƒÀ§ÑŒn ÊÀ0+» š1€À! ]‹u@bÀ@S0`ÁƒÀ§«¿+[ÌùÀ/þ#ô“€À ¿¢ŽG@S|ùóçÏ Àž¥V„/D€€ÀS ósÞi?òå˗.\¹@S|ùóçÏ Àž€ŽÍ18è¿ñœ›>Äò€ÀS +„GC @å˗.\¹@S|ùóçÏ Àž¢ÏñN:ÀãÜY€ÀS7ØrÃ0@ X±bŋ@S|ùóçÏ ÀžŸšÊu©ÀÀ ›º>çë€ÀRÿbêÑì@å˗.\¹@S|ùóçÏ Àž›@ÂÛÆÆÀ ‹QÊƀÀRõDí¥Ä@Ÿ>|ùóç@S|ùóçÏ Àž•™ÓµÀ=ž,_C€ÀRèMõ$@X±bŋ@S|ùóçÏ ÀžŽ¶ɝHÀh@IۀÀRØÁ÷öOK@ ‰$H‘"@S|ùóçÏ Àž†™ÖLmxÀ‡šŒ)0€ÀRÆ}Ë^œQ@"å˗.\¹@S|ùóçÏ Àž}Gi)JÚÀ Í *º¥€ÀR±Ž×œ±æ@%B… +(P@S|ùóçÏ ÀžrÃKŠ2À"ÎõY3v€ÀRšWŒL–@'Ÿ>|ùóç@S|ùóçÏ Àžg áp¡À$È¿X”V€ÀRï>À‰Š@)û÷ïß¿~@S|ùóçÏ ÀžZ8²dKªÀ&¹§„å@N€ÀRcbGzš@,X±bŋ@S|ùóçÏ ÀžL<²8ëlÀ( ôœ^3 €ÀRDq >ÍM@.µjÕ«V­@S|ùóçÏ Àž=#ì”w}À*}ønGÑg€ÀR#1u4Çv@0‰$H‘"@S|ùóçÏ Àž,ô¥ÚgÀ,Psf„€ÀQÿºžÅ@1·nÝ»ví@S|ùóçÏ Àžµ{íÝ6À.ŠL/ ¶€ÀQÚ"¥ƒ†;@2å˗.\¹@S|ùóçÏ Àž mf,ÈÃÀ/Ñ0,[{€ÀQ²ƒÿ—F;@4(P¡B…@S|ùóçÏ À·ö#Š¢C†À0¿˜’«Åî€ÀQˆ÷¶6@5B… +(P@S|ùóçÏ À·á߯hFÀ1®ô;€ÀQ]˜–‚p@6páÇ@S|ùóçÏ À·Ì©‹#2À2Yó’5à€ÀQ0å:öÎ@7Ÿ>|ùóç@S|ùóçÏ À·¶ˆóEPGÀ3ñÕ€ÀQÊdž_@@8͛6lÙ³@S|ùóçÏ À·Ÿ†*;;<À3ØòY¯Ø0€ÀPѓ>Ô@9û÷ïß¿~@S|ùóçÏ À·‡©‚ÂöëÀ4ÙO³C†€ÀPŸõ`¿hì@;*T©R¥J@S|ùóçÏ À·nûnöOMÀ5;Žƒb(€ÀPm »\@µjÕ«V­@S|ùóçÏ À· ^[æ?À7ônx€ÀO›HqBš @?ãǏ|ùóç@S|ùóçÏ Àµn²À<ƒ ‡“œ€ÀIKŸR¬ù@H6lÙ³fÍ@S|ùóçÏ ÀµL¶P›‡ À<µ©©™E׀ÀHׁì2LØ@H͛6lÙ³@S|ùóçÏ Àµ*²„_vÿÀ<✠7€ÀHcþ'#œ:@Idɓ&L™@S|ùóçÏ Àµ|ãó{À= +p]ºã€ÀGñ-Æ¢î4@Iû÷ïß¿~@S|ùóçÏ ÀŽæÒ0¹^À=,4"jh[€ÀG(âGé@J“&L™2d@S|ùóçÏ ÀŽÃ•}²§ÅÀ=I11ýŠ€ÀGñ»Ý+@K*T©R¥J@S|ùóçÏ ÀŽ ïà~™nÀ=a4ÿÞÚE€ÀFÙÙ|Ê@KÁƒ 0@S|ùóçÏ ÀŽ~0¿ÕÞ«À=tlM@H€ÀF.·÷â:¬@LX±bŋ@S|ùóçÏ ÀŽ[]¬/äÀ=ƒFœt#€ÀEÀ²2hiž@Lïß¿~ýû@S|ùóçÏ ÀŽ8|R‡qÀ=(Ú¬)xž§@S|ùóçϟ@S|ùóçÏ À±ïQ‚LüdÀ<0 EåúY€À=é £ìŒo@Sȑ"D‰@S|ùóçÏ À±Î£×ú2À<eäUP€À=Edÿ7Ã.@T(P¡B…@S|ùóçÏ À±­!)öœÀ;Ù"ßQ€À<€Ûn ó@T_¿~ýû÷@S|ùóçÏ À±ŒWÂ6Ö¥À;«^î!ۀÀ<€%@L@T«V­Zµj@S|ùóçÏ À±kÅKûCÀ;|5ìßੀÀ;mN; 'Ö@TöíÛ·nÝ@S|ùóçÏ À±KkKhXpÀ;KÄÀ‰oø€À:Ö@õk@UB… +(P@S|ùóçÏ À±+K8'ˆCÀ;$šiÎЀÀ:BOEÆÁo@UŽ8páÃ@S|ùóçÏ À± fh;yÀ:çn»è€À9±tmw“@UÙ³f͛6@S|ùóçÏ À°ëŸu-yÀ:³»¹h^€À9#§¡r«~@V%J•*T©@S|ùóçÏ À°ÌS]ÈKÀ: Æï:ɀÀ8˜àNsñ@VpáÇ@S|ùóçÏ À°­'DnÏžÀ:Iµþdaÿ€À8K’ð”@VŒxñãǏ@S|ùóçÏ À°Ž:³bŒÀ:æ»Ð®€À7Œ<ïü@W @@S|ùóçÏ À°oŽzÖMÒÀ9ÜŸYêT€À7 +Mïú|@WS§N:t@S|ùóçÏ À°Q#U˜yÂÀ9¥aØ&qb€À6‹;8ép@WŸ>|ùóç@S|ùóçÏ À°2ùæÜ• À9m £Ù €À6ümÎW‘@WêÕ«V­Z@S|ùóçÏ À°Œí ÎÀ95-£Q6€À5•…}«@X6lÙ³fÍ@S|ùóçÏ À¯îÜ£»¢À8ü|L_•€À5ÊêÞЂ@X‚ @@S|ùóçÏ À¯Žð€À8Ã|B*8y€À4ªÀüšàV@X͛6lÙ³@S|ùóçÏ À¯yރ#ûûÀ8Š;Ãûˆ6€À49[Éc›@Y2dɓ&@S|ùóçÏ À¯@*hŠPÀ8PÉWuK€À3ʏ>C¹¶@Ydɓ&L™@S|ùóçÏ À¯þ/$fÅÀ82{3ã€À3^O+2ž'@Y°`Áƒ @S|ùóçÏ À®ÎZÎm9À7݃ñ€À2ôGЀþ@Yû÷ïß¿~@S|ùóçÏ À®–>^Ÿ€À7£Éą‘€À2C<š‚.@ZGœÐÀ6ƒï97*€À0«ßÊœ.p@[Áƒ 0@S|ùóçÏ À­PÂÔ_›À6JÇm#Ò¬€À0RGxµ¶@\ 4hÑ£@S|ùóçÏ À­]‹ïËÎÀ6Ör¡€À/õ™óâòn@\X±bŋ@S|ùóçÏ À¬è~}²ÚÀ5Ù#ƒ\ڀÀ/Jȟ+z"@\€H‘"Dˆ@S|ùóçÏ À¬µ%ŒŽMÀ5 µ^;7ä€À.€3YÉ@\ïß¿~ýû@S|ùóçÏ À¬‚P•µ$À5h’Eû¶à€À.24%ò@];víÛ·n@S|ùóçÏ À¬PXx@À50À +þeç€À-b>p;ÊR@]‡8pá@S|ùóçÏ À¬3“wžÀ4ùDaË`€À,Ç€~•@]Ò¥J•*T@S|ùóçÏ À«ìéta" À4Â#Ld,€À,/“Zœ€ú@^šSŽ@`=zõëׯ@S|ùóçÏ ÀªG«-°þ‡À2䇮ÜΗ€À't2¡p›-@`cF4h@S|ùóçÏ ÀªFdíH À2±»ó=ҋ€À&ýíÜH @`‰$H‘"@S|ùóçÏ À©ïYgØúÀ2lOkô€À&ˆ¯f׋@`®Ý»víÛ@S|ùóçÏ À©Ãáî8TÀ2Mš4ѐ?€À&Éx(@`Ô©R¥J•@S|ùóçÏ À©˜ßïVG±À2FÝï˜/€À%š#^óÛ@`útéÓ§N@S|ùóçÏ À©nQÞñ‘À1ësSH=ì€À%;ΊL‹@a @@S|ùóçÏ À©D6‹sÿ.À1» pU9U€À$ÒÏ®â@aF 0`Á@S|ùóçÏ À©ŒÆÃœRÀ1‹Nåℷ€À$j³Z…f@akׯ^œ{@S|ùóçÏ ÀšñS]Ê QÀ1[ÿ<±[€À$ÌGɋs@a‘£F4@S|ùóçÏ ÀšÈ‰=ŠåÀ1-1×û·€À#£?:n&È@a·nÝ»ví@S|ùóçÏ Àš ,Ñ߀À0þæ÷ԛ"€À#BüåNªé@aÝ:téÓ§@S|ùóçÏ Àšx=DÒ\ À0Ñ»tœ€À"äöZ _@b 0`@S|ùóçÏ ÀšP¹CÙ¡åÀ0£Ù#[Ý̀À"‰Œ-@b(Ñ£F@S|ùóçÏ Àš)Ÿ™«DŒÀ0wbyå€À"/b¹)Ñ@bN:téÓ@S|ùóçÏ Àšï+GÀ0JÕTª̀À!×¹“€3%@bthÑ£F@S|ùóçÏ À§ÜŠyÝ0À0—vu#€À!‚Ô_@bš4hÑ£F@S|ùóçÏ À§¶ÄžȈÀ/ç²éÚøÝ€À!.e7¢i@bÀ@S|ùóçÏ À§‘HMO‘xÀ/’:á ü€À ܟœ%É@SáÇ:Àž/k:q€€ÀRZWêÝí?òå˗.\¹@SáÇ:Àž.÷i†­ù¿ð0ßì;ž€ÀRYé±ëŸ@å˗.\¹@SáÇ:Àž-,ŠÅn©À-]lÚæ²€ÀRUà;J>@ X±bŋ@SáÇ:Àž*0éµÖÀ;J"ãì€ÀRN`³!/Ý@å˗.\¹@SáÇ:Àž&|‡Û]À`& câ€ÀRE0ÆGÔ@Ÿ>|ùóç@SáÇ:Àž ¬+Uô•À+Y…Ä€ÀR9;‡áü@X±bŋ@SáÇ:Àž'Aõ8ƒÀ Z_È*ȀÀR*È¿·É@ ‰$H‘"@SáÇ:Àžy‰;^{ÀôK,1g=€ÀRΣ£o.@"å˗.\¹@SáÇ:Àž ŠC°ŽÀÐgËûåW€ÀRXžÔ[D@%B… +(P@SáÇ:À·ÿ±)º†PÀ!ϔ@ü€ÀQðt§¬@'Ÿ>|ùóç@SáÇ:À·ôžeEwåÀ#¯‰ÏòUx€ÀQØ1Š–Øq@)û÷ïß¿~@SáÇ:À·èrŒøõ¶À%‡aM–M؀ÀQœ b\ @,X±bŋ@SáÇ:À·Û2žýÖÀ'Voìr䧀ÀQ Òâo"i@.µjÕ«V­@SáÇ:À·ÌãûbðÇÀ)Ø k÷€ÀQÜp‡‘@0‰$H‘"@SáÇ:À·œŒ^+$«À*×¹¯«Ë€ÀQ`Ñx®L@1·nÝ»ví@SáÇ:À·­1ÙÏÀ,ˆÒZL¶ã€ÀQ=Çi4ÆQ@2å˗.\¹@SáÇ:À·›ÚÍ (ËÀ..Þ€ÕMV€ÀQԙj’/@4(P¡B…@SáÇ:À·‰ã¢RËÀ/ÉiJˋ€ÀPò#(@5B… +(P@SáÇ:À·vRRÀ0¬dVs¡€ÀPɑÅðK@6páÇ@SáÇ:À·b.`£U«À1m/¬ÓπÀPŸqÂo?Ù@7Ÿ>|ùóç@SáÇ:À·M*G8o…À2( {xÛ^€ÀPsÈÁÖùq@8͛6lÙ³@SáÇ:À·7MBßõBÀ2Üz«z°7€ÀPF¯­ëêw@9û÷ïß¿~@SáÇ:À· Ÿ³„ÂÀ3ŠYÝÏ£·€ÀP?–Nìä@;*T©R¥J@SáÇ:À· 'Mb}À41“a-àW€ÀOÑ##Šï+@µjÕ«V­@SáÇ:À¶ŸVÒNµÀ5þÂ͓Æ€ÀNŠõ\qì@?ãǏœæ *@@‰$H‘"@SáÇ:À¶‰x2Ñ À79#ú~ÀÀMÕÚdöW>@A @@SáÇ:À¶m“UÊ À7ŽÇ ‡C.€ÀMkÅÏäl@A·nÝ»ví@SáÇ:À¶Qv0¶¶ÜÀ8˜²ÓÑì€ÀM5*°8g@BN:téÓ@SáÇ:À¶4Ò<ÃO3À8wŒiŠêh€ÀL“ÌgŒ@Bå˗.\¹@SáÇ:À¶¬ãUÀ8âD +fº€ÀL&éË×@C|ùóçϟ@SáÇ:Àµú ïtPÀ9FD°”à€ÀKž¢€Ö–@D(P¡B…@SáÇ:ÀµÛû!źÀ9£Öjv€ÀKJ2ܰ{@D«V­Zµj@SáÇ:Àµœ~L>sÀ9ûö)œê€ÀJÛaÔ"Ýp@EB… +(P@SáÇ:Àµžž=[\OÀ:LuuÀÀJlVP%3@EÙ³f͛6@SáÇ:ÀµbsŽþÀ:— *|- €ÀIý2֞Ï@FpáÇ@SáÇ:Àµ_Ðù¶àÀ:Ü9¥}ӀÀIŽèœq@G @@SáÇ:Àµ?ñÚã¿¡À;%h1ýr€ÀI!75© @GŸ>|ùóç@SáÇ:Àµˇ̱ À;T™ãÙț€ÀH°q˜rgF@H6lÙ³fÍ@SáÇ:ÀŽÿd¡Ï…fÀ;ˆ„ ð‹¥€ÀHB#ß]ä@H͛6lÙ³@SáÇ:ÀŽÞ݊ÀvÀ;· +> òï€ÀGÔP/Ñü@Idɓ&L™@SáÇ:ÀŽœîÁÌÄzÀ;àS©×\ü€ÀGg}õžÂ@Iû÷ïß¿~@SáÇ:Àޜì&žypÀ<ˆÁý€ÀFú~€Fˆã@J“&L™2d@SáÇ:ÀŽ{ÁµHâÀ<#ÏîÁI3€ÀFŽ­a;¬@K*T©R¥J@SáÇ:ÀŽZu%ðxžÀ<>SŒ›Pð€ÀF#²ÆŒ@KÁƒ 0@SáÇ:ÀŽ9 (’À=L€ÀB1©µ@U@PÔ©R¥J•@SáÇ:À²éÜñDÒÀԔî¶!@S1bŋ,@SáÇ:À±àÞÛ/¡1À;{í+aV܀À>0 +¹jä?@S|ùóçϟ@SáÇ:À±À~@ ê1À;W%jŒ¥«€À=Ž€€­–Á@Sȑ"D‰@SáÇ:À± J òNÀ;0ŸüÊÀÀ<ïõgƒ’@T(P¡B…@SáÇ:À±€Dxr:À;y¢¶ž€À|ùóç@SáÇ:À°X5xÀ8˜øûú'€À5ì<:o!=@WêÕ«V­Z@SáÇ:À¯æ\ݎÔÀ8–áTf€À5u‘(ËN@X6lÙ³fÍ@SáÇ:À¯¬„îŠÝ÷À8ažRy[€À5€ÇJw@X‚ @@SáÇ:À¯s+Þ‡NÀ8,0úm£/€À4Ÿµe,@X͛6lÙ³@SáÇ:À¯:Q U#ÕÀ7öZDJŸ€À4!ä0\@Y2dɓ&@SáÇ:À¯ößþWŒÀ7ÀB=­S€À3ކ …dá@Ydɓ&L™@SáÇ:À®Ê'z £À7‰ö;ó +€À3Ju¹Ñ…J@Y°`Áƒ @SáÇ:À®’Á₁#À7S‚âlò€À2âÉçvŽ@@Yû÷ïß¿~@SáÇ:À®[è_Å¥À7ô'mû9€À2}wYɰ@ZG€À&+8fî@`Ô©R¥J•@SáÇ:À©vŽÁYÀ1Óu7Ϟ0€À%œ¶¶í@`útéÓ§N@SáÇ:À©LÐ ëè˜À1€SÕæ {€À%Qœ‡F@a @@SáÇ:À©#ZÆ xÀ1ušCà“Ø€À$èyFo„!@aF 0`Á@SáÇ:ÀšúS1°oCÀ1Gsq‘‘N€À$Ñº:p{@akׯ^œ{@SáÇ:ÀšÑž6ei À1¶&0 ÷€À$‡‹I@a‘£F4@SáÇ:À𩈹 þ›À0ìqûQ€À#»Š îÏ@a·nÝ»ví@SáÇ:ÀšÃ40BÀ0¿€„w¥€À#[Ì_Êxï@aÝ:téÓ§@SáÇ:ÀšZgÅnìÀ0“QÐ7H€À"þ?n/Ì@b 0`@SáÇ:Àš3t˜é²À0gvÅÆd܀À"¢ÕKýêc@b(Ñ£F@SáÇ:Àš çi'YÀ0<ßȶՀÀ"I€O(–Ð@bN:téÓ@SáÇ:À§æÀ§e‰À0.W£D1€À!ò3!™œ@bthÑ£F@SáÇ:À§Àþ¯¹hbÀ/̀,Ÿ©€À!œà¿îQ~@bš4hÑ£F@SáÇ:À§› cÚÆÙÀ/y•ÙYšè€À!I|x#”P@bÀ@SáÇ:À§v€Š ôÖÀ/&*Ey׀À ÷ùè73@TF4hÒÀ·Ÿ +ã ÿð€€ÀQ²76®Ä7?òå˗.\¹@TF4hÒÀ·œyù#WL¿î«VʝB€ÀQ°úŸ…Í@å˗.\¹@TF4hÒÀ·»ÇwŸ—=¿þ€î!$蚀ÀQ­CÂæ@ X±bŋ@TF4hÒÀ·žôh—rÀó³ƒìó€ÀQ§@g\@å˗.\¹@TF4hÒÀ·µü€æÀ‹aì‡C؀ÀQžvt»gI@Ÿ>|ùóç@TF4hÒÀ·¯ïÔ¥œjÀ 2#/$€ÀQ“j,Жš@X±bŋ@TF4hÒÀ·©Âµcè:ÀÈ՜A}€ÀQ…ù +ÀO@ ‰$H‘"@TF4hÒÀ·¢|*4dvÀ}Á¡€ÀQv,WàŠ@"å˗.\¹@TF4hÒÀ·š-Üm9À&“£N¿€ÀQdÔ°+ @%B… +(P@TF4hÒÀ·¯'.À áéIÍ,G€ÀQO¬ÉùáS@'Ÿ>|ùóç@TF4hÒÀ·†/å5Ž‘À"©¿Ê'—<€ÀQ9ä6-ù@)û÷ïß¿~@TF4hÒÀ·z¥šôÖªÀ$j'ÙҔ’€ÀQ S"jt@,X±bŋ@TF4hÒÀ·nÚÈíÀ&"„!Òåè€ÀQz¿OZ.@.µjÕ«V­@TF4hÒÀ·`‚‘oeœÀ'Ò?ùhz)€ÀPèœ :@0‰$H‘"@TF4hÒÀ·QôÁpÖÀ)xÏàò¢‹€ÀPÉɗŽžå@1·nÝ»ví@TF4hÒÀ·Bnº+SÀ+±êÊ-‡€ÀP©‘ø^m@2å˗.\¹@TF4hÒÀ·1ø˜éð1À,šn^ÿ€ÀP†—2ù%¶@4(P¡B…@TF4hÒÀ· —Œ¬.À.0–{(v +€ÀPb`\Œ4o@5B… +(P@TF4hÒÀ·R€ŒSIÀ/­Ç©g¢€ÀP<‡Œlh @6páÇ@TF4hÒÀ¶û/{`ð›À0ÔK7{N€ÀP"œ¥}Ò@7Ÿ>|ùóç@TF4hÒÀ¶ç5r"®À1Bõ_‚sñ€ÀOؐœ"ì@8͛6lÙ³@TF4hÒÀ¶ÒkV\€qÀ1ð% |؀ÀO„Âæ4ª@9û÷ïß¿~@TF4hÒÀ¶ŒØ>…IÀ2—F:é€ÀO-Œ¹qY@;*T©R¥J@TF4hÒÀ¶Šƒ`SÓÀ38AûïAJ€ÀNÓŽ·ž$@µjÕ«V­@TF4hÒÀ¶_C‰Ÿ¯€À4õ­\d-œ€ÀMºúÅàØÏ@?ãǏœ˜ŠÀ6z9…1š€ÀL“@A·nÝ»ví@TF4hÒÀµ÷l!ýÓBÀ6ï ü{* +€ÀL-éÕ¹E2@BN:téÓ@TF4hÒÀµÜÄcR“À7]ÉpダÀKDZ]Øg @Bå˗.\¹@TF4hÒÀµÀ<]e؂À7ÆA.}2€ÀK`›®•Ç@C|ùóçϟ@TF4hÒÀµ£í2Å^À8(™+~€ÀJøÍ?€íä@D(P¡B…@TF4hÒÀµ‡-t1áÀ8„æÃ;#M€ÀJl±b>@D«V­Zµj@TF4hÒÀµj7yLÀ8Û?ƒnSԀÀJ'œï)t1@EB… +(P@TF4hÒÀµLxvôIkÀ9+ŒÞœwú€ÀIŸ€S^ +@EÙ³f͛6@TF4hÒÀµ.‘ ›…*À9vyøuO;€ÀIU6²¯?@FpáÇ@TF4hÒÀµTµ^z¿À9»“Ïû¡€ÀHëà +}¬@G @@TF4hÒÀŽñÊð­³À9û)ršQ€ÀH‚™ÌÊ¥Ä@GŸ>|ùóç@TF4hÒÀŽÒ÷mùΊÀ:5Y©ožG€ÀH€9ÚL +@H6lÙ³fÍ@TF4hÒÀ޳ã;‚P~À:jG ™š©€ÀG°®k‹@H͛6lÙ³@TF4hÒÀŽ”“™ÌÀ:š†«G€ÀGH<Ҍ¬w@Idɓ&L™@TF4hÒÀŽug.ØäÀ:ÄâX')F€ÀFàDHƞŠ@Iû÷ïß¿~@TF4hÒÀŽUY#¬À:ê×w‰mã€ÀFxÛ +g£@J“&L™2d@TF4hÒÀŽ5z­€@RÀ; oN/ €ÀFC¶ÜÃ@K*T©R¥J@TF4hÒÀŽw;â?ÐÀ;(Ç:R©‡€ÀE¬ Êïò@KÁƒ 0@TF4hÒÀ³õTˆ À;A #@Ë%€ÀEFÈ(Ë@LX±bŋ@TF4hÒÀ³Õ³zȂÀ;U Š™èr€ÀDâb¡ƒžÅ@Lïß¿~ýû@TF4hÒÀ³ŽÅ³|S°À;dëWv/€ÀD~é>4‚K@M‡8pá@TF4hÒÀ³”cPãSÀ;pÐÄjÀDjÖuïÆ@Njï€ÀBûTØEÛ@OãǏn9QbT@S1bŋ,@TF4hÒÀ±± èm„¶À:Š!:^í€À=Ï%PXk@S|ùóçϟ@TF4hÒÀ±’:‰ÎmœÀ:…5»H÷€À=2Üjü\_@Sȑ"D‰@TF4hÒÀ±rü¬À:bŒ<Ş€À<™_hMÑ@T(P¡B…@TF4hÒÀ±S珫À:>?œÇèB€À<®çcÒ@T_¿~ýû÷@TF4hÒÀ±4þÐ]3”À:k*}`"€À;nÆôˆ© @T«V­Zµj@TF4hÒÀ±C›%À9ñ(|qœ€À:Ýš7øB@TöíÛ·nÝ@TF4hÒÀ°÷·‘_6ùÀ9ȏY¢š}€À:ONG÷$&@UB… +(P@TF4hÒÀ°Ù\8mÍÀ9žžù_cP€À9õÁa\@UŽ8páÃ@TF4hÒÀ°»2ù¬3À9sŒyh€À9:Ùá°ú¶@UÙ³f͛6@TF4hÒÀ°=#ŠÝÀ9G¯Gv"€À8޵_ŸâÀ@V%J•*T©@TF4hÒÀ°{ë¹À9§t²Ï €À81BM®®c@VpáÇ@TF4hÒÀ°aðk‘ÚTÀ8ì¹Ì®qـÀ7°z)@ U@VŒxñãǏ@TF4hÒÀ°D›©p±À8œùÿe ”€À72Uçd@W @@TF4hÒÀ°'~ïAÀ8Ž{³i(€À6¶Íÿ}Ÿø@WS§N:t@TF4hÒÀ° +™øÁøÎÀ8^OU[¥|€À6=ÚzXëµ@WŸ>|ùóç@TF4hÒÀ¯ÛÝFí*ËÀ8-ˆ0“@€À5Çrú@WêÕ«V­Z@TF4hÒÀ¯¢ú|‰Z À7ü6]»‘U€À5SŽÆ©þ\@X6lÙ³fÍ@TF4hÒÀ¯jŒÆñŠ\À7ÊiѲÊI€À4â$׀ßQ@X‚ @@TF4hÒÀ¯2•5ì1uÀ7˜1Åîc€À4s+Ýiž@X͛6lÙ³@TF4hÒÀ®û¶!FŠÀ7eœœa{€À4šK£ËÞ@Y2dɓ&@TF4hÒÀ®Ä Å¥ÚÀ72ž‡Ór¥€À3œf`޵™@Ydɓ&L™@TF4hÒÀ®{÷9VÀ6ÿ’I]-€À34†-ÕK^@Y°`Áƒ @TF4hÒÀ®WdðœH|À6Ì6|°=ՀÀ2Îï °@Yû÷ïß¿~@TF4hÒÀ®!ÇoSÍ À6˜°ùrjL€À2k˜†6^*@ZG» À ö°’΀ÀQÞw±¡@Ÿ>|ùóç@T«V­ZµlÀ·C,Š€‹ÀìéÏ6%€ÀPö7EgÐ@X±bŋ@T«V­ZµlÀ·=Q'ªŠšÀ›—_ +K8€ÀPé±Iýò:@ ‰$H‘"@T«V­ZµlÀ·6j±õø>À°|lKˀÀPÚø<šðŸ@"å˗.\¹@T«V­ZµlÀ·.{ãG6’À™âý k€ÀPÊÒš|@%B… +(P@T«V­ZµlÀ·%‡Ù OÀ r9ãJƒ€ÀP·Ôq1@'Ÿ>|ùóç@T«V­ZµlÀ·’°‰À!µ»K0Ç~€ÀP¢Yû¡@)û÷ïß¿~@T«V­ZµlÀ·žcc§cÀ#`481Pç€ÀPŠëù˜­@,X±bŋ@T«V­ZµlÀ·±uQkÀ%Kœã€ÀPqÞñP@.µjÕ«V­@T«V­ZµlÀ¶÷Îr±ËZÀ&žxL®Á €ÀPVê²ê­@0‰$H‘"@T«V­ZµlÀ¶éû‡¿>ÆÀ(18zq[M€ÀP:!ÏñS”@1·nÝ»ví@T«V­ZµlÀ¶Û=_2sÀ)»^SE‘€ÀP”ÑÌ;ð@2å˗.\¹@T«V­ZµlÀ¶Ë™[pmÀ+;˜á[žŠ€ÀOö«ëŸ¢ @4(P¡B…@T«V­ZµlÀ¶»dq&À,²aü”Q€ÀO²ðH_“‘@5B… +(P@T«V­ZµlÀ¶©¶„ <À.æß•€ÀOl­õ¬ó@6páÇ@T«V­ZµlÀ¶—ƒ>RüÀ/Q26ø(€ÀO"\úë@7Ÿ>|ùóç@T«V­ZµlÀ¶„‚©›,À0lkìA(ۀÀNÕÓé‡*%@8͛6lÙ³@T«V­ZµlÀ¶pºZhÀ1±œ9ŒÛ€ÀN†®—à‡@9û÷ïß¿~@T«V­ZµlÀ¶\0d㝌À1³]{k€ÀN5žA€z@;*T©R¥J@T«V­ZµlÀ¶FìLýþhÀ2NX þSì€ÀMá2œß@µjÕ«V­@T«V­ZµlÀ¶–ÐóÀ3üvÕØã-€ÀLÙoVû£@?ãǏhրÀH²bPÐg@FpáÇ@T«V­ZµlÀŽÂÏ»¬,TÀ8©ùi€ÀHNzK·y@G @@T«V­ZµlÀŽ¥ˆQebÀ8éÄ#ýç&€ÀGêŽP1\@GŸ>|ùóç@T«V­ZµlÀއø(æÀ9$rO¯j€ÀG†¹Øò„;@H6lÙ³fÍ@T«V­ZµlÀŽj%Ý©ûxÀ9Z!3”@}€ÀG#D @H͛6lÙ³@T«V­ZµlÀŽL؉6À9Šï‹[<€ÀF¿»ÖU:Š@Idɓ&L™@T«V­ZµlÀŽ-Ñn¹.ƒÀ9¶üÿ4€ÀF\ÁŠp|@Iû÷ïß¿~@T«V­ZµlÀŽZ„zâXÀ9Þj1ÁJ€ÀEú=êŒ @J“&L™2d@T«V­ZµlÀ³ð·Ž–XÀ:W¥H‡‘€ÀE˜Bî ÿ@K*T©R¥J@T«V­ZµlÀ³Ñî;·ÒÀ:爿Xô€ÀE6æNl‚#@KÁƒ 0@T«V­ZµlÀ³³,5·ˆÀ::;©ÙúQ€ÀDÖ9>ߊõ@LX±bŋ@T«V­ZµlÀ³“ûs­cáÀ:PvOñø„€ÀDvL›#—@Lïß¿~ýû@T«V­ZµlÀ³tÛÔºôÀ:b¹ïÈ¥K€ÀD0Hu¿@M‡8pá@T«V­ZµlÀ³UšéÐc1À:q)Ô$z€ÀCžò\Ÿ¡Ä@N˜@NµjÕ«V­@T«V­ZµlÀ³ÎRìÀ:ƒèªáà€ÀBÿH/q@OL™2dɒ@T«V­ZµlÀ²÷È_«À:†ÔQœ6€ÀB£ó·ŒŠ@OãǏÿD€À?B¯ó8Ô@Rš4hÑ£F@T«V­ZµlÀ±ÀGK5À: `-ZÑրÀ>£œ•rƒ>@Rå˗.\¹@T«V­ZµlÀ±¡‘ðÕR‘À9òÙv…í€À>_AšWÔ@S1bŋ,@T«V­ZµlÀ±‚ü_f¿À9×_n©ÂŒ€À=m˜ë™7@S|ùóçϟ@T«V­ZµlÀ±d‡Í3èÔÀ9º }z¶©€À<Ök j€V@Sȑ"D‰@T«V­ZµlÀ±F7,ÅP9À9šþs>€À¶ñ€À7úÝn¶.Û@VpáÇ@T«V­ZµlÀ°<Éû8À8ExÁ[‰€À7}‚ ÓÇX@VŒxñãǏ@T«V­ZµlÀ° 9G4<À8®šosI€À7ŠëÛ{@W @@T«V­ZµlÀ°Ü<ÄÝ,À7íŽÀ^O€À6ŠC鹖›@WS§N:t@T«V­ZµlÀ¯Ïg}$š²À7À¶…1*؀À6RÆ\¯ý@WŸ>|ùóç@T«V­ZµlÀ¯—5!ÕÅÀ7“8óóËF€À5 ÌB“¹ @WêÕ«V­Z@T«V­ZµlÀ¯`^ÓVÀ7e%›ªº€À5/šÁÐOË@X6lÙ³fÍ@T«V­ZµlÀ¯(úp)ÎÀ76ŒxN€À4ÀàS[J@X‚ @@T«V­ZµlÀ®ò\mãŠcÀ7{Bþ€À4Tj»ƒ‡ @X͛6lÙ³@T«V­ZµlÀ®Œ.˜ÊÍÀ6ØZJæ€À3ê?|f^û@Y2dɓ&@T«V­ZµlÀ®†pXÂáÀ6š+M@ZÞœzõë×@T«V­ZµlÀ­M^<èéÀ5„MÛØŸ‘€À1@A’€K@[*T©R¥J@T«V­ZµlÀ­ÄnŽíÀ5S>ê„#·€À0ç!ˁ-€@[uëׯ^œ@T«V­ZµlÀ¬èžŠ=œdÀ5"+JL/Ž€À0ù™' @[Áƒ 0@T«V­ZµlÀ¬¶ìòO)À4ñnªFi€À0:Ÿë.Ý@\ 4hÑ£@T«V­ZµlÀ¬…®pCpŠÀ4ÀQ4Š€À/ÎÐaçÏ @\X±bŋ@T«V­ZµlÀ¬Tä}]fÀ4&u"ÀÀ/+×Ã6¬Ñ@\€H‘"Dˆ@T«V­ZµlÀ¬$²qÏÀ4^OíÁЀÀ.Œ€ûFʲ@\ïß¿~ýû@T«V­ZµlÀ«ô©]ÀôÀ4-š`ñÌ®€À-ð¹%R@];víÛ·n@T«V­ZµlÀ«Å8‰N¬úÀ3ý fb€À-Xm|ì~˜@]‡8pá@T«V­ZµlÀ«–:3ºPlÀ3̪ɜV€À,Ëc—¯œ@]Ò¥J•*T@T«V­ZµlÀ«g­ìûäÀ3œ|¬ª4€À,2eÒÑá@^€À$EA„ŸŠ°@a‘£F4@T«V­ZµlÀšk+™ +nÀ0l4ðä;€À#䎕»p@a·nÝ»ví@T«V­ZµlÀšD…ÇOÀ0B4š°+r€À#†W ï@aÝ:téÓ§@T«V­ZµlÀšNäžgÀ0Žâ"’ž€À#)‘Þ(ô^@b 0`@T«V­ZµlÀ§øyŽ+À/ß9a€øZ€À"Ï. ]ô@b(Ñ£F@T«V­ZµlÀ§ÓÐ#HnÀ/Ù µ €À"vË2š5@bN:téÓ@T«V­ZµlÀ§­ï³ì=À/=IÄ$èH€À" \zžË£@bthÑ£F@T«V­ZµlÀ§‰9BH)ÉÀ.í‹úó€À!ËÖad&@bš4hÑ£F@T«V­ZµlÀ§dàƒsä”À.ž &ûE_€À!y,öÀn@bÀ@T«V­ZµlÀ§@äK ÅÀ.P†„ƃ€À!(T¹4@D@U @À¶æß%åà€€ÀP{Í')vÜ?òå˗.\¹@U @À¶æ\ÈQ¿ë—-Óšqö€ÀPz¹ÌAÍ`@å˗.\¹@U @À¶äÕâPò\¿û‘Ï¥_¶ß€ÀPw€o@ X±bŋ@U @À¶âK äš&ÀŠšðx(ՀÀPr#)IË{@å˗.\¹@U @À¶ÞœAšsaÀ |h{ùŒ€ÀPj¥wL+@Ÿ>|ùóç@U @À¶Ú-ãÂŒŒÀ#Äjàj€ÀPa 31\ñ@X±bŋ@U @À¶ÔžŽeÑŽÀ‚»íؕ[€ÀPU]VÖK@ ‰$H‘"@U @À¶ÎÕê“jÀÙØÎ“Æ€ÀPG¡!j@"å˗.\¹@U @À¶Æ‰È†ÖUÀ'áv&üŠ€ÀP7ßSÙ£M@%B… +(P@U @À¶Ÿ g€pÛÀk¥î… +0€ÀP&"sÂÕŒ@'Ÿ>|ùóç@U @À¶Ž“æÞ;sÀ ҂RE¥€ÀPu}Œ‹W@)û÷ïß¿~@U @À¶ª,ΚúÀ"g쮅b<€ÀOùÉD¿v@,X±bŋ@U @À¶ž×ø®»²À#÷7÷€ÀOÊú/«8î@.µjÕ«V­@U @À¶’™‹ãÃ"À%~î±N2é€ÀO˜š hË@0‰$H‘"@U @À¶…uø\zÿÀ&ÿ ð‚›€ÀObÆêfG@1·nÝ»ví@U @À¶wqòìY À(vû\‚"û€ÀO) i~zŽ@2å˗.\¹@U @À¶h’p•Ü}À)æQ9튀ÀNíG•â@4(P¡B…@U @À¶XÜ¡ÔæÀ+L­äE|€ÀN­Þ¹a-I@5B… +(P@U @À¶HUíÅÒÀ,©žùƒf€ÀNk‰0 ñ‹@6páÇ@U @À¶7íD&™À-ý"wM™W€ÀN&k;÷1@7Ÿ>|ùóç@U @À¶$ìeêÍÓÀ/F¢Éëõb€ÀMÞ©Ù—0@8͛6lÙ³@U @À¶E¬ßÀ0BýfªÊö€ÀM”j‘¶ú @9û÷ïß¿~@U @Àµþ„› ] +À0Ýyàly¢€ÀMGÓSžH@;*T©R¥J@U @Àµê@•ÉÝ&À1r¯–þl€ÀLù +G>V@µjÕ«V­@U @Àµ©Š™‹xÀ3üW«±€ÀL@*î@?ãǏ|ùóç@U @ÀŽ>À³ŒÉðÀ8!.·›E€ÀFø /§s@H6lÙ³fÍ@U @ÀŽ" séè„À8WL•²y€ÀF™L£4X@H͛6lÙ³@U @ÀŽBì(@”À8ˆàž5ڀÀF:ÂPžd@Idɓ&L™@U @À³è-…yJðÀ8µí0›†€ÀE܂;Yjî@Iû÷ïß¿~@U @À³Ê凹÷À8ސ­–JK€ÀE~¡?ˆš@J“&L™2d@U @À³­pó“¹À9èû%›W€ÀE!3éå)@K*T©R¥J@U @À³Ò/ÎuÀ9#kõ\\€ÀDÄJS…Ô!@KÁƒ 0@U @À³r³Ã¡æÀ9?1õ¢ €ÀDgøxÔ_G@LX±bŋ@U @À³T0X;–À9W`çæ%€ÀD Mí„-¶@Lïß¿~ýû@U @À³65¯zàÀ9kÀÓEã€ÀC±Z Ÿ­0@M‡8pá@U @À³%&d#jÀ9|qoîŸz€ÀCW+%Òºn@NбJü)å@Rš4hÑ£F@U @À±\)Uš¢À9=ËÌ+–€À>7C.³‹!@Rå˗.\¹@U @À±r™ £8›À9'— —€À= 8oM~z@S1bŋ,@U @À±TñJ·QlÀ9sÿù‚6€À= •å¡iî@S|ùóçϟ@U @À±7g$ØC±À8õ|Ó Ù€À|ùóç@U @À¯S¡™yBÀ6ü¥vÎu1€À5xqw€BÓ@WêÕ«V­Z@U @À¯‡…“7^À6ѧˆrf€À5 +pnÚ@X6lÙ³fÍ@U @À®çÓ®­ôÀ6Šm+4€À4Ú'@X‚ @@U @À®²‡X&0>À6zã9ҀÀ43ãlŒtË@X͛6lÙ³@U @À®}£€6£À6Mƒ^’EO€À3̰²ò‚@Y2dɓ&@U @À®I)•sÀ6 ˜…®m€À3fxŒFÒç@Ydɓ&L™@U @À®P÷uÀ5óRÎً'€À3õU€·@Y°`Áƒ @U @À­áuÖUíîÀ5Å¿Û~«€À2¡‡þt@Yû÷ïß¿~@U @À­®=—©Œ$À5—éޘT΀À2B)¯DÆ@ZG€À0‚øoç@[Áƒ 0@U @À¬ƒàqaXÀ4³šßW€À0/:.Œv@\ 4hÑ£@U @À¬SŠ“5­À4S"W,Ž€À/ºšÔõÐ@\X±bŋ@U @À¬#Ú¯SŠÀ4$˜¯$€À/R«vTó@\€H‘"Dˆ@U @À«ô|«x!HÀ3öúšý̀À.}ŠP3Ÿ8@\ïß¿~ýû@U @À«ÅŒ^õSÀ3ǎóR€À-ä0A+ÐŒ@];víÛ·n@U @À«— ªê€À3™h¿®¬4€À-N3f¿í@]‡8pá@U @À«hóùAWÀ3k=ßn‡¥€À,»n€Á§@]Ò¥J•*T@U @À«;KCÇ¿kÀ3=9·,4~€À,, +&H®@^ëÙOÙÀ2áº&Ï +€À+†˜Sy@^µjÕ«V­@U @ÀªŽÚbÛ­~À2ŽH9j²€À*X¶ ¹ƒ@_ @U @Àªˆàð=ùiÀ2‡ŽÓš€À* !ü!ã@_L™2dɒ@U @Àª]RíŽÀ2Zx8þ€À)ŒÒ¡’U@_˜0`Áƒ@U @Àª2-ØLÀ2-\TxG(€À)X瀕@_ãǏ)@`®Ý»víÛ@U @À©7ۉ…¢+À1'AlÞE€À&VµÈY@`Ô©R¥J•@U @À©‰RcžŒÀ0üuÅGDb€À%ëk +fŽ·@`útéÓ§N@U @Àšç›]—á}À0Ò/¹žÌ€€À%‚^œÄ@a @@U @ÀšÀÜá„À0šA×q׀À%äÿ:@aF 0`Á@U @Àš˜èþ"ŽÀ0~­ªpí€À$·‹’kWé@akׯ^œ{@U @Àšr"ëÄôÀ0Ut–xœ€À$Uhk ž4@a‘£F4@U @ÀšKœÍZÀ0,—Ùs§€À#õm‚ôŽ @a·nÝ»ví@U @Àš%žÆ²j×À0‰³v?€À#—þ¡îÐ@aÝ:téÓ§@U @Àšú±ÛÏÀ/·ï:©í’€À#;œE±rÑ@b 0`@U @À§Úˉ"À/hkÏò„Á€À"áï¡T @b(Ñ£F@U @À§µá<ƒsÀ/š=tE€À"Š"h(U@bN:téÓ@U @À§‘T,²Ê\À.Ë¥°Ö<*€À"4)Óø·J@bthÑ£F@U @À§m"yñl +À.~e# +<£€À!à‡±-x@bš4hÑ£F@U @À§IK’_®ŽÀ.1çW¬€À!àî·ÕI@bÀ@U @À§%Ώ›[9À-æ,ß5݀À!=núGSÂ@UtéÓ§NžÀ¶€Ñhaïd€€ÀOØŽÍ+ð?òå˗.\¹@UtéÓ§NžÀ¶€U–Ýþà¿ê4|s)àz€ÀOÖ²¯0UM@å˗.\¹@UtéÓ§NžÀ¶~âPÜú»¿ú/æƒŸ€ÀOݘö@ X±bŋ@UtéÓ§NžÀ¶|x!Î$"À†Ö}Ù9€ÀOÆ©SÇiŽ@å˗.\¹@UtéÓ§NžÀ¶yñrjÀ +íG6òb€ÀOž¬(ñƒ§@Ÿ>|ùóç@UtéÓ§NžÀ¶tÃçÀHJ>Ñ֘€ÀOŠŸØ›U@X±bŋ@UtéÓ§NžÀ¶ozóV)À|ñûVæ€ÀOìŒºRZ@ ‰$H‘"@UtéÓ§NžÀ¶iAžCÃ:À©ŸŸRóQ€ÀOwBÉde@"å˗.\¹@UtéÓ§NžÀ¶b‰6xÀÎYîÆG€ÀOYÑXÀM@%B… +(P@UtéÓ§NžÀ¶ZBöiÐÀé£ýäEЀÀO8ª4À³Ý@'Ÿ>|ùóç@UtéÓ§NžÀ¶Q™¥†1ÀúrÔõT€ÀOámoÂ@)û÷ïß¿~@UtéÓ§NžÀ¶G#áAÀ!ߞ‡fŠ€ÀNë àû@,X±bŋ@UtéÓ§NžÀ¶<]£€YÀ"üI˜Î¡Û€ÀN¿ÄøÓì@.µjÕ«V­@UtéÓ§NžÀ¶0ž³^zÀ$rÊî•c€ÀN¢Ð§â@0‰$H‘"@UtéÓ§NžÀ¶$9&l\œÀ%à•„¯J€ÀN^AÍm +Ô@1·nÝ»ví@UtéÓ§NžÀ¶ãRª®„À'GžÙ ÛJ€ÀN(Ÿ›)=G@2å˗.\¹@UtéÓ§NžÀ¶»ÉòÉÀ(Šºà›7à€ÀMð74ÒWÉ@4(P¡B…@UtéÓ§NžÀµùÇUÌ¢ÉÀ)ýŽï‚s€ÀMŽÊœÏ(@5B… +(P@UtéÓ§NžÀµê +ó"ÂiÀ+KÇŸÛ¬§€ÀMv™['@6páÇ@UtéÓ§NžÀµÙ‹ÍԒÀ,‘‰‘€€ÀM5Ä vl@7Ÿ>|ùóç@UtéÓ§NžÀµÈOµjÕ«V­@UtéÓ§NžÀµR•_QNÀ252î‹(Ÿ€ÀK1BøóÏò@?ãǏœ‡Ž/€ÀJ9ÌëV¡{@A·nÝ»ví@UtéÓ§NžÀŽø)žœ>‘À4R<Ûï€ÀIäÐsþ;þ@BN:téÓ@UtéÓ§NžÀŽàGøhR«À4kFTð˘€ÀIŽÊÜåÉ@Bå˗.\¹@UtéÓ§NžÀŽÇï»É`6À4Í"Õo€ÀI7Ûõ5@e@C|ùóçϟ@UtéÓ§NžÀޝ&èŸTWÀ5)íÿ8÷b€ÀHà"¿w²Ö@D(P¡B…@UtéÓ§NžÀŽ•ós#Ù}À5µ€Ä&ö€ÀH‡œb”ê@D«V­Zµj@UtéÓ§NžÀŽ|[@"ú±À5ԆzLˊ€ÀH.ÉôHÏ@EB… +(P@UtéÓ§NžÀŽbd#±hÀ6"p7’1€ÀGÕb9€F@EÙ³f͛6@UtéÓ§NžÀŽHÞA*³À6kƒÙÈ@€ÀG{€$;@FpáÇ@UtéÓ§NžÀŽ-p§qµÀ6¯Ôùý\€ÀG!šÔNša@G @@UtéÓ§NžÀŽ~l+[ðÀ6ïx%r^u€ÀFljâ[¬Ñ@GŸ>|ùóç@UtéÓ§NžÀ³÷DLÉo©À7*ƒvÝŸ€ÀFm_j²ÍS@H6lÙ³fÍ@UtéÓ§NžÀ³ÛÇ¡ÅÀ7aHÈ×€ÀF@—ê)@H͛6lÙ³@UtéÓ§NžÀ³À ‘÷ÿÀ7“13]3€ÀE¹C…÷Rƒ@Idɓ&L™@UtéÓ§NžÀ³€qøžöÀ7ÁO€O€ÀE_}B‘ ‘@Iû÷ïß¿~@UtéÓ§NžÀ³‡ñ% XœÀ7ê¥FFAç€ÀEΜBK@J“&L™2d@UtéÓ§NžÀ³k›Ðq¡À8,’^€ÀD¬ä ‡‚÷@K*T©R¥J@UtéÓ§NžÀ³O!"WÀ81µ,z.€ÀDT6'wP +@KÁƒ 0@UtéÓ§NžÀ³2uÖ±d À8O\ì‚CV€ÀCüÏ& +Ú@LX±bŋ@UtéÓ§NžÀ³¯ÁÜe–À8i?Ög†L€ÀC€l`@Lïß¿~ýû@UtéÓ§NžÀ²øÍEŒ²bÀ8zÌ.hU€ÀCMnºo€@M‡8pá@UtéÓ§NžÀ²ÛÒ¢ ,ùÀ8’*é¥ÛR€ÀB÷îwNÖ@NŽZŸúÀ8­Ãßju€À@DC@Q·nÝ»ví@UtéÓ§NžÀ±žŸ K@À8£Ek”ЀÀ?Šãœž@R 0`@UtéÓ§NžÀ±› IÌÊÀ8–vfäQã€À>õ6GþR@RN:téÓ@UtéÓ§NžÀ±~ +ÍnµÀ8‡ry–k€À>^íÄpF@Rš4hÑ£F@UtéÓ§NžÀ±a +}CµÀ8vS—-€À=ÊÕ-€N@Rå˗.\¹@UtéÓ§NžÀ±DB±ÄŽnÀ8c3}á‚Ü€À=8ó+pçV@S1bŋ,@UtéÓ§NžÀ±'}JÌÀ8N+b6h׀À<©ML¬š@S|ùóçϟ@UtéÓ§NžÀ± +Ù^nBÎÀ87Sé€k$€À<è Ìv¬@Sȑ"D‰@UtéÓ§NžÀ°îMª™Ó;À8Å%Ãw€À;ÆåðV¶@T(P¡B…@UtéÓ§NžÀ°ÑßïîjnÀ8–Ž_ó)€À;ìa [@T_¿~ýû÷@UtéÓ§NžÀ°µ’ ü[ÙÀ7èß.{€À:ZP×@T«V­Zµj@UtéÓ§NžÀ°™eÁ¯=œÀ7ËŽÂÚ÷C€À9ýÜa<þ@TöíÛ·nÝ@UtéÓ§NžÀ°}\º +?‹À7­-lŸ%€À9{—é•@UB… +(P@UtéÓ§NžÀ°ax„æËÀ7]ý;†*€À8ûXƒë”X@UŽ8páÃ@UtéÓ§NžÀ°Eº™ŽË4À7lZÎ29ƀÀ8}çÏ}@UÙ³f͛6@UtéÓ§NžÀ°*$X;ýÉÀ7J7•á€À8º@Ñpå@V%J•*T©@UtéÓ§NžÀ°· ]ŒTÀ7'cHπÀ7‰ÏÓÜø@VpáÇ@UtéÓ§NžÀ¯æç¿­hTÀ7ÜŠoҊ€À7"Yç¥þ@VŒxñãǏ@UtéÓ§NžÀ¯°·òÀ6ÝÉ(÷ñ9€À6ž°,ƒ¢@W @@UtéÓ§NžÀ¯zà» +dÀ6·Þ7¿â€À6,sÙ W@WS§N:t@UtéÓ§NžÀ¯Ed¬5=À6‘+ì×ê€À5ŒhÞ#9ž@WŸ>|ùóç@UtéÓ§NžÀ¯C‘ù£dÀ6ižŽÕŒ€À5N‰ÙÇq@WêÕ«V­Z@UtéÓ§NžÀ®Û~ŒuÀ6A±t(:”€À4âÑ%-_&@X6lÙ³fÍ@UtéÓ§NžÀ®§ÎGX!À6Ñ¡€À4y8À0(@X‚ @@UtéÓ§NžÀ®sQ_éOÀ5ïÑ· º(€À4ºY®Ñ¬@X͛6lÙ³@UtéÓ§NžÀ®?zÃ7šðÀ5ÆÂYØã€À3¬OWaB@Y2dɓ&@UtéÓ§NžÀ® =<þցÀ5›û0ZÛŠ€À3HðÞa[“@Ydɓ&L™@UtéÓ§NžÀ­Ùc¹ü² À5qsc¹Ü €À2ç—Ø“Ö@Y°`Áƒ @UtéÓ§NžÀ­ŠïÞ">À5F“2… q€À2ˆ<ý_Öž@Yû÷ïß¿~@UtéÓ§NžÀ­tàü…çÀ5eê"wǀÀ2*Ø×F›¡@ZGûÿ +À4)A€À%­E£Þo@a @@UtéÓ§NžÀšžÈóýlõÀ0dèJG–“€À%'ý›ŒHÎ@aF 0`Á@UtéÓ§NžÀšx>nÃÀ0<Ìéµ}³€À$Ă»%5¹@akׯ^œ{@UtéÓ§NžÀšRE©ÝÉÀ0òÎO€À$c/ä+<@a‘£F4@UtéÓ§NžÀš,CŽÊßÍÀ/ÛõÍn€À$ø“9á@a·nÝ»ví@UtéÓ§NžÀšÑôL¢¶À/ŒÞ,lx€À#ŠÐóE}@aÝ:téÓ§@UtéÓ§NžÀ§áŒ9ž–À/?JèCH*€À#K«§•@b 0`@UtéÓ§NžÀ§œž-íÀ.òf&®[Ž€À"ò~6êç@b(Ñ£F@UtéÓ§NžÀ§˜¡ A[À.Š1¥ o€À"›<Ÿ€Õ°@bN:téÓ@UtéÓ§NžÀ§t›!ìaÀ.Z®è«€À"Eې:G@bthÑ£F@UtéÓ§NžÀ§PígîÛÀ.ß9ـÀ!òOñBÕ£@bš4hÑ£F@UtéÓ§NžÀ§-— TOÀ-ÅîV/‡€À! ŽçN\ö@bÀ@UtéÓ§NžÀ§ +˜õ±À-|],q€À!PÒ;íõ@UÙ³f͛8À¶4„ug\€€ÀNÈÄúÐ-?òå˗.\¹@UÙ³f͛8À¶ŸÐúV¿èé)Þ[z˜€ÀNÆ9>N_>@å˗.\¹@UÙ³f͛8À¶]ß¡^ž¿øä£ÖW\€ÀNÀ˜Î–Lt@ X±bŋ@UÙ³f͛8À¶1ÂìsÀ¥Õ¥â]܀ÀN·;á?u@å˗.\¹@UÙ³f͛8À¶ܛâýýÀҙŒšª €ÀNª(#7âë@Ÿ>|ùóç@UÙ³f͛8À¶ŸFìEVÀöh_ç@K€ÀN™e|ZÒ¥@X±bŋ@UÙ³f͛8À¶ ž®˜ÅÀ‡Š÷ñsš€ÀN„þÁ 4@ ‰$H‘"@UÙ³f͛8À¶͟òޜÀ@ŸÍö_€ÀNlýíI_í@"å˗.\¹@UÙ³f͛8À¶ÿ7†1À‹KIhá€ÀNQs‚XS@%B… +(P@UÙ³f͛8ÀµùOßG1€À€š¯c #€ÀN2oj­@'Ÿ>|ùóç@UÙ³f͛8ÀµðÂL/ŒÀl_ L–€ÀN™šßj@)û÷ïß¿~@UÙ³f͛8ÀµçY{šÚËÀ Š¿ ß{R€ÀMêB7<9Ô@,X±bŋ@UÙ³f͛8ÀµÝ°a@QÀ"ÇYO€ÀMÁC‚]Bæ@.µjÕ«V­@UÙ³f͛8ÀµÒoŒñmÀ#v4Ñ€ÀM•µ™@0‰$H‘"@UÙ³f͛8ÀµÆ}ù¡¥À$ÔD A©>€ÀMeé‚>÷@1·nÝ»ví@UÙ³f͛8Àµ¹jÚ÷·…À&+]ÓðL€ÀM3Àï]ď@2å˗.\¹@UÙ³f͛8Àµ«ïŸˆ'QÀ'{%áU"€ÀLþ¿;QbŸ@4(P¡B…@UÙ³f͛8Àµ°”§{À(ÃG˶7€ÀLÇ·–«@5B… +(P@UÙ³f͛8ÀµŽ±ùžvbÀ*s Öj€ÀLŒ¢§¿ž@6páÇ@UÙ³f͛8Àµ~ø¶ÍÈÀ+;aúŸ•.€ÀLOë;B@7Ÿ>|ùóç@UÙ³f͛8Àµn‰ºò[kÀ,jÓ1m€ÀL€Ûÿ ‰@8͛6lÙ³@UÙ³f͛8Àµ]j“%4À-‘Œ ëiV€ÀKÎû)èþ§@9û÷ïß¿~@UÙ³f͛8ÀµKŸ‚YžÀ.¯Y۞ÀK‹Q»l)ò@;*T©R¥J@UÙ³f͛8Àµ9-î”/öÀ/ÄöidπÀKE€‹w¢L@µjÕ«V­@UÙ³f͛8ÀŽþ,eËýÀ1e%b³„ €ÀJiÇâGs@?ãǏ..³€ÀI€J‰z_@A·nÝ»ví@UÙ³f͛8Àާ¹ú™.ºÀ3&˜ê­(L€ÀI0JÐDŽ@BN:téÓ@UÙ³f͛8ÀސßÅŽœÀ3ŠõI­þ†€ÀHÞ°wMO@Bå˗.\¹@UÙ³f͛8ÀŽy‘فÞ>À3ê‰æýFf€ÀHŒuª˜^¢@C|ùóçϟ@UÙ³f͛8ÀŽaÕÙd†À4E]¬}|j€ÀH9mÏåQ{@D(P¡B…@UÙ³f͛8ÀŽI±\Å¡ÆÀ4›yœMš5€ÀG嵇y@D«V­Zµj@UÙ³f͛8ÀŽ1)ï€ÒÎÀ4ìéP‰ûs€ÀG‘fá”ÿ@EB… +(P@UÙ³f͛8ÀŽEšÉÀ59¹‹ûUB€ÀG<Â™oê@EÙ³f͛6@UÙ³f͛8À³ÿ)§ÕÀ5ù^ÿV€ÀFçskye@FpáÇ@UÙ³f͛8À³åx˜)êÀ5Ź]íÚ €ÀF’©¹™`@G @@UÙ³f͛8À³Ë›¡¥±À6 ž4¬­€ÀF<]`<{ð@GŸ>|ùóç@UÙ³f͛8À³±vsïRÀ6@“[ì€ÀE栂ö@H6lÙ³fÍ@UÙ³f͛8À³—(çtŒÀ6vµí"ñ€ÀEàõÅ4@H͛6lÙ³@UÙ³f͛8À³|gŸÒÀ6©8vˆT€ÀE;1!š_Þ@Idɓ&L™@UÙ³f͛8À³aˆ>ȰÀ6סö×ìЀÀDå§Æ6·û@Iû÷ïß¿~@UÙ³f͛8À³FtP!À7 + ÑI€ÀDW'ãÅ@J“&L™2d@UÙ³f͛8À³+0)ƅšÀ7(‰1bï%€ÀD;QzO÷@K*T©R¥J@UÙ³f͛8À³Á,W0À7K8^ž`€ÀC暈Ò@KÁƒ 0@UÙ³f͛8À²ô+G!Z(À7j11'Cí€ÀC’kÇ¢ì@LX±bŋ@UÙ³f͛8À²ØsÃÖëÀ7…¶°œ\€ÀC>ªÙ@Lïß¿~ýû@UÙ³f͛8À²Œœ„5Á9À7hW}€ÀBësþäÇ@M‡8pá@UÙ³f͛8À² «Ý~ƒJâT@RN:téÓ@UÙ³f͛8À±N'Dî]À7Äõ󣘀À=í|OD0@Rš4hÑ£F@UÙ³f͛8À±2†‡¢Ï=À7µŒáÕT©€À=^MõB@Rå˗.\¹@UÙ³f͛8À±Ž÷W€^À7¥xW/^€À<ѹÆkçŽ@S1bŋ,@UÙ³f͛8À°ú«Œbè<À7“RnÂà1€À|ùóç@UÙ³f͛8À®ÍkÆuŒjÀ5ڄ*0'ƒ€À5#:—¶@@WêÕ«V­Z@UÙ³f͛8À®™øKßízÀ5µ9ÿœÜ߀À4º*€À3)ß»à@Ydɓ&L™@UÙ³f͛8À­žƒžÀ4òdÁÊ *€À2Ê­jš+b@Y°`Áƒ @UÙ³f͛8À­lºƒOlnÀ4ÊaÇ +¹€À2maÍÆ¹Ó@Yû÷ïß¿~@UÙ³f͛8À­;Αi"ØÀ4¡ue×L€À2ö9Ų6@ZG|ùóçÐÀµŸÜõCEȀ€ÀMÄÚFðW?òå˗.\¹@V>|ùóçÐÀµŸlù‡"Ó¿ç³ON7×S€ÀMÃscj@å˗.\¹@V>|ùóçÐÀµœ-šš"¿÷¯%냜<€ÀMœÔ‹šû@ X±bŋ@V>|ùóçÐÀµºî‚píÀŸ*ÃOã€ÀMµy XG@å˗.\¹@V>|ùóçÐÀµ·àK OÀžŒ±wƒ€ÀMšÔ_ì–@Ÿ>|ùóç@V>|ùóçÐÀµ³õ \‰÷À v°N«çˀÀM™$e „@X±bŋ@V>|ùóçÐÀµ¯- âÊÀ¢Jžtíñ€ÀM† +ª “€@ ‰$H‘"@V>|ùóçÐÀµ©‹·œ§öÀƒ"ŠðB߀ÀMo’=*¶@"å˗.\¹@V>|ùóçÐÀµ£A®CêÀ\ê ×Ïâ€ÀMUÈ •„z@%B… +(P@V>|ùóçÐÀµ›Àw€ÐÀ.ŽZÊV€ÀM8ºÏ€SÁ@'Ÿ>|ùóç@V>|ùóçÐÀµ“›×—áíÀ÷˜Ì÷€Å€ÀMzý7U@)û÷ïß¿~@V>|ùóçÐÀµŠŠ#v³ÎÀ¶¹j;Ž€ÀLõ«øùÛ@,X±bŋ@V>|ùóçÐÀµ€â\ÿyÀ!5 ]€€ÀLέ»Ùá@.µjÕ«V­@V>|ùóçÐÀµvSðÝSÀ"Š1%u€ÀL¥H%n@0‰$H‘"@V>|ùóçÐÀµjýѲÀ#دcɚ·€ÀLyHãvÙ@1·nÝ»ví@V>|ùóçÐÀµ^ä8²ÀÀ% ŸŒÃ€ÀLIòWš+Ô@2å˗.\¹@V>|ùóçÐÀµR +Þ¥9ßÀ&b Ùžo݀ÀL1…Ã@4(P¡B…@V>|ùóçÐÀµDuÚaÚžÀ'œ@ÞLR€ÀKãٜ"Ø@5B… +(P@V>|ùóçÐÀµ6)p*ÓþÀ(ÏÞÓÓ*€ÀK­JlI@6páÇ@V>|ùóçÐÀµ'*ËxÀ)úQ˜׀ÀKsÐþ °@7Ÿ>|ùóç@V>|ùóçÐÀµ|H£ˆÁÀ+šŽQç€ÀK8UڀB0@8͛6lÙ³@V>|ùóçÐÀµ$Ö¹³ÛÀ,8èöþ®J€ÀJú³rÑÛ,@9û÷ïß¿~@V>|ùóçÐÀŽö(ŽbùÀ-KàِÝˀÀJ»Ó%k@;*T©R¥J@V>|ùóçÐÀŽäŒ`ö4SÀ.Vdõû €ÀJyjNýw@|ùóçÐÀŽÒUWL«À/XPèj%€ÀJ5þiß!º@=‡8pá@V>|ùóçÐÀŽ¿ˆwš8À0(ÁˆPùl€ÀIðߺˆo@>µjÕ«V­@V>|ùóçÐÀެ+6­7À0 ò=Qò_€ÀIª+Ñ,œß@?ãǏ|ùóçÐÀޘBˆx4À1°Uç –€ÀIbK¿Ð@@‰$H‘"@V>|ùóçÐÀރÓÊäÀ1ƒô¿FY€ÀIyÛª@A @@V>|ùóçÐÀŽnäGŸØYÀ1î»aÜ{€ÀH͵ó4'@A·nÝ»ví@V>|ùóçÐÀŽYyPº »À2Ut)ŠÚ€ÀHÐðŒ0@BN:téÓ@V>|ùóçÐÀŽC˜5؞ÉÀ2¶È‚ôu€ÀH4æè>ÔÈ@Bå˗.\¹@V>|ùóçÐÀŽ-FENsêÀ3.j¶T€ÀGçixT(@C|ùóçϟ@V>|ùóçÐÀŽˆÈ€ žÀ3læGmo€ÀG˜qn#Ÿ€@D(P¡B…@V>|ùóçÐÀ³ÿe ÍTÀ3ÁJ F=i€ÀGIM­@D«V­Zµj@V>|ùóçÐÀ³çà*† À4F­‹ĀÀFù*­Ðwº@EB… +(P@V>|ùóçÐÀ³Ïÿm–¬À4\ç‡ÌÔš€ÀFšž~BhÝ@EÙ³f͛6@V>|ùóçÐÀ³·ÇéÇâ8À4€9U†žš€ÀFWÜ鉩s@FpáÇ@V>|ùóçÐÀ³Ÿ>®ŽgÀ4çJdjDP€ÀF¯P•Dâ@G @@V>|ùóçÐÀ³†hž1gÀ5&*uH€ÀEµFDö@GŸ>|ùóç@V>|ùóçÐÀ³mJ𠺓À5`ꜞçрÀEc·R}#@H6lÙ³fÍ@V>|ùóçÐÀ³Sê*ŒÉˆÀ5—#îìá€ÀEåOÛ®@H͛6lÙ³@V>|ùóçÐÀ³:K%í@þÀ5ÊUkÿáñ€ÀDÀ{}žÅ@Idɓ&L™@V>|ùóçÐÀ³ rˆäG§À5ù'Ð$‡€ÀDnõsMt‚@Iû÷ïß¿~@V>|ùóçÐÀ³dᙛ€À6$)Š—úü€ÀD˜XÌõ@J“&L™2d@V>|ùóçÐÀ²ì&€¬ ‰À6Kp™ÿt*€ÀCÌtÒ +‘@K*T©R¥J@V>|ùóçÐÀ²ÑŒ,X•ÎÀ6ošBc€ÀC{œBê€@KÁƒ 0@V>|ùóçÐÀ²·)·Ÿ–‘À6)ñ«#€ÀC+ ‡“W@LX±bŋ@V>|ùóçÐÀ²œsj?áÿÀ6«Ë/ˆCV€ÀBÛ Qø.@Lïß¿~ýû@V>|ùóçÐÀ²Jû=À6Å€óm°€ÀB‹kæÀW{@M‡8pá@V>|ùóçÐÀ²f«D^ÞÃÀ6ÛWŸt€ÀB|ùóçÐÀ²K¡#ÔˊÀ6íãa T?€ÀAíËkºÌ@NµjÕ«V­@V>|ùóçÐÀ²0‚™‚=ÐÀ6ý€|®%€ÀAŸàvk×$@OL™2dɒ@V>|ùóçÐÀ²S89GÀ7 +kŸ˜û€ÀAR%Ôc@OãǏ|ùóçÐÀ±útÚºPÀ7QÍג€ÀA žÿÂu@P=zõëׯ@V>|ùóçÐÀ±Þϧ`X€À7pÔ³†€À@º5M;ë;@P‰$H‘"@V>|ùóçÐÀ±Ã‚ ÚÿqÀ7ß1Q7œ€À@o"摃@PÔ©R¥J•@V>|ùóçÐÀ±š0¹”.À7!ž$ +!€À@$Üq#è„@Q @@V>|ùóçÐÀ±ŒÞŽªh-À7!Ó©€À?¶Ò‘_E@Qkׯ^œ{@V>|ùóçÐÀ±qŽß2eðÀ7 ²“¢–€À?% HEAx@Q·nÝ»ví@V>|ùóçÐÀ±VCþ”íÀ7²éöÔs€À>–.6’i–@R 0`@V>|ùóçÐÀ±;ŒTbfÀ7' îx&€À>‡Y1@RN:téÓ@V>|ùóçÐÀ±Ç¥ës\À7}€(s€À=|µ…:Ò)@Rš4hÑ£F@V>|ùóçÐÀ±›-33ÝÀ6ûÍïaê€À<òÁtٚ^@Rå˗.\¹@V>|ùóçÐÀ°é}šÒ!LÀ6î.ÁŽšß€À|ùóçÐÀ°ÎqT³|ùóçÐÀ°³xR„f<À6Í{Cß4u€À;`_l³†@Sȑ"D‰@V>|ùóçÐÀ°˜”ª;7NÀ6º’€JbG€À:Þ%wՌ@T(P¡B…@V>|ùóçÐÀ°}ÈJŸ“uÀ6ŠS€À:]åa_@T_¿~ýû÷@V>|ùóçÐÀ°c ÛAÀ6 V<²€À9ߢmB}@T«V­Zµj@V>|ùóçÐÀ°H|Š ÕÂÀ6x—£ìŸx€À9c_XçÙG@TöíÛ·nÝ@V>|ùóçÐÀ°.ÅäQ&À6_ÆÕãø€À8éLiŠ7@UB… +(P@V>|ùóçÐÀ°¢ù3ÆÏÀ6E­ô¹4€À8pÝøÞŠ@UŽ8páÃ@V>|ùóçÐÀ¯òÉs-ÙÀ6*\Ž9sž€À7úž—š@UÙ³f͛6@V>|ùóçÐÀ¯ŸŽÖFÀ6 ç÷{ü÷€À7†an +Ÿ@V%J•*T©@V>|ùóçÐÀ¯Š˜¹)5øÀ5ð`J@§À€À7$b<Ã8@VpáÇ@V>|ùóçÐÀ¯V鑾ÞÀ5ÑÖ£9ÿπÀ6£åžº`@VŒxñãǏ@V>|ùóçÐÀ¯#ƒ­Šñ¡À5²[p€³€À65£2ªE®@W @@V>|ùóçÐÀ®ði4š9WÀ5‘þ•ÙÓ²€À5ÉZÂ/@WS§N:t@V>|ùóçÐÀ®œœ)†òõÀ5pÏq;I€À5_+ž@WŸ>|ùóç@V>|ùóçÐÀ®‹kcÈ<À5NÜ×íµ€À4öŠÝáw @WêÕ«V­Z@V>|ùóçÐÀ®Xñ¶ôv¬À5,5@­€À45+l@X6lÙ³fÍ@V>|ùóçÐÀ®'§¿ƒfÀ5åù²­z€À4+­·‚é-@X‚ @@V>|ùóçÐÀ­õ‘¹Tc°À4äüÇܳ€À3É ÓÃA@X͛6lÙ³@V>|ùóçÐÀ­ÄaH‘%À4À†CÑ(€À3hJƒ5¬º@Y2dɓ&@V>|ùóçÐÀ­“‡”z ÄÀ4›Žµª¡5€À3 dƒF«Ü@Ydɓ&L™@V>|ùóçÐÀ­cÀájÀ4v!åÕd€À2¬TQù$R@Y°`Áƒ @V>|ùóçÐÀ­2ÜÒÙBÀ4PKŽÑD€À2Q45ÑA@Yû÷ïß¿~@V>|ùóçÐÀ­ º1ëÃÀ4*:Oœ€À1÷ž;Ì@á@ZG|ùóçÐÀ¬Ó™Jz~×À4Š”­ýï€À1ŸìM'¥@Z“&L™2d@V>|ùóçÐÀ¬€€@ŒÆÀ3ܵö +b€À1Iø$ž”Û@ZÞœzõë×@V>|ùóçÐÀ¬uÃ@‚"‹À3µžI*¬€À0õ»\+~@[*T©R¥J@V>|ùóçÐÀ¬GbÛM¡ À3ŽO-æáõ€À0£/nÑ7.@[uëׯ^œ@V>|ùóçÐÀ¬_‹&œ1À3fÐmI°È€À0RM¿,Ñ@[Áƒ 0@V>|ùóçÐÀ«ë¹¶ËŠÀ3?*Cír€À0™ÒÑP@\ 4hÑ£@V>|ùóçÐÀ«Ÿq±üzQÀ3d‰çY€À/jÜv,ò@\X±bŋ@V>|ùóçÐÀ«‘‡Ÿf@îÀ2ÉmɀÀ.ÒÅ¢y‡Š@\€H‘"Dˆ@V>|ùóçÐÀ«dü „™À2Ǘ߫@=€À.=Í¿*c@\ïß¿~ýû@V>|ùóçÐÀ«8ÎŒ}ãHÀ2ŸžÄ,m“€À-«äÓ· à@];víÛ·n@V>|ùóçÐÀ« ÿÞ÷ŠŽÀ2w¡ÇxZǀÀ-ÿK–­@]‡8pá@V>|ùóçÐÀªáuãùàÀ2OŠøCŽX€À,‘ŸŠò@]Ò¥J•*T@V>|ùóçÐÀª¶}uGã À2'Ž‹‡€À, œ~@@^|ùóçÐÀª‹ÉÃúÄŠÀ1ÿ΃˜?€À+Ô̋óå@^iÓ§N:@V>|ùóçÐÀªat<^àÀ1×ûjºyπÀ*þp9tì8@^µjÕ«V­@V>|ùóçÐÀª7|­yÀ1°?žKà€À*}ɳyæÿ@_ @V>|ùóçÐÀª âٞþÀ1ˆŸ­dÞÆ€À)ÿÓ³»ÑV@_L™2dɒ@V>|ùóçÐÀ©äŠ{€ÒÀ1aâÚ_û€À)„€Ìâä@_˜0`Áƒ@V>|ùóçÐÀ©»Ç@®jÀ19ÄGç;€À) îXKÃ@_ãǏ|ùóçÐÀ©“DÐoŒ1À1ŠÐԞ€À(•'4ÌO@`¯^œzö@V>|ùóçÐÀ©kǪyLÀ0눍yµÙ€À(!Ö(èv@`=zõëׯ@V>|ùóçÐÀ©CT»‘†åÀ0įOᇬ€À'°‹ÉŸh?@`cF4h@V>|ùóçÐÀ©æ9ŒÎŽÀ0ž +–•#€À'A£Fgov@`‰$H‘"@V>|ùóçÐÀšôÒÈ©íéÀ0w•¥S/€À&ÕIÆ@`®Ý»víÛ@V>|ùóçÐÀšÎè6€áÀ0QZÔÁ׀À&jŖ@`Ô©R¥J•@V>|ùóçÐÀš§» À0+ZÍYQ€À&·µiƒ@`útéÓ§N@V>|ùóçÐÀšµº@â +À0•Òn®€À%œÚM`dG@a @@V>|ùóçÐÀš\ O` ‚À/À @ìø€À%9!u_à¥@aF 0`Á@V>|ùóçÐÀš6µ;35@À/u– ¶¥®€À$ׁtÿÛ7@akׯ^œ{@V>|ùóçÐÀšžâñú®À/+µ¶Èõ€À$wîÃÍð›@a‘£F4@V>|ùóçÐÀ§í§šÊxÀ.âjTŀÀ$^ +(¹û@a·nÝ»ví@V>|ùóçÐÀ§ÈÄæ‘W(À.™!Ü?ú9€À#ŸÄ!Ä«Ö@aÝ:téÓ§@V>|ùóçÐÀ§€ËùfÈqÀ.PŸE€À#eù$@b 0`@V>|ùóçÐÀ§(6µÔµÀ.ëhÎD€À# I$~ÜU@b(Ñ£F@V>|ùóçÐÀ§]Øò(éaÀ-Á«™9]€À"·RŒ­ˆ@bN:téÓ@V>|ùóçÐÀ§:Ý|ЈsÀ-{엉}€À"c(€|â_@bthÑ£F@V>|ùóçÐÀ§5%hÀ-4í@s÷þ€À"ÀD ¯@bš4hÑ£F@V>|ùóçÐÀŠõß8– ŽÀ,ïr<”€€À!À Ž@bÀ@V>|ùóçÐÀŠÓÛ-•BÀ,ª‘UÙ€À!qG·ü@V£F4jÀµb¢$J€€ÀLÎ÷£P?òå˗.\¹@V£F4jÀµb7g; †¿æ‘4áTüƒ€ÀLÌt¿áÃ@å˗.\¹@V£F4jÀµ`÷šœ<¿ö_[c €ÀLdžŒq.@ X±bŋ@V£F4jÀµ^ã9>íÇÀå>qN€ÀL¿PŸÁeç@å˗.\¹@V£F4jÀµ[úÍE`ãÀ~;ç €ÀL³Ù+Ôº•@Ÿ>|ùóç@V£F4jÀµX?`™;À Q6¹Ä&€ÀL¥&'î“J@X±bŋ@V£F4jÀµS²5XbàÀˎ^—ڀÀL“?Ú'CÑ@ ‰$H‘"@V£F4jÀµNTÒŎYÀ‰Óˆ¯ß€ÀL~0H¿©z@"å˗.\¹@V£F4jÀµH)ØÕÀA•|à¡R€ÀLf,ì–÷@%B… +(P@V£F4jÀµA0Փ¹ŒÀñ÷®eTù€ÀLJÅã‚óv@'Ÿ>|ùóç@V£F4jÀµ9n•­–Àš$Xþ3€ÀL,‡[й0@)û÷ïß¿~@V£F4jÀµ0ä͞vOÀ9M0$—±€ÀL X£&Ú@,X±bŋ@V£F4jÀµ'–F,ÍÀ gVÂé€ÀKçIŽ0|@.µjÕ«V­@V£F4jÀµ…þrå"À!¬Áºô`€ÀKÀo™œ~ú@0‰$H‘"@V£F4jÀµ·-šI*À"쏙ºu€ÀK–Þ.'@1·nÝ»ví@V£F4jÀµ->-¬zÀ$&j^¬ö&€ÀKjªË]Î×@2å˗.\¹@V£F4jÀŽúëËkK"À%ZÓˆK€ÀK;ì:°ž@4(P¡B…@V£F4jÀŽíöžŸ…5À&‡ +ørõ€ÀK +¹ñ=-+@5B… +(P@V£F4jÀŽàQ«ŸýtÀ'­@, +Ø~€ÀJ×,IÕÚc@6páÇ@V£F4jÀŽÒBˆÀ(Ìa@۔€ÀJ¡\XÞ©Þ@7Ÿ>|ùóç@V£F4jÀŽÃ çÖKÀ)ä3Âij€ÀJicÑÀ2@8͛6lÙ³@V£F4jÀ޳mói¬£À*ô‚dêJ€ÀJ/\è„"{@9û÷ïß¿~@V£F4jÀŽ£4U4pƒÀ+ý,4€ÀIób=¢? @;*T©R¥J@V£F4jÀŽ’`ďßÀ,ýÛ çàc€ÀIµŽœ$Àj@µjÕ«V­@V£F4jÀŽ\yŸ\ÎmÀ/ϔµ­€ÀHòúQ}ï@?ãǏ:w؀ÀH­èµ-_@@‰$H‘"@V£F4jÀŽ5à7ÝV_À0ín£&•€ÀHhp%ϓ]@A @@V£F4jÀŽ!Õ¯1À1+SWÀ/t€ÀH!‰ÃÛ@A·nÝ»ví@V£F4jÀŽ S8§°À1ŽÂ+¢—€ÀGÙø$”/ö@BN:téÓ@V£F4jÀ³ø]Î$.4À1íù'ŠËr€ÀG‘,¢øòx@Bå˗.\¹@V£F4jÀ³âúkº€À2Hú 3œ®€ÀGGy(ΐ@C|ùóçϟ@V£F4jÀ³Í.OØKÀ2ŸÈlÚ$€ÀFüöÞGb¿@D(P¡B…@V£F4jÀ³¶ý•âÓ7À2òiâžò-€ÀF±ŸcrÑï@D«V­Zµj@V£F4jÀ³ mþº÷cÀ3@åß3рÀFeçœÇ?Ÿ@EB… +(P@V£F4jÀ³‰„#NÄÀ3‹EŒØÃ€ÀFŠQQÏ@EÙ³f͛6@V£F4jÀ³rDØIU À3ѓŽy€)€ÀEÌŒ×%e@FpáÇ@V£F4jÀ³ZŽä²·VÀ4ܟ俚€ÀE•UÓRž@G @@V£F4jÀ³BÙ:EËÀ4R-ý"8h€ÀE2)J@GŸ>|ùóç@V£F4jÀ³*µÑ¢š©À4Œ–Â4ní€ÀD䌶£í@H6lÙ³fÍ@V£F4jÀ³OíNÛüÀ4Ã'l· €ÀD–ÓöDà8@H͛6lÙ³@V£F4jÀ²ù«ÓïÀ4õðpW^€ÀDIⵜµ@Idɓ&L™@V£F4jÀ²àÍñOKÀ5%ú €ÀCûXœ¬ª@Iû÷ïß¿~@V£F4jÀ²Çº›?ˆžÀ5PuúÍŸ*€ÀC­º¬Ï2@J“&L™2d@V£F4jÀ²®vìRÀ5xY É£á€ÀC`F\ªøº@K*T©R¥J@V£F4jÀ²•x{ûùÀ5œÃ‚刀ÀC ºm7@KÁƒ 0@V£F4jÀ²{iá]†ëÀ5œÈ²1 l€ÀBÆ;• #@LX±bŋ@V£F4jÀ²aª@’“,À5Û~Ç(÷ƀÀBy‰Š Q6®æ@Q·nÝ»ví@V£F4jÀ±&yfܔhÀ6^nH4€À>ï Ñ.@R 0`@V£F4jÀ± +éylÀ6XßTŒµ)€À=“cU1Ðé@RN:téÓ@V£F4jÀ°ñ°WïúÀ6Q”E š€À= ‡K.—@Rš4hÑ£F@V£F4jÀ°×YNÆysÀ6HNº©€À<‡cö/mÊ@Rå˗.\¹@V£F4jÀ°œZž}?À6="*ísç€À<]žB^@S1bŋ,@V£F4jÀ°¢Ñ¬ãNAÀ60%»¶[d€À;‚f[~@S|ùóçϟ@V£F4jÀ°ˆ¥]§³úÀ6!mƒCãx€À;™¯)Ì@Sȑ"D‰@V£F4jÀ°n‹mŀÀ6 ß1Mµ€À:„Ÿ÷ ‹™@T(P¡B…@V£F4jÀ°T…ɇûÀ5ÿÃÕ €À:}˘×@T_¿~ýû÷@V£F4jÀ°:–1ãÂ'À5ë§ž–9€À9Ž6Á }@T«V­Zµj@V£F4jÀ° ŸrS^†À5ÖÇÒÀ€À9ÍŠ¯h@TöíÛ·nÝ@V£F4jÀ°(¹œÍÀ5À³bHù€À8ŸD5ðD@UB… +(P@V£F4jÀ¯Ú¹ÆxÄ À5© ‡rdº€À8*œàJÖ@UŽ8páÃ@V£F4jÀ¯§¬5€Å?À5S¡ +ɀÀ7·×P‡º@UÙ³f͛6@V£F4jÀ¯tÚgšAiÀ5vuVàßX€À7FóýšH!@V%J•*T©@V£F4jÀ¯BFýtUÀ5[ƒFd—Á€À6×òm·¶@VpáÇ@V£F4jÀ¯ôo™ƒÀ5?œÆö€À6jњéâÓ@VŒxñãǏ@V£F4jÀ®ÝåûŽèÀ5"¢rZŠ€À5ÿû~Ÿ`@W @@V£F4jÀ®¬l“šÀ5Ò¶XÄV€À5–+Š~<–@WS§N:t@V£F4jÀ®z˜…›GÀ4æ,¢³.‹€À5.¡Ïß@WŸ>|ùóç@V£F4jÀ®I_Iß>RÀ4ÆŸ‚±˜€À4ÈïèxŸœ@WêÕ«V­Z@V£F4jÀ®q+^šÀ4Š–%§“]€À4e¯é§@X6lÙ³fÍ@V£F4jÀ­çÏÒ1ü©À4…Àßñè܀À4ߍð@X‚ @@V£F4jÀ­·|Dž ³À4dKŒ0Îq€À3¢Æžxϝ@X͛6lÙ³@V£F4jÀ­‡yvŽí)À4BBŒ¹.€À3DOÌÝÚZ@Y2dɓ&@V£F4jÀ­WÇ.j0bÀ4±Í7êЀÀ2çôÍ @Ydɓ&L™@V£F4jÀ­(g!¯uÀ3ü€Ä‚Bk€À2Œ©ºs ‹@Y°`Áƒ @V£F4jÀ¬ùZidÕÀ3Ù&vŽ–”€À23p¡å7=@Yû÷ïß¿~@V£F4jÀ¬Ê¢^ø†À3µAv‘­€À1Ûì’h#b@ZG×MÓ©À3ÿé8+å€À1†'äé@Z“&L™2d@V£F4jÀ¬n1³ÞJÀ3lk‡û·€À11íš‘–ù@ZÞœzõë×@V£F4jÀ¬@{Q§ž›À3Gž¶ò€À0ßguE1Ô@[*T©R¥J@V£F4jÀ¬S¿[À3"oԐ<€À0ޢĝú@[uëׯ^œ@V£F4jÀ«æH©òÀ2ýu=ÿ€À0?03â‰â@[Áƒ 0@V£F4jÀ«¹f«BOYÀ2ב×ÊO/€À/âæ%â8Á@\ 4hÑ£@V£F4jÀ«ã¥þÀ2±ãöê€À/J„+iR[@\X±bŋ@V£F4jÀ«aH À2ŒU”Í‹€À.µ.3q…@\€H‘"Dˆ@V£F4jÀ«5qœ¿À2f)àˆž€À."×coç@\ïß¿~ýû@V£F4jÀ« +'™H 2À2@-U{Z³€À-“rËïw@];víÛ·n@V£F4jÀªß7àð&À2$˜V€À-ôfŸÀ@]‡8pá@V£F4jÀªŽ¢ +@u]À1ô(Cîm€À,}O._‡.@]Ò¥J•*T@V£F4jÀªŠfDRiÀ1ÎQÏAæ€À+övdY?@^ÔÎÀ0zjx®þ_€À'® ¥tˆé@`cF4h@V£F4jÀš÷FºTXÀ0UO²il%€À'AÄbvž@`‰$H‘"@V£F4jÀšÐÝõ[ÛÀ00a°]F7€À&ÕÄõ™@`®Ý»víÛ@V£F4jÀšªÊšÕ=€À0 £Aø,U€À&l±|Ë!@`Ô©R¥J•@V£F4jÀš…ÈÔl_À/Î.8S€À&ÊÃ_Ë]@`útéÓ§N@V£F4jÀš_©p^mÀ/…~äVi„€À%¡Yæ@a @@V£F4jÀš:˜Ð’ªõÀ/==” £:€À%>UùZM@aF 0`Á@V£F4jÀšÝŽõŠ7À.õnWãÍ©€À$ݱ„AZ@akׯ^œ{@V£F4jÀ§ñw$ƒÚÌÀ.®`©€À$ -H@a‘£F4@V£F4jÀ§Íd‘ŽÑûÀ.g5|¹€À$"]»œ+;@a·nÝ»ví@V£F4jÀ§©¥jãzÃÀ. ÒÓðó)€À#Ǚè¯D@aÝ:téÓ§@V£F4jÀ§†9¢(gÀ-Úð2€ïR€À#nŽsíDd@b 0`@V£F4jÀ§c ÿÌÀ-•iqǀÀ#¥ÆI·@b(Ñ£F@V£F4jÀ§@V—»À-P¶ä9þ€À"ÂbçÓNt@bN:téÓ@V£F4jÀ§ß(žÊ]À- ch.o€À"náðKٍ@bthÑ£F@V£F4jÀŠûž7óYÀ,Ț€` ǀÀ"&'Œ%@bš4hÑ£F@V£F4jÀŠÙàÊØ‘ŸÀ,…]¥M€€À!ÌþýGt0@bÀ@V£F4jÀŠžX‘e˜âÀ,B®£â €À!~Š5J8@W @Àµ ]¢ÐÃȀ€ÀKãïzx?òå˗.\¹@W @ÀµøŸk¿åLpÇÜž€ÀKá„Ô{ú@å˗.\¹@W @ÀµÇSõпõ}ÂòZEy€ÀKÜãÖэ`@ X±bŋ@W @ÀµËî£KøÀçúgE€ÀKÕ1 ¶o@å˗.\¹@W @Àµ}T™ÚÀoŠÅœKۀÀKÊoՄ€ì@Ÿ>|ùóç@W @ÀŽÿwè_JžÀ +ŸbÝQòÀÀKŒŠùÅŒ@X±bŋ@W @ÀŽû!Y UTÀ3Ø[ð€ÀK«Û_ò1Ä@ ‰$H‘"@W @ÀŽö8ƒ=@À QL1†€ÀK˜»Ãµ@"å˗.\¹@W @ÀŽð".‹²*À7Ñ@*ó‡€ÀKh՘Ž@%B… +(P@W @ÀŽé} ðsÀÈÎtxx€ÀKg×Ñô…Ö@'Ÿ>|ùóç@W @ÀŽâ-8ÀR5ЈÀ€ÀKKsDjýl@)û÷ïß¿~@W @ÀŽÙò¯öNKÀÓGèÿÛ³€ÀK,Jœ8è@,X±bŋ@W @ÀŽÑ9„MÏÀKL˜Ëc€ÀK +lžwç³@.µjÕ«V­@W @ÀŽÇxdFœÀ ÜÉÄcùˀÀJåì9;Œ@0‰$H‘"@W @ÀŽœ(­ór¬À"ºU,Ük€ÀJŸÛ‡ØSð@1·nÝ»ví@W @À޲%»çìˆÀ#;(R/žr€ÀJ•N0iá©@2å˗.\¹@W @ÀŽŠs°$À$așÔH€ÀJiXϞFÕ@4(P¡B…@W @Àޚ1·» À%‚T¹«À€€ÀJ;áWÉæ@5B… +(P@W @ÀŽ Ƙ‰oÀ&œ‹m€ÀJ +ŒšÖÐ/@6páÇ@W @ÀŽ` 8 +À'°/…ù€ÀI×㚎 @7Ÿ>|ùóç@W @ÀŽq°Ù]À(œÊu πÀI£+º&$à@8͛6lÙ³@W @ÀŽb* +&UÄÀ)ÂåÉA{Z€ÀIl~•ÖÑ@9û÷ïß¿~@W @ÀŽR§Ú3uÀ*Á˜g²ô€ÀI3ôõÃÄ@;*T©R¥J@W @ÀŽB‘hƒÙÏÀ+žøäïl€ÀHù¥/œË@µjÕ«V­@W @ÀŽ“lŒ«À.qÛ܂€ÀHA|Î'@?ãǏ|ùóç@W @À²é«t—š¬À3ÂoŸX}?€ÀDid»<@H6lÙ³fÍ@W @À²Ò4~IُÀ3ø¿Ü€€ÀDÿ„òk@H͛6lÙ³@W @À²ºíšÀ4+y5/Žä€ÀCÔâÁÿ@@Idɓ&L™@W @À²¢³4FvÀ4Z«ø;î]€ÀCŠÃµÄc@Iû÷ïß¿~@W @À²Šl2,Ö8À4†i1Ô\€ÀC@±5ûtÇ@J“&L™2d@W @À²r +Nq+À4®Â¯ž¹Ã€ÀBöœ °Ü@K*T©R¥J@W @À²Y’1ªÀ4ÓÊó! K€ÀB¬õÚVŒ @KÁƒ 0@W @À²@ä‡æÌ€À4õ•œ²*€ÀBcj&}/µ@LX±bŋ@W @À²(Ö ¬À54³Û̀ÀB'ò֊@Lïß¿~ýû@W @À²ͱŠ÷À5/œãêû€ÀAÑ;Ñ<@M‡8pá@W @À±ö7%œÀ5HEà€ÀAˆ²»ãš„@N®bVÇ@Qkׯ^œ{@W @À±ö0PÀ5«GG°œ€À>'ö‚-£@Q·nÝ»ví@W @À°÷npDÔ4À5ªG4ì ԀÀ=¢Óýüž”@R 0`@W @À°ÝÙW£‚ÁÀ5§.œ|ÇI€À=3ý«'~@RN:téÓ@W @À°ÄIîÜgÀ5¢óöu€À< Õ§—@Rš4hÑ£F@W @À°ªÀ ¬ À5›±Õšg€À<£Í–áµ@Rå˗.\¹@W @À°‘@lïþ?À5’ö÷š€À;Å7=º@S1bŋ,@W @À°wÌ`ÇH/À5‡nØLœ€À; Œw‘@S|ùóçϟ@W @À°^eöæ9À5{ ·Â8ڀÀ:¥|x;@Sȑ"D‰@W @À°E% @À5máJG€À:+%šmé}@T(P¡B…@W @À°+ÉÏ!è4À5]v3¯ŀÀ9³ÂV@T_¿~ýû÷@W @À°—œõ»À5Li-ƒ>ë€À9<™tX@T«V­Zµj@W @À¯òõNՁøÀ59òé$ÓŠ€À8Çï Rªž@TöíÛ·nÝ@W @À¯ÀèW^>"À5&%yœÉ€À8U}9'œ@UB… +(P@W @À¯ ¬ŠÃ0À5#G³€À7ãÞŒöüê@UŽ8páÃ@W @À¯]b= zÙÀ4úǎ"Š'€À7t|µ§@UÙ³f͛6@W @À¯+îÐGđÀ4ãYèJĀÀ7ÞLŒ¿@V%J•*T©@W @À®úŽQUÀ4ÊÕ­ÊŠ•€À6›w|B@VpáÇ@W @À®ÉŽ^ôSÀ4±LÏÚõ²€À60ñ5gZi@VŒxñãǏ@W @À®˜ò+üÀ4–Íœb€À5È ¡ßÚh@W @@W @À®ho¥ÂSÀ4{f¹ëºÏ€À5b]_Ãü@WS§N:t@W @À®8.ÝŒ1ñÀ4_&^!ä€À4ýD—N¯@WŸ>|ùóç@W @À®1ÅL¥ôÀ4BJ€Cî€À4š5ýhµ@WêÕ«V­Z@W @À­Øz-ܖgÀ4$OϏ猀À48á8Þ•@X6lÙ³fÍ@W @À­© Éé>ðÀ4ÓË1ºB€À3ÙF |×k@X‚ @@W @À­yâ.)MÀ3沫;a”€À3{`8;øC@X͛6lÙ³@W @À­KÒøÀ3ÆømÉù€À3,'λ›@Y2dɓ&@W @À­sèîÀ3а¢¯K%€À2Ä¥òu™œ@Ydɓ&L™@W @À¬î..²I‰À3…ælè8ÿ€À2kÉoú@Y°`Áƒ @W @À¬À7OÆNßÀ3d€„;ñÁ€À2’;j9 @Yû÷ïß¿~@W @À¬’ƒ€2XÀ3Bõ6ÿÂM€À1Ÿûž•›@ZGÕ@€À*d*|]pœ@_ @W @À©Œ€•šÀ0äÏ ?.u€À)éÚ4gÝ&@_L™2dɒ@W @À©”b>Ÿ·%À0À­ø²Ãý€À)r„ûK@_˜0`Áƒ@W @À©lú«˜×À0œ rØ/À€À(ü”ËŸ~@_ãǏ3‹A„%@`‰$H‘"@W @Àš¬èP¹À/Ô +CäP€À&Ô6÷Ü2‚@`®Ý»víÛ@W @Àš‡z'qó•À/pŒ“›€À&lbeMÕ]@`Ô©R¥J•@W @Àšb_fKX>À/G,±‰Éº€À&ªäŽ@`útéÓ§N@W @Àš=—mܳëÀ/C8àq€À%£Š-’0@a @@W @Àš!ÐVŸÜÀ.»¹ï€-€À%AhRŸÛ@aF 0`Á@W @À§ôþ}Â%À.v’«* €À$áÇka¥ï@akׯ^œ{@W @À§Ñ+ÔÀ.1Ô'.ü™€À$„…]r”@a‘£F4@W @À§­ªøÖÀ-í[t›¬€À$(Te5N@a·nÝ»ví@W @À§Šyœòù9À-©ÖucE€À#Îlýƒæo@aÝ:téÓ§@W @À§g˜¥“À-f,â”â€À#vZY`ƒ«@b 0`@W @À§E¶X{À-#1‰žf€À# `ÐlÔ@b(Ñ£F@W @À§"ÄQÉÎÇÀ,க®í€À"ˋwN +Œ@bN:téÓ@W @À§ÏÙ)À,žŠ–xž€À"xŒ)U±ý@bthÑ£F@W @ÀŠß)ê­ À,]ä·èž€À"'›,ªš@bš4hÑ£F@W @ÀŠœÏm*bÀ,¡‚u}€À!Ø`…­ç@bÀ@W @ÀМÂLÙiÀ+ۆ»aQ‚€À!Š?ͯÔ5@WlÙ³f͜À޲ì +ñ܀€ÀKþ¹ÝÞç?òå˗.\¹@WlÙ³f͜À޲‹(|ߚ¿ä‚-Î(ȀÀK‹éKÝ[@å˗.\¹@WlÙ³f͜Àޱh“Žwß¿ô~蒓÷’€ÀJý4=̵@ X±bŋ@WlÙ³f͜Àޝ„­;É¿þ¶4Š+{O€ÀJõúžPy@å˗.\¹@WlÙ³f͜ÀެàðëÀq߅·û?€ÀJëá}2Ž@Ÿ>|ùóç@WlÙ³f͜ÀŽ©{† À ‚*`æ ž€ÀJÞîËoäÁ@X±bŋ@WlÙ³f͜ÀŽ¥XAŸâÀŠg$™U€ÀJÏ)÷ċœ@ ‰$H‘"@WlÙ³f͜ÀŽ ws šîÀĄ— žB€ÀJŒšâÐ!h@"å˗.\¹@WlÙ³f͜ÀŽšÚ«ÉuAÀ>F$Ô]Œ€ÀJ§K<¬?@%B… +(P@WlÙ³f͜ÀŽ”ƒ«û`ðÀ±ºù±Mç€ÀJFKj@'Ÿ>|ùóç@WlÙ³f͜ÀŽtk0])À+EZyp€ÀJt—Àôº@)û÷ïß¿~@WlÙ³f͜ÀŽ…¯InŸÀ‚å8‘Š×€ÀJWMâ“d@,X±bŋ@WlÙ³f͜ÀŽ}6 ŽÍKÀß=Šõåi€ÀJ7wFü9@.µjÕ«V­@WlÙ³f͜ÀŽt ãnøÀ Gúþ£Ó€ÀJ#ØúàÌ@0‰$H‘"@WlÙ³f͜ÀŽj3XÛéqÀ!>ÒC»ä€ÀIðd“`+@1·nÝ»ví@WlÙ³f͜ÀŽ_¯\Ž,ŠÀ"]ÛËA± €ÀIÉK^ª³@2å˗.\¹@WlÙ³f͜ÀŽTƒàçÊÀ#x5|ú<€ÀIŸëà9ý@4(P¡B…@WlÙ³f͜ÀŽH±œ‡ IÀ$Œê¶#1€ÀItWdÎoÕ@5B… +(P@WlÙ³f͜ÀŽ<>‚$àÀ%›œŠœEƀÀIF€¢Ú¬€@6páÇ@WlÙ³f͜ÀŽ/-C’­À&€tëßÖڀÀIçØŒ S@7Ÿ>|ùóç@WlÙ³f͜ÀŽ!ŠÐBòÀ'ŠÛª\ҀÀHå6›áç@8͛6lÙ³@WlÙ³f͜ÀŽ?"‡y¯À(¢Á—¥›`€ÀH±ŠûÚvœ@9û÷ïß¿~@WlÙ³f͜ÀŽiîsqËÀ)—úÿúŽó€ÀH|Olñz@;*T©R¥J@WlÙ³f͜À³õëR]'À*†`Æ.§a€ÀHEF³ UÈ@µjÕ«V­@WlÙ³f͜À³Ãª9 èÀ-'YYçp+€ÀG–ì&iŒ@?ãǏÇBÀ-ùCúfR„€ÀGZØ¥!ç@@‰$H‘"@WlÙ³f͜À³ D¥êªÀ.Ãڵ͙ý€ÀGâžÿ7@A @@WlÙ³f͜À³ß‹_W—À/‡Êú €ÀFܘ1×û@A·nÝ»ví@WlÙ³f͜À³{ Y`@À0!nŸý€!€ÀFœ>TŠè@BN:téÓ@WlÙ³f͜À³gÆq'ÜÀ0{þqVԀÀFZëªÖ±ë@Bå˗.\¹@WlÙ³f͜À³T6»pÀ0Ò˜ar³€ÀF¶IÔW€@C|ùóçϟ@WlÙ³f͜À³@ ÝyÀ1$Ö̱·<€ÀEÕŽJñä@D(P¡B…@WlÙ³f͜À³+U{ûŠÀ1sä‘jÁ€ÀE‘ú¡Ù6 @D«V­Zµj@WlÙ³f͜À³Óle5hÀ1¿CzÐð€ÀEMžî„â:@EB… +(P@WlÙ³f͜À³²§çÀ2ù©Ÿî€ÀEµ—BÖ @EÙ³f͛6@WlÙ³f͜À²ì?V èÀ2K­ž£€ÀDÃR®Žg¥@FpáÇ@WlÙ³f͜À²Ö}»"r À2‹‹uöSì€ÀD}‰Œ‚ +@G @@WlÙ³f͜À²Àr$`ÖÀ2Èz5 Ç€ÀD7m¶b @GŸ>|ùóç@WlÙ³f͜À²ª OËÐÀ3æKõ§¥€ÀCñün]{@H6lÙ³fÍ@WlÙ³f͜À²“,ÉlÑÀ37Ü3­ß€ÀCª…Sœ@H͛6lÙ³@WlÙ³f͜À²|Œ!`Z»À3jifù66€ÀCcÛáL'@Idɓ&L™@WlÙ³f͜À²e±]jo€À3™œL³º€ÀC%)v8@Iû÷ïß¿~@WlÙ³f͜À²Np͹ö{À3ń"°ÿž€ÀBÖq BÅ@J“&L™2d@WlÙ³f͜À²6þL«õ†À3î0é3 ƀÀBÎÄ¡5ä@K*T©R¥J@WlÙ³f͜À²]¡NXóÀ4³O…€ÀBIL珝¯@KÁƒ 0@WlÙ³f͜À²’~2”À46žV'\€ÀBùcí\ö@LX±bŋ@WlÙ³f͜À±ï ‚Ö8WÀ4U~ª(!®€ÀAŒá‚äî®@Lïß¿~ýû@WlÙ³f͜À±×‹6á¢÷À4q댯T&€ÀAwèÑ·‡@M‡8pá@WlÙ³f͜À±¿V ψ_À4‹v†PE€ÀA1––›q@N¯ºrd—@Q @@WlÙ³f͜À°ú²\ôøVÀ4ù°G€À>,Õ,I @Qkׯ^œ{@WlÙ³f͜À°áêó%êÀ4üÜjħ€À=«9õŠ~ +@Q·nÝ»ví@WlÙ³f͜À°É!;©íÀ4üü(‹€À=*õ~Ž7@R 0`@WlÙ³f͜À°°W©‰OÀ4ûØ#]ÆÛ€À<¬ –@RN:téÓ@WlÙ³f͜À°—›Â6À4øœ0—-µ€À<.F`@Rš4hÑ£F@WlÙ³f͜À°~ÎXr65À4󟁀\€À;²œÝû¯™@Rå˗.\¹@WlÙ³f͜À°f·áŸÀ4ìï ÔY€À;8rÖù@S1bŋ,@WlÙ³f͜À°M`ÛpŽŠÀ4äaˆ°g›€À:¿q>»@S|ùóçϟ@WlÙ³f͜À°4¹¿‡€çÀ4Ú(gÞÄæ€À:G°ŠŸßú@Sȑ"D‰@WlÙ³f͜À°ªL–{À4ÎUÏ<'u€À9ÑÔÀÜ4v@T(P¡B…@WlÙ³f͜À°”tÎÝ9À4Àû”èx€À9]ùlþŽ@T_¿~ýû÷@WlÙ³f͜À¯Ö3Æ~ÉÆÀ4²+75Ï<€À8êéËσß@T«V­Zµj@WlÙ³f͜À¯¥cJ­š-À4¡õàÉ;u€À8yâý(Œß@TöíÛ·nÝ@WlÙ³f͜À¯tº­ê_À4l]5ýH€À8 +€Äæ @UB… +(P@WlÙ³f͜À¯D<ÿ ”?À4}Ÿ¯0€À7œÂ4aŠ™@UŽ8páÃ@WlÙ³f͜À¯í)šÍÀ4iž#`he€À70¬€UóŽ@UÙ³f͛6@WlÙ³f͜À®ãÍïÎýÀ4Ty!碀À6Æ?ǜ†·@V%J•*T©@WlÙ³f͜À®³áó ãöÀ4>?Xò.ـÀ6]|»µUƒ@VpáÇ@WlÙ³f͜À®„+®ø?DÀ4&ÿ¥$,H€À5öcˆ_ם@VŒxñãǏ@WlÙ³f͜À®T­}ZPÀ4È{]à€À5óÛ(•@W @@WlÙ³f͜À®%i•Z€õÀ3õ§çü2€À5-,êÈo@WS§N:t@WlÙ³f͜À­öbàŽÀ3Û«Žgì-€À4Ë ~P–P@WŸ>|ùóç@WlÙ³f͜À­Ç˜á!À3ÀàšèŽ€À4j“ô(Ï@WêÕ«V­Z@WlÙ³f͜À­™äËŒÀ3¥TÀsŸ€À4 ŸHшs@X6lÙ³fÍ@WlÙ³f͜À­jÈÔÄÅ®À3‰ƒæ€À3®Šu +i@X‚ @@WlÙ³f͜À­<ÅOéÀ3l&Ò§ç€À3RôŸ>õà@X͛6lÙ³@WlÙ³f͜À­ÖG]À3NÖMY €À2øû({»¬@Y2dɓ&@WlÙ³f͜À¬áŽÐžm˜À30‚T:πÀ2 š œ@Ydɓ&L™@WlÙ³f͜À¬Ž^ŒˆÏ©À3ßù\ԀÀ2IÍçPç@Y°`Áƒ @WlÙ³f͜À¬‡w=«^'À2òŸ‘.Z€À1ô’à!Â@Yû÷ïß¿~@WlÙ³f͜À¬ZÚ4ÙvÀ2Ó*Áöu€À1 äõ€ã1@ZG™À1†8ÿ^s€À-ZùaDŸW@];víÛ·n@WlÙ³f͜Àª„Š[ÑÀ1cßç©@€À,ÓPk¬+@]‡8pá@WlÙ³f͜Àª[.~ë×À1Apwxx€À,NK6å6@]Ò¥J•*T@WlÙ³f͜Àª2”UùµœÀ1ðdÊOŸ€À+ËÞìiv²@^lƒß?€À&iïIÌ\@`Ô©R¥J•@WlÙ³f͜Àš?°O€Q7À.Áºhº¡€À&n;gÛw@`útéÓ§N@WlÙ³f͜Àš„[P×À.~ƒ ÃÀÀ%¢ð…g6@a @@WlÙ³f͜À§÷§›r¹À.;Cžþ€À%Bli³;@aF 0`Á@WlÙ³f͜À§Ô«°”×À-ù ª-䮀À$ãÖïv€@akׯ^œ{@WlÙ³f͜À§°Ú$ÆHÀ-¶Ø‘ø×€À$‡'D ¿ @a‘£F4@WlÙ³f͜À§è™ +jÀ-uýòµ€À$,S]# @a·nÝ»ví@WlÙ³f͜À§kD–škKÀ-3°ŠŠÌ€À#ÓQšbæ­@aÝ:téÓ§@WlÙ³f͜À§Hí§OǶÀ,ò%µ q€À#|¶Á23@b 0`@WlÙ³f͜À§&ãQ +±°À,±Ù™Jðæ€À#&Ÿ<ÌDÑ@b(Ñ£F@WlÙ³f͜À§%þ0rÀ,q  +Ñ,€À"ÒÜU•Ê@bN:téÓ@WlÙ³f͜ÀŠã²töž†À,1Õ<·†€À"€Æ7î>@bthÑ£F@WlÙ³f͜ÀŠÂŠéôÀ+ò{»ê$€À"0TÍJt…@bš4hÑ£F@WlÙ³f͜ÀŠ¡­ì»qiÀ+³•Ýn0š€À!áõØ@bÀ@WlÙ³f͜ÀЁôrSþÀ+u%Ʊ÷€À!”<’ŸÁ@WÑ£F6ÀŽ_+Ë)ª€€ÀJ-<à9€?òå˗.\¹@WÑ£F6ÀŽ^ÏPÚ{”¿ã’ŽáÝìX€ÀJ+àlxÿÓ@å˗.\¹@WÑ£F6ÀŽ]¹þ};Þ¿ó‰7Y8‘€ÀJ'ËÄó€À@ X±bŋ@WÑ£F6ÀŽ[ì)ªŠ¿ýOÂGÏĀÀJ!üœ@å˗.\¹@WÑ£F6ÀŽYf`¶©ÀƒzgŽŸy€ÀJƒ§ð:ƒ@Ÿ>|ùóç@WÑ£F6ÀŽV)j;ÛÀYcëa€ÀJ XŽî%Á@X±bŋ@WÑ£F6ÀŽR6DhvÏÀ '<: ä€ÀIü…ðñ @ ‰$H‘"@WÑ£F6ÀŽMŽ$*Û³Àö?ž[ˆ;€ÀIëXHšy@"å˗.\¹@WÑ£F6ÀŽH2t%äÀS»¢^,€ÀI× ˜‰1ª@%B… +(P@WÑ£F6ÀŽB$ÓK£À«aÿ©¯Ý€ÀIÀrÄ€x@'Ÿ>|ùóç@WÑ£F6ÀŽ;g‚6õÀüˆSéú­€ÀI§ZÚ4@)û÷ïß¿~@WÑ£F6ÀŽ3û;‹ËÀF‰4MEd€ÀI‹ÌÍ2+@,X±bŋ@WÑ£F6ÀŽ+ã{+<ŠÀˆÅ(:¹€ÀImÖ*Ù|:@.µjÕ«V­@WÑ£F6ÀŽ#"6Â5ŸÀ£4ÄɀÀIM†åÄSU@0‰$H‘"@WÑ£F6À޹ü,Ë@À yÈ'Èr€ÀI*íÈ·_€@1·nÝ»ví@WÑ£F6ÀŽ­ƒï©"À!€Ï»á€ÀI; +d·@2å˗.\¹@WÑ£F6ÀŽÿ®¥k×À"œ9sÚ9€ÀHß x]„þ@4(P¡B…@WÑ£F6À³ù³‚ÇÝÀ#¥³ßÔ1­€ÀH¶~&F’@5B… +(P@WÑ£F6À³íÌ*aÑ.À$©µÎO ׀ÀHŠúøÒ$š@6páÇ@WÑ£F6À³áLðŒæÝÀ%š IwӀÀH]ö0¡n@7Ÿ>|ùóç@WÑ£F6À³Ô9?óï¡À& {Nè1o€ÀH/ö\Ôÿ@8͛6lÙ³@WÑ£F6À³Æ”ž‡ÚûÀ'’Þ··IP€ÀGþkEx@9û÷ïß¿~@WÑ£F6À³žb¬êt À( c|ðŠ€ÀGÌ¥¬‘…@;*T©R¥J@WÑ£F6À³©§#iÐÀ)dÕ§ÉUX€ÀG˜.kð@µjÕ«V­@WÑ£F6À³zaOzÑnÀ+îɔsög€ÀFó>AUˆ@?ãǏ|ùóç@WÑ£F6À²l íi°À2Js¢$4€ÀC| TŽ@H6lÙ³fÍ@WÑ£F6À²VO‹‹HxÀ2øÓˆ—’€ÀC9OҊ¿@H͛6lÙ³@WÑ£F6À²@WÝíò›À2²A Žëå€ÀBõê4(¥@Idɓ&L™@WÑ£F6À²*&°16ÃÀ2áYR‰‚€ÀB²nòÓbr@Iû÷ïß¿~@WÑ£F6À²¿Ÿ\\PÀ3 N•Ê:¶€ÀBnì'D€@J“&L™2d@WÑ£F6À±ý&³ñ>À36/Úüçõ€ÀB+p„$Õl@K*T©R¥J@WÑ£F6À±æ_+¶.À3\ ñ»Øˆ€ÀAè +)Ðý¿@KÁƒ 0@WÑ£F6À±Ïl«ÎŸÀ3~ò|íòˀÀA€Æ`ª€@LX±bŋ@WÑ£F6À±žR«[¢À3žó©u]€ÀAa±é™šÜ@Lïß¿~ýû@WÑ£F6À±¡‹’IvÀ3Œ ŽâŠ€ÀAØîYYŒ@M‡8pá@WÑ£F6À±‰µšbU–À3ֈé¿g+€À@ÜGR_œ@NÒÙ î€À>©Þÿv… @PÔ©R¥J•@WÑ£F6À°ãŠ€HòŒÀ4GÕë ]e€À>*ÆKÝXš@Q @@WÑ£F6À°Ë’Pi+ŒÀ4NŠîjŸ¶€À=¬ÌZ< +L@Qkׯ^œ{@WÑ£F6À°³“2Ðß§À4SW†¡u€À=/þy»Áp@Q·nÝ»ví@WÑ£F6À°›Ã© =À4UúkŒÞ€À<Žhÿ }@R 0`@WÑ£F6À°ƒŠew ,À4V ÓÿZá€À<:Ki5Ž@RN:téÓ@WÑ£F6À°k…e>ºˆÀ4U]òþ"$€À;ÁÔú‰@Rš4hÑ£F@WÑ£F6À°S‚ú¬Š¯À4RCjýQ€À;Ih)IB@Rå˗.\¹@WÑ£F6À°;…HJGÎÀ4Mc ›‡€À:Óþ•‚@S1bŋ,@WÑ£F6À°#Ž[žâÎÀ4FÎg þ‚€À:^:1Æ @S|ùóçϟ@WÑ£F6À°  -ô2À4>–॑á€À9êÆÒü–k@Sȑ"D‰@WÑ£F6À¯çyG6H—À44͒ž#€À9xÉ,_j×@T(P¡B…@WÑ£F6À¯·Ëy€À4)ƒT‚Êy€À9FÉà€<@T_¿~ýû÷@WÑ£F6À¯ˆ9OuàÀ4ȳg9„€À8™Dµ)@T«V­Zµj@WÑ£F6À¯XÇ9 +šÀ4­ïÂÿ€À8+Æx‹=u@TöíÛ·nÝ@WÑ£F6À¯)x5،À3ÿBõéS;€À7¿Ð0C @UB… +(P@WÑ£F6À®úNž¢óÀ3î—bŒ€À7Udˆ€±4@UŽ8páÃ@WÑ£F6À®ËN9ŽÓ‹À3ܺvµ§ €À6ì…ɲ€“@UÙ³f͛6@WÑ£F6À®œyF”â*À3É»“„c€À6…5«Ëær@V%J•*T©@WÑ£F6À®mÒ|~3À3µ§å8ÎՀÀ6u]?d@VpáÇ@WÑ£F6À®?\V žÀ3 ŽÿM–€À5»E‹¯TS@VŒxñãǏ@WÑ£F6À®-2œ3À3Š~?MG€À5XŠgçMk@W @@WÑ£F6À­ã ;=xÀ3sƒ“œU€À4÷—¯ÃÈ&@WS§N:t@WÑ£F6À­µ4šj/À3[ª¢£” €À4˜³_§p@WŸ>|ùóç@WÑ£F6À­‡—Fq@À3CŽÁx)€À4:([衇@WêÕ«V­Z@WÑ£F6À­Z5y£,À3)”1Ⱥ€À3ÝÅ1÷µö@X6lÙ³fÍ@WÑ£F6À­-àýYaÀ3n;€À3‚íc¹ý@X‚ @@WÑ£F6À­)6¬Ì`À2ôœ +“:2€À3)žÊè‡1@X͛6lÙ³@WÑ£F6À¬Ó‚©Q äÀ2Ù(œ‹z€À2ÑÖòŽD±@Y2dɓ&@WÑ£F6À¬§©¬®À2œKÌ"¢€À2{“›+g@Ydɓ&L™@WÑ£F6À¬zûZû'À2 †MÒËñ€À2&ÐGD (@Y°`Áƒ @WÑ£F6À¬O™¬¯fÀ2ƒm'ì|€À1Ӌ2.ÇÖ@Yû÷ïß¿~@WÑ£F6À¬#„ð‚N‘À2eۘf4ŀÀ1Àcj­v@ZG[€À.M|çø€X@\€H‘"Dˆ@WÑ£F6Àª§êösWÀ1L0ZÔš€À-ÃWB³@\ïß¿~ýû@WÑ£F6Àª4ÿNdÀ1+·Éõ^n€À-;=|Ñâ@];víÛ·n@WÑ£F6ÀªVËæKžáÀ1  +cڏ€À,¶Žóó*@]‡8pá@WÑ£F6Àª.¯ø\»¬À0êc +Š¥€À,3M®6@]Ò¥J•*T@WÑ£F6Àªás/ŠÀ0ɒt²Or€À+³ãBÿ=@^2Óz@_˜0`Áƒ@WÑ£F6À©kvmßKÀ0ÜîTg€À(ä8$ÿWÔ@_ãǏt›À/„#§€À(ÎÏàÖ¡@`=zõëׯ@WÑ£F6Àš®JýôBçÀ/B†š‚M€À'›WU›æå@`cF4h@WÑ£F6Àš‰…ÜŽfqÀ/—‚4–€À'1ûd£aÑ@`‰$H‘"@WÑ£F6Àše ð‚ÿÀ.¿Êàfê€À&ʱ ߊ@`®Ý»víÛ@WÑ£F6Àš@âÿ2À.~ž’ªˆø€À&enÁšÚ€@`Ô©R¥J•@WÑ£F6ÀšÆà©ÝÀ.=àgAl΀À&*“œÝé@`útéÓ§N@WÑ£F6À§ùs@?À-ýGÁ°d€À% Úû“Ä@a @@WÑ£F6À§Ö-]p.À-Œó±øœ €À%Avt¹ú@aF 0`Á@WÑ£F6À§³3‰«åÀ-|èý0²Ó€À$ãó•ë|#@akׯ^œ{@WÑ£F6À§…,:;À-=,æB€À$ˆIËW’@a‘£F4@WÑ£F6À§n!æÓ QÀ,ýÁ;Œ¯7€À$.m¹ÊÓ¹@a·nÝ»ví@WÑ£F6À§L VÀ,Ÿ¬IÑ^րÀ#ÖXw¯º@aÝ:téÓ§@WÑ£F6À§*;]€ÑÀ,ðî”Gn€À#€W߂@b 0`@WÑ£F6À§¶¯È À,A’“+Ó€À#+\…2ªÈ@b(Ñ£F@WÑ£F6ÀŠç{ŸšíŒÀ,”d8žÒ€À"ØdJ t—@bN:téÓ@WÑ£F6ÀŠÆ‰Ë{µTÀ+ÅùTHlÀÀ"‡Úç@bthÑ£F@WÑ£F6ÀŠ¥à_µŒÁÀ+ˆÄaøÕ€À"7TmÊ]ô@bš4hÑ£F@WÑ£F6ÀŠ…zSKÀ+K÷H}uk€À!é,ÿP'@bÀ@WÑ£F6ÀŠee4û¿À+•%æe€À!œ¯ÿ+j@X6lÙ³fÎÀŽ ý_Ž€€ÀIa*O®?òå˗.\¹@X6lÙ³fÎÀŽ ¥ / ¿â±Gñº\ €ÀI_âŽ,ÃÚ@å˗.\¹@X6lÙ³fÎÀŽ œ2#ȹ¿ò®{wD¥€ÀI\ íz£Ä@ X±bŋ@X6lÙ³fÎÀŽ +ã'@„¿ûþŒZ©‰~€ÀIUšXJ…î@å˗.\¹@X6lÙ³fÎÀŽzR sÀ£P™5$*€ÀILºýêƒ@Ÿ>|ùóç@X6lÙ³fÎÀŽbˆÉ#ÉÀAµÆûˀÀIAHN‹œ@X±bŋ@X6lÙ³fÎÀŽœ¬Ö ”À Ù3ý®€ÀI3UõÊ9@ ‰$H‘"@X6lÙ³fÎÀ³ý)ÜqHŸÀ49ÜÀ /€ÀI"êÔ¶–@"å˗.\¹@X6lÙ³fÎÀ³ø g®ŠYÀwÆ»Ÿ}€ÀIøDuŽ@%B… +(P@X6lÙ³fÎÀ³òBÏcœÍÀއ)[ÀÀHú˒Y:Î@'Ÿ>|ùóç@X6lÙ³fÎÀ³ëÑÃîš5ÀëòFÜøŠ€ÀHã*í z?@)û÷ïß¿~@X6lÙ³fÎÀ³äº#ØmÿÀŒ;*0/€ÀHÉ8` mä@,X±bŋ@X6lÙ³fÎÀ³ÜýúUÍ*ÀFPiŽÞë€ÀH­CœaW@.µjÕ«V­@X6lÙ³fÎÀ³ÔŸ}šþÍÀhâ+º€ÀHŽâéŠ>@0‰$H‘"@X6lÙ³fÎÀ³Ë¡ föêÀ¡Ÿ®Êk€ÀHmõmwXÏ@1·nÝ»ví@X6lÙ³fÎÀ³Â0¢hÀ É)ºòþ€ÀHK?çÔsú@2å˗.\¹@X6lÙ³fÎÀ³·Î“ÿŒÝÀ!ÌܒæLñ€ÀH&®®@4(P¡B…@X6lÙ³fÎÀ³­³~òÀ"Ë®éýeC€ÀGÿÇcô¹@5B… +(P@X6lÙ³fÎÀ³¡œ}ršÀ#ÅiÒÔ€ÀG×M0éR@6páÇ@X6lÙ³fÎÀ³•§L¬3À$¹Ú)Op€ÀG¬¡".!@7Ÿ>|ùóç@X6lÙ³fÎÀ³‰"ІcðÀ%šÏè! ž€ÀG€^<uÊ@8͛6lÙ³@X6lÙ³fÎÀ³|%^QÀ&’Ž¿€W€ÀGRhC„@9û÷ïß¿~@X6lÙ³fÎÀ³n{fÓZâÀ'u¡c}Hb€ÀG"Ò<‹’?@;*T©R¥J@X6lÙ³fÎÀ³`_ hqfÀ(S0œm!€ÀFñ¯{g`Ñ@µjÕ«V­@X6lÙ³fÎÀ³37îÿÀ*ÆÿvZ–"€ÀFU¿SÆ@?ãǏ<‘€ÀE:«2…Ÿ@Bå˗.\¹@X6lÙ³fÎÀ²Ìm¶»äÀ/‚ ?€ÀDþtP„Û3@C|ùóçϟ@X6lÙ³fÎÀ²¹ïb$A5À/žþ¬ç*~€ÀDÂȍ=ô@D(P¡B…@X6lÙ³fÎÀ²§ýg\ÌÀ0ô'ô€ÀD„õâ[ïÁ@D«V­Zµj@X6lÙ³fÎÀ²“åhžvÀ0c##ŒZU€ÀDG9¬éá@EB… +(P@X6lÙ³fÎÀ²€a€2_œÀ0šßÍŸÔ€ÀDë‰Qû@EÙ³f͛6@X6lÙ³fÎÀ²lŽK!<À0éŒÚd`€ÀCÊím,¬@FpáÇ@X6lÙ³fÎÀ²Xo +Tœ)À1(3`aVC€ÀCŠä’ðA@G @@X6lÙ³fÎÀ²D™_yÀ1cyæè&ĀÀCKMTGE@GŸ>|ùóç@X6lÙ³fÎÀ²/\ûñ°À1›˜õ9'Š€ÀC i—EzÙ@H6lÙ³fÍ@X6lÙ³fÎÀ²qq‡ —À1КYoá€ÀBËID.I@H͛6lÙ³@X6lÙ³fÎÀ²IŽ6‘À2‡Œ¥Dz€ÀBŠûÃêœ@Idɓ&L™@X6lÙ³fÎÀ±ïçžï>šÀ21mCúç€ÀBJüTþ/@Iû÷ïß¿~@X6lÙ³fÎÀ±ÚPÑ3*À2]VÌ1UM€ÀB +M—Œù@J“&L™2d@X6lÙ³fÎÀ±Ä‡@.Ù{À2†Q1ë²€ÀAɖ$@K*T©R¥J@X6lÙ³fÎÀ±®OEüÀ2¬iûÒԉ€ÀA‰$=.@KÁƒ 0@X6lÙ³fÎÀ±˜l þDCÀ2ϯJM¡Ñ€ÀAHɜDW@LX±bŋ@X6lÙ³fÎÀ±‚ Ä= ÑÀ2ð/Ǭ/L€ÀA“e ¶µ@Lïß¿~ýû@X6lÙ³fÎÀ±k°ŽKŠ5À3 ú˜ã"€À@ȍWŽ&@M‡8pá@X6lÙ³fÎÀ±UN»ãÀ3)NÐsZ€À@ˆÁíÁt—@NnÑÐY*À3A­Øˆ|€À@IBc'@P‰$H‘"@X6lÙ³fÎÀ°ËšÝŠkjÀ3—ê­Žœ€À>" ó2Ÿr@PÔ©R¥J•@X6lÙ³fÎÀ°Žp3k# À3¢PÛÈ8â€À=§ÆcÉT¿@Q @@X6lÙ³fÎÀ°:„(?À3ª–zá]³€À=.…Ì™­E@Qkׯ^œ{@X6lÙ³fÎÀ°…üHëB“À3°Ì°v3€À<¶UT6â@Q·nÝ»ví@X6lÙ³fÎÀ°n·æ„…ªÀ3µšîŸK€À|ùóç@X6lÙ³fÎÀ­H/IG–{À2Èl}]ã€À4 yWÚN@WêÕ«V­Z@X6lÙ³fÎÀ­ìQ@ÿDÀ2±/^€À3¯ÑMÖ+@X6lÙ³fÍ@X6lÙ³fÎÀ¬ïአµÀ2˜Û<«Û€À3V‰`ü’è@X‚ @@X6lÙ³fÎÀ¬ÄŸH³¯À2€Ìq§€À2ÿwƒNˆ¶@X͛6lÙ³@X6lÙ³fÎÀ¬˜{"Ž«èÀ2f‹¬€ Y€À2©ØE>U@Y2dɓ&@X6lÙ³fÎÀ¬m"ŒãßýÀ2LwU…§¡€À2U©j·@Ydɓ&L™@X6lÙ³fÎÀ¬B>‡À21ÒæÑ‹€À2èrHä@Y°`Áƒ @X6lÙ³fÎÀ¬-ôúÀ2š*8OY€À1±’Ÿ$ @Yû÷ïß¿~@X6lÙ³fÎÀ«ì“äJ±À1û“ðô €À1a€÷õ[@ZG ±€À)·ŠÛ‹@_L™2dɒ@X6lÙ³fÎÀ©¹EG`À/±HËÛ8€À)E*>š^K@_˜0`Áƒ@X6lÙ³fÎÀš÷DKV€À/rOy\‘€À(ÔÝÝ •@_ãǏڀÀ%üõWq¶è@`útéÓ§N@X6lÙ³fÎÀ§×fsLGNÀ-}™·÷™Ù€À%œÙ²ÂEì@a @@X6lÙ³fÎÀ§Ž¶"öL}À-?ÄìK$€À%>›Hßç@aF 0`Á@X6lÙ³fÎÀ§’N¶]‚ˆÀ--Yÿ¿€À$â0€~‰@akׯ^œ{@X6lÙ³fÎÀ§p/ârb²À,Äלoߌ€À$‡‘b%@a‘£F4@X6lÙ³fÎÀ§NYVß ðÀ,‡È«Þ@€À$.µ„@a·nÝ»ví@X6lÙ³fÎÀ§,ÊŸXüšÀ,K¬.ɶ€À#ג«úæ&@aÝ:téÓ§@X6lÙ³fÎÀ§ ƒŸïhÀ,‹Yl­Í€À#‚!Ý (Ÿ@b 0`@X6lÙ³fÎÀŠêƒúV„@À+Òe {.5€À#.Z??8@b(Ñ£F@X6lÙ³fÎÀŠÉË/¡¥À+–”ÖŠŒÐ€À"Ü3‹¥ë/@bN:téÓ@X6lÙ³fÎÀŠ©X”NM°À+[ó" €À"‹¥™ª­@bthÑ£F@X6lÙ³fÎÀЉ,"ú‰À+þI‹Z€À"<š]l£}@bš4hÑ£F@X6lÙ³fÎÀŠiEM0+À*å>VڀÀ!ï3éGMÿ@bÀ@X6lÙ³fÎÀŠI£¢Û‡éÀ*ªÞcŽ€À!£@n ù@X›6lÙ³hÀ³¿C4Ãt̀€ÀHž3ŒŠî?òå˗.\¹@X›6lÙ³hÀ³ŸîÌŠñ¿áÝHŸº×΀ÀHœÿ'óŠ@å˗.\¹@X›6lÙ³hÀ³œñ¬ØÜ¿ñÚ°n tn€ÀH™bO.¬@ X±bŋ@X›6lÙ³hÀ³ŒLÖΙ¿úÁøK–þ€ÀH“^êœ@å˗.\¹@X›6lÙ³hÀ³¹þœà»”ÀÐU€e/—€ÀHŠö$^W@Ÿ>|ùóç@X›6lÙ³hÀ³· э«À:œé²k€€ÀH€.c»p@X±bŋ@X›6lÙ³hÀ³³n—E«]À +žŸ†ã†ñ€ÀHs ó߀ž@ ‰$H‘"@X›6lÙ³hÀ³¯-÷õbÀûÍab%€ÀHc•¬@"å˗.\¹@X›6lÙ³hÀ³ªI*C +™À§N'PŸ€ÀHQÑ;§[@%B… +(P@X›6lÙ³hÀ³€Á”‚?äÀÌ Ñûq6€ÀH=ÈŽ“N@'Ÿ>|ùóç@X›6lÙ³hÀ³ž˜Ç­FŽÀë,§°“ÿ€ÀH'„òS|@)û÷ïß¿~@X›6lÙ³hÀ³—Ѐ hÖÀ'Hߓ€ÀHTK'@,X±bŋ@X›6lÙ³hÀ³j£Ü`¶Ào·ƒJ€ÀGôv&‘1\@.µjÕ«V­@X›6lÙ³hÀ³ˆiAØUÀ!~ïŠ›€ÀGו.@0‰$H‘"@X›6lÙ³hÀ³ΌŒÇ À$ÕŠ÷Æc€ÀG¹žàŒè@1·nÝ»ví@X›6lÙ³hÀ³vœàýÀ ý id€ÀG˜DHÒ@2å˗.\¹@X›6lÙ³hÀ³lÖž±HëÀ! ;ÄAñрÀGu•LÜ@4(P¡B…@X›6lÙ³hÀ³b~ŽÈފÀ!ýïÿhò€ÀGQŽ×÷ @5B… +(P@X›6lÙ³hÀ³W—’Ug×À"íåï€Ú݀ÀG*€˜@o@6páÇ@X›6lÙ³hÀ³L$,ìÃÊÀ#ØíCÖ;€ÀG‚Ûdö@7Ÿ>|ùóç@X›6lÙ³hÀ³@'{ÈM˜À$ŸØ[Ý+á€ÀFذWփ@8͛6lÙ³@X›6lÙ³hÀ³3€ÃýdÀ%Ÿ~Œô€ÀF­?ò‚Q@9û÷ïß¿~@X›6lÙ³hÀ³&ž‘V{!À&z·á~U€ÀF€@JQ#o@;*T©R¥J@X›6lÙ³hÀ³Ÿ…ÆÝÀ'PcŒQK€ÀFQÅÓršJ@µjÕ«V­@X›6lÙ³hÀ²í©ÐfQ6À)®åfâ +º€ÀEŸ%î7íú@?ãǏí‹@A @@X›6lÙ³hÀ²Ÿ7üoÀ+×Êy[à3€ÀE¿j€3Ö@A·nÝ»ví@X›6lÙ³hÀ²­“ÒX,À,ƒáƒÞ+€ÀDèãˆe,@BN:téÓ@X›6lÙ³hÀ²œŠÛÁ–À-)ͧoò€ÀD±™6Zë@Bå˗.\¹@X›6lÙ³hÀ²‹"réæ¿À-ɉ²ŸEc€ÀDx‚ªž˜@C|ùóçϟ@X›6lÙ³hÀ²y]‰›ŸìÀ.cYsµ€ÀD?!”ZÝ@D(P¡B…@X›6lÙ³hÀ²g?ȬâzÀ.ökLv’€ÀD î¢9ù@D«V­Zµj@X›6lÙ³hÀ²TÌ×Þ°¡À/ƒ”¶â€ÀCÊV HQÞ@EB… +(P@X›6lÙ³hÀ²B\RÒEÀ0JØ­4€ÀC ã›}@EÙ³f͛6@X›6lÙ³hÀ².õ÷¿ŠÀ0E¹‹2ßù€ÀCSE$ú¶„@FpáÇ@X›6lÙ³hÀ²™CªéÀ0ƒÖZŸÞ€ÀC * Ç@G @@X›6lÙ³hÀ²õÖӃÜÀ0œ}5aYø€ÀBÚr›S&@GŸ>|ùóç@X›6lÙ³hÀ±ô=:@À0ôÞý¡ƒÞ€ÀBˆ.»êk@H6lÙ³fÍ@X›6lÙ³hÀ±ßèúL~šÀ1)Kza–€ÀB`[Ü%–e@H͛6lÙ³@X›6lÙ³hÀ±Ë†‡$JŒÀ1ZË܇„ñ€ÀB"ü<Ï­@Idɓ&L™@X›6lÙ³hÀ±¶ëQ€ƒAÀ1‰j*p>“€ÀAåwt—"@Iû÷ïß¿~@X›6lÙ³hÀ±¢ºÒҋÀ1µ1/üÂ<€ÀA§Û,³Ú3@J“&L™2d@X›6lÙ³hÀ±_Œ3À1Þ,nä*j€ÀAj4–n_‚@K*T©R¥J@X›6lÙ³hÀ±wæ­p À2hX1§€ÀA,dxË^@KÁƒ 0@X›6lÙ³hÀ±b‰Ž–ÐòÀ2'ðÑÇì€À@îúÌ/Ў@LX±bŋ@X›6lÙ³hÀ±MU‡œÀ2HÓü‰’g€À@±„Zh@Lïß¿~ýû@X›6lÙ³hÀ±7YŠónEÀ2gU6ŒÑ€À@t)ÄÕç @M‡8pá@X›6lÙ³hÀ±!Œ²žÀ2‚á {ƒ¡€À@7Fšì@NŠ,4MHf@P=zõëׯ@X›6lÙ³hÀ°²ïß]À2é¡4ÁK€À>ã&íðä@P‰$H‘"@X›6lÙ³hÀ°œJ‡À2÷d³0€À=œfÖ°ïO@PÔ©R¥J•@X›6lÙ³hÀ°†"}£À3 »ý3ò€À=&ƚä@Q @@X›6lÙ³hÀ°oš(«ýÀ3  8Œ€À<²{ +]c@Qkׯ^œ{@X›6lÙ³hÀ°Y#¯ãu€À38Ù»F€À<>M:•n«@Q·nÝ»ví@X›6lÙ³hÀ°B—dހ‚À3áUE>—€À;ˎW3)B@R 0`@X›6lÙ³hÀ°,†ra÷À3«Ò‰M€À;Yݧ~T@RN:téÓ@X›6lÙ³hÀ°p@°I:À3§nƒŽ^€À:éCÙ(œs@Rš4hÑ£F@X›6lÙ³hÀ¯ý³Yö+À3ãîdV€À:yÌQ5cè@Rå˗.\¹@X›6lÙ³hÀ¯Ð‡€J(ÓÀ3púåµÓ€À: W• ”@S1bŋ,@X›6lÙ³hÀ¯£aI,ì‹À3^ÌV€€À9že +xš@S|ùóçϟ@X›6lÙ³hÀ¯vD ò$ŽÀ3ºšRÄs€À92„η\@Sȑ"D‰@X›6lÙ³hÀ¯I3×¡ÆÀ3•ÕñOê€À8ÇåV Â@T(P¡B…@X›6lÙ³hÀ¯3NwÃÀ3þŸ’Z,€À8^Œ¥Ð»ê@T_¿~ýû÷@X›6lÙ³hÀ®ïF¢BbúÀ3Ë/¶,€À7ö€®qø@T«V­Zµj@X›6lÙ³hÀ®Âp +ëÀ2õ³ãՋ¬€À7Äyœ¶–@TöíÛ·nÝ@X›6lÙ³hÀ®•ŽÂYþÀ2ê6§€À7*]â”cÛ@UB… +(P@X›6lÙ³hÀ®i—¢7À2ÝḾ®á€À6ÆO鹿8@UŽ8páÃ@X›6lÙ³hÀ®<–µÀ2ÏSmZ€À6c”ý¹–@UÙ³f͛6@X›6lÙ³hÀ®8嚌À2À;—psۀÀ6IcÁsŠ@V%J•*T©@X›6lÙ³hÀ­ä€Žó8À2°€7І€À5¢UU-^Œ@VpáÇ@X›6lÙ³hÀ­·ï’÷èÀ2žèË®Ÿ€À5CÂîQ¢É@VŒxñãǏ@X›6lÙ³hÀ­Œ•\tÀ2ŒÅéQÆ4€À4æ“@-Œ!@W @@X›6lÙ³hÀ­`MU$ÖÀ2y¹U¢ø€À4ŠÆíœŠŽ@WS§N:t@X›6lÙ³hÀ­4Àm=»À2eÎV8€À40^1"XÊ@WŸ>|ùóç@X›6lÙ³hÀ­ bíÂQôÀ2QšVÜM€À3×X⚠ž@WêÕ«V­Z@X›6lÙ³hÀ¬Þ7ªs øÀ2;‹€zóڀÀ3¶|Öº@X6lÙ³fÍ@X›6lÙ³hÀ¬³@TGÀ2%Jø1]€À3)v"¥Å @X‚ @@X›6lÙ³hÀ¬ˆ}ö~=œÀ2WÑ¢W€À2Ԗ€Ö=¬@X͛6lÙ³@X›6lÙ³hÀ¬]òÍ: À1öŒíÂna€À2†’!‰@Y2dɓ&@X›6lÙ³hÀ¬3 é*ÜÀ1ޅŽtò6€À2.ô²wè@Ydɓ&L™@X›6lÙ³hÀ¬ ‡Vq°{À1Å»3äÃä€À1Þ-w€P@Y°`Áƒ @X›6lÙ³hÀ«ß©ÁOê¯À1¬fäè`?€À1Ž¿h*à@@Yû÷ïß¿~@X›6lÙ³hÀ«¶›)HÀ1’‘ðßZ³€À1@š‡˜:H@ZGÿ€À0^Pjy@[*T©R¥J@X›6lÙ³hÀ«öœ‡<À1&ä=á>€À0w“âcP@[uëׯ^œ@X›6lÙ³hÀªé’ž\£áÀ1 +ÞTœf€À/›Ì<3ª@[Áƒ 0@X›6lÙ³hÀªÁq† +‰8À0îáƒ#„#€À/0ø Ä»@\ 4hÑ£@X›6lÙ³hÀª™“1&œrÀ0ÒoEN„÷€À.…à k@\X±bŋ@X›6lÙ³hÀªqø]¿À0µºø_(€À-ýsÍh T@\€H‘"Dˆ@X›6lÙ³hÀªJ¡ Õ}À0˜Ë+ †ç€À-xBw¿‚@\ïß¿~ýû@X›6lÙ³hÀª#}3Q™À0{Š)&m€À,õz[CB@];víÛ·n@X›6lÙ³hÀ©üÂk ÖùÀ0^QüZÝ}€À,u2¶W@]‡8pá@X›6lÙ³hÀ©Ö:Óãé3À0@Ôn˜úЀÀ+÷|ŽRå@]Ò¥J•*T@X›6lÙ³hÀ©¯ù"°òÀ0#3 F©ï€À+{F<Çݖ@^ٙV.À/“W–émˀÀ*˜ LÇ@_ @X›6lÙ³hÀ©±Áèï±À/W[ž7ù€À)¢Æ}çŽ@_L™2dɒ@X›6lÙ³hÀšôÐÑÓP†À/HÁ0ž€À)2É+(Ç@_˜0`Áƒ@X›6lÙ³hÀšÐ6ßW-À.ß'!HP€À(ÉÍrEP@_ãǏ£€À(W Ӆè˜@`¯^œzö@X›6lÙ³hÀš‡Ø ØÀ.f×¢¿ÜP€À'ì$–ùd@`=zõëׯ@X›6lÙ³hÀšdHý€À.*ž°lú+€À'„/ Ôö@`cF4h@X›6lÙ³hÀš@•oåƒ`À-îšò°E€À'ºÞòi@`‰$H‘"@X›6lÙ³hÀš^zæY…À-²®ú4ðj€À&¹7ô«üo@`®Ý»víÛ@X›6lÙ³hÀ§únL“t—À-vÑ€0€À&V²ßÆ@`Ô©R¥J•@X›6lÙ³hÀ§×ÄÀV^À-;ïôr€À%õ㊫jQ@`útéÓ§N@X›6lÙ³hÀ§µaªÏ(À,ÿ€\Èħ€À%—û€h!@a @@X›6lÙ³hÀ§“DÚ1eÞÀ,Ä‘î:¿€À%9í”áx+@aF 0`Á@X›6lÙ³hÀ§qnÁÀ,ˆâŽè ¹€À$Þ ÷úÎp@akׯ^œ{@X›6lÙ³hÀ§OÝ"x<ƒÀ,M㠓8 €À$…Ù$è@a‘£F4@X›6lÙ³hÀ§.‘º»89À,zÜ ú€À$-;óeš@a·nÝ»ví@X›6lÙ³hÀ§ ‹—GKvÀ+ؙ gò1€À#×MòŒ×@aÝ:téÓ§@X›6lÙ³hÀŠìÊk0(À+žVŽ$:…€À#‚µ4ÌF@b 0`@X›6lÙ³hÀŠÌMå¶&À+d["Ϻ‚€À#/šCªZ@b(Ñ£F@X›6lÙ³hÀЬ¯/dÀ+*©Òj6€À"ÞYoG€@bN:téÓ@X›6lÙ³hÀЌ!oÎΑÀ*ñF•ÌH€À"Ž˜„›Åd@bthÑ£F@X›6lÙ³hÀŠlpÉÁš À*ž2ÀæTҀÀ"@^Îè;÷@bš4hÑ£F@X›6lÙ³hÀŠM\ ýÀ*ráJ®€À!ó€pËXö@bÀ@X›6lÙ³hÀŠ-ØÂØrÊÀ*G `\΀À!šaøã7L@YÀ³ráu5”•€€ÀGãЩ;?òå˗.\¹@YÀ³rŒ¢Z¿á™­ž- €ÀGâ­ßˆI›@å˗.\¹@YÀ³qž©«šT¿ñ1ÐF݀ÀGßF œëÖ@ X±bŋ@YÀ³p €/›¿ù–ÅÀó€ÀGٚÇг@å˗.\¹@YÀ³mײÂ[zÀ ” +ÁÞ>€ÀGÑ®ºò¶@Ÿ>|ùóç@YÀ³kÞ®ÀBýYX >€ÀGDž™)þµ@X±bŋ@YÀ³gÏygÀ vs⌌T€ÀG»$ žîd@ ‰$H‘"@YÀ³cz®ZÀ ¢Ñ`ËÁ€ÀG¬Ú÷ú@"å˗.\¹@YÀ³^Ñ-Àãz‡Ý™€ÀG›Ð" ©@%B… +(P@YÀ³Y†³ôøüÀðâ@®_׀ÀGˆìœÎ @'Ÿ>|ùóç@YÀ³S¢‚ÄŽÀù4Œë˜€ÀGsì^7Ÿl@)û÷ïß¿~@YÀ³M$‹Â"ˆÀû¯(À̀ÀG\ÚŠ*œµ@,X±bŋ@YÀ³F¢d+À÷ÐFŸ €ÀGCÁ9·aŸ@.µjÕ«V­@YÀ³>fƒœQÏÀíX2¯ˆ€ÀG(«A‘T¬@0‰$H‘"@YÀ³6)éI1ÀÛ°”ÕR€ÀG €©v*P@1·nÝ»ví@YÀ³-\tãšÀÂfÆ8•|€ÀFìº øÌ@2å˗.\¹@YÀ³$w³”QÀ P†Çæv¥€ÀFËøÎ-۟@4(P¡B…@YÀ³bn› À!;ž²Ž€ÀF©nÁ絋@5B… +(P@YÀ³ŠÃYÀ""JIiwù€ÀF…*hñè†@6páÇ@YÀ³®EH _À#[³÷ñŠ€ÀF_:¿ ìµ@7Ÿ>|ùóç@YÀ²ù1¬WÀ#áš*m€]€ÀF7¯4ƒç@8͛6lÙ³@YÀ²í3Փ%6À$ºFú €ÀF—ž-âj@9û÷ïß¿~@YÀ²à·ŽbžÀ%V¶ž²€ÀEä+»ˆþ@;*T©R¥J@YÀ²ÓÀPÍPÀ&[ríw”X€ÀEžU”ºÌ@wòŽ€ÀEŠ«ÑUx„@=‡8pá@YÀ²žl>‚ bÀ'çžQ +Þ¬€ÀE\ƒîŒ²@>µjÕ«V­@YÀ²ªö/±À(¥z—‘鈀ÀE,,t…a,@?ãǏÀ*Xdøx €ÀDÉ‹ X@A @@YÀ²|‰›ÞHKÀ*œ9€ÞÞG€ÀD•îùDZC@A·nÝ»ví@YÀ²l†KpÑÀ+dV9¿Û€ÀDaÛ ƒø@BN:téÓ@YÀ²\0v×y¡À,¥;›ç#€ÀD,ãûOqÁ@Bå˗.\¹@YÀ²Kuܬ"~À,¡ Ö6 .€ÀC÷cñ@C|ùóçϟ@YÀ²:a)Ã*À-6Åy:πÀCÀ£L@D(P¡B…@YÀ²(õÑU^~À-ƒQQã +€ÀC‰RTùê¬@D«V­Zµj@YÀ²7FZš‹À.P‰ %Ž4€ÀCQtŽi‰@EB… +(P@YÀ²(úŸÀ.Ô­»?f €ÀC£uÁ@EÙ³f͛6@YÀ±òÎZž#HÀ/S¹kí%€ÀBà:€ùZ@FpáÇ@YÀ±à*ÒàŠÀ/˜Žò°€ÀBвŠÚ€a@G @@YÀ±ÍAÄ$\GÀ0<èq€ÀBlìâù @GŸ>|ùóç@YÀ±ºŽ\^ÄÀ0UՄŠ,ý€ÀB2ҋPÝ^@H6lÙ³fÍ@YÀ±Š¬†n”À0‰ŸKܝ=€ÀAøqÙÅ¡@H͛6lÙ³@YÀ±“÷~ŒÀ0º¢HºT €ÀAœØ¡¯;@Idɓ&L™@YÀ±)% CøÀ0èçSbZ-€ÀAƒLmÞ5@Iû÷ïß¿~@YÀ±kFfï×À1x €”€ÀAH1և9@J“&L™2d@YÀ±VчQÀ1=^œ€ÀA =ÍEÀ@K*T©R¥J@YÀ±B^TÀ1cŠ Ñ+·€À@ÒDLÒe|@KÁƒ 0@YÀ±-ŸÖ/À1‡YÑ> H€À@—PþÉÍ-@LX±bŋ@YÀ±öú¹{£À1š…í‡Õ€À@\o5ù@Lïß¿~ýû@YÀ± i›ÏÀ1Ç6àIÖ9€À@!©]ò‰Ú@M‡8pá@YÀ°îù I¡gÀ1ãy—’iF€À?Î4ölH@NäË×Ðò@OL™2dɒ@YÀ°¯$ã‹XÀ2*2ûÿÃo€À>pæ°ôT¿@OãǏ|ùóç@YÀ¬Ë3ìùAdÀ1ÜÜǘjf€À3¥$š‰˜…@WêÕ«V­Z@YÀ¬¡ ñ[À1É#RŠv†€À3OЃÍÌ@X6lÙ³fÍ@YÀ¬w-žÀ¶ÐÀ1ެvÙú€À2ûÊMËrx@X‚ @@YÀ¬Msa;UÀ1Ÿ‚„¢·[€À2©’ãâh@X͛6lÙ³@YÀ¬#ëëWÀ1‰¯…œñ€À2W§Ò@Y2dɓ&@YÀ«ú˜Á®ãŽÀ1s=5Îw€À2ˆ²ÒÕò@Ydɓ&L™@YÀ«Ñ{PŽùJÀ1\5j õ€À1ž³™Ã@Y°`Áƒ @YÀ«š”ïdR+À1D "`Ò0€À1k&¬œ@Yû÷ïß¿~@YÀ«æà àÀ1,‡dàHï€À1ß֟€)@ZG@aÝ:téÓ§@YÀŠÎ»”À+/Z†OÀ€À#Tk“@b 0`@YÀŠ® VDfÀ*÷z»€À#/V:MJ‹@b(Ñ£F@YÀŠŽ^3ÑÈÕÀ*¿Û ~m€À"Þäóè@bN:téÓ@YÀŠnæçH€èÀ*ˆ~Þ2Ñb€À"öiB@bthÑ£F@YÀŠO°Ô™TÀ*QiXŸ1o€À"B…ÒÛÏÜ@bš4hÑ£F@YÀŠ0»¥ðÀ*u£³ €À!ö‹.\gÝ@bÀ@YÀŠ“ÐzÀ)äür‘ЀÀ!«ÿe_Àf \ No newline at end of file diff --git a/gala/source/tests/potential/potential/ccomposite.yml b/gala/source/tests/potential/potential/ccomposite.yml new file mode 100644 index 0000000000000000000000000000000000000000..f3181e343d7671f410ac1ba97e42db71859bc02a --- /dev/null +++ b/gala/source/tests/potential/potential/ccomposite.yml @@ -0,0 +1,31 @@ +type: composite +class: CCompositePotential +components: + - class: KeplerPotential + name: halo + parameters: !!python/object/apply:collections.OrderedDict + dictitems: + m: 100000000000.0 + m_unit: "" + units: + angle: rad + angular speed: mas / yr + length: kpc + mass: solMass + speed: km / s + time: Myr + - class: IsochronePotential + name: bulge + parameters: !!python/object/apply:collections.OrderedDict + dictitems: + b: 0.76 + b_unit: "" + m: 100000000000.0 + m_unit: "" + units: + angle: rad + angular speed: mas / yr + length: kpc + mass: solMass + speed: km / s + time: Myr diff --git a/gala/source/tests/potential/potential/exp_basis.yml b/gala/source/tests/potential/potential/exp_basis.yml new file mode 100644 index 0000000000000000000000000000000000000000..950e25840c4ea5717931e33db7c463d086779914 --- /dev/null +++ b/gala/source/tests/potential/potential/exp_basis.yml @@ -0,0 +1,10 @@ +--- +id: sphereSL +parameters: + numr: 1024 + rmin: 0.0004 + rmax: 40.0 + Lmax: 2 + nmax: 20 + modelname: exp_hernquist.model + cachename: exp_hernquist.cache diff --git a/gala/source/tests/potential/potential/generate_agama.py b/gala/source/tests/potential/potential/generate_agama.py new file mode 100644 index 0000000000000000000000000000000000000000..1031936a89ba32e431cdfacad6fcc24d8036f06c --- /dev/null +++ b/gala/source/tests/potential/potential/generate_agama.py @@ -0,0 +1,39 @@ +import pathlib + +import astropy.table as at +import astropy.units as u +import numpy as np + +this_path = pathlib.Path(__file__).absolute().parent + + +def main(): + # For pytest: + import agama + + agama.setUnits(mass=1, length=1, time=1) + + # Shared by Ana Bonaca + agama_pot = agama.Potential(file=str(this_path / "pot_disk_506151.pot")) + + # Generate a grid of points to evaluate at: + test_R = np.linspace(0, 150, 128) + test_z = np.linspace(-100, 100, 128) + test_Rz = np.stack(list(map(np.ravel, np.meshgrid(test_R, test_z)))) + test_xyz = np.zeros((3, test_Rz.shape[1])) + test_xyz[0] = test_Rz[0] + test_xyz[2] = test_Rz[1] + + pot = agama_pot.potential(test_xyz.T)[:, None] + acc = agama_pot.force(test_xyz.T) + + tbl = at.QTable() + tbl["xyz"] = test_xyz.T * u.kpc + tbl["pot"] = pot * (u.km / u.s) ** 2 + tbl["acc"] = acc * (u.km / u.s) ** 2 / u.kpc + + tbl.write(this_path / "agama_cylspline_test.fits") + + +if __name__ == "__main__": + main() diff --git a/gala/source/tests/potential/potential/generate_exp.py b/gala/source/tests/potential/potential/generate_exp.py new file mode 100644 index 0000000000000000000000000000000000000000..fff3c97f1646b6f3dc644a1c51eaef8748193dcb --- /dev/null +++ b/gala/source/tests/potential/potential/generate_exp.py @@ -0,0 +1,214 @@ +""" +Generate test data for EXP interface tests +""" + +__all__ = ["EXPTestDataGenerator"] + +import os +import pathlib + +import astropy.table as at +import astropy.units as u +import numpy as np +import pyEXP + +import gala.potential as gp +from gala.units import SimulationUnitSystem, galactic + +this_path = pathlib.Path(__file__).parent + + +class EXPTestDataGenerator: + def __init__( + self, + potential: gp.PotentialBase, + name: str | None = None, + lmax: int = 4, + nmax: int = 10, + overwrite: bool = False, + ): + if name is None: + name = potential.__class__.__name__[:-9] + self.name = str(name) + self.gala_pot = potential + + length_unit = mass_unit = None + for name, val in self.gala_pot.parameters.items(): + if val.unit.is_equivalent(u.kpc): + length_unit = val + elif val.unit.is_equivalent(u.Msun): + mass_unit = val + + if length_unit is None or mass_unit is None: + msg = "Potential must have length and mass unit parameters." + raise ValueError(msg) + self.usys = SimulationUnitSystem(mass=mass_unit, length=length_unit, G=1) + self.overwrite = overwrite + + self.lmax = lmax + self.nmax = nmax + l_size = (self.lmax + 1) * (self.lmax + 2) // 2 + self.coef_shape = (l_size, self.nmax) + + def make_empirical_basis(self, r_grid: u.Quantity | None = None): + if r_grid is None: + r_grid = np.geomspace(1e-3, 2e2, 1024) * u.kpc + + xyz_grid = np.zeros((3, r_grid.size)) * u.kpc + xyz_grid[0] = r_grid + + self._basis_table_file = this_path / f"{self.name}.model" + + if self._basis_table_file.exists() and not self.overwrite: + print( + f"File {self._basis_table_file} already exists. Use overwrite=True to " + "regenerate." + ) + return None + + tbl = at.Table() + tbl["r"] = r_grid.decompose(self.usys).value + tbl["density"] = self.gala_pot.density(xyz_grid).decompose(self.usys).value + tbl["mass"] = self.gala_pot.mass_enclosed(xyz_grid).decompose(self.usys).value + tbl["energy"] = self.gala_pot.energy(xyz_grid).decompose(self.usys).value + tbl.meta["comments"] = ["! r density mass energy", f"{len(tbl)}"] + tbl.write( + self._basis_table_file, + format="ascii.no_header", + delimiter=" ", + overwrite=True, + comment="", + ) + return tbl + + def make_config(self, basis_tbl: at.Table): + self._cache_file = this_path / f"{self.name}.cache" + if self._cache_file.exists() and self.overwrite: + self._cache_file.unlink() + + bconfig = f""" +--- +id: sphereSL +parameters : + numr: {len(basis_tbl)} + rmin: {basis_tbl["r"].min():.4f} + rmax: {basis_tbl["r"].max():.1f} + Lmax: {self.lmax} + nmax: {self.nmax} + modelname: {self.name}.model + cachename: {self.name}.cache +... + """ + print(bconfig) + self._basis_file = this_path / f"{self.name}-basis.yml" + with open(self._basis_file, "w", encoding="utf-8") as f: + f.write(bconfig) + + cwd = os.getcwd() + os.chdir(this_path) + basis = pyEXP.basis.Basis.factory(bconfig) + os.chdir(cwd) + return basis + + def make_coef( + self, + basis: pyEXP.basis.Basis, + coef_arr: np.ndarray, + time: u.Quantity | float, + coefs: pyEXP.coefs.Coefs | None = None, + ): + if hasattr(time, "unit"): + time = time.to_value(self.usys["time"]) + + # Create coefficients with a dummy particle at time=0 + coef = basis.createFromArray([1.0], [[1.0], [1.0], [1.0]], time=time) + + # Set values for the coefficients based on input array + coef.assign(coef_arr, self.lmax, self.nmax) + + if coefs is None: + coefs = pyEXP.coefs.Coefs.makecoefs(coef, self.name) + coefs.add(coef) + + return coefs + + def save_coefs( + self, + coefs: pyEXP.coefs.Coefs, + filename: str | pathlib.Path | None = None, + ): + if filename is None: + filename = this_path / f"{self.name}-coefs.h5" + filename = pathlib.Path(filename) + + if filename.exists() and not self.overwrite: + print(f"File {filename} already exists. Use overwrite=True to regenerate.") + return str(filename) + if filename.exists(): + filename.unlink() + + coefs.WriteH5Coefs(str(filename)) + return filename + + +def main(): + """ + This generates an empirical basis from a spherical Hernquist potential, and makes + two coefficient files: one with a single snapshot at time=0 and one with multiple + snapshots at different times. + """ + # Random parameter values: + pot = gp.HernquistPotential(m=1.25234e11, c=3.845, units=galactic) + + gen = EXPTestDataGenerator(pot, overwrite=True, name="EXP-Hernquist") + + # make the empirical basis: + r_grid = np.geomspace(1e-3, 2e2, 1024) * u.kpc + tbl = gen.make_empirical_basis(r_grid) + basis = gen.make_config(tbl) + + coef_arr = np.zeros(gen.coef_shape, dtype=np.complex128) + coef_arr[0, 0] = 2.05 # close to matching the input Hernquist potential + + coefs = gen.make_coef(basis, coef_arr, time=0.0 * u.Myr) + gen.save_coefs(coefs, this_path / f"{gen.name}-single-coefs.hdf5") + + # A few snapshots with slightly different coefficients: + coefs = gen.make_coef(basis, coef_arr, time=0.0 * u.Myr) + + coef_arr = np.zeros(gen.coef_shape, dtype=np.complex128) + coef_arr[0, 0] = 2.1 + coefs = gen.make_coef(basis, coef_arr, time=500.0 * u.Myr, coefs=coefs) + + coef_arr = np.zeros(gen.coef_shape, dtype=np.complex128) + coef_arr[0, 0] = 2.15 + coefs = gen.make_coef(basis, coef_arr, time=1000.0 * u.Myr, coefs=coefs) + + coef_arr = np.zeros(gen.coef_shape, dtype=np.complex128) + coef_arr[0, 0] = 2.2 + coefs = gen.make_coef(basis, coef_arr, time=1500.0 * u.Myr, coefs=coefs) + + coef_arr = np.zeros(gen.coef_shape, dtype=np.complex128) + coef_arr[0, 0] = 2.25 + coefs = gen.make_coef(basis, coef_arr, time=2000.0 * u.Myr, coefs=coefs) + gen.save_coefs(coefs, this_path / f"{gen.name}-multi-coefs.hdf5") + + # A few snapshots with slightly different coefficients and a snapshot time unit + # different from the internal unit system time + coef_arr = np.zeros(gen.coef_shape, dtype=np.complex128) + coef_arr[0, 0] = 1.0 # close to matching the input Hernquist potential + coefs = gen.make_coef(basis, coef_arr, time=0.0) + + coef_arr = np.zeros(gen.coef_shape, dtype=np.complex128) + coef_arr[0, 0] = 2.0 + coefs = gen.make_coef(basis, coef_arr, time=0.5, coefs=coefs) + + coef_arr = np.zeros(gen.coef_shape, dtype=np.complex128) + coef_arr[0, 0] = 3.0 + coefs = gen.make_coef(basis, coef_arr, time=1.0, coefs=coefs) + + gen.save_coefs(coefs, this_path / f"{gen.name}-multi-coefs-snap-time-Gyr.hdf5") + + +if __name__ == "__main__": + main() diff --git a/gala/source/tests/potential/potential/lm10.yml b/gala/source/tests/potential/potential/lm10.yml new file mode 100644 index 0000000000000000000000000000000000000000..23252aa9a77c31e3623414a58c7297f1b960494e --- /dev/null +++ b/gala/source/tests/potential/potential/lm10.yml @@ -0,0 +1,58 @@ +class: LM10Potential +type: custom +components: + - class: MiyamotoNagaiPotential + name: disk + parameters: !!python/object/apply:collections.OrderedDict + dictitems: + a: 10. + a_unit: kpc + b: 0.26 + b_unit: kpc + m: 150000. + m_unit: solMass + units: + angle: rad + angular speed: mas / yr + length: kpc + mass: solMass + speed: km / s + time: Myr + - class: HernquistPotential + name: bulge + parameters: !!python/object/apply:collections.OrderedDict + dictitems: + c: 0.7 + c_unit: kpc + m: 34000000000.0 + m_unit: solMass + units: + angle: rad + angular speed: mas / yr + length: kpc + mass: solMass + speed: km / s + time: Myr + - class: LogarithmicPotential + name: halo + parameters: !!python/object/apply:collections.OrderedDict + dictitems: + phi: 1.6929693744344996 + phi_unit: rad + q1: 1.38 + q1_unit: "" + q2: 1.0 + q2_unit: "" + q3: 1.36 + q3_unit: "" + r_h: 12.0 + r_h_unit: kpc + v_c: 0.17624729719037474 + v_c_unit: kpc / Myr + units: + angle: rad + angular speed: mas / yr + length: kpc + mass: solMass + speed: km / s + time: Myr diff --git a/gala/source/tests/potential/potential/pot_disk_506151.pot b/gala/source/tests/potential/potential/pot_disk_506151.pot new file mode 100644 index 0000000000000000000000000000000000000000..b45e3423b9c512c1be72760e75db88eb38bad150 --- /dev/null +++ b/gala/source/tests/potential/potential/pot_disk_506151.pot @@ -0,0 +1,30 @@ +[Potential] +type=CylSpline +gridSizeR=20 +gridSizez=20 +mmax=0 +symmetry=Axisymmetric +Coefficients +#Phi +0 #m +#R(row)\z(col) 0.0000000000000 0.0500000000000 0.1182084351260 0.2112562475729 0.3381891611508 0.5113470691736 0.7475636678922 1.0698029588800 1.5093917143679 2.1090649365845 2.9271203780720 4.0430860082759 5.5654513940886 7.6422146072148 10.475269985108 14.340035464136 19.612227573209 26.804387042081 36.615705893054 50.000000000000 +0.0000000000000 -193083.9835733 -192881.3561670 -191891.9715573 -189165.7369903 -182924.5538425 -170282.3740697 -149752.5827582 -126522.8039230 -105364.3779252 -88024.87295139 -73653.63552806 -61403.37335216 -50633.66793865 -41158.54208657 -33029.89798508 -26233.91737483 -20578.11036170 -16050.17864046 -12442.49854384 -9497.018205116 +0.2000000000000 -182985.1026772 -182821.7336359 -182024.9095618 -179814.0120575 -174718.3184644 -163981.5922608 -146470.1113922 -125067.5015756 -104779.9772947 -87795.69591026 -73562.61719733 -61363.53515720 -50611.12130506 -41148.22944545 -33026.32985394 -26232.41344440 -20577.37197326 -16049.86342535 -12442.35379495 -9496.958476333 +0.4605725372340 -156490.5981046 -156373.9690757 -155827.9389683 -154386.0526858 -151205.0589636 -144990.9075482 -134385.8576847 -119063.8707808 -102311.7354562 -86814.12011168 -73157.63658825 -61181.79916917 -50521.67383332 -41108.40630268 -33009.25946528 -26224.94351114 -20574.19421564 -16048.50962097 -12441.70610307 -9496.701469852 +0.8000627730368 -129131.2481255 -129073.0771498 -128804.9566747 -128093.3510701 -126522.4924737 -123398.1774971 -117747.9062156 -108845.1986019 -97244.26209366 -84579.58420341 -72190.15559026 -60740.05523100 -50312.26908819 -41012.82168665 -32968.78285122 -26206.33390076 -20566.14017878 -16045.15603360 -12440.08443799 -9496.055020928 +1.2423719335833 -107417.9826790 -107391.1600953 -107265.5607590 -106927.8232932 -106179.3787593 -104658.6803654 -101811.3464336 -96994.69300055 -89831.65942338 -80633.21376082 -70333.13802607 -59855.37283735 -49883.16247849 -40814.45540267 -32880.72943220 -26167.06229135 -20549.36262023 -16038.10955945 -12436.44540814 -9494.627052585 +1.8186400346104 -90443.69182166 -90430.63232787 -90371.21544605 -90212.48103679 -89854.15462719 -89120.07642065 -87712.90765744 -85194.46641429 -81058.68233550 -74995.37863466 -67199.56890338 -58266.42198071 -49089.83454262 -40439.32884162 -32710.83360891 -26090.55366392 -20516.57169411 -16024.43159788 -12429.81334389 -9491.889912637 +2.5694382406688 -76741.28171409 -76734.71558581 -76706.22954863 -76631.10517475 -76461.62787453 -76105.19946269 -75404.31315821 -74093.65789533 -71792.41721720 -68068.81350061 -62664.12048128 -55672.80067414 -47737.66357225 -39776.08503968 -32402.58721895 -25950.80898647 -20455.74337016 -15998.95373735 -12417.53514035 -9486.875217393 +3.5476252081855 -65227.91502614 -65224.86086274 -65210.79732821 -65172.81913290 -65086.25890730 -64905.67396420 -64545.98856570 -63859.96311301 -62605.23587835 -60427.96780983 -56950.52019904 -51943.90112876 -45605.14731861 -38670.45515991 -31866.54079731 -25703.54810934 -20347.11908580 -15951.67119138 -12395.65388718 -9477.740813335 +4.8220685082607 -55263.47914702 -55261.83271677 -55254.31192636 -55234.16975001 -55189.15166583 -55095.57445953 -54905.77330226 -54537.57855134 -53845.58480797 -52597.70347448 -50473.41435205 -47163.64532328 -42542.86126035 -36950.28977443 -30986.29888122 -25283.17631969 -20158.44980545 -15867.76289054 -12357.18682877 -9461.405988605 +6.4824931295681 -46432.40717833 -46431.45633504 -46427.41232505 -46416.85507957 -46392.22654413 -46340.41517460 -46236.36183769 -46033.68862172 -45652.34346654 -44956.90721732 -43735.10065116 -41710.74648300 -38638.37781120 -34505.26366497 -29636.30800805 -24601.51841444 -19841.13591027 -15723.78787091 -12291.24391228 -9433.141325115 +8.6457984118673 -38724.75987539 -38724.38386520 -38722.40426819 -38717.04113926 -38704.58838059 -38678.44024600 -38625.87221032 -38522.16849513 -38322.81162147 -37951.95724679 -37278.63171831 -36111.86475523 -34212.34088632 -31397.63307072 -27734.61487270 -23558.23054954 -19326.34284149 -15484.08430705 -12180.83726563 -9384.844704087 +11.464288142969 -32206.71021465 -32206.46774760 -32205.38070797 -32202.42038747 -32195.73985299 -32181.76435588 -32153.80908794 -32098.75105028 -31994.02068534 -31794.51130533 -31424.82147295 -30764.11431181 -29635.16064780 -27840.15182784 -25289.86696417 -22076.52630102 -18535.87681154 -15097.29167672 -11999.12357705 -9303.167376542 +15.136393244975 -26640.04672322 -26639.88381504 -26639.22599206 -26637.57252539 -26633.88389696 -26626.05994450 -26610.26758946 -26579.83155762 -26521.04254926 -26409.28164372 -26202.21077722 -25828.76150703 -25174.09079711 -24082.91239559 -22424.07664142 -20148.34226722 -17407.45419620 -14502.23659918 -11707.85814955 -9167.276917246 +19.920641962073 -21853.50651826 -21853.43151144 -21853.10481094 -21852.23023926 -21850.25194826 -21845.96152583 -21837.35505485 -21820.34209898 -21787.50649459 -21725.17021692 -21608.77286140 -21395.22806753 -21016.57122747 -20380.12728230 -19367.06237517 -17879.24388801 -15924.52621440 -13643.77221867 -11257.77148841 -8947.779956972 +26.153861096936 -17795.66259379 -17795.63287487 -17795.48317649 -17795.05205427 -17793.95844327 -17791.65307201 -17787.03670278 -17778.05822269 -17760.60510171 -17726.98072197 -17662.65095173 -17542.35018719 -17324.12719914 -16950.96864423 -16346.98777167 -15427.99856920 -14139.99996878 -12493.12690623 -10596.48376764 -8604.811123070 +34.274889722469 -14262.96581361 -14262.94813615 -14262.85811915 -14262.62842809 -14262.13216129 -14261.03024387 -14258.83351230 -14254.41783366 -14245.69639377 -14228.67651040 -14196.08889287 -14134.93603045 -14021.83080611 -13817.95831938 -13474.47753358 -12940.73000382 -12157.46622578 -11074.39237204 -9692.377749117 -8096.377293632 +44.855474891995 -11146.02368008 -11146.01607772 -11145.98267782 -11145.87284467 -11145.62350655 -11145.10913636 -11144.08614252 -11142.07304883 -11138.15353923 -11130.64250955 -11116.43612918 -11089.84747050 -11040.98211757 -10952.95989603 -10798.53530734 -10537.80051182 -10116.62989964 -9475.428806423 -8568.624198929 -7403.172203861 +58.640524507213 -8647.733438060 -8647.730015542 -8647.714179532 -8647.672265978 -8647.578361318 -8647.376247806 -8646.957099701 -8646.149252759 -8644.600874742 -8641.626535330 -8635.990299432 -8625.394135177 -8605.744795890 -8569.535075820 -8503.528247188 -8385.361899053 -8180.914042105 -7840.839647591 -7307.599563596 -6541.192971769 +76.600551277882 -6710.722783172 -6710.720908549 -6710.711885570 -6710.690634953 -6710.648223346 -6710.560492257 -6710.380305783 -6710.039615070 -6709.368824500 -6708.083727301 -6705.657292678 -6701.046750902 -6692.321074993 -6676.085406396 -6645.872448470 -6590.697735295 -6491.622866646 -6318.466890510 -6028.238959283 -5572.187624221 +100.00000000000 -5159.834215568 -5159.833624334 -5159.830851275 -5159.823269738 -5159.804827292 -5159.763269011 -5159.679804704 -5159.519494671 -5159.203064749 -5158.593943652 -5157.451458302 -5155.305577978 -5151.294552974 -5143.839894602 -5130.021755140 -5104.449311679 -5057.633801864 -4973.372825153 -4826.374797863 -4581.319512478 diff --git a/gala/source/tests/potential/potential/potential_helpers.py b/gala/source/tests/potential/potential/potential_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..08de14fa4c4745aff1a49c6fd2181dc40bafd796 --- /dev/null +++ b/gala/source/tests/potential/potential/potential_helpers.py @@ -0,0 +1,592 @@ +import copy +import pickle +import time + +import astropy.units as u +import matplotlib.pyplot as plt +import numpy as np +import pytest +from astropy.constants import G +from findiff import Diff + +from gala._optional_deps import HAS_SYMPY +from gala.dynamics import PhaseSpacePosition +from gala.potential import Hamiltonian, StaticFrame +from gala.potential.potential.io import load +from gala.units import DimensionlessUnitSystem, UnitSystem + + +class PotentialTestBase: + name = None + potential = None # MUST SET THIS + frame = None + tol = 1e-5 + show_plots = False + + sympy_hessian = True + sympy_density = True + check_finite_at_origin = True + check_zero_at_infinity = True + rotation = False + + skip_hessian = False + skip_density = False + + # Used for numerical derivative tests + num_dx = None + num_max_x = None + + @pytest.fixture(scope="class") + def rng(self): + return np.random.default_rng(42) + + def setup_method(self): + # set up hamiltonian + if self.frame is None: + self.frame = StaticFrame(units=self.potential.units) + self.H = Hamiltonian(self.potential, self.frame) + self.rnd = np.random.default_rng(seed=42) + + cls = self.__class__ + if cls.name is None: + cls.name = cls.__name__[4:] # removes "Test" + print(f"Testing potential: {cls.name}") + self.w0 = np.array(self.w0) + self.ndim = self.w0.size // 2 + + # TODO: need to test also quantity objects and phasespacepositions! + + # these are arrays we will test the methods on: + w0_2d = np.repeat(self.w0[:, None], axis=1, repeats=16) + w0_3d = np.repeat(w0_2d[..., None], axis=2, repeats=8) + w0_list = list(self.w0) + w0_slice = w0_2d[:, :4] + self.w0s = [self.w0, w0_2d, w0_3d, w0_list, w0_slice] + self._grad_return_shapes = [ + (*self.w0[: self.ndim].shape, 1), + w0_2d[: self.ndim].shape, + w0_3d[: self.ndim].shape, + (*self.w0[: self.ndim].shape, 1), + w0_slice[: self.ndim].shape, + ] + self._hess_return_shapes = [ + (self.ndim, *self.w0[: self.ndim].shape, 1), + (self.ndim, *w0_2d[: self.ndim].shape), + (self.ndim, *w0_3d[: self.ndim].shape), + (self.ndim, *self.w0[: self.ndim].shape, 1), + (self.ndim, *w0_slice[: self.ndim].shape), + ] + self._valu_return_shapes = [x[1:] for x in self._grad_return_shapes] + + def test_unitsystem(self): + assert isinstance(self.potential.units, UnitSystem) + + if isinstance(self.potential.units, DimensionlessUnitSystem): + # Don't do a replace_units test for dimensionless potentials + return + + # check that we can replace the units as expected + usys = UnitSystem([u.pc, u.Gyr, u.radian, u.Msun]) + pot = copy.deepcopy(self.potential) + + pot2 = pot.replace_units(usys) + assert pot2.units == usys + assert pot.units == self.potential.units + + def test_energy(self): + assert self.ndim == self.potential.ndim + + for arr, shp in zip(self.w0s, self._valu_return_shapes): + v = self.potential.energy(arr[: self.ndim]) + assert v.shape == shp + + self.potential.energy(arr[: self.ndim], t=0.1) + self.potential.energy( + arr[: self.ndim], t=0.1 * self.potential.units["time"] + ) + + t = np.zeros(np.array(arr).shape[1:]) + 0.1 + self.potential.energy(arr[: self.ndim], t=t) + self.potential.energy(arr[: self.ndim], t=t * self.potential.units["time"]) + + if self.check_finite_at_origin: + val = self.potential.energy([0.0, 0, 0]) + assert np.isfinite(val) + + if self.check_zero_at_infinity: + val = self.potential.energy([1e12, 1e12, 1e12]) + np.testing.assert_allclose(val, 0.0, rtol=1e-05, atol=1e-6) + + def test_gradient(self): + for arr, shp in zip(self.w0s, self._grad_return_shapes): + g = self.potential.gradient(arr[: self.ndim]) + assert g.shape == shp + + g = self.potential.gradient(arr[: self.ndim], t=0.1) + g = self.potential.gradient( + arr[: self.ndim], t=0.1 * self.potential.units["time"] + ) + + t = np.zeros(np.array(arr).shape[1:]) + 0.1 + g = self.potential.gradient(arr[: self.ndim], t=t) + g = self.potential.gradient( + arr[: self.ndim], t=t * self.potential.units["time"] + ) + + def test_hessian(self): + if self.skip_hessian: + pytest.skip("Hessian not implemented for this potential") + + for arr, shp in zip(self.w0s, self._hess_return_shapes): + g = self.potential.hessian(arr[: self.ndim]) + assert g.shape == shp + + g = self.potential.hessian(arr[: self.ndim], t=0.1) + g = self.potential.hessian( + arr[: self.ndim], t=0.1 * self.potential.units["time"] + ) + + t = np.zeros(np.array(arr).shape[1:]) + 0.1 + g = self.potential.hessian(arr[: self.ndim], t=t) + g = self.potential.hessian( + arr[: self.ndim], t=t * self.potential.units["time"] + ) + + def test_mass_enclosed(self): + for arr, shp in zip(self.w0s, self._valu_return_shapes): + g = self.potential.mass_enclosed(arr[: self.ndim]) + assert g.shape == shp + assert np.all(g > 0.0) + + g = self.potential.mass_enclosed(arr[: self.ndim], t=0.1) + g = self.potential.mass_enclosed( + arr[: self.ndim], t=0.1 * self.potential.units["time"] + ) + + t = np.zeros(np.array(arr).shape[1:]) + 0.1 + g = self.potential.mass_enclosed(arr[: self.ndim], t=t) + g = self.potential.mass_enclosed( + arr[: self.ndim], t=t * self.potential.units["time"] + ) + + def test_circular_velocity(self): + for arr, shp in zip(self.w0s, self._valu_return_shapes): + g = self.potential.circular_velocity(arr[: self.ndim]) + assert g.shape == shp + np.testing.assert_array_less(0.0, g) + + g = self.potential.circular_velocity(arr[: self.ndim], t=0.1) + g = self.potential.circular_velocity( + arr[: self.ndim], t=0.1 * self.potential.units["time"] + ) + + t = np.zeros(np.array(arr).shape[1:]) + 0.1 + g = self.potential.circular_velocity(arr[: self.ndim], t=t) + g = self.potential.circular_velocity( + arr[: self.ndim], t=t * self.potential.units["time"] + ) + + def test_repr(self): + pot_repr = repr(self.potential) + if isinstance(self.potential.units, DimensionlessUnitSystem): + assert "dimensionless" in pot_repr + else: + assert str(self.potential.units["length"]) in pot_repr + assert str(self.potential.units["time"]) in pot_repr + assert str(self.potential.units["mass"]) in pot_repr + + for k in self.potential.parameters: + assert f"{k}=" in pot_repr + + def test_compare(self): + # skip if composite potentials + if len(self.potential.parameters) == 0: + return + + other = self.potential.__class__( + units=self.potential.units, **self.potential.parameters + ) + assert other == self.potential + + pars = self.potential.parameters.copy() + for k in pars: + if isinstance(pars[k], str | int): + continue + + pars[k] = pars[k] * 1.1 # fmt: skip, ruff: noqa + other = self.potential.__class__(units=self.potential.units, **pars) + assert other != self.potential + + # check that comparing to non-potentials works + assert self.potential != "sup" + assert self.potential is not None + + def test_plot(self): + p = self.potential + + p.plot_contours(grid=(np.linspace(-10.0, 10.0, 100), 0.0, 0.0), labels=["X"]) + + p.plot_contours( + grid=( + np.linspace(-10.0, 10.0, 100), + np.linspace(-10.0, 10.0, 100), + 0.0, + ), + cmap="Blues", + ) + + p.plot_contours( + grid=( + np.linspace(-10.0, 10.0, 100), + 1.0, + np.linspace(-10.0, 10.0, 100), + ), + cmap="Blues", + labels=["X", "Z"], + ) + + _f, _a = p.plot_rotation_curve(R_grid=np.linspace(0.1, 10.0, 100)) + + plt.close("all") + + if self.show_plots: + plt.show() + + def test_save_load(self, tmpdir): + """ + Test writing to a YAML file, and reading back in + """ + fn = str(tmpdir.join(f"{self.name}.yml")) + self.potential.save(fn) + p = load(fn) + p.energy(self.w0[: self.w0.size // 2]) + p.gradient(self.w0[: self.w0.size // 2]) + + def test_numerical_gradient_vs_gradient(self, rng): + """ + Check that the value of the implemented gradient function is close to a + numerically estimated value. This is to check the coded-up version. + """ + # NOTE: 1e-3 and 2 are magic numbers and should maybe be configurable + w0_r = np.linalg.norm(self.w0[: self.potential.ndim]) + dx = 1e-3 * w0_r + max_x = 2 * w0_r + + # Pick random points in 3-space, build a finite-difference grid around each + # point to compute numerical gradient + N_points = 16 + pt_xyz = rng.uniform(-max_x, max_x, size=(self.potential.ndim, N_points)) + + grid = np.arange(-4, 4 + 1, 1) * dx + grids = np.meshgrid(*[grid for _ in range(self.potential.ndim)], indexing="ij") + grid_xyz = np.stack(grids, axis=0) + + d_dxs = [Diff(i, dx) for i in range(self.potential.ndim)] + for n in range(N_points): + pt_grid = ( + np.expand_dims( + pt_xyz[:, n], axis=tuple(np.arange(1, 1 + self.potential.ndim)) + ) + + grid_xyz + ) + + energy = self.potential.energy(pt_grid).value + + dPhi_dx = [d_dx(energy) for d_dx in d_dxs] + num_grad = np.stack(dPhi_dx, axis=0) + grad = self.potential.gradient(pt_grid).value + + assert np.allclose(grad, num_grad, rtol=self.tol) + + def test_numerical_density_vs_density(self, rng): + """ + Compare a numerically estimated Laplacian (trace of Hessian) times 4*pi*G + to the implemented density function via Poisson's equation + """ + if self.skip_density: + pytest.skip("density not implemented for this potential") + + # TODO: duplicate code here to test_numerical_gradient_vs_gradient; refactor + + _G = G if not isinstance(self.potential.units, DimensionlessUnitSystem) else 1.0 + + # NOTE: 1e-3 and 2 are magic numbers and should maybe be configurable + w0_r = np.linalg.norm(self.w0[: self.potential.ndim]) + dx = 1e-3 * w0_r if self.num_dx is None else self.num_dx + max_x = 2 * w0_r if self.num_max_x is None else self.num_max_x + + # Pick random points in 3-space, build a finite-difference grid around each + # point to compute numerical gradient + N_points = 16 + pt_xyz = rng.uniform(-max_x, max_x, size=(self.potential.ndim, N_points)) + + grid = np.arange(-4, 4 + 1, 1) * dx + grids = np.meshgrid(*[grid for _ in range(self.potential.ndim)], indexing="ij") + grid_xyz = np.stack(grids, axis=0) + + d2_dx2s = [Diff(i, dx) ** 2 for i in range(self.potential.ndim)] + for n in range(N_points): + pt_grid = ( + np.expand_dims( + pt_xyz[:, n], axis=tuple(np.arange(1, 1 + self.potential.ndim)) + ) + + grid_xyz + ) + + energy = self.potential.energy(pt_grid).value + + d2Phi_dx2 = [d2_dx2(energy) for d2_dx2 in d2_dx2s] + num_Lap = np.sum(d2Phi_dx2, axis=0) + dens_Lap = ( + (self.potential.density(pt_grid) * 4 * np.pi * _G) + .decompose(self.potential.units) + .value + ) + + # NOTE: 1e3 factor here is also a magic number + assert np.allclose(dens_Lap, num_Lap, rtol=1e3 * self.tol) + + def test_hessian_density_consistency(self): + """ + Check that the trace of the Hessian matches the density via Poisson's equation + """ + if self.skip_hessian or self.skip_density: + pytest.skip("Hessian not implemented for this potential") + + _G = G if not isinstance(self.potential.units, DimensionlessUnitSystem) else 1.0 + + for arr in self.w0s: + hess = self.potential.hessian(arr[: self.ndim]) + lap = np.sum(np.diagonal(hess, axis1=0, axis2=1), axis=-1) + + dens = self.potential.density(arr[: self.ndim]) + rho_from_hess = lap / (4.0 * np.pi * _G) + assert u.allclose(dens, rho_from_hess, rtol=self.tol) + + def test_orbit_integration(self, t1=0.0, t2=1000.0, nsteps=10000): + """ + Make sure we can integrate an orbit in this potential + """ + w0 = self.w0 + w0 = np.vstack((w0, w0, w0)).T + + dt = (t2 - t1) / nsteps + + twall = time.time() + orbit = self.H.integrate_orbit(w0, t1=t1, dt=dt, n_steps=nsteps) + print(f"Integration time ({nsteps} steps): {time.time() - twall}") + + if self.show_plots: + f = orbit.plot() + f.suptitle("Vector w0") + plt.show() + plt.close(f) + + us = self.potential.units + w0 = PhaseSpacePosition( + pos=w0[: self.ndim] * us["length"], + vel=w0[self.ndim :] * us["length"] / us["time"], + ) + orbit = self.H.integrate_orbit(w0, t1=t1, dt=dt, n_steps=nsteps) + + if self.show_plots: + f = orbit.plot() + f.suptitle("Object w0") + plt.show() + plt.close(f) + + def test_pickle(self, tmpdir): + fn = str(tmpdir.join(f"{self.name}.pickle")) + with open(fn, "wb") as f: + pickle.dump(self.potential, f) + + with open(fn, "rb") as f: + p = pickle.load(f) + + p.energy(self.w0[: self.w0.size // 2]) + + @pytest.mark.skipif(not HAS_SYMPY, reason="requires sympy to run this test") + def test_against_sympy(self): + # TODO: should really split this into separate tests for each check... + + import sympy as sy + from sympy import Q + + # compare Gala gradient, hessian, and density to sympy values + + pot = self.potential + Phi, v, p = pot.to_sympy() + + # Derive sympy gradient and hessian functions to evaluate: + from scipy.special import gamma, gammainc + + def lowergamma(a, x): + # Differences between scipy and sympy lower gamma + return gammainc(a, x) * gamma(a) + + modules = [ + { + "atan": np.arctan, + # "lowergamma": lowergamma, + "gamma": gamma, + "re": np.real, + "im": np.imag, + }, + "numpy", + "scipy", + "sympy", + ] + + vars_ = list(p.values()) + list(v.values()) + np.bitwise_and.reduce([Q.real(x) for x in vars_]) + # Phi = sy.refine(Phi, assums) + e_func = sy.lambdify(vars_, Phi, modules=modules) + + if self.sympy_density: + dens_tmp = sum(sy.diff(Phi, var, 2) for var in v.values()) / ( + 4 * sy.pi * p["G"] + ) + # dens_tmp = sy.refine(dens_tmp, assums) + dens_func = sy.lambdify(vars_, dens_tmp, modules=modules) + + grad = sy.derive_by_array(Phi, list(v.values())) + # grad = sy.refine(grad, assums) + grad_func = sy.lambdify(vars_, grad, modules=modules) + + if self.sympy_hessian: + Hess = sy.hessian(Phi, list(v.values())) + # Hess = sy.refine(Hess, assums) + Hess_func = sy.lambdify(vars_, Hess, modules=modules) + + # Make a dict of potential parameter values without units: + par_vals = {} + for k, v in pot.parameters.items(): + par_vals[k] = v.value + + N = 64 # MAGIC NUMBER: + trial_x = self.rnd.uniform(-10.0, 10.0, size=(pot.ndim, N)) + x_dict = dict(zip(["x", "y", "z"], trial_x)) + + f_gala = pot.energy(trial_x).value + f_sympy = e_func(G=pot.G, **par_vals, **x_dict) + e_close = np.allclose(f_gala, f_sympy) + test_cases = [e_close] + vals = [(f_gala, f_sympy)] + + if self.sympy_density: + d_gala = pot.density(trial_x).value + d_sympy = dens_func(G=pot.G, **par_vals, **x_dict) + d_close = np.allclose(d_gala, d_sympy) + test_cases.append(d_close) + vals.append((d_gala, d_sympy)) + + G_gala = pot.gradient(trial_x).value + G_sympy = grad_func(G=pot.G, **par_vals, **x_dict) + g_close = np.allclose(G_gala, G_sympy) + test_cases.append(g_close) + vals.append((G_gala, G_sympy)) + + if self.sympy_hessian: + H_gala = pot.hessian(trial_x).value + H_sympy = Hess_func(G=pot.G, **par_vals, **x_dict) + h_close = np.allclose(H_gala, H_sympy) + test_cases.append(h_close) + vals.append((H_gala, H_sympy)) + + if not all(test_cases): + names = ["energy", "density", "gradient", "hessian"] + for name, (val1, val2), test in zip(names, vals, test_cases): + if not test: + print(trial_x) + print(f"{pot}: {name}\nGala:{val1}\nSympy:{val2}") + + assert all(test_cases) + + def test_regression_165(self): + if self.potential.ndim == 1: + pytest.skip("ndim = 1") + + with pytest.raises(ValueError): + self.potential.energy(8.0) + + with pytest.raises(ValueError): + self.potential.gradient(8.0) + + with pytest.raises(ValueError): + self.potential.circular_velocity(8.0) + + @pytest.mark.parametrize("meth", ["energy", "gradient", "density"]) + def test_rotation_shift(self, meth): + if not self.rotation: + pytest.skip("Rotation has no impact for this potential") + if meth == "density" and not self.sympy_density: + pytest.skip("No analytic density") + + x = np.array([10.0, 5.0, 3.0]) + x0 = np.array([1.0, 1.0, 3.0]) + R = np.array([[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) + + R_pot = self.potential.replicate(R=R) + origin_pot = self.potential.replicate(origin=x0) + R_origin_pot = self.potential.replicate(R=R, origin=x0) + + x_R = getattr(R_pot, meth)(x) + test_val = getattr(self.potential, meth)(R @ x) + if test_val.size > 1: + test_val = R.T @ test_val + assert u.allclose(x_R, test_val) + + x_origin = getattr(origin_pot, meth)(x) + assert u.allclose(x_origin, getattr(self.potential, meth)(x - x0)) + + x_R_origin = getattr(R_origin_pot, meth)(x) + test_val = getattr(self.potential, meth)(R @ (x - x0)) + if test_val.size > 1: + test_val = R.T @ test_val + assert u.allclose(x_R_origin, test_val) + + +class CompositePotentialTestBase(PotentialTestBase): + @pytest.mark.skip(reason="Skip composite potential repr test") + def test_repr(self): + pass + + @pytest.mark.skip(reason="Skip composite potential compare test") + def test_compare(self): + pass + + @pytest.mark.skip(reason="to_sympy() not implemented yet") + def test_against_sympy(self): + pass + + @pytest.mark.parametrize("meth", ["energy", "gradient", "density"]) + def test_rotation_shift(self, meth): + if not self.rotation: + pytest.skip("Rotation has no impact for this potential") + + x = np.array([10.0, 5.0, 3.0]) + x0 = np.array([1.0, 1.0, 3.0]) + R = np.array([[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]) + + R_pot = self.potential.__class__() + origin_pot = self.potential.__class__() + R_origin_pot = self.potential.__class__() + for k, p in self.potential.items(): + R_pot[k] = p.replicate(R=R) + origin_pot[k] = p.replicate(origin=x0) + R_origin_pot[k] = p.replicate(R=R, origin=x0) + + x_R = getattr(R_pot, meth)(x) + test_val = getattr(self.potential, meth)(R @ x) + if test_val.size > 1: + test_val = R.T @ test_val + assert u.allclose(x_R, test_val) + + x_origin = getattr(origin_pot, meth)(x) + assert u.allclose(x_origin, getattr(self.potential, meth)(x - x0)) + + x_R_origin = getattr(R_origin_pot, meth)(x) + test_val = getattr(self.potential, meth)(R @ (x - x0)) + if test_val.size > 1: + test_val = R.T @ test_val + assert u.allclose(x_R_origin, test_val) diff --git a/gala/source/tests/potential/potential/test_all_builtin.py b/gala/source/tests/potential/potential/test_all_builtin.py new file mode 100644 index 0000000000000000000000000000000000000000..0fed4e3dd89deabbb71bdc0b7d1596f376865b32 --- /dev/null +++ b/gala/source/tests/potential/potential/test_all_builtin.py @@ -0,0 +1,637 @@ +""" +Test the builtin CPotential classes +""" + +from pathlib import Path + +import astropy.table as at +import astropy.units as u +import numpy as np +import pytest +from gala._cconfig import GSL_ENABLED +from potential_helpers import CompositePotentialTestBase, PotentialTestBase +from scipy.spatial.transform import Rotation + +import gala.potential as p +from gala._optional_deps import HAS_SYMPY +from gala.potential import ( + CCompositePotential, + CompositePotential, + ConstantRotatingFrame, +) +from gala.units import DimensionlessUnitSystem, galactic, solarsystem + +this_path = Path(__file__).parent + +############################################################################## +# Python +############################################################################## + + +class TestHarmonicOscillator1D(PotentialTestBase): + potential = p.HarmonicOscillatorPotential(omega=[1.0]) + w0 = [1.0, 0.1] + sympy_density = False + check_finite_at_origin = False + check_zero_at_infinity = False + skip_density = True + + def test_plot(self): + # Skip for now because contour plotting assumes 3D + pass + + +class TestHarmonicOscillator2D(PotentialTestBase): + potential = p.HarmonicOscillatorPotential(omega=[1.0, 2]) + w0 = [1.0, 0.5, 0.0, 0.1] + sympy_density = False + check_finite_at_origin = False + check_zero_at_infinity = False + skip_density = True + + def test_plot(self): + # Skip for now because contour plotting assumes 3D + pass + + @pytest.mark.skip(reason="to_sympy() won't support multi-dim HO") + def test_against_sympy(self): + pass + + +############################################################################## +# Cython +############################################################################## + + +class TestNull(PotentialTestBase): + potential = p.NullPotential() + w0 = [1.0, 0.0, 0.0, 0.0, 2 * np.pi, 0.0] + skip_density = True + + def test_mass_enclosed(self): + for arr, shp in zip(self.w0s, self._valu_return_shapes): + g = self.potential.mass_enclosed(arr[: self.ndim]) + assert g.shape == shp + assert np.all(g == 0.0) + + g = self.potential.mass_enclosed(arr[: self.ndim], t=0.1) + g = self.potential.mass_enclosed( + arr[: self.ndim], t=0.1 * self.potential.units["time"] + ) + + t = np.zeros(np.array(arr).shape[1:]) + 0.1 + g = self.potential.mass_enclosed(arr[: self.ndim], t=t) + g = self.potential.mass_enclosed( + arr[: self.ndim], t=t * self.potential.units["time"] + ) + + def test_circular_velocity(self): + for arr, shp in zip(self.w0s, self._valu_return_shapes): + g = self.potential.circular_velocity(arr[: self.ndim]) + assert g.shape == shp + assert np.all(g == 0.0) + + g = self.potential.circular_velocity(arr[: self.ndim], t=0.1) + g = self.potential.circular_velocity( + arr[: self.ndim], t=0.1 * self.potential.units["time"] + ) + + t = np.zeros(np.array(arr).shape[1:]) + 0.1 + g = self.potential.circular_velocity(arr[: self.ndim], t=t) + g = self.potential.circular_velocity( + arr[: self.ndim], t=t * self.potential.units["time"] + ) + + @pytest.mark.skip(reason="Nothing to compare to for Null potential!") + def test_against_sympy(self): + pass + + +class TestHenonHeiles(PotentialTestBase): + potential = p.HenonHeilesPotential() + w0 = [1.0, 0.0, 0.0, 2 * np.pi] + sympy_density = False + check_finite_at_origin = False + check_zero_at_infinity = False + skip_density = True + + @pytest.mark.skip(reason="Not relevant") + def test_plot(self): + pass + + +class TestKepler(PotentialTestBase): + potential = p.KeplerPotential(units=solarsystem, m=1.0) + w0 = [1.0, 0.0, 0.0, 0.0, 2 * np.pi, 0.0] + check_finite_at_origin = False + skip_density = True + + +class TestKeplerUnitInput(PotentialTestBase): + potential = p.KeplerPotential(units=solarsystem, m=(1 * u.Msun).to(u.Mjup)) + w0 = [1.0, 0.0, 0.0, 0.0, 2 * np.pi, 0.0] + check_finite_at_origin = False + skip_density = True + + +class TestIsochrone(PotentialTestBase): + potential = p.IsochronePotential(units=solarsystem, m=1.0, b=0.1) + w0 = [1.0, 0.0, 0.0, 0.0, 2 * np.pi, 0.0] + + +class TestIsochroneDimensionless(PotentialTestBase): + potential = p.IsochronePotential(units=DimensionlessUnitSystem(), m=1.0, b=0.1) + w0 = [1.0, 0.0, 0.0, 0.0, 2 * np.pi, 0.0] + + +class TestHernquist(PotentialTestBase): + potential = p.HernquistPotential(units=galactic, m=1.0e11, c=0.26) + w0 = [1.0, 0.0, 0.0, 0.0, 0.1, 0.1] + + +class TestPlummer(PotentialTestBase): + potential = p.PlummerPotential(units=galactic, m=1.0e11, b=0.26) + w0 = [1.0, 0.0, 0.0, 0.0, 0.1, 0.1] + + +class TestJaffe(PotentialTestBase): + check_finite_at_origin = False + potential = p.JaffePotential(units=galactic, m=1.0e11, c=0.26) + w0 = [1.0, 0.0, 0.0, 0.0, 0.1, 0.1] + + +class TestMiyamotoNagai(PotentialTestBase): + potential = p.MiyamotoNagaiPotential(units=galactic, m=1.0e11, a=6.5, b=0.26) + w0 = [8.0, 0.0, 0.0, 0.0, 0.22, 0.1] + rotation = True + + @pytest.mark.skipif(not HAS_SYMPY, reason="requires sympy to run this test") + def test_hessian_analytic(self): + import sympy as sy + from astropy.constants import G + from sympy import symbols + + x, y, z = symbols("x y z") + + usys = self.potential.units + GM = (G * self.potential.parameters["m"]).decompose(usys).value + a = self.potential.parameters["a"].decompose(usys).value + b = self.potential.parameters["b"].decompose(usys).value + Phi = -GM / sy.sqrt(x**2 + y**2 + (a + sy.sqrt(z**2 + b**2)) ** 2) + + d2Phi_dx2 = sy.lambdify((x, y, z), sy.diff(Phi, x, 2)) + d2Phi_dy2 = sy.lambdify((x, y, z), sy.diff(Phi, y, 2)) + d2Phi_dz2 = sy.lambdify((x, y, z), sy.diff(Phi, z, 2)) + + d2Phi_dxdy = sy.lambdify((x, y, z), sy.diff(Phi, x, y)) + d2Phi_dxdz = sy.lambdify((x, y, z), sy.diff(Phi, x, z)) + d2Phi_dydz = sy.lambdify((x, y, z), sy.diff(Phi, y, z)) + + rnd = np.random.default_rng(42) + xyz = rnd.normal(0, 25, size=(3, 64)) + + H1 = self.potential.hessian(xyz).decompose(usys).value + + H2 = np.zeros((3, 3, xyz.shape[1])) + H2[0, 0] = d2Phi_dx2(*xyz) + H2[1, 1] = d2Phi_dy2(*xyz) + H2[2, 2] = d2Phi_dz2(*xyz) + + H2[0, 1] = H2[1, 0] = d2Phi_dxdy(*xyz) + H2[0, 2] = H2[2, 0] = d2Phi_dxdz(*xyz) + H2[1, 2] = H2[2, 1] = d2Phi_dydz(*xyz) + + assert np.allclose(H1, H2) + + +class TestMN3(PotentialTestBase): + potential = p.MN3ExponentialDiskPotential( + units=galactic, m=1.0e11, h_R=3.5, h_z=0.26 + ) + w0 = [8.0, 0.0, 0.0, 0.0, 0.22, 0.1] + rotation = True + + # TODO: + @pytest.mark.skip(reason="to_sympy() not implemented yet") + def test_against_sympy(self): + pass + + def test_get_three(self): + pots = self.potential.get_three_potentials() + assert len(pots) == 3 + + +class TestSatoh(PotentialTestBase): + potential = p.SatohPotential(units=galactic, m=1.0e11, a=6.5, b=0.26) + w0 = [8.0, 0.0, 0.0, 0.0, 0.22, 0.1] + rotation = True + + +class TestKuzmin(PotentialTestBase): + potential = p.KuzminPotential(units=galactic, m=1.0e11, a=3.5) + w0 = [8.0, 0.0, 0.0, 0.0, 0.22, 0.1] + sympy_hessian = False + sympy_density = False + rotation = True + skip_hessian = True # TODO: implement + + +class TestStone(PotentialTestBase): + potential = p.StonePotential(units=galactic, m=1e11, r_c=0.1, r_h=10.0) + w0 = [8.0, 0.0, 0.0, 0.0, 0.18, 0.1] + + +@pytest.mark.skipif(not GSL_ENABLED, reason="requires GSL to run this test") +class TestPowerLawCutoff(PotentialTestBase): + w0 = [8.0, 0.0, 0.0, 0.0, 0.1, 0.1] + atol = 1e-3 + sympy_density = False # weird underflow issues?? + check_finite_at_origin = False + + def setup_method(self): + self.potential = p.PowerLawCutoffPotential( + units=galactic, m=1e10, r_c=10.0, alpha=1.8 + ) + super().setup_method() + + +class TestSphericalNFW(PotentialTestBase): + potential = p.NFWPotential(units=galactic, m=1e11, r_s=12.0) + w0 = [19.0, 2.7, -6.9, 0.0352238, -0.03579493, 0.075] + + +class TestFlattenedNFW(PotentialTestBase): + potential = p.NFWPotential(units=galactic, m=1e11, r_s=12.0, c=0.7) + w0 = [19.0, 2.7, -6.9, 0.0352238, -0.03579493, 0.075] + sympy_density = False # not defined + rotation = True + skip_density = True # no density defined + + def test_against_spherical(self): + """ + Note: This is a regression test for Issue #254 + """ + + sph = p.NFWPotential(units=galactic, m=1e11, r_s=12.0) + assert not u.allclose( + self.potential.gradient(self.w0[:3]), sph.gradient(self.w0[:3]) + ) + + +class TestTriaxialNFW(PotentialTestBase): + potential = p.NFWPotential(units=galactic, m=1e11, r_s=12.0, a=1.0, b=0.95, c=0.9) + w0 = [19.0, 2.7, -6.9, 0.0352238, -0.03579493, 0.075] + sympy_density = False # not defined + rotation = True + skip_density = True # no density defined + + +class TestSphericalNFWFromCircVel(PotentialTestBase): + potential = p.NFWPotential.from_circular_velocity( + v_c=220.0 * u.km / u.s, r_s=20 * u.kpc, r_ref=8.0 * u.kpc, units=galactic + ) + w0 = [19.0, 2.7, -0.9, 0.00352238, -0.165134, 0.0075] + + def test_circ_vel(self): + for r_ref in [3.0, 8.0, 21.7234]: + pot = p.NFWPotential.from_circular_velocity( + v_c=220.0 * u.km / u.s, + r_s=20 * u.kpc, + r_ref=r_ref * u.kpc, + units=galactic, + ) + vc = pot.circular_velocity([r_ref, 0, 0] * u.kpc) # at ref. velocity + assert u.allclose(vc, 220 * u.km / u.s) + + def test_against_triaxial(self): + this = p.NFWPotential.from_circular_velocity( + v_c=220.0 * u.km / u.s, r_s=20 * u.kpc, units=galactic + ) + other = p.LeeSutoTriaxialNFWPotential( + units=galactic, + v_c=220.0 * u.km / u.s, + r_s=20.0 * u.kpc, + a=1.0, + b=1.0, + c=1.0, + ) + + v1 = this.energy(self.w0[:3]) + v2 = other.energy(self.w0[:3]) + assert u.allclose(v1, v2) + + a1 = this.gradient(self.w0[:3]) + a2 = other.gradient(self.w0[:3]) + assert u.allclose(a1, a2) + + d1 = this.density(self.w0[:3]) + d2 = other.density(self.w0[:3]) + assert u.allclose(d1, d2) + + def test_mass_enclosed(self): + # true mass profile + m = self.potential.parameters["m"].value + rs = self.potential.parameters["r_s"].value + + r = np.linspace(1.0, 400, 100) + fac = np.log(1 + r / rs) - (r / rs) / (1 + (r / rs)) + true_mprof = m * fac + + R = np.zeros((3, len(r))) + R[0, :] = r + esti_mprof = self.potential.mass_enclosed(R) + + assert np.allclose(true_mprof, esti_mprof.value, rtol=1e-6) + + +class TestNFW(PotentialTestBase): + potential = p.NFWPotential( + m=6e11 * u.Msun, r_s=20 * u.kpc, a=1.0, b=0.9, c=0.75, units=galactic + ) + w0 = [19.0, 2.7, -0.9, 0.00352238, -0.15134, 0.0075] + sympy_density = False # like triaxial case + skip_density = True # no density defined + + def test_compare(self): + sph = p.NFWPotential(m=6e11 * u.Msun, r_s=20 * u.kpc, units=galactic) + fla = p.NFWPotential(m=6e11 * u.Msun, r_s=20 * u.kpc, c=0.8, units=galactic) + tri = p.NFWPotential( + m=6e11 * u.Msun, r_s=20 * u.kpc, b=0.9, c=0.8, units=galactic + ) + + xyz = np.zeros((3, 128)) + xyz[0] = np.logspace(-1.0, 3, xyz.shape[1]) + + assert u.allclose(sph.energy(xyz), fla.energy(xyz)) + assert u.allclose(sph.energy(xyz), tri.energy(xyz)) + + assert u.allclose(sph.gradient(xyz), fla.gradient(xyz)) + assert u.allclose(sph.gradient(xyz), tri.gradient(xyz)) + + # assert u.allclose(sph.density(xyz), fla.density(xyz)) # TODO: fla density not implemented + # assert u.allclose(sph.density(xyz), tri.density(xyz)) # TODO: tri density not implemented + + # --- + + tri = p.NFWPotential( + m=6e11 * u.Msun, r_s=20 * u.kpc, a=0.9, c=0.8, units=galactic + ) + + xyz = np.zeros((3, 128)) + xyz[1] = np.logspace(-1.0, 3, xyz.shape[1]) + + assert u.allclose(sph.energy(xyz), fla.energy(xyz)) + assert u.allclose(sph.energy(xyz), tri.energy(xyz)) + + assert u.allclose(sph.gradient(xyz), fla.gradient(xyz)) + assert u.allclose(sph.gradient(xyz), tri.gradient(xyz)) + + # assert u.allclose(sph.density(xyz), fla.density(xyz)) # TODO: fla density not implemented + # assert u.allclose(sph.density(xyz), tri.density(xyz)) # TODO: tri density not implemented + + # --- + + xyz = np.zeros((3, 128)) + xyz[0] = np.logspace(-1.0, 3, xyz.shape[1]) + xyz[1] = np.logspace(-1.0, 3, xyz.shape[1]) + + assert u.allclose(sph.energy(xyz), fla.energy(xyz)) + assert u.allclose(sph.gradient(xyz), fla.gradient(xyz)) + + def test_nfw_properties(self): + """Test that M200, c200, and R200 properties work correctly.""" + + M200_input = 1e12 * u.Msun + c_input = 15.0 + pot_input = p.NFWPotential.from_M200_c(M200_input, c_input, units=galactic) + + # Test the inverse properties + c200_computed = pot_input.c200() + M200_computed = pot_input.M200() + + assert u.allclose(c200_computed, c_input) + assert u.allclose(M200_computed, M200_input) + + # Test that R200 evaluates and is equivalent to r_s * c200 + R200_computed = pot_input.R200() + r_s = pot_input.parameters["r_s"] + R200_expected = r_s * c200_computed + assert u.allclose(R200_computed, R200_expected) + + +class TestLeeSutoTriaxialNFW(PotentialTestBase): + potential = p.LeeSutoTriaxialNFWPotential( + units=galactic, v_c=0.35, r_s=12.0, a=1.3, b=1.0, c=0.8 + ) + w0 = [19.0, 2.7, -6.9, 0.0352238, -0.03579493, 0.075] + rotation = True + skip_hessian = True # TODO: implement + + @pytest.mark.skip(reason="to_sympy() not implemented yet") + def test_against_sympy(self): + pass + + @pytest.mark.skip( + reason="density potential correspondence bad because approximation" + ) + def test_numerical_density_vs_density(self): + pass + + +class TestLogarithmic(PotentialTestBase): + potential = p.LogarithmicPotential( + units=galactic, v_c=0.17, r_h=10.0, q1=1.2, q2=1.0, q3=0.8 + ) + w0 = [19.0, 2.7, -6.9, 0.0352238, -0.03579493, 0.075] + check_zero_at_infinity = False + + +class TestLongMuraliBar(PotentialTestBase): + potential = p.LongMuraliBarPotential( + units=galactic, m=1e11, a=4.0 * u.kpc, b=1 * u.kpc, c=1.0 * u.kpc + ) + vc = potential.circular_velocity([19.0, 0, 0] * u.kpc).decompose(galactic).value[0] + w0 = [19.0, 0.2, -0.9, 0.0, vc, 0.0] + rotation = True + + +class TestLongMuraliBarRotate(PotentialTestBase): + potential = p.LongMuraliBarPotential( + units=galactic, + m=1e11, + a=4.0 * u.kpc, + b=1 * u.kpc, + c=1.0 * u.kpc, + R=np.array( + [ + [0.63302222, 0.75440651, 0.17364818], + [-0.76604444, 0.64278761, 0.0], + [-0.1116189, -0.13302222, 0.98480775], + ] + ), + ) + vc = potential.circular_velocity([19.0, 0, 0] * u.kpc).decompose(galactic).value[0] + w0 = [19.0, 0.2, -0.9, 0.0, vc, 0.0] + + skip_hessian = True # TODO: implement + + @pytest.mark.skip(reason="Not implemented for rotated potentials") + def test_against_sympy(self): + pass + + +class TestLongMuraliBarRotationScipy(PotentialTestBase): + potential = p.LongMuraliBarPotential( + units=galactic, + m=1e11, + a=4.0 * u.kpc, + b=1 * u.kpc, + c=1.0 * u.kpc, + R=Rotation.from_euler("zxz", [90.0, 0, 0.0], degrees=True), + ) + vc = potential.circular_velocity([19.0, 0, 0] * u.kpc).decompose(galactic).value[0] + w0 = [19.0, 0.2, -0.9, 0.0, vc, 0.0] + + skip_hessian = True # TODO: implement + + @pytest.mark.skip(reason="Not implemented for rotated potentials") + def test_against_sympy(self): + pass + + +class TestComposite(CompositePotentialTestBase): + p1 = p.LogarithmicPotential( + units=galactic, v_c=0.17, r_h=10.0, q1=1.2, q2=1.0, q3=0.8 + ) + p2 = p.MiyamotoNagaiPotential(units=galactic, m=1.0e11, a=6.5, b=0.26) + potential = CompositePotential() + potential["disk"] = p2 + potential["halo"] = p1 + w0 = [19.0, 2.7, -6.9, 0.0352238, -0.03579493, 0.075] + rotation = True + check_zero_at_infinity = False + + num_dx = 1e-3 # to resolve scale height + + +class TestCComposite(CompositePotentialTestBase): + p1 = p.LogarithmicPotential( + units=galactic, v_c=0.17, r_h=10.0, q1=1.2, q2=1.0, q3=0.8 + ) + p2 = p.MiyamotoNagaiPotential(units=galactic, m=1.0e11, a=6.5, b=0.26) + potential = CCompositePotential() + potential["disk"] = p2 + potential["halo"] = p1 + w0 = [19.0, 2.7, -6.9, 0.0352238, -0.03579493, 0.075] + rotation = True + check_zero_at_infinity = False + + num_dx = 1e-3 # to resolve scale height + + +class TestKepler3Body(CompositePotentialTestBase): + """This implicitly tests the origin shift""" + + mu = 1 / 11.0 + x1 = -mu + m1 = 1 - mu + x2 = 1 - mu + m2 = mu + potential = CCompositePotential() + potential["m1"] = p.KeplerPotential(m=m1, origin=[x1, 0, 0.0]) + potential["m2"] = p.KeplerPotential(m=m2, origin=[x2, 0, 0.0]) + + Omega = np.array([0, 0, 1.0]) + frame = ConstantRotatingFrame(Omega=Omega) + w0 = [0.5, 0, 0, 0.0, 1.05800316, 0.0] + + skip_density = True # no density defined + + +@pytest.mark.skipif(not GSL_ENABLED, reason="requires GSL to run this test") +class TestMultipoleInner(CompositePotentialTestBase): + potential_1 = p.NFWPotential(m=1e12, r_s=15.0, units=galactic) + potential = potential_1 + p.MultipolePotential( + units=galactic, m=1e10, r_s=15.0, inner=True, lmax=2, S10=1.0, S21=0.5 + ) + vc = potential.circular_velocity([19.0, 0, 0] * u.kpc).decompose(galactic).value[0] + w0 = [19.0, 0.2, -0.9, 0.0, vc, 0.0] + check_zero_at_infinity = False + skip_hessian = True # TODO: implement + + @pytest.mark.skip(reason="Not implemented for multipole potentials") + def test_against_sympy(self): + pass + + +@pytest.mark.skipif(not GSL_ENABLED, reason="requires GSL to run this test") +class TestMultipoleOuter(CompositePotentialTestBase): + potential_1 = p.NFWPotential(m=1e12, r_s=15.0, units=galactic) + potential = potential_1 + p.MultipolePotential( + units=galactic, m=1e10, r_s=15.0, inner=False, lmax=2, S10=1.0, S21=0.5 + ) + vc = potential.circular_velocity([19.0, 0, 0] * u.kpc).decompose(galactic).value[0] + w0 = [19.0, 0.2, -0.9, 0.0, vc, 0.0] + check_finite_at_origin = False + skip_hessian = True # TODO: implement + + @pytest.mark.skip(reason="Not implemented for multipole potentials") + def test_against_sympy(self): + pass + + +@pytest.mark.skipif(not GSL_ENABLED, reason="requires GSL to run this test") +class TestCylspline(PotentialTestBase): + check_finite_at_origin = True + skip_hessian = True # TODO: implement + skip_density = True # TODO: implement + + def setup_method(self): + self.potential = p.CylSplinePotential.from_file( + this_path / "pot_disk_506151.pot", units=galactic + ) + vc = self.potential.circular_velocity([19.0, 0, 0] * u.kpc).decompose(galactic) + self.w0 = [19.0, 0.2, -0.9, 0.0, vc.value[0], 0.0] + super().setup_method() + + @pytest.mark.skip(reason="Not implemented for cylspline potentials") + def test_density(self): + pass + + @pytest.mark.skip(reason="Not implemented for cylspline potentials") + def test_against_sympy(self): + pass + + def test_against_agama(self): + agama_tbl = at.QTable.read(this_path / "agama_cylspline_test.fits") + + gala_ene = self.potential.energy(agama_tbl["xyz"].T) + gala_acc = self.potential.acceleration(agama_tbl["xyz"].T) + + assert u.allclose(gala_ene, agama_tbl["pot"][:, 0], rtol=1e-3) + for i in range(3): + assert u.allclose(gala_acc[i], agama_tbl["acc"][:, i], rtol=1e-2) + + +class TestBurkert(PotentialTestBase): + potential = p.BurkertPotential( + units=galactic, rho=5e-25 * u.g / u.cm**3, r0=12 * u.kpc + ) + w0 = [1.0, 0.0, 0.0, 0.0, 0.1, 0.1] + + check_finite_at_origin = False + skip_hessian = True # TODO: implement + + @pytest.mark.skip(reason="Not implemented for Burkert potentials") + def test_against_sympy(self): + pass + + def test_from_r0(self): + # Test against values from Zhu+2023 + pot = p.BurkertPotential.from_r0(r0=11.87 * u.kpc, units=galactic) + + rho = pot.parameters["rho"].to(u.g / u.cm**3) + rho_check = 5.93e-25 * u.g / u.cm**3 + + # Check a 1% tolerance on inferred density against published values + assert abs(rho - rho_check) / rho_check < 0.01 diff --git a/gala/source/tests/potential/potential/test_composite.py b/gala/source/tests/potential/potential/test_composite.py new file mode 100644 index 0000000000000000000000000000000000000000..74efa1aef60d2d73f57b60bbbb6311dc50dec4e9 --- /dev/null +++ b/gala/source/tests/potential/potential/test_composite.py @@ -0,0 +1,193 @@ +# Third party +import astropy.units as u +import numpy as np +import pytest + +import gala.potential as gp +from gala.integrate import DOPRI853Integrator, LeapfrogIntegrator +from gala.potential.common import PotentialParameter +from gala.units import UnitSystem, galactic, solarsystem + + +class CompositeHelper: + rotation = False + + def setup_method(self): + self.units = solarsystem + self.p1 = gp.KeplerPotential(m=1.0 * u.Msun, units=self.units) + self.p2 = gp.HernquistPotential(m=0.5 * u.Msun, c=0.1 * u.au, units=self.units) + + def test_shit(self): + potential = self.Cls(one=self.p1, two=self.p2) + + q = np.ascontiguousarray(np.array([[1.1, 0, 0]]).T) + print("val", potential.energy(q)) + + q = np.ascontiguousarray(np.array([[1.1, 0, 0]]).T) + print("grad", potential.gradient(q)) + + def test_composite_create(self): + potential = self.Cls() + + # Add a point mass with same unit system + potential["one"] = gp.KeplerPotential(units=self.units, m=1.0) + + with pytest.raises(TypeError): + potential["two"] = "derp" + + assert "one" in potential.parameters + assert "m" in potential.parameters["one"] + with pytest.raises(TypeError): + potential.parameters["m"] = "derp" + + def test_plot_composite(self): + # TODO: do image comparison or something to compare? + + potential = self.Cls() + + # Add a kepler potential and a harmonic oscillator + potential["one"] = self.p1 + potential["two"] = self.p2 + + grid = np.linspace(-5.0, 5) + potential.plot_contours(grid=(grid, 0.0, 0.0)) + # fig.savefig(os.path.join(plot_path, "composite_kepler_sho_1d.png")) + + potential.plot_contours(grid=(grid, grid, 0.0)) + # fig.savefig(os.path.join(plot_path, "composite_kepler_sho_2d.png")) + + def test_integrate(self): + potential = self.Cls() + potential["one"] = self.p1 + potential["two"] = self.p2 + + for Integrator in [DOPRI853Integrator, LeapfrogIntegrator]: + kw = {} + if Integrator == DOPRI853Integrator: + kw = {"atol": 1e-14, "rtol": 1e-14} + + H = gp.Hamiltonian(potential) + w_cy = H.integrate_orbit( + [1.0, 0, 0, 0, 2 * np.pi, 0], + dt=0.01, + n_steps=1000, + Integrator=Integrator, + cython_if_possible=True, + Integrator_kwargs=kw, + ) + w_py = H.integrate_orbit( + [1.0, 0, 0, 0, 2 * np.pi, 0], + dt=0.01, + n_steps=1000, + Integrator=Integrator, + cython_if_possible=False, + ) + + dx = w_cy.xyz.value - w_py.xyz.value + assert np.allclose(w_cy.xyz.value, w_py.xyz.value) + assert np.allclose(w_cy.v_xyz.value, w_py.v_xyz.value) + + +# ------------------------------------------------------------------------ + + +class TestComposite(CompositeHelper): + Cls = gp.CompositePotential + + +class TestCComposite(CompositeHelper): + Cls = gp.CCompositePotential + + +def test_failures(): + p = gp.CCompositePotential() + p["derp"] = gp.KeplerPotential(m=1.0 * u.Msun, units=solarsystem) + with pytest.raises(ValueError): + p["jnsdfn"] = gp.HenonHeilesPotential(units=solarsystem) + + +def test_lock(): + p = gp.CompositePotential() + p["derp"] = gp.KeplerPotential(m=1.0 * u.Msun, units=solarsystem) + p.lock = True + with pytest.raises(ValueError): # try adding potential after lock + p["herp"] = gp.KeplerPotential(m=2.0 * u.Msun, units=solarsystem) + + p = gp.CCompositePotential() + p["derp"] = gp.KeplerPotential(m=1.0 * u.Msun, units=solarsystem) + p.lock = True + with pytest.raises(ValueError): # try adding potential after lock + p["herp"] = gp.KeplerPotential(m=2.0 * u.Msun, units=solarsystem) + + +class MyPotential(gp.PotentialBase): + m = PotentialParameter("m", physical_type="mass") + x0 = PotentialParameter("x0", physical_type="length", ndim=1) + + def _energy(self, x, t): + m = self.parameters["m"] + x0 = self.parameters["x0"] + r = np.sqrt(np.sum((x - x0[None]) ** 2, axis=1)) + return -m / r + + def _gradient(self, x, t): + m = self.parameters["m"] + x0 = self.parameters["x0"] + x0 = np.atleast_2d(x0).T + r = np.sqrt(np.sum((x - x0) ** 2, axis=0)) + return m * (x - x0) / r**3 + + +def test_add(): + """Test adding potentials to get a composite""" + p1 = gp.KeplerPotential(units=galactic, m=1 * u.Msun) + p2 = gp.HernquistPotential(units=galactic, m=1.0e11, c=0.26) + + comp1 = gp.CompositePotential() + comp1["0"] = p1 + comp1["1"] = p2 + + py_p1 = MyPotential(m=1.0, x0=[1.0, 0.0, 0.0], units=galactic) + py_p2 = MyPotential(m=4.0, x0=[-1.0, 0.0, 0.0], units=galactic) + + # python + python + new_p = py_p1 + py_p2 + assert isinstance(new_p, gp.CompositePotential) + assert not isinstance(new_p, gp.CCompositePotential) + assert len(new_p.keys()) == 2 + + # python + python + python + new_p = py_p1 + py_p2 + py_p2 + assert isinstance(new_p, gp.CompositePotential) + assert len(new_p.keys()) == 3 + + # cython + cython + new_p = p1 + p2 + assert isinstance(new_p, gp.CCompositePotential) + assert len(new_p.keys()) == 2 + + # cython + python + new_p = py_p1 + p2 + assert isinstance(new_p, gp.CompositePotential) + assert not isinstance(new_p, gp.CCompositePotential) + assert len(new_p.keys()) == 2 + + # cython + cython + python + new_p = p1 + p2 + py_p1 + assert isinstance(new_p, gp.CompositePotential) + assert not isinstance(new_p, gp.CCompositePotential) + assert len(new_p.keys()) == 3 + + +def test_no_max_n_components(): + units = UnitSystem(u.pc, u.Myr, u.radian, u.Msun) + + pots = {} + ms = np.linspace(10, 100, 1024) + q0s = np.zeros((3, ms.shape[0])) * u.kpc + q0s[0] = np.linspace(-10, 10, ms.shape[0]) * u.pc + for i in range(ms.shape[0]): + pots[f"yo{i}"] = gp.HernquistPotential( + units=units, m=ms[i] * u.Msun, c=0.1 * u.pc, origin=q0s[:, i] + ) + comp = gp.CompositePotential(**pots) diff --git a/gala/source/tests/potential/potential/test_cpotential.py b/gala/source/tests/potential/potential/test_cpotential.py new file mode 100644 index 0000000000000000000000000000000000000000..9ee73912042e808ed082764bdbedf19c4e445f68 --- /dev/null +++ b/gala/source/tests/potential/potential/test_cpotential.py @@ -0,0 +1,19 @@ +import astropy.units as u + +from gala.potential import HernquistPotential +from gala.units import UnitSystem + + +def test_replace_units(): + usys1 = UnitSystem([u.kpc, u.Gyr, u.Msun, u.radian]) + usys2 = UnitSystem([u.pc, u.Myr, u.Msun, u.degree]) + + p = HernquistPotential(m=1e10 * u.Msun, c=1.0 * u.kpc, units=usys1) + assert p.parameters["m"].unit == usys1["mass"] + assert p.parameters["c"].unit == usys1["length"] + + p2 = p.replace_units(usys2) + assert p2.parameters["m"].unit == usys2["mass"] + assert p2.parameters["c"].unit == usys2["length"] + assert p.units == usys1 + assert p2.units == usys2 diff --git a/gala/source/tests/potential/potential/test_exp.py b/gala/source/tests/potential/potential/test_exp.py new file mode 100644 index 0000000000000000000000000000000000000000..48c605e2d9cf2c0ad62af2e5b06e062563f5925e --- /dev/null +++ b/gala/source/tests/potential/potential/test_exp.py @@ -0,0 +1,675 @@ +""" +Test the EXP potential +""" + +import os +from pathlib import Path + +import astropy.units as u +import numpy as np +import pytest +from gala._cconfig import EXP_ENABLED +from potential_helpers import PotentialTestBase + +import gala.dynamics as gd +import gala.potential as gp +from gala.potential.potential.builtin import EXPPotential, PyEXPPotential +from gala.units import SimulationUnitSystem +from gala.util import chdir + +this_path = Path(__file__).parent + +# Use in CI to ensure tests aren't silently skipped +FORCE_EXP_TEST = os.environ.get("GALA_FORCE_EXP_TEST", "0") == "1" +FORCE_PYEXP_TEST = os.environ.get("GALA_FORCE_PYEXP_TEST", "0") == "1" + +try: + import pyEXP + + HAVE_PYEXP = True +except ImportError as e: + HAVE_PYEXP = False + if FORCE_PYEXP_TEST: + raise ImportError("pyEXP is required to run pyEXP tests") from e + + +EXP_CONFIG_FILE = this_path / "EXP-Hernquist-basis.yml" +EXP_FIELD_CONFIG_FILE = this_path / "EXP-field-basis.yml" # dummy +EXP_SINGLE_COEF_FILE = this_path / "EXP-Hernquist-single-coefs.hdf5" +EXP_MULTI_COEF_FILE = this_path / "EXP-Hernquist-multi-coefs.hdf5" +EXP_MULTI_COEF_SNAPSHOT_TIME_FILE = ( + this_path / "EXP-Hernquist-multi-coefs-snap-time-Gyr.hdf5" +) +EXP_UNITS = SimulationUnitSystem(mass=1.25234e11 * u.Msun, length=3.845 * u.kpc, G=1) + +# global pytest marker to skip tests if EXP is not enabled +pytestmark = pytest.mark.skipif( + not EXP_ENABLED and not FORCE_EXP_TEST, + reason="requires Gala compiled with EXP support", +) + +# See: generate_exp.py, which generates the basis and coefficients for these tests + + +# base for EXP and PyEXP tests +class CommonEXPTestBase(PotentialTestBase): + tol = 1e-1 # increase tolerance for gradient test + + exp_units = EXP_UNITS + + _tmp = gd.PhaseSpacePosition( + pos=[-8, 0.0, 0.0] * u.kpc, + vel=[0.0, 180, 0.0] * u.km / u.s, + ) + w0 = _tmp.w(exp_units)[:, 0] + show_plots = False + check_finite_at_origin = True + check_zero_at_infinity = False + + num_dx = 1e-3 + skip_hessian = True + + # TODO: deepcopy is not implemented for EXPPotential + @pytest.mark.skip(reason="Not implemented for EXP") + def test_unitsystem(self): + pass + + @pytest.mark.skip(reason="Not implemented for EXP") + def test_hessian(self): + pass + + @pytest.mark.skip(reason="Not implemented for EXP") + def test_against_sympy(self): + pass + + # TODO: constructing EXPPotential(**other.parameters) is not implemented + @pytest.mark.skip(reason="Not implemented for EXP") + def test_compare(self): + pass + + @pytest.mark.skip(reason="Not implemented for EXP") + def test_save_load(self): + pass + + @pytest.mark.skip(reason="Not implemented for EXP") + def test_pickle(self, tmpdir): + pass + + def test_orbit_integration(self, *args, **kwargs): + """Test orbit integration with EXPPotential""" + if self.potential.static: + # Use any time range with a static potential. + time_spec = {} + else: + # With a non-static potential, we need to stay within the time range + time_spec = {"t1": self.potential.tmin_exp, "t2": self.potential.tmax_exp} + return super().test_orbit_integration( + *args, + **kwargs, + **time_spec, + ) + + @pytest.mark.skipif( + not HAVE_PYEXP, + reason="requires pyEXP", + ) + def test_pyexp(self): + """Test against pyEXP""" + + gala_test_x = [1.0, 2.0, -3.0] * u.kpc + exp_test_x = gala_test_x.to_value(self.exp_units["length"]) + + with open(self.EXP_CONFIG_FILE, encoding="utf-8") as fp: + config_str = fp.read() + with chdir(os.path.dirname(self.EXP_CONFIG_FILE)): + exp_basis = pyEXP.basis.Basis.factory(config_str) + exp_coefs = pyEXP.coefs.Coefs.factory(str(self.EXP_COEF_FILE)) + + # Use a snapshot time so that we don't have to rebuild the interpolation + # functionality + t = exp_coefs.Times()[-1] * self.exp_units["time"] + + exp_coefs_at_time = exp_coefs.getCoefStruct(t.to_value(self.exp_units["time"])) + exp_basis.set_coefs(exp_coefs_at_time) + + exp_fields = exp_basis.getFields(*exp_test_x) + exp_dens = exp_fields[2] * self.exp_units["mass density"] + exp_pot = exp_fields[5] * self.exp_units["energy"] / self.exp_units["mass"] + exp_grad = -np.stack(exp_fields[6:9]) * self.exp_units["acceleration"] + + gala_dens = self.potential.density(gala_test_x, t=t) + gala_pot = self.potential.energy(gala_test_x, t=t) + gala_grad = self.potential.gradient(gala_test_x, t=t).reshape(-1) + + assert u.allclose(exp_dens, gala_dens) + assert u.allclose(exp_pot, gala_pot) + assert u.allclose(exp_grad, gala_grad) + + +class EXPTestBase(CommonEXPTestBase): + def setup_method(self): + assert os.path.exists(self.EXP_CONFIG_FILE), "EXP config file does not exist" + assert os.path.exists(self.EXP_COEF_FILE), "EXP coef file does not exist" + + self.potential = EXPPotential( + config_file=self.EXP_CONFIG_FILE, + coef_file=self.EXP_COEF_FILE, + units=self.exp_units, + ) + return super().setup_method() + + +@pytest.mark.skipif( + not HAVE_PYEXP, + reason="requires pyEXP", +) +class PyEXPTestBase(CommonEXPTestBase): + def setup_method(self): + assert os.path.exists(self.EXP_CONFIG_FILE), "EXP config file does not exist" + assert os.path.exists(self.EXP_COEF_FILE), "EXP coef file does not exist" + + with open(self.EXP_CONFIG_FILE) as fp, chdir(self.EXP_CONFIG_FILE.parent): + basis = pyEXP.basis.Basis.factory(fp.read()) + + coefs = pyEXP.coefs.Coefs.factory(str(self.EXP_COEF_FILE)) + + self.potential = PyEXPPotential( + basis=basis, + coefs=coefs, + units=self.exp_units, + ) + return super().setup_method() + + +class TestEXPSingle(EXPTestBase): + EXP_CONFIG_FILE = EXP_CONFIG_FILE + EXP_COEF_FILE = EXP_SINGLE_COEF_FILE + + +class TestEXPMulti(EXPTestBase): + EXP_CONFIG_FILE = EXP_CONFIG_FILE + EXP_COEF_FILE = EXP_MULTI_COEF_FILE + + +class TestPyEXPSingle(PyEXPTestBase): + EXP_CONFIG_FILE = EXP_CONFIG_FILE + EXP_COEF_FILE = EXP_SINGLE_COEF_FILE + + +class TestPyEXPMulti(PyEXPTestBase): + EXP_CONFIG_FILE = EXP_CONFIG_FILE + EXP_COEF_FILE = EXP_MULTI_COEF_FILE + + +def test_exp_unit_tests(): + pot_single = EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_SINGLE_COEF_FILE, + units=EXPTestBase.exp_units, + ) + + pot_single_frozen = EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_SINGLE_COEF_FILE, + snapshot_index=0, + units=EXPTestBase.exp_units, + ) + + pot_multi = EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_MULTI_COEF_FILE, + units=EXPTestBase.exp_units, + ) + + pot_multi_frozen = EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_MULTI_COEF_FILE, + snapshot_index=0, + units=EXPTestBase.exp_units, + ) + + # TODO: not yet implemented + # pot_multi_frozen_arbitrary = EXPPotential( + # config_file=EXP_CONFIG_FILE, + # coef_file=EXP_MULTI_COEF_FILE, + # tmin=0.4 * u.Gyr, + # tmax=0.4 * u.Gyr, + # units=EXPTestBase.exp_units, + # ) + + assert pot_single.static is True + assert pot_single_frozen.static is True + assert pot_multi_frozen.static is True + # assert pot_multi_frozen_arbitrary.static is True + + assert pot_multi.static is False + + test_x = [8.0, 0, 0] * u.kpc + assert u.allclose( + pot_single.energy(test_x, t=0 * u.Gyr), + pot_single.energy(test_x, t=1.4 * u.Gyr), + ) + assert u.allclose( + pot_single_frozen.energy(test_x, t=0 * u.Gyr), + pot_single_frozen.energy(test_x, t=1.4 * u.Gyr), + ) + assert not u.allclose( + pot_multi.energy(test_x, t=0 * u.Gyr), + pot_multi.energy(test_x, t=1.4 * u.Gyr), + ) + assert u.allclose( + pot_multi_frozen.energy(test_x, t=0 * u.Gyr), + pot_multi_frozen.energy(test_x, t=1.4 * u.Gyr), + ) + # assert u.allclose( + # pot_multi_frozen_arbitrary.energy(test_x, t=0. * u.Gyr), + # pot_multi_frozen_arbitrary.energy(test_x, t=1.4 * u.Gyr), + # ) + + # check tmin/tmax + assert u.allclose(pot_multi.tmin_exp, 0.0 * u.Gyr) + assert u.allclose(pot_multi.tmax_exp, 2.0 * u.Gyr) + + +@pytest.mark.skipif(not HAVE_PYEXP, reason="requires pyEXP") +def test_pyexp_unit_tests(): + """Test PyEXPPotential static/dynamic behavior""" + units = EXPTestBase.exp_units + + with open(EXP_CONFIG_FILE) as fp, chdir(EXP_CONFIG_FILE.parent): + basis = pyEXP.basis.Basis.factory(fp.read()) + + coefs_single = pyEXP.coefs.Coefs.factory(str(EXP_SINGLE_COEF_FILE)) + coefs_multi = pyEXP.coefs.Coefs.factory(str(EXP_MULTI_COEF_FILE)) + + pot_single = PyEXPPotential(basis=basis, coefs=coefs_single, units=units) + pot_multi = PyEXPPotential(basis=basis, coefs=coefs_multi, units=units) + + assert pot_single.static is True + assert pot_multi.static is False + + test_x = [8.0, 0, 0] * u.kpc + assert u.allclose( + pot_single.energy(test_x, t=0 * u.Gyr), + pot_single.energy(test_x, t=1.4 * u.Gyr), + ) + assert not u.allclose( + pot_multi.energy(test_x, t=0 * u.Gyr), + pot_multi.energy(test_x, t=1.4 * u.Gyr), + ) + + # check tmin/tmax + assert u.allclose(pot_multi.tmin_exp, 0.0 * u.Gyr) + assert u.allclose(pot_multi.tmax_exp, 2.0 * u.Gyr) + + +def test_multi_different_snapshot_time_unit(): + pot_multi = EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_MULTI_COEF_SNAPSHOT_TIME_FILE, + units=EXPTestBase.exp_units, + snapshot_time_unit=u.Gyr, + ) + x = [8.0, 0, 0] * u.kpc + val0 = pot_multi.energy(x, t=0.0 * u.Gyr) + val1 = pot_multi.energy(x, t=1.0 * u.Gyr) + assert np.isclose(val1 / val0, 3.0) # see: generate_exp.py + + assert u.allclose(pot_multi.tmin_exp, 0.0 * u.Gyr) + assert u.allclose(pot_multi.tmax_exp, 1.0 * u.Gyr) + + +@pytest.mark.skipif(not HAVE_PYEXP, reason="requires pyEXP") +def test_pyexp_multi_different_snapshot_time_unit(): + """Test PyEXPPotential with different snapshot time units""" + units = EXPTestBase.exp_units + + with open(EXP_CONFIG_FILE) as fp, chdir(EXP_CONFIG_FILE.parent): + basis = pyEXP.basis.Basis.factory(fp.read()) + + coefs = pyEXP.coefs.Coefs.factory(str(EXP_MULTI_COEF_SNAPSHOT_TIME_FILE)) + + pot_multi = PyEXPPotential( + basis=basis, coefs=coefs, units=units, snapshot_time_unit=u.Gyr + ) + x = [8.0, 0, 0] * u.kpc + val0 = pot_multi.energy(x, t=0.0 * u.Gyr) + val1 = pot_multi.energy(x, t=1.0 * u.Gyr) + assert np.isclose(val1 / val0, 3.0) # see: generate_exp.py + + assert u.allclose(pot_multi.tmin_exp, 0.0 * u.Gyr) + assert u.allclose(pot_multi.tmax_exp, 1.0 * u.Gyr) + + +def test_cython_exceptions(): + """Test various exceptions propagated from C++""" + units = SimulationUnitSystem(mass=1e11 * u.Msun, length=2.5 * u.kpc, G=1) + with pytest.raises(RuntimeError, match="file"): + EXPPotential( + config_file="nonexistent_config.yml", + coef_file=EXP_SINGLE_COEF_FILE, + snapshot_index=0, + units=units, + ) + + with pytest.raises(RuntimeError, match="index"): + EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_SINGLE_COEF_FILE, + snapshot_index=0xBAD, + units=units, + ) + + with pytest.raises(RuntimeError, match="time"): + EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_MULTI_COEF_FILE, + tmin=0xBAD, + units=units, + ) + + pot = EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_MULTI_COEF_FILE, + units=units, + ) + with pytest.raises(RuntimeError, match="time"): + pot.energy([0, 0, 0], t=float(0xBAD)) + + w0 = gd.PhaseSpacePosition( + pos=[-8, 0.0, 0.0] * u.kpc, + vel=[0.0, 220, 0.0] * u.km / u.s, + ) + with pytest.raises(RuntimeError, match="time"): + gp.Hamiltonian(pot).integrate_orbit( + w0, dt=1.0, t1=float(0xBAD), t2=float(0xBADBAD) + ) + + +@pytest.mark.skipif(not HAVE_PYEXP, reason="requires pyEXP") +def test_pyexp_exceptions(): + """Test various exceptions for PyEXPPotential""" + units = SimulationUnitSystem(mass=1e11 * u.Msun, length=2.5 * u.kpc, G=1) + + with open(EXP_CONFIG_FILE) as fp, chdir(EXP_CONFIG_FILE.parent): + basis = pyEXP.basis.Basis.factory(fp.read()) + coefs = pyEXP.coefs.Coefs.factory(str(EXP_MULTI_COEF_FILE)) + + # Test with None + with pytest.raises(ValueError, match="BiorthBasis"): + PyEXPPotential(basis=None, coefs=None, units=units) + + # Test with a real Coefs object that is empty + empty_coefs = pyEXP.coefs.Coefs(type="empty", verbose=False) + with pytest.raises(RuntimeError, match="Coefs"): + PyEXPPotential(basis=basis, coefs=empty_coefs, units=units) + + # Test with a non-BiorthBasis + with open(EXP_FIELD_CONFIG_FILE) as fp, chdir(EXP_FIELD_CONFIG_FILE.parent): + field_basis = pyEXP.basis.FieldBasis(fp.read()) + with pytest.raises(ValueError, match="BiorthBasis"): + PyEXPPotential(basis=field_basis, coefs=coefs, units=units) + + # Test with valid objects but runtime errors + with open(EXP_CONFIG_FILE) as fp, chdir(EXP_CONFIG_FILE.parent): + basis = pyEXP.basis.Basis.factory(fp.read()) + + pot = PyEXPPotential(basis=basis, coefs=coefs, units=units) + with pytest.raises(RuntimeError, match="time"): + pot.energy([0, 0, 0], t=float(0xBAD)) + + +def _make_exp_pot(config_fn, coef_fn): + return EXPPotential( + config_file=config_fn, + coef_file=coef_fn, + units=EXP_UNITS, + ) + + +def _make_pyexp_pot(config_fn, coef_fn): + return PyEXPPotential( + basis=_load_pyexp_basis(config_fn), + coefs=pyEXP.coefs.Coefs.factory(str(coef_fn)), + units=EXP_UNITS, + ) + + +def _load_pyexp_basis(config_file): + """Helper to load pyEXP basis for parametrized tests""" + if not HAVE_PYEXP: + return None + with open(config_file) as fp, chdir(config_file.parent): + return pyEXP.basis.Basis.factory(fp.read()) + + +potentials_parametrize = pytest.mark.parametrize( + "make_pot", + [ + pytest.param(_make_exp_pot, id="exp"), + pytest.param( + _make_pyexp_pot, + id="pyexp", + marks=pytest.mark.skipif(not HAVE_PYEXP, reason="requires pyEXP"), + ), + ], +) + + +@potentials_parametrize +def test_composite_parametrized(make_pot): + """Test that both EXPPotential and PyEXPPotential can be used in a CompositePotential""" + pot_single = make_pot(EXP_CONFIG_FILE, EXP_SINGLE_COEF_FILE) + + pot_multi = make_pot(EXP_CONFIG_FILE, EXP_MULTI_COEF_FILE) + composite_pot = pot_single + pot_multi + assert isinstance( + composite_pot, gp.potential.ccompositepotential.CCompositePotential + ) + + # Test potential energy addition + test_x = [1.0, 2.0, 3.0] * u.kpc + assert u.allclose( + composite_pot.energy(test_x, t=0 * u.Gyr), + pot_single.energy(test_x, t=0 * u.Gyr) + pot_multi.energy(test_x, t=0 * u.Gyr), + ) + assert u.allclose( + composite_pot.energy(test_x, t=1.4 * u.Gyr), + pot_single.energy(test_x, t=1.4 * u.Gyr) + + pot_multi.energy(test_x, t=1.4 * u.Gyr), + ) + + # Test gradient addition + assert u.allclose( + composite_pot.gradient(test_x, t=0 * u.Gyr), + pot_single.gradient(test_x, t=0 * u.Gyr) + + pot_multi.gradient(test_x, t=0 * u.Gyr), + ) + assert u.allclose( + composite_pot.gradient(test_x, t=1.4 * u.Gyr), + pot_single.gradient(test_x, t=1.4 * u.Gyr) + + pot_multi.gradient(test_x, t=1.4 * u.Gyr), + ) + + # Test orbit integration + w0 = gd.PhaseSpacePosition( + pos=[-8, 0.0, 0.0] * u.kpc, + vel=[0.0, 220, 0.0] * u.km / u.s, + ) + orbit = gp.Hamiltonian(composite_pot).integrate_orbit( + w0, dt=1 * u.Myr, t1=0 * u.Gyr, t2=1 * u.Gyr + ) + assert orbit is not None + assert np.all(np.isfinite(orbit.pos.xyz.value)) + assert np.all(np.isfinite(orbit.vel.d_xyz.value)) + assert np.all(np.isfinite(orbit.t.value)) + + +@potentials_parametrize +def test_replace_units(make_pot): + """Test that replace_units works for both EXPPotential and PyEXPPotential""" + pot = make_pot(EXP_CONFIG_FILE, EXP_SINGLE_COEF_FILE) + + new_units = SimulationUnitSystem( + mass=EXP_UNITS["mass"] * 2.0, + length=EXP_UNITS["length"], + G=1.0, + ) + pot_replaced = pot.replace_units(new_units) + + assert pot_replaced.units == new_units + assert pot_replaced is not pot + + x = [1.0, 2.0, 3.0] * u.kpc + e1 = pot.energy(x) + + x_new = x.to_value(new_units["length"]) * new_units["length"] + e2 = pot_replaced.energy(x_new) + + assert u.isclose(e1, e2 / 2.0) + + +def test_paths(): + """ + Test relative and absolute file paths + """ + + gp.EXPPotential( + config_file=Path(EXP_CONFIG_FILE).absolute(), + coef_file=Path(EXP_SINGLE_COEF_FILE).absolute(), + units=SimulationUnitSystem(mass=1e11 * u.Msun, length=2.5 * u.kpc, G=1), + ) + + with chdir(Path(EXP_CONFIG_FILE).parent): + gp.EXPPotential( + config_file=Path(EXP_CONFIG_FILE).name, + coef_file=Path(EXP_SINGLE_COEF_FILE).name, + units=SimulationUnitSystem(mass=1e11 * u.Msun, length=2.5 * u.kpc, G=1), + ) + + +def test_replicate(): + """Test that replicate works for EXPPotential""" + + units = SimulationUnitSystem(mass=1e11 * u.Msun, length=2.5 * u.kpc, G=1) + pot = EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_MULTI_COEF_FILE, + units=units, + snapshot_index=0, + ) + + pot_replicated = pot.replicate(snapshot_index=1) + + assert pot_replicated.units == pot.units + assert pot_replicated.parameters["snapshot_index"] == 1 + assert pot.parameters["snapshot_index"] == 0 + assert pot_replicated is not pot # should be a new instance + + # Check that the energy at a point is not the same in both instances + x = [1.0, 2.0, 3.0] * u.kpc + e1 = pot.energy(x) + e2 = pot_replicated.energy(x) + assert not u.isclose(e1, e2) + + +@pytest.mark.xfail(reason="replicate not supported by PyEXP") +@pytest.mark.skipif(not HAVE_PYEXP, reason="requires pyEXP") +def test_pyexp_replicate(): + """Test that replicate works for PyEXPPotential using coef_file""" + + units = SimulationUnitSystem(mass=1e11 * u.Msun, length=2.5 * u.kpc, G=1) + with open(EXP_CONFIG_FILE) as fp, chdir(EXP_CONFIG_FILE.parent): + basis = pyEXP.basis.Basis.factory(fp.read()) + coefs = pyEXP.coefs.Coefs.factory(str(EXP_SINGLE_COEF_FILE)) + + pot = PyEXPPotential( + basis=basis, + coefs=coefs, + units=units, + ) + + pot_replicated = pot.replicate(coef_file=str(EXP_MULTI_COEF_FILE)) + + assert pot_replicated.units == pot.units + assert Path(pot_replicated.parameters["coef_file"]) == Path(EXP_MULTI_COEF_FILE) + assert Path(pot.parameters["coef_file"]) == Path(EXP_SINGLE_COEF_FILE) + assert pot_replicated is not pot # should be a new instance + + x = [1.0, 2.0, 3.0] * u.kpc + e1 = pot.energy(x, t=0 * u.Gyr) + e2 = pot_replicated.energy(x, t=0 * u.Gyr) + assert not u.isclose(e1, e2) + + +@pytest.mark.skipif(not HAVE_PYEXP, reason="requires pyEXP") +def test_exp_pyexp_consistency_single(): + """Test that EXPPotential and PyEXPPotential give the same results""" + + # Create EXPPotential + exp_pot = EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_SINGLE_COEF_FILE, + units=EXP_UNITS, + ) + + # Create PyEXPPotential with same data + with open(EXP_CONFIG_FILE) as fp, chdir(EXP_CONFIG_FILE.parent): + basis = pyEXP.basis.Basis.factory(fp.read()) + coefs = pyEXP.coefs.Coefs.factory(str(EXP_SINGLE_COEF_FILE)) + pyexp_pot = PyEXPPotential(basis=basis, coefs=coefs, units=EXP_UNITS) + + x = [1.0, 2.0, 3.0] * u.kpc + + # Compare energy + exp_energy = exp_pot.energy(x) + pyexp_energy = pyexp_pot.energy(x) + assert u.allclose(exp_energy, pyexp_energy) + + # Compare density + exp_density = exp_pot.density(x) + pyexp_density = pyexp_pot.density(x) + assert u.allclose(exp_density, pyexp_density) + + # Compare gradient + exp_gradient = exp_pot.gradient(x) + pyexp_gradient = pyexp_pot.gradient(x) + assert u.allclose(exp_gradient, pyexp_gradient) + + +@pytest.mark.skipif(not HAVE_PYEXP, reason="requires pyEXP") +def test_exp_pyexp_consistency_multi(): + """Test time-dependent consistency between EXPPotential and PyEXPPotential.""" + exp_dynamic = EXPPotential( + config_file=EXP_CONFIG_FILE, + coef_file=EXP_MULTI_COEF_FILE, + units=EXP_UNITS, + ) + + with open(EXP_CONFIG_FILE) as fp, chdir(EXP_CONFIG_FILE.parent): + basis = pyEXP.basis.Basis.factory(fp.read()) + coefs = pyEXP.coefs.Coefs.factory(str(EXP_MULTI_COEF_FILE)) + + pyexp_dynamic = PyEXPPotential( + basis=basis, + coefs=coefs, + units=EXP_UNITS, + ) + + assert exp_dynamic.static is False + assert pyexp_dynamic.static is False + + x = [2.5, -1.5, 0.4] * u.kpc + times = [0.0, 1.4] * u.Gyr + + for t in times: + exp_energy = exp_dynamic.energy(x, t=t) + pyexp_energy = pyexp_dynamic.energy(x, t=t) + exp_density = exp_dynamic.density(x, t=t) + pyexp_density = pyexp_dynamic.density(x, t=t) + exp_gradient = exp_dynamic.gradient(x, t=t) + pyexp_gradient = pyexp_dynamic.gradient(x, t=t) + + assert u.allclose(exp_energy, pyexp_energy) + assert u.allclose(exp_density, pyexp_density) + assert u.allclose(exp_gradient, pyexp_gradient) diff --git a/gala/source/tests/potential/potential/test_interop_agama.py b/gala/source/tests/potential/potential/test_interop_agama.py new file mode 100644 index 0000000000000000000000000000000000000000..cce94400874cde1103fa39c06b1c6ca8be955ad1 --- /dev/null +++ b/gala/source/tests/potential/potential/test_interop_agama.py @@ -0,0 +1,106 @@ +""" +Test converting the builtin Potential classes to Agama +""" + +import astropy.units as u +import numpy as np +import pytest + +from gala._optional_deps import HAS_AGAMA +from gala.potential import JaffePotential, LogarithmicPotential, MiyamotoNagaiPotential +from gala.units import galactic + +if HAS_AGAMA: + from gala.potential.potential.interop import _gala_to_agama + + +def pytest_generate_tests(metafunc): + # Some magic, semi-random numbers below! + gala_pots = [] + other_pots = [] + + if not HAS_AGAMA: + return + + # Test the Gala -> Agama direction + for Potential in _gala_to_agama: + init = {} + len_scale = 1.0 + for k, par in Potential._parameters.items(): + if k == "m": + val = 1.43e10 * u.Msun + elif par.physical_type == "length": + val = 5.12 * u.kpc * len_scale + len_scale *= 0.5 + elif par.physical_type == "dimensionless": + val = 1.0 + elif par.physical_type == "speed": + val = 201.41 * u.km / u.s + else: + continue + + init[k] = val + + pot = Potential(**init, units=galactic) + other_pot = pot.as_interop("agama") + + gala_pots.append(pot) + other_pots.append(other_pot) + + # Make a composite potential too: + gala_pots.append(gala_pots[0] + gala_pots[1]) + other_pots.append(gala_pots[-1].as_interop("agama")) + + test_names = [ + f"{g1.__class__.__name__}:{g2.__class__.__name__}" + for g1, g2 in zip(gala_pots, other_pots) + ] + + metafunc.parametrize( + ["gala_pot", "other_pot"], list(zip(gala_pots, other_pots)), ids=test_names + ) + + +@pytest.mark.skipif( + not HAS_AGAMA, reason="must have agama installed to run these tests" +) +class TestAgamaInterop: + def setup_method(self): + # Test points: + rng = np.random.default_rng(42) + ntest = 4 + + xyz = rng.uniform(-25, 25, size=(3, ntest)) * u.kpc + self.xyz = xyz.copy() + + def test_density(self, gala_pot, other_pot): + gala_val = gala_pot.density(self.xyz).decompose(gala_pot.units).value + other_val = other_pot.density(self.xyz.decompose(gala_pot.units).value.T) + assert np.allclose(gala_val, other_val) + + def test_energy(self, gala_pot, other_pot): + if isinstance(gala_pot, LogarithmicPotential): + # TODO: Agama has an inconsistency with Gala's log potential energy + pytest.skip() + gala_val = gala_pot.energy(self.xyz).decompose(gala_pot.units).value + other_val = other_pot.potential(self.xyz.decompose(gala_pot.units).value.T) + assert np.allclose(gala_val, other_val) + + def test_acc(self, gala_pot, other_pot): + gala_val = gala_pot.acceleration(self.xyz).decompose(gala_pot.units).value + other_val = other_pot.force(self.xyz.decompose(gala_pot.units).value.T).T + assert np.allclose(gala_val, other_val) + + def test_Menc(self, gala_pot, other_pot): + if isinstance( + gala_pot, LogarithmicPotential | JaffePotential | MiyamotoNagaiPotential + ): + # TODO: Agama has an inconsistency with Gala's log potential energy + pytest.skip() + + grid = np.zeros((3, 128)) + grid[0] = np.geomspace(1e-3, 100.0, 128) + + gala_val = gala_pot.mass_enclosed(grid).value + agama_val = other_pot.enclosedMass(grid[0]) + assert np.allclose(gala_val, agama_val) diff --git a/gala/source/tests/potential/potential/test_interop_galpy.py b/gala/source/tests/potential/potential/test_interop_galpy.py new file mode 100644 index 0000000000000000000000000000000000000000..ebc86f92657d2e4dbe84b9335b98d3bab29c46d5 --- /dev/null +++ b/gala/source/tests/potential/potential/test_interop_galpy.py @@ -0,0 +1,212 @@ +""" +Test converting the builtin Potential classes to other packages +""" + +import astropy.units as u +import numpy as np +import pytest +from astropy.constants import G +from astropy.coordinates import CylindricalRepresentation + +import gala.potential as gp +from gala._optional_deps import HAS_GALPY +from gala.potential.potential.interop import galpy_to_gala_potential +from gala.units import galactic + +# Set these globally! +ro = 8.122 * u.kpc +vo = 245 * u.km / u.s + +if HAS_GALPY: + import galpy.potential as galpy_gp + + from gala.potential.potential.interop import _gala_to_galpy, _galpy_to_gala + + +def pytest_generate_tests(metafunc): + # Some magic, semi-random numbers below! + gala_pots = [] + galpy_pots = [] + + if not HAS_GALPY: + return + + # Test the Gala -> Galpy direction + for Potential in _gala_to_galpy: + init = {} + len_scale = 1.0 + for k, par in Potential._parameters.items(): + if k == "m": + val = 1.43e10 * u.Msun + elif par.physical_type == "length": + val = 5.12 * u.kpc * len_scale + len_scale *= 0.5 + elif par.physical_type == "dimensionless": + val = 1.0 + elif par.physical_type == "speed": + val = 201.41 * u.km / u.s + else: + continue + + init[k] = val + + pot = Potential(**init, units=galactic) + galpy_pot = pot.as_interop("galpy", ro=ro, vo=vo) + + gala_pots.append(pot) + galpy_pots.append(galpy_pot) + + # Custom settings in the MN3 potential: + if isinstance(pot, gp.MN3ExponentialDiskPotential): + pot = Potential(**init, units=galactic, sech2_z=False) + galpy_pot = pot.as_interop("galpy", ro=ro, vo=vo) + gala_pots.append(pot) + galpy_pots.append(galpy_pot) + + # Make a composite potential too: + gala_pots.append(gala_pots[0] + gala_pots[1]) + galpy_pots.append([galpy_pots[0], galpy_pots[1]]) + + # Test the Galpy -> Gala direction + for Potential in _galpy_to_gala: + galpy_pot = Potential(ro=ro, vo=vo) # use defaults + + if isinstance(galpy_pot, galpy_gp.MN3ExponentialDiskPotential): + with pytest.warns(): + pot = galpy_to_gala_potential(galpy_pot, ro=ro, vo=vo) + else: + pot = galpy_to_gala_potential(galpy_pot, ro=ro, vo=vo) + + gala_pots.append(pot) + galpy_pots.append(galpy_pot) + + test_names = [ + f"{g1.__class__.__name__}:{g2.__class__.__name__}" + for g1, g2 in zip(gala_pots, galpy_pots) + ] + + metafunc.parametrize( + ["gala_pot", "galpy_pot"], list(zip(gala_pots, galpy_pots)), ids=test_names + ) + + +@pytest.mark.skipif( + not HAS_GALPY, reason="must have galpy installed to run these tests" +) +class TestGalpy: + def setup_method(self): + # Test points: + rng = np.random.default_rng(42) + ntest = 4 + + Rs = rng.uniform(1, 15, size=ntest) * u.kpc + phis = rng.uniform(0, 2 * np.pi, size=ntest) * u.radian + zs = rng.uniform(1, 15, size=ntest) * u.kpc + + cyl = CylindricalRepresentation(Rs, phis, zs) + xyz = cyl.to_cartesian().xyz + + self.Rs = Rs.to_value(ro) + self.phis = phis.to_value(u.rad) + self.zs = zs.to_value(ro) + self.Rpz_iter = np.array(list(zip(self.Rs, self.phis, self.zs))).copy() + + self.xyz = xyz.copy() + + Jac = np.zeros((len(cyl), 3, 3)) + Jac[:, 0, 0] = xyz[0] / cyl.rho + Jac[:, 0, 1] = xyz[1] / cyl.rho + Jac[:, 1, 0] = (-xyz[1] / cyl.rho**2).to_value(1 / ro) + Jac[:, 1, 1] = (xyz[0] / cyl.rho**2).to_value(1 / ro) + Jac[:, 2, 2] = 1.0 + self.Jac = Jac + + def test_density(self, gala_pot, galpy_pot): + if isinstance(gala_pot, gp.LogarithmicPotential): + pytest.skip() + + gala_val = gala_pot.density(self.xyz).to_value(u.Msun / u.pc**3) + galpy_val = np.array( + [ + galpy_gp.evaluateDensities(galpy_pot, R=RR, z=zz, phi=pp) + for RR, pp, zz in self.Rpz_iter + ] + ) + assert np.allclose(gala_val, galpy_val) + + def test_energy(self, gala_pot, galpy_pot): + gala_val = gala_pot.energy(self.xyz).to_value(u.km**2 / u.s**2) + galpy_val = np.array( + [ + galpy_gp.evaluatePotentials(galpy_pot, R=RR, z=zz, phi=pp) + for RR, pp, zz in self.Rpz_iter + ] + ) + + if isinstance(gala_pot, gp.LogarithmicPotential): + # Logarithms are weird + gala_val -= ( + 0.5 * gala_pot.parameters["v_c"] ** 2 * np.log(ro.value**2) + ).to_value((u.km / u.s) ** 2) + + elif isinstance(gala_pot, gp.PowerLawCutoffPotential): + # Gala normalizes the potential to zero at infinity, while Galpy does not. + from scipy.special import gamma + + alpha = gala_pot.parameters["alpha"] + r_c = gala_pot.parameters["r_c"] + m = gala_pot.parameters["m"] + + phi_inf = G * m * gamma(-alpha / 2 + 1) / (r_c * gamma(alpha / 2 + 0.5)) + phi_inf = phi_inf.to(u.km**2 / u.s**2).value + gala_val += phi_inf + + assert np.allclose(gala_val, galpy_val) + + def test_gradient(self, gala_pot, galpy_pot): + gala_grad = gala_pot.gradient(self.xyz) + gala_grad = gala_grad.to_value(u.km / u.s / u.Myr) + + # TODO: Starting with galpy 1.7, this has been failing because of a + # units issue with dPhi/dphi + if isinstance(gala_pot, gp.LongMuraliBarPotential): + pytest.skip() + + galpy_dR = np.array( + [ + -galpy_gp.evaluateRforces(galpy_pot, R=RR, z=zz, phi=pp) + for RR, pp, zz in self.Rpz_iter + ] + ) + galpy_dp = np.array( + [ + -galpy_gp.evaluatephitorques(galpy_pot, R=RR, z=zz, phi=pp) + for RR, pp, zz in self.Rpz_iter + ] + ) + galpy_dp = (galpy_dp * (u.km / u.s) ** 2).to_value(vo**2) + + galpy_dz = np.array( + [ + -galpy_gp.evaluatezforces(galpy_pot, R=RR, z=zz, phi=pp) + for RR, pp, zz in self.Rpz_iter + ] + ) + galpy_dRpz = np.stack((galpy_dR, galpy_dp, galpy_dz), axis=1) + + galpy_grad = np.einsum("nij,ni->nj", self.Jac, galpy_dRpz).T + + assert np.allclose(gala_grad, galpy_grad) + + def test_vcirc(self, gala_pot, galpy_pot): + tmp = self.xyz.copy() + tmp[2] = 0.0 + + if not hasattr(galpy_pot, "vcirc") or isinstance( + gala_pot, gp.LongMuraliBarPotential + ): + pytest.skip() + + gala_vcirc = gala_pot.circular_velocity(tmp).to_value(u.km / u.s) + galpy_vcirc = np.array([galpy_pot.vcirc(R=RR) for RR, *_ in self.Rpz_iter]) + assert np.allclose(gala_vcirc, galpy_vcirc) diff --git a/gala/source/tests/potential/potential/test_io.py b/gala/source/tests/potential/potential/test_io.py new file mode 100644 index 0000000000000000000000000000000000000000..508ccd0b3a418f6ce95d76a3805a4893685bee55 --- /dev/null +++ b/gala/source/tests/potential/potential/test_io.py @@ -0,0 +1,143 @@ +"""test reading/writing potentials to files""" + +from pathlib import Path + +import astropy.units as u +import numpy as np +import pytest +from gala._cconfig import GSL_ENABLED + +from gala.potential import ( + CCompositePotential, + CompositePotential, + IsochronePotential, + KeplerPotential, + LM10Potential, + SCFPotential, +) +from gala.potential.potential.io import load, save +from gala.units import DimensionlessUnitSystem, galactic + +this_path = Path(__file__).parent + + +def test_read_plummer(): + potential = load(this_path / "Plummer.yml") + assert np.allclose(potential.parameters["m"].value, 100000000000.0) + assert np.allclose(potential.parameters["b"].value, 0.26) + assert potential.parameters["b"].unit == u.kpc + + +def test_read_harmonic_oscillator(): + potential = load(this_path / "HarmonicOscillator1D.yml") + assert isinstance(potential.units, DimensionlessUnitSystem) + + +def test_read_composite(): + potential = load(this_path / "Composite.yml") + assert "halo" in potential + assert "disk" in potential + assert str(potential) == "CompositePotential" + assert potential.units["length"] == u.kpc + assert potential.units["speed"] == u.km / u.s + + +def test_read_lm10(): + potential = load(this_path / "lm10.yml") + assert "halo" in potential + assert "disk" in potential + assert str(potential) == "LM10Potential" + assert np.allclose(potential["disk"].parameters["a"].value, 10) + assert np.allclose(potential["disk"].parameters["b"].value, 0.26) + assert np.allclose(potential["disk"].parameters["m"].value, 150000.0) + + +def test_write_isochrone(tmpdir): + tmp_filename = str(tmpdir.join("potential.yml")) + + # try a simple potential + potential = IsochronePotential(m=1e11, b=0.76, units=galactic) + + with open(tmp_filename, "w", encoding="utf-8") as f: + save(potential, f) + + save(potential, tmp_filename) + p = load(tmp_filename) + + +def test_write_isochrone_units(tmpdir): + tmp_filename = str(tmpdir.join("potential.yml")) + + # try a simple potential with units + potential = IsochronePotential(m=1e11 * u.Msun, b=0.76 * u.kpc, units=galactic) + + with open(tmp_filename, "w", encoding="utf-8") as f: + save(potential, f) + + save(potential, tmp_filename) + p = load(tmp_filename) + + +def test_write_lm10(tmpdir): + tmp_filename = str(tmpdir.join("potential.yml")) + + # more complex + potential = LM10Potential(disk={"m": 5e12 * u.Msun}) + potential_default = LM10Potential() + v1 = potential.energy([4.0, 0, 0]) + v2 = potential_default.energy([4.0, 0, 0]) + + with open(tmp_filename, "w", encoding="utf-8") as f: + save(potential, f) + + save(potential, tmp_filename) + p = load(tmp_filename) + assert u.allclose(p["disk"].parameters["m"], 5e12 * u.Msun) + assert u.allclose(v1, p.energy([4.0, 0, 0])) + assert not u.allclose(v2, p.energy([4.0, 0, 0])) + + +def test_write_composite(tmpdir): + tmp_filename = str(tmpdir.join("potential.yml")) + print(tmp_filename) + + # composite potential + potential = CompositePotential( + halo=KeplerPotential(m=1e11, units=galactic), + bulge=IsochronePotential(m=1e11, b=0.76, units=galactic), + ) + save(potential, tmp_filename) + p = load(tmp_filename) + + +def test_write_ccomposite(tmpdir): + tmp_filename = str(tmpdir.join("potential.yml")) + + # composite potential + potential = CCompositePotential( + halo=KeplerPotential(m=1e11, units=galactic), + bulge=IsochronePotential(m=1e11, b=0.76, units=galactic), + ) + save(potential, tmp_filename) + p = load(tmp_filename) + + +def test_units(tmpdir): + import astropy.units as u + + tmp_filename = str(tmpdir.join("potential.yml")) + + # try a simple potential + potential = KeplerPotential(m=1e11, units=[u.kpc, u.Gyr, u.Msun, u.radian]) + save(potential, tmp_filename) + p = load(tmp_filename) + + +@pytest.mark.skipif(not GSL_ENABLED, reason="requires GSL to run this test") +def test_read_write_SCF(tmpdir): + tmp_filename = str(tmpdir.join("potential.yml")) + + # try a basic SCF potential + potential = SCFPotential(100, 1, np.zeros((4, 3, 2)), np.zeros((4, 3, 2))) + save(potential, tmp_filename) + p = load(tmp_filename) diff --git a/gala/source/tests/potential/potential/test_potential_core.py b/gala/source/tests/potential/potential/test_potential_core.py new file mode 100644 index 0000000000000000000000000000000000000000..01292c26cb83cc2103ca51a268d8f823293582d0 --- /dev/null +++ b/gala/source/tests/potential/potential/test_potential_core.py @@ -0,0 +1,167 @@ +""" +Test the core Potential classes +""" + +import astropy.units as u +import numpy as np +import pytest +from astropy.constants import G + +from gala._optional_deps import HAS_MATPLOTLIB +from gala.potential import CompositePotential, PotentialBase, PotentialParameter +from gala.units import UnitSystem + +if HAS_MATPLOTLIB: + import matplotlib.pyplot as plt +else: + plt = None + + +units = [u.kpc, u.Myr, u.Msun, u.radian] +usys = UnitSystem(u.au, u.yr, u.Msun, u.radian) +G = G.decompose(units) + + +def test_new_simple(): + class MyPotential(PotentialBase): + ndim = 1 + + def _energy(self, r, t=0.0): + return -1 / r + + def _gradient(self, r, t=0.0): + return r**-2 + + p = MyPotential() + assert p(0.5) == -2.0 + assert p.energy(0.5) == -2.0 + assert p.acceleration(0.5) == -4.0 + + p(np.arange(0.5, 11.5, 0.5).reshape(1, -1)) + p.energy(np.arange(0.5, 11.5, 0.5).reshape(1, -1)) + p.acceleration(np.arange(0.5, 11.5, 0.5).reshape(1, -1)) + + +class MyPotential(PotentialBase): + m = PotentialParameter("m", "mass") + x0 = PotentialParameter("x0", "length", ndim=1, convert=np.atleast_1d) + filename = PotentialParameter("filename", None, convert=str) + n = PotentialParameter("n", physical_type=None, default=2) + + def _energy(self, x, t): + m = self.parameters["m"].value + x0 = self.parameters["x0"].value + r = np.sqrt(np.sum((x - x0[None]) ** 2, axis=1)) + return -m / r + + def _gradient(self, x, t): + m = self.parameters["m"].value + x0 = self.parameters["x0"].value + x0 = np.atleast_2d(x0).T + r = np.sqrt(np.sum((x - x0) ** 2, axis=0)) + return m * (x - x0) / r**3 + + +def test_init_potential(): + MyPotential(1.5, 1, "blah") + MyPotential(1.5, x0=1, filename="blah") + MyPotential(m=1.5, x0=1, filename="blah") + MyPotential(1.5 * u.Msun, 1 * u.au, "blah", 10, units=usys) + MyPotential(1.5 * u.Msun, x0=1 * u.au, filename="blah", units=usys) + MyPotential(m=1.5 * u.Msun, x0=1 * u.au, filename="blah", units=usys) + MyPotential(m=1.5, x0=1, filename="blah", units="galactic") + + pot = MyPotential(m=1.5 * u.Msun, x0=1 * u.au, n=10, filename="blah", units=usys) + assert pot.parameters["n"] == 10 + assert pot.parameters["filename"] == "blah" + + +def test_repr(): + p = MyPotential(m=1.0e10 * u.Msun, x0=0.0, units=usys) + repr_ = repr(p) + assert repr_.startswith("") + + +@pytest.mark.skipif(not HAS_MATPLOTLIB, reason="matplotlib is required") +def test_plot(): + # TODO: test that the plots are correct?? + p = MyPotential(m=1, x0=[1.0, 3.0, 0.0], units=usys) + f = p.plot_contours(grid=(np.linspace(-10.0, 10.0, 100), 0.0, 0.0), labels=["X"]) + plt.close(f) + # f.suptitle("slice off from 0., won't have cusp") + # f.savefig(os.path.join(plot_path, "contour_x.png")) + + f = p.plot_contours( + grid=( + np.linspace(-10.0, 10.0, 100), + np.linspace(-10.0, 10.0, 100), + 0.0, + ), + cmap="Blues", + ) + plt.close(f) + # f.savefig(os.path.join(plot_path, "contour_xy.png")) + + f = p.plot_contours( + grid=( + np.linspace(-10.0, 10.0, 100), + 1.0, + np.linspace(-10.0, 10.0, 100), + ), + cmap="Blues", + labels=["X", "Z"], + ) + plt.close(f) + # f.savefig(os.path.join(plot_path, "contour_xz.png")) + + +def test_composite(): + p1 = MyPotential(m=1.0, x0=[1.0, 0.0, 0.0], units=usys) + p2 = MyPotential(m=1.0, x0=[-1.0, 0.0, 0.0], units=usys) + + p = CompositePotential(one=p1, two=p2) + assert u.allclose(p.energy([0.0, 0.0, 0.0]), -2 * usys["energy"] / usys["mass"]) + assert u.allclose(p.acceleration([0.0, 0.0, 0.0]), 0.0 * usys["acceleration"]) + + p1 = MyPotential(m=1.0, x0=[1.0, 0.0, 0.0], units=usys) + p2 = MyPotential(m=1.0, x0=[-1.0, 0.0, 0.0], units=[u.kpc, u.yr, u.Msun, u.radian]) + with pytest.raises(ValueError): + p = CompositePotential(one=p1, two=p2) + + p1 = MyPotential(m=1.0, x0=[1.0, 0.0, 0.0], units=usys) + p2 = MyPotential(m=1.0, x0=[-1.0, 0.0, 0.0], units=usys) + p = CompositePotential(one=p1, two=p2) + assert u.au in p.units + assert u.yr in p.units + assert u.Msun in p.units + + +def test_replace_units(): + usys1 = UnitSystem([u.kpc, u.Gyr, u.Msun, u.radian]) + usys2 = UnitSystem([u.pc, u.Myr, u.Msun, u.degree]) + + p = MyPotential(m=1.0e10 * u.Msun, x0=0.0, units=usys1) + assert p.parameters["m"].unit == usys1["mass"] + + p2 = p.replace_units(usys2) + assert p2.parameters["m"].unit == usys2["mass"] + assert p.units == usys1 + assert p2.units == usys2 + + p3 = p.replace_units("galactic") + assert p3.units["length"] == u.kpc + + +def test_replicate(): + usys = UnitSystem([u.kpc, u.Gyr, u.Msun, u.radian]) + R = np.diag(np.arange(3)) + p1 = MyPotential(m=1.0e10 * u.Msun, x0=0.0, units=usys, R=R) + p2 = p1.replicate(m=2e10 * u.Msun, R=None) + + assert p2.R is None + assert np.isclose(p2.parameters["m"].value, 2e10) + assert np.isclose(p2.parameters["x0"].value, p1.parameters["x0"].value) + assert p2.parameters["n"] == p1.parameters["n"] diff --git a/gala/source/tests/potential/potential/test_potential_util.py b/gala/source/tests/potential/potential/test_potential_util.py new file mode 100644 index 0000000000000000000000000000000000000000..5c0ee8884e5e9022bb867586c50b7050870a3414 --- /dev/null +++ b/gala/source/tests/potential/potential/test_potential_util.py @@ -0,0 +1,57 @@ +import pytest +from potential_helpers import PotentialTestBase + +from gala._optional_deps import HAS_SYMPY +from gala.potential.potential.util import from_equation + + +class EquationBase(PotentialTestBase): + def test_plot(self): + # Skip for now because contour plotting assumes 3D + pass + + def test_pickle(self): + # Skip for now because these are not picklable + pass + + def test_save_load(self): + # Skip for now because these can't be written to YAML + pass + + +if HAS_SYMPY: + + class TestHarmonicOscillatorFromEquation(EquationBase): + check_finite_at_origin = False + check_zero_at_infinity = False + skip_density = True + + Potential = from_equation( + "1/2*k*x**2", vars="x", pars="k", name="HarmonicOscillator", hessian=True + ) + potential = Potential(k=1.0) + w0 = [1.0, 0.0] + + def test_derp(self): + import numpy as np + + self.potential.gradient(np.random.random(size=(1, 13))) + + @pytest.mark.skip(reason="to_sympy() not implemented") + def test_against_sympy(self): + pass + + +# class TestHarmonicOscillatorFromEquationUnits(EquationBase): +# Potential = from_equation("1/2*k*x**2", vars="x", pars="k", +# name='HarmonicOscillator', +# hessian=True) +# potential = Potential(k=1., units=solarsystem) +# w0 = [1., 0.] + +# class TestKeplerFromEquation(EquationBase): +# Potential = from_equation("-G*M/sqrt(x**2+y**2+z**2)", vars=["x","y","z"], +# pars=["G","M"], name='Kepler', +# hessian=True) +# potential = Potential(G=1., M=1., units=solarsystem) +# w0 = [1., 0., 0., 0., 6.28, 0.] diff --git a/gala/source/tests/potential/potential/test_special.py b/gala/source/tests/potential/potential/test_special.py new file mode 100644 index 0000000000000000000000000000000000000000..932f6cb4021a7b9a7126ca1b82c864f0469c1c62 --- /dev/null +++ b/gala/source/tests/potential/potential/test_special.py @@ -0,0 +1,52 @@ +""" +Test the special potentials... +""" + +import astropy.units as u +import pytest +from gala._cconfig import GSL_ENABLED +from potential_helpers import CompositePotentialTestBase + +from gala.potential import ( + BovyMWPotential2014, + LM10Potential, + MilkyWayPotential, +) + + +class TestLM10Potential(CompositePotentialTestBase): + potential = LM10Potential() + w0 = [8.0, 0.0, 0.0, 0.0, 0.22, 0.1] + check_zero_at_infinity = False + + num_dx = 1e-3 + skip_density = True + + +class TestLM10Potential2(CompositePotentialTestBase): + potential = LM10Potential(disk={"m": 5e10 * u.Msun}, bulge={"m": 5e10 * u.Msun}) + w0 = [8.0, 0.0, 0.0, 0.0, 0.22, 0.1] + check_zero_at_infinity = False + + num_dx = 1e-3 + skip_density = True + + +class TestMilkyWayPotentialv1(CompositePotentialTestBase): + potential = MilkyWayPotential(version="v1") + w0 = [8.0, 0.0, 0.0, 0.0, 0.22, 0.1] + + +class TestMilkyWayPotentialv2(CompositePotentialTestBase): + potential = MilkyWayPotential(version="v2") + w0 = [8.0, 0.0, 0.0, 0.0, 0.22, 0.1] + + +@pytest.mark.skipif(not GSL_ENABLED, reason="requires GSL to run this test") +class TestBovyMWPotential2014(CompositePotentialTestBase): + w0 = [8.0, 0.0, 0.0, 0.0, 0.22, 0.1] + check_finite_at_origin = False + + def setup_method(self): + self.potential = BovyMWPotential2014() + super().setup_method() diff --git a/gala/source/tests/potential/potential/test_spherical_spline.py b/gala/source/tests/potential/potential/test_spherical_spline.py new file mode 100644 index 0000000000000000000000000000000000000000..4950b458a8cb4c59bc53908198a0b6f8e54dd155 --- /dev/null +++ b/gala/source/tests/potential/potential/test_spherical_spline.py @@ -0,0 +1,153 @@ +""" +Test the builtin CPotential classes + +TODO: +- Test different valid interpolation methods +""" + +import astropy.units as u +import numpy as np +import pytest +from astropy.constants import G +from gala._cconfig import GSL_ENABLED +from potential_helpers import PotentialTestBase + +import gala.potential as gp +from gala.units import galactic + +# global pytest marker to skip tests if GSL is not enabled +pytestmark = pytest.mark.skipif( + not GSL_ENABLED, + reason="requires Gala compiled with GSL support", +) + + +def _analytic_hernquist_potential(): + pot = gp.HernquistPotential(m=1e12 * u.Msun, c=18 * u.kpc, units=galactic) + r_knots = np.geomspace(0.01, 1000, 128) * u.kpc + return r_knots, pot + + +def _make_potential(kind): + r_knots, pot = _analytic_hernquist_potential() + r_xyz = np.stack((r_knots, np.zeros_like(r_knots), np.zeros_like(r_knots)), axis=0) + + if kind == "potential": + vals = pot.energy(r_xyz) + elif kind == "density": + vals = pot.density(r_xyz) + elif kind == "mass": + vals = pot.mass_enclosed(r_xyz) + else: + raise ValueError("Invalid kind") + + return gp.SphericalSplinePotential( + r_knots=r_knots, + spline_values=vals, + spline_value_type=kind, + interpolation_method="cspline", + units=galactic, + ) + + +class SphericalSplineTestBase(PotentialTestBase): + w0 = [8.0, 0.0, 0.0, 0.0, 0.1, 0.1] + # atol = 1e-3 + sympy_density = False + check_finite_at_origin = False + skip_density = True + + def setup_method(self): + self.potential = _make_potential(self._spline_type) + super().setup_method() + + @pytest.mark.skip(reason="Not implemented for SphericalSpline potentials") + def test_against_sympy(self): + pass + + @pytest.mark.skip(reason="Not implemented for SphericalSpline potentials") + def test_numerical_gradient_vs_gradient(self): + pass + + @pytest.mark.parametrize("compare", ["energy", "density", "mass_enclosed"]) + def test_against_hernquist_potential(self, compare): + r_knots, hern = _analytic_hernquist_potential() + + r_grid = ( + np.geomspace(r_knots.min().value * 10, r_knots.max().value / 10, 256) + * r_knots.unit + ) + xyz = np.stack((r_grid, np.zeros_like(r_grid), np.zeros_like(r_grid)), axis=0) + spline_vals = getattr(self.potential, compare)(xyz) + analytic_vals = getattr(hern, compare)(xyz) + + # Different spline types can accurately reproduce different quantities. + # - Potential spline: should accurately reproduce energy, mass, and density + # (because they are computed from derivatives of the spline) + # - Density spline: should accurately reproduce density (by construction). + # - Mass spline: should accurately reproduce mass_enclosed (by construction) and + # density (derivative), but will only match analytic energy up to a + # multiplicative factor. And actually won't match mass_enclosed well because + # of the way that is computed in Gala, using the potential derivative. + if ( + (self._spline_type == "potential" and compare == "energy") + or (self._spline_type == "density" and compare == "density") + or (self._spline_type == "mass" and compare == "mass_enclosed") + ): + # Remove edge effects near the edges of the knot grid -- internally, should + # be robust (256 vs. 64 knots = 4 times as many points, so ignore 8 points + # to ignore the last 2 knots on either end) + assert u.allclose(spline_vals, analytic_vals, rtol=1e-3) + + # This involves the 2nd derivative of the spline, so looser tolerance + elif self._spline_type == "potential" and compare == "density": + assert u.allclose(spline_vals, analytic_vals, rtol=5e-2) + + # The next two cases need to be corrected for the fact that the spline has no + # density inwards or outwards of the knot range + elif self._spline_type == "density" and compare == "energy": + M_inner = hern.mass_enclosed(r_knots.min() * [1.0, 0, 0]) + # The missing inner mass contributes -G*M_inner/r to the potential + + # We also need the constant offset from missing outer mass + E_outer_offset = hern.energy( + r_knots.max() * [1.0, 0, 0] + ) - self.potential.energy(r_knots.max() * [1.0, 0, 0]) + + # Total correction: position-dependent inner mass + constant outer offset + E_correct = -G * M_inner / r_grid + E_outer_offset + + assert u.allclose(spline_vals + E_correct, analytic_vals, rtol=1e-3) + + elif self._spline_type == "density" and compare == "mass_enclosed": + M_correct = hern.mass_enclosed(r_knots.min() * [1.0, 0, 0]) + assert u.allclose(spline_vals + M_correct, analytic_vals, rtol=1e-3) + + # Account for the fact that the spline has zero mass enclosed inside of the knot + # range + elif self._spline_type == "mass" and compare == "energy": + M_inner = hern.mass_enclosed(r_knots.min() * [1.0, 0, 0]) + + # Add constant offset from missing outer mass + E_offset = hern.energy(r_knots.max() * [1.0, 0, 0]) - ( + self.potential.energy(r_knots.max() * [1.0, 0, 0]) + ) + + E_correct = -G * M_inner / r_grid + E_offset + + assert u.allclose(spline_vals + E_correct, analytic_vals, rtol=1e-3) + + else: + assert u.allclose(spline_vals, analytic_vals, rtol=1e-3) + + +class TestSphericalSpline_potential(SphericalSplineTestBase): + _spline_type = "potential" + + +class TestSphericalSpline_density(SphericalSplineTestBase): + _spline_type = "density" + + +class TestSphericalSpline_mass(SphericalSplineTestBase): + _spline_type = "mass" diff --git a/gala/source/tests/potential/potential/test_symmetry.py b/gala/source/tests/potential/potential/test_symmetry.py new file mode 100644 index 0000000000000000000000000000000000000000..17e5a4722196441ef52d5988e6234c066a9e8692 --- /dev/null +++ b/gala/source/tests/potential/potential/test_symmetry.py @@ -0,0 +1,746 @@ +""" +Tests for potential symmetry coordinate support. +""" + +import astropy.units as u +import numpy as np +import pytest + +from gala.potential import HernquistPotential, MiyamotoNagaiPotential, PlummerPotential +from gala.potential.potential.symmetry import ( + CylindricalSymmetry, + SphericalSymmetry, +) + + +class TestSphericalSymmetry: + """Tests for SphericalSymmetry class.""" + + def test_to_cartesian_scalar(self): + """Test conversion of scalar radius to Cartesian.""" + sym = SphericalSymmetry() + r = 5.0 + xyz = sym.to_cartesian(r) + + assert xyz.shape == (3, 1) + assert xyz[0, 0] == 5.0 + assert xyz[1, 0] == 0.0 + assert xyz[2, 0] == 0.0 + + def test_to_cartesian_array(self): + """Test conversion of array of radii to Cartesian.""" + sym = SphericalSymmetry() + r = np.array([1.0, 2.0, 3.0]) + xyz = sym.to_cartesian(r) + + assert xyz.shape == (3, 3) + np.testing.assert_array_equal(xyz[0], r) + np.testing.assert_array_equal(xyz[1], 0.0) + np.testing.assert_array_equal(xyz[2], 0.0) + + def test_to_cartesian_with_units(self): + """Test conversion preserves units.""" + sym = SphericalSymmetry() + r = np.array([1.0, 2.0, 3.0]) * u.kpc + xyz = sym.to_cartesian(r) + + assert xyz.shape == (3, 3) + assert xyz.unit == u.kpc + np.testing.assert_array_equal(xyz[0].value, r.value) + + def test_validate_negative_radius(self): + """Test validation catches negative radii.""" + sym = SphericalSymmetry() + r = np.array([1.0, -2.0, 3.0]) + + with pytest.raises(ValueError, match="non-negative"): + sym.validate_coords(r=r) + + +class TestCylindricalSymmetry: + """Tests for CylindricalSymmetry class.""" + + def test_to_cartesian_scalar(self): + """Test conversion of scalar R, z to Cartesian.""" + sym = CylindricalSymmetry() + R = 5.0 + z = 1.0 + xyz = sym.to_cartesian(R, z) + + assert xyz.shape == (3, 1) + assert xyz[0, 0] == 5.0 + assert xyz[1, 0] == 0.0 + assert xyz[2, 0] == 1.0 + + def test_to_cartesian_array(self): + """Test conversion of arrays to Cartesian.""" + sym = CylindricalSymmetry() + R = np.array([1.0, 2.0, 3.0]) + z = np.array([0.1, 0.2, 0.3]) + xyz = sym.to_cartesian(R, z) + + assert xyz.shape == (3, 3) + np.testing.assert_array_equal(xyz[0], R) + np.testing.assert_array_equal(xyz[1], 0.0) + np.testing.assert_array_equal(xyz[2], z) + + def test_to_cartesian_default_z(self): + """Test that z defaults to zero.""" + sym = CylindricalSymmetry() + R = np.array([1.0, 2.0, 3.0]) + xyz = sym.to_cartesian(R) + + assert xyz.shape == (3, 3) + np.testing.assert_array_equal(xyz[0], R) + np.testing.assert_array_equal(xyz[1], 0.0) + np.testing.assert_array_equal(xyz[2], 0.0) + + def test_to_cartesian_scalar_broadcast(self): + """Test broadcasting of scalar z to array R.""" + sym = CylindricalSymmetry() + R = np.array([1.0, 2.0, 3.0]) + z = 0.5 + xyz = sym.to_cartesian(R, z) + + assert xyz.shape == (3, 3) + np.testing.assert_array_equal(xyz[0], R) + np.testing.assert_array_equal(xyz[2], 0.5) + + def test_to_cartesian_with_units(self): + """Test conversion preserves units.""" + sym = CylindricalSymmetry() + R = np.array([1.0, 2.0, 3.0]) * u.kpc + z = np.array([0.1, 0.2, 0.3]) * u.kpc + xyz = sym.to_cartesian(R, z) + + assert xyz.shape == (3, 3) + assert xyz.unit == u.kpc + + def test_to_cartesian_incompatible_shapes(self): + """Test that incompatible shapes raise an error.""" + sym = CylindricalSymmetry() + R = np.array([1.0, 2.0, 3.0]) + z = np.array([0.1, 0.2]) + + with pytest.raises(ValueError, match="Incompatible shapes"): + sym.to_cartesian(R, z) + + def test_validate_negative_R(self): + """Test validation catches negative R.""" + sym = CylindricalSymmetry() + R = np.array([1.0, -2.0, 3.0]) + + with pytest.raises(ValueError, match="non-negative"): + sym.validate_coords(R=R) + + +class TestSphericalPotentialWithSymmetry: + """Test spherical potentials using symmetry coordinates.""" + + def setup_method(self): + """Set up test potentials.""" + self.pot_hernquist = HernquistPotential( + m=1e10 * u.Msun, c=1 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + self.pot_plummer = PlummerPotential( + m=1e10 * u.Msun, b=1 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + + def test_energy_spherical_vs_cartesian(self): + """Test that energy computed with r matches Cartesian.""" + r = np.array([1.0, 2.0, 5.0, 10.0]) * u.kpc + + # Using spherical coordinate + E_r = self.pot_hernquist.energy(r=r) + + # Using Cartesian (x, 0, 0) + xyz = np.zeros((3, len(r))) * u.kpc + xyz[0] = r + E_xyz = self.pot_hernquist.energy(xyz) + + np.testing.assert_allclose(E_r.value, E_xyz.value, rtol=1e-10) + + def test_gradient_spherical_vs_cartesian(self): + """Test that gradient computed with r matches Cartesian.""" + r = np.array([1.0, 2.0, 5.0, 10.0]) * u.kpc + + # Using spherical coordinate + grad_r = self.pot_hernquist.gradient(r=r) + + # Using Cartesian (x, 0, 0) + xyz = np.zeros((3, len(r))) * u.kpc + xyz[0] = r + grad_xyz = self.pot_hernquist.gradient(xyz) + + np.testing.assert_allclose(grad_r.value, grad_xyz.value, rtol=1e-10) + + def test_density_spherical(self): + """Test density computation with spherical coordinates.""" + r = np.array([0.5, 1.0, 2.0]) * u.kpc + + # Using spherical coordinate + rho_r = self.pot_hernquist.density(r=r) + + # Using Cartesian + xyz = np.zeros((3, len(r))) * u.kpc + xyz[0] = r + rho_xyz = self.pot_hernquist.density(xyz) + + np.testing.assert_allclose(rho_r.value, rho_xyz.value, rtol=1e-10) + + def test_acceleration_spherical(self): + """Test acceleration with spherical coordinates.""" + r = np.array([1.0, 5.0, 10.0]) * u.kpc + + acc_r = self.pot_hernquist.acceleration(r=r) + + xyz = np.zeros((3, len(r))) * u.kpc + xyz[0] = r + acc_xyz = self.pot_hernquist.acceleration(xyz) + + np.testing.assert_allclose(acc_r.value, acc_xyz.value, rtol=1e-10) + + def test_mass_enclosed_spherical(self): + """Test mass_enclosed with spherical coordinates.""" + r = np.array([1.0, 5.0, 10.0]) * u.kpc + + m_r = self.pot_hernquist.mass_enclosed(r=r) + + xyz = np.zeros((3, len(r))) * u.kpc + xyz[0] = r + m_xyz = self.pot_hernquist.mass_enclosed(xyz) + + np.testing.assert_allclose(m_r.value, m_xyz.value, rtol=1e-8) + + def test_circular_velocity_spherical(self): + """Test circular_velocity with spherical coordinates.""" + r = np.array([1.0, 5.0, 10.0]) * u.kpc + + v_r = self.pot_hernquist.circular_velocity(r=r) + + xyz = np.zeros((3, len(r))) * u.kpc + xyz[0] = r + v_xyz = self.pot_hernquist.circular_velocity(xyz) + + np.testing.assert_allclose(v_r.value, v_xyz.value, rtol=1e-8) + + def test_hessian_spherical(self): + """Test hessian with spherical coordinates.""" + r = np.array([1.0, 5.0]) * u.kpc + + H_r = self.pot_hernquist.hessian(r=r) + + xyz = np.zeros((3, len(r))) * u.kpc + xyz[0] = r + H_xyz = self.pot_hernquist.hessian(xyz) + + np.testing.assert_allclose(H_r.value, H_xyz.value, rtol=1e-10) + + def test_scalar_input(self): + """Test that scalar inputs work.""" + r_scalar = 5.0 * u.kpc + + E_scalar = self.pot_hernquist.energy(r=r_scalar) + # Scalars get converted to shape (1,) internally, which is fine + assert E_scalar.shape == (1,) + assert E_scalar.size == 1 + + def test_multiple_potentials(self): + """Test with different spherical potentials.""" + r = np.array([1.0, 5.0, 10.0]) * u.kpc + + E_hern = self.pot_hernquist.energy(r=r) + E_plum = self.pot_plummer.energy(r=r) + + # Just check they run and produce different results + assert not np.allclose(E_hern.value, E_plum.value) + + +class TestCylindricalPotentialWithSymmetry: + """Test cylindrical potentials using symmetry coordinates.""" + + def setup_method(self): + """Set up test potentials.""" + self.pot = MiyamotoNagaiPotential( + m=1e11 * u.Msun, + a=3 * u.kpc, + b=0.3 * u.kpc, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + + def test_energy_cylindrical_vs_cartesian(self): + """Test that energy computed with R,z matches Cartesian.""" + R = np.array([1.0, 5.0, 10.0]) * u.kpc + z = np.array([0.0, 0.5, 1.0]) * u.kpc + + # Using cylindrical coordinates + E_cyl = self.pot.energy(R=R, z=z) + + # Using Cartesian (R, 0, z) + xyz = np.zeros((3, len(R))) * u.kpc + xyz[0] = R + xyz[2] = z + E_xyz = self.pot.energy(xyz) + + np.testing.assert_allclose(E_cyl.value, E_xyz.value, rtol=1e-10) + + def test_energy_default_z(self): + """Test that z defaults to zero.""" + R = np.array([1.0, 5.0, 10.0]) * u.kpc + + # Using R only (z defaults to 0) + E_R = self.pot.energy(R=R) + + # Using R with explicit z=0 + E_Rz = self.pot.energy(R=R, z=0 * u.kpc) + + np.testing.assert_allclose(E_R.value, E_Rz.value, rtol=1e-10) + + def test_gradient_cylindrical(self): + """Test gradient with cylindrical coordinates.""" + R = np.array([5.0, 8.0]) * u.kpc + z = np.array([0.2, 0.5]) * u.kpc + + grad_cyl = self.pot.gradient(R=R, z=z) + + xyz = np.zeros((3, len(R))) * u.kpc + xyz[0] = R + xyz[2] = z + grad_xyz = self.pot.gradient(xyz) + + np.testing.assert_allclose(grad_cyl.value, grad_xyz.value, rtol=1e-10) + + def test_density_cylindrical(self): + """Test density with cylindrical coordinates.""" + R = np.array([5.0, 8.0]) * u.kpc + z = np.array([0.2, 0.5]) * u.kpc + + rho_cyl = self.pot.density(R=R, z=z) + + xyz = np.zeros((3, len(R))) * u.kpc + xyz[0] = R + xyz[2] = z + rho_xyz = self.pot.density(xyz) + + np.testing.assert_allclose(rho_cyl.value, rho_xyz.value, rtol=1e-10) + + def test_acceleration_cylindrical(self): + """Test acceleration with cylindrical coordinates.""" + R = np.array([5.0, 8.0]) * u.kpc + z = np.array([0.0, 0.5]) * u.kpc + + acc_cyl = self.pot.acceleration(R=R, z=z) + + xyz = np.zeros((3, len(R))) * u.kpc + xyz[0] = R + xyz[2] = z + acc_xyz = self.pot.acceleration(xyz) + + np.testing.assert_allclose(acc_cyl.value, acc_xyz.value, rtol=1e-10) + + def test_scalar_broadcast(self): + """Test broadcasting of scalar z to array R.""" + R = np.array([5.0, 8.0, 10.0]) * u.kpc + z = 0.5 * u.kpc + + E = self.pot.energy(R=R, z=z) + assert E.shape == (3,) + + def test_midplane_values(self): + """Test midplane (z=0) calculations.""" + R = np.linspace(1, 15, 10) * u.kpc + + # Explicit z=0 + E_z0 = self.pot.energy(R=R, z=0 * u.kpc) + + # Default z (should be 0) + E_default = self.pot.energy(R=R) + + np.testing.assert_allclose(E_z0.value, E_default.value) + + +class TestErrorHandling: + """Test error handling for symmetry coordinates.""" + + def setup_method(self): + """Set up test potentials.""" + self.pot_spherical = HernquistPotential( + m=1e10 * u.Msun, c=1 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + self.pot_cylindrical = MiyamotoNagaiPotential( + m=1e11 * u.Msun, + a=3 * u.kpc, + b=0.3 * u.kpc, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + + def test_both_cartesian_and_symmetry_coords(self): + """Test that providing both raises an error.""" + xyz = np.array([[1.0], [0.0], [0.0]]) * u.kpc + r = 1.0 * u.kpc + + with pytest.raises(ValueError, match="Cannot provide both"): + self.pot_spherical.energy(xyz, r=r) + + def test_wrong_symmetry_coords_for_potential(self): + """Test using cylindrical coords on spherical potential.""" + R = np.array([1.0, 2.0]) * u.kpc + + # HernquistPotential is spherical, doesn't accept R (without r) + with pytest.raises(ValueError, match="symmetry"): + self.pot_spherical.energy(R=R) + + def test_missing_required_coords(self): + """Test error when no position is provided.""" + with pytest.raises(ValueError, match="Must provide"): + self.pot_spherical.energy() + + def test_no_symmetry_potential(self): + """Test that potentials without symmetry reject symmetry coords.""" + from gala.potential import LogarithmicPotential + + pot = LogarithmicPotential( + v_c=150 * u.km / u.s, + r_h=0, + q1=1, + q2=0.9, + q3=0.8, + phi=0, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + + r = 5.0 * u.kpc + + # LogarithmicPotential is not spherically symmetric (q2, q3 != 1) + # so it shouldn't accept r= coordinate + with pytest.raises(ValueError, match="does not have a defined symmetry"): + pot.energy(r=r) + + +class TestCompositePotentialSymmetry: + """Tests for CompositePotential symmetry inheritance.""" + + def test_all_spherical_components(self): + """Test that composite of spherical potentials is spherical.""" + from gala.potential import CompositePotential + + pot1 = HernquistPotential( + m=1e10 * u.Msun, c=5 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + pot2 = PlummerPotential( + m=5e9 * u.Msun, b=2 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + + comp_pot = CompositePotential(bulge=pot1, halo=pot2) + + # Should inherit spherical symmetry + assert isinstance(comp_pot._symmetry, SphericalSymmetry) + + # Should be able to use r= coordinate + r = np.array([1.0, 5.0, 10.0]) * u.kpc + E = comp_pot.energy(r=r) + + assert E.shape == (3,) + assert E.unit == u.kpc**2 / u.Myr**2 + + # Should equal the sum of components + E_comp = pot1.energy(r=r) + pot2.energy(r=r) + np.testing.assert_allclose(E.value, E_comp.value) + + def test_all_cylindrical_components(self): + """Test that composite of cylindrical potentials is cylindrical.""" + from gala.potential import CompositePotential + + pot1 = MiyamotoNagaiPotential( + m=1e11 * u.Msun, + a=3 * u.kpc, + b=0.3 * u.kpc, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + pot2 = MiyamotoNagaiPotential( + m=5e10 * u.Msun, + a=5 * u.kpc, + b=0.5 * u.kpc, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + + comp_pot = CompositePotential(disk1=pot1, disk2=pot2) + + # Should inherit cylindrical symmetry + assert isinstance(comp_pot._symmetry, CylindricalSymmetry) + + # Should be able to use R=, z= coordinates + R = np.array([4.0, 8.0, 12.0]) * u.kpc + z = np.array([0.1, 0.2, 0.3]) * u.kpc + E = comp_pot.energy(R=R, z=z) + + assert E.shape == (3,) + + # Should equal the sum of components + E_comp = pot1.energy(R=R, z=z) + pot2.energy(R=R, z=z) + np.testing.assert_allclose(E.value, E_comp.value) + + def test_mixed_symmetry_components(self): + """Test that composite with mixed spherical/cylindrical has cylindrical symmetry.""" + from gala.potential import CompositePotential + + pot_sph = HernquistPotential( + m=1e10 * u.Msun, c=5 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + pot_cyl = MiyamotoNagaiPotential( + m=1e11 * u.Msun, + a=3 * u.kpc, + b=0.3 * u.kpc, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + + comp_pot = CompositePotential(bulge=pot_sph, disk=pot_cyl) + + # Mix of spherical and cylindrical -> cylindrical + assert isinstance(comp_pot._symmetry, CylindricalSymmetry) + + # Should accept cylindrical coordinates (R, z) but not spherical (r) + R = 5.0 * u.kpc + E = comp_pot.energy(R=R) # This should work + assert E.shape == (1,) + + # Should reject spherical coordinates + with pytest.raises(ValueError, match="Invalid coordinate"): + comp_pot.energy(r=5.0 * u.kpc) + + def test_symmetry_with_no_symmetry_component(self): + """Test that adding a non-symmetric component removes symmetry.""" + from gala.potential import CompositePotential, LogarithmicPotential + + pot_sph = HernquistPotential( + m=1e10 * u.Msun, c=5 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + pot_log = LogarithmicPotential( + v_c=150 * u.km / u.s, + r_h=0, + q1=1, + q2=0.9, + q3=0.8, + phi=0, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + + comp_pot = CompositePotential(bulge=pot_sph, halo=pot_log) + + # Should have no symmetry (one component has no symmetry) + assert comp_pot._symmetry is None + + def test_empty_composite(self): + """Test that empty composite has no symmetry.""" + from gala.potential import CompositePotential + + comp_pot = CompositePotential() + + assert comp_pot._symmetry is None + + def test_adding_component_updates_symmetry(self): + """Test that symmetry updates when adding components.""" + from gala.potential import CompositePotential + + comp_pot = CompositePotential() + assert comp_pot._symmetry is None + + # Add first spherical component + pot1 = HernquistPotential( + m=1e10 * u.Msun, c=5 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + comp_pot["bulge"] = pot1 + + # Should now have spherical symmetry + assert isinstance(comp_pot._symmetry, SphericalSymmetry) + + # Add second spherical component + pot2 = PlummerPotential( + m=5e9 * u.Msun, b=2 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + comp_pot["halo"] = pot2 + + # Should still have spherical symmetry + assert isinstance(comp_pot._symmetry, SphericalSymmetry) + + # Add cylindrical component + pot3 = MiyamotoNagaiPotential( + m=1e11 * u.Msun, + a=3 * u.kpc, + b=0.3 * u.kpc, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + comp_pot["disk"] = pot3 + + # Should now have cylindrical symmetry (mix of spherical and cylindrical) + assert isinstance(comp_pot._symmetry, CylindricalSymmetry) + + def test_composite_gradient_with_symmetry(self): + """Test that gradients work correctly with composite symmetry.""" + from gala.potential import CompositePotential + + pot1 = HernquistPotential( + m=1e10 * u.Msun, c=5 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + pot2 = PlummerPotential( + m=5e9 * u.Msun, b=2 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + + comp_pot = CompositePotential(bulge=pot1, halo=pot2) + + r = np.array([1.0, 5.0, 10.0]) * u.kpc + grad = comp_pot.gradient(r=r) + + # Should return Cartesian gradient + assert grad.shape == (3, 3) + + # Should equal sum of component gradients + grad_comp = pot1.gradient(r=r) + pot2.gradient(r=r) + np.testing.assert_allclose(grad.value, grad_comp.value) + + def test_composite_all_spherical(self): + """Test that all spherical components -> spherical composite.""" + from gala.potential import CompositePotential + + pot1 = HernquistPotential( + m=1e10 * u.Msun, c=5 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + pot2 = PlummerPotential( + m=5e9 * u.Msun, b=2 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + + comp_pot = CompositePotential(bulge=pot1, halo=pot2) + + # Both components are spherical -> composite is spherical + assert isinstance(comp_pot._symmetry, SphericalSymmetry) + + # Should work with r= coordinate + r = 10 * u.kpc + E = comp_pot.energy(r=r) + assert E.shape == (1,) + + def test_composite_all_cylindrical(self): + """Test that all cylindrical components -> cylindrical composite.""" + from gala.potential import CompositePotential + + pot1 = MiyamotoNagaiPotential( + m=1e11 * u.Msun, + a=3 * u.kpc, + b=0.3 * u.kpc, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + pot2 = MiyamotoNagaiPotential( + m=5e10 * u.Msun, + a=5 * u.kpc, + b=0.5 * u.kpc, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + + comp_pot = CompositePotential(disk1=pot1, disk2=pot2) + + # Both components are cylindrical -> composite is cylindrical + assert isinstance(comp_pot._symmetry, CylindricalSymmetry) + + # Should work with R=, z= coordinates + R = 8 * u.kpc + z = 0.5 * u.kpc + E = comp_pot.energy(R=R, z=z) + assert E.shape == (1,) + + def test_composite_mixed_spherical_cylindrical(self): + """Test that mix of spherical and cylindrical -> cylindrical composite.""" + from gala.potential import CompositePotential + + # Spherical component + pot1 = HernquistPotential( + m=1e10 * u.Msun, c=5 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + # Cylindrical component + pot2 = MiyamotoNagaiPotential( + m=1e11 * u.Msun, + a=3 * u.kpc, + b=0.3 * u.kpc, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + + comp_pot = CompositePotential(bulge=pot1, disk=pot2) + + # Mix of spherical and cylindrical -> composite is cylindrical + assert isinstance(comp_pot._symmetry, CylindricalSymmetry) + + # Should work with R=, z= coordinates + R = 8 * u.kpc + E = comp_pot.energy(R=R) # z defaults to 0 + assert E.shape == (1,) + + # Should NOT work with r= coordinate (not spherical anymore) + with pytest.raises(ValueError, match="Invalid coordinate"): + comp_pot.energy(r=10 * u.kpc) + + def test_composite_with_no_symmetry_component(self): + """Test that any component without symmetry -> no symmetry composite.""" + from gala.potential import CompositePotential, NFWPotential + + # Spherical component + pot1 = HernquistPotential( + m=1e10 * u.Msun, c=5 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + # NFW with flattening (no symmetry) + pot2 = NFWPotential( + m=1e12 * u.Msun, + r_s=20 * u.kpc, + a=1, + b=0.8, + c=0.6, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + + comp_pot = CompositePotential(bulge=pot1, halo=pot2) + + # One component has no symmetry -> composite has no symmetry + assert comp_pot._symmetry is None + + # Should NOT work with symmetry coordinates + with pytest.raises(ValueError, match="does not have a defined symmetry"): + comp_pot.energy(r=10 * u.kpc) + + def test_composite_empty(self): + """Test that empty composite has no symmetry.""" + from gala.potential import CompositePotential + + comp_pot = CompositePotential() + + # Empty composite has no symmetry + assert comp_pot._symmetry is None + + def test_composite_symmetry_updates_on_add(self): + """Test that symmetry is updated when components are added.""" + from gala.potential import CompositePotential + + comp_pot = CompositePotential() + assert comp_pot._symmetry is None + + # Add first spherical component + pot1 = HernquistPotential( + m=1e10 * u.Msun, c=5 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + comp_pot["bulge"] = pot1 + assert isinstance(comp_pot._symmetry, SphericalSymmetry) + + # Add second spherical component + pot2 = PlummerPotential( + m=5e9 * u.Msun, b=2 * u.kpc, units=[u.kpc, u.Myr, u.Msun, u.radian] + ) + comp_pot["halo"] = pot2 + assert isinstance(comp_pot._symmetry, SphericalSymmetry) + + # Add cylindrical component + pot3 = MiyamotoNagaiPotential( + m=1e11 * u.Msun, + a=3 * u.kpc, + b=0.3 * u.kpc, + units=[u.kpc, u.Myr, u.Msun, u.radian], + ) + comp_pot["disk"] = pot3 + # Now should be cylindrical (mix of spherical and cylindrical) + assert isinstance(comp_pot._symmetry, CylindricalSymmetry) diff --git a/gala/source/tests/potential/potential/test_time_interpolated.py b/gala/source/tests/potential/potential/test_time_interpolated.py new file mode 100644 index 0000000000000000000000000000000000000000..c3c3da18737e6dbbb21b654c08e337d06b05228b --- /dev/null +++ b/gala/source/tests/potential/potential/test_time_interpolated.py @@ -0,0 +1,525 @@ +""" +Test suite for TimeInterpolatedPotential implementation. + +Tests the functionality of the TimeInterpolatedPotential class including: +- Constant parameter behavior +- Time-varying parameters +- Rotation matrix interpolation +- Bounds checking +- Vectorized evaluations +""" + +import astropy.units as u +import numpy as np +import pytest +from gala._cconfig import GSL_ENABLED +from scipy.spatial.transform import Rotation as R + +import gala.dynamics as gd +import gala.integrate as gi +import gala.potential as gp +from gala.potential.potential.builtin.time_interpolated import _unsupported_cls +from gala.units import galactic + +# global pytest marker to skip tests if EXP is not enabled +pytestmark = pytest.mark.skipif( + not GSL_ENABLED, + reason="requires Gala compiled with GSL support", +) + + +@pytest.fixture +def time_knots(): + """Standard time knots for testing.""" + return np.linspace(0, 100, 11) * u.Myr + + +@pytest.fixture +def test_positions(): + """Test positions for evaluation.""" + return { + "single": np.array([8.0, 0.0, 0.0]), + "multiple": np.array([[1.0, 2.0, 3.0], [0.0, 1.0, -1.0], [0.0, 0.5, 2.0]]).T, + "grid": np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]).T, + } + + +@pytest.fixture +def potentials(): + """Different potential configurations for testing.""" + time_knots = np.array([0.0, 50.0, 100.0]) * u.Myr + + pots = {} + + # Base potential for comparison + pots["base"] = gp.HernquistPotential( + m=1e12 * u.Msun, c=10.0 * u.kpc, units=galactic + ) + + # Time-interpolated with constant parameters + pots["constant"] = gp.TimeInterpolatedPotential( + potential_cls=gp.HernquistPotential, + time_knots=time_knots, + m=1e12 * u.Msun, + c=10.0 * u.kpc, + units=galactic, + ) + + # Time-interpolated with varying mass + masses = np.array([1e12, 1.5e12, 2e12]) * u.Msun + pots["varying"] = gp.TimeInterpolatedPotential( + potential_cls=gp.HernquistPotential, + time_knots=time_knots, + m=masses, + c=10.0 * u.kpc, + units=galactic, + ) + + return pots + + +def test_constant_parameters_single_value(test_positions, time_knots): + """Test TimeInterpolatedPotential with single constant values.""" + # Single value parameters - pass as scalars, not single-element lists + pot_single = gp.TimeInterpolatedPotential( + potential_cls=gp.HernquistPotential, + time_knots=time_knots, + m=1e12 * u.Msun, # Scalar for constant parameter + c=10.0 * u.kpc, # Scalar for constant parameter + units=galactic, + ) + + # Should work without errors + energy = pot_single.energy(test_positions["single"], t=50 * u.Myr) + assert np.isfinite(energy.value) + + +def test_constant_parameters_array_values(test_positions, time_knots): + """Test TimeInterpolatedPotential with array constant values.""" + # Array of same values + pot_array = gp.TimeInterpolatedPotential( + potential_cls=gp.HernquistPotential, + time_knots=time_knots, + m=np.full(len(time_knots), 1e12) * u.Msun, + c=np.full(len(time_knots), 10.0) * u.kpc, + units=galactic, + ) + + energy = pot_array.energy(test_positions["single"], t=50 * u.Myr) + assert np.isfinite(energy.value) + + +@pytest.mark.parametrize("func_name", ["energy", "gradient", "density"]) +def test_constant_vs_regular_potential(func_name, test_positions, potentials): + """Test that constant-parameter TimeInterpolatedPotential equals regular potential.""" + pos = test_positions["single"] + + val_base = getattr(potentials["base"], func_name)(pos) + val_constant = getattr(potentials["constant"], func_name)(pos, t=25 * u.Myr) + + assert u.allclose(val_base, val_constant, rtol=1e-10) + + +@pytest.mark.parametrize("func_name", ["energy", "gradient", "density"]) +def test_vectorized_evaluation(func_name, test_positions, potentials): + """Test vectorized evaluation with multiple positions.""" + pos = test_positions["multiple"] + + result = getattr(potentials["varying"], func_name)(pos, t=25 * u.Myr) + + # Check shapes + if func_name == "energy": + assert result.shape == (pos.shape[1],) + elif func_name == "gradient": + assert result.shape == pos.shape + elif func_name == "density": + assert result.shape == (pos.shape[1],) + + # Check all values are finite + assert np.all(np.isfinite(result.value)) + + +def test_time_varying_parameters(test_positions, potentials): + """Test that time-varying parameters produce different results at different times.""" + pos = test_positions["single"] + + # Energy should be different at different times due to varying mass + E_start = potentials["varying"].energy(pos, t=0 * u.Myr) + E_mid = potentials["varying"].energy(pos, t=50 * u.Myr) + E_end = potentials["varying"].energy(pos, t=100 * u.Myr) + + # All should be finite + assert np.all(np.isfinite([E_start.value, E_mid.value, E_end.value])) + + # Should be different (mass is increasing, so energy magnitude should increase) + assert not u.allclose(E_start, E_mid, rtol=1e-6) + assert not u.allclose(E_mid, E_end, rtol=1e-6) + + # More massive = more negative energy (for bound systems) + assert E_end < E_mid < E_start + + +def test_interpolation_accuracy(): + """Test that interpolation gives reasonable intermediate values.""" + time_knots = np.array([0.0, 100.0]) * u.Myr + masses = np.array([1e12, 2e12]) * u.Msun + + pot = gp.TimeInterpolatedPotential( + potential_cls=gp.KeplerPotential, + time_knots=time_knots, + m=masses, + units=galactic, + interpolation_method="linear", # Only 2 knots, so must use linear + ) + + pos = np.array([1.0, 0.0, 0.0]) + + # At midpoint, should be close to average mass behavior + E_mid = pot.energy(pos, t=50 * u.Myr) + + # Create reference potential with average mass + pot_ref = gp.KeplerPotential(m=1.5e12 * u.Msun, units=galactic) + E_ref = pot_ref.energy(pos) + + # Should be close (exact for linear interpolation) + assert u.allclose(E_mid, E_ref, rtol=1e-10) + + +def test_rotation_interpolation(): + """Test rotation matrix interpolation.""" + time_knots = np.array([0.0, 100.0]) * u.Myr + + # Create rotation matrices: 0 to 90 degrees around z-axis + angles = np.array([0.0, np.pi / 2]) + rotations = np.array([R.from_rotvec([0, 0, angle]).as_matrix() for angle in angles]) + + pot = gp.TimeInterpolatedPotential( + potential_cls=gp.KeplerPotential, + time_knots=time_knots, + m=1e10 * u.Msun, + R=rotations, + units=galactic, + interpolation_method="linear", # Only 2 knots, so must use linear + ) + + # Test position along x-axis + pos = np.array([1.0, 0.0, 0.0]) + + # At t=0, should behave like no rotation + E_start = pot.energy(pos, t=0 * u.Myr) + pot_ref = gp.KeplerPotential(m=1e10 * u.Msun, units=galactic) + E_ref_start = pot_ref.energy(pos) + assert u.allclose(E_start, E_ref_start, rtol=1e-10) + + # At t=100, should behave like 90-degree rotation + # x-axis position should now be equivalent to y-axis in potential frame + E_end = pot.energy(pos, t=100 * u.Myr) + pos_rotated = np.array([0.0, 1.0, 0.0]) + E_ref_end = pot_ref.energy(pos_rotated) + assert u.allclose(E_end, E_ref_end, rtol=1e-3) + + +def test_bounds_checking(): + """Test that evaluation outside time bounds returns NaN.""" + time_knots = np.array([10.0, 90.0]) * u.Myr + masses = np.array([1e12, 2e12]) * u.Msun + + pot = gp.TimeInterpolatedPotential( + potential_cls=gp.KeplerPotential, + time_knots=time_knots, + m=masses, + units=galactic, + interpolation_method="linear", + ) + + pos = np.array([1.0, 0.0, 0.0]) + + # Outside bounds should return NaN + E_before = pot.energy(pos, t=0 * u.Myr) # Before t_min + E_after = pot.energy(pos, t=100 * u.Myr) # After t_max + + assert np.isnan(E_before.value) + assert np.isnan(E_after.value) + + # Within bounds should work + E_within = pot.energy(pos, t=50 * u.Myr) + assert np.isfinite(E_within.value) + + +def test_gradient_consistency(test_positions, potentials): + """Test gradient accuracy.""" + # Use single position for finite difference test + grad = potentials["varying"].gradient(test_positions["single"], t=50 * u.Myr) + assert np.isfinite(grad).all() + # For a single position, gradient returns (3, 1) shape + assert grad.shape == (3, 1) + + # Test multiple positions + grad_multi = potentials["varying"].gradient( + test_positions["multiple"], t=50 * u.Myr + ) + assert np.isfinite(grad_multi).all() + assert grad_multi.shape == test_positions["multiple"].shape + + +def test_hessian_basic_functionality(test_positions, potentials): + """Test that hessian evaluation works and returns finite values.""" + pos = test_positions["single"] + + # Hessian for constant parameters (no coordinate transformations) + hess = potentials["constant"].hessian(pos, t=25 * u.Myr) + + # Should have correct shape + assert hess.shape == (3, 3, 1) + + # Should return finite values (though coordinate transformation may introduce small errors) + # For now, we just test that it doesn't crash and returns something reasonable + assert not np.all(np.isnan(hess.value)) + + +def test_different_interpolation_types(): + """Test different interpolation methods.""" + time_knots = np.linspace(0, 100, 5) * u.Myr + masses = np.array([1e12, 1.2e12, 1.8e12, 1.5e12, 2e12]) * u.Msun + + for interp_kind in ["linear", "cspline"]: + pot = gp.TimeInterpolatedPotential( + potential_cls=gp.KeplerPotential, + time_knots=time_knots, + m=masses, + interpolation_method=interp_kind, + units=galactic, + ) + + pos = np.array([1.0, 0.0, 0.0]) + + # Should work and give finite results + energy = pot.energy(pos, t=50 * u.Myr) + assert np.isfinite(energy.value) + + gradient = pot.gradient(pos, t=50 * u.Myr) + assert np.all(np.isfinite(gradient.value)) + + +@pytest.mark.parametrize("func_name", ["energy", "gradient", "density", "hessian"]) +def test_timeinterp_same(func_name, potentials): + pos = np.array([8.0, 7.0, 6.0]) + + vals = {} + for name, pot in potentials.items(): + vals[name] = getattr(pot, func_name)(pos, t=0 * u.Myr) + assert u.allclose(vals["base"], vals["varying"]) + assert u.allclose(vals["base"], vals["constant"]) + print(f"{func_name} evaluation: {vals['base']}, {vals['varying']}") + + +@pytest.mark.parametrize("func_name", ["energy", "gradient", "density", "hessian"]) +def test_timeinterp_diff(func_name, potentials): + pos = np.array([8.0, 7.0, 6.0]) + + vals = {} + for name, pot in potentials.items(): + vals[name] = getattr(pot, func_name)(pos, t=75 * u.Myr) + assert u.allclose(vals["base"], vals["constant"]) + assert not u.allclose(vals["base"], vals["varying"]) + print(f"{func_name} evaluation: {vals['base']}, {vals['varying']}") + + +def test_mismatched_parameter_length(): + """Test that mismatched parameter array lengths raise appropriate errors.""" + time_knots = np.linspace(0, 100, 11) * u.Myr + + # Single-element array should raise ValueError (ambiguous: constant or interpolated?) + with pytest.raises(ValueError, match="Parameter 'm' has shape"): + gp.TimeInterpolatedPotential( + potential_cls=gp.HernquistPotential, + time_knots=time_knots, + m=[1e12] * u.Msun, # Length 1, but 11 time knots + c=10.0 * u.kpc, + units=galactic, + ) + + # Wrong-length array should also raise ValueError + with pytest.raises(ValueError, match="Parameter 'm' has shape"): + gp.TimeInterpolatedPotential( + potential_cls=gp.HernquistPotential, + time_knots=time_knots, + m=np.linspace(1e12, 2e12, 5) * u.Msun, # Length 5, but 11 time knots + c=10.0 * u.kpc, + units=galactic, + ) + + +def test_scf_interpolated(): + """ + This is a specialized test for SCFPotential to make sure that everything works ok + with the array parameters. + """ + + t_knots = np.linspace(0, 4, 32) * u.Gyr + + Sjnlm = np.zeros((len(t_knots), 3, 3, 3)) + Sjnlm[:, 0, 0, 0] = np.linspace(1.0, 4.0, len(t_knots)) + Tjnlm = np.zeros_like(Sjnlm) + + comx = np.zeros((len(t_knots), 3)) + comx[:, 0] = np.linspace(0, 2, len(t_knots)) * u.kpc + + # Moving potential and growing mass + pot_interp = gp.TimeInterpolatedPotential( + gp.SCFPotential, + t_knots, + m=1e10, + r_s=1.0, + Snlm=Sjnlm, + Tnlm=Tjnlm, + origin=comx, + units=galactic, + ) + + x0 = [1.0, 0, 0.0] * u.kpc + w0 = gd.PhaseSpacePosition( + pos=x0, vel=[0, 0, 1] * pot_interp.circular_velocity(x0)[0] + ) + + orbit = pot_interp.integrate_orbit( + w0, dt=0.1 * u.Myr, t1=0 * u.Gyr, t2=4 * u.Gyr, Integrator=gi.DOPRI853Integrator + ) + + assert u.isclose(np.mean(orbit.x[:1000]), 0 * u.kpc, atol=0.1 * u.kpc) + assert u.isclose(np.mean(orbit.x[-1000:]), 2 * u.kpc, atol=0.1 * u.kpc) + + assert u.isclose(np.ptp(orbit.z[:1000]), 2 * u.kpc, atol=0.1 * u.kpc) + assert u.isclose(np.ptp(orbit.z[-1000:]), 1 * u.kpc, atol=0.1 * u.kpc) + + +@pytest.mark.xfail( + reason="SphericalSplinePotential does not work with time interpolated wrapper" +) +def test_spherical_spline_time_interpolated(): + """ + This is a specialized test for SphericalSplinePotential to make sure that everything + works ok with the array parameters. + """ + + t_knots = np.linspace(0, 4, 32) * u.Gyr + + r_knots = np.geomspace(1e-1, 1e2, 128) * u.kpc + values = np.zeros((len(t_knots), len(r_knots))) + for i, t in enumerate(t_knots): + tmp = gp.HernquistPotential( + m=1e10 * (1 + t.to_value(u.Gyr)) * u.Msun, c=10 * u.kpc, units=galactic + ) + values[i, :] = tmp.energy(r=r_knots).value + + pot_interp = gp.TimeInterpolatedPotential( + gp.SphericalSplinePotential, + t_knots, + r_knots=r_knots, + spline_values=values, + spline_value_type="potential", + units=galactic, + ) + + x0 = [1.0, 0, 0.0] * u.kpc + w0 = gd.PhaseSpacePosition( + pos=x0, vel=[0, 0, 1] * pot_interp.circular_velocity(x0)[0] + ) + E1 = pot_interp.energy(x0, t=0 * u.Gyr) + E2 = pot_interp.energy(x0, t=2.5 * u.Gyr) + assert E2 < E1 # potential is getting deeper + + orbit = pot_interp.integrate_orbit( + w0, dt=0.1 * u.Myr, t1=0 * u.Gyr, t2=4 * u.Gyr, Integrator=gi.DOPRI853Integrator + ) + + +# Check that all potential classes work with TimeInterpolatedPotential +@pytest.mark.parametrize( + "pot_cls_name", + [ + p + for p in [*gp.potential.builtin.core.__all__, "SCFPotential"] + if p not in _unsupported_cls and p != "TimeInterpolatedPotential" + ], +) +def test_all_builtin_potentials_time_interpolated(pot_cls_name): + pot_cls = getattr(gp, pot_cls_name) + param_names = list(pot_cls._parameters.keys()) + + knots = np.linspace(0, 100, 32) * u.Myr + if param_names[0] == "m": + params_const = {param_names[0]: 1e10} + else: + params_const = {param_names[0]: 1.0} + + params_time = { + param_names[0]: params_const[param_names[0]] * np.linspace(1.0, 4, len(knots)) + } + + for param_name in param_names[1:]: + params_time[param_name] = params_const[param_name] = 1.0 + + # Special case a few potentials: + if pot_cls_name == "SCFPotential": + Sjnlm = np.zeros((len(knots), 4, 4, 4)) + Sjnlm[:, 0, 0, 0] = np.linspace(1.0, 2.0, len(knots)) + Tjnlm = np.zeros_like(Sjnlm) + params_time["Snlm"] = Sjnlm + params_time["Tnlm"] = Tjnlm + params_const["Snlm"] = Sjnlm[0] + params_const["Tnlm"] = Tjnlm[0] + + elif pot_cls_name == "MN3ExponentialDiskPotential": + params_const["h_R"] = params_time["h_R"] = 5.0 + params_const["h_z"] = params_time["h_z"] = 0.5 + + elif pot_cls_name == "StonePotential": + params_const["r_c"] = params_time["r_c"] = 1.0 + params_const["r_h"] = params_time["r_h"] = 10.0 + + elif pot_cls_name in ("EXPPotential", "PyEXPPotential"): + pytest.skip(f"{pot_cls_name} uses its own interpolation") + + pot_const = pot_cls(**params_const, units=galactic) + pot_time = gp.TimeInterpolatedPotential( + potential_cls=pot_cls, + time_knots=knots, + **params_time, + units=galactic, + ) + + x = np.array([1.0, 0.0, 0.0]) * u.kpc + assert u.allclose(pot_const.energy(x, t=0 * u.Myr), pot_time.energy(x, t=0 * u.Myr)) + assert not u.allclose( + pot_const.energy(x, t=0 * u.Myr), pot_time.energy(x, t=50 * u.Myr) + ) + + +def test_integration_outside_interpolation_range(): + """Test that attempting to integrate an orbit outside of the interpolation fails""" + time_knots = np.linspace(0, 100, 11) * u.Myr + + pot = gp.TimeInterpolatedPotential( + potential_cls=gp.HernquistPotential, + time_knots=time_knots, + m=np.linspace(1e11, 1e12, len(time_knots)), + c=10.0 * u.kpc, + units=galactic, + ) + + w0 = gd.PhaseSpacePosition(pos=[8, 0, 0] * u.kpc, vel=[0, 100, 0] * u.km / u.s) + + # Single-element array should raise ValueError (ambiguous: constant or interpolated?) + with pytest.raises(ValueError, match="Integration times must be within the range"): + pot.integrate_orbit( + w0, + t1=0 * u.Myr, + t2=200 * u.Myr, # max time is beyond max time knots time (100 Myr) + dt=1 * u.Myr, + ) + + +# TODO: functional tests +# - Orbit integration with a rotating bar +# - ... diff --git a/gala/source/tests/potential/scf/data/README.md b/gala/source/tests/potential/scf/data/README.md new file mode 100644 index 0000000000000000000000000000000000000000..79622779e49f8a44bfb67e61fd23db91d7a60b18 --- /dev/null +++ b/gala/source/tests/potential/scf/data/README.md @@ -0,0 +1,2 @@ +Note: the data in here was generated using the scripts in +https://github.com/adrn/biff diff --git a/gala/source/tests/potential/scf/data/Snlm-mathematica.csv b/gala/source/tests/potential/scf/data/Snlm-mathematica.csv new file mode 100644 index 0000000000000000000000000000000000000000..bc270f71d57255776b4fd211f731f92589fa6c7b --- /dev/null +++ b/gala/source/tests/potential/scf/data/Snlm-mathematica.csv @@ -0,0 +1,9 @@ +0.9152366869235695,0.,-0.09450062158550537,-3.1190158821107065e-16,0.030783603783174643,8.124049411618912e-15,-0.013512420689246692,-4.207875466038105e-14,0.006866337559153507 +-0.0052054990653439175,0.,-0.004684744344628895,-1.354913191125643e-19,0.0019258260006106988,-7.229431168944954e-17,-0.0008690870281076569,3.031290510878991e-16,0.0004298172601139982 +0.00006499178959724264,0.,-0.002490842172896697,-8.323318468706546e-18,0.0008289440253978623,1.9605436738499557e-16,-0.00033152902434216295,-8.779248689882321e-16,0.0001506309722314064 +-1.0406645452567241e-6,0.,-0.00032581037837221117,-4.1016303404253453e-20,0.0001283047314703666,-4.57452351412777e-18,-0.00005208441081737473,2.8566531035235203e-18,0.000023039206232664963 +1.886362542347212e-8,0.,-0.0002276107685616673,-6.951838679377756e-19,0.00006617213180033839,1.3641697007917025e-17,-0.000023116467882576172,-5.1595217795682355e-17,9.262680411260551e-6 +-3.6836564726891204e-10,0.,-0.00004346235840999843,-3.560144788169866e-22,0.000014715145724316492,-5.610687144071785e-19,-5.188336919654948e-6,1.916669654861419e-18,2.0233170742521486e-6 +7.559881132455772e-12,0.,-0.00003599412558254942,-1.0190039079360521e-19,8.630590080754665e-6,1.6604494844060874e-18,-2.5787743997281766e-6,-4.25916783378157e-18,9.037464998463334e-7 +-1.6076033814313124e-13,0.,-8.516447924267826e-6,5.193050170763415e-21,2.374852909637775e-6,-6.780704184205336e-20,-7.151022836804109e-7,1.8484678338454885e-19,2.4396862431845174e-7 +3.5096996742605785e-15,0.,-7.929167154701838e-6,-1.1898967976889553e-20,1.534413588121971e-6,2.432706965765482e-19,-3.8833806578106424e-7,-6.995743173477491e-19,1.1852338716667918e-7 diff --git a/gala/source/tests/potential/scf/data/computed-hernquist.coeff b/gala/source/tests/potential/scf/data/computed-hernquist.coeff new file mode 100644 index 0000000000000000000000000000000000000000..7aa511b351de4cff2c969360e6ffea26cec15106 --- /dev/null +++ b/gala/source/tests/potential/scf/data/computed-hernquist.coeff @@ -0,0 +1,462 @@ + 0 0 0 -1.0057553547018365 0.0000000000000000 + 0 1 0 -1.7891070404864942E-002 0.0000000000000000 + 0 1 1 -1.8193246827105047E-002 1.2184133088114271E-002 + 0 2 0 -1.7192742805895292E-002 0.0000000000000000 + 0 2 1 -2.5073288272602995E-002 3.7807577650467765E-002 + 0 2 2 -2.1202837742990630E-002 -1.0508646311633467E-003 + 0 3 0 2.0473777361908172E-002 0.0000000000000000 + 0 3 1 3.5381236243487990E-002 -1.6007850427367330E-002 + 0 3 2 1.3646734692758934E-002 -1.1424109562885700E-002 + 0 3 3 -1.1277884062534518E-002 -4.5948554512636793E-003 + 0 4 0 -2.0231706914587966E-002 0.0000000000000000 + 0 4 1 8.1652096738516394E-002 2.0657227962459730E-002 + 0 4 2 -1.9810283609542314E-002 1.1652714166265466E-002 + 0 4 3 6.7708352882058448E-003 8.3868515760214916E-003 + 0 4 4 -4.1331948801702689E-003 1.8490422606209577E-003 + 0 5 0 -6.6348545287153490E-002 0.0000000000000000 + 0 5 1 0.42864969996238728 0.40133763783451598 + 0 5 2 5.0585368043610336E-002 -1.2900436565368040E-002 + 0 5 3 -1.2104702254656523E-002 -8.7357930733069546E-003 + 0 5 4 5.0189026379321190E-003 5.3812079297120338E-003 + 0 5 5 -4.4777248820398498E-004 -7.6997814353392752E-004 + 0 6 0 -2.6888304648622823 0.0000000000000000 + 0 6 1 0.60051877284889754 0.20119706265634710 + 0 6 2 -5.7482914441719372E-002 -0.13661798135726155 + 0 6 3 -7.8861004788618441E-004 6.3050635539543464E-003 + 0 6 4 1.6960304517006323E-003 -6.0912249673521976E-003 + 0 6 5 1.5945971685501334E-003 -4.2598502856598105E-004 + 0 6 6 -1.6813353775817458E-004 1.7131898086264008E-004 + 0 7 0 13.811158590988986 0.0000000000000000 + 0 7 1 -1.1718300649335589 3.7618587820778657 + 0 7 2 -7.9878642443716827E-002 -1.5247868649838989E-002 + 0 7 3 4.8259239440825136E-002 -1.4781806412261487E-002 + 0 7 4 1.0865265692505481E-002 1.3448497858874815E-002 + 0 7 5 8.0100404729614347E-004 -1.0318291535058675E-004 + 0 7 6 -2.3434578758104338E-004 -4.7798041289893951E-004 + 0 7 7 -2.6237861261866509E-005 4.5174558792828142E-005 + 0 8 0 -48.811962699770518 0.0000000000000000 + 0 8 1 -4.8237949965299878 11.474688601012739 + 0 8 2 0.90062654457751001 -1.4168043135355182 + 0 8 3 -7.5018083509998890E-002 6.1881601066405334E-002 + 0 8 4 -1.0711653133808457E-002 1.5012967267755715E-002 + 0 8 5 -5.7446103749590489E-004 1.6301197147289683E-005 + 0 8 6 -1.8762723293062425E-004 -7.4211281802233776E-004 + 0 8 7 -6.0647790161183450E-005 -5.3955646569360521E-006 + 0 8 8 -8.9274717318204841E-006 -3.2270766850726990E-005 + 0 9 0 170.06854674078340 0.0000000000000000 + 0 9 1 -3.3376021167955487 15.816471356609323 + 0 9 2 -7.0648393411958776E-002 0.80971781436633361 + 0 9 3 0.29517426257041135 0.16727208740577165 + 0 9 4 -5.5994355721222133E-002 2.9225930754297077E-002 + 0 9 5 -5.4371394173587488E-003 2.3523684089365333E-005 + 0 9 6 1.1241196032973060E-004 5.4783332842797222E-006 + 0 9 7 -4.5215060644693870E-005 -7.7578037137952756E-005 + 0 9 8 -8.0171533478772851E-006 -1.4114696065449287E-005 + 0 9 9 -2.3107249560628927E-006 8.8045705288970608E-007 + 0 10 0 -86.482288675912542 0.0000000000000000 + 0 10 1 15.745929712916613 -85.762723715978211 + 0 10 2 4.3136027486653985 2.8843860925736204 + 0 10 3 -0.10773843842825899 0.61121023658426377 + 0 10 4 3.5440243782785832E-002 -4.2258961197398047E-002 + 0 10 5 2.4048719359752806E-003 -2.5429660806883213E-003 + 0 10 6 -1.1215794668719180E-003 2.5197472055490356E-005 + 0 10 7 3.3672050815111495E-006 -6.5402035884282994E-005 + 0 10 8 1.4473630550826083E-005 -1.0136224374235120E-005 + 0 10 9 -2.2585995270753232E-006 -1.4126468345673932E-006 + 0 10 10 -2.0461024496303656E-007 3.2967844637118868E-007 + 1 0 0 2.0054711857580621E-003 0.0000000000000000 + 1 1 0 3.2651312093979951E-003 0.0000000000000000 + 1 1 1 -8.8812604118666053E-003 2.9174891982785820E-003 + 1 2 0 -1.7515963250888437E-002 0.0000000000000000 + 1 2 1 -1.1021693869213908E-002 8.8270477777936500E-003 + 1 2 2 3.3768589638711521E-003 -1.8858205790655697E-003 + 1 3 0 -5.6063128063441679E-002 0.0000000000000000 + 1 3 1 8.0107249419955305E-003 3.4709014205334615E-003 + 1 3 2 -6.3671772384033464E-003 5.5327257397455674E-003 + 1 3 3 1.2749736715597438E-003 9.7095338926482273E-004 + 1 4 0 -6.6067321240772955E-002 0.0000000000000000 + 1 4 1 -1.5054142083380058E-002 7.9644572827133765E-003 + 1 4 2 4.6357589946867056E-003 3.1066562666821271E-003 + 1 4 3 -1.9738250920108284E-003 1.0299659038830724E-003 + 1 4 4 2.6404139035008913E-004 -5.3580151855441450E-004 + 1 5 0 -0.21803802034781627 0.0000000000000000 + 1 5 1 1.1612962212665626E-002 -4.1757295841737756E-002 + 1 5 2 1.4949830619379737E-002 7.8152496718431243E-003 + 1 5 3 1.5945301560227616E-003 -1.3545133905164032E-003 + 1 5 4 -4.9125827833410452E-004 4.5229114717772976E-004 + 1 5 5 -1.6720144130699858E-004 -1.5081037110252493E-004 + 1 6 0 -0.46994288629812392 0.0000000000000000 + 1 6 1 7.6761820851795337E-002 -5.8833853693170735E-002 + 1 6 2 2.9924763090051217E-002 -4.3058139729419805E-002 + 1 6 3 -4.1654834792538521E-003 9.6339865321948672E-004 + 1 6 4 4.1229100743666507E-004 1.2947466414568655E-004 + 1 6 5 1.6686385902510924E-004 -1.2032678070367403E-004 + 1 6 6 -9.4709711071150235E-005 -6.8413617759212529E-005 + 1 7 0 -0.43826532810678842 0.0000000000000000 + 1 7 1 0.50974779178249585 9.7981988204996553E-002 + 1 7 2 7.0046490381902027E-002 -5.7056377790759248E-002 + 1 7 3 1.9272407363475643E-003 7.5987794160017070E-003 + 1 7 4 -3.7681854141665934E-004 -2.3388813136402654E-003 + 1 7 5 1.8826316762946892E-006 -2.0660906786478425E-004 + 1 7 6 6.8899472851753906E-005 5.5365921223587779E-005 + 1 7 7 7.8548450368134490E-006 9.6596381747573360E-006 + 1 8 0 -9.0021441051910145 0.0000000000000000 + 1 8 1 -0.70206405574637643 0.53082127933321976 + 1 8 2 -0.12714386266978994 0.12728738278331719 + 1 8 3 -6.2817464468749359E-003 -1.3800630057922628E-002 + 1 8 4 3.5024408879010408E-003 -2.1881416210564691E-003 + 1 8 5 2.2403005602808824E-004 1.5004622297188966E-004 + 1 8 6 -1.7815566872388194E-007 8.6737085619078233E-006 + 1 8 7 4.0510891816112585E-006 5.5193283544984126E-007 + 1 8 8 1.1417336360595196E-006 2.3696183623569033E-006 + 1 9 0 27.963677940030244 0.0000000000000000 + 1 9 1 -5.8235360529465172 3.3148192826988643 + 1 9 2 0.21779484940261662 0.21487059168243516 + 1 9 3 -3.1556754975977763E-002 1.9434069652818305E-002 + 1 9 4 8.8438899056521875E-003 -9.0448893433216443E-004 + 1 9 5 -8.2652209100941268E-004 -1.2010087207715925E-003 + 1 9 6 5.8921667687280397E-005 -7.8771451173018862E-005 + 1 9 7 1.4271687970736716E-005 9.0019509848749499E-006 + 1 9 8 -7.4214301184645698E-007 4.2783416257247134E-007 + 1 9 9 -1.4009816465657095E-007 1.8859135755697387E-007 + 1 10 0 -53.695174506634764 0.0000000000000000 + 1 10 1 -13.607333931537276 18.236812211970033 + 1 10 2 -0.10139172900322999 -1.7229622944398342 + 1 10 3 -7.6006358804631843E-002 6.0566167820900553E-002 + 1 10 4 -1.1189263735165661E-002 1.5992808432118245E-002 + 1 10 5 6.8466540014839911E-004 1.7513129861134711E-003 + 1 10 6 -2.0584150589401091E-004 -5.8433178569947160E-005 + 1 10 7 -1.7666891117383476E-005 7.1912551688311656E-006 + 1 10 8 2.6562410241865357E-007 -6.4116965455996811E-007 + 1 10 9 5.2801810300004426E-009 6.8781334072961465E-008 + 1 10 10 6.5991717458725683E-009 1.1917094405454564E-007 + 2 0 0 5.0826639833751125E-004 0.0000000000000000 + 2 1 0 -1.7618608823290107E-003 0.0000000000000000 + 2 1 1 5.2696242598384811E-003 9.8764484129597015E-004 + 2 2 0 1.9586465921677507E-003 0.0000000000000000 + 2 2 1 3.9617878107633727E-003 -1.2034254604959397E-003 + 2 2 2 1.0789906991743442E-003 1.1277381631101648E-003 + 2 3 0 -8.2316594463186718E-003 0.0000000000000000 + 2 3 1 3.3085859489090049E-003 3.5832231545676256E-003 + 2 3 2 -1.7434948826152105E-004 -2.2813221056885904E-003 + 2 3 3 3.2024777528269400E-005 -1.6041973251717717E-005 + 2 4 0 -5.3593933429141143E-003 0.0000000000000000 + 2 4 1 -8.0020113889600220E-003 7.0540917884718887E-003 + 2 4 2 -1.5904739533246853E-003 1.3881617084483785E-003 + 2 4 3 7.6083871900559723E-005 -4.2113010138628395E-004 + 2 4 4 -7.2568116531472284E-005 -1.5601709730060471E-004 + 2 5 0 -8.9575374750130360E-002 0.0000000000000000 + 2 5 1 -2.0431581699054758E-003 1.6106379755626522E-003 + 2 5 2 -1.2169158390166151E-003 1.2251561343717460E-003 + 2 5 3 -2.5258165857432820E-004 1.4372127421155524E-004 + 2 5 4 -5.0240424740963924E-005 8.9299437724705576E-005 + 2 5 5 4.7257956328708598E-005 1.3919066961644476E-004 + 2 6 0 0.18215637265919721 0.0000000000000000 + 2 6 1 -2.1548394902326011E-002 6.2199305679148366E-002 + 2 6 2 -1.2836177747883730E-002 4.9680145732873887E-003 + 2 6 3 -7.6918762854353550E-005 -4.4469627433309223E-004 + 2 6 4 1.2168495933228140E-005 2.3607381497198719E-004 + 2 6 5 5.2636991950403245E-007 3.7504958770271087E-005 + 2 6 6 1.7647026545709172E-007 -5.3291582328560799E-006 + 2 7 0 -0.64489157143908749 0.0000000000000000 + 2 7 1 -5.6826383495659535E-002 -0.10048145611064689 + 2 7 2 1.1426895059755368E-002 8.9791287462215666E-003 + 2 7 3 -4.9799652125295313E-004 -9.0414391356977456E-004 + 2 7 4 2.6821371774239850E-004 1.0899781557050416E-004 + 2 7 5 -4.7583273954674574E-005 4.1479128043522386E-006 + 2 7 6 -3.1527862322264226E-006 -2.0613711661277754E-006 + 2 7 7 -2.3251577116424053E-006 -5.0306441075092283E-006 + 2 8 0 -0.56394027304910099 0.0000000000000000 + 2 8 1 -3.6147946002741078E-002 -0.18248733060785738 + 2 8 2 9.8061257430459292E-003 -6.0715282876406566E-003 + 2 8 3 1.4785157666433102E-003 1.4067893183848286E-003 + 2 8 4 2.9259781159429829E-004 2.0034618163933114E-004 + 2 8 5 4.6374232357746732E-005 1.4780195135567253E-005 + 2 8 6 -1.3961666062827923E-005 -1.1471801443986313E-005 + 2 8 7 -2.0906131015735951E-007 2.6182799620578290E-006 + 2 8 8 -5.8128469081483722E-007 -4.2082541362486968E-007 + 2 9 0 -2.9657097157979018 0.0000000000000000 + 2 9 1 0.22521483260041697 -0.28321269890566836 + 2 9 2 -3.3699872098964308E-002 8.3084283456399018E-003 + 2 9 3 5.8810581058659425E-003 5.6236373677824711E-003 + 2 9 4 -3.8689161073406840E-004 6.1090472320173708E-005 + 2 9 5 -1.1142323238559402E-004 2.2905240897229745E-004 + 2 9 6 1.0051233502651782E-005 -2.0307958953151089E-005 + 2 9 7 3.2866827245466963E-007 5.8280571022530475E-007 + 2 9 8 1.6202160295136399E-007 7.5899188377050548E-007 + 2 9 9 -8.1995882679698939E-008 -1.4872099358928328E-007 + 2 10 0 -17.019658693457252 0.0000000000000000 + 2 10 1 0.37609595831769205 1.7826514712463488 + 2 10 2 -7.8623896103839231E-003 -0.34048189805577878 + 2 10 3 3.4359242890201050E-002 2.3556509384315982E-002 + 2 10 4 2.9085081748791443E-003 -4.2695243782586182E-003 + 2 10 5 1.4563405113293611E-004 1.4548720843511687E-005 + 2 10 6 -1.1480196541649275E-006 -2.3265777685995576E-005 + 2 10 7 1.2677445577890437E-006 -6.0289465041338076E-006 + 2 10 8 5.5639056070396330E-007 -5.8278830787630587E-007 + 2 10 9 -5.1547906091423617E-008 -6.6189746056487467E-008 + 2 10 10 -1.7691847253559642E-008 -5.5577566297297631E-009 + 3 0 0 -5.4580468227726593E-004 0.0000000000000000 + 3 1 0 2.0851484890031993E-003 0.0000000000000000 + 3 1 1 -2.4769668393453542E-003 9.4431919689556755E-004 + 3 2 0 -3.5623196658449027E-004 0.0000000000000000 + 3 2 1 -6.7758521932777163E-004 5.3395577971478831E-004 + 3 2 2 -5.9324373728215094E-004 -1.9885909363590593E-004 + 3 3 0 3.8817062272831129E-003 0.0000000000000000 + 3 3 1 -1.4916153884780012E-003 1.9936513227521234E-004 + 3 3 2 6.7141240084761045E-005 1.5726170517147979E-004 + 3 3 3 1.4289461129553312E-004 -4.7780349136610119E-005 + 3 4 0 7.0940264605314348E-003 0.0000000000000000 + 3 4 1 -3.1669446074704837E-003 -2.3360342859557890E-004 + 3 4 2 8.1813650169585051E-005 3.1055072425107043E-005 + 3 4 3 8.0004496766606517E-006 -1.0943213029433051E-004 + 3 4 4 5.6420598531525647E-006 1.0328243809807484E-004 + 3 5 0 2.0132532647869107E-002 0.0000000000000000 + 3 5 1 -2.6884528746379931E-003 -3.2314447777165101E-003 + 3 5 2 5.7022310743155206E-004 -2.9542079689964882E-004 + 3 5 3 -1.0882181742801795E-004 -1.8564432845700316E-004 + 3 5 4 -1.5817359000283349E-005 -4.4316625763224042E-005 + 3 5 5 -7.9602538386695239E-006 -2.6455901535865390E-005 + 3 6 0 -0.12093449207326655 0.0000000000000000 + 3 6 1 9.1520163944444777E-003 6.9812960204934374E-003 + 3 6 2 -3.0451188772477497E-004 1.1296051544515951E-003 + 3 6 3 -6.4086555838894382E-004 -2.2675315516622332E-004 + 3 6 4 -6.5518186503812833E-005 -1.0581704312720306E-004 + 3 6 5 2.9381473471085454E-006 5.8973834134786654E-006 + 3 6 6 -5.3189453812453547E-007 4.0311294286824424E-006 + 3 7 0 0.20768311624105895 0.0000000000000000 + 3 7 1 1.1358112298521727E-002 1.0798749258892867E-002 + 3 7 2 5.0546349210895466E-004 -9.2136429680196076E-004 + 3 7 3 1.3809335305426415E-004 -1.1279953772012949E-004 + 3 7 4 -7.3233435092467136E-005 -2.7975229208120974E-005 + 3 7 5 1.5138375734760376E-005 8.9534844770540746E-007 + 3 7 6 -7.7429291128525466E-008 6.7582615678225363E-007 + 3 7 7 9.7366251617983999E-007 -3.4112900154421662E-007 + 3 8 0 -7.1313412426683431E-002 0.0000000000000000 + 3 8 1 1.2973695591204800E-003 -0.10550509033073990 + 3 8 2 -4.3095198837454383E-003 -2.9184686792466242E-003 + 3 8 3 -1.0376949859877823E-003 -4.0512659542084209E-004 + 3 8 4 6.0481430247696336E-005 -1.9189155140808154E-004 + 3 8 5 -8.4017247118607565E-006 -2.3472279040758207E-005 + 3 8 6 5.4007083767630478E-007 -3.9467655245126429E-006 + 3 8 7 1.0991315796985149E-007 1.3771240022512028E-006 + 3 8 8 1.0247610616400785E-007 6.1135106180793120E-008 + 3 9 0 1.1361559153066956 0.0000000000000000 + 3 9 1 0.11607312293410108 -1.4482984940178184E-002 + 3 9 2 5.2710669167170543E-002 -7.8644390905955944E-003 + 3 9 3 1.6218454976482247E-003 8.2970041199874160E-004 + 3 9 4 1.1855963286438422E-004 3.1844505234550173E-004 + 3 9 5 3.9877860567764718E-005 2.4686938886689525E-005 + 3 9 6 -1.0067824300889677E-006 7.1198094369512662E-006 + 3 9 7 -8.1524885978321096E-007 9.7195878459420861E-007 + 3 9 8 -1.3576834983270660E-007 -8.2198085523178036E-008 + 3 9 9 -2.0305478920150071E-008 3.3258620576885339E-008 + 3 10 0 0.72729134015939345 0.0000000000000000 + 3 10 1 -0.24859073180775679 -0.40718738574487567 + 3 10 2 -7.3937373493680927E-003 -7.9633474256919461E-004 + 3 10 3 -8.4384576955973782E-003 2.0549461243836459E-003 + 3 10 4 3.9039382636320107E-006 2.5744749139578642E-004 + 3 10 5 -3.5265917428210253E-005 3.6120800717323324E-005 + 3 10 6 4.7923746921407991E-006 4.5237648347008324E-006 + 3 10 7 -9.7956498765850928E-008 9.7583042433143454E-007 + 3 10 8 -1.5706315398945146E-007 5.9968986778745444E-008 + 3 10 9 -1.4571042706271957E-008 -2.0327914497898411E-008 + 3 10 10 7.2600806281739646E-009 3.9141745276629965E-009 + 4 0 0 1.2484587562257353E-004 0.0000000000000000 + 4 1 0 -5.9953528959615998E-004 0.0000000000000000 + 4 1 1 1.1417600927261752E-004 -7.1966090718179030E-004 + 4 2 0 3.7915777185961311E-005 0.0000000000000000 + 4 2 1 -3.6355206897553791E-004 -1.5612562009393785E-004 + 4 2 2 3.5088022946385508E-005 -1.2921470695334409E-004 + 4 3 0 -5.5574038778131031E-004 0.0000000000000000 + 4 3 1 5.5294732133218539E-004 2.5316232407164558E-004 + 4 3 2 1.2509271328916559E-004 -6.0944543490224638E-005 + 4 3 3 -6.3405822827418160E-005 -6.8860369298490227E-005 + 4 4 0 -3.2971523114643994E-004 0.0000000000000000 + 4 4 1 1.9401173071355391E-003 -5.1997863290647098E-004 + 4 4 2 -1.2671877207817622E-004 -4.0083894166863161E-005 + 4 4 3 2.4965787105451009E-005 1.3952680284077979E-005 + 4 4 4 -1.6320734027494639E-005 -5.7237839619370631E-006 + 4 5 0 -4.3124158339884791E-004 0.0000000000000000 + 4 5 1 -7.5816752852977390E-004 1.1142424727643423E-003 + 4 5 2 3.8737977348453415E-005 4.8979049649685886E-004 + 4 5 3 7.7792825247887485E-005 -4.6082776992966973E-005 + 4 5 4 1.0323202497169216E-005 8.0348936963051105E-006 + 4 5 5 7.9653670278947141E-006 1.2131707217324661E-005 + 4 6 0 -9.1041703194686355E-003 0.0000000000000000 + 4 6 1 3.9665569459135189E-003 -1.0631101782605009E-003 + 4 6 2 1.6495282780452519E-003 2.6985620844805137E-004 + 4 6 3 1.3802922474193916E-004 6.4298433441222923E-005 + 4 6 4 -4.1892564729219796E-006 6.8406831998583935E-006 + 4 6 5 -1.5426829274407850E-006 2.3962463857197790E-006 + 4 6 6 6.9860029657367846E-007 -6.8135418477819329E-007 + 4 7 0 1.2646590467261796E-002 0.0000000000000000 + 4 7 1 -1.1574555150840880E-002 7.6526742914657334E-003 + 4 7 2 -3.3186356958028008E-004 -7.4198767449075356E-005 + 4 7 3 2.8197792031885887E-004 2.8927236201441818E-005 + 4 7 4 -1.1894023258596546E-005 6.1977604519384159E-006 + 4 7 5 -4.6764340863944565E-006 -2.7871679739323016E-006 + 4 7 6 -5.8749614152267271E-008 -8.6886870080538984E-008 + 4 7 7 -1.1746337573491274E-007 -5.6034279225728573E-008 + 4 8 0 0.10627046672017494 0.0000000000000000 + 4 8 1 -1.1253542775643671E-002 1.0049952725841981E-002 + 4 8 2 9.6533251119131124E-004 1.8160493176711417E-003 + 4 8 3 4.9080898210381529E-004 4.3889412330409513E-004 + 4 8 4 -3.6099158709637569E-005 2.1572776888377915E-005 + 4 8 5 -2.8488225679804872E-006 -1.7294278488850445E-006 + 4 8 6 2.4173612378482771E-007 -1.0853205222996012E-006 + 4 8 7 1.3008624291515734E-007 -2.4671967789922889E-007 + 4 8 8 -3.2428478022940911E-009 3.5262983356931029E-008 + 4 9 0 0.59375832323625033 0.0000000000000000 + 4 9 1 -6.0547181119962781E-002 -3.7819610821027250E-002 + 4 9 2 -3.3488223727392678E-003 -1.5439741624893838E-003 + 4 9 3 -7.3709658393858967E-004 -3.0579980503683677E-004 + 4 9 4 -4.6458680798684640E-005 -1.0353000889259050E-005 + 4 9 5 7.2606279272014335E-006 4.7395252976028061E-006 + 4 9 6 -4.5220844992179947E-007 -1.9904115745064303E-006 + 4 9 7 1.0684491111402539E-007 -9.6838922277486848E-008 + 4 9 8 4.1393050913221936E-008 -1.3152786095509776E-008 + 4 9 9 -1.4096894969767243E-008 5.7267000679214719E-009 + 4 10 0 1.7925487777522358 0.0000000000000000 + 4 10 1 4.5704516058297152E-002 9.8295685558846380E-002 + 4 10 2 2.9647990039205478E-002 1.6698691006334393E-002 + 4 10 3 3.7872639443375835E-004 -6.5729195176315999E-004 + 4 10 4 2.2492168398342198E-004 -2.0364362279124052E-004 + 4 10 5 3.0584786435639623E-005 -8.3459378032250829E-006 + 4 10 6 1.6432019821517735E-007 -2.1864624282850920E-006 + 4 10 7 1.6238934844727705E-007 -9.6427905473114125E-008 + 4 10 8 3.4788486367407247E-008 -2.0288106333694510E-009 + 4 10 9 7.4909950077130288E-010 3.4373291718023413E-009 + 4 10 10 -3.7530663665197426E-010 -1.4954073110112257E-009 + 5 0 0 -4.0822087266337619E-005 0.0000000000000000 + 5 1 0 -1.7022706381283111E-005 0.0000000000000000 + 5 1 1 8.5034850624346025E-005 4.2380244886740736E-004 + 5 2 0 -1.6477180497361866E-004 0.0000000000000000 + 5 2 1 6.9173895145727043E-005 1.4304179887470622E-004 + 5 2 2 1.0702779312866454E-004 -4.6275914564313868E-005 + 5 3 0 -7.7762457450370186E-004 0.0000000000000000 + 5 3 1 -3.8663344552653244E-004 1.6696241585568744E-004 + 5 3 2 -2.9788240088983606E-005 5.1309301239127589E-005 + 5 3 3 1.9262693016345563E-005 -1.3383464042133870E-006 + 5 4 0 6.0212822155173795E-004 0.0000000000000000 + 5 4 1 -1.9344442293847968E-004 1.5700775582389598E-004 + 5 4 2 9.7174433034294182E-006 6.0634073523522796E-005 + 5 4 3 -4.0219162427522964E-006 -4.5337893329079384E-006 + 5 4 4 8.7880352988149045E-006 8.4923495315274290E-006 + 5 5 0 1.0884089180952547E-003 0.0000000000000000 + 5 5 1 6.9258760321410330E-005 -8.8219651047894386E-004 + 5 5 2 1.6499818742466121E-004 -9.6763590716397830E-005 + 5 5 3 -2.6171838359403089E-005 1.8415980360295110E-005 + 5 5 4 2.9412463235038723E-006 -8.9192723830518526E-006 + 5 5 5 -1.4955563083553196E-006 -1.1872320664493684E-006 + 5 6 0 -5.7882602032597284E-003 0.0000000000000000 + 5 6 1 -3.0139556422554476E-003 9.9599252641289896E-004 + 5 6 2 -3.5928532570461503E-006 -1.9475233944383583E-004 + 5 6 3 -1.5862127314981486E-005 3.7606094261361573E-005 + 5 6 4 4.4201003877605552E-006 8.3455750660105550E-006 + 5 6 5 -2.6606252427818036E-006 -2.6634823919016786E-007 + 5 6 6 1.0339593192788806E-007 5.2174951804671151E-007 + 5 7 0 -8.8942785700655267E-004 0.0000000000000000 + 5 7 1 1.6387227379278091E-003 -1.9699648596504171E-003 + 5 7 2 -3.1079086242941735E-004 4.3056809886668887E-005 + 5 7 3 2.2393516537279953E-005 1.9131332774575786E-005 + 5 7 4 1.9739927051232875E-006 -3.3617896428916640E-006 + 5 7 5 4.2261507360930524E-007 1.3344674098567231E-006 + 5 7 6 -2.9803805367072555E-007 4.5910162644948010E-008 + 5 7 7 -6.8229126110466062E-009 8.4337520358242874E-008 + 5 8 0 -5.3725852544345740E-002 0.0000000000000000 + 5 8 1 6.9926408344513254E-003 -3.8750156112191830E-004 + 5 8 2 -7.3790624778569027E-004 -9.3522581325828171E-004 + 5 8 3 -1.8992226424095703E-004 -2.4616920088563451E-006 + 5 8 4 1.6033847493752185E-006 3.8628616271540428E-006 + 5 8 5 2.2589557966150201E-006 -1.0379474809798478E-006 + 5 8 6 -3.5426193852997648E-007 -1.6758475197670842E-007 + 5 8 7 -9.6688469185956220E-009 1.0532522358441312E-008 + 5 8 8 1.0035574863421382E-008 -8.4514155417755334E-009 + 5 9 0 -0.18262716434565840 0.0000000000000000 + 5 9 1 8.1414332359541625E-003 1.1999251910980250E-002 + 5 9 2 -3.1472520405238384E-004 -3.2035498642534110E-003 + 5 9 3 -2.1787960337332600E-004 1.2420036967097017E-004 + 5 9 4 1.3727009380792598E-005 1.9502628461299732E-005 + 5 9 5 -1.8472661284934935E-006 4.2642082095340990E-007 + 5 9 6 7.2534553103192780E-008 -2.9504773057515799E-007 + 5 9 7 -6.3839429218981086E-008 -1.2858715274327965E-008 + 5 9 8 -3.3556491767739913E-011 1.0817337458241120E-008 + 5 9 9 -1.3774899847048886E-009 -6.6341675984417397E-010 + 5 10 0 0.43670934989512555 0.0000000000000000 + 5 10 1 -1.0990165628049109E-002 -7.1182233624273367E-002 + 5 10 2 -4.2959107211846337E-003 -3.7489965951406790E-003 + 5 10 3 -2.0307872353298695E-004 -4.2022169862355817E-004 + 5 10 4 -2.3448298540932588E-006 -7.7920914452012200E-006 + 5 10 5 1.2100329851252683E-006 2.3511622008442197E-006 + 5 10 6 -1.2781915709441691E-007 7.7066114106515627E-007 + 5 10 7 8.1510924570078952E-008 5.2936743937150206E-008 + 5 10 8 -1.1985149669543029E-008 5.8761101240044839E-009 + 5 10 9 -1.9479305378167728E-010 -7.0488425530132678E-010 + 5 10 10 4.5141144889776694E-010 2.6957873997317736E-010 + 6 0 0 -1.1205087761690147E-004 0.0000000000000000 + 6 1 0 -6.6644520364487523E-005 0.0000000000000000 + 6 1 1 -5.4817499020080297E-005 -2.5048567031992576E-004 + 6 2 0 -6.9854688084005490E-005 0.0000000000000000 + 6 2 1 -1.4503823833560870E-006 -3.6681400214589100E-005 + 6 2 2 2.0990042399232726E-005 5.2539951955781204E-005 + 6 3 0 -1.1671073243216297E-004 0.0000000000000000 + 6 3 1 1.5060729029466949E-004 1.7878356018287663E-005 + 6 3 2 4.1012725990144489E-006 -2.8969254128764802E-005 + 6 3 3 -7.3528490257491409E-006 9.0688439741167863E-006 + 6 4 0 -2.0971182372846157E-005 0.0000000000000000 + 6 4 1 -1.3014515906096519E-004 -1.6542130201250860E-004 + 6 4 2 1.3932016715534271E-005 -4.1382464113943184E-005 + 6 4 3 -2.0019647175021900E-006 2.0283329181613351E-006 + 6 4 4 -5.0408236378815827E-006 -3.0528957083162369E-006 + 6 5 0 -1.0575927646745164E-003 0.0000000000000000 + 6 5 1 1.1417042419065466E-006 -4.5610158933365440E-004 + 6 5 2 -6.9939202765174492E-005 6.5537215131353208E-005 + 6 5 3 -2.5634574804762235E-006 -3.4162225361537077E-006 + 6 5 4 -2.4611581289666683E-006 1.8850094582145002E-006 + 6 5 5 1.0589690759346218E-006 -1.0853249653065834E-007 + 6 6 0 -3.4187954619490306E-004 0.0000000000000000 + 6 6 1 1.0824244299505073E-003 -6.2595912419067623E-004 + 6 6 2 -2.5562710451908272E-005 1.2415966528616360E-004 + 6 6 3 -1.2272359898791735E-005 -1.7192546456658749E-005 + 6 6 4 -2.3540760755697455E-006 -4.9027159385852368E-006 + 6 6 5 7.4819011739109219E-007 6.0742861098265927E-009 + 6 6 6 6.8021481218546486E-008 -1.2003549487990960E-007 + 6 7 0 -4.3893255413245498E-003 0.0000000000000000 + 6 7 1 -1.1416537296266365E-003 1.7885822253428565E-004 + 6 7 2 1.4215214258708067E-004 9.6338306719973067E-005 + 6 7 3 1.3725376014190013E-005 5.6094961487703903E-006 + 6 7 4 -1.0255266139010924E-007 2.6225958483037281E-006 + 6 7 5 5.1450809976841945E-007 -3.4247939692565699E-007 + 6 7 6 1.1680475575950724E-007 -3.3892027922781915E-008 + 6 7 7 -3.9085558014725729E-008 -2.0937917925292689E-008 + 6 8 0 2.0516601790207026E-003 0.0000000000000000 + 6 8 1 -5.9845112768387525E-004 -2.0687911981164095E-003 + 6 8 2 -3.6076766897543929E-004 7.7677353344126211E-005 + 6 8 3 1.2463080846879336E-005 -2.7215367931731194E-005 + 6 8 4 -4.8123822041870360E-006 -4.6257725318198618E-007 + 6 8 5 -2.5767415555785668E-007 -3.2668681449782604E-007 + 6 8 6 -6.4153769924800805E-008 1.3390285915545545E-007 + 6 8 7 -8.1356028900567398E-009 1.2137346929734363E-008 + 6 8 8 -2.3493970474138765E-009 -1.2810258589109376E-009 + 6 9 0 -2.5078338055646892E-002 0.0000000000000000 + 6 9 1 -6.6088086089733245E-004 1.7762353458403725E-003 + 6 9 2 5.3560451431282005E-004 7.0913470350902896E-004 + 6 9 3 1.1579192196210454E-004 -8.4483945142419567E-005 + 6 9 4 6.9557453289127198E-006 -9.5576022606804223E-006 + 6 9 5 1.7279792012539475E-006 1.5162487846783297E-006 + 6 9 6 2.4032053334955993E-008 3.0087353144573379E-007 + 6 9 7 -6.1579901316244174E-009 1.6145342936009076E-008 + 6 9 8 -1.9276446784388169E-009 -1.1081990348128972E-009 + 6 9 9 5.2496634186921284E-010 1.1014097468783538E-010 + 6 10 0 3.8365251899441753E-002 0.0000000000000000 + 6 10 1 -9.1329661098295788E-003 1.7579462960261619E-003 + 6 10 2 -2.5088710699969295E-004 3.3946556168095760E-003 + 6 10 3 9.2060054467726617E-005 7.7421560006214553E-005 + 6 10 4 3.1633857724347186E-006 8.4733678592361611E-007 + 6 10 5 -4.5097165073893203E-007 -2.3628958037565561E-007 + 6 10 6 1.0159891487636674E-007 1.5344842581303489E-007 + 6 10 7 -6.6340012657443420E-009 -9.6741893884202245E-009 + 6 10 8 4.6370116455433031E-009 -2.3825247802518148E-009 + 6 10 9 -2.4209412670845549E-010 1.9960591921293462E-010 + 6 10 10 -1.9416411521198454E-011 -2.9516366479404090E-010 diff --git a/gala/source/tests/potential/scf/data/hernquist-samples.dat.gz b/gala/source/tests/potential/scf/data/hernquist-samples.dat.gz new file mode 100644 index 0000000000000000000000000000000000000000..d833c72d0b8ac88dcdb529f33140811172a04fd8 --- /dev/null +++ b/gala/source/tests/potential/scf/data/hernquist-samples.dat.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d19d8168b0589b99a78483ee98378fa48e5ae6c0a605df2e2eba66f1dcb72382 +size 275451 diff --git a/gala/source/tests/potential/scf/data/multi-hernquist-accp.dat.gz b/gala/source/tests/potential/scf/data/multi-hernquist-accp.dat.gz new file mode 100644 index 0000000000000000000000000000000000000000..b9981d03e407a609bb4a5565b47c9b7d40cde6d4 --- /dev/null +++ b/gala/source/tests/potential/scf/data/multi-hernquist-accp.dat.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d12712ae79f94cf68b836dfd94ae23f118e4047d72a98d5d3b2e7091d23e8d89 +size 257229 diff --git a/gala/source/tests/potential/scf/data/multi-hernquist.coeff b/gala/source/tests/potential/scf/data/multi-hernquist.coeff new file mode 100644 index 0000000000000000000000000000000000000000..0cc5d7fcaab78709b59ee3e47b8859e6d32a518d --- /dev/null +++ b/gala/source/tests/potential/scf/data/multi-hernquist.coeff @@ -0,0 +1,4 @@ +3 +0 0 0 1.0 0.0 +1 0 0 0.1 0.0 +2 0 0 0.05 0.0 diff --git a/gala/source/tests/potential/scf/data/plummer-pos.dat.gz b/gala/source/tests/potential/scf/data/plummer-pos.dat.gz new file mode 100644 index 0000000000000000000000000000000000000000..6b686c309bc3977e16ea1a341289284be758068c --- /dev/null +++ b/gala/source/tests/potential/scf/data/plummer-pos.dat.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ced4edce8f2037e1587278bf65b05addd637b07c36e456b409effb887a3bed99 +size 242449 diff --git a/gala/source/tests/potential/scf/data/plummer_coeff_nmax10_lmax5.txt b/gala/source/tests/potential/scf/data/plummer_coeff_nmax10_lmax5.txt new file mode 100644 index 0000000000000000000000000000000000000000..f9ecc60cca16ecf17f1f6913d649e5096a82df17 --- /dev/null +++ b/gala/source/tests/potential/scf/data/plummer_coeff_nmax10_lmax5.txt @@ -0,0 +1,396 @@ +1.304434625064201658e-01 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-1.439343900240657457e-03 0.000000000000000000e+00 +-3.129058292531008631e-03 6.921619177617611756e-04 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-2.581491437328634238e-03 0.000000000000000000e+00 +-6.728266117825634773e-04 2.205723286922194482e-03 +8.671023279691653009e-04 7.205922972199911743e-04 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-6.581251343708807711e-03 0.000000000000000000e+00 +-1.194330047000859499e-02 8.194561933078515006e-03 +1.056532954992223973e-03 -3.120601013877618863e-03 +-1.326623579967563596e-02 2.615749198945627445e-03 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +1.376411683319737076e-03 0.000000000000000000e+00 +-2.075099702183726483e-02 4.531682575732806466e-03 +-2.529479631493424668e-03 -4.659173485126370112e-02 +-1.138839020870170606e-03 -3.362298424469774283e-02 +1.069240923479596560e-02 1.980674151590648864e-03 +0.000000000000000000e+00 0.000000000000000000e+00 +-5.532662863400076431e-02 0.000000000000000000e+00 +6.372093799154628091e-02 -2.599044394270757022e-02 +-5.683778551138042590e-02 4.748118610795373784e-02 +2.030992311135371847e-02 1.760347459244713392e-02 +1.325859027505918442e-01 -2.813076607673856838e-02 +5.165708023981169239e-02 7.390343659543437138e-02 +4.109321959164383343e-05 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +3.453261446066403762e-04 0.000000000000000000e+00 +-6.285378762138135310e-05 1.230487964726284967e-04 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +4.143831066159271259e-04 0.000000000000000000e+00 +1.076667929557620666e-03 -5.626664924611220892e-04 +1.183559128303013656e-04 -2.558474312053322561e-04 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-1.430190394100834886e-03 0.000000000000000000e+00 +-2.772765133368108176e-03 2.984217854820448530e-04 +6.891782006831674925e-04 1.092871838394224304e-03 +-2.134925568561857074e-03 8.330201475862573056e-04 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +2.411276659171514639e-03 0.000000000000000000e+00 +7.404986463712261852e-03 -3.821078990167115722e-03 +-8.883737075693943792e-04 1.154906799782482460e-03 +-5.507932758824355454e-04 8.697917880693108172e-03 +-3.981324406349732135e-03 3.845042014490551024e-03 +0.000000000000000000e+00 0.000000000000000000e+00 +8.694748914922967656e-03 0.000000000000000000e+00 +1.821435161325079535e-03 2.462662038380764723e-03 +7.626511370216215203e-03 -4.727696746874375695e-03 +5.955799208694712889e-03 -1.445856695250371041e-02 +-1.174096156720067993e-02 -4.220657022257651506e-03 +2.251575951158383757e-02 -2.385716932497319864e-02 +-6.245875059439466061e-03 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-1.099169574658092697e-04 0.000000000000000000e+00 +1.575563794648594308e-04 2.478235525590057823e-06 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +7.191357794823988528e-05 0.000000000000000000e+00 +7.450705020331181412e-05 7.593530303276212927e-05 +-2.131526859309371938e-04 -4.343655579324548813e-05 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +2.202960026198896360e-04 0.000000000000000000e+00 +4.762537814177440374e-04 -3.205125574587600447e-04 +-2.742572569445001683e-05 1.985689005807942244e-04 +9.200698017212266544e-04 3.287825465640080934e-04 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-3.163173695521699939e-04 0.000000000000000000e+00 +-9.505475780980315132e-04 -2.432898070976638699e-04 +-1.045991431523721508e-03 -1.871375789779506325e-03 +8.042458130626786890e-04 -9.963000320803564770e-04 +-5.535771480262208135e-04 -1.039816649679743210e-03 +0.000000000000000000e+00 0.000000000000000000e+00 +-1.764313919970723725e-03 0.000000000000000000e+00 +-2.233324198683913145e-03 1.098382965858850325e-03 +-9.780876258116232957e-05 9.699105994771298721e-04 +1.046341102267151239e-03 2.619020153031520731e-03 +-1.014837757442827630e-03 2.729408438002430340e-03 +-6.312590895618967529e-04 1.780076542834160662e-03 +-4.454336215801040541e-05 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-6.856854572295220107e-06 0.000000000000000000e+00 +-7.841864373574215492e-05 -1.118868504363355118e-05 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +1.648074688432395437e-05 0.000000000000000000e+00 +-2.111308967918107805e-04 -1.955046164326432355e-05 +-8.720136009562520069e-05 4.062569648161322383e-06 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +1.485155448360843400e-04 0.000000000000000000e+00 +2.256761180958199466e-04 -3.773856407221754764e-05 +-2.422064639250435278e-04 7.131298046645498444e-05 +2.296504805054935503e-06 -2.012723169486858366e-04 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-1.560827879165303475e-04 0.000000000000000000e+00 +-1.340284633957868695e-04 -2.376341934360842097e-05 +-1.660793710070972162e-04 3.752853428834111672e-04 +1.459164561972175653e-04 -5.051899816343666380e-04 +1.963115986488453782e-04 -4.116822002319337013e-04 +0.000000000000000000e+00 0.000000000000000000e+00 +2.473673083235440693e-05 0.000000000000000000e+00 +1.977325985589657622e-04 1.127307839841890612e-03 +-3.552722618387055666e-04 -4.929479669471027725e-04 +-1.575309329347667300e-04 -7.916674383866514919e-05 +1.291188421747252978e-03 -1.721871618326903657e-04 +-4.733772649175980763e-05 -4.670395506495363699e-04 +5.755437594244592735e-04 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-4.889266108270894070e-06 0.000000000000000000e+00 +-7.821785713684144663e-06 1.680676013803841188e-05 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-3.100890063341312229e-05 0.000000000000000000e+00 +6.106111790867447273e-05 7.054877160525870394e-06 +8.337320877263439632e-05 2.011667006758752151e-05 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-7.342190911282240381e-05 0.000000000000000000e+00 +1.741140349733171435e-05 4.017675997724581702e-05 +-5.098109137226668506e-05 2.458562235145258157e-05 +-6.619064261263156587e-05 -7.670903444430240121e-06 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +9.724383878580921900e-05 0.000000000000000000e+00 +9.625279301384229570e-05 6.412162812899151968e-05 +1.913757931187845724e-04 1.846020727879679608e-06 +-9.975246150484625037e-05 2.440484138439270824e-04 +1.800910683028545569e-05 5.832548946715759559e-05 +0.000000000000000000e+00 0.000000000000000000e+00 +1.281818068932058139e-04 0.000000000000000000e+00 +-2.555544093995338727e-04 -1.960499860662581935e-04 +2.245617041150458032e-04 5.437662795816716136e-04 +-2.481942501044721557e-04 1.804202481731724799e-04 +-7.650816800662619600e-05 -2.832006022871280814e-05 +9.909353599822405751e-05 -4.639549114871540611e-05 +9.970659052252758234e-06 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +2.884349749719073380e-06 0.000000000000000000e+00 +2.441468367608399081e-05 -1.435239622880282575e-05 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +3.724114221485872788e-06 0.000000000000000000e+00 +1.998678071995217922e-05 8.125301894157419631e-06 +5.235955655655371523e-06 1.738220063145758801e-05 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-1.785108392427998652e-05 0.000000000000000000e+00 +-6.328092691343508921e-05 8.429906211832557304e-06 +5.842576038526473285e-05 -4.186353254978873435e-05 +1.214082292188408249e-05 2.451183796238299775e-05 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-1.284868933836862448e-06 0.000000000000000000e+00 +-1.561398234923812230e-05 1.490184826282580221e-05 +1.254168362373678326e-06 -6.126656578853878802e-05 +-2.055826660164001093e-05 -3.846529541565431261e-05 +6.517538434031610572e-06 7.745055744442566061e-05 +0.000000000000000000e+00 0.000000000000000000e+00 +1.478262728721372941e-05 0.000000000000000000e+00 +1.603274481682154549e-05 4.491598841742159935e-06 +-1.118816857914054294e-04 1.060385802643919359e-05 +-5.303188573825791321e-05 -2.243687971532007391e-05 +5.009818679269007391e-05 3.548723736967405442e-05 +3.778487721358077570e-05 5.638954419405082310e-05 +-5.840656296845502557e-05 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +6.736775884001822605e-06 0.000000000000000000e+00 +7.962947215209853564e-06 1.191689697190123280e-06 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +9.501224706322899361e-06 0.000000000000000000e+00 +-9.020936496553465448e-06 6.425005961385313195e-07 +-5.529032467452674796e-06 -1.019282588364806918e-05 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +2.096773858577811634e-05 0.000000000000000000e+00 +3.979078054903180175e-06 -1.438064572388084900e-05 +5.713301919354310361e-06 7.192415200602913848e-06 +2.128097141410034959e-05 1.028332952774682338e-05 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-2.815911568980001748e-05 0.000000000000000000e+00 +2.665653443698168117e-06 -1.472841581686814786e-05 +-2.732278121390535359e-05 1.260433087507474647e-05 +3.142504824508865409e-05 -1.836120349401925923e-06 +-2.990151112953675033e-05 -1.167879053341489046e-05 +0.000000000000000000e+00 0.000000000000000000e+00 +-1.866437815431127331e-05 0.000000000000000000e+00 +7.419736482629750310e-05 4.609228098551572357e-05 +4.230752571316732100e-05 -1.228895957492147936e-04 +7.496075538629683546e-05 2.185597116295610098e-05 +2.241233724416355583e-05 8.541059024044979849e-06 +-1.494248126537928758e-05 2.705383729513708332e-05 +4.270991754891668838e-06 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +1.819641380601381200e-06 0.000000000000000000e+00 +-5.872475378451113167e-06 1.259263866052971703e-06 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-3.450830730295567427e-06 0.000000000000000000e+00 +2.597868841564937610e-06 -5.326483180411220231e-06 +-5.088308032168992359e-07 1.036704021745175158e-06 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-2.543451499281563293e-06 0.000000000000000000e+00 +9.728674506409675296e-06 6.025696310445161686e-06 +-4.974526004525086173e-06 2.720649861617755288e-06 +-4.201055070709284495e-07 -4.228514798910577297e-06 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +8.928525175557225382e-06 0.000000000000000000e+00 +-3.918642445174474147e-06 -6.572256963260701923e-06 +5.335607889462749693e-06 1.354810964882532624e-05 +-2.656485543359852701e-06 3.256283528027096609e-07 +-1.620450673005140737e-06 -2.045104502152567351e-07 +0.000000000000000000e+00 0.000000000000000000e+00 +-8.850174239400176721e-07 0.000000000000000000e+00 +-1.838828553200937011e-05 8.445115665831431670e-06 +-4.336060735184760177e-06 9.951827892384611522e-06 +-2.600245588896701827e-05 -1.507662643774671361e-05 +-3.492376380855933323e-05 -3.366016210112201477e-05 +-5.739764471032531206e-06 1.548849254065660434e-05 +-9.795793045089899370e-06 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-3.351925983618716022e-06 0.000000000000000000e+00 +-6.987149996304767616e-07 -3.561158892310595392e-06 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-1.831224835567532322e-06 0.000000000000000000e+00 +-2.365143208424990369e-06 3.860790931880018931e-07 +-3.783959268088811988e-07 4.006856678565868190e-07 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-2.399017350079396918e-06 0.000000000000000000e+00 +1.490538858865357756e-06 -2.150452307748374249e-06 +-3.003997099557287875e-07 -3.479902394262972312e-07 +-2.163979077885408800e-06 -7.437277877562604192e-07 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +2.314187012574232976e-06 0.000000000000000000e+00 +6.081796667252602831e-06 3.616008105480609300e-06 +-1.377776292899372955e-06 -6.900097470965827353e-06 +-6.647638855868451568e-06 -2.200801587743863170e-06 +1.163266562071029116e-05 -7.119645962329327105e-07 +0.000000000000000000e+00 0.000000000000000000e+00 +3.054825732269478577e-06 0.000000000000000000e+00 +6.971335229183869107e-06 -1.719540610739949627e-05 +-1.168034586322194109e-05 1.349780252864542939e-05 +1.104292154768494567e-05 -6.984058118109886942e-06 +4.060497827638037072e-06 1.492975444771517132e-05 +8.956469895688857471e-06 -1.417838660118450628e-06 +-1.878085410125813063e-06 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-2.561513314710346999e-06 0.000000000000000000e+00 +3.998126944512776706e-07 2.235979141154641425e-06 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +8.403045257038042587e-07 0.000000000000000000e+00 +9.929490824847277457e-07 2.103276618519428488e-06 +2.380943348849475002e-07 -2.557907068286337071e-07 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-1.121952800263125177e-06 0.000000000000000000e+00 +-2.373644160211889600e-07 2.243386172560191973e-07 +5.056657584927242192e-07 -1.442883552704267060e-06 +2.634560833471630544e-07 -2.560289521348768220e-07 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +-1.460611267514909573e-06 0.000000000000000000e+00 +1.796197982199792562e-06 -4.790405338991362705e-06 +-2.199175422988432862e-06 -1.541535876302072589e-06 +2.377002400121986390e-06 2.999554539870974747e-06 +-4.993919215867527423e-06 2.135618803853240912e-06 +0.000000000000000000e+00 0.000000000000000000e+00 +-1.799745988768611524e-07 0.000000000000000000e+00 +1.083625156687995180e-06 1.877616546750391271e-06 +7.233392723400661863e-06 -9.803864710390580817e-07 +-9.251346992246597385e-07 1.782842415241592558e-06 +4.376861547887300820e-06 1.988059187996584017e-07 +1.426259014464856113e-06 -5.415999458248678548e-06 +1.398660187900680145e-05 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +1.873995580893917312e-06 0.000000000000000000e+00 +-4.981638440253702015e-07 1.044526142252208167e-06 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +6.133030417146916056e-07 0.000000000000000000e+00 +-5.495810682008039152e-07 -6.761617378116299236e-07 +9.618987677065846455e-07 7.040639900698219721e-07 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +3.911629150463877622e-07 0.000000000000000000e+00 +-1.721740466233316291e-06 4.267469919835734111e-07 +-1.567724336201443722e-06 -1.229409022945579084e-07 +-6.317731785752117489e-07 -2.645467196340974731e-07 +0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 +7.144547697102718910e-07 0.000000000000000000e+00 +-2.990794295510930245e-06 1.002727849520059236e-06 +2.099794565567973999e-06 6.859935555064075368e-07 +9.155676712583608323e-07 -2.094584098099119314e-06 +-4.253270067044640701e-07 -2.661566770531764332e-06 +0.000000000000000000e+00 0.000000000000000000e+00 +-6.599705024862042910e-07 0.000000000000000000e+00 +-4.074638633640298232e-06 3.305215111875517394e-06 +3.783987706205318015e-06 -1.041936237348737632e-06 +-1.249215811181738234e-06 2.027647620083294914e-06 +-1.849298779812065456e-07 1.303782405880884547e-06 +-1.519779329623025048e-06 1.692601264332519547e-06 diff --git a/gala/source/tests/potential/scf/data/plummer_coeff_var_nmax10_lmax5.txt b/gala/source/tests/potential/scf/data/plummer_coeff_var_nmax10_lmax5.txt new file mode 100644 index 0000000000000000000000000000000000000000..158241cf3fc7b26b3381ba302c521a645e1859b9 --- /dev/null +++ b/gala/source/tests/potential/scf/data/plummer_coeff_var_nmax10_lmax5.txt @@ -0,0 +1,396 @@ +1.955542420667336910e-06 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.501694738331875903e-06 0.000000000000000000e+00 0.000000000000000000e+00 +3.069974360420259202e-06 3.058244723598650198e-06 8.259305429235818083e-09 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +5.151475760715934999e-06 0.000000000000000000e+00 0.000000000000000000e+00 +1.038563355002337311e-05 1.035077180930233886e-05 -1.589996923167818011e-07 +1.058534066643928995e-05 1.043526748767122636e-05 -1.129025988375223285e-08 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +2.902924507841970914e-05 0.000000000000000000e+00 0.000000000000000000e+00 +5.720866869013867338e-05 5.676538685279609712e-05 -4.399557984988813699e-07 +5.874248691105214172e-05 5.753466544441846934e-05 2.529302209277357381e-07 +5.930885644358156294e-05 5.781732253073513031e-05 3.265804064070954633e-07 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +2.073773228526394694e-04 0.000000000000000000e+00 0.000000000000000000e+00 +4.129940370202110850e-04 4.031100235969758624e-04 -5.404526211741658649e-06 +4.068099631203810291e-04 4.122789531152861570e-04 3.458183832150718069e-06 +4.168211653823444287e-04 4.169496562075238749e-04 -6.709829757764032992e-06 +4.162940666034420497e-04 4.211183084448069159e-04 -5.012489664447216521e-07 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.750464741414420929e-03 0.000000000000000000e+00 0.000000000000000000e+00 +3.365212565029881628e-03 3.357546932682020906e-03 -3.913630922346395224e-05 +3.367548043722668437e-03 3.464627647149332024e-03 7.015539873152493997e-06 +3.429006899756932222e-03 3.412635078887994028e-03 -1.458127332528042313e-05 +3.457991023531225682e-03 3.499182327842530020e-03 -1.244260515000052001e-06 +3.511213062423861227e-03 3.466013425050964904e-03 -1.835077914063982228e-05 +6.154249653618050666e-08 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +6.292690044024561206e-08 0.000000000000000000e+00 0.000000000000000000e+00 +1.279286089304920707e-07 1.291034671431923107e-07 2.694255353134101858e-10 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.996501579758773836e-07 0.000000000000000000e+00 0.000000000000000000e+00 +3.987160054579043966e-07 4.101032541954881082e-07 -1.294555217018412604e-08 +4.066324252119791976e-07 4.050754495337268223e-07 -4.210152820923436036e-09 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.013671287198434257e-06 0.000000000000000000e+00 0.000000000000000000e+00 +1.958515702176806657e-06 1.995554363946965203e-06 -4.637858321334611655e-08 +2.043820831120128426e-06 2.021575982631594098e-06 1.473407114508798328e-08 +2.055743341201720956e-06 1.962191442597902465e-06 1.374838256550107891e-08 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +6.392246238862768901e-06 0.000000000000000000e+00 0.000000000000000000e+00 +1.271015911567457694e-05 1.292825328955402627e-05 -3.887426971875215156e-07 +1.258116908611657525e-05 1.284891344270544819e-05 1.919640170527257559e-07 +1.317195963089071501e-05 1.290636031074247607e-05 -2.542102831953534507e-07 +1.279788639832947623e-05 1.273508661884189125e-05 2.263529674166630785e-07 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +4.827952819300896677e-05 0.000000000000000000e+00 0.000000000000000000e+00 +9.424190585875452469e-05 9.619748794811842116e-05 -1.830597753594446977e-06 +9.409720482618970448e-05 9.798929279871706165e-05 1.440832166805832670e-07 +9.712011382941124768e-05 9.479951262107583492e-05 -1.296922239481954883e-06 +9.708587573072389532e-05 9.833972372083668579e-05 7.190463629596031577e-07 +9.420745442128132616e-05 9.632840047672453973e-05 1.053309449877799526e-06 +1.225482878202881586e-08 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +7.464117858255532386e-09 0.000000000000000000e+00 0.000000000000000000e+00 +1.603756650060489091e-08 1.528404488601745721e-08 5.127699473812801457e-11 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.813605025125152232e-08 0.000000000000000000e+00 0.000000000000000000e+00 +3.665643979662440699e-08 3.537502726315677454e-08 -8.117927148333732987e-10 +3.761204488745535171e-08 3.752925586629598854e-08 -1.748863531942093784e-10 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +7.553257701569056366e-08 0.000000000000000000e+00 0.000000000000000000e+00 +1.444364176537227846e-07 1.454893275729833864e-07 -1.610931415441400672e-09 +1.492092997719821444e-07 1.480794624014914403e-07 1.534429683566218053e-09 +1.552101904976523698e-07 1.505843079498609427e-07 1.229818546584380122e-09 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +4.069010689945093577e-07 0.000000000000000000e+00 0.000000000000000000e+00 +8.104525375393547929e-07 8.177744814249472170e-07 -2.042535350388787241e-08 +7.916456651401325181e-07 7.906252794534128260e-07 1.088058102364256104e-08 +8.291716668581216286e-07 8.189756966554863425e-07 -7.926230074630115705e-09 +8.359641930885894215e-07 8.281664412304361511e-07 -1.143199183504787219e-09 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +2.720014684206167938e-06 0.000000000000000000e+00 0.000000000000000000e+00 +5.259830469348248607e-06 5.373791015996052541e-06 -7.166972530140828425e-08 +5.311951661239744218e-06 5.359586231834790811e-06 9.287061415112108138e-08 +5.325149562224412970e-06 5.189122260897168688e-06 -3.367734267982601723e-08 +5.399269834420611753e-06 5.566331353916384709e-06 4.322966253025839644e-08 +5.447959635802507371e-06 5.446707065103944059e-06 1.005112378086796415e-08 +3.224844499646951859e-09 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.477318053130910693e-09 0.000000000000000000e+00 0.000000000000000000e+00 +3.034163581499809991e-09 2.942623390103944677e-09 -4.475096631152589526e-11 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +2.663159752296150455e-09 0.000000000000000000e+00 0.000000000000000000e+00 +5.561794821162051498e-09 5.484476140118572450e-09 -1.201807257515811292e-10 +5.464795865159360144e-09 5.615521558762375658e-09 -9.638926171029957453e-11 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +8.852014042347463434e-09 0.000000000000000000e+00 0.000000000000000000e+00 +1.789763909375238255e-08 1.759074511200920497e-08 3.213564720807068699e-10 +1.798536469551458735e-08 1.817932743751014468e-08 1.088831753081169398e-10 +1.886128435679916301e-08 1.770158012412863226e-08 1.268207436401327458e-10 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +4.072058647044656389e-08 0.000000000000000000e+00 0.000000000000000000e+00 +8.273370562157914736e-08 8.005955215778657427e-08 1.431993723210857483e-10 +7.979543411745320842e-08 8.150617083116538310e-08 -6.489307147848282612e-12 +8.486192562844999565e-08 8.114880315855176395e-08 -3.776808503460354424e-10 +8.495415567469645829e-08 8.312417679734297727e-08 -7.424138699462392538e-10 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +2.344545693382513998e-07 0.000000000000000000e+00 0.000000000000000000e+00 +4.645145219072512296e-07 4.605438601691486079e-07 -3.231510145114265867e-10 +4.506339352411797537e-07 4.671678403189288886e-07 -3.219675785686195465e-09 +4.773555754415972091e-07 4.409186091709433092e-07 2.407548285263202807e-11 +4.765006121275016015e-07 4.749775451811467544e-07 8.796519695288754818e-09 +4.798858185213348706e-07 4.781573564714084898e-07 1.133659542996676267e-08 +1.124878401910943608e-09 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +3.499637068527346058e-10 0.000000000000000000e+00 0.000000000000000000e+00 +7.259939440283042018e-10 7.084754464518281134e-10 -1.033141806227861822e-11 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +5.139642031541438449e-10 0.000000000000000000e+00 0.000000000000000000e+00 +1.053513443368176093e-09 1.053352657525335080e-09 -4.926029840905539569e-11 +1.089173683146579052e-09 1.036255587192240977e-09 -2.260323240570193669e-11 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.401027176183919621e-09 0.000000000000000000e+00 0.000000000000000000e+00 +2.798842608211604154e-09 2.945904068341456618e-09 -2.999853474555550255e-11 +2.878814370328683252e-09 2.951698753547726017e-09 5.780384667095386705e-11 +2.937295171752520237e-09 2.873970351902024333e-09 6.348939411611532766e-11 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +5.389602361998939282e-09 0.000000000000000000e+00 0.000000000000000000e+00 +1.120343210643673065e-08 1.126594910784988289e-08 -2.074619025339290647e-11 +1.074881864447610285e-08 1.155057449524051064e-08 1.962591651562543713e-10 +1.137010920167736542e-08 1.131825725644974821e-08 7.956810518373705519e-12 +1.144768871452673022e-08 1.115278310891438586e-08 -4.127562578486695071e-11 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +2.690972075872912806e-08 0.000000000000000000e+00 0.000000000000000000e+00 +5.446150025742075829e-08 5.511094062951710029e-08 2.689585392497582786e-10 +5.298199956252860320e-08 5.648092829024149550e-08 -3.786914345043207495e-10 +5.612943494589317206e-08 5.380058054339198258e-08 2.737082136164978462e-11 +5.546975580455735950e-08 5.591385722116058478e-08 5.836243659309952976e-10 +5.587690770640462459e-08 5.564491980827620397e-08 1.757929740399571071e-09 +5.049647647648927402e-10 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.061924382871060242e-10 0.000000000000000000e+00 0.000000000000000000e+00 +2.202656389670875193e-10 2.106697541155129628e-10 -4.413176821609211320e-14 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.201460836114832518e-10 0.000000000000000000e+00 0.000000000000000000e+00 +2.454730115040905277e-10 2.340576983038293002e-10 -1.033954966706560048e-11 +2.521398284896769111e-10 2.421442605616351184e-10 -1.149839393469590103e-11 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +2.742972736334709864e-10 0.000000000000000000e+00 0.000000000000000000e+00 +5.539717593307005421e-10 5.490408639609299650e-10 4.017250574855704644e-13 +5.678437518365972118e-10 5.559881062740458057e-10 -6.118837899306465003e-12 +5.819912614734698550e-10 5.557215121533186650e-10 -1.464407152092444426e-12 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +8.925003964493838748e-10 0.000000000000000000e+00 0.000000000000000000e+00 +1.916282514485573543e-09 1.887376723637087531e-09 1.311207454393070694e-11 +1.825882227947816716e-09 1.869303291148545783e-09 -7.383151625741304712e-13 +1.939911329336631862e-09 1.898131738699123073e-09 -3.521008268199570280e-11 +1.913775970645862643e-09 1.900661973865388344e-09 -1.679196658763610760e-11 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +3.850345845898911280e-09 0.000000000000000000e+00 0.000000000000000000e+00 +8.189737273755337455e-09 8.063950071614595091e-09 2.070132683972923640e-11 +7.946804899553253617e-09 8.298969277178271070e-09 -3.915996254960506300e-11 +8.124636962946553703e-09 7.785341442156637416e-09 7.112366543801725829e-11 +8.257077076209882429e-09 8.444717679195313920e-09 1.814522649539691177e-10 +8.195625700143349606e-09 8.230589444527513729e-09 1.619366564449920151e-10 +2.418440340438496756e-10 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +3.853946281892877164e-11 0.000000000000000000e+00 0.000000000000000000e+00 +7.749455342225214758e-11 7.591778670447883394e-11 -1.595241057777848666e-13 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +3.296120646087236366e-11 0.000000000000000000e+00 0.000000000000000000e+00 +6.637196675454028342e-11 6.532890753648263445e-11 -3.280896230597016365e-12 +6.805380605123305986e-11 6.576405636206745189e-11 -9.373654831394700376e-13 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +6.111103762906620979e-11 0.000000000000000000e+00 0.000000000000000000e+00 +1.240450011539199367e-10 1.264372621388873612e-10 -2.145624207165082390e-12 +1.250493371430281574e-10 1.246950236368058953e-10 -3.200876667763571251e-12 +1.291256047539483158e-10 1.254185842184306650e-10 -3.299242081843928883e-13 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.787803859218156691e-10 0.000000000000000000e+00 0.000000000000000000e+00 +3.596382069019199190e-10 3.621612221764095659e-10 2.734766022671124478e-12 +3.683229885692087969e-10 3.692806219911911473e-10 -1.175316110186545283e-11 +3.679745095442539873e-10 3.661686361561307558e-10 -7.334670494422035345e-12 +3.755491774054564274e-10 3.647889129635244117e-10 -8.242797102648938796e-12 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +6.660129830895630122e-10 0.000000000000000000e+00 0.000000000000000000e+00 +1.376999553686192842e-09 1.402526839466798447e-09 5.687726226270321907e-12 +1.382187607858145117e-09 1.390017261759421558e-09 1.340689623476634822e-12 +1.432141988881250787e-09 1.370850577344738421e-09 -2.253456629926024008e-11 +1.387959185737627580e-09 1.432532819308312155e-09 2.830125350507720713e-11 +1.400134613246773099e-09 1.397094231850021292e-09 7.196996999012667057e-12 +1.259120822309661796e-10 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.518549224437385860e-11 0.000000000000000000e+00 0.000000000000000000e+00 +3.109752967129153372e-11 2.995735260977583622e-11 -5.021268050847311868e-14 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.044168775802168580e-11 0.000000000000000000e+00 0.000000000000000000e+00 +2.133702479429557412e-11 2.055186697805852174e-11 -5.880587737822933244e-13 +2.176826907389185543e-11 2.068825501328848133e-11 5.919261427115326027e-14 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.609538189193252476e-11 0.000000000000000000e+00 0.000000000000000000e+00 +3.204046599460035641e-11 3.129653320785798314e-11 -8.002329319651215602e-13 +3.261500288032126370e-11 3.194270132866781855e-11 -6.670217957958855340e-14 +3.352821642687519244e-11 3.248948207336120152e-11 -3.579545010593330895e-13 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +3.939493462142988876e-11 0.000000000000000000e+00 0.000000000000000000e+00 +8.178919267177747509e-11 8.051482762589722519e-11 -2.132360708453290293e-12 +7.960144653802840713e-11 8.114934776985525454e-11 -2.611256696134488503e-12 +8.129725688281082125e-11 8.177738902902640040e-11 -2.193438898293965193e-12 +8.311344968938896150e-11 8.393711648569390736e-11 -1.616759258241948097e-12 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.360914351894146666e-10 0.000000000000000000e+00 0.000000000000000000e+00 +2.706571628014560990e-10 2.714226141836222067e-10 -4.101180278645566972e-12 +2.807191035455768412e-10 2.800392922027231254e-10 -6.118936124149278224e-12 +2.828487596243198652e-10 2.692714469617568613e-10 -1.965016092001095432e-12 +2.746342702394422227e-10 2.847442996958909425e-10 4.434316936981717911e-12 +2.839704297491786294e-10 2.853709348383055217e-10 -3.679545754687407942e-12 +7.257516113734235602e-11 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +6.414371180194103076e-12 0.000000000000000000e+00 0.000000000000000000e+00 +1.292933997057751632e-11 1.281238704237722088e-11 -1.222947336053036776e-13 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +3.697643558010748261e-12 0.000000000000000000e+00 0.000000000000000000e+00 +7.440125192668666101e-12 7.384652258235513706e-12 -1.467793408998789279e-13 +7.414087305790444449e-12 7.354657935416706961e-12 -3.837019598264792612e-14 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +4.643201965202050264e-12 0.000000000000000000e+00 0.000000000000000000e+00 +9.072748424965415114e-12 9.364197440883862357e-12 -2.163322814118304625e-14 +9.515510064102467504e-12 9.308591503557812947e-12 1.385481804370315919e-13 +9.630696188464663816e-12 9.149701117953892902e-12 1.801777596568665196e-15 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +9.625473414226720678e-12 0.000000000000000000e+00 0.000000000000000000e+00 +1.920938687796980439e-11 1.964193294296897346e-11 -7.944715897830023893e-13 +1.946955762275289352e-11 1.919021493905150214e-11 -2.564881469669617856e-13 +1.958134976408083770e-11 2.007053177840841298e-11 -5.337038081684425876e-13 +1.960489616991941994e-11 2.003224099146012803e-11 -4.204662735566095595e-13 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +2.866142505134648015e-11 0.000000000000000000e+00 0.000000000000000000e+00 +5.960474927275288191e-11 5.791676133341629378e-11 -2.948445467813419937e-12 +5.869494353063501558e-11 5.761075430241717798e-11 -5.770019628797047667e-13 +5.944393939418903974e-11 5.745008898350029844e-11 -1.098049362846760925e-12 +5.778707813648108667e-11 6.097470543722549861e-11 1.471001149611219205e-12 +5.837025614734168256e-11 6.085131357497173529e-11 -1.415376828445966535e-12 +4.501367653221281796e-11 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +2.879723013095073545e-12 0.000000000000000000e+00 0.000000000000000000e+00 +6.005589734960714160e-12 5.807875507024871252e-12 -3.384304678425237182e-14 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.414827838134706533e-12 0.000000000000000000e+00 0.000000000000000000e+00 +2.827744507478850109e-12 2.715020659858806908e-12 -1.481541516910848357e-13 +2.802171334217860349e-12 2.834420703841962688e-12 -4.933000573596051965e-14 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.540302248378772545e-12 0.000000000000000000e+00 0.000000000000000000e+00 +3.102629603205183762e-12 3.064876429318944762e-12 1.366498511007262618e-14 +3.093540536407955661e-12 3.091816970364022553e-12 3.035514861683012543e-14 +3.213954369297482847e-12 3.069007715178577995e-12 1.295675091167728591e-14 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +2.678449523858159449e-12 0.000000000000000000e+00 0.000000000000000000e+00 +5.376501897039607272e-12 5.630460807581595272e-12 -7.794148235691610774e-14 +5.479867831084443026e-12 5.543074399175339949e-12 -1.166814819540805070e-13 +5.558575700683859324e-12 5.631417382803890843e-12 -1.146969784210942868e-13 +5.537579562204165869e-12 5.791105675134707127e-12 -1.396598322436072022e-13 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +6.763237181525172007e-12 0.000000000000000000e+00 0.000000000000000000e+00 +1.359877961096404003e-11 1.427603693907594070e-11 -7.233844217419753033e-13 +1.434099417026095166e-11 1.379845031490931562e-11 1.221601513717413345e-13 +1.423916729156160569e-11 1.392517729983021179e-11 2.285057232434158715e-15 +1.403952118610047282e-11 1.482586145374840371e-11 2.646262301132666319e-13 +1.471450145462063977e-11 1.425540239321531578e-11 -3.076431219061037627e-13 +2.849924298051221704e-11 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.432531197995135573e-12 0.000000000000000000e+00 0.000000000000000000e+00 +2.917284682603617198e-12 2.906574253268154485e-12 -1.107881030944939383e-14 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +5.441594528428117194e-13 0.000000000000000000e+00 0.000000000000000000e+00 +1.106453366219980978e-12 1.056440839874971620e-12 -5.252696255480552352e-14 +1.101112737372159504e-12 1.062271231245146958e-12 8.179579220452581752e-15 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +5.354390102361499700e-13 0.000000000000000000e+00 0.000000000000000000e+00 +1.050487753929663483e-12 9.995161815694140799e-13 -3.399226189887866295e-15 +1.056835692359001597e-12 1.031727799749988527e-12 9.923729247828882196e-15 +1.059835430860786120e-12 1.023130194393242195e-12 8.857353859005130775e-15 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +8.445311268195063201e-13 0.000000000000000000e+00 0.000000000000000000e+00 +1.685180387686050324e-12 1.659205673773709066e-12 1.763787827722718989e-14 +1.700807246390284102e-12 1.654762176302202158e-12 -5.901338318468332747e-14 +1.699962744413702306e-12 1.649128697730139577e-12 -7.847139167715137061e-15 +1.684567203696272682e-12 1.709404519996951499e-12 -3.838224963655591701e-14 +0.000000000000000000e+00 0.000000000000000000e+00 0.000000000000000000e+00 +1.851130298038994461e-12 0.000000000000000000e+00 0.000000000000000000e+00 +3.802799954469440909e-12 3.802157332356086186e-12 -1.027128282347662240e-13 +3.763144091432152882e-12 3.755241270995029359e-12 -5.669537216790900043e-14 +3.814444647999950293e-12 3.746095484842269683e-12 3.839181416162380646e-14 +3.798840557685720703e-12 3.955183814026824247e-12 1.008864113329574717e-13 +3.847875242614496537e-12 3.872101308912856533e-12 -1.215065079270114607e-13 diff --git a/gala/source/tests/potential/scf/data/positions.dat.gz b/gala/source/tests/potential/scf/data/positions.dat.gz new file mode 100644 index 0000000000000000000000000000000000000000..eed0af709ae34cb9c7ddf767d11465230e42172c --- /dev/null +++ b/gala/source/tests/potential/scf/data/positions.dat.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ee35f83a302ca698d7cc1a089a088557f14a355bb62d937974b298a150d8a98 +size 106367 diff --git a/gala/source/tests/potential/scf/data/random-accp.dat.gz b/gala/source/tests/potential/scf/data/random-accp.dat.gz new file mode 100644 index 0000000000000000000000000000000000000000..944dbf963ac79b8b173d4574dc24d9f1e169f269 --- /dev/null +++ b/gala/source/tests/potential/scf/data/random-accp.dat.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:530f95f6b0ac8df80872390222055eb30c4002b695e226933027e6a8e2e7491d +size 661196 diff --git a/gala/source/tests/potential/scf/data/random.coeff b/gala/source/tests/potential/scf/data/random.coeff new file mode 100644 index 0000000000000000000000000000000000000000..eeb01fe2d2323e92aee8166bfcea8552fc4a1135 --- /dev/null +++ b/gala/source/tests/potential/scf/data/random.coeff @@ -0,0 +1,41 @@ +40 +0 0 0 -0.250920 0.000000 +0 1 0 0.901429 0.000000 +0 1 1 0.463988 0.000000 +0 2 0 0.197317 0.000000 +0 2 1 -0.687963 0.000000 +0 2 2 -0.688011 0.000000 +0 3 0 -0.883833 0.000000 +0 3 1 0.732352 0.000000 +0 3 2 0.202230 0.000000 +0 3 3 0.416145 0.000000 +1 0 0 -0.958831 0.000000 +1 1 0 0.939820 0.000000 +1 1 1 0.664885 0.000000 +1 2 0 -0.575322 0.000000 +1 2 1 -0.636350 0.000000 +1 2 2 -0.633191 0.000000 +1 3 0 -0.391516 0.000000 +1 3 1 0.049513 0.000000 +1 3 2 -0.136110 0.000000 +1 3 3 -0.417542 0.000000 +2 0 0 0.223706 0.000000 +2 1 0 -0.721012 0.000000 +2 1 1 -0.415711 0.000000 +2 2 0 -0.267276 0.000000 +2 2 1 -0.087860 0.000000 +2 2 2 0.570352 0.000000 +2 3 0 -0.600652 0.000000 +2 3 1 0.028469 0.000000 +2 3 2 0.184829 0.000000 +2 3 3 -0.907099 0.000000 +3 0 0 0.215090 0.000000 +3 1 0 -0.658952 0.000000 +3 1 1 -0.869897 0.000000 +3 2 0 0.897771 0.000000 +3 2 1 0.931264 0.000000 +3 2 2 0.616795 0.000000 +3 3 0 -0.390772 0.000000 +3 3 1 -0.804656 0.000000 +3 3 2 0.368466 0.000000 +3 3 3 -0.119695 0.000000 diff --git a/gala/source/tests/potential/scf/data/simple-hernquist-accp.dat.gz b/gala/source/tests/potential/scf/data/simple-hernquist-accp.dat.gz new file mode 100644 index 0000000000000000000000000000000000000000..c6002442fedc754407611c42c27786911c000430 --- /dev/null +++ b/gala/source/tests/potential/scf/data/simple-hernquist-accp.dat.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:991678077bddf53f375abbc25b20184bf1e102d191b9ed06c4b4b602a8a18ec1 +size 257329 diff --git a/gala/source/tests/potential/scf/data/simple-hernquist.coeff b/gala/source/tests/potential/scf/data/simple-hernquist.coeff new file mode 100644 index 0000000000000000000000000000000000000000..a7d34e3ca39bdca66c85b051a00fab61ece8557a --- /dev/null +++ b/gala/source/tests/potential/scf/data/simple-hernquist.coeff @@ -0,0 +1,2 @@ +1 +0 0 0 1.0 0.0 diff --git a/gala/source/tests/potential/scf/data/simple-nonsph-accp.dat.gz b/gala/source/tests/potential/scf/data/simple-nonsph-accp.dat.gz new file mode 100644 index 0000000000000000000000000000000000000000..7dd64e9d84b3bd5e1441874413f9eeea6d1f6e18 --- /dev/null +++ b/gala/source/tests/potential/scf/data/simple-nonsph-accp.dat.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32cc6512555708ebbfbcd425b9e5bb5204c7578449a932bdf7f9229655c51dd4 +size 350877 diff --git a/gala/source/tests/potential/scf/data/simple-nonsph.coeff b/gala/source/tests/potential/scf/data/simple-nonsph.coeff new file mode 100644 index 0000000000000000000000000000000000000000..2828a4131dc91262416de407f922a05cae12ee36 --- /dev/null +++ b/gala/source/tests/potential/scf/data/simple-nonsph.coeff @@ -0,0 +1,2 @@ +1 +0 1 1 1.0 0.0 diff --git a/gala/source/tests/potential/scf/data/wang-zhao-accp.dat.gz b/gala/source/tests/potential/scf/data/wang-zhao-accp.dat.gz new file mode 100644 index 0000000000000000000000000000000000000000..9b2b478732ff4035017de0cf54a838040087e4a9 --- /dev/null +++ b/gala/source/tests/potential/scf/data/wang-zhao-accp.dat.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2051a3b26ab0f2320150d93bfaad00b41215c4bbd6019788803dc6fe16a9f530 +size 342217 diff --git a/gala/source/tests/potential/scf/data/wang-zhao.coeff b/gala/source/tests/potential/scf/data/wang-zhao.coeff new file mode 100644 index 0000000000000000000000000000000000000000..abd94b386f49d6231728bdf4da4395bfb8a111af --- /dev/null +++ b/gala/source/tests/potential/scf/data/wang-zhao.coeff @@ -0,0 +1,21 @@ +20 +0 0 0 1.509000 0.000000 +1 0 0 -0.086000 0.000000 +2 0 0 -0.033000 0.000000 +3 0 0 -0.020000 0.000000 +0 2 0 -2.606000 0.000000 +1 2 0 -0.221000 0.000000 +2 2 0 -0.001000 0.000000 +0 2 2 0.665000 0.000000 +1 2 2 0.129000 0.000000 +2 2 2 0.006000 0.000000 +0 4 0 6.406000 0.000000 +1 4 0 1.295000 0.000000 +0 4 2 -0.660000 0.000000 +1 4 2 -0.140000 0.000000 +0 4 4 0.044000 0.000000 +1 4 4 -0.012000 0.000000 +0 6 0 -5.859000 0.000000 +0 6 2 0.984000 0.000000 +0 6 4 -0.030000 0.000000 +0 6 6 0.001000 0.000000 diff --git a/gala/source/tests/potential/scf/test_accp_fortran.py b/gala/source/tests/potential/scf/test_accp_fortran.py new file mode 100644 index 0000000000000000000000000000000000000000..c259dfeec71768fc148d02cd99ecd57eabfd2130 --- /dev/null +++ b/gala/source/tests/potential/scf/test_accp_fortran.py @@ -0,0 +1,156 @@ +from math import factorial as _factorial +from pathlib import Path + +import numpy as np +import pytest +from astropy.constants import G as _G +from gala._cconfig import GSL_ENABLED +from gala.potential.scf._bfe import density, gradient, potential + +from gala.units import galactic + +this_path = Path(__file__).parent + +G = _G.decompose(galactic).value + +if not GSL_ENABLED: + pytest.skip("skipping SCF tests: they depend on GSL", allow_module_level=True) + + +def factorial(x): + return _factorial(int(x)) + + +@pytest.mark.parametrize( + "basename", + [ + "simple-hernquist", + "multi-hernquist", + "simple-nonsph", + "random", + "wang-zhao", + ], +) +def test_density(basename): + pos_path = this_path / "data/positions.dat.gz" + coeff_path = this_path / f"data/{basename}.coeff" + accp_path = this_path / f"data/{basename}-accp.dat.gz" + + xyz = np.ascontiguousarray(np.loadtxt(pos_path, skiprows=1).T) + coeff = np.atleast_2d(np.loadtxt(coeff_path, skiprows=1)) + + nmax = coeff[:, 0].astype(int).max() + lmax = coeff[:, 1].astype(int).max() + + cos_coeff = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + sin_coeff = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + for row in coeff: + n, l, m, cc, sc = row + + # transform from H&O 1992 coefficients to Lowing 2011 coefficients + if l != 0: + fac = np.sqrt(4 * np.pi) * np.sqrt( + (2 * l + 1) / (4 * np.pi) * factorial(l - m) / factorial(l + m) + ) + cc /= fac + sc /= fac + + cos_coeff[int(n), int(l), int(m)] = cc + sin_coeff[int(n), int(l), int(m)] = sc + + dens = density(xyz, M=1.0, r_s=1.0, Snlm=cos_coeff, Tnlm=sin_coeff) + + # TODO: nothing to compare this to.... + # just test that it runs... + + +@pytest.mark.parametrize( + "basename", + [ + "simple-hernquist", + "multi-hernquist", + "simple-nonsph", + "random", + "wang-zhao", + ], +) +def test_potential(basename): + coeff_path = this_path / f"data/{basename}.coeff" + accp_path = this_path / f"data/{basename}-accp.dat.gz" + + coeff = np.atleast_2d(np.loadtxt(coeff_path, skiprows=1)) + accp = np.loadtxt(accp_path) + + pos_path = this_path / "data/positions.dat.gz" + xyz = np.loadtxt(pos_path, skiprows=1) + + nmax = coeff[:, 0].astype(int).max() + lmax = coeff[:, 1].astype(int).max() + + cos_coeff = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + sin_coeff = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + for row in coeff: + n, l, m, cc, sc = row + + # transform from H&O 1992 coefficients to Lowing 2011 coefficients + if l != 0: + fac = np.sqrt(4 * np.pi) * np.sqrt( + (2 * l + 1) / (4 * np.pi) * factorial(l - m) / factorial(l + m) + ) + cc /= fac + sc /= fac + + cos_coeff[int(n), int(l), int(m)] = cc + sin_coeff[int(n), int(l), int(m)] = sc + + potv = potential(xyz, G=1.0, M=1.0, r_s=1.0, Snlm=cos_coeff, Tnlm=sin_coeff) + + # for some reason, SCF potential is -potential + scf_potv = -accp[:, -1] + np.testing.assert_allclose(potv, scf_potv, rtol=1e-6) + + +@pytest.mark.parametrize( + "basename", + [ + "simple-hernquist", + "multi-hernquist", + "simple-nonsph", + "random", + "wang-zhao", + ], +) +def test_gradient(basename): + pos_path = this_path / "data/positions.dat.gz" + coeff_path = this_path / f"data/{basename}.coeff" + accp_path = this_path / f"data/{basename}-accp.dat.gz" + + xyz = np.loadtxt(pos_path, skiprows=1) + coeff = np.atleast_2d(np.loadtxt(coeff_path, skiprows=1)) + accp = np.loadtxt(accp_path) + + nmax = coeff[:, 0].astype(int).max() + lmax = coeff[:, 1].astype(int).max() + + cos_coeff = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + sin_coeff = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + for row in coeff: + n, l, m, cc, sc = row + + # transform from H&O 1992 coefficients to Lowing 2011 coefficients + if l != 0: + fac = np.sqrt(4 * np.pi) * np.sqrt( + (2 * l + 1) / (4 * np.pi) * factorial(l - m) / factorial(l + m) + ) + cc /= fac + sc /= fac + + cos_coeff[int(n), int(l), int(m)] = cc + sin_coeff[int(n), int(l), int(m)] = sc + + grad = gradient(xyz, G=1.0, M=1.0, r_s=1.0, Snlm=cos_coeff, Tnlm=sin_coeff) + + # I output the acceleration from SCF when I make the files + # so I have no idea why I don't need a minus sign here... + scf_grad = accp[:, :3] + np.testing.assert_allclose(grad, scf_grad, rtol=1e-6) diff --git a/gala/source/tests/potential/scf/test_bfe.py b/gala/source/tests/potential/scf/test_bfe.py new file mode 100644 index 0000000000000000000000000000000000000000..54d8ce34ec1a6f10533f9cff71f6585c43a49c44 --- /dev/null +++ b/gala/source/tests/potential/scf/test_bfe.py @@ -0,0 +1,223 @@ +import astropy.units as u +import numpy as np +import pytest +from astropy.constants import G as _G +from gala._cconfig import GSL_ENABLED +from gala.potential.scf._bfe import density, gradient, potential + +from gala.units import galactic + +G = _G.decompose(galactic).value + +if not GSL_ENABLED: + pytest.skip("skipping SCF tests: they depend on GSL", allow_module_level=True) + + +# Check that we get A000=1. for putting in hernquist density +def hernquist_density(xyz, M, r_s): + r = np.sqrt(np.sum(xyz**2, axis=0)) + return M / (2 * np.pi) * r_s / (r * (r + r_s) ** 3) + + +def hernquist_potential(xyz, M, r_s): + r = np.sqrt(np.sum(xyz**2, axis=0)) + return -G * M / (r + r_s) + + +def hernquist_gradient(xyz, M, r_s): + import gala.potential as gp + + p = gp.HernquistPotential(m=M, c=r_s, units=[u.kpc, u.Myr, u.Msun, u.radian]) + return p.gradient(xyz).value + + +def test_hernquist(): + nmax = 6 + lmax = 2 + + Snlm = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + Tnlm = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + Snlm[0, 0, 0] = 1.0 + + M = 1e10 + r_s = 3.5 + + nbins = 128 + rr = np.linspace(0.1, 10.0, nbins) + xyz = np.zeros((nbins, 3)) + xyz[:, 0] = rr * np.cos(np.pi / 4.0) * np.sin(np.pi / 4.0) + xyz[:, 1] = rr * np.sin(np.pi / 4.0) * np.sin(np.pi / 4.0) + xyz[:, 2] = rr * np.cos(np.pi / 4.0) + + bfe_dens = density(xyz, Snlm, Tnlm, M=M, r_s=r_s) + true_dens = hernquist_density(xyz.T, M, r_s) + np.testing.assert_allclose(bfe_dens, true_dens) + + bfe_pot = potential(xyz, Snlm, Tnlm, G=G, M=M, r_s=r_s) + true_pot = hernquist_potential(xyz.T, M, r_s) + np.testing.assert_allclose(bfe_pot, true_pot) + + bfe_grad = gradient(xyz, Snlm, Tnlm, G=G, M=M, r_s=r_s) + true_grad = hernquist_gradient(xyz.T, M, r_s) + np.testing.assert_allclose(bfe_grad.T, true_grad) + + +def pure_py(xyz, Snlm, Tnlm, nmax, lmax): + from math import factorial as f + + from scipy.special import eval_gegenbauer, gamma, gegenbauer, lpmv + + def Plm(l, m, costh): + return lpmv(m, l, costh) + + def Ylmth(l, m, costh): + return np.sqrt((2 * l + 1) / (4 * np.pi) * f(l - m) / f(l + m)) * Plm( + l, m, costh + ) + + twopi = 2 * np.pi + sqrtpi = np.sqrt(np.pi) + sqrt4pi = np.sqrt(4 * np.pi) + + r = np.sqrt(np.sum(xyz**2, axis=0)) + X = xyz[2] / r # cos(theta) + sinth = np.sqrt(1 - X**2) + phi = np.arctan2(xyz[1], xyz[0]) + xsi = (r - 1) / (r + 1) + + density = 0 + potenti = 0 + gradien = np.zeros_like(xyz) + sph_gradien = np.zeros_like(xyz) + for l in range(lmax + 1): + r_term1 = r**l / (r * (1 + r) ** (2 * l + 3)) + r_term2 = r**l / (1 + r) ** (2 * l + 1) + for m in range(l + 1): + for n in range(nmax + 1): + Cn = gegenbauer(n, 2 * l + 3 / 2) + Knl = 0.5 * n * (n + 4 * l + 3) + (l + 1) * (2 * l + 1) + rho_nl = Knl / twopi * sqrt4pi * r_term1 * Cn(xsi) + phi_nl = -sqrt4pi * r_term2 * Cn(xsi) + + density += ( + rho_nl + * Ylmth(l, m, X) + * ( + Snlm[n, l, m] * np.cos(m * phi) + + Tnlm[n, l, m] * np.sin(m * phi) + ) + ) + potenti += ( + phi_nl + * Ylmth(l, m, X) + * ( + Snlm[n, l, m] * np.cos(m * phi) + + Tnlm[n, l, m] * np.sin(m * phi) + ) + ) + + # derivatives + dphinl_dr = ( + 2 + * sqrtpi + * np.power(r, -1 + l) + * np.power(1 + r, -3 - 2 * l) + * ( + -2 + * (3 + 4 * l) + * r + * eval_gegenbauer(-1 + n, 2.5 + 2 * l, (-1 + r) / (1 + r)) + + (1 + r) + * (l * (-1 + r) + r) + * eval_gegenbauer(n, 1.5 + 2 * l, (-1 + r) / (1 + r)) + ) + ) + sph_gradien[0] += ( + dphinl_dr + * Ylmth(l, m, X) + * ( + Snlm[n, l, m] * np.cos(m * phi) + + Tnlm[n, l, m] * np.sin(m * phi) + ) + ) + + A = np.sqrt((2 * l + 1) / (4 * np.pi)) * np.sqrt( + gamma(l - m + 1) / gamma(l + m + 1) + ) + dYlm_dth = ( + A / sinth * (l * X * Plm(l, m, X) - (l + m) * Plm(l - 1, m, X)) + ) + sph_gradien[1] += ( + (1 / r) + * dYlm_dth + * phi_nl + * ( + Snlm[n, l, m] * np.cos(m * phi) + + Tnlm[n, l, m] * np.sin(m * phi) + ) + ) + + sph_gradien[2] += ( + (m / (r * sinth)) + * phi_nl + * Ylmth(l, m, X) + * ( + -Snlm[n, l, m] * np.sin(m * phi) + + Tnlm[n, l, m] * np.cos(m * phi) + ) + ) + + cosphi = np.cos(phi) + sinphi = np.sin(phi) + gradien[0] = ( + sinth * cosphi * sph_gradien[0] + + X * cosphi * sph_gradien[1] + - sinphi * sph_gradien[2] + ) + gradien[1] = ( + sinth * sinphi * sph_gradien[0] + + X * sinphi * sph_gradien[1] + + cosphi * sph_gradien[2] + ) + gradien[2] = X * sph_gradien[0] - sinth * sph_gradien[1] + + return density, potenti, gradien + + +def test_pure_py(): + nmax = 6 + lmax = 4 + + # xyz = np.array([[1., 0., 1.], + # [1., 1., 0.], + # [0., 1., 1.]]) + xyz = np.random.uniform(-2.0, 2.0, size=(128, 3)) + + # first try spherical: + Snlm = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + Snlm[:, 0, 0] = np.logspace(0.0, -4, nmax + 1) + Tnlm = np.zeros_like(Snlm) + + py_den, py_pot, py_grd = pure_py(xyz.T, Snlm, Tnlm, nmax, lmax) + + cy_den = density(xyz, Snlm, Tnlm, M=1.0, r_s=1.0) + cy_pot = potential(xyz, Snlm, Tnlm, G=1.0, M=1.0, r_s=1.0) + cy_grd = gradient(xyz, Snlm, Tnlm, G=1.0, M=1.0, r_s=1.0).T + + assert np.allclose(py_den, cy_den) + assert np.allclose(py_pot, cy_pot) + assert np.allclose(py_grd, cy_grd) + + # non-spherical: + Snlm = np.random.uniform(-1, 1, size=(nmax + 1, lmax + 1, lmax + 1)) + Tnlm = np.zeros_like(Snlm) + + py_den, py_pot, py_grd = pure_py(xyz.T, Snlm, Tnlm, nmax, lmax) + + cy_den = density(xyz, Snlm, Tnlm, M=1.0, r_s=1.0) + cy_pot = potential(xyz, Snlm, Tnlm, G=1.0, M=1.0, r_s=1.0) + cy_grd = gradient(xyz, Snlm, Tnlm, G=1.0, M=1.0, r_s=1.0).T + + assert np.allclose(py_den, cy_den) + assert np.allclose(py_pot, cy_pot) + assert np.allclose(py_grd, cy_grd) diff --git a/gala/source/tests/potential/scf/test_bfe_interp.py b/gala/source/tests/potential/scf/test_bfe_interp.py new file mode 100644 index 0000000000000000000000000000000000000000..12444ea5bdf5edd808fa3e52e3b6657807da05e5 --- /dev/null +++ b/gala/source/tests/potential/scf/test_bfe_interp.py @@ -0,0 +1,49 @@ +import astropy.units as u +import numpy as np +import pytest +from gala._cconfig import GSL_ENABLED + +from gala.potential.scf import SCFInterpolatedPotential, SCFPotential +from gala.units import galactic + +if not GSL_ENABLED: + pytest.skip("skipping SCF tests: they depend on GSL", allow_module_level=True) + + +@pytest.mark.parametrize("func_name", ["energy", "density", "gradient"]) +def test_simple_compare_noninterp(func_name): + """ + Compare the interpolated to time-invariant versions for a trivial case + """ + rng = np.random.default_rng(42) + nmax = 5 + lmax = 3 + + Snlm = rng.uniform(size=(nmax + 1, lmax + 1, lmax + 1)) + Tnlm = np.zeros_like(Snlm) + + tj = np.linspace(0, 1000, 16) + Sjnlm = np.repeat(Snlm[None], len(tj), axis=0) + Tjnlm = np.repeat(Tnlm[None], len(tj), axis=0) + + m = 1e9 + r_s = 10.0 + + pot_static = SCFPotential(m=m, r_s=r_s, Snlm=Snlm, Tnlm=Tnlm, units=galactic) + pot_t = SCFInterpolatedPotential( + m=m, + r_s=r_s, + tj=tj, + Sjnlm=Sjnlm, + Tjnlm=Tjnlm, + units=galactic, + com_xj=np.zeros((3, len(tj))), + com_vj=np.zeros((3, len(tj))), + ) + + test_xyz = rng.uniform(-10, 10, size=(3, 10)) + test_t = rng.uniform(0, 1000, size=16) + for t in test_t: + t_val = getattr(pot_t, func_name)(test_xyz, t=t) + static_val = getattr(pot_static, func_name)(test_xyz, t=t) + assert u.allclose(t_val, static_val) diff --git a/gala/source/tests/potential/scf/test_class.py b/gala/source/tests/potential/scf/test_class.py new file mode 100644 index 0000000000000000000000000000000000000000..742bb28eddab43779851ecc6c90451516b285792 --- /dev/null +++ b/gala/source/tests/potential/scf/test_class.py @@ -0,0 +1,116 @@ +import sys +from pathlib import Path + +import astropy.units as u +import numpy as np +import pytest +from astropy.constants import G as _G +from gala._cconfig import GSL_ENABLED + +import gala.potential as gp +from gala.potential.potential.io import load +from gala.potential.scf import _bfe_class +from gala.units import galactic + +# HACK: +this_path = Path(__file__).parent +sys.path.insert(0, str(this_path / "../potential")) + +from potential_helpers import PotentialTestBase # noqa: E402 + +G = _G.decompose(galactic).value + +if not GSL_ENABLED: + pytest.skip("skipping SCF tests: they depend on GSL", allow_module_level=True) + + +def test_hernquist(): + nmax = 6 + lmax = 2 + + M = 1e10 + r_s = 3.5 + + cos_coeff = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + sin_coeff = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + cos_coeff[0, 0, 0] = 1.0 + scf_potential = _bfe_class.SCFPotential( + m=M, r_s=r_s, Snlm=cos_coeff, Tnlm=sin_coeff, units=galactic + ) + # scf_potential = HackPotential(m=10., units=galactic) + + nbins = 128 + rr = np.linspace(0.1, 10.0, nbins) + xyz = np.zeros((3, nbins)) + xyz[0] = rr * np.cos(np.pi / 4.0) * np.sin(np.pi / 4.0) + xyz[1] = rr * np.sin(np.pi / 4.0) * np.sin(np.pi / 4.0) + xyz[2] = rr * np.cos(np.pi / 4.0) + + hernquist = gp.HernquistPotential(m=M, c=r_s, units=galactic) + + bfe_pot = scf_potential.energy(xyz).value + true_pot = hernquist.energy(xyz).value + np.testing.assert_allclose(bfe_pot, true_pot) + + bfe_grad = scf_potential.gradient(xyz).value + true_grad = hernquist.gradient(xyz).value + np.testing.assert_allclose(bfe_grad, true_grad) + + +class TestSCFPotential(PotentialTestBase): + nmax = 6 + lmax = 2 + Snlm = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + Tnlm = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + Snlm[0, 0, 0] = 1.0 + Snlm[2, 0, 0] = 0.5 + Snlm[4, 0, 0] = 0.25 + + potential = _bfe_class.SCFPotential( + m=1e11 * u.Msun, r_s=10 * u.kpc, Snlm=Snlm, Tnlm=Tnlm, units=galactic + ) + w0 = [4.0, 0.7, -0.9, 0.0352238, 0.1579493, 0.02] + + skip_density = True + + def test_save_load(self, tmpdir): + fn = str(tmpdir.join(f"{self.name}.yml")) + self.potential.save(fn) + p = load(fn, module=_bfe_class) + p.energy(self.w0[: self.w0.size // 2]) + + @pytest.mark.skipif(True, reason="no hessian implemented") + def test_hessian(self): + pass + + @pytest.mark.skip(reason="to_sympy() not implemented yet") + def test_against_sympy(self): + pass + + def test_compare(self): + # skip if composite potentials + if len(self.potential.parameters) == 0: + return + + other = self.potential.__class__( + units=self.potential.units, **self.potential.parameters + ) + assert other == self.potential + + pars = self.potential.parameters.copy() + for k in pars: + if k != 0: + pars[k] = pars[k] * 1.1 # fmt: skip, ruff: noqa + + other = self.potential.__class__(units=self.potential.units, **pars) + assert other != self.potential + + def test_replace_units(self): + H = gp.Hamiltonian(self.potential) + H2 = gp.Hamiltonian(self.potential.replace_units(self.potential.units)) + + ww = [20.0, 10, 10, 0, 0.2, 0] + w1 = H.integrate_orbit(ww, t=np.array([0, 1.0]))[-1].w(galactic).T + w2 = H2.integrate_orbit(ww, t=np.array([0, 1.0]))[-1].w(galactic).T + + assert np.allclose(w1, w2) diff --git a/gala/source/tests/potential/scf/test_computecoeff.py b/gala/source/tests/potential/scf/test_computecoeff.py new file mode 100644 index 0000000000000000000000000000000000000000..35de558fe42922bf8135dbcf7919f77cca50d32c --- /dev/null +++ b/gala/source/tests/potential/scf/test_computecoeff.py @@ -0,0 +1,300 @@ +from pathlib import Path + +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np +import pytest +from astropy.constants import G as _G +from gala._cconfig import GSL_ENABLED +from gala.potential.scf._bfe import density, gradient, potential +from scipy.integrate import quad + +import gala.potential as gp +from gala.potential.scf.core import compute_coeffs +from gala.units import galactic + +this_path = Path(__file__).parent + +G = _G.decompose(galactic).value + +if not GSL_ENABLED: + pytest.skip("skipping SCF tests: they depend on GSL", allow_module_level=True) + + +# Check that we get A000=1. for putting in hernquist density +def hernquist_density(x, y, z, M, r_s): + r = np.sqrt(x**2 + y**2 + z**2) + return M / (2 * np.pi) * r_s / (r * (r + r_s) ** 3) + + +def test_hernquist(): + for M in [1e5, 1e10]: + for r_s in np.logspace(-1, 2, 4): + (S, Serr), (T, Terr) = compute_coeffs( + hernquist_density, nmax=0, lmax=0, M=M, r_s=r_s, args=(M, r_s) + ) + + np.testing.assert_allclose(S, 1.0) + np.testing.assert_allclose(Serr, 0.0, atol=1e-10) + + np.testing.assert_allclose(T, 0.0) + np.testing.assert_allclose(Terr, 0.0, atol=1e-10) + + +def test_hernquist_spherical(): + (S, Serr), (T, Terr) = compute_coeffs( + hernquist_density, nmax=8, lmax=8, M=1.0, r_s=1.0, args=(1.0, 1.0), skip_m=True + ) + + np.testing.assert_allclose(S[0, 0, 0], 1.0, atol=1e-13) + np.testing.assert_allclose(S[1:, :, :], 0.0, atol=1e-13) + np.testing.assert_allclose(Serr, 0.0, atol=1e-10) + + np.testing.assert_allclose(T, 0.0, atol=1e-13) + np.testing.assert_allclose(Terr, 0.0, atol=1e-10) + + +# ---------------------------------------------------------------------------- + + +def _plummer_density(x, y, z, M, r_s): + r2 = x * x + y * y + z * z + return (3 * M / (4 * np.pi * r_s**3)) * (1 + r2 / r_s**2) ** (-5 / 2.0) + + +def test_plummer(): + true_M = 1 / G + true_r_s = 1.0 + + x = np.logspace(-2, 1, 512) + xyz = np.zeros((len(x), 3)) + xyz[:, 0] = x + + pot = gp.PlummerPotential(m=true_M, b=true_r_s, units=galactic) + true_pot = pot.energy(xyz.T).value + true_dens = pot.density(xyz.T).value + true_grad = pot.gradient(xyz.T).value.T + + nmax = 16 + lmax = 0 + + (S, _S_err), (T, _T_err) = compute_coeffs( + _plummer_density, + nmax=nmax, + lmax=lmax, + M=true_M, + r_s=true_r_s, + args=(true_M, true_r_s), + epsrel=1e-9, + ) + + bfe_dens = density(xyz, S, T, true_M, true_r_s) + bfe_pot = potential(xyz, S, T, G, true_M, true_r_s) + bfe_grad = gradient(xyz, S, T, G, true_M, true_r_s) + + # fig, axes = pl.subplots(3, 1, figsize=(6, 12), sharex=True) + + # axes[0].loglog(x, true_dens) + # axes[0].loglog(x, bfe_dens) + + # axes[1].semilogx(x, true_pot) + # axes[1].semilogx(x, bfe_pot) + + # axes[2].semilogx(x, true_grad[:, 0]) + # axes[2].semilogx(x, bfe_grad[:, 0]) + + # pl.show() + + assert np.allclose(true_dens, bfe_dens, rtol=2e-3) + assert np.allclose(true_pot, bfe_pot, rtol=1e-6) + assert np.allclose(true_grad[:, 0], bfe_grad[:, 0], rtol=5e-3) + # print(np.abs((bfe_dens - true_dens) / true_dens).max()) + # print(np.abs((bfe_pot - true_pot) / true_pot).max()) + # print(np.abs((bfe_grad[:, 0] - true_grad[:, 0]) / true_grad[:, 0]).max()) + + +# ---------------------------------------------------------------------------- +# Non-spherical, axisymmetric + + +def flattened_hernquist_density_s(s, M, a, q): + return M * a / (2 * np.pi) / (s * (a + s) ** 3) + + +def flattened_hernquist_density(x, y, z, M, a, q): + s = np.sqrt(x * x + y * y + z * z / (q * q)) + return flattened_hernquist_density_s(s, M, a, q) + + +def _integrand_helper(tau, xyz, M, a, q): + x, y, z = xyz + m = a * np.sqrt((x * x + y * y) / (a * a + tau) + z * z / (q * q + tau)) + return flattened_hernquist_density_s(m, M, a, q) / ( + (tau + a * a) * np.sqrt(tau + q * q) + ) + + +def integrand(tau, i, xyz, M, a, q): + if i in {0, 1}: + denom = tau + a * a + elif i == 2: + denom = tau + q * q + else: + raise ValueError("WTF") + + return _integrand_helper(tau, xyz, M, a, q) * xyz[i] / denom + + +def flattened_hernquist_gradient(x, y, z, G, M, a, q): + A = 2 * np.pi * G * a**2 * q + gx = A * quad(integrand, 0, np.inf, args=(0, (x, y, z), M, a, q), limit=1000)[0] + gy = A * quad(integrand, 0, np.inf, args=(1, (x, y, z), M, a, q))[0] + gz = A * quad(integrand, 0, np.inf, args=(2, (x, y, z), M, a, q))[0] + + return np.array([gx, gy, gz]) + + +def test_flattened_hernquist(): + """ + This test compares the coefficients against some computed in the mathematica + notebook 'flattened-hernquist.nb'. nmax and lmax here must match nmax and lmax + in that notebook. + """ + + coeff_path = this_path / "data/Snlm-mathematica.csv" + + G = 1.0 + M = 1 + a = 1.0 + q = 0.9 + + # Note: this must be the same as in the mathematica notebook + nmax = 8 + lmax = 8 + + (Snlm, _Serr), (Tnlm, _Terr) = compute_coeffs( + flattened_hernquist_density, + nmax=nmax, + lmax=lmax, + skip_odd=True, + skip_m=True, + M=M, + r_s=a, + args=(M, a, q), + ) + + for l in range(1, lmax + 1, 2): + for m in range(lmax + 1): + assert Snlm[0, l, m] == 0.0 + + m_Snl0 = np.loadtxt(coeff_path, delimiter=",") + m_Snl0 = m_Snl0[:, ::2] # every other l + + assert np.allclose(Snlm[0, ::2, 0], m_Snl0[0]) + + # check that random points match in gradient and density + np.random.seed(42) + n_test = 1024 + r = 10.0 * np.cbrt(np.random.uniform(0.1**3, 1, size=n_test)) # 1 to 10 + t = np.arccos(2 * np.random.uniform(size=n_test) - 1) + ph = np.random.uniform(0, 2 * np.pi, size=n_test) + x = r * np.cos(ph) * np.sin(t) + y = r * np.sin(ph) * np.sin(t) + z = r * np.cos(t) + xyz = np.vstack((x, y, z)) + + # confirmed by testing... + tru_dens = flattened_hernquist_density(xyz[0], xyz[1], xyz[2], M, a, q) + bfe_dens = density(np.ascontiguousarray(xyz.T), Snlm, Tnlm, M, a) + assert np.all((np.abs(bfe_dens - tru_dens) / tru_dens) < 0.05) # <5% + + tru_grad = np.array( + [ + flattened_hernquist_gradient(xyz[0, i], xyz[1, i], xyz[2, i], G, M, a, q) + for i in range(xyz.shape[1]) + ] + ).T + bfe_grad = gradient(np.ascontiguousarray(xyz.T), Snlm, Tnlm, G, M, a).T + + # check what typical errors are + # for j in range(3): + # pl.hist(np.abs((bfe_grad[j]-tru_grad[j])/tru_grad[j])) + + for j in range(3): + assert np.all(np.abs((bfe_grad[j] - tru_grad[j]) / tru_grad[j]) < 0.005) # 0.5% + + return + + # ------------------------------------------------------------------------ + # plots: + + # coefficients + fig, ax = plt.subplots(1, 1, figsize=(10, 8)) + n, l = np.mgrid[: nmax + 1, : lmax + 1] + c = ax.scatter( + n.ravel(), + l.ravel(), + c=Snlm[:, :, 0].ravel(), + s=64, + norm=mpl.colors.SymLogNorm(1e-5), + cmap="RdBu_r", + vmin=-100, + vmax=100, + linewidths=1.0, + edgecolors="#666666", + ) + + ax.xaxis.set_ticks(np.arange(0, nmax + 1, 1)) + ax.yaxis.set_ticks(np.arange(0, lmax + 1, 1)) + + ax.set_xlim(-0.5, nmax + 0.5) + ax.set_ylim(-0.5, lmax + 0.5) + + ax.set_xlabel("$n$") + ax.set_ylabel("$l$") + + tickloc = np.concatenate( + (-(10.0 ** np.arange(2, -5 - 1, -1)), 10.0 ** np.arange(-5, 2 + 1, 1)) + ) + fig.colorbar(c, ticks=tickloc, format="%.0e") + fig.tight_layout() + + # contour plot in r, t at ph=0 + + rgrid = np.logspace(-1, 1.0, 128) + tgrid = np.linspace(0, np.pi, 128) + + r, t = np.meshgrid(rgrid, tgrid) + x = r * np.sin(t) + z = r * np.cos(t) + + xyz_ = np.vstack((x.ravel(), np.zeros_like(x.ravel()), z.ravel())) + bfe_dens = density(np.ascontiguousarray(xyz_.T), Snlm, Tnlm, M, a) + true_dens = flattened_hernquist_density(xyz_[0], xyz_[1], xyz_[2], M, a, q) + + fig, ax = plt.subplots(1, 1, figsize=(8, 8)) + + levels = 10 ** np.linspace(-4.5, 1, 16) + ax.contour( + np.log10(r), + t, + true_dens.reshape(x.shape), + levels=levels, + colors="k", + locator=mpl.ticker.LogLocator(), + label="True", + ) + ax.contour( + np.log10(r), + t, + bfe_dens.reshape(x.shape), + levels=levels, + colors="r", + locator=mpl.ticker.LogLocator(), + label="BFE", + ) + + ax.legend() + fig.tight_layout() + + plt.show() diff --git a/gala/source/tests/potential/scf/test_computecoeff_discrete.py b/gala/source/tests/potential/scf/test_computecoeff_discrete.py new file mode 100644 index 0000000000000000000000000000000000000000..7a4226a2b110ced5fce09c75b39e65268968efcc --- /dev/null +++ b/gala/source/tests/potential/scf/test_computecoeff_discrete.py @@ -0,0 +1,97 @@ +import multiprocessing +from pathlib import Path + +import numpy as np +import pytest +from astropy.constants import G +from gala._cconfig import GSL_ENABLED +from gala.potential.scf._bfe import potential + +import gala.potential as gp +from gala.potential.scf.core import compute_coeffs_discrete +from gala.units import galactic + +this_path = Path(__file__).parent + +_G = G.decompose(galactic).value + +if not GSL_ENABLED: + pytest.skip("skipping SCF tests: they depend on GSL", allow_module_level=True) + + +def test_plummer(): + pos_path = this_path / "data/plummer-pos.dat.gz" + + scfbi = np.loadtxt(pos_path) + m_k = scfbi[:, 0] * 10 # masses sum to 0.1 + xyz = scfbi[:, 1:4] + + G = 1.0 + r_s = 1.0 + M = m_k.sum() + pot = gp.PlummerPotential(m=1 / _G, b=r_s, units=galactic) + + nmax = 10 + lmax = 0 + + Snlm, Tnlm = compute_coeffs_discrete(xyz, m_k, nmax=nmax, lmax=lmax, r_s=r_s) + + x = np.logspace(-2, 1, 512) + xyz = np.zeros((len(x), 3)) + xyz[:, 0] = x + + # plot discrete vs. analytic potential + true_pot = pot.energy(xyz.T).value + bfe_pot = potential(xyz, Snlm, Tnlm, G, M, r_s) + + assert np.allclose(true_pot, bfe_pot, rtol=1e-2) + + +@pytest.mark.parametrize("pool", [None, multiprocessing.Pool(2)]) +def test_coefficients(pool): + pos_path = this_path / "data/plummer-pos.dat.gz" + coeff_path = this_path / "data/plummer_coeff_nmax10_lmax5.txt" + scfbi = np.loadtxt(pos_path) + m_k = scfbi[:, 0] # masses sum to 0.1 + xyz = scfbi[:, 1:4] + + scfcoeff = np.loadtxt(coeff_path) + Snlm_true = scfcoeff[:, 0] + Tnlm_true = scfcoeff[:, 1] + + r_s = 1.0 + nmax = 10 + lmax = 5 + + Snlm, Tnlm = compute_coeffs_discrete( + xyz, m_k, nmax=nmax, lmax=lmax, r_s=r_s, pool=pool + ) + + assert np.allclose(Snlm_true, Snlm.flatten(), rtol=1e-3) + assert np.allclose(Tnlm_true, Tnlm.flatten(), rtol=1e-3) + + +@pytest.mark.parametrize("pool", [None, multiprocessing.Pool(2)]) +def test_coeff_variances(pool): + pos_path = this_path / "data/plummer-pos.dat.gz" + coeff_path = this_path / "data/plummer_coeff_var_nmax10_lmax5.txt" + + scfbi = np.loadtxt(pos_path) + m_k = scfbi[:, 0] # masses sum to 0.1 + xyz = scfbi[:, 1:4] + + scfcoeff = np.loadtxt(coeff_path) + Snlm_var_true = scfcoeff[:, 0] + Tnlm_var_true = scfcoeff[:, 1] + STnlm_var_true = scfcoeff[:, 2] + + r_s = 1.0 + nmax = 10 + lmax = 5 + + *_, STnlm_Cov = compute_coeffs_discrete( + xyz, m_k, nmax=nmax, lmax=lmax, r_s=r_s, compute_var=True, pool=pool + ) + assert np.allclose(Snlm_var_true, STnlm_Cov[0, 0].flatten(), rtol=1e-3) + assert np.allclose(Tnlm_var_true, STnlm_Cov[1, 1].flatten(), rtol=1e-3) + assert np.allclose(STnlm_var_true, STnlm_Cov[0, 1].flatten(), rtol=1e-3) diff --git a/gala/source/tests/potential/scf/test_computecoeff_fortran.py b/gala/source/tests/potential/scf/test_computecoeff_fortran.py new file mode 100644 index 0000000000000000000000000000000000000000..9c68a20fadc2c5bcd6ad8beba1c52fda584620ec --- /dev/null +++ b/gala/source/tests/potential/scf/test_computecoeff_fortran.py @@ -0,0 +1,57 @@ +from math import factorial as _factorial +from pathlib import Path + +import numpy as np +import pytest +from gala._cconfig import GSL_ENABLED + +from gala.potential.scf.core import compute_coeffs_discrete + +this_path = Path(__file__).parent + +if not GSL_ENABLED: + pytest.skip("skipping SCF tests: they depend on GSL", allow_module_level=True) + +# Compare coefficients computed with Fortran to Biff + + +def factorial(x): + return _factorial(int(x)) + + +@pytest.mark.parametrize("basename", ["hernquist"]) +def test_coeff(basename): + nmax = 6 + lmax = 10 # HACK: these are hard-set in Fortran + + pos_path = this_path / f"data/{basename}-samples.dat.gz" + coeff_path = this_path / f"data/computed-{basename}.coeff" + + coeff = np.atleast_2d(np.loadtxt(coeff_path)) + + xyz = np.ascontiguousarray(np.loadtxt(pos_path, skiprows=1)) + S, _T = compute_coeffs_discrete( + xyz, + mass=np.zeros(xyz.shape[0]) + 1.0 / xyz.shape[0], + nmax=nmax, + lmax=lmax, + r_s=1.0, + ) + + S_f77 = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + T_f77 = np.zeros((nmax + 1, lmax + 1, lmax + 1)) + for row in coeff: + n, l, m, cc, sc = row + + # transform from H&O 1992 coefficients to Lowing 2011 coefficients + if l != 0: + fac = np.sqrt(4 * np.pi) * np.sqrt( + (2 * l + 1) / (4 * np.pi) * factorial(l - m) / factorial(l + m) + ) + cc /= fac + sc /= fac + + S_f77[int(n), int(l), int(m)] = -cc + T_f77[int(n), int(l), int(m)] = -sc + + assert np.allclose(S_f77, S) diff --git a/gala/source/tests/regression/test_potential_timeinterpolated_539.py b/gala/source/tests/regression/test_potential_timeinterpolated_539.py new file mode 100644 index 0000000000000000000000000000000000000000..6651b4375412318b8114b2ae5b9f43d5a84776f9 --- /dev/null +++ b/gala/source/tests/regression/test_potential_timeinterpolated_539.py @@ -0,0 +1,26 @@ +import pickle + +import astropy.units as u +import numpy as np +import pytest +from gala._cconfig import GSL_ENABLED + +import gala.potential as gp + + +@pytest.mark.skipif( + not GSL_ENABLED, + reason="requires Gala compiled with GSL support", +) +def test_timeinterpolated_pickle(tmpdir): + # construct a simple time-evolving NFW potential + times = np.linspace(0, 10, 100) * u.Gyr + masses = np.linspace(1e11, 5e11, 100) * u.Msun + pot = gp.TimeInterpolatedPotential( + gp.NFWPotential, times, m=masses, r_s=20 * u.kpc, units="galactic" + ) + with open(tmpdir.join("time_interp_pot.pkl"), "wb") as f: + pickle.dump(pot, f) + + with open(tmpdir.join("time_interp_pot.pkl"), "rb") as f: + pot = pickle.load(f) diff --git a/gala/source/tests/test_units.py b/gala/source/tests/test_units.py new file mode 100644 index 0000000000000000000000000000000000000000..a9f8b8fd3fe87c759cd3c1d58feab3e9fa5dd7d0 --- /dev/null +++ b/gala/source/tests/test_units.py @@ -0,0 +1,119 @@ +""" +Test the unit system. +""" + +import itertools +import pickle + +import astropy.units as u +import numpy as np +import pytest +from astropy.constants import G, c + +from gala.units import DimensionlessUnitSystem, SimulationUnitSystem, UnitSystem + + +def test_create(): + # dumb + usys = UnitSystem(u.kpc, u.Myr, u.radian, u.Msun) + + with pytest.raises(ValueError): + UnitSystem(u.kpc, u.Myr, u.radian) # no mass + + with pytest.raises(ValueError): + UnitSystem(u.kpc, u.Myr, u.Msun) + + with pytest.raises(ValueError): + UnitSystem(u.kpc, u.radian, u.Msun) + + with pytest.raises(ValueError): + UnitSystem(u.Myr, u.radian, u.Msun) + + usys = UnitSystem((u.kpc, u.Myr, u.radian, u.Msun)) + usys = UnitSystem(usys) + + +def test_constants(): + usys = UnitSystem(u.kpc, u.Myr, u.radian, u.Msun) + assert np.allclose( + usys.get_constant("G"), G.decompose([u.kpc, u.Myr, u.radian, u.Msun]).value + ) + assert np.allclose( + usys.get_constant("c"), c.decompose([u.kpc, u.Myr, u.radian, u.Msun]).value + ) + + +def test_decompose(): + usys = UnitSystem(u.kpc, u.Myr, u.radian, u.Msun, u.km / u.s) + q = 15.0 * u.km / u.s + assert q.decompose(usys).unit == u.kpc / u.Myr # uses the core units + assert usys.decompose(q).unit == u.km / u.s + + +def test_dimensionless(): + usys = DimensionlessUnitSystem() + assert usys["dimensionless"] == u.one + assert usys["length"] == u.one + + with pytest.raises(ValueError): + (15 * u.kpc).decompose(usys) + + with pytest.raises(ValueError): + usys.decompose(15 * u.kpc) + + +@pytest.mark.parametrize( + ("nu1", "nu2"), + itertools.combinations( + { + "length": 15 * u.kpc, + "mass": 1e6 * u.Msun, + "time": 5e2 * u.Myr, + "velocity": 150 * u.km / u.s, + }.items(), + 2, + ), +) +def test_simulation(nu1, nu2): + print(nu1, nu2) + name1, unit1 = nu1 + name2, unit2 = nu2 + usys = SimulationUnitSystem(**{name1: unit1, name2: unit2}) + assert np.isclose(usys.get_constant("G"), 1.0) + + usys = SimulationUnitSystem(**{name1: unit1, name2: unit2}, G=2.4) + assert np.isclose(usys.get_constant("G"), 2.4) + + +def test_compare(): + usys1 = UnitSystem(u.kpc, u.Myr, u.radian, u.Msun, u.mas / u.yr) + usys1_clone = UnitSystem(u.kpc, u.Myr, u.radian, u.Msun, u.mas / u.yr) + + usys2 = UnitSystem(u.kpc, u.Myr, u.radian, u.Msun, u.kiloarcsecond / u.yr) + usys3 = UnitSystem(u.kpc, u.Myr, u.radian, u.kg, u.mas / u.yr) + + assert usys1 == usys1_clone + assert usys1_clone == usys1 + + assert usys1 != usys2 + assert usys2 != usys1 + + assert usys1 != usys3 + assert usys3 != usys1 + + +def test_pickle(tmpdir): + usys = UnitSystem(u.kpc, u.Myr, u.radian, u.Msun) + + with open(tmpdir / "test.pkl", "wb") as f: + pickle.dump(usys, f) + + with open(tmpdir / "test.pkl", "rb") as f: + usys2 = pickle.load(f) + + +def test_quantity_units(): + usys = UnitSystem(5 * u.kpc, 50 * u.Myr, 1e5 * u.Msun, u.rad) + + assert np.isclose((8 * u.Myr).decompose(usys).value, 8 / 50) + usys.get_constant("G") diff --git a/gala/source/uv.lock b/gala/source/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..21f5c3dd2938eeff9cc056a9ebb45aa8057f1553 --- /dev/null +++ b/gala/source/uv.lock @@ -0,0 +1,2805 @@ +version = 1 +revision = 1 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903 }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929 }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321 }, +] + +[[package]] +name = "astropy" +version = "6.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astropy-iers-data" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyerfa" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/f8/9c6675ab4c646b95aae2762d108f6be4504033d91bd50da21daa62cab5ce/astropy-6.1.7.tar.gz", hash = "sha256:a405ac186306b6cb152e6df2f7444ab8bd764e4127d7519da1b3ae4dd65357ef", size = 7063411 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/5e/d31204823764f6e5fa4820c1b4f49f8eef7cf691b796ec389f41b4f5a699/astropy-6.1.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:09edca01276ee63f7b2ff511da9bfb432068ba3242e27ef27d76e5a171087b7e", size = 6531221 }, + { url = "https://files.pythonhosted.org/packages/22/e2/ae5dd6d9272e41619d85df4e4a03cf06acea8bcb44c42fe67e5cd04ae131/astropy-6.1.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:072f62a67992393beb016dc80bee8fb994fda9aa69e945f536ed8ac0e51291e6", size = 6409477 }, + { url = "https://files.pythonhosted.org/packages/01/ed/9bc17beb457943ee04b8c85614ddb4a64a4a91597340dca28332e112209d/astropy-6.1.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2706156d3646f9c9a7fc810475d8ab0df4c717beefa8326552576a0f8ddca20", size = 10150734 }, + { url = "https://files.pythonhosted.org/packages/39/38/1c5263f0d775def518707ccd1cf9d4df1d99d523fc148df9e38aa5ba9d54/astropy-6.1.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcd99e627692f8e58bb3097d330bfbd109a22e00dab162a67f203b0a0601ad2c", size = 10210679 }, + { url = "https://files.pythonhosted.org/packages/32/d1/7365e16b0158f755977a5bdbd329df40a9772b0423a1d5075aba9246673f/astropy-6.1.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b0ebbcb637b2e9bcb73011f2b7890d7a3f5a41b66ccaad7c28f065e81e28f0b2", size = 10245960 }, + { url = "https://files.pythonhosted.org/packages/e9/b6/4dc6f9ef1c17738b8ebd8922bc1c6fec48542ccfe5124b6719737b012b8c/astropy-6.1.7-cp311-cp311-win32.whl", hash = "sha256:192b12ede49cd828362ab1a6ede2367fe203f4d851804ec22fa92e009a524281", size = 6272124 }, + { url = "https://files.pythonhosted.org/packages/ba/c6/b5f33597bfbc1afad0640b20000633127dfa0a4295b607a0439f45546d9a/astropy-6.1.7-cp311-cp311-win_amd64.whl", hash = "sha256:3cac64bcdf570c947019bd2bc96711eeb2c7763afe192f18c9551e52a6c296b2", size = 6396627 }, + { url = "https://files.pythonhosted.org/packages/46/2b/007c888fead170c714ecdcf56bc59e8d3252776bd3f16e1797158a46f65d/astropy-6.1.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2a8bcbb1306052cc38c9eed2c9331bfafe2582b499a7321946abf74b26eb256", size = 6535604 }, + { url = "https://files.pythonhosted.org/packages/8e/4c/cc30c9b1440f4a2f1f52845873ae3f8f7c4343261e516603a35546574ed7/astropy-6.1.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eaf88878684f9d31aff36475c90d101f4cff22fdd4fd50098d9950fd56994df7", size = 6415117 }, + { url = "https://files.pythonhosted.org/packages/12/2d/9985b8b4225c2495c4e64713d1630937c83af863db606d12676b72b4f651/astropy-6.1.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cb8cd231e53556e4eebe0393ea95a8cea6b2ff4187c95ac4ff8b17e7a8da823", size = 10177861 }, + { url = "https://files.pythonhosted.org/packages/b7/b6/63ccb085757638d15f0f9d6f2dffaccce7785236fe8bf23e4b380a333ce0/astropy-6.1.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ad36334d138a4f71d6fdcf225a98ad1dad6c343da4362d5a47a71f5c9da3ca9", size = 10258014 }, + { url = "https://files.pythonhosted.org/packages/c8/ee/a6af891802de463f70e3fddf09f3aeb1d46dde87885e2245d25a2ac46948/astropy-6.1.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dd731c526869d0c68507be7b31dd10871b7c44d310bb5495476505560c83cd33", size = 10277363 }, + { url = "https://files.pythonhosted.org/packages/dd/98/b253583f9de7033f03a7c5f5314b9e93177725a2020e0f36d338d242bf0e/astropy-6.1.7-cp312-cp312-win32.whl", hash = "sha256:662bacd7ae42561e038cbd85eea3b749308cf3575611a745b60f034d3350c97a", size = 6271741 }, + { url = "https://files.pythonhosted.org/packages/7a/63/e1b5f01e6735ed8f9d62d3eed5f226bc0ab516ab8558ffaccf6d4185f91d/astropy-6.1.7-cp312-cp312-win_amd64.whl", hash = "sha256:5b4d02a98a0bf91ff7fd4ef0bd0ecca83c9497338cb88b61ec9f971350688222", size = 6396352 }, + { url = "https://files.pythonhosted.org/packages/73/9d/21d2e61080a81e7e1f5e5006204a76e70588aa1a88aa9044c2d203578d07/astropy-6.1.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbeaf04427987c0c6fa2e579eb40011802b06fba6b3a7870e082d5c693564e1b", size = 6528360 }, + { url = "https://files.pythonhosted.org/packages/d5/3e/b999ec6cd607c512e66d8a138443361eb88899760c7cb8517a66155732ee/astropy-6.1.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ab6e88241a14185b9404b02246329185b70292984aa0616b20a0628dfe4f4ebb", size = 6407905 }, + { url = "https://files.pythonhosted.org/packages/db/2d/44557c63688c2ed03d0d72b4f27fc30fc1ea250aeb5ebd939796c5f98bee/astropy-6.1.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0529c75565feaabb629946806b4763ae7b02069aeff4c3b56a69e8a9e638500", size = 10106849 }, + { url = "https://files.pythonhosted.org/packages/66/bc/993552eb932dec528fe6b95f511e918473ea4406dee4b17c223f3fd8a919/astropy-6.1.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5ec347631da77573fc729ba04e5d89a3bc94500bf6037152a2d0f9965ae1ce", size = 10194766 }, + { url = "https://files.pythonhosted.org/packages/9f/f3/3c5282762c8a5746e7752e46a1e328c79a5d0186d96cfd0995bdf976e1f9/astropy-6.1.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc496f87aaccaa5c6624acc985b8770f039c5bbe74b120c8ed7bad3698e24e1b", size = 10219291 }, + { url = "https://files.pythonhosted.org/packages/d8/52/949bb79df9c03f56d0ae93ac62f2616fe3e67db51677bf412473bf6d077e/astropy-6.1.7-cp313-cp313-win32.whl", hash = "sha256:b1e01d534383c038dbf8664b964fa4ea818c7419318830d3c732c750c64115c6", size = 6269501 }, + { url = "https://files.pythonhosted.org/packages/a1/da/f369561a67061dd42e13c7f758b393ae90319dbbcf7e301a18ce3fa43ec6/astropy-6.1.7-cp313-cp313-win_amd64.whl", hash = "sha256:af08cf2b0368f1ea585eb26a55d99a2de9e9b0bd30aba84b5329059c3ec33590", size = 6393207 }, +] + +[[package]] +name = "astropy-iers-data" +version = "0.2025.2.24.0.34.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/4d/b9511aba29d4330437497166a7049ab9bac53e344c54e44a35390724ca37/astropy_iers_data-0.2025.2.24.0.34.4.tar.gz", hash = "sha256:fce62431ce38129d166360f59563f506fbe37b2d1df5e0038a4ad0b0277274f7", size = 1893706 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/14/28e7254183bb53502b323b7e170eaeb86501cc774e76f82ef04216699afb/astropy_iers_data-0.2025.2.24.0.34.4-py3-none-any.whl", hash = "sha256:be8b3b75b09b1aa1d22de9b5243854e00d19936aca6d8f64466d16197c04bb28", size = 1946502 }, +] + +[[package]] +name = "astropy-sphinx-theme" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/73/fc06598c336afa6626b4a50e2706bc235407bf975b5db9757d3a5e813e58/astropy-sphinx-theme-1.1.tar.gz", hash = "sha256:ee1dafa0cf4d109455f7a0d19da4cdd608ad24d380ed2eb8090bb945a3d286f9", size = 29222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2c/b59c6de34a802b4b0485cf0a8c5c9b3025651f79f6f468079d9c6dad2111/astropy_sphinx_theme-1.1-py2.py3-none-any.whl", hash = "sha256:089a2007b8645137460eb70bdbbc4dbc5c15f0873991d5b526da013cfac008c4", size = 31447 }, +] + +[[package]] +name = "astroquery" +version = "0.4.9.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astropy" }, + { name = "beautifulsoup4" }, + { name = "html5lib" }, + { name = "keyring" }, + { name = "numpy" }, + { name = "pyvo" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/4d/acfc42c29f82e7a0dd7901face8551a83e5055b75433ccc5c96dcbb395a3/astroquery-0.4.9.post1.tar.gz", hash = "sha256:5c116bf19036d71e9321d4a049875af55349a4888e24844558fe9984b9f57197", size = 12269009 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e5/ec215258bdc718a9a1ccfd44332af9824e9a4be60db96afce449dac81f79/astroquery-0.4.9.post1-py3-none-any.whl", hash = "sha256:d3e4af26bc57ce1822c1a89963f5096e89f3b8eb414341e0643cb235fee32f50", size = 11085630 }, +] + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + +[[package]] +name = "attrs" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/7c/fdf464bcc51d23881d110abd74b512a42b3d5d376a55a831b44c603ae17f/attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", size = 810562 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/30/d4986a882011f9df997a55e6becd864812ccfcd821d64aac8570ee39f719/attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a", size = 63152 }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181 }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/3c/adaf39ce1fb4afdd21b611e3d530b183bb7759c9b673d60db0e347fd4439/beautifulsoup4-4.13.3.tar.gz", hash = "sha256:1bd32405dacc920b42b83ba01644747ed77456a65760e285fbc47633ceddaf8b", size = 619516 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/49/6abb616eb3cbab6a7cca303dc02fdf3836de2e0b834bf966a7f5271a34d8/beautifulsoup4-4.13.3-py3-none-any.whl", hash = "sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16", size = 186015 }, +] + +[[package]] +name = "bleach" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406 }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, + { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, + { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, + { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, + { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, + { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, + { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, + { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, + { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, + { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, + { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, + { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, + { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "comm" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/a8/fb783cb0abe2b5fded9f55e5703015cdf1c9c85b3669087c538dd15a6a86/comm-0.2.2.tar.gz", hash = "sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e", size = 6210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180 }, +] + +[[package]] +name = "contourpy" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/c2/fc7193cc5383637ff390a712e88e4ded0452c9fbcf84abe3de5ea3df1866/contourpy-1.3.1.tar.gz", hash = "sha256:dfd97abd83335045a913e3bcc4a09c0ceadbe66580cf573fe961f4a825efa699", size = 13465753 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/bb/11250d2906ee2e8b466b5f93e6b19d525f3e0254ac8b445b56e618527718/contourpy-1.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3e8b974d8db2c5610fb4e76307e265de0edb655ae8169e8b21f41807ccbeec4b", size = 269555 }, + { url = "https://files.pythonhosted.org/packages/67/71/1e6e95aee21a500415f5d2dbf037bf4567529b6a4e986594d7026ec5ae90/contourpy-1.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20914c8c973f41456337652a6eeca26d2148aa96dd7ac323b74516988bea89fc", size = 254549 }, + { url = "https://files.pythonhosted.org/packages/31/2c/b88986e8d79ac45efe9d8801ae341525f38e087449b6c2f2e6050468a42c/contourpy-1.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19d40d37c1c3a4961b4619dd9d77b12124a453cc3d02bb31a07d58ef684d3d86", size = 313000 }, + { url = "https://files.pythonhosted.org/packages/c4/18/65280989b151fcf33a8352f992eff71e61b968bef7432fbfde3a364f0730/contourpy-1.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:113231fe3825ebf6f15eaa8bc1f5b0ddc19d42b733345eae0934cb291beb88b6", size = 352925 }, + { url = "https://files.pythonhosted.org/packages/f5/c7/5fd0146c93220dbfe1a2e0f98969293b86ca9bc041d6c90c0e065f4619ad/contourpy-1.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dbbc03a40f916a8420e420d63e96a1258d3d1b58cbdfd8d1f07b49fcbd38e85", size = 323693 }, + { url = "https://files.pythonhosted.org/packages/85/fc/7fa5d17daf77306840a4e84668a48ddff09e6bc09ba4e37e85ffc8e4faa3/contourpy-1.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a04ecd68acbd77fa2d39723ceca4c3197cb2969633836ced1bea14e219d077c", size = 326184 }, + { url = "https://files.pythonhosted.org/packages/ef/e7/104065c8270c7397c9571620d3ab880558957216f2b5ebb7e040f85eeb22/contourpy-1.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c414fc1ed8ee1dbd5da626cf3710c6013d3d27456651d156711fa24f24bd1291", size = 1268031 }, + { url = "https://files.pythonhosted.org/packages/e2/4a/c788d0bdbf32c8113c2354493ed291f924d4793c4a2e85b69e737a21a658/contourpy-1.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:31c1b55c1f34f80557d3830d3dd93ba722ce7e33a0b472cba0ec3b6535684d8f", size = 1325995 }, + { url = "https://files.pythonhosted.org/packages/a6/e6/a2f351a90d955f8b0564caf1ebe4b1451a3f01f83e5e3a414055a5b8bccb/contourpy-1.3.1-cp311-cp311-win32.whl", hash = "sha256:f611e628ef06670df83fce17805c344710ca5cde01edfdc72751311da8585375", size = 174396 }, + { url = "https://files.pythonhosted.org/packages/a8/7e/cd93cab453720a5d6cb75588cc17dcdc08fc3484b9de98b885924ff61900/contourpy-1.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:b2bdca22a27e35f16794cf585832e542123296b4687f9fd96822db6bae17bfc9", size = 219787 }, + { url = "https://files.pythonhosted.org/packages/37/6b/175f60227d3e7f5f1549fcb374592be311293132207e451c3d7c654c25fb/contourpy-1.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ffa84be8e0bd33410b17189f7164c3589c229ce5db85798076a3fa136d0e509", size = 271494 }, + { url = "https://files.pythonhosted.org/packages/6b/6a/7833cfae2c1e63d1d8875a50fd23371394f540ce809d7383550681a1fa64/contourpy-1.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805617228ba7e2cbbfb6c503858e626ab528ac2a32a04a2fe88ffaf6b02c32bc", size = 255444 }, + { url = "https://files.pythonhosted.org/packages/7f/b3/7859efce66eaca5c14ba7619791b084ed02d868d76b928ff56890d2d059d/contourpy-1.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade08d343436a94e633db932e7e8407fe7de8083967962b46bdfc1b0ced39454", size = 307628 }, + { url = "https://files.pythonhosted.org/packages/48/b2/011415f5e3f0a50b1e285a0bf78eb5d92a4df000553570f0851b6e309076/contourpy-1.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47734d7073fb4590b4a40122b35917cd77be5722d80683b249dac1de266aac80", size = 347271 }, + { url = "https://files.pythonhosted.org/packages/84/7d/ef19b1db0f45b151ac78c65127235239a8cf21a59d1ce8507ce03e89a30b/contourpy-1.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ba94a401342fc0f8b948e57d977557fbf4d515f03c67682dd5c6191cb2d16ec", size = 318906 }, + { url = "https://files.pythonhosted.org/packages/ba/99/6794142b90b853a9155316c8f470d2e4821fe6f086b03e372aca848227dd/contourpy-1.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa874e87e4a647fd2e4f514d5e91c7d493697127beb95e77d2f7561f6905bd9", size = 323622 }, + { url = "https://files.pythonhosted.org/packages/3c/0f/37d2c84a900cd8eb54e105f4fa9aebd275e14e266736778bb5dccbf3bbbb/contourpy-1.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1bf98051f1045b15c87868dbaea84f92408337d4f81d0e449ee41920ea121d3b", size = 1266699 }, + { url = "https://files.pythonhosted.org/packages/3a/8a/deb5e11dc7d9cc8f0f9c8b29d4f062203f3af230ba83c30a6b161a6effc9/contourpy-1.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:61332c87493b00091423e747ea78200659dc09bdf7fd69edd5e98cef5d3e9a8d", size = 1326395 }, + { url = "https://files.pythonhosted.org/packages/1a/35/7e267ae7c13aaf12322ccc493531f1e7f2eb8fba2927b9d7a05ff615df7a/contourpy-1.3.1-cp312-cp312-win32.whl", hash = "sha256:e914a8cb05ce5c809dd0fe350cfbb4e881bde5e2a38dc04e3afe1b3e58bd158e", size = 175354 }, + { url = "https://files.pythonhosted.org/packages/a1/35/c2de8823211d07e8a79ab018ef03960716c5dff6f4d5bff5af87fd682992/contourpy-1.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:08d9d449a61cf53033612cb368f3a1b26cd7835d9b8cd326647efe43bca7568d", size = 220971 }, + { url = "https://files.pythonhosted.org/packages/9a/e7/de62050dce687c5e96f946a93546910bc67e483fe05324439e329ff36105/contourpy-1.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a761d9ccfc5e2ecd1bf05534eda382aa14c3e4f9205ba5b1684ecfe400716ef2", size = 271548 }, + { url = "https://files.pythonhosted.org/packages/78/4d/c2a09ae014ae984c6bdd29c11e74d3121b25eaa117eca0bb76340efd7e1c/contourpy-1.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:523a8ee12edfa36f6d2a49407f705a6ef4c5098de4f498619787e272de93f2d5", size = 255576 }, + { url = "https://files.pythonhosted.org/packages/ab/8a/915380ee96a5638bda80cd061ccb8e666bfdccea38d5741cb69e6dbd61fc/contourpy-1.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece6df05e2c41bd46776fbc712e0996f7c94e0d0543af1656956d150c4ca7c81", size = 306635 }, + { url = "https://files.pythonhosted.org/packages/29/5c/c83ce09375428298acd4e6582aeb68b1e0d1447f877fa993d9bf6cd3b0a0/contourpy-1.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:573abb30e0e05bf31ed067d2f82500ecfdaec15627a59d63ea2d95714790f5c2", size = 345925 }, + { url = "https://files.pythonhosted.org/packages/29/63/5b52f4a15e80c66c8078a641a3bfacd6e07106835682454647aca1afc852/contourpy-1.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fa36448e6a3a1a9a2ba23c02012c43ed88905ec80163f2ffe2421c7192a5d7", size = 318000 }, + { url = "https://files.pythonhosted.org/packages/9a/e2/30ca086c692691129849198659bf0556d72a757fe2769eb9620a27169296/contourpy-1.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ea9924d28fc5586bf0b42d15f590b10c224117e74409dd7a0be3b62b74a501c", size = 322689 }, + { url = "https://files.pythonhosted.org/packages/6b/77/f37812ef700f1f185d348394debf33f22d531e714cf6a35d13d68a7003c7/contourpy-1.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b75aa69cb4d6f137b36f7eb2ace9280cfb60c55dc5f61c731fdf6f037f958a3", size = 1268413 }, + { url = "https://files.pythonhosted.org/packages/3f/6d/ce84e79cdd128542ebeb268f84abb4b093af78e7f8ec504676673d2675bc/contourpy-1.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:041b640d4ec01922083645a94bb3b2e777e6b626788f4095cf21abbe266413c1", size = 1326530 }, + { url = "https://files.pythonhosted.org/packages/72/22/8282f4eae20c73c89bee7a82a19c4e27af9b57bb602ecaa00713d5bdb54d/contourpy-1.3.1-cp313-cp313-win32.whl", hash = "sha256:36987a15e8ace5f58d4d5da9dca82d498c2bbb28dff6e5d04fbfcc35a9cb3a82", size = 175315 }, + { url = "https://files.pythonhosted.org/packages/e3/d5/28bca491f65312b438fbf076589dcde7f6f966b196d900777f5811b9c4e2/contourpy-1.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:a7895f46d47671fa7ceec40f31fae721da51ad34bdca0bee83e38870b1f47ffd", size = 220987 }, + { url = "https://files.pythonhosted.org/packages/2f/24/a4b285d6adaaf9746e4700932f579f1a7b6f9681109f694cfa233ae75c4e/contourpy-1.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9ddeb796389dadcd884c7eb07bd14ef12408aaae358f0e2ae24114d797eede30", size = 285001 }, + { url = "https://files.pythonhosted.org/packages/48/1d/fb49a401b5ca4f06ccf467cd6c4f1fd65767e63c21322b29b04ec40b40b9/contourpy-1.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19c1555a6801c2f084c7ddc1c6e11f02eb6a6016ca1318dd5452ba3f613a1751", size = 268553 }, + { url = "https://files.pythonhosted.org/packages/79/1e/4aef9470d13fd029087388fae750dccb49a50c012a6c8d1d634295caa644/contourpy-1.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:841ad858cff65c2c04bf93875e384ccb82b654574a6d7f30453a04f04af71342", size = 310386 }, + { url = "https://files.pythonhosted.org/packages/b0/34/910dc706ed70153b60392b5305c708c9810d425bde12499c9184a1100888/contourpy-1.3.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4318af1c925fb9a4fb190559ef3eec206845f63e80fb603d47f2d6d67683901c", size = 349806 }, + { url = "https://files.pythonhosted.org/packages/31/3c/faee6a40d66d7f2a87f7102236bf4780c57990dd7f98e5ff29881b1b1344/contourpy-1.3.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:14c102b0eab282427b662cb590f2e9340a9d91a1c297f48729431f2dcd16e14f", size = 321108 }, + { url = "https://files.pythonhosted.org/packages/17/69/390dc9b20dd4bb20585651d7316cc3054b7d4a7b4f8b710b2b698e08968d/contourpy-1.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05e806338bfeaa006acbdeba0ad681a10be63b26e1b17317bfac3c5d98f36cda", size = 327291 }, + { url = "https://files.pythonhosted.org/packages/ef/74/7030b67c4e941fe1e5424a3d988080e83568030ce0355f7c9fc556455b01/contourpy-1.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4d76d5993a34ef3df5181ba3c92fabb93f1eaa5729504fb03423fcd9f3177242", size = 1263752 }, + { url = "https://files.pythonhosted.org/packages/f0/ed/92d86f183a8615f13f6b9cbfc5d4298a509d6ce433432e21da838b4b63f4/contourpy-1.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:89785bb2a1980c1bd87f0cb1517a71cde374776a5f150936b82580ae6ead44a1", size = 1318403 }, + { url = "https://files.pythonhosted.org/packages/b3/0e/c8e4950c77dcfc897c71d61e56690a0a9df39543d2164040301b5df8e67b/contourpy-1.3.1-cp313-cp313t-win32.whl", hash = "sha256:8eb96e79b9f3dcadbad2a3891672f81cdcab7f95b27f28f1c67d75f045b6b4f1", size = 185117 }, + { url = "https://files.pythonhosted.org/packages/c1/31/1ae946f11dfbd229222e6d6ad8e7bd1891d3d48bde5fbf7a0beb9491f8e3/contourpy-1.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:287ccc248c9e0d0566934e7d606201abd74761b5703d804ff3df8935f523d546", size = 236668 }, +] + +[[package]] +name = "coverage" +version = "7.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/d6/2b53ab3ee99f2262e6f0b8369a43f6d66658eab45510331c0b3d5c8c4272/coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2", size = 805941 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/2d/da78abbfff98468c91fd63a73cccdfa0e99051676ded8dd36123e3a2d4d5/coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015", size = 208464 }, + { url = "https://files.pythonhosted.org/packages/31/f2/c269f46c470bdabe83a69e860c80a82e5e76840e9f4bbd7f38f8cebbee2f/coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45", size = 208893 }, + { url = "https://files.pythonhosted.org/packages/47/63/5682bf14d2ce20819998a49c0deadb81e608a59eed64d6bc2191bc8046b9/coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702", size = 241545 }, + { url = "https://files.pythonhosted.org/packages/6a/b6/6b6631f1172d437e11067e1c2edfdb7238b65dff965a12bce3b6d1bf2be2/coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0", size = 239230 }, + { url = "https://files.pythonhosted.org/packages/c7/01/9cd06cbb1be53e837e16f1b4309f6357e2dfcbdab0dd7cd3b1a50589e4e1/coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f", size = 241013 }, + { url = "https://files.pythonhosted.org/packages/4b/26/56afefc03c30871326e3d99709a70d327ac1f33da383cba108c79bd71563/coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f", size = 239750 }, + { url = "https://files.pythonhosted.org/packages/dd/ea/88a1ff951ed288f56aa561558ebe380107cf9132facd0b50bced63ba7238/coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d", size = 238462 }, + { url = "https://files.pythonhosted.org/packages/6e/d4/1d9404566f553728889409eff82151d515fbb46dc92cbd13b5337fa0de8c/coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba", size = 239307 }, + { url = "https://files.pythonhosted.org/packages/12/c1/e453d3b794cde1e232ee8ac1d194fde8e2ba329c18bbf1b93f6f5eef606b/coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f", size = 211117 }, + { url = "https://files.pythonhosted.org/packages/d5/db/829185120c1686fa297294f8fcd23e0422f71070bf85ef1cc1a72ecb2930/coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558", size = 212019 }, + { url = "https://files.pythonhosted.org/packages/e2/7f/4af2ed1d06ce6bee7eafc03b2ef748b14132b0bdae04388e451e4b2c529b/coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad", size = 208645 }, + { url = "https://files.pythonhosted.org/packages/dc/60/d19df912989117caa95123524d26fc973f56dc14aecdec5ccd7d0084e131/coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3", size = 208898 }, + { url = "https://files.pythonhosted.org/packages/bd/10/fecabcf438ba676f706bf90186ccf6ff9f6158cc494286965c76e58742fa/coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574", size = 242987 }, + { url = "https://files.pythonhosted.org/packages/4c/53/4e208440389e8ea936f5f2b0762dcd4cb03281a7722def8e2bf9dc9c3d68/coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985", size = 239881 }, + { url = "https://files.pythonhosted.org/packages/c4/47/2ba744af8d2f0caa1f17e7746147e34dfc5f811fb65fc153153722d58835/coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750", size = 242142 }, + { url = "https://files.pythonhosted.org/packages/e9/90/df726af8ee74d92ee7e3bf113bf101ea4315d71508952bd21abc3fae471e/coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea", size = 241437 }, + { url = "https://files.pythonhosted.org/packages/f6/af/995263fd04ae5f9cf12521150295bf03b6ba940d0aea97953bb4a6db3e2b/coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3", size = 239724 }, + { url = "https://files.pythonhosted.org/packages/1c/8e/5bb04f0318805e190984c6ce106b4c3968a9562a400180e549855d8211bd/coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a", size = 241329 }, + { url = "https://files.pythonhosted.org/packages/9e/9d/fa04d9e6c3f6459f4e0b231925277cfc33d72dfab7fa19c312c03e59da99/coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95", size = 211289 }, + { url = "https://files.pythonhosted.org/packages/53/40/53c7ffe3c0c3fff4d708bc99e65f3d78c129110d6629736faf2dbd60ad57/coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288", size = 212079 }, + { url = "https://files.pythonhosted.org/packages/76/89/1adf3e634753c0de3dad2f02aac1e73dba58bc5a3a914ac94a25b2ef418f/coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1", size = 208673 }, + { url = "https://files.pythonhosted.org/packages/ce/64/92a4e239d64d798535c5b45baac6b891c205a8a2e7c9cc8590ad386693dc/coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd", size = 208945 }, + { url = "https://files.pythonhosted.org/packages/b4/d0/4596a3ef3bca20a94539c9b1e10fd250225d1dec57ea78b0867a1cf9742e/coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9", size = 242484 }, + { url = "https://files.pythonhosted.org/packages/1c/ef/6fd0d344695af6718a38d0861408af48a709327335486a7ad7e85936dc6e/coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e", size = 239525 }, + { url = "https://files.pythonhosted.org/packages/0c/4b/373be2be7dd42f2bcd6964059fd8fa307d265a29d2b9bcf1d044bcc156ed/coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4", size = 241545 }, + { url = "https://files.pythonhosted.org/packages/a6/7d/0e83cc2673a7790650851ee92f72a343827ecaaea07960587c8f442b5cd3/coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6", size = 241179 }, + { url = "https://files.pythonhosted.org/packages/ff/8c/566ea92ce2bb7627b0900124e24a99f9244b6c8c92d09ff9f7633eb7c3c8/coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3", size = 239288 }, + { url = "https://files.pythonhosted.org/packages/7d/e4/869a138e50b622f796782d642c15fb5f25a5870c6d0059a663667a201638/coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc", size = 241032 }, + { url = "https://files.pythonhosted.org/packages/ae/28/a52ff5d62a9f9e9fe9c4f17759b98632edd3a3489fce70154c7d66054dd3/coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3", size = 211315 }, + { url = "https://files.pythonhosted.org/packages/bc/17/ab849b7429a639f9722fa5628364c28d675c7ff37ebc3268fe9840dda13c/coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef", size = 212099 }, + { url = "https://files.pythonhosted.org/packages/d2/1c/b9965bf23e171d98505eb5eb4fb4d05c44efd256f2e0f19ad1ba8c3f54b0/coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e", size = 209511 }, + { url = "https://files.pythonhosted.org/packages/57/b3/119c201d3b692d5e17784fee876a9a78e1b3051327de2709392962877ca8/coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703", size = 209729 }, + { url = "https://files.pythonhosted.org/packages/52/4e/a7feb5a56b266304bc59f872ea07b728e14d5a64f1ad3a2cc01a3259c965/coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0", size = 253988 }, + { url = "https://files.pythonhosted.org/packages/65/19/069fec4d6908d0dae98126aa7ad08ce5130a6decc8509da7740d36e8e8d2/coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924", size = 249697 }, + { url = "https://files.pythonhosted.org/packages/1c/da/5b19f09ba39df7c55f77820736bf17bbe2416bbf5216a3100ac019e15839/coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b", size = 252033 }, + { url = "https://files.pythonhosted.org/packages/1e/89/4c2750df7f80a7872267f7c5fe497c69d45f688f7b3afe1297e52e33f791/coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d", size = 251535 }, + { url = "https://files.pythonhosted.org/packages/78/3b/6d3ae3c1cc05f1b0460c51e6f6dcf567598cbd7c6121e5ad06643974703c/coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827", size = 249192 }, + { url = "https://files.pythonhosted.org/packages/6e/8e/c14a79f535ce41af7d436bbad0d3d90c43d9e38ec409b4770c894031422e/coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9", size = 250627 }, + { url = "https://files.pythonhosted.org/packages/cb/79/b7cee656cfb17a7f2c1b9c3cee03dd5d8000ca299ad4038ba64b61a9b044/coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3", size = 212033 }, + { url = "https://files.pythonhosted.org/packages/b6/c3/f7aaa3813f1fa9a4228175a7bd368199659d392897e184435a3b66408dd3/coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f", size = 213240 }, + { url = "https://files.pythonhosted.org/packages/fb/b2/f655700e1024dec98b10ebaafd0cedbc25e40e4abe62a3c8e2ceef4f8f0a/coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953", size = 200552 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "44.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/67/545c79fe50f7af51dbad56d16b23fe33f63ee6a5d956b3cb68ea110cbe64/cryptography-44.0.1.tar.gz", hash = "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14", size = 710819 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/b9/4d1fa8d73ae6ec350012f89c3abfbff19fc95fe5420cf972e12a8d182986/cryptography-44.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f", size = 3943865 }, + { url = "https://files.pythonhosted.org/packages/6e/57/371a9f3f3a4500807b5fcd29fec77f418ba27ffc629d88597d0d1049696e/cryptography-44.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2", size = 4162562 }, + { url = "https://files.pythonhosted.org/packages/c5/1d/5b77815e7d9cf1e3166988647f336f87d5634a5ccecec2ffbe08ef8dd481/cryptography-44.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911", size = 3951923 }, + { url = "https://files.pythonhosted.org/packages/28/01/604508cd34a4024467cd4105887cf27da128cba3edd435b54e2395064bfb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69", size = 3685194 }, + { url = "https://files.pythonhosted.org/packages/c6/3d/d3c55d4f1d24580a236a6753902ef6d8aafd04da942a1ee9efb9dc8fd0cb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026", size = 4187790 }, + { url = "https://files.pythonhosted.org/packages/ea/a6/44d63950c8588bfa8594fd234d3d46e93c3841b8e84a066649c566afb972/cryptography-44.0.1-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd", size = 3951343 }, + { url = "https://files.pythonhosted.org/packages/c1/17/f5282661b57301204cbf188254c1a0267dbd8b18f76337f0a7ce1038888c/cryptography-44.0.1-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0", size = 4187127 }, + { url = "https://files.pythonhosted.org/packages/f3/68/abbae29ed4f9d96596687f3ceea8e233f65c9645fbbec68adb7c756bb85a/cryptography-44.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf", size = 4070666 }, + { url = "https://files.pythonhosted.org/packages/0f/10/cf91691064a9e0a88ae27e31779200b1505d3aee877dbe1e4e0d73b4f155/cryptography-44.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864", size = 4288811 }, + { url = "https://files.pythonhosted.org/packages/ba/9f/1775600eb69e72d8f9931a104120f2667107a0ee478f6ad4fe4001559345/cryptography-44.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862", size = 3943269 }, + { url = "https://files.pythonhosted.org/packages/25/ba/e00d5ad6b58183829615be7f11f55a7b6baa5a06910faabdc9961527ba44/cryptography-44.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3", size = 4166461 }, + { url = "https://files.pythonhosted.org/packages/b3/45/690a02c748d719a95ab08b6e4decb9d81e0ec1bac510358f61624c86e8a3/cryptography-44.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7", size = 3950314 }, + { url = "https://files.pythonhosted.org/packages/e6/50/bf8d090911347f9b75adc20f6f6569ed6ca9b9bff552e6e390f53c2a1233/cryptography-44.0.1-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a", size = 3686675 }, + { url = "https://files.pythonhosted.org/packages/e1/e7/cfb18011821cc5f9b21efb3f94f3241e3a658d267a3bf3a0f45543858ed8/cryptography-44.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c", size = 4190429 }, + { url = "https://files.pythonhosted.org/packages/07/ef/77c74d94a8bfc1a8a47b3cafe54af3db537f081742ee7a8a9bd982b62774/cryptography-44.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62", size = 3950039 }, + { url = "https://files.pythonhosted.org/packages/6d/b9/8be0ff57c4592382b77406269b1e15650c9f1a167f9e34941b8515b97159/cryptography-44.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41", size = 4189713 }, + { url = "https://files.pythonhosted.org/packages/78/e1/4b6ac5f4100545513b0847a4d276fe3c7ce0eacfa73e3b5ebd31776816ee/cryptography-44.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b", size = 4071193 }, + { url = "https://files.pythonhosted.org/packages/3d/cb/afff48ceaed15531eab70445abe500f07f8f96af2bb35d98af6bfa89ebd4/cryptography-44.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7", size = 4289566 }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 }, +] + +[[package]] +name = "cython" +version = "3.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/25/886e197c97a4b8e254173002cdc141441e878ff29aaa7d9ba560cd6e4866/cython-3.0.12.tar.gz", hash = "sha256:b988bb297ce76c671e28c97d017b95411010f7c77fa6623dd0bb47eed1aee1bc", size = 2757617 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/60/3d27abd940f7b80a6aeb69dc093a892f04828e1dd0b243dd81ff87d7b0e9/Cython-3.0.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feb86122a823937cc06e4c029d80ff69f082ebb0b959ab52a5af6cdd271c5dc3", size = 3277430 }, + { url = "https://files.pythonhosted.org/packages/c7/49/f17b0541b317d11f1d021a580643ee2481685157cded92efb32e2fb4daef/Cython-3.0.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfdbea486e702c328338314adb8e80f5f9741f06a0ae83aaec7463bc166d12e8", size = 3444055 }, + { url = "https://files.pythonhosted.org/packages/6b/7f/c57791ba6a1c934b6f1ab51371e894e3b4bfde0bc35e50046c8754a9d215/Cython-3.0.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:563de1728c8e48869d2380a1b76bbc1b1b1d01aba948480d68c1d05e52d20c92", size = 3597874 }, + { url = "https://files.pythonhosted.org/packages/23/24/803a0db3681b3a2ef65a4bebab201e5ae4aef5e6127ae03683476a573aa9/Cython-3.0.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d4576c1e1f6316282aa0b4a55139254fbed965cba7813e6d9900d3092b128", size = 3644129 }, + { url = "https://files.pythonhosted.org/packages/27/13/9b53ba8336e083ece441af8d6d182b8ca83ad523e87c07b3190af379ebc3/Cython-3.0.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e5eadef80143026944ea8f9904715a008f5108d1d644a89f63094cc37351e73", size = 3504936 }, + { url = "https://files.pythonhosted.org/packages/a9/d2/d11104be6992a9fe256860cae6d1a79f7dcf3bdb12ae00116fac591f677d/Cython-3.0.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a93cbda00a5451175b97dea5a9440a3fcee9e54b4cba7a7dbcba9a764b22aec", size = 3713066 }, + { url = "https://files.pythonhosted.org/packages/d9/8c/1fe49135296efa3f460c760a4297f6a5b387f3e69ac5c9dcdbd620295ab3/Cython-3.0.12-cp311-cp311-win32.whl", hash = "sha256:3109e1d44425a2639e9a677b66cd7711721a5b606b65867cb2d8ef7a97e2237b", size = 2579935 }, + { url = "https://files.pythonhosted.org/packages/02/4e/5ac0b5b9a239cd3fdae187dda8ff06b0b812f671e2501bf253712278f0ac/Cython-3.0.12-cp311-cp311-win_amd64.whl", hash = "sha256:d4b70fc339adba1e2111b074ee6119fe9fd6072c957d8597bce9a0dd1c3c6784", size = 2787337 }, + { url = "https://files.pythonhosted.org/packages/e6/6c/3be501a6520a93449b1e7e6f63e598ec56f3b5d1bc7ad14167c72a22ddf7/Cython-3.0.12-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fe030d4a00afb2844f5f70896b7f2a1a0d7da09bf3aa3d884cbe5f73fff5d310", size = 3311717 }, + { url = "https://files.pythonhosted.org/packages/ee/ab/adfeb22c85491de18ae10932165edd5b6f01e4c5e3e363638759d1235015/Cython-3.0.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7fec4f052b8fe173fe70eae75091389955b9a23d5cec3d576d21c5913b49d47", size = 3344337 }, + { url = "https://files.pythonhosted.org/packages/0d/72/743730d7c46b4c85abefb93187cbbcb7aae8de288d7722b990db3d13499e/Cython-3.0.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0faa5e39e5c8cdf6f9c3b1c3f24972826e45911e7f5b99cf99453fca5432f45e", size = 3517692 }, + { url = "https://files.pythonhosted.org/packages/09/a1/29a4759a02661f8c8e6b703f62bfbc8285337e6918cc90f55dc0fadb5eb3/Cython-3.0.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d53de996ed340e9ab0fc85a88aaa8932f2591a2746e1ab1c06e262bd4ec4be7", size = 3577057 }, + { url = "https://files.pythonhosted.org/packages/d6/f8/03d74e98901a7cc2f21f95231b07dd54ec2f69477319bac268b3816fc3a8/Cython-3.0.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ea3a0e19ab77266c738aa110684a753a04da4e709472cadeff487133354d6ab8", size = 3396493 }, + { url = "https://files.pythonhosted.org/packages/50/ea/ac33c5f54f980dbc23dd8f1d5c51afeef26e15ac1a66388e4b8195af83b7/Cython-3.0.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c151082884be468f2f405645858a857298ac7f7592729e5b54788b5c572717ba", size = 3603859 }, + { url = "https://files.pythonhosted.org/packages/a2/4e/91fc1d6b5e678dcf2d1ecd8dce45b014b4b60d2044d376355c605831c873/Cython-3.0.12-cp312-cp312-win32.whl", hash = "sha256:3083465749911ac3b2ce001b6bf17f404ac9dd35d8b08469d19dc7e717f5877a", size = 2610428 }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a7fdec227b9f0bb07edbeb016c7b18ed6a8e6ce884d08b2e397cda2c0168/Cython-3.0.12-cp312-cp312-win_amd64.whl", hash = "sha256:c0b91c7ebace030dd558ea28730de8c580680b50768e5af66db2904a3716c3e3", size = 2794755 }, + { url = "https://files.pythonhosted.org/packages/67/ad/550ddcb8b5a5d9949fe6606595cce36984c1d42309f1e04af98f5933a7ea/Cython-3.0.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4ee6f1ea1bead8e6cbc4e64571505b5d8dbdb3b58e679d31f3a84160cebf1a1a", size = 3393574 }, + { url = "https://files.pythonhosted.org/packages/34/de/ade0a80bea17197662e23d39d3d3fbf89e9e99e6ad91fd95ab87120edb3a/Cython-3.0.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57aefa6d3341109e46ec1a13e3a763aaa2cbeb14e82af2485b318194be1d9170", size = 3367198 }, + { url = "https://files.pythonhosted.org/packages/a8/30/7f48207ea13dab46604db0dd388e807d53513ba6ad1c34462892072f8f8c/Cython-3.0.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:879ae9023958d63c0675015369384642d0afb9c9d1f3473df9186c42f7a9d265", size = 3535849 }, + { url = "https://files.pythonhosted.org/packages/81/ab/f61c79fa14bd433a7dfd1548c5e00d9bd18b557c2f836aaece4fb1b22f34/Cython-3.0.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:36fcd584dae547de6f095500a380f4a0cce72b7a7e409e9ff03cb9beed6ac7a1", size = 3559079 }, + { url = "https://files.pythonhosted.org/packages/d0/d1/1dbf17061229ccd35d5c0eed659fab60c2e50d2eadfa2a5729e753b6f4d0/Cython-3.0.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62b79dcc0de49efe9e84b9d0e2ae0a6fc9b14691a65565da727aa2e2e63c6a28", size = 3436649 }, + { url = "https://files.pythonhosted.org/packages/2d/d4/9ce42fff6de5550f870cdde9a1482d69ea66a1249a88fa0d0df9adebfb1a/Cython-3.0.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4aa255781b093a8401109d8f2104bbb2e52de7639d5896aefafddc85c30e0894", size = 3644025 }, + { url = "https://files.pythonhosted.org/packages/e3/89/b0c847f9df92af3ef11281b6811c000bd6f8ce0db02e4374397f8d67f829/Cython-3.0.12-cp313-cp313-win32.whl", hash = "sha256:77d48f2d4bab9fe1236eb753d18f03e8b2619af5b6f05d51df0532a92dfb38ab", size = 2604911 }, + { url = "https://files.pythonhosted.org/packages/a6/5f/bbfaf2b5f7bf78854ecbc82f8473a3892ae5580e0c5bd0d4a82580b39ed3/Cython-3.0.12-cp313-cp313-win_amd64.whl", hash = "sha256:86c304b20bd57c727c7357e90d5ba1a2b6f1c45492de2373814d7745ef2e63b4", size = 2786786 }, + { url = "https://files.pythonhosted.org/packages/27/6b/7c87867d255cbce8167ed99fc65635e9395d2af0f0c915428f5b17ec412d/Cython-3.0.12-py2.py3-none-any.whl", hash = "sha256:0038c9bae46c459669390e53a1ec115f8096b2e4647ae007ff1bf4e6dee92806", size = 1171640 }, +] + +[[package]] +name = "debugpy" +version = "1.8.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/25/c74e337134edf55c4dfc9af579eccb45af2393c40960e2795a94351e8140/debugpy-1.8.12.tar.gz", hash = "sha256:646530b04f45c830ceae8e491ca1c9320a2d2f0efea3141487c82130aba70dce", size = 1641122 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/9f/5b8af282253615296264d4ef62d14a8686f0dcdebb31a669374e22fff0a4/debugpy-1.8.12-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:36f4829839ef0afdfdd208bb54f4c3d0eea86106d719811681a8627ae2e53dd5", size = 2174643 }, + { url = "https://files.pythonhosted.org/packages/ef/31/f9274dcd3b0f9f7d1e60373c3fa4696a585c55acb30729d313bb9d3bcbd1/debugpy-1.8.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a28ed481d530e3138553be60991d2d61103ce6da254e51547b79549675f539b7", size = 3133457 }, + { url = "https://files.pythonhosted.org/packages/ab/ca/6ee59e9892e424477e0c76e3798046f1fd1288040b927319c7a7b0baa484/debugpy-1.8.12-cp311-cp311-win32.whl", hash = "sha256:4ad9a94d8f5c9b954e0e3b137cc64ef3f579d0df3c3698fe9c3734ee397e4abb", size = 5106220 }, + { url = "https://files.pythonhosted.org/packages/d5/1a/8ab508ab05ede8a4eae3b139bbc06ea3ca6234f9e8c02713a044f253be5e/debugpy-1.8.12-cp311-cp311-win_amd64.whl", hash = "sha256:4703575b78dd697b294f8c65588dc86874ed787b7348c65da70cfc885efdf1e1", size = 5130481 }, + { url = "https://files.pythonhosted.org/packages/ba/e6/0f876ecfe5831ebe4762b19214364753c8bc2b357d28c5d739a1e88325c7/debugpy-1.8.12-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:7e94b643b19e8feb5215fa508aee531387494bf668b2eca27fa769ea11d9f498", size = 2500846 }, + { url = "https://files.pythonhosted.org/packages/19/64/33f41653a701f3cd2cbff8b41ebaad59885b3428b5afd0d93d16012ecf17/debugpy-1.8.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:086b32e233e89a2740c1615c2f775c34ae951508b28b308681dbbb87bba97d06", size = 4222181 }, + { url = "https://files.pythonhosted.org/packages/32/a6/02646cfe50bfacc9b71321c47dc19a46e35f4e0aceea227b6d205e900e34/debugpy-1.8.12-cp312-cp312-win32.whl", hash = "sha256:2ae5df899732a6051b49ea2632a9ea67f929604fd2b036613a9f12bc3163b92d", size = 5227017 }, + { url = "https://files.pythonhosted.org/packages/da/a6/10056431b5c47103474312cf4a2ec1001f73e0b63b1216706d5fef2531eb/debugpy-1.8.12-cp312-cp312-win_amd64.whl", hash = "sha256:39dfbb6fa09f12fae32639e3286112fc35ae976114f1f3d37375f3130a820969", size = 5267555 }, + { url = "https://files.pythonhosted.org/packages/cf/4d/7c3896619a8791effd5d8c31f0834471fc8f8fb3047ec4f5fc69dd1393dd/debugpy-1.8.12-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:696d8ae4dff4cbd06bf6b10d671e088b66669f110c7c4e18a44c43cf75ce966f", size = 2485246 }, + { url = "https://files.pythonhosted.org/packages/99/46/bc6dcfd7eb8cc969a5716d858e32485eb40c72c6a8dc88d1e3a4d5e95813/debugpy-1.8.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:898fba72b81a654e74412a67c7e0a81e89723cfe2a3ea6fcd3feaa3395138ca9", size = 4218616 }, + { url = "https://files.pythonhosted.org/packages/03/dd/d7fcdf0381a9b8094da1f6a1c9f19fed493a4f8576a2682349b3a8b20ec7/debugpy-1.8.12-cp313-cp313-win32.whl", hash = "sha256:22a11c493c70413a01ed03f01c3c3a2fc4478fc6ee186e340487b2edcd6f4180", size = 5226540 }, + { url = "https://files.pythonhosted.org/packages/25/bd/ecb98f5b5fc7ea0bfbb3c355bc1dd57c198a28780beadd1e19915bf7b4d9/debugpy-1.8.12-cp313-cp313-win_amd64.whl", hash = "sha256:fdb3c6d342825ea10b90e43d7f20f01535a72b3a1997850c0c3cefa5c27a4a2c", size = 5267134 }, + { url = "https://files.pythonhosted.org/packages/38/c4/5120ad36405c3008f451f94b8f92ef1805b1e516f6ff870f331ccb3c4cc0/debugpy-1.8.12-py2.py3-none-any.whl", hash = "sha256:274b6a2040349b5c9864e475284bce5bb062e63dce368a394b8cc865ae3b00c6", size = 5229490 }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408 }, +] + +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 }, +] + +[[package]] +name = "executing" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/50/4b769ce1ac4071a1ef6d86b1a3fb56cdc3a37615e8c5519e1af96cdac366/fastjsonschema-2.21.1.tar.gz", hash = "sha256:794d4f0a58f848961ba16af7b9c85a3e88cd360df008c59aac6fc5ae9323b5d4", size = 373939 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/2b/0817a2b257fe88725c25589d89aec060581aabf668707a8d03b2e9e0cb2a/fastjsonschema-2.21.1-py3-none-any.whl", hash = "sha256:c9e5b7e908310918cf494a434eeb31384dd84a98b57a30bcb1f535015b554667", size = 23924 }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, +] + +[[package]] +name = "findiff" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, + { name = "sympy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/b5/f2926cd3046f902395184cbcefb6e4cd65b0d84f546c016f9ba8faf541d7/findiff-0.12.1.tar.gz", hash = "sha256:f5bad8f52a5f21f55903c0c9fa2bd243fbbd78fe8143d3e0b5a41abf4dbae54c", size = 1630934 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/be/5d0da13c229d798bbb668187a6188e443d2be2855e3a17c9a44b3f8dad79/findiff-0.12.1-py3-none-any.whl", hash = "sha256:ee9573888f7b437935ae745186250be3b7c4fbac307360d614f7c20645e3a3d7", size = 26547 }, +] + +[[package]] +name = "fonttools" +version = "4.56.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/8c/9ffa2a555af0e5e5d0e2ed7fdd8c9bef474ed676995bb4c57c9cd0014248/fonttools-4.56.0.tar.gz", hash = "sha256:a114d1567e1a1586b7e9e7fc2ff686ca542a82769a296cef131e4c4af51e58f4", size = 3462892 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/56/a2f3e777d48fcae7ecd29de4d96352d84e5ea9871e5f3fc88241521572cf/fonttools-4.56.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ef04bc7827adb7532be3d14462390dd71287644516af3f1e67f1e6ff9c6d6df", size = 2753325 }, + { url = "https://files.pythonhosted.org/packages/71/85/d483e9c4e5ed586b183bf037a353e8d766366b54fd15519b30e6178a6a6e/fonttools-4.56.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ffda9b8cd9cb8b301cae2602ec62375b59e2e2108a117746f12215145e3f786c", size = 2281554 }, + { url = "https://files.pythonhosted.org/packages/09/67/060473b832b2fade03c127019794df6dc02d9bc66fa4210b8e0d8a99d1e5/fonttools-4.56.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e993e8db36306cc3f1734edc8ea67906c55f98683d6fd34c3fc5593fdbba4c", size = 4869260 }, + { url = "https://files.pythonhosted.org/packages/28/e9/47c02d5a7027e8ed841ab6a10ca00c93dadd5f16742f1af1fa3f9978adf4/fonttools-4.56.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:003548eadd674175510773f73fb2060bb46adb77c94854af3e0cc5bc70260049", size = 4898508 }, + { url = "https://files.pythonhosted.org/packages/bf/8a/221d456d1afb8ca043cfd078f59f187ee5d0a580f4b49351b9ce95121f57/fonttools-4.56.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd9825822e7bb243f285013e653f6741954d8147427aaa0324a862cdbf4cbf62", size = 4877700 }, + { url = "https://files.pythonhosted.org/packages/a4/8c/e503863adf7a6aeff7b960e2f66fa44dd0c29a7a8b79765b2821950d7b05/fonttools-4.56.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b23d30a2c0b992fb1c4f8ac9bfde44b5586d23457759b6cf9a787f1a35179ee0", size = 5045817 }, + { url = "https://files.pythonhosted.org/packages/2b/50/79ba3b7e42f4eaa70b82b9e79155f0f6797858dc8a97862428b6852c6aee/fonttools-4.56.0-cp311-cp311-win32.whl", hash = "sha256:47b5e4680002ae1756d3ae3b6114e20aaee6cc5c69d1e5911f5ffffd3ee46c6b", size = 2154426 }, + { url = "https://files.pythonhosted.org/packages/3b/90/4926e653041c4116ecd43e50e3c79f5daae6dcafc58ceb64bc4f71dd4924/fonttools-4.56.0-cp311-cp311-win_amd64.whl", hash = "sha256:14a3e3e6b211660db54ca1ef7006401e4a694e53ffd4553ab9bc87ead01d0f05", size = 2200937 }, + { url = "https://files.pythonhosted.org/packages/39/32/71cfd6877999576a11824a7fe7bc0bb57c5c72b1f4536fa56a3e39552643/fonttools-4.56.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d6f195c14c01bd057bc9b4f70756b510e009c83c5ea67b25ced3e2c38e6ee6e9", size = 2747757 }, + { url = "https://files.pythonhosted.org/packages/15/52/d9f716b072c5061a0b915dd4c387f74bef44c68c069e2195c753905bd9b7/fonttools-4.56.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa760e5fe8b50cbc2d71884a1eff2ed2b95a005f02dda2fa431560db0ddd927f", size = 2279007 }, + { url = "https://files.pythonhosted.org/packages/d1/97/f1b3a8afa9a0d814a092a25cd42f59ccb98a0bb7a295e6e02fc9ba744214/fonttools-4.56.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d54a45d30251f1d729e69e5b675f9a08b7da413391a1227781e2a297fa37f6d2", size = 4783991 }, + { url = "https://files.pythonhosted.org/packages/95/70/2a781bedc1c45a0c61d29c56425609b22ed7f971da5d7e5df2679488741b/fonttools-4.56.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661a8995d11e6e4914a44ca7d52d1286e2d9b154f685a4d1f69add8418961563", size = 4855109 }, + { url = "https://files.pythonhosted.org/packages/0c/02/a2597858e61a5e3fb6a14d5f6be9e6eb4eaf090da56ad70cedcbdd201685/fonttools-4.56.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9d94449ad0a5f2a8bf5d2f8d71d65088aee48adbe45f3c5f8e00e3ad861ed81a", size = 4762496 }, + { url = "https://files.pythonhosted.org/packages/f2/00/aaf00100d6078fdc73f7352b44589804af9dc12b182a2540b16002152ba4/fonttools-4.56.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f59746f7953f69cc3290ce2f971ab01056e55ddd0fb8b792c31a8acd7fee2d28", size = 4990094 }, + { url = "https://files.pythonhosted.org/packages/bf/dc/3ff1db522460db60cf3adaf1b64e0c72b43406717d139786d3fa1eb20709/fonttools-4.56.0-cp312-cp312-win32.whl", hash = "sha256:bce60f9a977c9d3d51de475af3f3581d9b36952e1f8fc19a1f2254f1dda7ce9c", size = 2142888 }, + { url = "https://files.pythonhosted.org/packages/6f/e3/5a181a85777f7809076e51f7422e0dc77eb04676c40ec8bf6a49d390d1ff/fonttools-4.56.0-cp312-cp312-win_amd64.whl", hash = "sha256:300c310bb725b2bdb4f5fc7e148e190bd69f01925c7ab437b9c0ca3e1c7cd9ba", size = 2189734 }, + { url = "https://files.pythonhosted.org/packages/a5/55/f06b48d48e0b4ec3a3489efafe9bd4d81b6e0802ac51026e3ee4634e89ba/fonttools-4.56.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f20e2c0dfab82983a90f3d00703ac0960412036153e5023eed2b4641d7d5e692", size = 2735127 }, + { url = "https://files.pythonhosted.org/packages/59/db/d2c7c9b6dd5cbd46f183e650a47403ffb88fca17484eb7c4b1cd88f9e513/fonttools-4.56.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f36a0868f47b7566237640c026c65a86d09a3d9ca5df1cd039e30a1da73098a0", size = 2272519 }, + { url = "https://files.pythonhosted.org/packages/4d/a2/da62d779c34a0e0c06415f02eab7fa3466de5d46df459c0275a255cefc65/fonttools-4.56.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62b4c6802fa28e14dba010e75190e0e6228513573f1eeae57b11aa1a39b7e5b1", size = 4762423 }, + { url = "https://files.pythonhosted.org/packages/be/6a/fd4018e0448c8a5e12138906411282c5eab51a598493f080a9f0960e658f/fonttools-4.56.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a05d1f07eb0a7d755fbe01fee1fd255c3a4d3730130cf1bfefb682d18fd2fcea", size = 4834442 }, + { url = "https://files.pythonhosted.org/packages/6d/63/fa1dec8efb35bc11ef9c39b2d74754b45d48a3ccb2cf78c0109c0af639e8/fonttools-4.56.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0073b62c3438cf0058488c002ea90489e8801d3a7af5ce5f7c05c105bee815c3", size = 4742800 }, + { url = "https://files.pythonhosted.org/packages/dd/f4/963247ae8c73ccc4cf2929e7162f595c81dbe17997d1d0ea77da24a217c9/fonttools-4.56.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cad98c94833465bcf28f51c248aaf07ca022efc6a3eba750ad9c1e0256d278", size = 4963746 }, + { url = "https://files.pythonhosted.org/packages/ea/e0/46f9600c39c644b54e4420f941f75fa200d9288c9ae171e5d80918b8cbb9/fonttools-4.56.0-cp313-cp313-win32.whl", hash = "sha256:d0cb73ccf7f6d7ca8d0bc7ea8ac0a5b84969a41c56ac3ac3422a24df2680546f", size = 2140927 }, + { url = "https://files.pythonhosted.org/packages/27/6d/3edda54f98a550a0473f032d8050315fbc8f1b76a0d9f3879b72ebb2cdd6/fonttools-4.56.0-cp313-cp313-win_amd64.whl", hash = "sha256:62cc1253827d1e500fde9dbe981219fea4eb000fd63402283472d38e7d8aa1c6", size = 2186709 }, + { url = "https://files.pythonhosted.org/packages/bf/ff/44934a031ce5a39125415eb405b9efb76fe7f9586b75291d66ae5cbfc4e6/fonttools-4.56.0-py3-none-any.whl", hash = "sha256:1088182f68c303b50ca4dc0c82d42083d176cba37af1937e1a976a31149d4d14", size = 1089800 }, +] + +[[package]] +name = "gala" +source = { editable = "." } +dependencies = [ + { name = "astropy" }, + { name = "cython" }, + { name = "numpy" }, + { name = "pyyaml" }, + { name = "scipy" }, +] + +[package.optional-dependencies] +dev = [ + { name = "astroquery" }, + { name = "findiff" }, + { name = "galpy" }, + { name = "h5py" }, + { name = "ipykernel" }, + { name = "ipython" }, + { name = "ipython-genutils" }, + { name = "jupyter-client" }, + { name = "jupytext" }, + { name = "matplotlib" }, + { name = "nbconvert" }, + { name = "nbsphinx" }, + { name = "numexpr" }, + { name = "numpydoc" }, + { name = "pre-commit" }, + { name = "pydata-sphinx-theme" }, + { name = "pyia" }, + { name = "pytest" }, + { name = "pytest-astropy" }, + { name = "pytest-codspeed" }, + { name = "pytest-xdist" }, + { name = "requests" }, + { name = "rtds-action" }, + { name = "sphinx" }, + { name = "sphinx-astropy" }, + { name = "sphinx-astrorefs" }, + { name = "sphinx-automodapi" }, + { name = "sphinxcontrib-bibtex" }, + { name = "sympy" }, + { name = "tqdm" }, + { name = "twobody" }, +] +docs = [ + { name = "galpy" }, + { name = "h5py" }, + { name = "ipykernel" }, + { name = "ipython-genutils" }, + { name = "jupyter-client" }, + { name = "matplotlib" }, + { name = "nbsphinx" }, + { name = "numexpr" }, + { name = "numpydoc" }, + { name = "pydata-sphinx-theme" }, + { name = "requests" }, + { name = "rtds-action" }, + { name = "sphinx" }, + { name = "sphinx-astropy" }, + { name = "sphinx-astrorefs" }, + { name = "sphinx-automodapi" }, + { name = "sphinxcontrib-bibtex" }, + { name = "sympy" }, + { name = "tqdm" }, + { name = "twobody" }, +] +extra = [ + { name = "galpy" }, + { name = "sympy" }, + { name = "twobody" }, +] +shared = [ + { name = "h5py" }, + { name = "matplotlib" }, + { name = "numexpr" }, + { name = "tqdm" }, +] +test = [ + { name = "findiff" }, + { name = "h5py" }, + { name = "matplotlib" }, + { name = "numexpr" }, + { name = "pytest" }, + { name = "pytest-astropy" }, + { name = "pytest-codspeed" }, + { name = "pytest-xdist" }, + { name = "tqdm" }, +] +tutorials = [ + { name = "astroquery" }, + { name = "galpy" }, + { name = "h5py" }, + { name = "ipykernel" }, + { name = "ipython" }, + { name = "ipython-genutils" }, + { name = "jupyter-client" }, + { name = "jupytext" }, + { name = "matplotlib" }, + { name = "nbconvert" }, + { name = "numexpr" }, + { name = "pyia" }, + { name = "sympy" }, + { name = "tqdm" }, + { name = "twobody" }, +] + +[package.metadata] +requires-dist = [ + { name = "astropy", specifier = ">=6.0" }, + { name = "astroquery", marker = "extra == 'tutorials'" }, + { name = "cython", specifier = ">=0.29" }, + { name = "findiff", marker = "extra == 'test'" }, + { name = "gala", extras = ["docs", "extra", "test", "tutorials"], marker = "extra == 'dev'" }, + { name = "gala", extras = ["extra", "shared"], marker = "extra == 'docs'" }, + { name = "gala", extras = ["extra", "shared"], marker = "extra == 'tutorials'" }, + { name = "gala", extras = ["shared"], marker = "extra == 'test'" }, + { name = "galpy", marker = "extra == 'extra'" }, + { name = "h5py", marker = "extra == 'shared'" }, + { name = "ipykernel", marker = "extra == 'docs'" }, + { name = "ipykernel", marker = "extra == 'tutorials'" }, + { name = "ipython", marker = "extra == 'tutorials'" }, + { name = "ipython-genutils", marker = "extra == 'docs'" }, + { name = "ipython-genutils", marker = "extra == 'tutorials'" }, + { name = "jupyter-client", marker = "extra == 'docs'" }, + { name = "jupyter-client", marker = "extra == 'tutorials'" }, + { name = "jupytext", marker = "extra == 'tutorials'" }, + { name = "matplotlib", marker = "extra == 'shared'" }, + { name = "nbconvert", marker = "extra == 'tutorials'" }, + { name = "nbsphinx", marker = "extra == 'docs'" }, + { name = "numexpr", marker = "extra == 'shared'" }, + { name = "numpy", specifier = ">=1.26.4" }, + { name = "numpydoc", marker = "extra == 'docs'" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pydata-sphinx-theme", marker = "extra == 'docs'" }, + { name = "pyia", marker = "extra == 'tutorials'", specifier = ">=1.4" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-astropy", marker = "extra == 'test'" }, + { name = "pytest-codspeed", marker = "extra == 'test'" }, + { name = "pytest-xdist", marker = "extra == 'test'" }, + { name = "pyyaml" }, + { name = "requests", marker = "extra == 'docs'" }, + { name = "rtds-action", marker = "extra == 'docs'" }, + { name = "scipy", specifier = ">=1.12" }, + { name = "sphinx", marker = "extra == 'docs'" }, + { name = "sphinx-astropy", marker = "extra == 'docs'" }, + { name = "sphinx-astrorefs", marker = "extra == 'docs'" }, + { name = "sphinx-automodapi", marker = "extra == 'docs'" }, + { name = "sphinxcontrib-bibtex", marker = "extra == 'docs'" }, + { name = "sympy", marker = "extra == 'extra'" }, + { name = "tqdm", marker = "extra == 'shared'" }, + { name = "twobody", marker = "extra == 'extra'" }, +] +provides-extras = ["shared", "test", "extra", "docs", "tutorials", "dev"] + +[[package]] +name = "galpy" +version = "1.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/5b/b5f1a021046684b803f374026d5d5f9bf343c43bd31beb8170cb93ad1263/galpy-1.10.1.tar.gz", hash = "sha256:a674b9e38b2188efa716b34a4ab49035396049a2523166dcd1f0ffe068753067", size = 863612 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/dd/5a6e23ceea16818ceb2531c33ea4e9f55c5a03dc410e219e142b33dc3e0d/galpy-1.10.1-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:4799639949ba694ffa6b480b79a0fb1a9b7ff759b7b67312fb6555fa7da1036c", size = 17066523 }, + { url = "https://files.pythonhosted.org/packages/b6/51/a20e48c6c333583f7e981d1990d822e64af39ab42e8bd76dc8884f74f253/galpy-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93ee958b484bea2857542ee0e1c604f0cb31e7475fd5594d6d8d775ab82f0c0a", size = 7935754 }, + { url = "https://files.pythonhosted.org/packages/8a/c6/87ae643fc0ae34d15b473fa6f4a83022f3a47163957923cf11c4ce39d057/galpy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c098675f5a2d1cc5bed6e87376e1b512fc8ab1142105ff1c59acffc3bf52e21", size = 6113690 }, + { url = "https://files.pythonhosted.org/packages/a9/c1/b524f94e3663e56c82602cd20a06efbfca50491cd3955610fe50fb75349e/galpy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:2158dfc1db37a5821d6628618b39d11047ab46e8c7d86dd970168965923a7776", size = 806389 }, + { url = "https://files.pythonhosted.org/packages/d9/55/0c39909ea41645831fdf1abda154315d96d49154864653dcda0d1757122f/galpy-1.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfae865b40f38e5a36aeb33cebad57e33a1522e07b2e319923f460d75e949c6f", size = 17066527 }, + { url = "https://files.pythonhosted.org/packages/1f/23/ad58b6336ce4d7169f77d247f3b6bc411c631f5e49578e60f4d6b5423975/galpy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3f1d1aa5ca67591deff20825441113e3b2f43f90cc9e438b051c17e81a9e4e6", size = 7935761 }, + { url = "https://files.pythonhosted.org/packages/d7/c3/a546c9f9bd4afbe0617fed238d83c19de77fc8b4b7f1419158afacd0136b/galpy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:622d6fa09ace58c8a0614e52916ece63153751ae500b8f364afb008c68d854cc", size = 6113777 }, + { url = "https://files.pythonhosted.org/packages/9a/2d/ef12f8b10a809df454a6fc1a15f9c3d6111198e3b882919ae68626cb6425/galpy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:0f5f5d948d5b1b9dfaf3e85f24034cf80ddb9984fd5e8f1b298601c92a86c810", size = 806390 }, + { url = "https://files.pythonhosted.org/packages/f9/9f/04c519f95d9f2fe2a5273283fa2ba40b86191d665efdda4106e084895db5/galpy-1.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2275221e10e72fcf0aaeb2f621f72911055c460da3acaa2bf6c9e0c51421f5db", size = 17066523 }, + { url = "https://files.pythonhosted.org/packages/04/0d/83cdce97760b49082ef40b4a635a7c1ebd49428a98e10f1d2b51fb5b156e/galpy-1.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:865a4488674c429841bc9966157a7d1432393da44108c74ed745fa631cade061", size = 7935765 }, + { url = "https://files.pythonhosted.org/packages/6b/b7/16d2481d8966c27d52d36f64dadf19697970d5f186d806eb95b002fec193/galpy-1.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d86bacc31728afc9853eb9a545e66d7127b2184101d6b04d94dc1af637735cab", size = 6113776 }, + { url = "https://files.pythonhosted.org/packages/31/2d/aa588c554a039bf7e5524876ec37a154768d2f955545284a340f511793ec/galpy-1.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:37b779d8cf1a8986e149856c1e22f54cf21cc0ee566e358a840931b80f9e1a39", size = 806389 }, +] + +[[package]] +name = "h5py" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/2e/a22d6a8bfa6f8be33e7febd985680fba531562795f0a9077ed1eb047bfb0/h5py-3.13.0.tar.gz", hash = "sha256:1870e46518720023da85d0895a1960ff2ce398c5671eac3b1a41ec696b7105c3", size = 414876 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/2b/50b15fdefb577d073b49699e6ea6a0a77a3a1016c2b67e2149fc50124a10/h5py-3.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8a8e38ef4ceb969f832cc230c0cf808c613cc47e31e768fd7b1106c55afa1cb8", size = 3422922 }, + { url = "https://files.pythonhosted.org/packages/94/59/36d87a559cab9c59b59088d52e86008d27a9602ce3afc9d3b51823014bf3/h5py-3.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f35640e81b03c02a88b8bf99fb6a9d3023cc52f7c627694db2f379e0028f2868", size = 2921619 }, + { url = "https://files.pythonhosted.org/packages/37/ef/6f80b19682c0b0835bbee7b253bec9c16af9004f2fd6427b1dd858100273/h5py-3.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:337af114616f3656da0c83b68fcf53ecd9ce9989a700b0883a6e7c483c3235d4", size = 4259366 }, + { url = "https://files.pythonhosted.org/packages/03/71/c99f662d4832c8835453cf3476f95daa28372023bda4aa1fca9e97c24f09/h5py-3.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:782ff0ac39f455f21fd1c8ebc007328f65f43d56718a89327eec76677ebf238a", size = 4509058 }, + { url = "https://files.pythonhosted.org/packages/56/89/e3ff23e07131ff73a72a349be9639e4de84e163af89c1c218b939459a98a/h5py-3.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:22ffe2a25770a2d67213a1b94f58006c14dce06933a42d2aaa0318c5868d1508", size = 2966428 }, + { url = "https://files.pythonhosted.org/packages/d8/20/438f6366ba4ded80eadb38f8927f5e2cd6d2e087179552f20ae3dbcd5d5b/h5py-3.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:477c58307b6b9a2509c59c57811afb9f598aedede24a67da808262dfa0ee37b4", size = 3384442 }, + { url = "https://files.pythonhosted.org/packages/10/13/cc1cb7231399617d9951233eb12fddd396ff5d4f7f057ee5d2b1ca0ee7e7/h5py-3.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:57c4c74f627c616f02b7aec608a8c706fe08cb5b0ba7c08555a4eb1dde20805a", size = 2917567 }, + { url = "https://files.pythonhosted.org/packages/9e/d9/aed99e1c858dc698489f916eeb7c07513bc864885d28ab3689d572ba0ea0/h5py-3.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:357e6dc20b101a805ccfd0024731fbaf6e8718c18c09baf3b5e4e9d198d13fca", size = 4669544 }, + { url = "https://files.pythonhosted.org/packages/a7/da/3c137006ff5f0433f0fb076b1ebe4a7bf7b5ee1e8811b5486af98b500dd5/h5py-3.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6f13f9b5ce549448c01e4dfe08ea8d1772e6078799af2c1c8d09e941230a90d", size = 4932139 }, + { url = "https://files.pythonhosted.org/packages/25/61/d897952629cae131c19d4c41b2521e7dd6382f2d7177c87615c2e6dced1a/h5py-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:21daf38171753899b5905f3d82c99b0b1ec2cbbe282a037cad431feb620e62ec", size = 2954179 }, + { url = "https://files.pythonhosted.org/packages/60/43/f276f27921919a9144074320ce4ca40882fc67b3cfee81c3f5c7df083e97/h5py-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e520ec76de00943dd017c8ea3f354fa1d2f542eac994811943a8faedf2a7d5cb", size = 3358040 }, + { url = "https://files.pythonhosted.org/packages/1b/86/ad4a4cf781b08d4572be8bbdd8f108bb97b266a14835c640dc43dafc0729/h5py-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e79d8368cd9295045956bfb436656bea3f915beaa11d342e9f79f129f5178763", size = 2892766 }, + { url = "https://files.pythonhosted.org/packages/69/84/4c6367d6b58deaf0fa84999ec819e7578eee96cea6cbd613640d0625ed5e/h5py-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56dd172d862e850823c4af02dc4ddbc308f042b85472ffdaca67f1598dff4a57", size = 4664255 }, + { url = "https://files.pythonhosted.org/packages/fd/41/bc2df86b72965775f6d621e0ee269a5f3ac23e8f870abf519de9c7d93b4d/h5py-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be949b46b7388074c5acae017fbbe3e5ba303fd9daaa52157fdfef30bbdacadd", size = 4927580 }, + { url = "https://files.pythonhosted.org/packages/97/34/165b87ea55184770a0c1fcdb7e017199974ad2e271451fd045cfe35f3add/h5py-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f97ecde7ac6513b21cd95efdfc38dc6d19f96f6ca6f2a30550e94e551458e0a", size = 2940890 }, +] + +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173 }, +] + +[[package]] +name = "hypothesis" +version = "6.127.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/35/b67d606187bbae9a7bca7afb24d05e9b3c106397bc3f9e40241ced2c4255/hypothesis-6.127.3.tar.gz", hash = "sha256:be7946a8ba3da4964a5b8623bd8e95f019daca0618f5d0450fb17fe19272a108", size = 419847 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/24/8f7b0bbd677e1d3a2994dcf589734341be5ab3a32b3186e7239d6aa2b71e/hypothesis-6.127.3-py3-none-any.whl", hash = "sha256:8246ae8530b64af60f821d845bf7b12aafc28e49ef9096abf9238d48f13fc3dd", size = 483419 }, +] + +[[package]] +name = "identify" +version = "2.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, +] + +[[package]] +name = "importlib-metadata" +version = "8.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/9d/0fb148dc4d6fa4a7dd1d8378168d9b4cd8d4560a6fbf6f0121c5fc34eb68/importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e", size = 26971 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "ipykernel" +version = "6.29.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/67594cb0c7055dc50814b21731c22a601101ea3b1b50a9a1b090e11f5d0f/ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215", size = 163367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5", size = 117173 }, +] + +[[package]] +name = "ipython" +version = "8.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5d/27844489a849a9ceb94ea59c1adac9323fb77175a3076742ed76dcc87f07/ipython-8.33.0.tar.gz", hash = "sha256:4c3e36a6dfa9e8e3702bd46f3df668624c975a22ff340e96ea7277afbd76217d", size = 5508284 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/e7/7b144d0c3a16f56b213b2d9f9bee22e50f6e54265a551db9f43f09e2c084/ipython-8.33.0-py3-none-any.whl", hash = "sha256:aa5b301dfe1eaf0167ff3238a6825f810a029c9dad9d3f1597f30bd5ff65cc44", size = 826720 }, +] + +[[package]] +name = "ipython-genutils" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/69/fbeffffc05236398ebfcfb512b6d2511c622871dca1746361006da310399/ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8", size = 22208 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/bc/9bd3b5c2b4774d5f33b2d544f1460be9df7df2fe42f352135381c347c69a/ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8", size = 26343 }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825 }, +] + +[[package]] +name = "jaraco-functools" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 }, +] + +[[package]] +name = "jinja2" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596 }, +] + +[[package]] +name = "jsonschema" +version = "4.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2024.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/db/58f950c996c793472e336ff3655b13fbcf1e3b359dcf52dcf3ed3b52c352/jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272", size = 15561 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/0f/8910b19ac0670a0f80ce1008e5e751c4a57e14d2c4c13a482aa6079fa9d6/jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf", size = 18459 }, +] + +[[package]] +name = "jupyter-client" +version = "8.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105 }, +] + +[[package]] +name = "jupyter-core" +version = "5.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/11/b56381fa6c3f4cc5d2cf54a7dbf98ad9aa0b339ef7a601d6053538b079a7/jupyter_core-5.7.2.tar.gz", hash = "sha256:aa5f8d32bbf6b431ac830496da7392035d6f61b4f54872f15c4bd2a9c3f536d9", size = 87629 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/fb/108ecd1fe961941959ad0ee4e12ee7b8b1477247f30b1fdfd83ceaf017f0/jupyter_core-5.7.2-py3-none-any.whl", hash = "sha256:4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409", size = 28965 }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884 }, +] + +[[package]] +name = "jupytext" +version = "1.16.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/40/641e0a94d84dee18b7815233a1e0e3c54228169fad529f12c3549a12f9ac/jupytext-1.16.7.tar.gz", hash = "sha256:fc4e97f0890e22062c4ef10313c7ca960b07b3767246a1fef7585888cc2afe5d", size = 3734420 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/4c/3d7cfac5b8351f649ce41a1007a769baacae8d5d29e481a93d799a209c3f/jupytext-1.16.7-py3-none-any.whl", hash = "sha256:912f9d9af7bd3f15470105e5c5dddf1669b2d8c17f0c55772687fc5a4a73fe69", size = 154154 }, +] + +[[package]] +name = "keyring" +version = "25.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085 }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/59/7c91426a8ac292e1cdd53a63b6d9439abd573c875c3f92c146767dd33faf/kiwisolver-1.4.8.tar.gz", hash = "sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e", size = 97538 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/ed/c913ee28936c371418cb167b128066ffb20bbf37771eecc2c97edf8a6e4c/kiwisolver-1.4.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a4d3601908c560bdf880f07d94f31d734afd1bb71e96585cace0e38ef44c6d84", size = 124635 }, + { url = "https://files.pythonhosted.org/packages/4c/45/4a7f896f7467aaf5f56ef093d1f329346f3b594e77c6a3c327b2d415f521/kiwisolver-1.4.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:856b269c4d28a5c0d5e6c1955ec36ebfd1651ac00e1ce0afa3e28da95293b561", size = 66717 }, + { url = "https://files.pythonhosted.org/packages/5f/b4/c12b3ac0852a3a68f94598d4c8d569f55361beef6159dce4e7b624160da2/kiwisolver-1.4.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c2b9a96e0f326205af81a15718a9073328df1173a2619a68553decb7097fd5d7", size = 65413 }, + { url = "https://files.pythonhosted.org/packages/a9/98/1df4089b1ed23d83d410adfdc5947245c753bddfbe06541c4aae330e9e70/kiwisolver-1.4.8-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5020c83e8553f770cb3b5fc13faac40f17e0b205bd237aebd21d53d733adb03", size = 1343994 }, + { url = "https://files.pythonhosted.org/packages/8d/bf/b4b169b050c8421a7c53ea1ea74e4ef9c335ee9013216c558a047f162d20/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dace81d28c787956bfbfbbfd72fdcef014f37d9b48830829e488fdb32b49d954", size = 1434804 }, + { url = "https://files.pythonhosted.org/packages/66/5a/e13bd341fbcf73325ea60fdc8af752addf75c5079867af2e04cc41f34434/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11e1022b524bd48ae56c9b4f9296bce77e15a2e42a502cceba602f804b32bb79", size = 1450690 }, + { url = "https://files.pythonhosted.org/packages/9b/4f/5955dcb376ba4a830384cc6fab7d7547bd6759fe75a09564910e9e3bb8ea/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b9b4d2892fefc886f30301cdd80debd8bb01ecdf165a449eb6e78f79f0fabd6", size = 1376839 }, + { url = "https://files.pythonhosted.org/packages/3a/97/5edbed69a9d0caa2e4aa616ae7df8127e10f6586940aa683a496c2c280b9/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a96c0e790ee875d65e340ab383700e2b4891677b7fcd30a699146f9384a2bb0", size = 1435109 }, + { url = "https://files.pythonhosted.org/packages/13/fc/e756382cb64e556af6c1809a1bbb22c141bbc2445049f2da06b420fe52bf/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23454ff084b07ac54ca8be535f4174170c1094a4cff78fbae4f73a4bcc0d4dab", size = 2245269 }, + { url = "https://files.pythonhosted.org/packages/76/15/e59e45829d7f41c776d138245cabae6515cb4eb44b418f6d4109c478b481/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:87b287251ad6488e95b4f0b4a79a6d04d3ea35fde6340eb38fbd1ca9cd35bbbc", size = 2393468 }, + { url = "https://files.pythonhosted.org/packages/e9/39/483558c2a913ab8384d6e4b66a932406f87c95a6080112433da5ed668559/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b21dbe165081142b1232a240fc6383fd32cdd877ca6cc89eab93e5f5883e1c25", size = 2355394 }, + { url = "https://files.pythonhosted.org/packages/01/aa/efad1fbca6570a161d29224f14b082960c7e08268a133fe5dc0f6906820e/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:768cade2c2df13db52475bd28d3a3fac8c9eff04b0e9e2fda0f3760f20b3f7fc", size = 2490901 }, + { url = "https://files.pythonhosted.org/packages/c9/4f/15988966ba46bcd5ab9d0c8296914436720dd67fca689ae1a75b4ec1c72f/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d47cfb2650f0e103d4bf68b0b5804c68da97272c84bb12850d877a95c056bd67", size = 2312306 }, + { url = "https://files.pythonhosted.org/packages/2d/27/bdf1c769c83f74d98cbc34483a972f221440703054894a37d174fba8aa68/kiwisolver-1.4.8-cp311-cp311-win_amd64.whl", hash = "sha256:ed33ca2002a779a2e20eeb06aea7721b6e47f2d4b8a8ece979d8ba9e2a167e34", size = 71966 }, + { url = "https://files.pythonhosted.org/packages/4a/c9/9642ea855604aeb2968a8e145fc662edf61db7632ad2e4fb92424be6b6c0/kiwisolver-1.4.8-cp311-cp311-win_arm64.whl", hash = "sha256:16523b40aab60426ffdebe33ac374457cf62863e330a90a0383639ce14bf44b2", size = 65311 }, + { url = "https://files.pythonhosted.org/packages/fc/aa/cea685c4ab647f349c3bc92d2daf7ae34c8e8cf405a6dcd3a497f58a2ac3/kiwisolver-1.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d6af5e8815fd02997cb6ad9bbed0ee1e60014438ee1a5c2444c96f87b8843502", size = 124152 }, + { url = "https://files.pythonhosted.org/packages/c5/0b/8db6d2e2452d60d5ebc4ce4b204feeb16176a851fd42462f66ade6808084/kiwisolver-1.4.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bade438f86e21d91e0cf5dd7c0ed00cda0f77c8c1616bd83f9fc157fa6760d31", size = 66555 }, + { url = "https://files.pythonhosted.org/packages/60/26/d6a0db6785dd35d3ba5bf2b2df0aedc5af089962c6eb2cbf67a15b81369e/kiwisolver-1.4.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b83dc6769ddbc57613280118fb4ce3cd08899cc3369f7d0e0fab518a7cf37fdb", size = 65067 }, + { url = "https://files.pythonhosted.org/packages/c9/ed/1d97f7e3561e09757a196231edccc1bcf59d55ddccefa2afc9c615abd8e0/kiwisolver-1.4.8-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111793b232842991be367ed828076b03d96202c19221b5ebab421ce8bcad016f", size = 1378443 }, + { url = "https://files.pythonhosted.org/packages/29/61/39d30b99954e6b46f760e6289c12fede2ab96a254c443639052d1b573fbc/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:257af1622860e51b1a9d0ce387bf5c2c4f36a90594cb9514f55b074bcc787cfc", size = 1472728 }, + { url = "https://files.pythonhosted.org/packages/0c/3e/804163b932f7603ef256e4a715e5843a9600802bb23a68b4e08c8c0ff61d/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b5637c3f316cab1ec1c9a12b8c5f4750a4c4b71af9157645bf32830e39c03a", size = 1478388 }, + { url = "https://files.pythonhosted.org/packages/8a/9e/60eaa75169a154700be74f875a4d9961b11ba048bef315fbe89cb6999056/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:782bb86f245ec18009890e7cb8d13a5ef54dcf2ebe18ed65f795e635a96a1c6a", size = 1413849 }, + { url = "https://files.pythonhosted.org/packages/bc/b3/9458adb9472e61a998c8c4d95cfdfec91c73c53a375b30b1428310f923e4/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc978a80a0db3a66d25767b03688f1147a69e6237175c0f4ffffaaedf744055a", size = 1475533 }, + { url = "https://files.pythonhosted.org/packages/e4/7a/0a42d9571e35798de80aef4bb43a9b672aa7f8e58643d7bd1950398ffb0a/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36dbbfd34838500a31f52c9786990d00150860e46cd5041386f217101350f0d3", size = 2268898 }, + { url = "https://files.pythonhosted.org/packages/d9/07/1255dc8d80271400126ed8db35a1795b1a2c098ac3a72645075d06fe5c5d/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eaa973f1e05131de5ff3569bbba7f5fd07ea0595d3870ed4a526d486fe57fa1b", size = 2425605 }, + { url = "https://files.pythonhosted.org/packages/84/df/5a3b4cf13780ef6f6942df67b138b03b7e79e9f1f08f57c49957d5867f6e/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a66f60f8d0c87ab7f59b6fb80e642ebb29fec354a4dfad687ca4092ae69d04f4", size = 2375801 }, + { url = "https://files.pythonhosted.org/packages/8f/10/2348d068e8b0f635c8c86892788dac7a6b5c0cb12356620ab575775aad89/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858416b7fb777a53f0c59ca08190ce24e9abbd3cffa18886a5781b8e3e26f65d", size = 2520077 }, + { url = "https://files.pythonhosted.org/packages/32/d8/014b89fee5d4dce157d814303b0fce4d31385a2af4c41fed194b173b81ac/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:085940635c62697391baafaaeabdf3dd7a6c3643577dde337f4d66eba021b2b8", size = 2338410 }, + { url = "https://files.pythonhosted.org/packages/bd/72/dfff0cc97f2a0776e1c9eb5bef1ddfd45f46246c6533b0191887a427bca5/kiwisolver-1.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:01c3d31902c7db5fb6182832713d3b4122ad9317c2c5877d0539227d96bb2e50", size = 71853 }, + { url = "https://files.pythonhosted.org/packages/dc/85/220d13d914485c0948a00f0b9eb419efaf6da81b7d72e88ce2391f7aed8d/kiwisolver-1.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:a3c44cb68861de93f0c4a8175fbaa691f0aa22550c331fefef02b618a9dcb476", size = 65424 }, + { url = "https://files.pythonhosted.org/packages/79/b3/e62464a652f4f8cd9006e13d07abad844a47df1e6537f73ddfbf1bc997ec/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1c8ceb754339793c24aee1c9fb2485b5b1f5bb1c2c214ff13368431e51fc9a09", size = 124156 }, + { url = "https://files.pythonhosted.org/packages/8d/2d/f13d06998b546a2ad4f48607a146e045bbe48030774de29f90bdc573df15/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a62808ac74b5e55a04a408cda6156f986cefbcf0ada13572696b507cc92fa1", size = 66555 }, + { url = "https://files.pythonhosted.org/packages/59/e3/b8bd14b0a54998a9fd1e8da591c60998dc003618cb19a3f94cb233ec1511/kiwisolver-1.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68269e60ee4929893aad82666821aaacbd455284124817af45c11e50a4b42e3c", size = 65071 }, + { url = "https://files.pythonhosted.org/packages/f0/1c/6c86f6d85ffe4d0ce04228d976f00674f1df5dc893bf2dd4f1928748f187/kiwisolver-1.4.8-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34d142fba9c464bc3bbfeff15c96eab0e7310343d6aefb62a79d51421fcc5f1b", size = 1378053 }, + { url = "https://files.pythonhosted.org/packages/4e/b9/1c6e9f6dcb103ac5cf87cb695845f5fa71379021500153566d8a8a9fc291/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc373e0eef45b59197de815b1b28ef89ae3955e7722cc9710fb91cd77b7f47", size = 1472278 }, + { url = "https://files.pythonhosted.org/packages/ee/81/aca1eb176de671f8bda479b11acdc42c132b61a2ac861c883907dde6debb/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77e6f57a20b9bd4e1e2cedda4d0b986ebd0216236f0106e55c28aea3d3d69b16", size = 1478139 }, + { url = "https://files.pythonhosted.org/packages/49/f4/e081522473671c97b2687d380e9e4c26f748a86363ce5af48b4a28e48d06/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08e77738ed7538f036cd1170cbed942ef749137b1311fa2bbe2a7fda2f6bf3cc", size = 1413517 }, + { url = "https://files.pythonhosted.org/packages/8f/e9/6a7d025d8da8c4931522922cd706105aa32b3291d1add8c5427cdcd66e63/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5ce1e481a74b44dd5e92ff03ea0cb371ae7a0268318e202be06c8f04f4f1246", size = 1474952 }, + { url = "https://files.pythonhosted.org/packages/82/13/13fa685ae167bee5d94b415991c4fc7bb0a1b6ebea6e753a87044b209678/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc2ace710ba7c1dfd1a3b42530b62b9ceed115f19a1656adefce7b1782a37794", size = 2269132 }, + { url = "https://files.pythonhosted.org/packages/ef/92/bb7c9395489b99a6cb41d502d3686bac692586db2045adc19e45ee64ed23/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3452046c37c7692bd52b0e752b87954ef86ee2224e624ef7ce6cb21e8c41cc1b", size = 2425997 }, + { url = "https://files.pythonhosted.org/packages/ed/12/87f0e9271e2b63d35d0d8524954145837dd1a6c15b62a2d8c1ebe0f182b4/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e9a60b50fe8b2ec6f448fe8d81b07e40141bfced7f896309df271a0b92f80f3", size = 2376060 }, + { url = "https://files.pythonhosted.org/packages/02/6e/c8af39288edbce8bf0fa35dee427b082758a4b71e9c91ef18fa667782138/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:918139571133f366e8362fa4a297aeba86c7816b7ecf0bc79168080e2bd79957", size = 2520471 }, + { url = "https://files.pythonhosted.org/packages/13/78/df381bc7b26e535c91469f77f16adcd073beb3e2dd25042efd064af82323/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e063ef9f89885a1d68dd8b2e18f5ead48653176d10a0e324e3b0030e3a69adeb", size = 2338793 }, + { url = "https://files.pythonhosted.org/packages/d0/dc/c1abe38c37c071d0fc71c9a474fd0b9ede05d42f5a458d584619cfd2371a/kiwisolver-1.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:a17b7c4f5b2c51bb68ed379defd608a03954a1845dfed7cc0117f1cc8a9b7fd2", size = 71855 }, + { url = "https://files.pythonhosted.org/packages/a0/b6/21529d595b126ac298fdd90b705d87d4c5693de60023e0efcb4f387ed99e/kiwisolver-1.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:3cd3bc628b25f74aedc6d374d5babf0166a92ff1317f46267f12d2ed54bc1d30", size = 65430 }, + { url = "https://files.pythonhosted.org/packages/34/bd/b89380b7298e3af9b39f49334e3e2a4af0e04819789f04b43d560516c0c8/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:370fd2df41660ed4e26b8c9d6bbcad668fbe2560462cba151a721d49e5b6628c", size = 126294 }, + { url = "https://files.pythonhosted.org/packages/83/41/5857dc72e5e4148eaac5aa76e0703e594e4465f8ab7ec0fc60e3a9bb8fea/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:84a2f830d42707de1d191b9490ac186bf7997a9495d4e9072210a1296345f7dc", size = 67736 }, + { url = "https://files.pythonhosted.org/packages/e1/d1/be059b8db56ac270489fb0b3297fd1e53d195ba76e9bbb30e5401fa6b759/kiwisolver-1.4.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7a3ad337add5148cf51ce0b55642dc551c0b9d6248458a757f98796ca7348712", size = 66194 }, + { url = "https://files.pythonhosted.org/packages/e1/83/4b73975f149819eb7dcf9299ed467eba068ecb16439a98990dcb12e63fdd/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7506488470f41169b86d8c9aeff587293f530a23a23a49d6bc64dab66bedc71e", size = 1465942 }, + { url = "https://files.pythonhosted.org/packages/c7/2c/30a5cdde5102958e602c07466bce058b9d7cb48734aa7a4327261ac8e002/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f0121b07b356a22fb0414cec4666bbe36fd6d0d759db3d37228f496ed67c880", size = 1595341 }, + { url = "https://files.pythonhosted.org/packages/ff/9b/1e71db1c000385aa069704f5990574b8244cce854ecd83119c19e83c9586/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6d6bd87df62c27d4185de7c511c6248040afae67028a8a22012b010bc7ad062", size = 1598455 }, + { url = "https://files.pythonhosted.org/packages/85/92/c8fec52ddf06231b31cbb779af77e99b8253cd96bd135250b9498144c78b/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:291331973c64bb9cce50bbe871fb2e675c4331dab4f31abe89f175ad7679a4d7", size = 1522138 }, + { url = "https://files.pythonhosted.org/packages/0b/51/9eb7e2cd07a15d8bdd976f6190c0164f92ce1904e5c0c79198c4972926b7/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893f5525bb92d3d735878ec00f781b2de998333659507d29ea4466208df37bed", size = 1582857 }, + { url = "https://files.pythonhosted.org/packages/0f/95/c5a00387a5405e68ba32cc64af65ce881a39b98d73cc394b24143bebc5b8/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b47a465040146981dc9db8647981b8cb96366fbc8d452b031e4f8fdffec3f26d", size = 2293129 }, + { url = "https://files.pythonhosted.org/packages/44/83/eeb7af7d706b8347548313fa3a3a15931f404533cc54fe01f39e830dd231/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:99cea8b9dd34ff80c521aef46a1dddb0dcc0283cf18bde6d756f1e6f31772165", size = 2421538 }, + { url = "https://files.pythonhosted.org/packages/05/f9/27e94c1b3eb29e6933b6986ffc5fa1177d2cd1f0c8efc5f02c91c9ac61de/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:151dffc4865e5fe6dafce5480fab84f950d14566c480c08a53c663a0020504b6", size = 2390661 }, + { url = "https://files.pythonhosted.org/packages/d9/d4/3c9735faa36ac591a4afcc2980d2691000506050b7a7e80bcfe44048daa7/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:577facaa411c10421314598b50413aa1ebcf5126f704f1e5d72d7e4e9f020d90", size = 2546710 }, + { url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213 }, +] + +[[package]] +name = "latexcodec" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/e7/ed339caf3662976949e4fdbfdf4a6db818b8d2aa1cf2b5f73af89e936bba/latexcodec-3.0.0.tar.gz", hash = "sha256:917dc5fe242762cc19d963e6548b42d63a118028cdd3361d62397e3b638b6bc5", size = 31023 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/bf/ea8887e9f31a8f93ca306699d11909c6140151393a4216f0d9f85a004077/latexcodec-3.0.0-py3-none-any.whl", hash = "sha256:6f3477ad5e61a0a99bd31a6a370c34e88733a6bad9c921a3ffcfacada12f41a7", size = 18150 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "matplotlib" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/08/b89867ecea2e305f408fbb417139a8dd941ecf7b23a2e02157c36da546f0/matplotlib-3.10.1.tar.gz", hash = "sha256:e8d2d0e3881b129268585bf4765ad3ee73a4591d77b9a18c214ac7e3a79fb2ba", size = 36743335 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/14/a1b840075be247bb1834b22c1e1d558740b0f618fe3a823740181ca557a1/matplotlib-3.10.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:057206ff2d6ab82ff3e94ebd94463d084760ca682ed5f150817b859372ec4401", size = 8174669 }, + { url = "https://files.pythonhosted.org/packages/0a/e4/300b08e3e08f9c98b0d5635f42edabf2f7a1d634e64cb0318a71a44ff720/matplotlib-3.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a144867dd6bf8ba8cb5fc81a158b645037e11b3e5cf8a50bd5f9917cb863adfe", size = 8047996 }, + { url = "https://files.pythonhosted.org/packages/75/f9/8d99ff5a2498a5f1ccf919fb46fb945109623c6108216f10f96428f388bc/matplotlib-3.10.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56c5d9fcd9879aa8040f196a235e2dcbdf7dd03ab5b07c0696f80bc6cf04bedd", size = 8461612 }, + { url = "https://files.pythonhosted.org/packages/40/b8/53fa08a5eaf78d3a7213fd6da1feec4bae14a81d9805e567013811ff0e85/matplotlib-3.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f69dc9713e4ad2fb21a1c30e37bd445d496524257dfda40ff4a8efb3604ab5c", size = 8602258 }, + { url = "https://files.pythonhosted.org/packages/40/87/4397d2ce808467af86684a622dd112664553e81752ea8bf61bdd89d24a41/matplotlib-3.10.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4c59af3e8aca75d7744b68e8e78a669e91ccbcf1ac35d0102a7b1b46883f1dd7", size = 9408896 }, + { url = "https://files.pythonhosted.org/packages/d7/68/0d03098b3feb786cbd494df0aac15b571effda7f7cbdec267e8a8d398c16/matplotlib-3.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:11b65088c6f3dae784bc72e8d039a2580186285f87448babb9ddb2ad0082993a", size = 8061281 }, + { url = "https://files.pythonhosted.org/packages/7c/1d/5e0dc3b59c034e43de16f94deb68f4ad8a96b3ea00f4b37c160b7474928e/matplotlib-3.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:66e907a06e68cb6cfd652c193311d61a12b54f56809cafbed9736ce5ad92f107", size = 8175488 }, + { url = "https://files.pythonhosted.org/packages/7a/81/dae7e14042e74da658c3336ab9799128e09a1ee03964f2d89630b5d12106/matplotlib-3.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e9b4bb156abb8fa5e5b2b460196f7db7264fc6d62678c03457979e7d5254b7be", size = 8046264 }, + { url = "https://files.pythonhosted.org/packages/21/c4/22516775dcde10fc9c9571d155f90710761b028fc44f660508106c363c97/matplotlib-3.10.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1985ad3d97f51307a2cbfc801a930f120def19ba22864182dacef55277102ba6", size = 8452048 }, + { url = "https://files.pythonhosted.org/packages/63/23/c0615001f67ce7c96b3051d856baedc0c818a2ed84570b9bf9bde200f85d/matplotlib-3.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c96f2c2f825d1257e437a1482c5a2cf4fee15db4261bd6fc0750f81ba2b4ba3d", size = 8597111 }, + { url = "https://files.pythonhosted.org/packages/ca/c0/a07939a82aed77770514348f4568177d7dadab9787ebc618a616fe3d665e/matplotlib-3.10.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35e87384ee9e488d8dd5a2dd7baf471178d38b90618d8ea147aced4ab59c9bea", size = 9402771 }, + { url = "https://files.pythonhosted.org/packages/a6/b6/a9405484fb40746fdc6ae4502b16a9d6e53282ba5baaf9ebe2da579f68c4/matplotlib-3.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:cfd414bce89cc78a7e1d25202e979b3f1af799e416010a20ab2b5ebb3a02425c", size = 8063742 }, + { url = "https://files.pythonhosted.org/packages/60/73/6770ff5e5523d00f3bc584acb6031e29ee5c8adc2336b16cd1d003675fe0/matplotlib-3.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42eee41e1b60fd83ee3292ed83a97a5f2a8239b10c26715d8a6172226988d7b", size = 8176112 }, + { url = "https://files.pythonhosted.org/packages/08/97/b0ca5da0ed54a3f6599c3ab568bdda65269bc27c21a2c97868c1625e4554/matplotlib-3.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4f0647b17b667ae745c13721602b540f7aadb2a32c5b96e924cd4fea5dcb90f1", size = 8046931 }, + { url = "https://files.pythonhosted.org/packages/df/9a/1acbdc3b165d4ce2dcd2b1a6d4ffb46a7220ceee960c922c3d50d8514067/matplotlib-3.10.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa3854b5f9473564ef40a41bc922be978fab217776e9ae1545c9b3a5cf2092a3", size = 8453422 }, + { url = "https://files.pythonhosted.org/packages/51/d0/2bc4368abf766203e548dc7ab57cf7e9c621f1a3c72b516cc7715347b179/matplotlib-3.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e496c01441be4c7d5f96d4e40f7fca06e20dcb40e44c8daa2e740e1757ad9e6", size = 8596819 }, + { url = "https://files.pythonhosted.org/packages/ab/1b/8b350f8a1746c37ab69dda7d7528d1fc696efb06db6ade9727b7887be16d/matplotlib-3.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d45d3f5245be5b469843450617dcad9af75ca50568acf59997bed9311131a0b", size = 9402782 }, + { url = "https://files.pythonhosted.org/packages/89/06/f570373d24d93503988ba8d04f213a372fa1ce48381c5eb15da985728498/matplotlib-3.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:8e8e25b1209161d20dfe93037c8a7f7ca796ec9aa326e6e4588d8c4a5dd1e473", size = 8063812 }, + { url = "https://files.pythonhosted.org/packages/fc/e0/8c811a925b5a7ad75135f0e5af46408b78af88bbb02a1df775100ef9bfef/matplotlib-3.10.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:19b06241ad89c3ae9469e07d77efa87041eac65d78df4fcf9cac318028009b01", size = 8214021 }, + { url = "https://files.pythonhosted.org/packages/4a/34/319ec2139f68ba26da9d00fce2ff9f27679fb799a6c8e7358539801fd629/matplotlib-3.10.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01e63101ebb3014e6e9f80d9cf9ee361a8599ddca2c3e166c563628b39305dbb", size = 8090782 }, + { url = "https://files.pythonhosted.org/packages/77/ea/9812124ab9a99df5b2eec1110e9b2edc0b8f77039abf4c56e0a376e84a29/matplotlib-3.10.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f06bad951eea6422ac4e8bdebcf3a70c59ea0a03338c5d2b109f57b64eb3972", size = 8478901 }, + { url = "https://files.pythonhosted.org/packages/c9/db/b05bf463689134789b06dea85828f8ebe506fa1e37593f723b65b86c9582/matplotlib-3.10.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3dfb036f34873b46978f55e240cff7a239f6c4409eac62d8145bad3fc6ba5a3", size = 8613864 }, + { url = "https://files.pythonhosted.org/packages/c2/04/41ccec4409f3023a7576df3b5c025f1a8c8b81fbfe922ecfd837ac36e081/matplotlib-3.10.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dc6ab14a7ab3b4d813b88ba957fc05c79493a037f54e246162033591e770de6f", size = 9409487 }, + { url = "https://files.pythonhosted.org/packages/ac/c2/0d5aae823bdcc42cc99327ecdd4d28585e15ccd5218c453b7bcd827f3421/matplotlib-3.10.1-cp313-cp313t-win_amd64.whl", hash = "sha256:bc411ebd5889a78dabbc457b3fa153203e22248bfa6eedc6797be5df0164dbf9", size = 8134832 }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "mistune" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/f7/f6d06304c61c2a73213c0a4815280f70d985429cda26272f490e42119c1a/mistune-3.1.2.tar.gz", hash = "sha256:733bf018ba007e8b5f2d3a9eb624034f6ee26c4ea769a98ec533ee111d504dff", size = 94613 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/92/30b4e54c4d7c48c06db61595cffbbf4f19588ea177896f9b78f0fbe021fd/mistune-3.1.2-py3-none-any.whl", hash = "sha256:4b47731332315cdca99e0ded46fc0004001c1299ff773dfb48fbe1fd226de319", size = 53696 }, +] + +[[package]] +name = "more-itertools" +version = "10.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/3b/7fa1fe835e2e93fd6d7b52b2f95ae810cf5ba133e1845f726f5a992d62c2/more-itertools-10.6.0.tar.gz", hash = "sha256:2cd7fad1009c31cc9fb6a035108509e6547547a7a738374f10bd49a09eb3ee3b", size = 125009 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/62/0fe302c6d1be1c777cab0616e6302478251dfbf9055ad426f5d0def75c89/more_itertools-10.6.0-py3-none-any.whl", hash = "sha256:6eb054cb4b6db1473f6e15fcc676a08e4732548acd47c708f0e179c2c7c01e89", size = 63038 }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 }, +] + +[[package]] +name = "nbclient" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/66/7ffd18d58eae90d5721f9f39212327695b749e23ad44b3881744eaf4d9e8/nbclient-0.10.2.tar.gz", hash = "sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193", size = 62424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/6d/e7fa07f03a4a7b221d94b4d586edb754a9b0dc3c9e2c93353e9fa4e0d117/nbclient-0.10.2-py3-none-any.whl", hash = "sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d", size = 25434 }, +] + +[[package]] +name = "nbconvert" +version = "7.16.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/59/f28e15fc47ffb73af68a8d9b47367a8630d76e97ae85ad18271b9db96fdf/nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582", size = 857715 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9a/cd673b2f773a12c992f41309ef81b99da1690426bd2f96957a7ade0d3ed7/nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b", size = 258525 }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454 }, +] + +[[package]] +name = "nbsphinx" +version = "0.9.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "sphinx" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/38/6f6a0f9115b493af9d69b68335945827dd46e09de8ef525d1f58cd0870dc/nbsphinx-0.9.6.tar.gz", hash = "sha256:c2b28a2d702f1159a95b843831798e86e60a17fc647b9bff9ba1585355de54e3", size = 180213 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/8a/5dc4c8794053572a89f5c44437ef4e870f88903a6b6734500af1286f9018/nbsphinx-0.9.6-py3-none-any.whl", hash = "sha256:336b0b557945a7678ec7449b16449f854bc852a435bb53b8a72e6b5dc740d992", size = 31582 }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "numexpr" +version = "2.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/67/c7415cf04ebe418193cfd6595ae03e3a64d76dac7b9c010098b39cc7992e/numexpr-2.10.2.tar.gz", hash = "sha256:b0aff6b48ebc99d2f54f27b5f73a58cb92fde650aeff1b397c71c8788b4fff1a", size = 106787 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/b7/f25d6166f92ef23737c1c90416144492a664f0a56510d90f7c6577c2cd14/numexpr-2.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6b360eb8d392483410fe6a3d5a7144afa298c9a0aa3e9fe193e89590b47dd477", size = 145055 }, + { url = "https://files.pythonhosted.org/packages/66/64/428361ea6415826332f38ef2dd5c3abf4e7e601f033bfc9be68b680cb765/numexpr-2.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9a42f5c24880350d88933c4efee91b857c378aaea7e8b86221fff569069841e", size = 134743 }, + { url = "https://files.pythonhosted.org/packages/3f/fb/639ec91d2ea7b4a5d66e26e8ef8e06b020c8e9b9ebaf3bab7b0a9bee472e/numexpr-2.10.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83fcb11988b57cc25b028a36d285287d706d1f536ebf2662ea30bd990e0de8b9", size = 410397 }, + { url = "https://files.pythonhosted.org/packages/89/5a/0f5c5b8a3a6d34eeecb30d0e2f722d50b9b38c0e175937e7c6268ffab997/numexpr-2.10.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4213a92efa9770bc28e3792134e27c7e5c7e97068bdfb8ba395baebbd12f991b", size = 398902 }, + { url = "https://files.pythonhosted.org/packages/a2/d5/ec734e735eba5a753efed5be3707ee7447ebd371772f8081b65a4153fb97/numexpr-2.10.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebdbef5763ca057eea0c2b5698e4439d084a0505d9d6e94f4804f26e8890c45e", size = 1380354 }, + { url = "https://files.pythonhosted.org/packages/30/51/406e572531d817480bd612ee08239a36ee82865fea02fce569f15631f4ee/numexpr-2.10.2-cp311-cp311-win32.whl", hash = "sha256:3bf01ec502d89944e49e9c1b5cc7c7085be8ca2eb9dd46a0eafd218afbdbd5f5", size = 151938 }, + { url = "https://files.pythonhosted.org/packages/04/32/5882ed1dbd96234f327a73316a481add151ff827cfaf2ea24fb4d5ad04db/numexpr-2.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:e2d0ae24b0728e4bc3f1d3f33310340d67321d36d6043f7ce26897f4f1042db0", size = 144961 }, + { url = "https://files.pythonhosted.org/packages/2b/96/d5053dea06d8298ae8052b4b049cbf8ef74998e28d57166cc27b8ae909e2/numexpr-2.10.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5323a46e75832334f1af86da1ef6ff0add00fbacdd266250be872b438bdf2be", size = 145029 }, + { url = "https://files.pythonhosted.org/packages/3e/3c/fcd5a812ed5dda757b2d9ef2764a3e1cca6f6d1f02dbf113dc23a2c7702a/numexpr-2.10.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a42963bd4c62d8afa4f51e7974debfa39a048383f653544ab54f50a2f7ec6c42", size = 134851 }, + { url = "https://files.pythonhosted.org/packages/0a/52/0ed3b306d8c9944129bce97fec73a2caff13adbd7e1df148d546d7eb2d4d/numexpr-2.10.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5191ba8f2975cb9703afc04ae845a929e193498c0e8bcd408ecb147b35978470", size = 411837 }, + { url = "https://files.pythonhosted.org/packages/7d/9c/6b671dd3fb67d7e7da93cb76b7c5277743f310a216b7856bb18776bb3371/numexpr-2.10.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97298b14f0105a794bea06fd9fbc5c423bd3ff4d88cbc618860b83eb7a436ad6", size = 400577 }, + { url = "https://files.pythonhosted.org/packages/ea/4d/a167d1a215fe10ce58c45109f2869fd13aa0eef66f7e8c69af68be45d436/numexpr-2.10.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9d7805ccb6be2d3b0f7f6fad3707a09ac537811e8e9964f4074d28cb35543db", size = 1381735 }, + { url = "https://files.pythonhosted.org/packages/c1/d4/17e4434f989e4917d31cbd88a043e1c9c16958149cf43fa622987111392b/numexpr-2.10.2-cp312-cp312-win32.whl", hash = "sha256:cb845b2d4f9f8ef0eb1c9884f2b64780a85d3b5ae4eeb26ae2b0019f489cd35e", size = 152102 }, + { url = "https://files.pythonhosted.org/packages/b8/25/9ae599994076ef2a42d35ff6b0430da002647f212567851336a6c7b132d6/numexpr-2.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:57b59cbb5dcce4edf09cd6ce0b57ff60312479930099ca8d944c2fac896a1ead", size = 145061 }, + { url = "https://files.pythonhosted.org/packages/8c/cb/2ea1848c46e4d75073c038dd75628d1aa442975303264ed230bf90f74f44/numexpr-2.10.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a37d6a51ec328c561b2ca8a2bef07025642eca995b8553a5267d0018c732976d", size = 145035 }, + { url = "https://files.pythonhosted.org/packages/ec/cf/bb2bcd81d6f3243590e19ac3e7795a1a370f3ebcd8ecec1f46dcd5333f37/numexpr-2.10.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:81d1dde7dd6166d8ff5727bb46ab42a6b0048db0e97ceb84a121334a404a800f", size = 134858 }, + { url = "https://files.pythonhosted.org/packages/48/9b/c9128ffb453205c2a4c84a3abed35447c7591c2c2812e77e34fd238cb2bb/numexpr-2.10.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3f814437d5a10797f8d89d2037cca2c9d9fa578520fc911f894edafed6ea3e", size = 415517 }, + { url = "https://files.pythonhosted.org/packages/7e/b0/64c04c9f8b4a563218d00daa1ec4563364961b79025162c5276ab2c7c407/numexpr-2.10.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9309f2e43fe6e4560699ef5c27d7a848b3ff38549b6b57194207cf0e88900527", size = 403846 }, + { url = "https://files.pythonhosted.org/packages/80/35/60e9041fd709fe98dd3109d73a03cdffaeb6ee2089179155f5c3754e9934/numexpr-2.10.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ebb73b93f5c4d6994f357fa5a47a9f7a5485577e633b3c46a603cb01445bbb19", size = 1381659 }, + { url = "https://files.pythonhosted.org/packages/bd/5a/955bf5b5cf8f3de7b044a999e36327e14191fa073ed0e329456ed0f8161d/numexpr-2.10.2-cp313-cp313-win32.whl", hash = "sha256:ec04c9a3c050c175348801e27c18c68d28673b7bfb865ef88ce333be523bbc01", size = 152105 }, + { url = "https://files.pythonhosted.org/packages/be/7a/8ce360a1848bb5bcc30a414493371678f43790ece397f8652d5f65757e57/numexpr-2.10.2-cp313-cp313-win_amd64.whl", hash = "sha256:d7a3fc83c959288544db3adc70612475d8ad53a66c69198105c74036182d10dd", size = 145060 }, +] + +[[package]] +name = "numpy" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/90/8956572f5c4ae52201fdec7ba2044b2c882832dcec7d5d0922c9e9acf2de/numpy-2.2.3.tar.gz", hash = "sha256:dbdc15f0c81611925f382dfa97b3bd0bc2c1ce19d4fe50482cb0ddc12ba30020", size = 20262700 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/86/453aa3949eab6ff54e2405f9cb0c01f756f031c3dc2a6d60a1d40cba5488/numpy-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:16372619ee728ed67a2a606a614f56d3eabc5b86f8b615c79d01957062826ca8", size = 21237256 }, + { url = "https://files.pythonhosted.org/packages/20/c3/93ecceadf3e155d6a9e4464dd2392d8d80cf436084c714dc8535121c83e8/numpy-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5521a06a3148686d9269c53b09f7d399a5725c47bbb5b35747e1cb76326b714b", size = 14408049 }, + { url = "https://files.pythonhosted.org/packages/8d/29/076999b69bd9264b8df5e56f2be18da2de6b2a2d0e10737e5307592e01de/numpy-2.2.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:7c8dde0ca2f77828815fd1aedfdf52e59071a5bae30dac3b4da2a335c672149a", size = 5408655 }, + { url = "https://files.pythonhosted.org/packages/e2/a7/b14f0a73eb0fe77cb9bd5b44534c183b23d4229c099e339c522724b02678/numpy-2.2.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:77974aba6c1bc26e3c205c2214f0d5b4305bdc719268b93e768ddb17e3fdd636", size = 6949996 }, + { url = "https://files.pythonhosted.org/packages/72/2f/8063da0616bb0f414b66dccead503bd96e33e43685c820e78a61a214c098/numpy-2.2.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d42f9c36d06440e34226e8bd65ff065ca0963aeecada587b937011efa02cdc9d", size = 14355789 }, + { url = "https://files.pythonhosted.org/packages/e6/d7/3cd47b00b8ea95ab358c376cf5602ad21871410950bc754cf3284771f8b6/numpy-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2712c5179f40af9ddc8f6727f2bd910ea0eb50206daea75f58ddd9fa3f715bb", size = 16411356 }, + { url = "https://files.pythonhosted.org/packages/27/c0/a2379e202acbb70b85b41483a422c1e697ff7eee74db642ca478de4ba89f/numpy-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c8b0451d2ec95010d1db8ca733afc41f659f425b7f608af569711097fd6014e2", size = 15576770 }, + { url = "https://files.pythonhosted.org/packages/bc/63/a13ee650f27b7999e5b9e1964ae942af50bb25606d088df4229283eda779/numpy-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9b4a8148c57ecac25a16b0e11798cbe88edf5237b0df99973687dd866f05e1b", size = 18200483 }, + { url = "https://files.pythonhosted.org/packages/4c/87/e71f89935e09e8161ac9c590c82f66d2321eb163893a94af749dfa8a3cf8/numpy-2.2.3-cp311-cp311-win32.whl", hash = "sha256:1f45315b2dc58d8a3e7754fe4e38b6fce132dab284a92851e41b2b344f6441c5", size = 6588415 }, + { url = "https://files.pythonhosted.org/packages/b9/c6/cd4298729826af9979c5f9ab02fcaa344b82621e7c49322cd2d210483d3f/numpy-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f48ba6f6c13e5e49f3d3efb1b51c8193215c42ac82610a04624906a9270be6f", size = 12929604 }, + { url = "https://files.pythonhosted.org/packages/43/ec/43628dcf98466e087812142eec6d1c1a6c6bdfdad30a0aa07b872dc01f6f/numpy-2.2.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12c045f43b1d2915eca6b880a7f4a256f59d62df4f044788c8ba67709412128d", size = 20929458 }, + { url = "https://files.pythonhosted.org/packages/9b/c0/2f4225073e99a5c12350954949ed19b5d4a738f541d33e6f7439e33e98e4/numpy-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87eed225fd415bbae787f93a457af7f5990b92a334e346f72070bf569b9c9c95", size = 14115299 }, + { url = "https://files.pythonhosted.org/packages/ca/fa/d2c5575d9c734a7376cc1592fae50257ec95d061b27ee3dbdb0b3b551eb2/numpy-2.2.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:712a64103d97c404e87d4d7c47fb0c7ff9acccc625ca2002848e0d53288b90ea", size = 5145723 }, + { url = "https://files.pythonhosted.org/packages/eb/dc/023dad5b268a7895e58e791f28dc1c60eb7b6c06fcbc2af8538ad069d5f3/numpy-2.2.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a5ae282abe60a2db0fd407072aff4599c279bcd6e9a2475500fc35b00a57c532", size = 6678797 }, + { url = "https://files.pythonhosted.org/packages/3f/19/bcd641ccf19ac25abb6fb1dcd7744840c11f9d62519d7057b6ab2096eb60/numpy-2.2.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5266de33d4c3420973cf9ae3b98b54a2a6d53a559310e3236c4b2b06b9c07d4e", size = 14067362 }, + { url = "https://files.pythonhosted.org/packages/39/04/78d2e7402fb479d893953fb78fa7045f7deb635ec095b6b4f0260223091a/numpy-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b787adbf04b0db1967798dba8da1af07e387908ed1553a0d6e74c084d1ceafe", size = 16116679 }, + { url = "https://files.pythonhosted.org/packages/d0/a1/e90f7aa66512be3150cb9d27f3d9995db330ad1b2046474a13b7040dfd92/numpy-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:34c1b7e83f94f3b564b35f480f5652a47007dd91f7c839f404d03279cc8dd021", size = 15264272 }, + { url = "https://files.pythonhosted.org/packages/dc/b6/50bd027cca494de4fa1fc7bf1662983d0ba5f256fa0ece2c376b5eb9b3f0/numpy-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4d8335b5f1b6e2bce120d55fb17064b0262ff29b459e8493d1785c18ae2553b8", size = 17880549 }, + { url = "https://files.pythonhosted.org/packages/96/30/f7bf4acb5f8db10a96f73896bdeed7a63373137b131ca18bd3dab889db3b/numpy-2.2.3-cp312-cp312-win32.whl", hash = "sha256:4d9828d25fb246bedd31e04c9e75714a4087211ac348cb39c8c5f99dbb6683fe", size = 6293394 }, + { url = "https://files.pythonhosted.org/packages/42/6e/55580a538116d16ae7c9aa17d4edd56e83f42126cb1dfe7a684da7925d2c/numpy-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:83807d445817326b4bcdaaaf8e8e9f1753da04341eceec705c001ff342002e5d", size = 12626357 }, + { url = "https://files.pythonhosted.org/packages/0e/8b/88b98ed534d6a03ba8cddb316950fe80842885709b58501233c29dfa24a9/numpy-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bfdb06b395385ea9b91bf55c1adf1b297c9fdb531552845ff1d3ea6e40d5aba", size = 20916001 }, + { url = "https://files.pythonhosted.org/packages/d9/b4/def6ec32c725cc5fbd8bdf8af80f616acf075fe752d8a23e895da8c67b70/numpy-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23c9f4edbf4c065fddb10a4f6e8b6a244342d95966a48820c614891e5059bb50", size = 14130721 }, + { url = "https://files.pythonhosted.org/packages/20/60/70af0acc86495b25b672d403e12cb25448d79a2b9658f4fc45e845c397a8/numpy-2.2.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:a0c03b6be48aaf92525cccf393265e02773be8fd9551a2f9adbe7db1fa2b60f1", size = 5130999 }, + { url = "https://files.pythonhosted.org/packages/2e/69/d96c006fb73c9a47bcb3611417cf178049aae159afae47c48bd66df9c536/numpy-2.2.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:2376e317111daa0a6739e50f7ee2a6353f768489102308b0d98fcf4a04f7f3b5", size = 6665299 }, + { url = "https://files.pythonhosted.org/packages/5a/3f/d8a877b6e48103733ac224ffa26b30887dc9944ff95dffdfa6c4ce3d7df3/numpy-2.2.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fb62fe3d206d72fe1cfe31c4a1106ad2b136fcc1606093aeab314f02930fdf2", size = 14064096 }, + { url = "https://files.pythonhosted.org/packages/e4/43/619c2c7a0665aafc80efca465ddb1f260287266bdbdce517396f2f145d49/numpy-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52659ad2534427dffcc36aac76bebdd02b67e3b7a619ac67543bc9bfe6b7cdb1", size = 16114758 }, + { url = "https://files.pythonhosted.org/packages/d9/79/ee4fe4f60967ccd3897aa71ae14cdee9e3c097e3256975cc9575d393cb42/numpy-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b416af7d0ed3271cad0f0a0d0bee0911ed7eba23e66f8424d9f3dfcdcae1304", size = 15259880 }, + { url = "https://files.pythonhosted.org/packages/fb/c8/8b55cf05db6d85b7a7d414b3d1bd5a740706df00bfa0824a08bf041e52ee/numpy-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1402da8e0f435991983d0a9708b779f95a8c98c6b18a171b9f1be09005e64d9d", size = 17876721 }, + { url = "https://files.pythonhosted.org/packages/21/d6/b4c2f0564b7dcc413117b0ffbb818d837e4b29996b9234e38b2025ed24e7/numpy-2.2.3-cp313-cp313-win32.whl", hash = "sha256:136553f123ee2951bfcfbc264acd34a2fc2f29d7cdf610ce7daf672b6fbaa693", size = 6290195 }, + { url = "https://files.pythonhosted.org/packages/97/e7/7d55a86719d0de7a6a597949f3febefb1009435b79ba510ff32f05a8c1d7/numpy-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5b732c8beef1d7bc2d9e476dbba20aaff6167bf205ad9aa8d30913859e82884b", size = 12619013 }, + { url = "https://files.pythonhosted.org/packages/a6/1f/0b863d5528b9048fd486a56e0b97c18bf705e88736c8cea7239012119a54/numpy-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:435e7a933b9fda8126130b046975a968cc2d833b505475e588339e09f7672890", size = 20944621 }, + { url = "https://files.pythonhosted.org/packages/aa/99/b478c384f7a0a2e0736177aafc97dc9152fc036a3fdb13f5a3ab225f1494/numpy-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7678556eeb0152cbd1522b684dcd215250885993dd00adb93679ec3c0e6e091c", size = 14142502 }, + { url = "https://files.pythonhosted.org/packages/fb/61/2d9a694a0f9cd0a839501d362de2a18de75e3004576a3008e56bdd60fcdb/numpy-2.2.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2e8da03bd561504d9b20e7a12340870dfc206c64ea59b4cfee9fceb95070ee94", size = 5176293 }, + { url = "https://files.pythonhosted.org/packages/33/35/51e94011b23e753fa33f891f601e5c1c9a3d515448659b06df9d40c0aa6e/numpy-2.2.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:c9aa4496fd0e17e3843399f533d62857cef5900facf93e735ef65aa4bbc90ef0", size = 6691874 }, + { url = "https://files.pythonhosted.org/packages/ff/cf/06e37619aad98a9d03bd8d65b8e3041c3a639be0f5f6b0a0e2da544538d4/numpy-2.2.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4ca91d61a4bf61b0f2228f24bbfa6a9facd5f8af03759fe2a655c50ae2c6610", size = 14036826 }, + { url = "https://files.pythonhosted.org/packages/0c/93/5d7d19955abd4d6099ef4a8ee006f9ce258166c38af259f9e5558a172e3e/numpy-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:deaa09cd492e24fd9b15296844c0ad1b3c976da7907e1c1ed3a0ad21dded6f76", size = 16096567 }, + { url = "https://files.pythonhosted.org/packages/af/53/d1c599acf7732d81f46a93621dab6aa8daad914b502a7a115b3f17288ab2/numpy-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:246535e2f7496b7ac85deffe932896a3577be7af8fb7eebe7146444680297e9a", size = 15242514 }, + { url = "https://files.pythonhosted.org/packages/53/43/c0f5411c7b3ea90adf341d05ace762dad8cb9819ef26093e27b15dd121ac/numpy-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:daf43a3d1ea699402c5a850e5313680ac355b4adc9770cd5cfc2940e7861f1bf", size = 17872920 }, + { url = "https://files.pythonhosted.org/packages/5b/57/6dbdd45ab277aff62021cafa1e15f9644a52f5b5fc840bc7591b4079fb58/numpy-2.2.3-cp313-cp313t-win32.whl", hash = "sha256:cf802eef1f0134afb81fef94020351be4fe1d6681aadf9c5e862af6602af64ef", size = 6346584 }, + { url = "https://files.pythonhosted.org/packages/97/9b/484f7d04b537d0a1202a5ba81c6f53f1846ae6c63c2127f8df869ed31342/numpy-2.2.3-cp313-cp313t-win_amd64.whl", hash = "sha256:aee2512827ceb6d7f517c8b85aa5d3923afe8fc7a57d028cffcd522f1c6fd082", size = 12706784 }, +] + +[[package]] +name = "numpydoc" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/59/5d1d1afb0b9598e21e7cda477935188e39ef845bcf59cb65ac20845bfd45/numpydoc-1.8.0.tar.gz", hash = "sha256:022390ab7464a44f8737f79f8b31ce1d3cfa4b4af79ccaa1aac5e8368db587fb", size = 90445 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/45/56d99ba9366476cd8548527667f01869279cedb9e66b28eb4dfb27701679/numpydoc-1.8.0-py3-none-any.whl", hash = "sha256:72024c7fd5e17375dec3608a27c03303e8ad00c81292667955c6fea7a3ccf541", size = 64003 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pandas" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/44/d9502bf0ed197ba9bf1103c9867d5904ddcaf869e52329787fc54ed70cc8/pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039", size = 12602222 }, + { url = "https://files.pythonhosted.org/packages/52/11/9eac327a38834f162b8250aab32a6781339c69afe7574368fffe46387edf/pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd", size = 11321274 }, + { url = "https://files.pythonhosted.org/packages/45/fb/c4beeb084718598ba19aa9f5abbc8aed8b42f90930da861fcb1acdb54c3a/pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698", size = 15579836 }, + { url = "https://files.pythonhosted.org/packages/cd/5f/4dba1d39bb9c38d574a9a22548c540177f78ea47b32f99c0ff2ec499fac5/pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc", size = 13058505 }, + { url = "https://files.pythonhosted.org/packages/b9/57/708135b90391995361636634df1f1130d03ba456e95bcf576fada459115a/pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3", size = 16744420 }, + { url = "https://files.pythonhosted.org/packages/86/4a/03ed6b7ee323cf30404265c284cee9c65c56a212e0a08d9ee06984ba2240/pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32", size = 14440457 }, + { url = "https://files.pythonhosted.org/packages/ed/8c/87ddf1fcb55d11f9f847e3c69bb1c6f8e46e2f40ab1a2d2abadb2401b007/pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5", size = 11617166 }, + { url = "https://files.pythonhosted.org/packages/17/a3/fb2734118db0af37ea7433f57f722c0a56687e14b14690edff0cdb4b7e58/pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9", size = 12529893 }, + { url = "https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4", size = 11363475 }, + { url = "https://files.pythonhosted.org/packages/c6/2a/4bba3f03f7d07207481fed47f5b35f556c7441acddc368ec43d6643c5777/pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3", size = 15188645 }, + { url = "https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319", size = 12739445 }, + { url = "https://files.pythonhosted.org/packages/20/e8/45a05d9c39d2cea61ab175dbe6a2de1d05b679e8de2011da4ee190d7e748/pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", size = 16359235 }, + { url = "https://files.pythonhosted.org/packages/1d/99/617d07a6a5e429ff90c90da64d428516605a1ec7d7bea494235e1c3882de/pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", size = 14056756 }, + { url = "https://files.pythonhosted.org/packages/29/d4/1244ab8edf173a10fd601f7e13b9566c1b525c4f365d6bee918e68381889/pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", size = 11504248 }, + { url = "https://files.pythonhosted.org/packages/64/22/3b8f4e0ed70644e85cfdcd57454686b9057c6c38d2f74fe4b8bc2527214a/pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", size = 12477643 }, + { url = "https://files.pythonhosted.org/packages/e4/93/b3f5d1838500e22c8d793625da672f3eec046b1a99257666c94446969282/pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", size = 11281573 }, + { url = "https://files.pythonhosted.org/packages/f5/94/6c79b07f0e5aab1dcfa35a75f4817f5c4f677931d4234afcd75f0e6a66ca/pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", size = 15196085 }, + { url = "https://files.pythonhosted.org/packages/e8/31/aa8da88ca0eadbabd0a639788a6da13bb2ff6edbbb9f29aa786450a30a91/pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", size = 12711809 }, + { url = "https://files.pythonhosted.org/packages/ee/7c/c6dbdb0cb2a4344cacfb8de1c5808ca885b2e4dcfde8008266608f9372af/pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", size = 16356316 }, + { url = "https://files.pythonhosted.org/packages/57/b7/8b757e7d92023b832869fa8881a992696a0bfe2e26f72c9ae9f255988d42/pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", size = 14022055 }, + { url = "https://files.pythonhosted.org/packages/3b/bc/4b18e2b8c002572c5a441a64826252ce5da2aa738855747247a971988043/pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", size = 11481175 }, + { url = "https://files.pythonhosted.org/packages/76/a3/a5d88146815e972d40d19247b2c162e88213ef51c7c25993942c39dbf41d/pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", size = 12615650 }, + { url = "https://files.pythonhosted.org/packages/9c/8c/f0fd18f6140ddafc0c24122c8a964e48294acc579d47def376fef12bcb4a/pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", size = 11290177 }, + { url = "https://files.pythonhosted.org/packages/ed/f9/e995754eab9c0f14c6777401f7eece0943840b7a9fc932221c19d1abee9f/pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", size = 14651526 }, + { url = "https://files.pythonhosted.org/packages/25/b0/98d6ae2e1abac4f35230aa756005e8654649d305df9a28b16b9ae4353bff/pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", size = 11871013 }, + { url = "https://files.pythonhosted.org/packages/cc/57/0f72a10f9db6a4628744c8e8f0df4e6e21de01212c7c981d31e50ffc8328/pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", size = 15711620 }, + { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436 }, +] + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663 }, +] + +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + +[[package]] +name = "pillow" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/af/c097e544e7bd278333db77933e535098c259609c4eb3b85381109602fb5b/pillow-11.1.0.tar.gz", hash = "sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20", size = 46742715 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/d6/2000bfd8d5414fb70cbbe52c8332f2283ff30ed66a9cde42716c8ecbe22c/pillow-11.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e06695e0326d05b06833b40b7ef477e475d0b1ba3a6d27da1bb48c23209bf457", size = 3229968 }, + { url = "https://files.pythonhosted.org/packages/d9/45/3fe487010dd9ce0a06adf9b8ff4f273cc0a44536e234b0fad3532a42c15b/pillow-11.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96f82000e12f23e4f29346e42702b6ed9a2f2fea34a740dd5ffffcc8c539eb35", size = 3101806 }, + { url = "https://files.pythonhosted.org/packages/e3/72/776b3629c47d9d5f1c160113158a7a7ad177688d3a1159cd3b62ded5a33a/pillow-11.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3cd561ded2cf2bbae44d4605837221b987c216cff94f49dfeed63488bb228d2", size = 4322283 }, + { url = "https://files.pythonhosted.org/packages/e4/c2/e25199e7e4e71d64eeb869f5b72c7ddec70e0a87926398785ab944d92375/pillow-11.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f189805c8be5ca5add39e6f899e6ce2ed824e65fb45f3c28cb2841911da19070", size = 4402945 }, + { url = "https://files.pythonhosted.org/packages/c1/ed/51d6136c9d5911f78632b1b86c45241c712c5a80ed7fa7f9120a5dff1eba/pillow-11.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:dd0052e9db3474df30433f83a71b9b23bd9e4ef1de13d92df21a52c0303b8ab6", size = 4361228 }, + { url = "https://files.pythonhosted.org/packages/48/a4/fbfe9d5581d7b111b28f1d8c2762dee92e9821bb209af9fa83c940e507a0/pillow-11.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:837060a8599b8f5d402e97197d4924f05a2e0d68756998345c829c33186217b1", size = 4484021 }, + { url = "https://files.pythonhosted.org/packages/39/db/0b3c1a5018117f3c1d4df671fb8e47d08937f27519e8614bbe86153b65a5/pillow-11.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aa8dd43daa836b9a8128dbe7d923423e5ad86f50a7a14dc688194b7be5c0dea2", size = 4287449 }, + { url = "https://files.pythonhosted.org/packages/d9/58/bc128da7fea8c89fc85e09f773c4901e95b5936000e6f303222490c052f3/pillow-11.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0a2f91f8a8b367e7a57c6e91cd25af510168091fb89ec5146003e424e1558a96", size = 4419972 }, + { url = "https://files.pythonhosted.org/packages/5f/bb/58f34379bde9fe197f51841c5bbe8830c28bbb6d3801f16a83b8f2ad37df/pillow-11.1.0-cp311-cp311-win32.whl", hash = "sha256:c12fc111ef090845de2bb15009372175d76ac99969bdf31e2ce9b42e4b8cd88f", size = 2291201 }, + { url = "https://files.pythonhosted.org/packages/3a/c6/fce9255272bcf0c39e15abd2f8fd8429a954cf344469eaceb9d0d1366913/pillow-11.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fbd43429d0d7ed6533b25fc993861b8fd512c42d04514a0dd6337fb3ccf22761", size = 2625686 }, + { url = "https://files.pythonhosted.org/packages/c8/52/8ba066d569d932365509054859f74f2a9abee273edcef5cd75e4bc3e831e/pillow-11.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:f7955ecf5609dee9442cbface754f2c6e541d9e6eda87fad7f7a989b0bdb9d71", size = 2375194 }, + { url = "https://files.pythonhosted.org/packages/95/20/9ce6ed62c91c073fcaa23d216e68289e19d95fb8188b9fb7a63d36771db8/pillow-11.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2062ffb1d36544d42fcaa277b069c88b01bb7298f4efa06731a7fd6cc290b81a", size = 3226818 }, + { url = "https://files.pythonhosted.org/packages/b9/d8/f6004d98579a2596c098d1e30d10b248798cceff82d2b77aa914875bfea1/pillow-11.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a85b653980faad27e88b141348707ceeef8a1186f75ecc600c395dcac19f385b", size = 3101662 }, + { url = "https://files.pythonhosted.org/packages/08/d9/892e705f90051c7a2574d9f24579c9e100c828700d78a63239676f960b74/pillow-11.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9409c080586d1f683df3f184f20e36fb647f2e0bc3988094d4fd8c9f4eb1b3b3", size = 4329317 }, + { url = "https://files.pythonhosted.org/packages/8c/aa/7f29711f26680eab0bcd3ecdd6d23ed6bce180d82e3f6380fb7ae35fcf3b/pillow-11.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fdadc077553621911f27ce206ffcbec7d3f8d7b50e0da39f10997e8e2bb7f6a", size = 4412999 }, + { url = "https://files.pythonhosted.org/packages/c8/c4/8f0fe3b9e0f7196f6d0bbb151f9fba323d72a41da068610c4c960b16632a/pillow-11.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:93a18841d09bcdd774dcdc308e4537e1f867b3dec059c131fde0327899734aa1", size = 4368819 }, + { url = "https://files.pythonhosted.org/packages/38/0d/84200ed6a871ce386ddc82904bfadc0c6b28b0c0ec78176871a4679e40b3/pillow-11.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9aa9aeddeed452b2f616ff5507459e7bab436916ccb10961c4a382cd3e03f47f", size = 4496081 }, + { url = "https://files.pythonhosted.org/packages/84/9c/9bcd66f714d7e25b64118e3952d52841a4babc6d97b6d28e2261c52045d4/pillow-11.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3cdcdb0b896e981678eee140d882b70092dac83ac1cdf6b3a60e2216a73f2b91", size = 4296513 }, + { url = "https://files.pythonhosted.org/packages/db/61/ada2a226e22da011b45f7104c95ebda1b63dcbb0c378ad0f7c2a710f8fd2/pillow-11.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36ba10b9cb413e7c7dfa3e189aba252deee0602c86c309799da5a74009ac7a1c", size = 4431298 }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fc6e86750523f367923522014b821c11ebc5ad402e659d8c9d09b3c9d70c/pillow-11.1.0-cp312-cp312-win32.whl", hash = "sha256:cfd5cd998c2e36a862d0e27b2df63237e67273f2fc78f47445b14e73a810e7e6", size = 2291630 }, + { url = "https://files.pythonhosted.org/packages/08/5c/2104299949b9d504baf3f4d35f73dbd14ef31bbd1ddc2c1b66a5b7dfda44/pillow-11.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a697cd8ba0383bba3d2d3ada02b34ed268cb548b369943cd349007730c92bddf", size = 2626369 }, + { url = "https://files.pythonhosted.org/packages/37/f3/9b18362206b244167c958984b57c7f70a0289bfb59a530dd8af5f699b910/pillow-11.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:4dd43a78897793f60766563969442020e90eb7847463eca901e41ba186a7d4a5", size = 2375240 }, + { url = "https://files.pythonhosted.org/packages/b3/31/9ca79cafdce364fd5c980cd3416c20ce1bebd235b470d262f9d24d810184/pillow-11.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc", size = 3226640 }, + { url = "https://files.pythonhosted.org/packages/ac/0f/ff07ad45a1f172a497aa393b13a9d81a32e1477ef0e869d030e3c1532521/pillow-11.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0", size = 3101437 }, + { url = "https://files.pythonhosted.org/packages/08/2f/9906fca87a68d29ec4530be1f893149e0cb64a86d1f9f70a7cfcdfe8ae44/pillow-11.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1", size = 4326605 }, + { url = "https://files.pythonhosted.org/packages/b0/0f/f3547ee15b145bc5c8b336401b2d4c9d9da67da9dcb572d7c0d4103d2c69/pillow-11.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec", size = 4411173 }, + { url = "https://files.pythonhosted.org/packages/b1/df/bf8176aa5db515c5de584c5e00df9bab0713548fd780c82a86cba2c2fedb/pillow-11.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5", size = 4369145 }, + { url = "https://files.pythonhosted.org/packages/de/7c/7433122d1cfadc740f577cb55526fdc39129a648ac65ce64db2eb7209277/pillow-11.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114", size = 4496340 }, + { url = "https://files.pythonhosted.org/packages/25/46/dd94b93ca6bd555588835f2504bd90c00d5438fe131cf01cfa0c5131a19d/pillow-11.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352", size = 4296906 }, + { url = "https://files.pythonhosted.org/packages/a8/28/2f9d32014dfc7753e586db9add35b8a41b7a3b46540e965cb6d6bc607bd2/pillow-11.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3", size = 4431759 }, + { url = "https://files.pythonhosted.org/packages/33/48/19c2cbe7403870fbe8b7737d19eb013f46299cdfe4501573367f6396c775/pillow-11.1.0-cp313-cp313-win32.whl", hash = "sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9", size = 2291657 }, + { url = "https://files.pythonhosted.org/packages/3b/ad/285c556747d34c399f332ba7c1a595ba245796ef3e22eae190f5364bb62b/pillow-11.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c", size = 2626304 }, + { url = "https://files.pythonhosted.org/packages/e5/7b/ef35a71163bf36db06e9c8729608f78dedf032fc8313d19bd4be5c2588f3/pillow-11.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65", size = 2375117 }, + { url = "https://files.pythonhosted.org/packages/79/30/77f54228401e84d6791354888549b45824ab0ffde659bafa67956303a09f/pillow-11.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861", size = 3230060 }, + { url = "https://files.pythonhosted.org/packages/ce/b1/56723b74b07dd64c1010fee011951ea9c35a43d8020acd03111f14298225/pillow-11.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081", size = 3106192 }, + { url = "https://files.pythonhosted.org/packages/e1/cd/7bf7180e08f80a4dcc6b4c3a0aa9e0b0ae57168562726a05dc8aa8fa66b0/pillow-11.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c", size = 4446805 }, + { url = "https://files.pythonhosted.org/packages/97/42/87c856ea30c8ed97e8efbe672b58c8304dee0573f8c7cab62ae9e31db6ae/pillow-11.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547", size = 4530623 }, + { url = "https://files.pythonhosted.org/packages/ff/41/026879e90c84a88e33fb00cc6bd915ac2743c67e87a18f80270dfe3c2041/pillow-11.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab", size = 4465191 }, + { url = "https://files.pythonhosted.org/packages/e5/fb/a7960e838bc5df57a2ce23183bfd2290d97c33028b96bde332a9057834d3/pillow-11.1.0-cp313-cp313t-win32.whl", hash = "sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9", size = 2295494 }, + { url = "https://files.pythonhosted.org/packages/d7/6c/6ec83ee2f6f0fda8d4cf89045c6be4b0373ebfc363ba8538f8c999f63fcd/pillow-11.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe", size = 2631595 }, + { url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/e1/bd15cb8ffdcfeeb2bdc215de3c3cffca11408d829e4b8416dcfe71ba8854/prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", size = 429087 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/ea/d836f008d33151c7a1f62caf3d8dd782e4d15f6a43897f64480c2b8de2ad/prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198", size = 387816 }, +] + +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051 }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535 }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004 }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986 }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544 }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053 }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885 }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, +] + +[[package]] +name = "pybtex" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "latexcodec" }, + { name = "pyyaml" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/9b/fd39836a6397fb363446d83075a7b9c2cc562f4c449292e039ed36084376/pybtex-0.24.0.tar.gz", hash = "sha256:818eae35b61733e5c007c3fcd2cfb75ed1bc8b4173c1f70b56cc4c0802d34755", size = 402879 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/5f/40d8e90f985a05133a8895fc454c6127ecec3de8b095dd35bba91382f803/pybtex-0.24.0-py2.py3-none-any.whl", hash = "sha256:e1e0c8c69998452fea90e9179aa2a98ab103f3eed894405b7264e517cc2fcc0f", size = 561354 }, +] + +[[package]] +name = "pybtex-docutils" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pybtex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/84/796ea94d26188a853660f81bded39f8de4cfe595130aef0dea1088705a11/pybtex-docutils-1.0.3.tar.gz", hash = "sha256:3a7ebdf92b593e00e8c1c538aa9a20bca5d92d84231124715acc964d51d93c6b", size = 18348 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/b1/ce1f4596211efb5410e178a803f08e59b20bedb66837dcf41e21c54f9ec1/pybtex_docutils-1.0.3-py3-none-any.whl", hash = "sha256:8fd290d2ae48e32fcb54d86b0efb8d573198653c7e2447d5bec5847095f430b9", size = 6385 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pydata-sphinx-theme" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "babel" }, + { name = "beautifulsoup4" }, + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/20/bb50f9de3a6de69e6abd6b087b52fa2418a0418b19597601605f855ad044/pydata_sphinx_theme-0.16.1.tar.gz", hash = "sha256:a08b7f0b7f70387219dc659bff0893a7554d5eb39b59d3b8ef37b8401b7642d7", size = 2412693 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/0d/8ba33fa83a7dcde13eb3c1c2a0c1cc29950a048bfed6d9b0d8b6bd710b4c/pydata_sphinx_theme-0.16.1-py3-none-any.whl", hash = "sha256:225331e8ac4b32682c18fcac5a57a6f717c4e632cea5dd0e247b55155faeccde", size = 6723264 }, +] + +[[package]] +name = "pyerfa" +version = "2.0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/39/63cc8291b0cf324ae710df41527faf7d331bce573899199d926b3e492260/pyerfa-2.0.1.5.tar.gz", hash = "sha256:17d6b24fe4846c65d5e7d8c362dcb08199dc63b30a236aedd73875cc83e1f6c0", size = 818430 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/d9/3448a57cb5bd19950de6d6ab08bd8fbb3df60baa71726de91d73d76c481b/pyerfa-2.0.1.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b282d7c60c4c47cf629c484c17ac504fcb04abd7b3f4dfcf53ee042afc3a5944", size = 341818 }, + { url = "https://files.pythonhosted.org/packages/11/4a/31a363370478b63c6289a34743f2ba2d3ae1bd8223e004d18ab28fb92385/pyerfa-2.0.1.5-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:be1aeb70390dd03a34faf96749d5cabc58437410b4aab7213c512323932427df", size = 329370 }, + { url = "https://files.pythonhosted.org/packages/cb/96/b6210fc624123c8ae13e1eecb68fb75e3f3adff216d95eee1c7b05843e3e/pyerfa-2.0.1.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0603e8e1b839327d586c8a627cdc634b795e18b007d84f0cda5500a0908254e", size = 692794 }, + { url = "https://files.pythonhosted.org/packages/e5/e0/050018d855d26d3c0b4a7d1b2ed692be758ce276d8289e2a2b44ba1014a5/pyerfa-2.0.1.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e43c7194e3242083f2350b46c09fd4bf8ba1bcc0ebd1460b98fc47fe2389906", size = 738711 }, + { url = "https://files.pythonhosted.org/packages/b9/f5/ff91ee77308793ae32fa1e1de95e9edd4551456dd888b4e87c5938657ca5/pyerfa-2.0.1.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:07b80cd70701f5d066b1ac8cce406682cfcd667a1186ec7d7ade597239a6021d", size = 722966 }, + { url = "https://files.pythonhosted.org/packages/2c/56/b22b35c8551d2228ff8d445e63787112927ca13f6dc9e2c04f69d742c95b/pyerfa-2.0.1.5-cp39-abi3-win32.whl", hash = "sha256:d30b9b0df588ed5467e529d851ea324a67239096dd44703125072fd11b351ea2", size = 339955 }, + { url = "https://files.pythonhosted.org/packages/b4/11/97233cf23ad5411ac6f13b1d6ee3888f90ace4f974d9bf9db887aa428912/pyerfa-2.0.1.5-cp39-abi3-win_amd64.whl", hash = "sha256:66292d437dcf75925b694977aa06eb697126e7b86553e620371ed3e48b5e0ad0", size = 349410 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pyia" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astropy" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/f0/9c355e9a8d4195b353c06d28ad6cf0a4c538707ab1591324507e2db0ea49/pyia-1.4.1.tar.gz", hash = "sha256:7444ecdf1e8cb989be2c0accd016a6759e2a1abfbcb47d54a8ef4223c7d56026", size = 3143534 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/22/40f9d445ce2c94fd7ff1f409cf3f0fe1cc9e9bcafe2a635d5fc190c7b2d3/pyia-1.4.1-py3-none-any.whl", hash = "sha256:7b87eadfa7cca1cccb5f8040b36705e0a063f1d1c37f79b1ca04658aa87d4762", size = 1995364 }, +] + +[[package]] +name = "pyparsing" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/1a/3544f4f299a47911c2ab3710f534e52fea62a633c96806995da5d25be4b2/pyparsing-3.2.1.tar.gz", hash = "sha256:61980854fd66de3a90028d679a954d5f2623e83144b5afe5ee86f43d762e5f0a", size = 1067694 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/a7/c8a2d361bf89c0d9577c934ebb7421b25dc84bf3a8e3ac0a40aed9acc547/pyparsing-3.2.1-py3-none-any.whl", hash = "sha256:506ff4f4386c4cec0590ec19e6302d3aedb992fdc02c761e90416f158dacf8e1", size = 107716 }, +] + +[[package]] +name = "pytest" +version = "8.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, +] + +[[package]] +name = "pytest-arraydiff" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/8a/c43e892759d10d134763b6cccf853e66a18cd956616bebd4b7d782471534/pytest-arraydiff-0.6.1.tar.gz", hash = "sha256:2937b1450fc935620f24709d87d40c67e055a043d7b8541a25fdfa994dda67de", size = 16907 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/2a/2f8efb1ef8048aec7b44f37bc3fecb8c2c22e3238781d3e93c58d9c89268/pytest_arraydiff-0.6.1-py3-none-any.whl", hash = "sha256:64be1cc8e79874203eca80b1959134b8bb7a47b41cf7631310ba7fe6e5840694", size = 10042 }, +] + +[[package]] +name = "pytest-astropy" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "hypothesis" }, + { name = "pytest" }, + { name = "pytest-arraydiff" }, + { name = "pytest-astropy-header" }, + { name = "pytest-cov" }, + { name = "pytest-doctestplus" }, + { name = "pytest-filter-subpackage" }, + { name = "pytest-mock" }, + { name = "pytest-remotedata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/a8/dbe3cead1ebc25b0164af7cfde4b3d1693f6ab05de156667c836732d1f4c/pytest-astropy-0.11.0.tar.gz", hash = "sha256:4eaeaa99ed91163ed8f9aac132c70a81f25bc4c12f3cd54dba329fc26c6739b5", size = 6336 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/05/d6e2068e99c4ca69986844d68c82c4c08379296430e355a3756c51a55f73/pytest_astropy-0.11.0-py3-none-any.whl", hash = "sha256:5b9404cfa85bd815af86da78bd7d50d451419021bff27b127418886f85ae9ffa", size = 5171 }, +] + +[[package]] +name = "pytest-astropy-header" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/ed/1caf921a7612024544fe2217e2590a2ef3e80396c3ac098d47cbc924df0b/pytest-astropy-header-0.2.2.tar.gz", hash = "sha256:77891101c94b75a8ca305453b879b318ab6001b370df02be2c0b6d1bb322db10", size = 9914 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/db/3bde86b77504c01f8cc174ccce13029aa5e26daaae9fcdc08826f1f0c7a5/pytest_astropy_header-0.2.2-py3-none-any.whl", hash = "sha256:6088db080166d59f27c045247ad038ac8656f7c35d5c979cb87ed9a8f7efdee0", size = 7815 }, +] + +[[package]] +name = "pytest-codspeed" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "pytest" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/e8/27fcbe6516a1c956614a4b61a7fccbf3791ea0b992e07416e8948184327d/pytest_codspeed-4.2.0.tar.gz", hash = "sha256:04b5d0bc5a1851ba1504d46bf9d7dbb355222a69f2cd440d54295db721b331f7", size = 113263 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/2d/f0083a2f14ecf008d961d40439a71da0ae0d568e5f8dc2fccd3e8a2ab3e4/pytest_codspeed-4.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2de87bde9fbc6fd53f0fd21dcf2599c89e0b8948d49f9bad224edce51c47e26b", size = 261960 }, + { url = "https://files.pythonhosted.org/packages/5f/0c/1f514c553db4ea5a69dfbe2706734129acd0eca8d5101ec16f1dd00dbc0f/pytest_codspeed-4.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95aeb2479ca383f6b18e2cc9ebcd3b03ab184980a59a232aea6f370bbf59a1e3", size = 250808 }, + { url = "https://files.pythonhosted.org/packages/81/04/479905bd6653bc981c0554fcce6df52d7ae1594e1eefd53e6cf31810ec7f/pytest_codspeed-4.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d4fefbd4ae401e2c60f6be920a0be50eef0c3e4a1f0a1c83962efd45be38b39", size = 262084 }, + { url = "https://files.pythonhosted.org/packages/d2/46/d6f345d7907bac6cbb6224bd697ecbc11cf7427acc9e843c3618f19e3476/pytest_codspeed-4.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309b4227f57fcbb9df21e889ea1ae191d0d1cd8b903b698fdb9ea0461dbf1dfe", size = 251100 }, + { url = "https://files.pythonhosted.org/packages/de/dc/e864f45e994a50390ff49792256f1bdcbf42f170e3bc0470ee1a7d2403f3/pytest_codspeed-4.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72aab8278452a6d020798b9e4f82780966adb00f80d27a25d1274272c54630d5", size = 262057 }, + { url = "https://files.pythonhosted.org/packages/1d/1c/f1d2599784486879cf6579d8d94a3e22108f0e1f130033dab8feefd29249/pytest_codspeed-4.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:684fcd9491d810ded653a8d38de4835daa2d001645f4a23942862950664273f8", size = 251013 }, + { url = "https://files.pythonhosted.org/packages/0c/fd/eafd24db5652a94b4d00fe9b309b607de81add0f55f073afb68a378a24b6/pytest_codspeed-4.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50794dabea6ec90d4288904452051e2febace93e7edf4ca9f2bce8019dd8cd37", size = 262065 }, + { url = "https://files.pythonhosted.org/packages/f9/14/8d9340d7dc0ae647991b28a396e16b3403e10def883cde90d6b663d3f7ec/pytest_codspeed-4.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0ebd87f2a99467a1cfd8e83492c4712976e43d353ee0b5f71cbb057f1393aca", size = 251057 }, + { url = "https://files.pythonhosted.org/packages/4b/39/48cf6afbca55bc7c8c93c3d4ae926a1068bcce3f0241709db19b078d5418/pytest_codspeed-4.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbbb2d61b85bef8fc7e2193f723f9ac2db388a48259d981bbce96319043e9830", size = 267983 }, + { url = "https://files.pythonhosted.org/packages/33/86/4407341efb5dceb3e389635749ce1d670542d6ca148bd34f9d5334295faf/pytest_codspeed-4.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:748411c832147bfc85f805af78a1ab1684f52d08e14aabe22932bbe46c079a5f", size = 256732 }, + { url = "https://files.pythonhosted.org/packages/25/0e/8cb71fd3ed4ed08c07aec1245aea7bc1b661ba55fd9c392db76f1978d453/pytest_codspeed-4.2.0-py3-none-any.whl", hash = "sha256:e81bbb45c130874ef99aca97929d72682733527a49f84239ba575b5cb843bab0", size = 113726 }, +] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, +] + +[[package]] +name = "pytest-doctestplus" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/e5/97c4bc17e93d5caf6b37ebab0bdfd668c1c62d575e6e1e5040bfa759b4f2/pytest_doctestplus-1.4.0.tar.gz", hash = "sha256:df83832b1d11288572df2ee4c7cccdb421d812b8038a658bb514c9c62bdbd626", size = 47566 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/08/0e0e38a6046f91ad6ae352c0639c1b6dd90e2cd53ab2d2282d1d231535fb/pytest_doctestplus-1.4.0-py3-none-any.whl", hash = "sha256:cfbae130ec90d4a2831819bbbfd097121b8e55f1e4d20a47ea992e4eaad2539a", size = 25236 }, +] + +[[package]] +name = "pytest-filter-subpackage" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/f3/10d46a1dcd2245854db8654a27e48da727411e88ed4afa7fc24331ff217c/pytest-filter-subpackage-0.2.0.tar.gz", hash = "sha256:3f468f1b36518128869b95deab661ba45ed6293854329fef14da4c8cac78af56", size = 8085 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/2ed436b6ef3fb46743ba6e562c5421493d695c6534f051420ef2b5a99258/pytest_filter_subpackage-0.2.0-py2.py3-none-any.whl", hash = "sha256:b4a8c21b52110c3fefe949229c387c18be081132138ca3acc4953869a459b3f6", size = 5501 }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, +] + +[[package]] +name = "pytest-remotedata" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/b7/e39d14f37df7303fd6643db638db0e7722a8d168212d6deb36244f844b68/pytest-remotedata-0.4.1.tar.gz", hash = "sha256:05c08bf638cdd1ed66eb01738a1647c3c714737c3ec3abe009d2c1f793b4bb59", size = 13279 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/70/9d5e2a5020c4721115b48fa6efa765ffff1cd679ed89049fa4c9a3c62f55/pytest_remotedata-0.4.1-py3-none-any.whl", hash = "sha256:4e840bd8733091c2a84e52528ee2c2a98aa2d4a26376ba20448f211bccd30a35", size = 8583 }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "pytz" +version = "2025.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, +] + +[[package]] +name = "pyvo" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astropy" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/a2/878f424513396fba16f491e7538da4abab91020a73168b630329d7aba5aa/pyvo-1.6.1.tar.gz", hash = "sha256:f896717ca9825ad06d34558d8f13c444e249b8c2d2a475507a4635eccdd2cd41", size = 1021588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/0c/7b56358a1722d409914749f42b4e398233352a3cca365b280e3ca96ea2ff/pyvo-1.6.1-py3-none-any.whl", hash = "sha256:4260544bd2b81bd0d880acb2c9b8b45bd4a3cbc1b0961d0513d2b821d0ddec77", size = 997588 }, +] + +[[package]] +name = "pywin32" +version = "308" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/e2/02652007469263fe1466e98439831d65d4ca80ea1a2df29abecedf7e47b7/pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a", size = 5928156 }, + { url = "https://files.pythonhosted.org/packages/48/ef/f4fb45e2196bc7ffe09cad0542d9aff66b0e33f6c0954b43e49c33cad7bd/pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b", size = 6559559 }, + { url = "https://files.pythonhosted.org/packages/79/ef/68bb6aa865c5c9b11a35771329e95917b5559845bd75b65549407f9fc6b4/pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6", size = 7972495 }, + { url = "https://files.pythonhosted.org/packages/00/7c/d00d6bdd96de4344e06c4afbf218bc86b54436a94c01c71a8701f613aa56/pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897", size = 5939729 }, + { url = "https://files.pythonhosted.org/packages/21/27/0c8811fbc3ca188f93b5354e7c286eb91f80a53afa4e11007ef661afa746/pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47", size = 6543015 }, + { url = "https://files.pythonhosted.org/packages/9d/0f/d40f8373608caed2255781a3ad9a51d03a594a1248cd632d6a298daca693/pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091", size = 7976033 }, + { url = "https://files.pythonhosted.org/packages/a9/a4/aa562d8935e3df5e49c161b427a3a2efad2ed4e9cf81c3de636f1fdddfd0/pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed", size = 5938579 }, + { url = "https://files.pythonhosted.org/packages/c7/50/b0efb8bb66210da67a53ab95fd7a98826a97ee21f1d22949863e6d588b22/pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4", size = 6542056 }, + { url = "https://files.pythonhosted.org/packages/26/df/2b63e3e4f2df0224f8aaf6d131f54fe4e8c96400eb9df563e2aae2e1a1f9/pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd", size = 7974986 }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "pyzmq" +version = "26.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/e3/8d0382cb59feb111c252b54e8728257416a38ffcb2243c4e4775a3c990fe/pyzmq-26.2.1.tar.gz", hash = "sha256:17d72a74e5e9ff3829deb72897a175333d3ef5b5413948cae3cf7ebf0b02ecca", size = 278433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/03/5ecc46a6ed5971299f5c03e016ca637802d8660e44392bea774fb7797405/pyzmq-26.2.1-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:c059883840e634a21c5b31d9b9a0e2b48f991b94d60a811092bc37992715146a", size = 1346032 }, + { url = "https://files.pythonhosted.org/packages/40/51/48fec8f990ee644f461ff14c8fe5caa341b0b9b3a0ad7544f8ef17d6f528/pyzmq-26.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ed038a921df836d2f538e509a59cb638df3e70ca0fcd70d0bf389dfcdf784d2a", size = 943324 }, + { url = "https://files.pythonhosted.org/packages/c1/f4/f322b389727c687845e38470b48d7a43c18a83f26d4d5084603c6c3f79ca/pyzmq-26.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9027a7fcf690f1a3635dc9e55e38a0d6602dbbc0548935d08d46d2e7ec91f454", size = 678418 }, + { url = "https://files.pythonhosted.org/packages/a8/df/2834e3202533bd05032d83e02db7ac09fa1be853bbef59974f2b2e3a8557/pyzmq-26.2.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d75fcb00a1537f8b0c0bb05322bc7e35966148ffc3e0362f0369e44a4a1de99", size = 915466 }, + { url = "https://files.pythonhosted.org/packages/b5/e2/45c0f6e122b562cb8c6c45c0dcac1160a4e2207385ef9b13463e74f93031/pyzmq-26.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0019cc804ac667fb8c8eaecdb66e6d4a68acf2e155d5c7d6381a5645bd93ae4", size = 873347 }, + { url = "https://files.pythonhosted.org/packages/de/b9/3e0fbddf8b87454e914501d368171466a12550c70355b3844115947d68ea/pyzmq-26.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f19dae58b616ac56b96f2e2290f2d18730a898a171f447f491cc059b073ca1fa", size = 874545 }, + { url = "https://files.pythonhosted.org/packages/1f/1c/1ee41d6e10b2127263b1994bc53b9e74ece015b0d2c0a30e0afaf69b78b2/pyzmq-26.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f5eeeb82feec1fc5cbafa5ee9022e87ffdb3a8c48afa035b356fcd20fc7f533f", size = 1208630 }, + { url = "https://files.pythonhosted.org/packages/3d/a9/50228465c625851a06aeee97c74f253631f509213f979166e83796299c60/pyzmq-26.2.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:000760e374d6f9d1a3478a42ed0c98604de68c9e94507e5452951e598ebecfba", size = 1519568 }, + { url = "https://files.pythonhosted.org/packages/c6/f2/6360b619e69da78863c2108beb5196ae8b955fe1e161c0b886b95dc6b1ac/pyzmq-26.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:817fcd3344d2a0b28622722b98500ae9c8bfee0f825b8450932ff19c0b15bebd", size = 1419677 }, + { url = "https://files.pythonhosted.org/packages/da/d5/f179da989168f5dfd1be8103ef508ade1d38a8078dda4f10ebae3131a490/pyzmq-26.2.1-cp311-cp311-win32.whl", hash = "sha256:88812b3b257f80444a986b3596e5ea5c4d4ed4276d2b85c153a6fbc5ca457ae7", size = 582682 }, + { url = "https://files.pythonhosted.org/packages/60/50/e5b2e9de3ffab73ff92bee736216cf209381081fa6ab6ba96427777d98b1/pyzmq-26.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:ef29630fde6022471d287c15c0a2484aba188adbfb978702624ba7a54ddfa6c1", size = 648128 }, + { url = "https://files.pythonhosted.org/packages/d9/fe/7bb93476dd8405b0fc9cab1fd921a08bd22d5e3016aa6daea1a78d54129b/pyzmq-26.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:f32718ee37c07932cc336096dc7403525301fd626349b6eff8470fe0f996d8d7", size = 562465 }, + { url = "https://files.pythonhosted.org/packages/9c/b9/260a74786f162c7f521f5f891584a51d5a42fd15f5dcaa5c9226b2865fcc/pyzmq-26.2.1-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:a6549ecb0041dafa55b5932dcbb6c68293e0bd5980b5b99f5ebb05f9a3b8a8f3", size = 1348495 }, + { url = "https://files.pythonhosted.org/packages/bf/73/8a0757e4b68f5a8ccb90ddadbb76c6a5f880266cdb18be38c99bcdc17aaa/pyzmq-26.2.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0250c94561f388db51fd0213cdccbd0b9ef50fd3c57ce1ac937bf3034d92d72e", size = 945035 }, + { url = "https://files.pythonhosted.org/packages/cf/de/f02ec973cd33155bb772bae33ace774acc7cc71b87b25c4829068bec35de/pyzmq-26.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36ee4297d9e4b34b5dc1dd7ab5d5ea2cbba8511517ef44104d2915a917a56dc8", size = 671213 }, + { url = "https://files.pythonhosted.org/packages/d1/80/8fc583085f85ac91682744efc916888dd9f11f9f75a31aef1b78a5486c6c/pyzmq-26.2.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2a9cb17fd83b7a3a3009901aca828feaf20aa2451a8a487b035455a86549c09", size = 908750 }, + { url = "https://files.pythonhosted.org/packages/c3/25/0b4824596f261a3cc512ab152448b383047ff5f143a6906a36876415981c/pyzmq-26.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:786dd8a81b969c2081b31b17b326d3a499ddd1856e06d6d79ad41011a25148da", size = 865416 }, + { url = "https://files.pythonhosted.org/packages/a1/d1/6fda77a034d02034367b040973fd3861d945a5347e607bd2e98c99f20599/pyzmq-26.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2d88ba221a07fc2c5581565f1d0fe8038c15711ae79b80d9462e080a1ac30435", size = 865922 }, + { url = "https://files.pythonhosted.org/packages/ad/81/48f7fd8a71c427412e739ce576fc1ee14f3dc34527ca9b0076e471676183/pyzmq-26.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1c84c1297ff9f1cd2440da4d57237cb74be21fdfe7d01a10810acba04e79371a", size = 1201526 }, + { url = "https://files.pythonhosted.org/packages/c7/d8/818f15c6ef36b5450e435cbb0d3a51599fc884a5d2b27b46b9c00af68ef1/pyzmq-26.2.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:46d4ebafc27081a7f73a0f151d0c38d4291656aa134344ec1f3d0199ebfbb6d4", size = 1512808 }, + { url = "https://files.pythonhosted.org/packages/d9/c4/b3edb7d0ae82ad6fb1a8cdb191a4113c427a01e85139906f3b655b07f4f8/pyzmq-26.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:91e2bfb8e9a29f709d51b208dd5f441dc98eb412c8fe75c24ea464734ccdb48e", size = 1411836 }, + { url = "https://files.pythonhosted.org/packages/69/1c/151e3d42048f02cc5cd6dfc241d9d36b38375b4dee2e728acb5c353a6d52/pyzmq-26.2.1-cp312-cp312-win32.whl", hash = "sha256:4a98898fdce380c51cc3e38ebc9aa33ae1e078193f4dc641c047f88b8c690c9a", size = 581378 }, + { url = "https://files.pythonhosted.org/packages/b6/b9/d59a7462848aaab7277fddb253ae134a570520115d80afa85e952287e6bc/pyzmq-26.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a0741edbd0adfe5f30bba6c5223b78c131b5aa4a00a223d631e5ef36e26e6d13", size = 643737 }, + { url = "https://files.pythonhosted.org/packages/55/09/f37e707937cce328944c1d57e5e50ab905011d35252a0745c4f7e5822a76/pyzmq-26.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:e5e33b1491555843ba98d5209439500556ef55b6ab635f3a01148545498355e5", size = 558303 }, + { url = "https://files.pythonhosted.org/packages/4f/2e/fa7a91ce349975971d6aa925b4c7e1a05abaae99b97ade5ace758160c43d/pyzmq-26.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:099b56ef464bc355b14381f13355542e452619abb4c1e57a534b15a106bf8e23", size = 942331 }, + { url = "https://files.pythonhosted.org/packages/64/2b/1f10b34b6dc7ff4b40f668ea25ba9b8093ce61d874c784b90229b367707b/pyzmq-26.2.1-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:651726f37fcbce9f8dd2a6dab0f024807929780621890a4dc0c75432636871be", size = 1345831 }, + { url = "https://files.pythonhosted.org/packages/4c/8d/34884cbd4a8ec050841b5fb58d37af136766a9f95b0b2634c2971deb09da/pyzmq-26.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57dd4d91b38fa4348e237a9388b4423b24ce9c1695bbd4ba5a3eada491e09399", size = 670773 }, + { url = "https://files.pythonhosted.org/packages/0f/f4/d4becfcf9e416ad2564f18a6653f7c6aa917da08df5c3760edb0baa1c863/pyzmq-26.2.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d51a7bfe01a48e1064131f3416a5439872c533d756396be2b39e3977b41430f9", size = 908836 }, + { url = "https://files.pythonhosted.org/packages/07/fa/ab105f1b86b85cb2e821239f1d0900fccd66192a91d97ee04661b5436b4d/pyzmq-26.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7154d228502e18f30f150b7ce94f0789d6b689f75261b623f0fdc1eec642aab", size = 865369 }, + { url = "https://files.pythonhosted.org/packages/c9/48/15d5f415504572dd4b92b52db5de7a5befc76bb75340ba9f36f71306a66d/pyzmq-26.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f1f31661a80cc46aba381bed475a9135b213ba23ca7ff6797251af31510920ce", size = 865676 }, + { url = "https://files.pythonhosted.org/packages/7e/35/2d91bcc7ccbb56043dd4d2c1763f24a8de5f05e06a134f767a7fb38e149c/pyzmq-26.2.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:290c96f479504439b6129a94cefd67a174b68ace8a8e3f551b2239a64cfa131a", size = 1201457 }, + { url = "https://files.pythonhosted.org/packages/6d/bb/aa7c5119307a5762b8dca6c9db73e3ab4bccf32b15d7c4f376271ff72b2b/pyzmq-26.2.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f2c307fbe86e18ab3c885b7e01de942145f539165c3360e2af0f094dd440acd9", size = 1513035 }, + { url = "https://files.pythonhosted.org/packages/4f/4c/527e6650c2fccec7750b783301329c8a8716d59423818afb67282304ce5a/pyzmq-26.2.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:b314268e716487bfb86fcd6f84ebbe3e5bec5fac75fdf42bc7d90fdb33f618ad", size = 1411881 }, + { url = "https://files.pythonhosted.org/packages/89/9f/e4412ea1b3e220acc21777a5edba8885856403d29c6999aaf00a9459eb03/pyzmq-26.2.1-cp313-cp313-win32.whl", hash = "sha256:edb550616f567cd5603b53bb52a5f842c0171b78852e6fc7e392b02c2a1504bb", size = 581354 }, + { url = "https://files.pythonhosted.org/packages/55/cd/f89dd3e9fc2da0d1619a82c4afb600c86b52bc72d7584953d460bc8d5027/pyzmq-26.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:100a826a029c8ef3d77a1d4c97cbd6e867057b5806a7276f2bac1179f893d3bf", size = 643560 }, + { url = "https://files.pythonhosted.org/packages/a7/99/5de4f8912860013f1116f818a0047659bc20d71d1bc1d48f874bdc2d7b9c/pyzmq-26.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:6991ee6c43e0480deb1b45d0c7c2bac124a6540cba7db4c36345e8e092da47ce", size = 558037 }, + { url = "https://files.pythonhosted.org/packages/06/0b/63b6d7a2f07a77dbc9768c6302ae2d7518bed0c6cee515669ca0d8ec743e/pyzmq-26.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:25e720dba5b3a3bb2ad0ad5d33440babd1b03438a7a5220511d0c8fa677e102e", size = 938580 }, + { url = "https://files.pythonhosted.org/packages/85/38/e5e2c3ffa23ea5f95f1c904014385a55902a11a67cd43c10edf61a653467/pyzmq-26.2.1-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:9ec6abfb701437142ce9544bd6a236addaf803a32628d2260eb3dbd9a60e2891", size = 1339670 }, + { url = "https://files.pythonhosted.org/packages/d2/87/da5519ed7f8b31e4beee8f57311ec02926822fe23a95120877354cd80144/pyzmq-26.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e1eb9d2bfdf5b4e21165b553a81b2c3bd5be06eeddcc4e08e9692156d21f1f6", size = 660983 }, + { url = "https://files.pythonhosted.org/packages/f6/e8/1ca6a2d59562e04d326a026c9e3f791a6f1a276ebde29da478843a566fdb/pyzmq-26.2.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:90dc731d8e3e91bcd456aa7407d2eba7ac6f7860e89f3766baabb521f2c1de4a", size = 896509 }, + { url = "https://files.pythonhosted.org/packages/5c/e5/0b4688f7c74bea7e4f1e920da973fcd7d20175f4f1181cb9b692429c6bb9/pyzmq-26.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b6a93d684278ad865fc0b9e89fe33f6ea72d36da0e842143891278ff7fd89c3", size = 853196 }, + { url = "https://files.pythonhosted.org/packages/8f/35/c17241da01195001828319e98517683dad0ac4df6fcba68763d61b630390/pyzmq-26.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c1bb37849e2294d519117dd99b613c5177934e5c04a5bb05dd573fa42026567e", size = 855133 }, + { url = "https://files.pythonhosted.org/packages/d2/14/268ee49bbecc3f72e225addeac7f0e2bd5808747b78c7bf7f87ed9f9d5a8/pyzmq-26.2.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:632a09c6d8af17b678d84df442e9c3ad8e4949c109e48a72f805b22506c4afa7", size = 1191612 }, + { url = "https://files.pythonhosted.org/packages/5e/02/6394498620b1b4349b95c534f3ebc3aef95f39afbdced5ed7ee315c49c14/pyzmq-26.2.1-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:fc409c18884eaf9ddde516d53af4f2db64a8bc7d81b1a0c274b8aa4e929958e8", size = 1500824 }, + { url = "https://files.pythonhosted.org/packages/17/fc/b79f0b72891cbb9917698add0fede71dfb64e83fa3481a02ed0e78c34be7/pyzmq-26.2.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:17f88622b848805d3f6427ce1ad5a2aa3cf61f12a97e684dab2979802024d460", size = 1399943 }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393 }, +] + +[[package]] +name = "rpds-py" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/79/2ce611b18c4fd83d9e3aecb5cba93e1917c050f556db39842889fa69b79f/rpds_py-0.23.1.tar.gz", hash = "sha256:7f3240dcfa14d198dba24b8b9cb3b108c06b68d45b7babd9eefc1038fdf7e707", size = 26806 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/67/6e5d4234bb9dee062ffca2a5f3c7cd38716317d6760ec235b175eed4de2c/rpds_py-0.23.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b79f5ced71efd70414a9a80bbbfaa7160da307723166f09b69773153bf17c590", size = 372264 }, + { url = "https://files.pythonhosted.org/packages/a7/0a/3dedb2daee8e783622427f5064e2d112751d8276ee73aa5409f000a132f4/rpds_py-0.23.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c9e799dac1ffbe7b10c1fd42fe4cd51371a549c6e108249bde9cd1200e8f59b4", size = 356883 }, + { url = "https://files.pythonhosted.org/packages/ed/fc/e1acef44f9c24b05fe5434b235f165a63a52959ac655e3f7a55726cee1a4/rpds_py-0.23.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721f9c4011b443b6e84505fc00cc7aadc9d1743f1c988e4c89353e19c4a968ee", size = 385624 }, + { url = "https://files.pythonhosted.org/packages/97/0a/a05951f6465d01622720c03ef6ef31adfbe865653e05ed7c45837492f25e/rpds_py-0.23.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f88626e3f5e57432e6191cd0c5d6d6b319b635e70b40be2ffba713053e5147dd", size = 391500 }, + { url = "https://files.pythonhosted.org/packages/ea/2e/cca0583ec0690ea441dceae23c0673b99755710ea22f40bccf1e78f41481/rpds_py-0.23.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:285019078537949cecd0190f3690a0b0125ff743d6a53dfeb7a4e6787af154f5", size = 444869 }, + { url = "https://files.pythonhosted.org/packages/cc/e6/95cda68b33a6d814d1e96b0e406d231ed16629101460d1740e92f03365e6/rpds_py-0.23.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92f5654157de1379c509b15acec9d12ecf6e3bc1996571b6cb82a4302060447", size = 444930 }, + { url = "https://files.pythonhosted.org/packages/5f/a7/e94cdb73411ae9c11414d3c7c9a6ad75d22ad4a8d094fb45a345ba9e3018/rpds_py-0.23.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e768267cbe051dd8d1c5305ba690bb153204a09bf2e3de3ae530de955f5b5580", size = 386254 }, + { url = "https://files.pythonhosted.org/packages/dd/c5/a4a943d90a39e85efd1e04b1ad5129936786f9a9aa27bb7be8fc5d9d50c9/rpds_py-0.23.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c5334a71f7dc1160382d45997e29f2637c02f8a26af41073189d79b95d3321f1", size = 417090 }, + { url = "https://files.pythonhosted.org/packages/0c/a0/80d0013b12428d1fce0ab4e71829400b0a32caec12733c79e6109f843342/rpds_py-0.23.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6adb81564af0cd428910f83fa7da46ce9ad47c56c0b22b50872bc4515d91966", size = 557639 }, + { url = "https://files.pythonhosted.org/packages/a6/92/ec2e6980afb964a2cd7a99cbdef1f6c01116abe94b42cbe336ac93dd11c2/rpds_py-0.23.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cafa48f2133d4daa028473ede7d81cd1b9f9e6925e9e4003ebdf77010ee02f35", size = 584572 }, + { url = "https://files.pythonhosted.org/packages/3d/ce/75b6054db34a390789a82523790717b27c1bd735e453abb429a87c4f0f26/rpds_py-0.23.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fced9fd4a07a1ded1bac7e961ddd9753dd5d8b755ba8e05acba54a21f5f1522", size = 553028 }, + { url = "https://files.pythonhosted.org/packages/cc/24/f45abe0418c06a5cba0f846e967aa27bac765acd927aabd857c21319b8cc/rpds_py-0.23.1-cp311-cp311-win32.whl", hash = "sha256:243241c95174b5fb7204c04595852fe3943cc41f47aa14c3828bc18cd9d3b2d6", size = 220862 }, + { url = "https://files.pythonhosted.org/packages/2d/a6/3c0880e8bbfc36451ef30dc416266f6d2934705e468db5d21c8ba0ab6400/rpds_py-0.23.1-cp311-cp311-win_amd64.whl", hash = "sha256:11dd60b2ffddba85715d8a66bb39b95ddbe389ad2cfcf42c833f1bcde0878eaf", size = 232953 }, + { url = "https://files.pythonhosted.org/packages/f3/8c/d17efccb9f5b9137ddea706664aebae694384ae1d5997c0202093e37185a/rpds_py-0.23.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3902df19540e9af4cc0c3ae75974c65d2c156b9257e91f5101a51f99136d834c", size = 364369 }, + { url = "https://files.pythonhosted.org/packages/6e/c0/ab030f696b5c573107115a88d8d73d80f03309e60952b64c584c70c659af/rpds_py-0.23.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66f8d2a17e5838dd6fb9be6baaba8e75ae2f5fa6b6b755d597184bfcd3cb0eba", size = 349965 }, + { url = "https://files.pythonhosted.org/packages/b3/55/b40170f5a079c4fb0b6a82b299689e66e744edca3c3375a8b160fb797660/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:112b8774b0b4ee22368fec42749b94366bd9b536f8f74c3d4175d4395f5cbd31", size = 389064 }, + { url = "https://files.pythonhosted.org/packages/ab/1c/b03a912c59ec7c1e16b26e587b9dfa8ddff3b07851e781e8c46e908a365a/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0df046f2266e8586cf09d00588302a32923eb6386ced0ca5c9deade6af9a149", size = 397741 }, + { url = "https://files.pythonhosted.org/packages/52/6f/151b90792b62fb6f87099bcc9044c626881fdd54e31bf98541f830b15cea/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f3288930b947cbebe767f84cf618d2cbe0b13be476e749da0e6a009f986248c", size = 448784 }, + { url = "https://files.pythonhosted.org/packages/71/2a/6de67c0c97ec7857e0e9e5cd7c52405af931b303eb1e5b9eff6c50fd9a2e/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce473a2351c018b06dd8d30d5da8ab5a0831056cc53b2006e2a8028172c37ce5", size = 440203 }, + { url = "https://files.pythonhosted.org/packages/db/5e/e759cd1c276d98a4b1f464b17a9bf66c65d29f8f85754e27e1467feaa7c3/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d550d7e9e7d8676b183b37d65b5cd8de13676a738973d330b59dc8312df9c5dc", size = 391611 }, + { url = "https://files.pythonhosted.org/packages/1c/1e/2900358efcc0d9408c7289769cba4c0974d9db314aa884028ed7f7364f61/rpds_py-0.23.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e14f86b871ea74c3fddc9a40e947d6a5d09def5adc2076ee61fb910a9014fb35", size = 423306 }, + { url = "https://files.pythonhosted.org/packages/23/07/6c177e6d059f5d39689352d6c69a926ee4805ffdb6f06203570234d3d8f7/rpds_py-0.23.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1bf5be5ba34e19be579ae873da515a2836a2166d8d7ee43be6ff909eda42b72b", size = 562323 }, + { url = "https://files.pythonhosted.org/packages/70/e4/f9097fd1c02b516fff9850792161eb9fc20a2fd54762f3c69eae0bdb67cb/rpds_py-0.23.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7031d493c4465dbc8d40bd6cafefef4bd472b17db0ab94c53e7909ee781b9ef", size = 588351 }, + { url = "https://files.pythonhosted.org/packages/87/39/5db3c6f326bfbe4576ae2af6435bd7555867d20ae690c786ff33659f293b/rpds_py-0.23.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55ff4151cfd4bc635e51cfb1c59ac9f7196b256b12e3a57deb9e5742e65941ad", size = 557252 }, + { url = "https://files.pythonhosted.org/packages/fd/14/2d5ad292f144fa79bafb78d2eb5b8a3a91c358b6065443cb9c49b5d1fedf/rpds_py-0.23.1-cp312-cp312-win32.whl", hash = "sha256:a9d3b728f5a5873d84cba997b9d617c6090ca5721caaa691f3b1a78c60adc057", size = 222181 }, + { url = "https://files.pythonhosted.org/packages/a3/4f/0fce63e0f5cdd658e71e21abd17ac1bc9312741ebb8b3f74eeed2ebdf771/rpds_py-0.23.1-cp312-cp312-win_amd64.whl", hash = "sha256:b03a8d50b137ee758e4c73638b10747b7c39988eb8e6cd11abb7084266455165", size = 237426 }, + { url = "https://files.pythonhosted.org/packages/13/9d/b8b2c0edffb0bed15be17b6d5ab06216f2f47f9ee49259c7e96a3ad4ca42/rpds_py-0.23.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4caafd1a22e5eaa3732acb7672a497123354bef79a9d7ceed43387d25025e935", size = 363672 }, + { url = "https://files.pythonhosted.org/packages/bd/c2/5056fa29e6894144d7ba4c938b9b0445f75836b87d2dd00ed4999dc45a8c/rpds_py-0.23.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:178f8a60fc24511c0eb756af741c476b87b610dba83270fce1e5a430204566a4", size = 349602 }, + { url = "https://files.pythonhosted.org/packages/b0/bc/33779a1bb0ee32d8d706b173825aab75c628521d23ce72a7c1e6a6852f86/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c632419c3870507ca20a37c8f8f5352317aca097639e524ad129f58c125c61c6", size = 388746 }, + { url = "https://files.pythonhosted.org/packages/62/0b/71db3e36b7780a619698ec82a9c87ab44ad7ca7f5480913e8a59ff76f050/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:698a79d295626ee292d1730bc2ef6e70a3ab135b1d79ada8fde3ed0047b65a10", size = 397076 }, + { url = "https://files.pythonhosted.org/packages/bb/2e/494398f613edf77ba10a916b1ddea2acce42ab0e3b62e2c70ffc0757ce00/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:271fa2184cf28bdded86bb6217c8e08d3a169fe0bbe9be5e8d96e8476b707122", size = 448399 }, + { url = "https://files.pythonhosted.org/packages/dd/53/4bd7f5779b1f463243ee5fdc83da04dd58a08f86e639dbffa7a35f969a84/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b91cceb5add79ee563bd1f70b30896bd63bc5f78a11c1f00a1e931729ca4f1f4", size = 439764 }, + { url = "https://files.pythonhosted.org/packages/f6/55/b3c18c04a460d951bf8e91f2abf46ce5b6426fb69784166a6a25827cb90a/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a6cb95074777f1ecda2ca4fa7717caa9ee6e534f42b7575a8f0d4cb0c24013", size = 390662 }, + { url = "https://files.pythonhosted.org/packages/2a/65/cc463044a3cbd616029b2aa87a651cdee8288d2fdd7780b2244845e934c1/rpds_py-0.23.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:50fb62f8d8364978478b12d5f03bf028c6bc2af04082479299139dc26edf4c64", size = 422680 }, + { url = "https://files.pythonhosted.org/packages/fa/8e/1fa52990c7836d72e8d70cd7753f2362c72fbb0a49c1462e8c60e7176d0b/rpds_py-0.23.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8f7e90b948dc9dcfff8003f1ea3af08b29c062f681c05fd798e36daa3f7e3e8", size = 561792 }, + { url = "https://files.pythonhosted.org/packages/57/b8/fe3b612979b1a29d0c77f8585903d8b3a292604b26d4b300e228b8ac6360/rpds_py-0.23.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5b98b6c953e5c2bda51ab4d5b4f172617d462eebc7f4bfdc7c7e6b423f6da957", size = 588127 }, + { url = "https://files.pythonhosted.org/packages/44/2d/fde474de516bbc4b9b230f43c98e7f8acc5da7fc50ceed8e7af27553d346/rpds_py-0.23.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2893d778d4671ee627bac4037a075168b2673c57186fb1a57e993465dbd79a93", size = 556981 }, + { url = "https://files.pythonhosted.org/packages/18/57/767deeb27b81370bbab8f74ef6e68d26c4ea99018f3c71a570e506fede85/rpds_py-0.23.1-cp313-cp313-win32.whl", hash = "sha256:2cfa07c346a7ad07019c33fb9a63cf3acb1f5363c33bc73014e20d9fe8b01cdd", size = 221936 }, + { url = "https://files.pythonhosted.org/packages/7d/6c/3474cfdd3cafe243f97ab8474ea8949236eb2a1a341ca55e75ce00cd03da/rpds_py-0.23.1-cp313-cp313-win_amd64.whl", hash = "sha256:3aaf141d39f45322e44fc2c742e4b8b4098ead5317e5f884770c8df0c332da70", size = 237145 }, + { url = "https://files.pythonhosted.org/packages/ec/77/e985064c624230f61efa0423759bb066da56ebe40c654f8b5ba225bd5d63/rpds_py-0.23.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:759462b2d0aa5a04be5b3e37fb8183615f47014ae6b116e17036b131985cb731", size = 359623 }, + { url = "https://files.pythonhosted.org/packages/62/d9/a33dcbf62b29e40559e012d525bae7d516757cf042cc9234bd34ca4b6aeb/rpds_py-0.23.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3e9212f52074fc9d72cf242a84063787ab8e21e0950d4d6709886fb62bcb91d5", size = 345900 }, + { url = "https://files.pythonhosted.org/packages/92/eb/f81a4be6397861adb2cb868bb6a28a33292c2dcac567d1dc575226055e55/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e9f3a3ac919406bc0414bbbd76c6af99253c507150191ea79fab42fdb35982a", size = 386426 }, + { url = "https://files.pythonhosted.org/packages/09/47/1f810c9b5e83be005341201b5389f1d240dfa440346ea7189f9b3fd6961d/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c04ca91dda8a61584165825907f5c967ca09e9c65fe8966ee753a3f2b019fe1e", size = 392314 }, + { url = "https://files.pythonhosted.org/packages/83/bd/bc95831432fd6c46ed8001f01af26de0763a059d6d7e6d69e3c5bf02917a/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ab923167cfd945abb9b51a407407cf19f5bee35001221f2911dc85ffd35ff4f", size = 447706 }, + { url = "https://files.pythonhosted.org/packages/19/3e/567c04c226b1802dc6dc82cad3d53e1fa0a773258571c74ac5d8fbde97ed/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed6f011bedca8585787e5082cce081bac3d30f54520097b2411351b3574e1219", size = 437060 }, + { url = "https://files.pythonhosted.org/packages/fe/77/a77d2c6afe27ae7d0d55fc32f6841502648070dc8d549fcc1e6d47ff8975/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6959bb9928c5c999aba4a3f5a6799d571ddc2c59ff49917ecf55be2bbb4e3722", size = 389347 }, + { url = "https://files.pythonhosted.org/packages/3f/47/6b256ff20a74cfebeac790ab05586e0ac91f88e331125d4740a6c86fc26f/rpds_py-0.23.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ed7de3c86721b4e83ac440751329ec6a1102229aa18163f84c75b06b525ad7e", size = 415554 }, + { url = "https://files.pythonhosted.org/packages/fc/29/d4572469a245bc9fc81e35166dca19fc5298d5c43e1a6dd64bf145045193/rpds_py-0.23.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5fb89edee2fa237584e532fbf78f0ddd1e49a47c7c8cfa153ab4849dc72a35e6", size = 557418 }, + { url = "https://files.pythonhosted.org/packages/9c/0a/68cf7228895b1a3f6f39f51b15830e62456795e61193d2c8b87fd48c60db/rpds_py-0.23.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7e5413d2e2d86025e73f05510ad23dad5950ab8417b7fc6beaad99be8077138b", size = 583033 }, + { url = "https://files.pythonhosted.org/packages/14/18/017ab41dcd6649ad5db7d00155b4c212b31ab05bd857d5ba73a1617984eb/rpds_py-0.23.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d31ed4987d72aabdf521eddfb6a72988703c091cfc0064330b9e5f8d6a042ff5", size = 554880 }, + { url = "https://files.pythonhosted.org/packages/2e/dd/17de89431268da8819d8d51ce67beac28d9b22fccf437bc5d6d2bcd1acdb/rpds_py-0.23.1-cp313-cp313t-win32.whl", hash = "sha256:f3429fb8e15b20961efca8c8b21432623d85db2228cc73fe22756c6637aa39e7", size = 219743 }, + { url = "https://files.pythonhosted.org/packages/68/15/6d22d07e063ce5e9bfbd96db9ec2fbb4693591b4503e3a76996639474d02/rpds_py-0.23.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d6f6512a90bd5cd9030a6237f5346f046c6f0e40af98657568fa45695d4de59d", size = 235415 }, +] + +[[package]] +name = "rtds-action" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "setuptools" }, + { name = "setuptools-scm" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/f2/d6b99748e97ba132f856f1caa891d577da63bacb44a08eb4944e6511c986/rtds_action-1.1.0.tar.gz", hash = "sha256:f824851318a5d41550ae30edfb166dc6442bc9b9940a50ea06e649cd471f6ac1", size = 81459 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/54/b9aaf8e4867e95ac8ea27cd3249946c62c58058779e998040442d6d07625/rtds_action-1.1.0-py2.py3-none-any.whl", hash = "sha256:097a73eac507387a32b24c3da690f3a876936cb56150aa1786d52a9a67cd62f9", size = 6660 }, +] + +[[package]] +name = "scipy" +version = "1.16.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/3b/546a6f0bfe791bbb7f8d591613454d15097e53f906308ec6f7c1ce588e8e/scipy-1.16.2.tar.gz", hash = "sha256:af029b153d243a80afb6eabe40b0a07f8e35c9adc269c019f364ad747f826a6b", size = 30580599 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/ef/37ed4b213d64b48422df92560af7300e10fe30b5d665dd79932baebee0c6/scipy-1.16.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:6ab88ea43a57da1af33292ebd04b417e8e2eaf9d5aa05700be8d6e1b6501cd92", size = 36619956 }, + { url = "https://files.pythonhosted.org/packages/85/ab/5c2eba89b9416961a982346a4d6a647d78c91ec96ab94ed522b3b6baf444/scipy-1.16.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c95e96c7305c96ede73a7389f46ccd6c659c4da5ef1b2789466baeaed3622b6e", size = 28931117 }, + { url = "https://files.pythonhosted.org/packages/80/d1/eed51ab64d227fe60229a2d57fb60ca5898cfa50ba27d4f573e9e5f0b430/scipy-1.16.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:87eb178db04ece7c698220d523c170125dbffebb7af0345e66c3554f6f60c173", size = 20921997 }, + { url = "https://files.pythonhosted.org/packages/be/7c/33ea3e23bbadde96726edba6bf9111fb1969d14d9d477ffa202c67bec9da/scipy-1.16.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:4e409eac067dcee96a57fbcf424c13f428037827ec7ee3cb671ff525ca4fc34d", size = 23523374 }, + { url = "https://files.pythonhosted.org/packages/96/0b/7399dc96e1e3f9a05e258c98d716196a34f528eef2ec55aad651ed136d03/scipy-1.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e574be127bb760f0dad24ff6e217c80213d153058372362ccb9555a10fc5e8d2", size = 33583702 }, + { url = "https://files.pythonhosted.org/packages/1a/bc/a5c75095089b96ea72c1bd37a4497c24b581ec73db4ef58ebee142ad2d14/scipy-1.16.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5db5ba6188d698ba7abab982ad6973265b74bb40a1efe1821b58c87f73892b9", size = 35883427 }, + { url = "https://files.pythonhosted.org/packages/ab/66/e25705ca3d2b87b97fe0a278a24b7f477b4023a926847935a1a71488a6a6/scipy-1.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec6e74c4e884104ae006d34110677bfe0098203a3fec2f3faf349f4cb05165e3", size = 36212940 }, + { url = "https://files.pythonhosted.org/packages/d6/fd/0bb911585e12f3abdd603d721d83fc1c7492835e1401a0e6d498d7822b4b/scipy-1.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:912f46667d2d3834bc3d57361f854226475f695eb08c08a904aadb1c936b6a88", size = 38865092 }, + { url = "https://files.pythonhosted.org/packages/d6/73/c449a7d56ba6e6f874183759f8483cde21f900a8be117d67ffbb670c2958/scipy-1.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:91e9e8a37befa5a69e9cacbe0bcb79ae5afb4a0b130fd6db6ee6cc0d491695fa", size = 38687626 }, + { url = "https://files.pythonhosted.org/packages/68/72/02f37316adf95307f5d9e579023c6899f89ff3a051fa079dbd6faafc48e5/scipy-1.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:f3bf75a6dcecab62afde4d1f973f1692be013110cad5338007927db8da73249c", size = 25503506 }, + { url = "https://files.pythonhosted.org/packages/b7/8d/6396e00db1282279a4ddd507c5f5e11f606812b608ee58517ce8abbf883f/scipy-1.16.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:89d6c100fa5c48472047632e06f0876b3c4931aac1f4291afc81a3644316bb0d", size = 36646259 }, + { url = "https://files.pythonhosted.org/packages/3b/93/ea9edd7e193fceb8eef149804491890bde73fb169c896b61aa3e2d1e4e77/scipy-1.16.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ca748936cd579d3f01928b30a17dc474550b01272d8046e3e1ee593f23620371", size = 28888976 }, + { url = "https://files.pythonhosted.org/packages/91/4d/281fddc3d80fd738ba86fd3aed9202331180b01e2c78eaae0642f22f7e83/scipy-1.16.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:fac4f8ce2ddb40e2e3d0f7ec36d2a1e7f92559a2471e59aec37bd8d9de01fec0", size = 20879905 }, + { url = "https://files.pythonhosted.org/packages/69/40/b33b74c84606fd301b2915f0062e45733c6ff5708d121dd0deaa8871e2d0/scipy-1.16.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:033570f1dcefd79547a88e18bccacff025c8c647a330381064f561d43b821232", size = 23553066 }, + { url = "https://files.pythonhosted.org/packages/55/a7/22c739e2f21a42cc8f16bc76b47cff4ed54fbe0962832c589591c2abec34/scipy-1.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ea3421209bf00c8a5ef2227de496601087d8f638a2363ee09af059bd70976dc1", size = 33336407 }, + { url = "https://files.pythonhosted.org/packages/53/11/a0160990b82999b45874dc60c0c183d3a3a969a563fffc476d5a9995c407/scipy-1.16.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f66bd07ba6f84cd4a380b41d1bf3c59ea488b590a2ff96744845163309ee8e2f", size = 35673281 }, + { url = "https://files.pythonhosted.org/packages/96/53/7ef48a4cfcf243c3d0f1643f5887c81f29fdf76911c4e49331828e19fc0a/scipy-1.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e9feab931bd2aea4a23388c962df6468af3d808ddf2d40f94a81c5dc38f32ef", size = 36004222 }, + { url = "https://files.pythonhosted.org/packages/49/7f/71a69e0afd460049d41c65c630c919c537815277dfea214031005f474d78/scipy-1.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03dfc75e52f72cf23ec2ced468645321407faad8f0fe7b1f5b49264adbc29cb1", size = 38664586 }, + { url = "https://files.pythonhosted.org/packages/34/95/20e02ca66fb495a95fba0642fd48e0c390d0ece9b9b14c6e931a60a12dea/scipy-1.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:0ce54e07bbb394b417457409a64fd015be623f36e330ac49306433ffe04bc97e", size = 38550641 }, + { url = "https://files.pythonhosted.org/packages/92/ad/13646b9beb0a95528ca46d52b7babafbe115017814a611f2065ee4e61d20/scipy-1.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a8ffaa4ac0df81a0b94577b18ee079f13fecdb924df3328fc44a7dc5ac46851", size = 25456070 }, + { url = "https://files.pythonhosted.org/packages/c1/27/c5b52f1ee81727a9fc457f5ac1e9bf3d6eab311805ea615c83c27ba06400/scipy-1.16.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:84f7bf944b43e20b8a894f5fe593976926744f6c185bacfcbdfbb62736b5cc70", size = 36604856 }, + { url = "https://files.pythonhosted.org/packages/32/a9/15c20d08e950b540184caa8ced675ba1128accb0e09c653780ba023a4110/scipy-1.16.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5c39026d12edc826a1ef2ad35ad1e6d7f087f934bb868fc43fa3049c8b8508f9", size = 28864626 }, + { url = "https://files.pythonhosted.org/packages/4c/fc/ea36098df653cca26062a627c1a94b0de659e97127c8491e18713ca0e3b9/scipy-1.16.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e52729ffd45b68777c5319560014d6fd251294200625d9d70fd8626516fc49f5", size = 20855689 }, + { url = "https://files.pythonhosted.org/packages/dc/6f/d0b53be55727f3e6d7c72687ec18ea6d0047cf95f1f77488b99a2bafaee1/scipy-1.16.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:024dd4a118cccec09ca3209b7e8e614931a6ffb804b2a601839499cb88bdf925", size = 23512151 }, + { url = "https://files.pythonhosted.org/packages/11/85/bf7dab56e5c4b1d3d8eef92ca8ede788418ad38a7dc3ff50262f00808760/scipy-1.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a5dc7ee9c33019973a470556081b0fd3c9f4c44019191039f9769183141a4d9", size = 33329824 }, + { url = "https://files.pythonhosted.org/packages/da/6a/1a927b14ddc7714111ea51f4e568203b2bb6ed59bdd036d62127c1a360c8/scipy-1.16.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c2275ff105e508942f99d4e3bc56b6ef5e4b3c0af970386ca56b777608ce95b7", size = 35681881 }, + { url = "https://files.pythonhosted.org/packages/c1/5f/331148ea5780b4fcc7007a4a6a6ee0a0c1507a796365cc642d4d226e1c3a/scipy-1.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af80196eaa84f033e48444d2e0786ec47d328ba00c71e4299b602235ffef9acb", size = 36006219 }, + { url = "https://files.pythonhosted.org/packages/46/3a/e991aa9d2aec723b4a8dcfbfc8365edec5d5e5f9f133888067f1cbb7dfc1/scipy-1.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9fb1eb735fe3d6ed1f89918224e3385fbf6f9e23757cacc35f9c78d3b712dd6e", size = 38682147 }, + { url = "https://files.pythonhosted.org/packages/a1/57/0f38e396ad19e41b4c5db66130167eef8ee620a49bc7d0512e3bb67e0cab/scipy-1.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:fda714cf45ba43c9d3bae8f2585c777f64e3f89a2e073b668b32ede412d8f52c", size = 38520766 }, + { url = "https://files.pythonhosted.org/packages/1b/a5/85d3e867b6822d331e26c862a91375bb7746a0b458db5effa093d34cdb89/scipy-1.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:2f5350da923ccfd0b00e07c3e5cfb316c1c0d6c1d864c07a72d092e9f20db104", size = 25451169 }, + { url = "https://files.pythonhosted.org/packages/09/d9/60679189bcebda55992d1a45498de6d080dcaf21ce0c8f24f888117e0c2d/scipy-1.16.2-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:53d8d2ee29b925344c13bda64ab51785f016b1b9617849dac10897f0701b20c1", size = 37012682 }, + { url = "https://files.pythonhosted.org/packages/83/be/a99d13ee4d3b7887a96f8c71361b9659ba4ef34da0338f14891e102a127f/scipy-1.16.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:9e05e33657efb4c6a9d23bd8300101536abd99c85cca82da0bffff8d8764d08a", size = 29389926 }, + { url = "https://files.pythonhosted.org/packages/bf/0a/130164a4881cec6ca8c00faf3b57926f28ed429cd6001a673f83c7c2a579/scipy-1.16.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:7fe65b36036357003b3ef9d37547abeefaa353b237e989c21027b8ed62b12d4f", size = 21381152 }, + { url = "https://files.pythonhosted.org/packages/47/a6/503ffb0310ae77fba874e10cddfc4a1280bdcca1d13c3751b8c3c2996cf8/scipy-1.16.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6406d2ac6d40b861cccf57f49592f9779071655e9f75cd4f977fa0bdd09cb2e4", size = 23914410 }, + { url = "https://files.pythonhosted.org/packages/fa/c7/1147774bcea50d00c02600aadaa919facbd8537997a62496270133536ed6/scipy-1.16.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff4dc42bd321991fbf611c23fc35912d690f731c9914bf3af8f417e64aca0f21", size = 33481880 }, + { url = "https://files.pythonhosted.org/packages/6a/74/99d5415e4c3e46b2586f30cdbecb95e101c7192628a484a40dd0d163811a/scipy-1.16.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:654324826654d4d9133e10675325708fb954bc84dae6e9ad0a52e75c6b1a01d7", size = 35791425 }, + { url = "https://files.pythonhosted.org/packages/1b/ee/a6559de7c1cc710e938c0355d9d4fbcd732dac4d0d131959d1f3b63eb29c/scipy-1.16.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63870a84cd15c44e65220eaed2dac0e8f8b26bbb991456a033c1d9abfe8a94f8", size = 36178622 }, + { url = "https://files.pythonhosted.org/packages/4e/7b/f127a5795d5ba8ece4e0dce7d4a9fb7cb9e4f4757137757d7a69ab7d4f1a/scipy-1.16.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fa01f0f6a3050fa6a9771a95d5faccc8e2f5a92b4a2e5440a0fa7264a2398472", size = 38783985 }, + { url = "https://files.pythonhosted.org/packages/3e/9f/bc81c1d1e033951eb5912cd3750cc005943afa3e65a725d2443a3b3c4347/scipy-1.16.2-cp313-cp313t-win_amd64.whl", hash = "sha256:116296e89fba96f76353a8579820c2512f6e55835d3fad7780fece04367de351", size = 38631367 }, + { url = "https://files.pythonhosted.org/packages/d6/5e/2cc7555fd81d01814271412a1d59a289d25f8b63208a0a16c21069d55d3e/scipy-1.16.2-cp313-cp313t-win_arm64.whl", hash = "sha256:98e22834650be81d42982360382b43b17f7ba95e0e6993e2a4f5b9ad9283a94d", size = 25787992 }, + { url = "https://files.pythonhosted.org/packages/8b/ac/ad8951250516db71619f0bd3b2eb2448db04b720a003dd98619b78b692c0/scipy-1.16.2-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:567e77755019bb7461513c87f02bb73fb65b11f049aaaa8ca17cfaa5a5c45d77", size = 36595109 }, + { url = "https://files.pythonhosted.org/packages/ff/f6/5779049ed119c5b503b0f3dc6d6f3f68eefc3a9190d4ad4c276f854f051b/scipy-1.16.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:17d9bb346194e8967296621208fcdfd39b55498ef7d2f376884d5ac47cec1a70", size = 28859110 }, + { url = "https://files.pythonhosted.org/packages/82/09/9986e410ae38bf0a0c737ff8189ac81a93b8e42349aac009891c054403d7/scipy-1.16.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:0a17541827a9b78b777d33b623a6dcfe2ef4a25806204d08ead0768f4e529a88", size = 20850110 }, + { url = "https://files.pythonhosted.org/packages/0d/ad/485cdef2d9215e2a7df6d61b81d2ac073dfacf6ae24b9ae87274c4e936ae/scipy-1.16.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:d7d4c6ba016ffc0f9568d012f5f1eb77ddd99412aea121e6fa8b4c3b7cbad91f", size = 23497014 }, + { url = "https://files.pythonhosted.org/packages/a7/74/f6a852e5d581122b8f0f831f1d1e32fb8987776ed3658e95c377d308ed86/scipy-1.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9702c4c023227785c779cba2e1d6f7635dbb5b2e0936cdd3a4ecb98d78fd41eb", size = 33401155 }, + { url = "https://files.pythonhosted.org/packages/d9/f5/61d243bbc7c6e5e4e13dde9887e84a5cbe9e0f75fd09843044af1590844e/scipy-1.16.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d1cdf0ac28948d225decdefcc45ad7dd91716c29ab56ef32f8e0d50657dffcc7", size = 35691174 }, + { url = "https://files.pythonhosted.org/packages/03/99/59933956331f8cc57e406cdb7a483906c74706b156998f322913e789c7e1/scipy-1.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:70327d6aa572a17c2941cdfb20673f82e536e91850a2e4cb0c5b858b690e1548", size = 36070752 }, + { url = "https://files.pythonhosted.org/packages/c6/7d/00f825cfb47ee19ef74ecf01244b43e95eae74e7e0ff796026ea7cd98456/scipy-1.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5221c0b2a4b58aa7c4ed0387d360fd90ee9086d383bb34d9f2789fafddc8a936", size = 38701010 }, + { url = "https://files.pythonhosted.org/packages/e4/9f/b62587029980378304ba5a8563d376c96f40b1e133daacee76efdcae32de/scipy-1.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:f5a85d7b2b708025af08f060a496dd261055b617d776fc05a1a1cc69e09fe9ff", size = 39360061 }, + { url = "https://files.pythonhosted.org/packages/82/04/7a2f1609921352c7fbee0815811b5050582f67f19983096c4769867ca45f/scipy-1.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:2cc73a33305b4b24556957d5857d6253ce1e2dcd67fa0ff46d87d1670b3e1e1d", size = 26126914 }, + { url = "https://files.pythonhosted.org/packages/51/b9/60929ce350c16b221928725d2d1d7f86cf96b8bc07415547057d1196dc92/scipy-1.16.2-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:9ea2a3fed83065d77367775d689401a703d0f697420719ee10c0780bcab594d8", size = 37013193 }, + { url = "https://files.pythonhosted.org/packages/2a/41/ed80e67782d4bc5fc85a966bc356c601afddd175856ba7c7bb6d9490607e/scipy-1.16.2-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7280d926f11ca945c3ef92ba960fa924e1465f8d07ce3a9923080363390624c4", size = 29390172 }, + { url = "https://files.pythonhosted.org/packages/c4/a3/2f673ace4090452696ccded5f5f8efffb353b8f3628f823a110e0170b605/scipy-1.16.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:8afae1756f6a1fe04636407ef7dbece33d826a5d462b74f3d0eb82deabefd831", size = 21381326 }, + { url = "https://files.pythonhosted.org/packages/42/bf/59df61c5d51395066c35836b78136accf506197617c8662e60ea209881e1/scipy-1.16.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:5c66511f29aa8d233388e7416a3f20d5cae7a2744d5cee2ecd38c081f4e861b3", size = 23915036 }, + { url = "https://files.pythonhosted.org/packages/91/c3/edc7b300dc16847ad3672f1a6f3f7c5d13522b21b84b81c265f4f2760d4a/scipy-1.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efe6305aeaa0e96b0ccca5ff647a43737d9a092064a3894e46c414db84bc54ac", size = 33484341 }, + { url = "https://files.pythonhosted.org/packages/26/c7/24d1524e72f06ff141e8d04b833c20db3021020563272ccb1b83860082a9/scipy-1.16.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f3a337d9ae06a1e8d655ee9d8ecb835ea5ddcdcbd8d23012afa055ab014f374", size = 35790840 }, + { url = "https://files.pythonhosted.org/packages/aa/b7/5aaad984eeedd56858dc33d75efa59e8ce798d918e1033ef62d2708f2c3d/scipy-1.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bab3605795d269067d8ce78a910220262711b753de8913d3deeaedb5dded3bb6", size = 36174716 }, + { url = "https://files.pythonhosted.org/packages/fd/c2/e276a237acb09824822b0ada11b028ed4067fdc367a946730979feacb870/scipy-1.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b0348d8ddb55be2a844c518cd8cc8deeeb8aeba707cf834db5758fc89b476a2c", size = 38790088 }, + { url = "https://files.pythonhosted.org/packages/c6/b4/5c18a766e8353015439f3780f5fc473f36f9762edc1a2e45da3ff5a31b21/scipy-1.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:26284797e38b8a75e14ea6631d29bda11e76ceaa6ddb6fdebbfe4c4d90faf2f9", size = 39457455 }, + { url = "https://files.pythonhosted.org/packages/97/30/2f9a5243008f76dfc5dee9a53dfb939d9b31e16ce4bd4f2e628bfc5d89d2/scipy-1.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d2a4472c231328d4de38d5f1f68fdd6d28a615138f842580a8a321b5845cf779", size = 26448374 }, +] + +[[package]] +name = "secretstorage" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221 }, +] + +[[package]] +name = "setuptools" +version = "75.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/53/43d99d7687e8cdef5ab5f9ec5eaf2c0423c2b35133a2b7e7bc276fc32b21/setuptools-75.8.2.tar.gz", hash = "sha256:4880473a969e5f23f2a2be3646b2dfd84af9028716d398e46192f84bc36900d2", size = 1344083 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/38/7d7362e031bd6dc121e5081d8cb6aa6f6fedf2b67bf889962134c6da4705/setuptools-75.8.2-py3-none-any.whl", hash = "sha256:558e47c15f1811c1fa7adbd0096669bf76c1d3f433f58324df69f3f5ecac4e8f", size = 1229385 }, +] + +[[package]] +name = "setuptools-scm" +version = "8.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/bd/c5d16dd95900567e09744af92119da7abc5f447320d53ec1d9415ec30263/setuptools_scm-8.2.0.tar.gz", hash = "sha256:a18396a1bc0219c974d1a74612b11f9dce0d5bd8b1dc55c65f6ac7fd609e8c28", size = 77572 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/7c/5a9799042320242c383c4485a2771a37d49e8ce2312ca647653d2fd1a7a4/setuptools_scm-8.2.0-py3-none-any.whl", hash = "sha256:136e2b1d393d709d2bcf26f275b8dec06c48b811154167b0fd6bb002aad17d6d", size = 43944 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/7b/af302bebf22c749c56c9c3e8ae13190b5b5db37a33d9068652e8f73b7089/snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", size = 86699 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/c02e01294f7265e63a7315fe086dd1df7dacb9f840a804da846b96d01b96/snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a", size = 93002 }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, +] + +[[package]] +name = "soupsieve" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125 }, +] + +[[package]] +name = "sphinx-astropy" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astropy-sphinx-theme" }, + { name = "numpydoc" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pytest-doctestplus" }, + { name = "sphinx" }, + { name = "sphinx-automodapi" }, + { name = "sphinx-gallery" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/ee/957984621943eaa47497be959cc6695ae775745525e5fe9d4ff8174678cc/sphinx-astropy-1.9.1.tar.gz", hash = "sha256:7931c795f445caee38f98754afd75fe7393db7df2c4dcc860f94a011fb162454", size = 19277 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/dd/1487887b8fc3be42fb0cf896f4683ea1d21035485407498e5c1d51325987/sphinx_astropy-1.9.1-py3-none-any.whl", hash = "sha256:0a6be6addb511c3d83647763608ef4fb9254dbc25ae04504837b3b1139c4b130", size = 23765 }, +] + +[[package]] +name = "sphinx-astrorefs" +version = "0.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "latexcodec" }, + { name = "pybtex" }, + { name = "sphinx" }, + { name = "sphinxcontrib-bibtex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f7/a216afc98c2c3f1f078671b002887eab1714be3e7920f8253e772d32bcee/sphinx_astrorefs-0.14.tar.gz", hash = "sha256:6116950fa83dc68da233a9bc1b2973de5f81dc918508986a581eb7c620f54bf6", size = 12771 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/4b/b7c78c79fc074fdedbd2042507897659ca3ac0e6420be8af0c0846c58d9f/sphinx_astrorefs-0.14-py3-none-any.whl", hash = "sha256:661931a7b79e46a1b1c0e43cc8525603d62a2b68a9a0edccbda875a9cac402b9", size = 11250 }, +] + +[[package]] +name = "sphinx-automodapi" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/38/ffc5c1dc26776f1d037021ebcffdfaca74644ec5e5cf5a7735149c51df90/sphinx_automodapi-0.18.0.tar.gz", hash = "sha256:7bf9d9a2cb67a5389c51071cfd86674ca3892ca5d5943f95de4553d6f35dddae", size = 51297 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/96/5492f177d6bb95c5418315ab1c517283f06895b4066f2837ba215be42c84/sphinx_automodapi-0.18.0-py3-none-any.whl", hash = "sha256:022860385590768f52d4f6e19abb83b2574772d2721fb4050ecdb6e593a1a440", size = 88516 }, +] + +[[package]] +name = "sphinx-gallery" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/e5/9ccd6ecd492043123adb465cba504217b9f0a82e2cb5b1d7249c648497c6/sphinx_gallery-0.19.0.tar.gz", hash = "sha256:8400cb5240ad642e28a612fdba0667f725d0505a9be0222d0243de60e8af2eb3", size = 471479 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c7/52b48aec16b26c52aba854d03a3a31e0681150301dac1bea2243645a69e7/sphinx_gallery-0.19.0-py3-none-any.whl", hash = "sha256:4c28751973f81769d5bbbf5e4ebaa0dc49dff8c48eb7f11131eb5f6e4aa25f0e", size = 455923 }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300 }, +] + +[[package]] +name = "sphinxcontrib-bibtex" +version = "2.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pybtex" }, + { name = "pybtex-docutils" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/0d/4f7f131809f70ad3a58ca87057672d63f4b04eeba1803af75c2af3be8bf1/sphinxcontrib-bibtex-2.1.4.tar.gz", hash = "sha256:f53ec0cd534d2c8f0a51b4b3473ced46e9cb0dd99a7c5019249fe0ef9cbef18e", size = 79938 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/13/24b1d6481e94143b5900c82ccac6de398d9f40ad0b42b5afc56ee3527eba/sphinxcontrib_bibtex-2.1.4-py3-none-any.whl", hash = "sha256:f8a0625e1367b8fba243af48990b9a487b12388a470a4fd91daf3e06720aa287", size = 17654 }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530 }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705 }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104 }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071 }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743 }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072 }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, +] + +[[package]] +name = "sympy" +version = "1.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/8a/5a7fd6284fa8caac23a26c9ddf9c30485a48169344b4bd3b0f02fef1890f/sympy-1.13.3.tar.gz", hash = "sha256:b27fd2c6530e0ab39e275fc9b683895367e51d5da91baa8d3d64db2565fec4d9", size = 7533196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/ff/c87e0622b1dadea79d2fb0b25ade9ed98954c9033722eb707053d310d4f3/sympy-1.13.3-py3-none-any.whl", hash = "sha256:54612cf55a62755ee71824ce692986f23c88ffa77207b30c1368eda4a7060f73", size = 6189483 }, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252 }, +] + +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "tornado" +version = "6.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/45/a0daf161f7d6f36c3ea5fc0c2de619746cc3dd4c76402e9db545bd920f63/tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b", size = 501135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/7e/71f604d8cea1b58f82ba3590290b66da1e72d840aeb37e0d5f7291bd30db/tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1", size = 436299 }, + { url = "https://files.pythonhosted.org/packages/96/44/87543a3b99016d0bf54fdaab30d24bf0af2e848f1d13d34a3a5380aabe16/tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803", size = 434253 }, + { url = "https://files.pythonhosted.org/packages/cb/fb/fdf679b4ce51bcb7210801ef4f11fdac96e9885daa402861751353beea6e/tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec", size = 437602 }, + { url = "https://files.pythonhosted.org/packages/4f/3b/e31aeffffc22b475a64dbeb273026a21b5b566f74dee48742817626c47dc/tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946", size = 436972 }, + { url = "https://files.pythonhosted.org/packages/22/55/b78a464de78051a30599ceb6983b01d8f732e6f69bf37b4ed07f642ac0fc/tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf", size = 437173 }, + { url = "https://files.pythonhosted.org/packages/79/5e/be4fb0d1684eb822c9a62fb18a3e44a06188f78aa466b2ad991d2ee31104/tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634", size = 437892 }, + { url = "https://files.pythonhosted.org/packages/f5/33/4f91fdd94ea36e1d796147003b490fe60a0215ac5737b6f9c65e160d4fe0/tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73", size = 437334 }, + { url = "https://files.pythonhosted.org/packages/2b/ae/c1b22d4524b0e10da2f29a176fb2890386f7bd1f63aacf186444873a88a0/tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c", size = 437261 }, + { url = "https://files.pythonhosted.org/packages/b5/25/36dbd49ab6d179bcfc4c6c093a51795a4f3bed380543a8242ac3517a1751/tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482", size = 438463 }, + { url = "https://files.pythonhosted.org/packages/61/cc/58b1adeb1bb46228442081e746fcdbc4540905c87e8add7c277540934edb/tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38", size = 438907 }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, +] + +[[package]] +name = "twobody" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astropy" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/89/3dd2c155a05863f927fe07e3f80639404c828fd7bf0d4fa998a155ff6cad/twobody-0.9.1.tar.gz", hash = "sha256:59ab0a2042c3c21b280306b38296a0de40fe6646acd3f67448c44211342f79ef", size = 114705 } + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "tzdata" +version = "2025.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/0f/fa4723f22942480be4ca9527bbde8d43f6c3f2fe8412f00e7f5f6746bc8b/tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", size = 194950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[[package]] +name = "virtualenv" +version = "20.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 }, +] + +[[package]] +name = "zipp" +version = "3.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 }, +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..7601582273f48d08f197bc8356a065b4c99c277e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastmcp +fastapi +uvicorn[standard] +pydantic>=2.0.0 +numpy>=1.26.4 +scipy>=1.12,<1.17 +astropy>=6.0 +pyyaml +cython>=0.29 diff --git a/run_docker.ps1 b/run_docker.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..0672c4e4284c552070c56a7152602da3ffe13626 --- /dev/null +++ b/run_docker.ps1 @@ -0,0 +1,35 @@ +cd $PSScriptRoot + +$ErrorActionPreference = "Stop" + +$entryName = if ($env:MCP_ENTRY_NAME) { $env:MCP_ENTRY_NAME } else { "gala" } +$entryUrl = if ($env:MCP_ENTRY_URL) { $env:MCP_ENTRY_URL } else { "http://localhost:7860/mcp" } +$imageName = if ($env:MCP_IMAGE_NAME) { $env:MCP_IMAGE_NAME } else { "gala-mcp" } + +$mcpDir = Join-Path $env:USERPROFILE ".cursor" +$mcpPath = Join-Path $mcpDir "mcp.json" +if (!(Test-Path $mcpDir)) { New-Item -ItemType Directory -Path $mcpDir | Out-Null } + +$config = @{} +if (Test-Path $mcpPath) { + try { $config = Get-Content $mcpPath -Raw | ConvertFrom-Json } catch { $config = @{} } +} + +# Rebuild mcpServers as ordered and append the entry last +$serversOrdered = [ordered]@{} +if ($config -and ($config.PSObject.Properties.Name -contains "mcpServers") -and $config.mcpServers) { + $existing = $config.mcpServers + if ($existing -is [pscustomobject]) { + foreach ($p in $existing.PSObject.Properties) { if ($p.Name -ne $entryName) { $serversOrdered[$p.Name] = $p.Value } } + } elseif ($existing -is [System.Collections.IDictionary]) { + foreach ($k in $existing.Keys) { if ($k -ne $entryName) { $serversOrdered[$k] = $existing[$k] } } + } +} +$serversOrdered[$entryName] = @{ url = $entryUrl } +$config = @{ mcpServers = $serversOrdered } + +$config | ConvertTo-Json -Depth 10 | Set-Content -Path $mcpPath -Encoding UTF8 +Write-Host ("Updated $entryName in " + $mcpPath + " -> " + $entryUrl) + +docker build -t $imageName . +docker run --rm -p 7860:7860 $imageName diff --git a/run_docker.sh b/run_docker.sh new file mode 100644 index 0000000000000000000000000000000000000000..b1d348578404c70051755824c730ce396d442a2c --- /dev/null +++ b/run_docker.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Switch to the directory where this script is located +cd "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +mcp_entry_name="${MCP_ENTRY_NAME:-gala}" +mcp_entry_url="${MCP_ENTRY_URL:-http://localhost:7860/mcp}" +mcp_dir="${HOME}/.cursor" +mcp_path="${mcp_dir}/mcp.json" +mkdir -p "${mcp_dir}" + +if command -v python3 >/dev/null 2>&1; then +python3 - "${mcp_path}" "${mcp_entry_name}" "${mcp_entry_url}" <<'PY' +import json, os, sys +path, name, url = sys.argv[1:4] +cfg = {"mcpServers": {}} +if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + except Exception: + cfg = {"mcpServers": {}} +if not isinstance(cfg, dict): + cfg = {"mcpServers": {}} +servers = cfg.get("mcpServers") +if not isinstance(servers, dict): + servers = {} +ordered = {} +for k, v in servers.items(): + if k != name: + ordered[k] = v +ordered[name] = {"url": url} +cfg = {"mcpServers": ordered} +with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) +PY +elif command -v python >/dev/null 2>&1; then +python - "${mcp_path}" "${mcp_entry_name}" "${mcp_entry_url}" <<'PY' +import json, os, sys +path, name, url = sys.argv[1:4] +cfg = {"mcpServers": {}} +if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + except Exception: + cfg = {"mcpServers": {}} +if not isinstance(cfg, dict): + cfg = {"mcpServers": {}} +servers = cfg.get("mcpServers") +if not isinstance(servers, dict): + servers = {} +ordered = {} +for k, v in servers.items(): + if k != name: + ordered[k] = v +ordered[name] = {"url": url} +cfg = {"mcpServers": ordered} +with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) +PY +elif command -v jq >/dev/null 2>&1; then + name="${mcp_entry_name}"; url="${mcp_entry_url}" + if [ -f "${mcp_path}" ]; then + tmp="$(mktemp)" + jq --arg name "$name" --arg url "$url" ' + .mcpServers = (.mcpServers // {}) + | .mcpServers as $s + | ($s | with_entries(select(.key != $name))) as $base + | .mcpServers = ($base + {($name): {"url": $url}}) + ' "${mcp_path}" > "${tmp}" && mv "${tmp}" "${mcp_path}" + else + printf '{ "mcpServers": { "%s": { "url": "%s" } } } +' "$name" "$url" > "${mcp_path}" + fi +else + echo "Warning: neither python nor jq found; skipped updating ~/.cursor/mcp.json" >&2 +fi + +docker build -t gala-mcp . +docker run --rm -p 7860:7860 gala-mcp